Every address that's shown up in this chapter so far - 0x80000000 for .text, 0x80001000 for .data, and the rest - looks like it came from nowhere. It didn't. A linker script decided every one of those numbers, and it's the same -T soc_test.ld flag mentioned earlier in this chapter that tells the linker which script to use.

8 min read | Intermediate Level

What You'll Learn

  • How the MEMORY command describes the DUT's address map to the linker
  • How the SECTIONS command decides exactly where each ELF section lands
  • Where the entry point and startup-code symbols like _bss_start actually come from
  • Why a linker script that disagrees with the DUT's real memory map is a verification bug, not a build error

The MEMORY Command: Describing the DUT's Address Map

A linker script's first job is to tell the linker what memory actually exists to place things into. The MEMORY command does exactly that - it's a list of named regions, each with a starting address and a size:


MEMORY
{
  RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 64K
}

ORIGIN is the region's base address, LENGTH is how big it is, and the letters in parentheses - r, w, x - describe what the region allows: readable, writable, executable. A real product often splits this into multiple regions (flash for code, RAM for data), but a C test running directly in an SoC testbench is frequently simple enough to fit in one region, which is the model this article uses.

This MEMORY block is a description, not a discovery - the linker takes it on faith. Nothing checks it against the DUT's actual RTL address decode, which is exactly the risk this article comes back to below.

The SECTIONS Command: Deciding Where Each Section Lands

Once the linker knows what memory exists, the SECTIONS command tells it what to put where. Each entry names an output section, lists which input sections feed into it, and points at a MEMORY region with >:


ENTRY(_start)

SECTIONS
{
  .text   : { *(.text*)   } > RAM
  .rodata : { *(.rodata*) } > RAM
  .data   : { *(.data*)   } > RAM
  .bss    : {
    _bss_start = .;
    *(.bss*)
    *(COMMON)
    _bss_end = .;
  } > RAM
}

*(.text*) means "take the .text section from every input object file and place it here." The linker walks the SECTIONS block top to bottom, packing each output section immediately after the one before it inside its memory region. That ordering is the entire reason .text lands at 0x80000000, .rodata at 0x80000ee0 right after it, and .data at 0x80001000 - each one starts exactly where the previous one's bytes ran out.

A real product usually needs two addresses per section instead of one: a VMA (where it runs from) and an LMA (where it's stored), set with AT>. .data is the classic case - it's stored in flash but must run from RAM, so startup code copies it before main(). A C test loaded straight into simulation memory usually doesn't need this split; VMA and LMA are simply the same address, which is why the addresses in this article's examples only ever need one number.

Entry Point and the Symbols Startup Code Needs

ENTRY(_start) sets the ELF header's entry point field to wherever the symbol _start ends up - the same Entry point address field a disassembler or readelf -h reports. Since _start is the very first thing placed in .text, the entry point and the start of .text end up being the same address, 0x80000000 in this example.

The _bss_start and _bss_end lines inside the .bss block aren't sections at all - they're symbols, defined by assigning the current location counter (.) to a name. They don't consume any memory themselves; they just record an address for something else to read. Startup code (the small routine that runs before main()) uses exactly these two symbols to know how much memory to zero:


  extern char _bss_start, _bss_end;
  char *p = &_bss_start;
  while (p < &_bss_end) *p++ = 0;

This is the other half of the .bss story: the section itself carries no bytes in the ELF file, and this loop - driven entirely by symbols the linker script defines - is what actually zeroes the memory at load time.

Why This Is a Verification Concern, Not Just a Build Detail

A linker script's MEMORY block is only ever as correct as whoever wrote it. Nothing forces its ORIGIN and LENGTH values to match the address range the DUT's RTL actually decodes for that memory - they're two separate artifacts, maintained separately, and the linker has no way to check one against the other.

A linker script that places code at an address the DUT's interconnect doesn't decode to real memory will link cleanly and fail only in simulation - often as a bus error or a fetch that reads back garbage, far from where the actual mismatch lives.

The same is true in the other direction: if the RTL's memory region shrinks in some SoC configuration but the linker script's LENGTH doesn't shrink with it, a large C test can link successfully while quietly overlapping the next region in address space - and any corruption it causes shows up somewhere else entirely, far from the actual overflow.

Common Mistakes

Mistake: Letting the linker script's MEMORY block drift from the DUT's real address map.

The linker script and the RTL address decode are edited by different people at different times. Whenever the DUT's memory map changes size or base address, the linker script needs the matching update, or every C test built against it silently targets the wrong region.

Mistake: Renaming a startup-code symbol without updating the linker script, or vice versa.

If the startup routine expects _bss_start but the linker script defines __bss_start__, the build can still succeed - an unresolved weak symbol or a zero-initialized fallback can mask the mismatch - while .bss quietly never gets zeroed.

Mistake: Assuming VMA and LMA are always the same address.

That's true for a C test loaded directly into simulation memory, but not for firmware that copies .data from flash to RAM at startup. Carrying that assumption into a product-representative boot flow produces a binary that runs from the wrong copy of its own initialized data.

Summary

In this article, you learned:

  • The MEMORY command tells the linker what address ranges exist to place sections into, but it's a description the linker trusts - nothing validates it against the DUT's real RTL address decode
  • The SECTIONS command packs output sections into memory in the order they're listed, which is why sections land at consecutive addresses immediately after one another
  • ENTRY() sets the ELF header's entry point, and linker-script symbols like _bss_start/_bss_end give startup code the addresses it needs to zero .bss itself
  • A linker script that disagrees with the DUT's actual memory configuration links without error and fails only in simulation, often far from where the real mismatch lives
Did you find what you were looking for?

Quiz

No quiz questions available for this article.