Search topics...
Build Systems
intermediate
Weight: 3/10

ELF, Map & Binary Inspection

Read ELF files and linker map files to debug binary-size, symbol-resolution, and unexpected-bloat issues using objdump, nm, size, readelf, strip, and addr2line.

build-systems
elf
map-file
objdump
nm
readelf
size
addr2line
binary-inspection
Loading quiz status...

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 check
  • nm foo.elf / nm -S --size-sort: list symbols, sorted by size to find the bloat
  • readelf -a foo.elf: full structural dump — sections, symbols, relocations, ARM ABI tags
  • objdump -d foo.elf (or -S for source-mixed): disassembly
  • addr2line -e foo.elf 0x080012ac: "what source line is this address?"
  • .map file: per-symbol Flash placement; the canonical bloat-investigation source

Deep Dive

At a Glance

QuestionToolTypical command
How big is each section?sizearm-none-eabi-size firmware.elf
What symbols are in the binary, sorted by size?nmarm-none-eabi-nm -S --size-sort -r firmware.elf
Disassemble code (with source if -g)?objdumparm-none-eabi-objdump -d -S firmware.elf
Show full ELF structure (sections, symbols, ABI tags)?readelfarm-none-eabi-readelf -a firmware.elf
What source line is at address X?addr2linearm-none-eabi-addr2line -e firmware.elf 0x080012ac
Strip debug info from a binarystriparm-none-eabi-strip --strip-all firmware.elf
Where did the linker place each symbol?Read firmware.mapGenerated by -Wl,-Map=firmware.map
Get raw flash imageobjcopy -O binaryarm-none-eabi-objcopy -O binary firmware.elf firmware.bin
Get Intel HEXobjcopy -O ihexarm-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:

DiagramELF File Layout
 ┌──────────────────────────────┐
 │  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
 └──────────────────────────────┘
Header + tables on top; sections (text, rodata, data, bss, symtab, debug) below.

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:

text
$ arm-none-eabi-size firmware.elf
text data bss dec hex filename
41268 1024 8192 50484 c534 firmware.elf
ColumnMeaningLives in
textCode + .rodataFlash
dataInitialized globalsFlash (LMA) + RAM (VMA) — counted once here
bssZero-init globalsRAM only
decTotal in decimalSum of text + data + bss
hexTotal 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):

text
$ arm-none-eabi-nm -S --size-sort -r firmware.elf | head -10
0801a4c0 00003a40 T __aeabi_dadd ← 14.6 KB! The float-double add routine
080121a0 00000800 T sine_lookup_table ← 2 KB lookup table
080129a0 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:

LetterTypeWhere
T (or t)Code (text)Flash
D (or d)Initialized dataRAM (with init in Flash)
B (or b)Uninitialized data (.bss)RAM
R (or r)Read-only data (.rodata)Flash
UUndefined (referenced, not defined here)Should be defined elsewhere
W (or w)Weak symbolCan 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:

  1. Memory configuration — your MEMORY regions and their utilization
  2. Linker script and memory map — every output section, every input section, every symbol with its address and size
  3. Cross-references — which .o files reference which symbols

A typical bloat investigation:

text
.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:

text
$ arm-none-eabi-objdump -d -S firmware.elf | less
080012a0 <main>:
int main(void) {
...
for (int i = 0; i < N; i++) {
80012a4: 2300 movs r3, #0
80012a6: 4602 mov r2, r0
sum += 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?":

text
$ arm-none-eabi-readelf -a firmware.elf
ELF Header:
Class: ELF32
Machine: ARM
Entry point address: 0x80001ad
...
ARM Attributes:
Tag_CPU_arch: v7E-M
Tag_FP_arch: VFPv4-D16
Tag_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:

text
$ 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 sectionHolds
.debug_infoType and variable descriptions
.debug_lineSource line ↔ PC mapping
.debug_abbrevAbbreviation tables for .debug_info
.debug_strString pool
.debug_locVariable 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:

text
$ 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:

text
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin # flat binary
arm-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 -r is the bloat-finder
  • You can describe what's in the .map file and how to use it
  • You know addr2line decodes 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 / .hex with objcopy

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 addr2line even 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 .map files to see what changed between builds?"
  • "What is bloaty and how does it differ from size?"
  • "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?"
💡Practice Build Systems Interview Questions

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.

Was this helpful?