.debug_* Sections"Add debug info" is one compiler flag, but it produces at least six separate sections with different jobs, different readers, and different fates. When a build is too large, teams reach for strip without knowing which sections they are discarding — and then a crash report loses the one table it needed.
Knowing the sections separately is what lets you answer practical questions: what did strip actually remove, what can be regenerated and what cannot, and why two string sections exist when one would do.
Group the six sections by what they answer:
.debug_line + .debug_line_str): address to source line. This is the one crash reporters need first..debug_info + .debug_abbrev): the tree of types, functions, parameters and variables. This is what lets a debugger show you a variable's value and its type..debug_str, .debug_line_str): names, referenced by offset, never by inline text..debug_aranges): which address ranges belong to which compilation unit, so a tool can skip CUs it does not care about..debug_info as expression opcodes. At -O2 a fifth section (.debug_rnglists or .debug_loclists) appears. Treat the four groups as a way of finding things, not as a fixed list.Here is the real section table for shape_v5, from readelf -S -W. Read the last three columns carefully — they carry the whole lesson.
[26] .debug_aranges PROGBITS 0000000000000000 003036 000030 00 0 0 1
[27] .debug_info PROGBITS 0000000000000000 003066 000110 00 0 0 1
[28] .debug_abbrev PROGBITS 0000000000000000 003176 0000bf 00 0 0 1
[29] .debug_line PROGBITS 0000000000000000 003235 000073 00 0 0 1
[30] .debug_str PROGBITS 0000000000000000 0032a8 000111 01 MS 0 0 1
[31] .debug_line_str PROGBITS 0000000000000000 0033b9 00005e 01 MS 0 0 1Three facts fall straight out of those rows:
Address is 0000000000000000. No debug section is ever mapped into memory. This is the same conclusion as Addr 0 in the ELF section header table — these sections exist in the file, not in the running process.MS flag and an entry size of 1.MS is SHF_MERGE | SHF_STRINGS: the linker is allowed to deduplicate them across every object file it links. That is why main and int32_t appear once in .debug_str no matter how many object files mention them.Point 3 explains a design choice that otherwise looks pointless. .debug_line_str was added in DWARF 5 purely so that path strings live in their own mergeable pool, separate from .debug_str. Before v5, shape.c and int32_t shared one pool, so the linker merged the type-name pool into what was really a directory-path pool — both fully occupied, no overlap, pure waste in every single object file.
All three binaries below come from the same shape.c. Only the flags differ.
| Command | Size (bytes) | Debug sections | .symtab |
|---|---|---|---|
gcc -gdwarf-5 -O0 -o shape_v5 shape.c | 17256 | all six | present |
gcc -g0 -O0 -o shape_g0 shape.c | 15800 | none | present |
strip shape_v5 | 14328 | none | removed |
Two things to notice. -g0 costs 1456 bytes of debug info and leaves symbols alone. strip removes debug info and the symbol table, which is why the stripped binary is smaller than the one built with no debug info at all — 14328 against 15800. Stripping is a different operation from -g0, and it removes strictly more.
Now the string pools, to see the merge flag doing its job. The .debug_str of shape_v5 starts with the compiler's own signature:
00000000: 474e 5520 4332 3320 3135 2e32 2e30 202d GNU C23 15.2.0 - 00000010: 6d74 756e 653d 6765 6e65 7269 6320 2d6d mtune=generic -m 00000020: 6172 6368 3d78 3836 2d36 3420 2d67 6477 arch=x86-64 -gdw 00000030: 6172 662d 3520 2d4f 3020 2d66 6173 796e arf-5 -O0 -fasyn
That whole string is the value of DW_AT_producer in the compilation unit. Note it records -O0 — the optimisation level is part of the debug info, because changing it changes the line table completely. And note it is stored once, at offset 0, and referenced by a 4-byte offset from .debug_info, never copied inline. readelf -p .debug_str prints the whole pool with the offsets a DIE would use:
[ 0] GNU C23 15.2.0 -mtune=generic -march=x86-64 -gdwarf-5 -O0 ...
[ b6] long unsigned int
[ c8] __int32_t
[ d2] unsigned char
[ e0] main
[ e5] long int$ gcc -gdwarf-5 -O0 -o shape_v5 shape.c
$ gcc -g0 -O0 -o shape_g0 shape.c
$ cp shape_v5 shape_stripped && strip shape_stripped
$ readelf -S -W shape_v5 | grep debug
$ readelf -S -W shape_g0 | grep debug # nothing
$ readelf -S -W shape_stripped | grep -E 'debug|symtab' # nothing
$ ls -l shape_v5 shape_g0 shape_strippedThen isolate a single section, which is the cleanest way to see that it is just file bytes:
$ objcopy --dump-section .debug_line=dl.bin shape_v5
$ xxd dl.binWhat to look for: the MS flag on exactly the two string sections and nowhere else, and the surprising size ordering in the ls output. Both are explained in the REALITY unit above.
Without looking back: a linker is combining forty object files into one executable. Why does the resulting .debug_str contain one copy of the string unsigned int rather than forty?
Your release pipeline strips binaries to save space. Support keeps reporting crashes with ??? where the function name should be, but engineers can still see correct source file and line numbers. Which sections survived, and what single change fixes the rest?
The general habit: when a crash report is half-degraded, that pattern is diagnostic. Working line numbers but missing function names is a specific, known configuration, not a mystery.
The MS flag is an ELF section-header attribute, so this concept leans on The ELF Section Header Table. The address column that is always zero is the subject of ELF memory mapping: a section with no address is never mapped.
You now know the sections exist and what each is for. But they are not interchangeable — a v4 file and a v5 file lay the same data out differently, and a parser that assumes the wrong one reads garbage. That is next.
Next: DWARF 2, 3, 4 and 5 — what each version changed, and the two specific byte layouts that will silently break a reader.