Embedded Systems · Embedded Tooling
Reading an ELF Image: Sections, Symbols, and DWARF
How a debugger maps a source line to a flash address, what the symbol table contributes that DWARF does not, and why a stripped image still debugs correctly.
A debugger attached to a target knows two things: a number that identifies an instruction address, and a set of bytes in memory. Everything else — the source line, the variable name, the type of a structure field, the stack frame boundaries — comes from a file on the host. That file is the ELF image with its debug sections, and understanding its layout explains most of the behaviour that otherwise looks like debugger magic or debugger failure.
The three tiers of information
An ELF file for a firmware target carries information at three levels of resolution, and each level can be absent independently.
The section headers describe the memory layout: which byte ranges are code, which are read-only data, which are writable and need initialisation. A loader or a flash programmer reads the program headers, not the section headers, but the section headers are what tools display and what the linker script arranged.
The symbol table maps names to addresses. Each entry has a value (the address), a size, a binding (local, global, weak), a type (function, object, section, file), and an index into the section table. Symbols answer the question “what is at this address” at the granularity of a named entity, which is enough for a backtrace of function names and for a map of memory consumption by object.
DWARF answers questions symbols cannot: which source line corresponds to instruction address 0x08001a3c, where the variable state lives at that point, whether it lives in a register or a stack slot, and what the layout of a struct is. The two are independent. A build compiled without -g has a symbol table and no line information at all; a build passed through objcopy --strip-debug keeps its symbol table and loses the debug sections. The reverse combination — line information without names — is what a build produces when the symbol table is stripped and the debug sections are kept, which is a deliberate arrangement rather than a default of any single flag.
How DWARF stores a compilation unit
DWARF is a tree of debugging information entries, each with a tag, a set of attributes, and optionally children. The root of each tree is a DW_TAG_compile_unit describing one translation unit: its language, its name, its compilation directory, the producer string, and the address ranges it covers.
Attributes are encoded as (name, form, value) triples, and the form determines how the value is stored in the byte stream. A form can be a fixed-width integer, a variable-length unsigned number, a string inline in the stream, or an offset into another section. That indirection is what keeps the format compact: strings live in .debug_str and are referenced by offset, so the same identifier appearing in twenty compilation units is stored once.
Attribute values are not read sequentially. Each compilation unit’s header points to an entry in .debug_abbrev, which declares which attributes a given tag may have and in which form. The reader takes the abbreviation code from the stream, looks up the declaration, and then knows exactly how many bytes to consume for each attribute. A reader that guesses at the layout instead of consulting the abbreviation table desynchronises on the first unit and produces nonsense.
Two features of version 5 change what a reader must do. Addresses may be stored in .debug_addr and referenced by index rather than written inline, and strings may be stored in .debug_line_str and .debug_str_offsets. A consumer written for version 4 that meets a version 5 producer must handle those indirections or refuse the file. Debuggers commonly report a version mismatch rather than decode partially, which is why a toolchain upgrade can make an older debugger unable to open an image that a newer one reads without complaint.
The line number program
The mapping from address to source location is produced by a state machine defined in .debug_line. The section holds, per compilation unit, a header with the file name table and directory table, followed by a program of opcodes.
The state machine’s registers include the current address, file index, line number, column, and a flag for whether the address begins a statement. Opcodes either advance one of these registers by a delta or append the current row to the matrix. Deltas are variable-length encoded, and the address advances in units of the minimum instruction length declared in the header, which is why an architecture with fixed 2-byte instructions can encode address advances in about half the space of a byte-addressed one.
The result is a sorted matrix of (address, file, line, column, is-statement) rows. A debugger searching for the source of an address binary-searches the matrix and takes the last row whose address is not greater than the query. Two consequences follow. First, a step over a multi-line expression can land on any of several lines depending on which instructions the optimiser kept. Second, the matrix is per compilation unit, so an address in a function inlined from another file resolves through the inlined-subroutine entries in .debug_info rather than through the line table alone.
The is_stmt flag is what distinguishes a line that a user would set a breakpoint on from a line that merely contributed instructions. Debuggers place breakpoints at statement rows; optimised code that hoists a load several lines early still marks the hoisted instruction with the flag of the line it came from, or does not, depending on the compiler’s policy. Both behaviours are visible as “the breakpoint landed on the wrong line”, and neither is a bug in the debugger.
Symbols and what survives stripping
The symbol table is a flat array, and the linker produces two of them in a hosted build: .symtab for static symbols and .dynsym for the dynamic linker. Firmware has only .symtab, and stripping removes it.
What is lost when .symtab goes is not only names. The size field of a function symbol is how a size-profiling tool attributes bytes to functions; without it, a tool can only report the total text size. The binding field is how a static function is distinguished from an exported one, which matters when the linker is asked to garbage-collect unreferenced sections and a symbol is the only reference. The STT_SECTION entries are how relocations against a section rather than a symbol are resolved.
What is not lost is DWARF. Debug sections are not referenced by the program at run time and are not loaded onto the target, so a programmer that writes only PT_LOAD segments writes none of them. This is the arrangement that makes field debugging practical: the flash holds the stripped image, and the host holds the unstripped one. The debugger opens the host file for its address tables and reads the target’s memory for contents, and the two agree only because they came from the same link. A rebuild that changes any input changes addresses, and a debugger holding the old file will report source lines for functions that are no longer at those addresses, with no error. That failure mode is the reason a build identifier in the image is worth the bytes it costs.
Practical inspection
The image can be inspected without a target, and doing so is faster than attaching:
$ arm-none-eabi-readelf -S firmware.elf # section headers and addresses
$ arm-none-eabi-readelf -s firmware.elf # symbol table: value, size, type, binding
$ arm-none-eabi-nm --size-sort -S firmware.elf # largest symbols first
$ arm-none-eabi-objdump --dwarf=decodedline firmware.elf
The last command decodes the line program into the matrix described above, which is the direct way to answer “which address does this line start at”. The section listing answers a different question that size tools obscure: whether .data has a load address distinct from its virtual address, and how large the gap is. That distinction, and the copy step it implies, belongs to linker script section placement.
One more property of the format matters for embedded work specifically: DWARF describes locations symbolically, and resolving them requires the register state at the point of interest. A variable may be described as living at a fixed frame offset, at an offset from a register, or in a location list that changes across the function’s address range. Reading a variable in an optimised build therefore requires unwinding the stack to recover the frame base, which is what stack unwinding without frame pointers is about. A debugger that cannot unwind reports a variable as optimised out even when the information is present and correct.
References
- DWARF Debugging Information Format, Version 5 — DWARF Standard Committee
- ELF Specification, Tool Interface Standard — TIS Committee, Executable and Linking Format, 1995
- ELF for the Arm Architecture — ARM ABI, Arm Ltd
- GNU Binutils: readelf — GNU Binutils