DEV Community

Ripan Deuri
Ripan Deuri

Posted on

Linker Scripts Explained: Controlling Memory Layout on Bare Metal


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:

  1. What memory regions are available? For example, FLASH at 0x00000000 (64M), RAM at 0x60000000 (128M), etc.
  2. Where should each section go? .text to flash, .bss to RAM, etc.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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 in SECTIONS
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  • .text : { ... } defines an output section named .text
  • *(.text) means "include all .text input sections from all object files"
  • > FLASH assigns 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
}
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verification: What the Linker Produced

Step 1: Examine Disassembly

arm-none-eabi-objdump -d boot.elf
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key observations:

  • Addresses in disassembly match the VMA from objdump -h
  • .vectors at 0x60000000 contains a branch to _start at 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 deadbeef is stored at 0x6000000c

Step 2: Examine Section Headers

arm-none-eabi-objdump -h boot.elf
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)