Embedded Systems · Embedded Tooling

Linker Scripts: Why Sections Have Two Addresses

How a linker script assigns load and virtual addresses, why initialised data needs a copy step at startup, and what happens to sections the script does not mention.

A hosted program has one address for everything: the loader maps the text segment read-only and the data segment read-write at the addresses the program header requests, and no byte moves afterwards. Firmware usually has two: the image is stored in flash at one address and the writable data must live in RAM at another. The linker script is the file that expresses this, and the distinction between the two addresses is the single idea that makes embedded linking different from hosted linking.

Two addresses for one section

Every section in an ELF file carries a virtual address (VMA) and a load address (LMA). The VMA is the address the code assumes at run time, so it is what symbol addresses and relocations refer to. The LMA is the address at which the section’s bytes are placed in the file image, so it is where a programmer or boot ROM must put them. For hosted programs the two are equal and the distinction never surfaces.

For firmware they differ for exactly one category of section: initialised writable data. Consider a global const uint8_t table[] = {1,2,3} and a global uint32_t counter = 5. The table can live in flash and be read in place, so its VMA and LMA are both in the flash region. The counter must be writable, so its VMA is in RAM, but its initial value of 5 has to be stored somewhere non-volatile, so its LMA is in flash immediately after the code. The startup code then copies the bytes from the load address to the virtual address before main runs. The .data section therefore occupies space in the flash image and space in RAM simultaneously, and a build that reports “data size” without saying which it means is reporting half the story.

The zero-initialised case takes the same idea to its limit. A .bss section has no bytes in the file at all: it is marked SHT_NOBITS, occupies no space in the image, and contributes only a size and an address. Startup code writes zeros across the address range. The RAM cost is real, the flash cost is zero, and that asymmetry is why uninitialised buffers are cheaper in flash terms than buffers initialised to zero explicitly.

A script that makes the copy explicit

The MEMORY block declares regions with an origin and a length; the SECTIONS block assigns sections to them and, where necessary, separates the two addresses.

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
  .text :
  {
    KEEP(*(.isr_vector))      /* vector table must be first and retained */
    *(.text*)
    *(.rodata*)
    . = ALIGN(4);
  } > FLASH

  .data :
  {
    . = ALIGN(4);
    _sdata = .;               /* VMA of the first initialised byte */
    *(.data*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH         /* runs in RAM, stored in flash */

  _sidata = LOADADDR(.data);  /* LMA, used as the copy source */

  .bss (NOLOAD) :
  {
    . = ALIGN(4);
    _sbss = .;
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM
}

Three details carry the weight. AT > FLASH sets the load address independently of the region the section runs in. LOADADDR(.data) yields the LMA so the startup routine has a source pointer. NOLOAD marks .bss as occupying no file space, which is what keeps the image smaller than the sum of its RAM usage.

The startup copy is then a loop over those symbols, and it must run before any code that touches an initialised global:

extern uint32_t _sidata[], _sdata[], _edata[], _sbss[], _ebss[];

void copy_init_data(void)
{
    uint32_t *src = _sidata;
    for (uint32_t *dst = _sdata; dst < _edata; )   /* flash -> RAM */
        *dst++ = *src++;
    for (uint32_t *dst = _sbss; dst < _ebss; )     /* zero fill */
        *dst++ = 0;
}

Alignment matters here beyond correctness of the copy. The loop above moves words, which requires _sidata and _sdata to be word-aligned; the ALIGN(4) directives in the script are what guarantee it. A script that omits them and a startup routine that copies words produce a fault on the first unaligned access on cores that reject it, and silently wrong data on cores configured to tolerate it.

Ordering constraints the script must respect

Section order inside a load region is not cosmetic. The vector table has to be at the base of the image because the hardware reads the initial stack pointer and reset vector from fixed offsets within the first entries. KEEP is required around it, and around anything else reachable only through a table, because the linker discards sections no symbol references, and a vector table is referenced by hardware rather than by code.

Constructors and destructors form a second ordering constraint. The .init_array and .fini_array sections hold arrays of function pointers, and the startup code walks them between the copy step and main. If a C++ build relies on static constructors to initialise objects that other initialised data depends on, the ordering inside the startup file is what lets the constructors see copied data; a startup file that calls the constructors before the copy loop hands them whatever RAM happened to contain. The compiler emits the array entries and the script decides only where they live: kept in flash they are callable in place at their load address, while a copy into RAM costs flash space and RAM at once.

Alignment constraints propagate to the image as a whole. A vector table on Cortex-M must be aligned to a power of two at least as large as the table’s size in bytes rounded up to the next power of two, and never less than 128 bytes. Each entry is four bytes, so a table of 16 entries occupies 64 bytes and still requires 128-byte alignment, while a table of 100 entries occupies 400 bytes and requires 512. A region start address that does not satisfy the constraint yields a table the hardware cannot relocate correctly, and the symptom is a fault taken on the first interrupt rather than at boot.

Orphan sections and the quiet failures

Any input section the script does not name becomes an orphan, and the linker places it by heuristic, usually after a section of similar attributes in the same region. That default is frequently wrong in ways nothing reports. A .rodata fragment containing a lookup table placed after a section in RAM costs flash and RAM at once. A section with executable attributes placed in a non-executable region produces a fault at the first call into it. Vendor startup files and compiler-generated sections (.init, .fini, .preinit_array, and architecture-specific unwind tables) are the usual orphans, because they appear in objects the script author never inspected.

--orphan-handling=warn reports every orphan section as the linker places it, and --orphan-handling=error refuses to produce an output file at all. place, the silent heuristic, is what runs when the option is absent; warn or error is the only practical way to find out what a script has missed. Without it, the first indication of a problem is usually a region overflow reported as a single number of bytes with no statement of which section caused it.

Overflow diagnostics themselves are worth configuring. --print-memory-usage makes the linker report each region’s size and capacity at the end of the link, so a build that has grown past a limit fails at the point of the change rather than when the image is programmed. Without that check, the failure appears as an image that fits the file but not the device, and the error comes from the programming tool rather than from the compiler.

The KEEP directive has a second use beyond the vector table. Sections that are reachable only through a linker-defined table — a command dispatch array, a list of registered drivers, a section of initialisation records — are invisible to the garbage collector, because no relocation in the object refers to them. Wrapping the input pattern in KEEP and providing __start_ and __stop_ symbols for the section name gives the startup code a base and an end, and it is the standard way to build a table whose contents are contributed by objects that do not know about each other.

The link map (-Wl,-Map=image.map) is the complementary check, listing every input section with its output section and address. Reading the top of the map answers the question the size tool answers ambiguously: how many bytes of flash the image occupies, and how many of those are data destined for RAM.

One more property of the map is worth the habit of reading it. Symbol addresses in the map are final, after relaxation and garbage collection, and they are what a debugger will use. A map that places a hot function at an address differing from the one in the symbol file is the first sign that the image on the device and the image on the host have diverged.

Where the script stops being enough

The script describes a single load region and a single execution region per section. It cannot express a section that must be decompressed at boot, a region that is only valid after a power-management transition, or code that is copied from flash to faster RAM and executed there. Those cases need additional copy steps and additional symbols, and they are usually handled by running the linker twice or by post-processing the image. Overlay schemes and position-independent images push the same problem further, which is where the image-format question in Intel HEX and SREC image formats becomes relevant: the file handed to a programmer carries load addresses, and those addresses come from this script.

A second constraint is that the script decides what the debugger sees. A section placed at an address the symbol file does not expect produces a session in which breakpoints land in the wrong function, which is the failure described in reading ELF sections and debug information.

References

  1. GNU Linker (ld) Documentation: Scripts — GNU Binutils
  2. ELF for the Arm Architecture — ARM ABI, Arm Ltd
  3. ELF Specification, Tool Interface Standard — TIS Committee, Executable and Linking Format, 1995