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.bssactually hold, and why.bsstakes no space in the file at all - Why a
.ofile 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:
| Section | Holds | Takes file space? |
|---|---|---|
.text | Compiled machine instructions | Yes |
.rodata | Read-only constants (string literals, const data) | Yes |
.data | Initialized global and static variables | Yes |
.bss | Uninitialized (or zero-initialized) global and static variables | No |
.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_LOADsegments actually get loaded into memory .text,.rodata, and.datacarry real bytes in the file;.bssis typeNOBITSand carries only a size, depending on the loader or startup code to zero it- A
.ofile is an ELF file too - typeET_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