- The Anatomy of a Minimal Linker Script
- The Location Counter:
. - VMA vs LMA: Virtual and Load Memory Addresses
- Linker-Defined Symbols
- The Map File
- Complete Minimal Example
- Verification: What the Linker Produced
- Verification: Loading in QEMU and Inspecting with GDB
- Alignment Directives
- Conclusion
Compilers generate relocatable object code—machine instructions and data whose addresses are not yet fixed. On hosted systems, a loader chooses the runtime addresses of each segment. Bare-metal systems have no loader: the ELF file’s section addresses become the addresses used directly by the CPU.
A linker script provides this mapping. It determines:
- What memory regions are available? For example, FLASH at 0x00000000 (64M), RAM at 0x60000000 (128M), etc.
-
Where should each section go?
.textto flash,.bssto RAM, etc. - Exactly where each section begins, with alignment enforced according to architecture requirements.
Without an explicit script, the linker defaults to placing sections at low addresses such as 0x00000000, which rarely matches real hardware memory layouts. Bare-metal firmware requires deterministic placement, so linker scripts are a fundamental tool.
In this post, the linker script targets QEMU’s vexpress-a9, where RAM begins at 0x60000000. QEMU’s -kernel argument loads ELF segments to their VMA addresses, so linking into RAM is sufficient for initial bring-up.
The Anatomy of a Minimal Linker Script
A linker script has two primary blocks: MEMORY and SECTIONS.
ENTRY(_start)
MEMORY
{
RAM (rwx) : ORIGIN = 0x60000000, LENGTH = 128M
}
SECTIONS
{
. = 0x60000000;
.text : {
*(.text)
} > RAM
}
Each component has a specific purpose.
ENTRY Directive
ENTRY(_start) specifies the program's entry point symbol. When the ELF file is loaded, this symbol's address becomes the starting point that debuggers and loaders recognize. In bare metal, _start should match the first instruction executed after the reset vector transfers control.
Note that QEMU does not read an architectural reset vector for vexpress-a9 when using -kernel. Instead, it sets the CPU’s PC to the ELF entry point address.
MEMORY Block
The MEMORY block declares available address regions and their properties. Each region has:
-
Name:
RAM,FLASH- labels used inSECTIONS -
Attributes:
r(read),w(write),x(execute) - ORIGIN: the starting address of the region
- LENGTH: the size in bytes
Example:
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 64M
RAM (rwx) : ORIGIN = 0x60000000, LENGTH = 128M
}
Attributes are advisory. They let the linker validate that sections are placed in regions matching their needs:
-
FLASH (rx): read and execute only; marking it writable would be nonsensical -
RAM (rwx): fully flexible for code, initialized data, uninitialized data
The linker issues warnings if a section with write permission is assigned to a read-only region, helping catch configuration mistakes.
SECTIONS Block
The SECTIONS block specifies the output memory layout. Each entry maps input sections (from object files) to output sections (in the final binary) and assigns a memory location.
SECTIONS
{
.text : {
*(.text)
} > FLASH
.rodata : {
*(.rodata*)
} > FLASH
}
Breaking this down:
-
.text : { ... }defines an output section named.text -
*(.text)means "include all.textinput sections from all object files" -
> FLASHassigns this output section to the FLASH memory region
The * is a wildcard matching all input files. More specific patterns are possible (e.g., startup.o(.text) to include only startup's .text section), but wildcards are typical for bare-metal work.
The Location Counter: .
The linker maintains an implicit variable called the location counter, written as a dot: .. The location counter tracks the current position within memory and automatically increments as sections are laid out.
SECTIONS
{
. = 0x60000000; /* Set location counter to 0x60000000 */
.text : {
*(.text) /* Place .text at current location */
} > RAM
/* After .text, . is automatically incremented by .text's size */
.rodata : {
*(.rodata*) /* Place .rodata immediately after .text */
} > RAM
}
Explicit assignment of . ensures predictable placement. Without . = 0x60000000;, the linker might place .text at 0x0 by default, ignoring the intended RAM region.
The location counter can also be used to create symbols:
_text_start = .; /* Symbol marks current position */
.text : { *(.text) } > RAM
_text_end = .; /* Symbol marks end position */
These symbols have no storage; they are merely addresses that can be referenced from assembly or C.
VMA vs LMA: Virtual and Load Memory Addresses
Every output section has two associated addresses:
- VMA (Virtual Memory Address): where the section resides during execution (runtime)
- LMA (Load Memory Address): where the section is stored initially (typically in non-volatile flash)
In simple cases—executable code stored and executed from the same location—VMA and LMA are identical. Both the > RAM clause and implicit location counter assignment govern VMA.
The .data section stores its initial contents in flash (LMA) but executes from RAM (VMA). Startup code copies the data from flash to RAM before execution.
Linker script syntax to specify both addresses:
.data : {
*(.data)
} > RAM AT > FLASH /* VMA in RAM, LMA in FLASH */
The > RAM specifies VMA. The AT > FLASH specifies LMA. The linker stores the section in FLASH (LMA) but generates symbols and relocation information assuming runtime execution from RAM (VMA).
Linker-Defined Symbols
The linker can create symbols by assigning the location counter or other expressions to a name. These symbols exist only as addresses; they occupy no storage.
SECTIONS
{
. = 0x60000000;
_text_start = .; /* Symbol: address where .text begins */
.text : {
*(.text)
} > RAM
_text_end = .; /* Symbol: address where .text ends */
_stack_top = ORIGIN(RAM) + LENGTH(RAM); /* Custom symbol */
}
In assembly, these symbols are referenced using the = pseudo-op:
ldr r0, =_text_start @ Load symbol address into r0
ldr sp, =_stack_top @ Load stack top address into sp
The = pseudo-op generates an LDR instruction with a literal pool reference. The linker resolves the symbol address and embeds it in the instruction encoding. When the CPU executes the LDR, the symbol's address is loaded into the register.
The Map File
The linker can generate a map file showing all sections, symbols, and their addresses. Generate it by adding -Map=output.map to the linker command:
arm-none-eabi-ld -T linker.ld startup.o -o boot.elf -Map=output.map
Complete Minimal Example
startup.s:
.global _start
.section .vectors, "ax"
_vectors:
b _start @ branch to start_
.section .text
_start:
ldr r0, =0xDEADBEEF @ Load test value
b . @ Infinite loop
Note that QEMU does not use the vectors section above as a real reset vector when booting with -kernel.
linker.ld:
ENTRY(_start)
MEMORY
{
RAM (rwx) : ORIGIN = 0x60000000, LENGTH = 128M
}
SECTIONS
{
. = 0x60000000;
.vectors : {
*(.vectors)
} > RAM
.text : {
*(.text)
} > RAM
}
Build Commands
# Assemble the startup code
arm-none-eabi-as -mcpu=cortex-a9 -g startup.s -o startup.o
# Link with the linker script
arm-none-eabi-ld -T linker.ld startup.o -o boot.elf
# Generate a map file
arm-none-eabi-ld -T linker.ld startup.o -o boot.elf -Map=output.map
# Create raw binary (optional, for certain QEMU loading modes)
arm-none-eabi-objcopy -O binary boot.elf boot.bin
Verification: What the Linker Produced
Step 1: Examine Disassembly
arm-none-eabi-objdump -d boot.elf
Excerpt:
Disassembly of section .vectors:
60000000 <_vectors>:
60000000: eaffffff b 60000004 <_start>
Disassembly of section .text:
60000004 <_start>:
60000004: e51f0000 ldr r0, [pc, #-0] @ 6000000c <_start+0x8>
60000008: eafffffe b 60000008 <_start+0x4>
6000000c: deadbeef .word 0xdeadbeef
Key observations:
- Addresses in disassembly match the VMA from objdump -h
-
.vectorsat 0x60000000 contains a branch to_startat 0x60000004 -
ldr r0, =0xDEADBEEF->ldr r0, [pc, #-0]is located at 0x60000004 and it depends on the literal stored at 0x6000000c. - The infinite loop
b .branches to itself at 0x60000008 - Literal
deadbeefis stored at 0x6000000c
Step 2: Examine Section Headers
arm-none-eabi-objdump -h boot.elf
Excerpt:
Sections:
Idx Name Size VMA LMA File off Algn
0 .vectors 00000004 60000000 60000000 00001000 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .text 0000000c 60000004 60000004 00001004 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
Key observations:
- VMA shows where code resides at runtime: 0x60000000 for
.vectors, 0x60000004 for.text - LMA matches VMA in this simple case (both in RAM)
- Section sizes: 4 bytes for the branch instruction in
.vectors, 12 bytes for the load and branch in.text
Step 3: Examine the Map File
cat output.map | head -30
Excerpt:
Memory Configuration
Name Origin Length Attributes
RAM 0x60000000 0x08000000 xrw
*default* 0x00000000 0xffffffff
Linker script and memory map
0x60000000 . = 0x60000000
.vectors 0x60000000 0x4
*(.vectors)
.vectors 0x60000000 0x4 startup.o
.text 0x60000004 0xc
*(.text)
.text 0x60000004 0xc startup.o
0x60000004 _start
Verification: Loading in QEMU and Inspecting with GDB
Launch QEMU with GDB Server:
qemu-system-arm -M vexpress-a9 -cpu cortex-a9 -m 128M \
-kernel boot.elf \
-nographic \
-S -gdb tcp::1234
Flags:
-
-kernel boot.elf: load ELF file (QEMU parses program headers and loads sections to their VMA) -
-S: start halted, waiting for debugger connection -
-gdb tcp::1234: open GDB server on port 1234
Connect GDB and Inspect:
gdb-multiarch boot.elf
(gdb) target remote :1234
Remote debugging using :1234
_start () at startup.s:9
9 ldr r0, =0xDEADBEEF @ Load test value
(gdb) info registers pc
pc 0x60000004 0x60000004 <_start>
The program counter starts at 0x60000004, points to the next instruction.
Disassemble in GDB:
(gdb) disassemble _start
Dump of assembler code for function _start:
=> 0x60000004 <+0>: ldr r0, [pc, #-0] @ 0x6000000c <_start+8>
0x60000008 <+4>: b 0x60000008 <_start+4>
0x6000000c <+8>: cdple 14, 10, cr11, cr13, cr15, {7}
End of assembler dump.
Instructions are at exact addresses from objdump, confirming the linker placed code at the intended locations.
The literal 0xDEADBEEF appears as a coprocessor instruction in disassembly because objdump interprets raw data as instructions when listing code; this is expected.
Step Through Instructions:
(gdb) info registers r0
r0 0x0 0
(gdb) break _start
Breakpoint 1 at 0x60000004: file startup.s, line 9.
(gdb) stepi
10 b . @ Infinite loop
(gdb) info registers r0
r0 0xdeadbeef -559038737
After the instruction 0x60000004, r0 contains the test value, proving the instruction executed and the linker's address assignment was correct.
Inspect Memory:
(gdb) x/4i 0x60000000
0x60000000 <_vectors>: b 0x60000004 <_start>
0x60000004 <_start>: ldr r0, [pc, #-0] @ 0x6000000c <_start+8>
=> 0x60000008 <_start+4>: b 0x60000008 <_start+4>
0x6000000c <_start+8>: cdple 14, 10, cr11, cr13, cr15, {7}
Memory contains the expected branch and ldr instructions at exact addresses, confirming the linker-assigned layout matches actual memory.
Alignment Directives
ARMv7 uses 4-byte instructions and requires 4-byte alignment. Explicit alignment ensures that sections begin at valid boundaries.
SECTIONS
{
. = 0x60000000;
. = ALIGN(4); /* Ensure 4-byte alignment */
.vectors : {
*(.vectors)
} > RAM
. = ALIGN(4); /* Ensure next section is aligned */
.text : {
*(.text)
} > RAM
}
The ALIGN(n) function rounds the location counter up to the next multiple of n bytes. If already aligned, it is a no-op. If misaligned, it advances the counter and introduces padding.
Conclusion
A linker script describes the mapping from ELF sections to concrete memory addresses and serves as the bridge between compiler output and hardware layout. By defining memory regions, assigning sections, managing alignment, and generating linker-defined symbols, the script establishes the program’s static memory structure. Verifying these decisions with map files and objdump ensures the final image matches the intended layout.
With this foundation established, the next step is to examine how execution begins—specifically, the reset mechanism and the first instruction fetched by the CPU. Understanding reset behavior complements the static layout described here and completes the initial stage of bare-metal bring-up.
Top comments (0)