Quick Cap
When firmware mysteriously grows, hard-faults at an unknown address, or just won't link, the answer is usually inside the binary itself. The ELF (Executable and Linkable Format) file produced by the linker contains every section, every symbol, and (if compiled with -g) full debug info — and binutils provides a suite of tools to inspect it. The linker map file (-Wl,-Map=foo.map) shows exactly what the linker placed where, sized to the byte. Knowing which tool answers which question — nm for symbols, size for section totals, objdump for disassembly, readelf for structure, addr2line for "what code is at address X" — is a top-tier embedded debugging skill.
Key Facts:
- ELF structure: file header → program headers → section headers → sections (
.text,.data,.bss,.rodata,.debug_*) size foo.elf: section totals — quickest sanity checknm foo.elf/nm -S --size-sort: list symbols, sorted by size to find the bloatreadelf -a foo.elf: full structural dump — sections, symbols, relocations, ARM ABI tagsobjdump -d foo.elf(or-Sfor source-mixed): disassemblyaddr2line -e foo.elf 0x080012ac: "what source line is this address?".mapfile: per-symbol Flash placement; the canonical bloat-investigation source
Deep Dive
At a Glance
| Question | Tool | Typical command |
|---|---|---|
| How big is each section? | size | arm-none-eabi-size firmware.elf |
| What symbols are in the binary, sorted by size? | nm | arm-none-eabi-nm -S --size-sort -r firmware.elf |
Disassemble code (with source if -g)? | objdump | arm-none-eabi-objdump -d -S firmware.elf |
| Show full ELF structure (sections, symbols, ABI tags)? | readelf | arm-none-eabi-readelf -a firmware.elf |
| What source line is at address X? | addr2line | arm-none-eabi-addr2line -e firmware.elf 0x080012ac |
| Strip debug info from a binary | strip | arm-none-eabi-strip --strip-all firmware.elf |
| Where did the linker place each symbol? | Read firmware.map | Generated by -Wl,-Map=firmware.map |
| Get raw flash image | objcopy -O binary | arm-none-eabi-objcopy -O binary firmware.elf firmware.bin |
| Get Intel HEX | objcopy -O ihex | arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex |
What's Inside an ELF File
ELF is a structured container with a fixed-format header followed by tables and sections:
┌──────────────────────────────┐ │ ELF Header │ Magic, ISA, entry point, table offsets ├──────────────────────────────┤ │ Program Headers │ Loadable segments — used by loaders ├──────────────────────────────┤ │ Section Headers │ Per-section: name, address, size, type ├──────────────────────────────┤ │ Section: .text │ Code (executable, read-only) ├──────────────────────────────┤ │ Section: .rodata │ Const data ├──────────────────────────────┤ │ Section: .data │ Initialized RW (LMA in Flash) ├──────────────────────────────┤ │ Section: .bss │ Zero-init RW (no Flash bytes; just metadata) ├──────────────────────────────┤ │ Section: .symtab │ Symbol table (function/global names + addresses) ├──────────────────────────────┤ │ Section: .strtab │ Strings referenced by .symtab ├──────────────────────────────┤ │ Section: .debug_info │ DWARF debug data (only if -g) │ Section: .debug_line │ Source line ↔ instruction mapping │ Section: .debug_* │ More DWARF subsections └──────────────────────────────┘
The ELF format has two views: execution view (program headers, "what to load") and linking view (section headers, "what was here"). For embedded inspection, you primarily care about the linking view.
size — The 5-Second Sanity Check
Run this on every build:
$ arm-none-eabi-size firmware.elftext data bss dec hex filename41268 1024 8192 50484 c534 firmware.elf
| Column | Meaning | Lives in |
|---|---|---|
text | Code + .rodata | Flash |
data | Initialized globals | Flash (LMA) + RAM (VMA) — counted once here |
bss | Zero-init globals | RAM only |
dec | Total in decimal | Sum of text + data + bss |
hex | Total in hex | — |
Flash usage = text + data (because data init values live in Flash). RAM usage = data + bss (variables live in RAM). Track these in CI and alert on regression.
nm — Find Your Bloat
nm lists symbols. The killer flags are -S (show size) and --size-sort -r (sort by size, largest first):
$ arm-none-eabi-nm -S --size-sort -r firmware.elf | head -100801a4c0 00003a40 T __aeabi_dadd ← 14.6 KB! The float-double add routine080121a0 00000800 T sine_lookup_table ← 2 KB lookup table080129a0 000005c0 T qsort ← 1.5 KB sorting routine...
The first column is the address; the second is the size in hex; the third is the symbol type:
| Letter | Type | Where |
|---|---|---|
T (or t) | Code (text) | Flash |
D (or d) | Initialized data | RAM (with init in Flash) |
B (or b) | Uninitialized data (.bss) | RAM |
R (or r) | Read-only data (.rodata) | Flash |
U | Undefined (referenced, not defined here) | Should be defined elsewhere |
W (or w) | Weak symbol | Can be overridden |
Lowercase = local (file scope, like static); uppercase = global. Use nm -S --size-sort -r firmware.elf | head -50 whenever Flash usage jumps unexpectedly.
Reading the .map File
The linker map file (generated via -Wl,-Map=firmware.map) is gigantic but invaluable. It has three main sections:
- Memory configuration — your
MEMORYregions and their utilization - Linker script and memory map — every output section, every input section, every symbol with its address and size
- Cross-references — which
.ofiles reference which symbols
A typical bloat investigation:
.text 0x08000000 0x6e80.text.main 0x080012a0 0x180 build/main.o.text.printf 0x08001420 0x4a00 /opt/.../newlib/libc.a(printf.o) ← here it is.text.helper 0x08005e20 0x040 build/helper.o...
The pattern: Search the map file for .text or whichever section is bloating, find the largest entries, and identify the source. The .o filename in the rightmost column tells you which translation unit (or library member) brought it in.
objdump — Disassembly with Source Mixing
For "why is this code so large?" or "why is this loop slow?", disassemble the function:
$ arm-none-eabi-objdump -d -S firmware.elf | less080012a0 <main>:int main(void) {...for (int i = 0; i < N; i++) {80012a4: 2300 movs r3, #080012a6: 4602 mov r2, r0sum += data[i];80012a8: f853 1023 ldr.w r1, [r3, r3, lsl #2]80012ac: 1859 adds r1, r3, r1...}
-d disassembles all executable sections. -S interleaves source lines (requires -g). --no-show-raw-insn cleans up output if you don't need the byte values. -d -S --visualize-jumps=color (newer binutils) draws ASCII jump arrows — great for following control flow.
readelf -a — Structural Dump
When you need to know "what's the float ABI tag in this object?" or "what segments will the loader create?":
$ arm-none-eabi-readelf -a firmware.elfELF Header:Class: ELF32Machine: ARMEntry point address: 0x80001ad...ARM Attributes:Tag_CPU_arch: v7E-MTag_FP_arch: VFPv4-D16Tag_ABI_VFP_args: VFP registers ← hard float
Especially useful for ABI-mismatch detection (covered in Toolchains & Cross-Compilation).
addr2line — Address to Source
When a HardFault dumps PC = 0x080012ac and you want to know what line of code that is:
$ arm-none-eabi-addr2line -e firmware.elf 0x080012ac/home/dev/firmware/main.c:42
-f adds the function name; -C demangles C++ symbols. This is the killer technique for triaging field crash reports — your firmware logs a fault PC, the lab decodes it offline.
DWARF Debug Info Basics
-g (and friends like -g3 or -gdwarf-4) includes DWARF debug data: the mapping from source files and line numbers to instruction addresses, structure layouts, type info, and variable locations.
| DWARF section | Holds |
|---|---|
.debug_info | Type and variable descriptions |
.debug_line | Source line ↔ PC mapping |
.debug_abbrev | Abbreviation tables for .debug_info |
.debug_str | String pool |
.debug_loc | Variable location lists |
DWARF can be larger than the code itself. A typical embedded ELF compiled -g -Os might have 40 KB of code and 200 KB of .debug_*. This is fine — DWARF lives only in the ELF, not in the flashed binary. After producing .bin via objcopy -O binary, the DWARF is gone.
strip — Remove Symbols
For binaries you ship, strip removes the symbol table and (with --strip-debug) the debug sections:
$ arm-none-eabi-strip --strip-all -o firmware-stripped.elf firmware.elf
Useful when shipping ELF for end-users (e.g., bootloader needs only loadable sections), or for checking "if I removed debug info, what's left?" Note that you should keep the unstripped ELF in build artifacts for addr2line use later.
Building the .bin / .hex for Flashing
The .elf is rich in metadata; flash tools want a flat binary or Intel HEX:
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin # flat binaryarm-none-eabi-objcopy -O ihex firmware.elf firmware.hex # Intel HEX
A .bin is a literal Flash image — byte 0 of the file goes to the start of the linker's first loadable section (typically the vector table). .hex carries explicit address info per record, useful when there are multiple non-contiguous Flash regions.
Debugging Story: The 14 KB Mystery
A team's firmware grew 14 KB after adding a single if (config.use_debug_log) { dlog("temp=%f\n", temp); } to a configuration screen. The if was nearly always false in production — but the code was getting compiled in.
size confirmed text had jumped 14 KB. nm -S --size-sort -r firmware.elf | head showed __aeabi_dadd and friends — the float-double conversion routines pulled in by the %f in printf. They had been using newlib-nano with the integer-only printf, which silently fell back to scanning extra memory once %f was needed.
Two-part fix: (1) gate the call behind a build-time flag (#if CONFIG_DEBUG_LOG) so unused code is fully removed; (2) if %f is genuinely needed, use a tiny floating-point formatter rather than dragging in newlib's full IEEE 754 conversion.
The diagnostic flow took 5 minutes thanks to nm. Without it, this would have been a multi-hour "what changed?" investigation.
The lesson: Always run nm --size-sort -r | head after a size regression. The bloat almost always concentrates in a few large symbols.
What Interviewers Want to Hear
- You can name the right tool for each inspection task (size for totals, nm for symbols, etc.)
- You know
nm -S --size-sort -ris the bloat-finder - You can describe what's in the
.mapfile and how to use it - You know
addr2linedecodes a PC to source line for crash triage - You understand DWARF lives only in the ELF, not the flashed binary
- You can convert ELF to
.bin/.hexwithobjcopy
Interview Focus
Classic Interview Questions
Q1: "My firmware just grew 8 KB. How do you find what's bloating it?"
Model Answer Starter: "Three tools, in order. First arm-none-eabi-size firmware.elf to confirm the regression — which section grew (text, data, bss)? Then arm-none-eabi-nm -S --size-sort -r firmware.elf | head -20 to see the largest symbols sorted descending. Bloat almost always concentrates in a handful of big functions or tables — printf with float, large lookup tables, dragged-in library functions. Finally, look at the .map file to see which .o file or library brought the symbol in. The full bloat hunt usually takes 5-10 minutes if you know these tools."
Q2: "Walk me through using addr2line for a HardFault."
Model Answer Starter: "When the device hard-faults, you get the PC value from the stacked frame — say 0x080012ac. Run arm-none-eabi-addr2line -e firmware.elf -f -C 0x080012ac. The -e specifies the ELF (must be the unstripped version with debug info from the same build), -f adds the function name, -C demangles C++. Output is something like compute_crc /home/dev/firmware/crc.c:42. This requires you've kept the debug ELF — strip the binary you flash, but archive the ELF in CI artifacts indexed by build version."
Q3: "What's the difference between an ELF file and a .bin file?"
Model Answer Starter: "ELF is the rich, structured format the linker produces — it has section headers, a symbol table, debug info, and metadata describing what the binary contains. A .bin is a flat dump of the loadable sections, byte for byte, in the order they appear in memory — no headers, no metadata, no symbols. Bootloaders and flash tools typically want the .bin because it's a literal image of what to write to Flash. You produce it with arm-none-eabi-objcopy -O binary in.elf out.bin. Same for Intel HEX (-O ihex), which is text-format with address records per chunk — useful when Flash regions are non-contiguous."
Q4: "What does the linker map file tell you, and when do you read it?"
Model Answer Starter: "It's the linker's complete record of what it placed where. Three sections matter: the memory configuration (your MEMORY regions and their utilization), the section/symbol map (every output section's input components, with addresses and sizes), and cross-references (which .o referenced which symbol). I read it whenever there's a section overflow ('region FLASH overflowed by N bytes'), whenever I need to find which library is dragging in a particular function, and as part of every release-build review to track size trends. Generated via -Wl,-Map=firmware.map."
Q5: "What's the relationship between -g, DWARF, and the size of my flashed image?"
Model Answer Starter: "-g tells the compiler to include DWARF debug information — type info, source-line-to-PC mapping, variable locations. DWARF lives in .debug_* sections in the ELF and can easily be larger than the code itself. But DWARF is not loadable — it has no LMA, no program-header entry. When you produce a .bin with objcopy -O binary, only the loadable sections are extracted, so .debug_* is dropped. Net effect: -g makes your ELF much bigger, but the actual flashed binary is unchanged. Always build with -g; archive the ELF for later debugging; flash the bin."
Trap Alerts
- Don't say: "Just look at the source" — for bloat investigations, the binary is the source of truth
- Don't forget: Keep the unstripped ELF for
addr2lineeven after shipping a stripped binary - Don't ignore: Section types (T/D/B/R/U) in nm output — they tell you where the symbol lives
Follow-up Questions
- "How do you compare two
.mapfiles to see what changed between builds?" - "What is
bloatyand how does it differ fromsize?" - "How does
--print-memory-usage(linker flag) work?" - "How do you tell objdump to disassemble Thumb-2 vs ARM mode correctly?"
- "What is
__attribute__((section()))and how does it interact with binary inspection?" - "How do you generate a flash image that includes a fixed-position calibration block?"
Ready to test yourself? Head over to the Build Systems Interview Questions page for a full set of Q&A with collapsible answers — perfect for self-study and mock interview practice.
Practice
❓ Which tool answers the question 'what symbols, sorted by size, are in this binary?'
❓ A HardFault report shows PC = 0x08001ac4. Which command tells you the source line?
❓ What's the difference between the .text and .bss section sizes shown by `size firmware.elf`?
❓ Why does enabling -g make the ELF much larger but not change the flashed binary?
❓ Which file gives you a per-symbol report of what the linker placed where, and how big each piece is?
Real-World Tie-In
CI Size-Regression Gate — A team added a CI check: after each build, parse arm-none-eabi-size firmware.elf output and compare against a stored baseline. Any 1% increase in text or 5% in bss fails the build with a diff of nm -S --size-sort -r highlighting the new top symbols. Catches accidental bloat before merge.
Field Crash Triage Pipeline — Production firmware logs PC and LR on every fault, plus the build's git SHA. Cloud service receives the report, checks out the matching build's archived ELF, runs addr2line -e firmware.elf -f -C $PC, and tags the crash with function name and source line. Engineers see grouped crash reports keyed to source location, not raw addresses.
Toolchain Audit via readelf -A — Before integrating a closed-source vendor BLE library (.a file), an engineer extracted all .o files (ar x lib_ble.a) and ran arm-none-eabi-readelf -A *.o | grep VFP on each. Confirmed every object had Tag_FP_arch: VFPv4-D16 and Tag_ABI_VFP_args: VFP registers, matching the project. Integration succeeded first try; without the audit, an ABI mismatch would have caused subtle runtime float corruption.
