Linker Scripts Explained: Controlling Memory Layout on Bare Metal
Source: Dev.to
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 code, _start should match the first instruction executed after the reset vector transfers control.
Note: QEMU does not read an architectural reset vector for vexpress‑a9 when using
-kernel; it sets the CPU’s PC to the ELF entry point address.
MEMORY Block
The MEMORY block declares available address regions and their properties.
| Name | Attributes | ORIGIN | LENGTH |
|---|---|---|---|
| FLASH | rx | 0x00000000 | 64M |
| RAM | rwx | 0x60000000 | 128M |
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 64M
RAM (rwx): ORIGIN = 0x60000000, LENGTH = 128M
}
Attributes are advisory; the linker warns if a write‑able section is placed in a read‑only region.
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
}
.text : { … }defines an output section named.text.*(.text)includes all.textinput sections from all object files.> FLASHassigns the output section to theFLASHmemory region.
Wildcards (*) match all input files; more specific patterns (e.g., startup.o(.text)) are also possible.
The Location Counter: .
The linker maintains an implicit variable called the location counter, written as a dot: .. It tracks the current position within memory and automatically increments as sections are laid out.
SECTIONS
{
. = 0.60000000; /* Set location counter */
.text : {
*(.text)
} > RAM /* .text placed at 0x60000000 */
.rodata : {
*(.rodata*)
} > RAM /* .rodata follows .text */
}
You can also create symbols from the location counter:
_text_start = .; /* Address where .text begins */
.text : { *(.text) } > RAM
_text_end = .; /* Address where .text ends */
These symbols occupy 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.
- LMA (Load Memory Address): where the section is stored initially (typically in non‑volatile flash).
In simple cases VMA and LMA are identical. For sections like .data, the initial contents reside in flash (LMA) but execute from RAM (VMA). Startup code copies the data from flash to RAM before use.
.data : {
*(.data)
} > RAM AT > FLASH /* VMA in RAM, LMA in FLASH */
> RAM specifies the VMA; AT > FLASH specifies the LMA.
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.
SECTIONS
{
. = 0x60000000;
_text_start = .; /* Address where .text begins */
.text : { *(.text) } > RAM
_text_end = .; /* 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 at link time.
The Map File
The linker can generate a map file (-Map=output.map) that lists the layout of sections, symbols, and memory regions. This file is invaluable for verifying that sections are placed where you expect and for spotting size overruns.
Verification: What the Linker Produced
Step 1: Examine Disassembly
arm-none-eabi-objdump -d firmware.elf
Look for the entry point (_start) and verify that code resides at the intended addresses (e.g., 0x60000000).
Step 2: Examine Section Headers
arm-none-eabi-objdump -h firmware.elf
The output shows each section’s VMA, LMA, size, and the memory region it belongs to.
Step 3: Examine the Map File
Open firmware.map and search for symbols such as _text_start, _text_end, and _stack_top. Confirm that their addresses match the layout described in the script.
Verification: Loading in QEMU and Inspecting with GDB
qemu-system-arm -M vexpress-a9 -kernel firmware.elf -nographic -S -s
In another terminal:
arm-none-eabi-gdb firmware.elf
(gdb) target remote :1234
(gdb) info files # Shows loaded sections and entry point
(gdb) break _start
(gdb) continue
Check that the PC starts at the address defined by ENTRY(_start) and that memory regions contain the expected data.
Alignment Directives
Linker scripts support alignment directives to satisfy architectural constraints:
.text ALIGN(4) : { *(.text) } > FLASH
ALIGN(4) forces the start of .text to be on a 4‑byte boundary. You can also align the location counter directly:
. = ALIGN(0x1000); /* Align to the next 4 KB boundary */
Proper alignment prevents faults on CPUs that require word‑aligned accesses.
Conclusion
Linker scripts give you deterministic control over where code and data live in a bare‑metal system. By defining memory regions, using the location counter, specifying VMA/LMA, and creating linker‑defined symbols, you can match the exact memory map required by your hardware. Verifying the output with disassembly, section headers, map files, and runtime inspection (e.g., QEMU + GDB) ensures that the layout is correct before flashing the device.