Running riscv32-unknown-elf-gcc test.o -T soc_test.ld -o test.elf produces "one complete binary," but that description hides almost everything worth knowing about what the file actually contains. That output is an ELF file - Executable and Linkable Format - and its internal structure is exactly what a simulator's loader reads to get code and data into the DUT's memory, and what your testbench can read to find the address of a specific C variable without guessing.

12 min read | Intermediate Level

What You'll Learn

  • The three pieces every ELF file is built from: a header, section data, and a symbol table
  • Why an ELF file offers two different views of itself - sections and segments - and who uses which
  • What .text, .rodata, .data, and .bss actually hold, and why .bss takes no space in the file at all
  • Why a .o file is an unfinished ELF file, and how relocation entries record what's still left to fill in
  • How the symbol table lets a testbench find a C variable's address without hardcoding it

One File, Read Two Different Ways

Every ELF file starts with a small, fixed-size ELF header. It opens with a 4-byte magic number - 0x7F followed by the ASCII characters "ELF" - which is how any tool that opens the file, from a debugger to readelf, immediately recognizes what it's looking at.

Past the magic number, the header records a handful of facts about the rest of the file: whether it's 32-bit or 64-bit, little-endian or big-endian, which target architecture it's for, and - critically - the entry point, the address execution should start at.


$ riscv32-unknown-elf-readelf -h test.elf

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 ...
  Class:                             ELF32
  Data:                              2's complement, little endian
  Machine:                           RISC-V
  Entry point address:               0x80000000

The header doesn't hold the code or data itself - it mostly holds two table locations: where the section header table is, and where the program header table is. Those two tables are the two different views of the same underlying bytes mentioned above, and they exist for two different audiences.

The section header table is the linker's and debugger's view: it lists named sections like .text and .data, each with a name, a size, and a type. This is the view tools use to resolve symbols, attach debug information, and let you inspect the file by section name.

The program header table is the loader's view: it groups those same bytes into a smaller number of segments, each one a contiguous block that gets mapped into memory with a specific permission set (readable, writable, executable). Only segments marked PT_LOAD are actually loaded at all - a segment is the granularity a simulator's ELF loader, or an on-target bootloader, actually cares about.

Sections are a logical grouping used at compile and link time; segments are a runtime grouping used at load time. A section header table entry with no matching segment - debug information, for instance - simply never gets loaded into memory, which is exactly why it can be stripped out of a production image without changing behavior.

The Sections That Actually Hold Your Program

Of the section names an ELF file can contain, four show up in essentially every C test:

SectionHoldsTakes file space?
.textCompiled machine instructionsYes
.rodataRead-only constants (string literals, const data)Yes
.dataInitialized global and static variablesYes
.bssUninitialized (or zero-initialized) global and static variablesNo

.text, .rodata, and .data all need real bytes in the file, because the loader has to copy actual content from the file into memory. .bss is different: a C global declared without an initializer contributes nothing to the file at all - only its size is recorded. The loader (or, on bare metal, the startup code that runs before main()) is responsible for zeroing that many bytes of memory at the address .bss is assigned.

This is why a "successful load" and a "correctly initialized memory" are not automatically the same thing. If the startup code your test links against skips zeroing .bss - or you load an ELF's segments into simulation without running that startup code first - every uninitialized global starts out holding whatever was already sitting in that memory, not zero.

readelf -S confirms this directly, through the section's Type and Flags fields rather than just its size:


$ riscv32-unknown-elf-readelf -S test.elf

  [Nr] Name      Type      Addr      Size      Flags
  [ 1] .text     PROGBITS  80000000  00000ee0  AX
  [ 2] .rodata   PROGBITS  80000ee0  00000058  A
  [ 3] .data     PROGBITS  80001000  00000088  WA
  [ 4] .bss      NOBITS    80002000  00000800  WA

The Flags column previews what a segment built from that section will need at load time: A means it occupies memory at runtime, X means executable, W means writable. .text is AX - allocated and executable, never writable - while .bss is WA, but its Type is NOBITS: readelf's own way of saying "this section has a size and an address, but no actual bytes stored in the file."

The size command turns the same distinction into three plain numbers, which is often faster to check than reading section headers by hand:


$ riscv32-unknown-elf-size test.elf

   text    data     bss     dec     hex filename
   3808     136    2048    5992    1768 test.elf

Only text and data - 3944 bytes together - contribute to how big test.elf is on disk. The 2048 bytes of bss are memory the DUT needs at runtime, not file size the loader has to copy.

Object Files Are ELF Too - Just Not Finished

An assembler's .o output is already an ELF file - just a different type of one. The ELF header's Type field records which: an object file is ET_REL ("relocatable"), while the final binary the linker produces is ET_EXEC ("executable").


$ riscv32-unknown-elf-readelf -h test.o | grep Type
  Type:                              REL (Relocatable file)

$ riscv32-unknown-elf-readelf -h test.elf | grep Type
  Type:                              EXEC (Executable file)

That one field explains most of what's different about a .o file. It has section headers - .text, .data, and the rest are all present - but no program header table at all, because nothing ever loads a .o file directly; it isn't a runnable image yet. It also doesn't have final addresses. Anything it refers to outside its own section - a function defined elsewhere, a global variable's address - is still a placeholder.

Relocations: How the Linker Fills In the Placeholders

Every one of those placeholders has an entry describing it, stored in a dedicated relocation section (commonly named .rela.text, sitting next to the .text it applies to). Each entry records three things: where the placeholder sits, which symbol it should ultimately resolve to, and a relocation type telling the linker exactly how to combine that symbol's final address with what's already there.


$ riscv32-unknown-elf-readelf -r test.o

Relocation section '.rela.text':
  Offset     Type              Symbol's Name
  00000010   R_RISCV_CALL_PLT  dma_start
  00000024   R_RISCV_HI20      test_status
  00000028   R_RISCV_LO12_I    test_status

The relocation type matters because "fill in the address" isn't one operation. R_RISCV_CALL_PLT patches an entire function-call sequence. R_RISCV_HI20 and R_RISCV_LO12_I split a 32-bit address across two separate instructions, because a single RISC-V instruction can't hold a full address as an immediate value - the high bits go in one instruction, the low bits in the next. Every target architecture defines its own set of relocation types for exactly this reason: each instruction set has different rules for how much of an address a single instruction can encode.

You can see the placeholder disappear by disassembling the same instruction on both sides of linking:


# In test.o (before linking) - the call target is unresolved
   10: jal ra, 0 

# In test.elf (after linking) - same instruction, address filled in
80000010: jal ra, 80003400 

The disassembly above is simplified for readability - a real objdump -d dump also prints the raw instruction encoding alongside the mnemonic, and typically shows a relocation comment on the unlinked side instead of a bare 0.

The Symbol Table: Names Your Testbench Can Use Too

Alongside the sections, the linker builds a symbol table (.symtab): an entry per function and per global variable, recording its name, its final address, and which section it lives in. This is what lets a debugger show initialize_dma() instead of 0x80001a4c, and it's the same mechanism a linker uses to resolve a call in one object file to a function's address in another.


$ riscv32-unknown-elf-nm test.elf | grep test_status

80002000 D test_status

That address is genuinely useful outside the C test itself. If a C test signals pass/fail through a global like test_status, the symbol table is how you find that variable's address without hardcoding it into the SV environment - a virtual sequence or a scoreboard can look the symbol up once (via a map file, or a tool that reads the ELF directly) and use the resulting address for a backdoor peek, the same handshake pattern The Role of Software in SoC Verification covered from the software side.

A backdoor peek at a symbol's address only works if the symbol is still there. Stripping an ELF file with strip removes .symtab entirely to save space - useful for a final production image, but it breaks exactly this kind of testbench lookup if it's done to the binary the bench is actually loading.

Common Mistakes

Mistake: Assuming a stripped ELF works the same as an unstripped one in the bench.

A stripped binary runs identically on the CPU - the symbol table has no effect on execution - but any environment component that resolves a variable's address by name will fail to find it, often well after the strip step was added for an unrelated reason.

Mistake: Treating an uninitialized global as "don't care" without checking .bss handling.

A global that's genuinely supposed to start at zero relies on either the loader or the startup code zeroing .bss. If simulation loads only PT_LOAD segments and skips running startup code first, that zeroing never happens, and the test starts with stale memory content instead.

Mistake: Confusing a section's file offset with its runtime address.

Where a section's bytes sit inside the ELF file and where those bytes end up in the DUT's memory map are two different numbers - readelf shows both. Using the wrong one when computing an address by hand is a common source of "it worked in nm but not in the bench" confusion.

Mistake: Assuming any two object files with a matching target triplet always link cleanly.

Relocation processing depends on more than just the architecture name in the triplet - things like the code model in use can change how a relocation type gets resolved. Mixing object files built under different assumptions can produce a relocation error at link time, or, on some targets, an address that silently overflows what the instruction can encode instead of being reported as an error.

Summary

In this article, you learned:

  • An ELF file starts with a fixed header that identifies the file and points to two separate tables describing the rest of it
  • Sections (the linker's, debugger's view) and segments (the loader's view) are two different groupings of the same bytes, and only PT_LOAD segments actually get loaded into memory
  • .text, .rodata, and .data carry real bytes in the file; .bss is type NOBITS and carries only a size, depending on the loader or startup code to zero it
  • A .o file is an ELF file too - type ET_REL, with sections but no program headers - and its unresolved references are recorded as relocation entries for the linker to fill in
  • The symbol table maps names to final addresses, which a testbench can use for backdoor access to a C test's variables - as long as the ELF hasn't been stripped
Did you find what you were looking for?

Quiz

No quiz questions available for this article.