Debug Information
The compiler emits a second artifact alongside the code: a map from addresses to source positions, a description of where every variable lives at every point, the shape of every type, and how to walk back up the stack. None of it is recoverable from the instructions.
How does a debugger know that this machine instruction came from line 47 and that count is in rbx right now?
A separate, structured description of the *compilation*, keyed by machine address: a line table mapping address ranges to file, line and column; a tree of type and scope descriptions; per-variable location expressions saying where a value lives over each address range; and unwind information describing how to recover the caller's frame. It is a second program, in effect — DWARF's line table is literally a bytecode interpreted by a state machine — and it exists because the instruction stream itself contains none of this.
Debug information is metadata, so emitting it may not change the generated code — a build with -g and a build without it must produce the same instructions, or the information describes a program you did not ship. That is the precondition, and it is why -g is orthogonal to -O. The obligation runs the other way too: every transformation that moves, merges, duplicates or deletes an instruction must update the metadata attached to it, and a pass that fails to do so does not produce wrong code — it produces correct code with a wrong description, which is strictly harder to notice.
Key points
- Debug information is a separate structured description of the compilation, keyed by machine address, carried alongside the code rather than derived from it.
- The four parts are the line table, variable location lists, type and scope descriptions, and unwind information.
- A variable does not have a location; it has a location *list*, because where the value lives changes as the allocator moves it.
- DWARF's line table is encoded as a program for a state machine, because the expanded table would be very large.
- Emitting debug information must not change the generated code —
-gis orthogonal to-O, and that is a correctness requirement. - Every optimization pass must update the metadata it moves or deletes; failing to do so produces correct code with a wrong description.
- DWARF on ELF and Mach-O, PDB on Windows, and macOS additionally uses a debug map plus a
.dSYMbundle produced bydsymutil. - Debug info is frequently larger than the code, which is what
-gsplit-dwarf,-gzanddwzexist to manage. - Debuggers are not the only consumer: profilers, crash reporters, sanitizers and exception unwinding all read it.
Four things the compiler has to write down
By the time the backend has finished, the correspondence between what you wrote and what executes is gone. [[information-loss]] describes the deaths one by one: names die at register allocation, expression structure dies at lowering, types die at erasure or at codegen, scopes die when the stack frame is laid out. Debug information is the record kept as each of those dies, and it has four parts that answer four different questions.
The line table answers "which source position produced this address". It is a mapping from address ranges to (file, line, column), plus flags — most importantly is_stmt, marking addresses that are reasonable places to set a breakpoint, because after optimization most addresses are not. In DWARF this table is not stored as a table: it is encoded as a program for a small state machine, with opcodes that advance the address and the line by deltas, because the raw table would be enormous.
Variable locations answer "where is count right now". Not "where is count" — there is no such fact. A local variable lives in a register for one range of addresses, in a different register after a call clobbered the first, in a stack slot while it is spilled, and nowhere at all where the optimizer proved it unnecessary. DWARF records this as a *location list*: a set of address ranges, each with an expression saying where the value is. The expression is itself a small stack language (DW_OP_reg3, DW_OP_fbreg -24, DW_OP_breg6 -16; DW_OP_deref) capable of describing values that are computed rather than stored.
Type and scope descriptions answer "how do I interpret those bytes, and what is in scope here". A tree of entries — DWARF calls them DIEs, debugging information entries — describing every type, every struct member and its offset, every function, every lexical block and its address range, every inlined instance and where it came from. This is what lets a debugger print a struct field by name rather than as hex.
Unwind information answers "given this program counter and stack pointer, where is the caller". Modern code does not keep a frame pointer by default, so walking the stack requires a table describing, per address range, how to compute the canonical frame address and where each saved register was stored. On ELF this lives in .eh_frame and is used by both the debugger and the language runtime, since [[stack-unwinding]] for exceptions needs exactly the same information.
| Part | Answers | Where it lives (ELF/DWARF) | Without it you get |
|---|---|---|---|
| Line table | Which source line produced this address | .debug_line | Addresses only; no source view, no line breakpoints |
| Location lists | Where this variable is at this address | .debug_loclists (.debug_loc pre-DWARF5) | "optimized out" for everything, always |
| Type/scope DIEs | How to interpret these bytes; what is in scope | .debug_info plus .debug_abbrev, .debug_str | Hex dumps instead of structs |
| Unwind info (CFI) | How to find the caller's frame | .eh_frame, .debug_frame | A one-frame stack trace, or a wrong one |
| Inline records | Which function this code was inlined from | DW_TAG_inlined_subroutine in .debug_info | Stack traces missing every inlined frame |
| Symbol table | Which function this address belongs to | .symtab — not DWARF, but needed with it | Addresses with no function names at all |
DWARF, PDB, and why the formats differ
.debug_line and .eh_frame are ELF spellings; Mach-O uses __DWARF,__debug_line and a .dSYM bundle produced by dsymutil; Windows uses a PDB file keyed by a GUID and age with no debug sections in the executable at all. DWARF register numbers are defined per-ABI, so DW_OP_reg3 is RBX under x86-64 System V and a completely different register under AArch64 AAPCS. The four categories of information transfer everywhere; none of the spellings do.Two mainstream formats, and the split is by platform rather than by compiler. DWARF is used on Linux, the BSDs, macOS and most embedded targets, and is carried in sections of the object and executable files themselves. PDB — Program Database — is Microsoft's format, and it is always a separate file next to the binary, linked to it by a GUID and an age counter stamped into the executable.
The macOS arrangement is a third case worth knowing because it surprises people: Clang leaves DWARF in the individual .o files and the linker writes only a *debug map* into the executable, recording which object file and symbol each address came from. dsymutil then follows that map and gathers the DWARF into a .dSYM bundle. The consequence is practical: deleting your build directory can leave a binary whose debug information is unreachable, and shipping a build means shipping or archiving the .dSYM.
The size is the other thing to internalise. Debug information is routinely larger than the code it describes — several times larger is normal for C++ with heavy template instantiation, because every translation unit re-describes the same types. DWARF 5 added .debug_str_offsets and other deduplication; -gsplit-dwarf moves the bulk into separate .dwo files so the linker never sees it; -gz compresses the sections; and dwz deduplicates across a whole distribution. None of this affects the shipped instructions, which is exactly the point of the legality condition above.
1Address Line Column File Flags2---------- ---- ------ ------ -------------30x00401136 12 3 1 is_stmt40x0040113e 13 10 1 is_stmt prologue_end50x00401145 14 5 1 is_stmt60x00401145 19 7 1 is_stmt <- two lines, one address: inlined70x00401152 14 5 18 90x0000000c:10 [0x00401136, 0x0040113e): DW_OP_consts +0, DW_OP_stack_value <- a value, not a place11 [0x0040113e, 0x00401152): DW_OP_reg3 RBX <- lives in rbx12 [0x00401152, 0x00401160): DW_OP_fbreg -24 <- spilled to the frame13 [0x00401160, 0x00401180): <empty> <- "optimized out" hereThree things to notice. Two entries share address 0x401145 because an inlined call meant one instruction belongs to two source lines — that is why the instruction pointer appears to jump between functions. DW_OP_stack_value describes a value that was never stored anywhere, so the debugger can print it but you cannot assign to it. And the empty final range is the honest form of "optimized out": not an error, a gap in the location list.
Keeping it correct through the optimizer
Emitting debug information from an unoptimized frontend is straightforward. Keeping it accurate through a hundred optimization passes is the actual engineering problem, and it is why "debug info quality" is a long-running work item in every serious compiler rather than a solved feature.
Every transformation has an obligation. Instruction scheduling moves an instruction, so its source location moves with it — and now two source lines are interleaved. Inlining copies a body into a caller, so every copied instruction needs a location that records both where it is and which call it came from, which is what DW_TAG_inlined_subroutine and DWARF's inline call-site attributes exist for. Register allocation moves a value between registers and stack slots, so its location list acquires a new range at every move. Dead-code elimination deletes an instruction, and the debug metadata attached to it must be dropped rather than reattached to an unrelated neighbour — a mistake that shows up as a variable reporting a plausible, wrong value.
LLVM makes this concrete in a way worth knowing: debug locations are attached to instructions as metadata, and variable locations are tracked by dedicated intrinsic records that the passes must maintain like any other use. The historical bug class is exactly what you would expect — a pass that correctly transforms the code and forgets the metadata, producing a build that runs right and debugs wrong. Test suites for this compare debug output between optimization levels, because there is no other way to notice.
The reason to care as a user is that this determines what you can trust. When a debugger prints a value at -O2, it is reporting what the location list says, and the location list is only as good as the last pass that touched it. That is the mechanism behind [[debugging-optimized-code]], and it is why the honest answer to "can I trust this value" is "check whether the variable is live and whether the location list covers this address".
Who else reads it
Debuggers are the obvious consumer and not the largest one. The same metadata is what makes a profiler show source lines, and a [[symbolication]] pipeline turn a crash address into a function and line, and a sanitizer report a stack trace, and a coverage tool attribute execution to source. Several language runtimes read the unwind tables directly to implement exceptions, which is why .eh_frame is present even in binaries built without -g — unwind information is not optional in the way the rest of DWARF is.
This is worth stating because it changes the cost calculation. Debug information is often described as "for debugging", implying it is only needed when you attach a debugger, and therefore droppable in production. In practice it is what makes production crash reports readable, what makes a continuous profiler show a line number instead of an address, and what a core dump from a customer is useless without. The correct arrangement is almost always to build with it and *split* it out of the shipped artifact rather than not to generate it — which is the whole argument of [[debug-vs-release]].
- Debuggers: breakpoints by line, variable inspection, stack traces with inlined frames.
- Profilers: attributing samples to source lines rather than to addresses — see
[[reading-compiler-output]]for the tooling. - Crash reporting and symbolication: turning a shipped binary's addresses back into names and lines.
- Sanitizers: reporting the allocation site and the offending line rather than an address.
- Language runtimes:
.eh_frameunwind tables are used for exception propagation, independently of-g. - Coverage and tracing tools: mapping counters and probes to source constructs.
How it works
The steps, in the order the compiler takes them.
- The frontend attaches a source location to every IR instruction and records a description of every variable, type and lexical scope as it lowers.
- Each optimization pass propagates those locations through the transformations it makes, and drops or merges them where instructions are deleted or combined.
- Register allocation records, for each variable, the sequence of address ranges over which the value lived in a particular register, stack slot, or nowhere at all.
- Inlining records the inlined call site so that the copied instructions can be attributed both to their original function and to the call that pulled them in.
- The backend emits the line table as a state-machine program of address and line deltas, and the type and scope tree as abbreviated entries with a shared string table.
- The unwind tables are generated from the frame layout, describing per address range how to compute the canonical frame address and recover saved registers.
- The linker concatenates and relocates the debug sections, or on macOS writes a debug map that
dsymutillater follows to build a.dSYM. - A consumer looks up an address in the line table, walks the scope tree to find the enclosing function and lexical block, and evaluates the location expression for the address to find each variable.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A stack trace shows three frames for a call chain of eight, because the unwind information was missing or wrong and the walker gave up.
- A variable prints a plausible but wrong value, because a pass moved an instruction and reattached the debug metadata to a neighbouring one.
- A breakpoint on a line never fires, because the line has no
is_stmtaddress after optimization — the code for it was hoisted, folded or deleted. - A production crash report is a list of hex addresses, because the shipped binary was stripped and the symbols were not archived anywhere.
- The linked binary on macOS debugs perfectly on the build machine and not at all on another, because the DWARF was still in the
.ofiles and only a debug map was linked in. - A build directory grows to tens of gigabytes and link times triple, because every translation unit re-describes the same template instantiations and nothing is deduplicating them.
- The line table attributes instructions to a header file rather than to the call site, so a profile blames a standard-library inline function for the caller's cost.
When it helps
- Any binary you will ever need to debug, profile, or receive a crash report from — which is every binary that reaches a user.
- Post-mortem analysis: a core dump is only interpretable with the type and location information that describes it.
- Continuous profiling in production, where line-level attribution is the difference between "this service is slow" and "this loop is slow".
- Sanitizer and fuzzing workflows, where the report's value is entirely in the source locations it can name.
When it hurts
- Where artifact size is a hard constraint — embedded firmware, a container image budget, a mobile app download size — and the answer is to split rather than to omit.
- In very large C++ builds, where debug information dominates build time, link time and disk, and
-gsplit-dwarfor-g1becomes a real necessity rather than a tuning knob. - When the debug information is a disclosure concern: it names internal paths, function names and structure layouts, which is a reason to strip the shipped copy and keep the symbols privately.
- When it is trusted uncritically at high optimization levels, where an out-of-date location list produces a confident wrong answer — see
[[debugging-optimized-code]].
What it costs
Every one of these is paid by something.
- Full debug information buys interpretable crashes, profiles and core dumps and pays in artifact size — routinely a multiple of the code — plus link time and build disk.
- Splitting debug info into separate files buys a small shipped artifact and pays an archival obligation: the symbols must be kept, indexed by build ID, for as long as the binary is deployed anywhere.
- Maintaining metadata through the optimizer buys accurate debugging at
-O2and pays real implementation effort in every pass, plus a test surface that most compilers under-invest in. - Lower debug levels (
-g1, line tables only) buy most of the value of symbolication at a fraction of the size, and pay the ability to inspect variables at all. - Compressing debug sections buys disk and transfer size and pays decompression time in every consumer, including the debugger's start-up.
- Emitting column numbers as well as lines buys precise attribution inside a complex expression and pays a noticeably larger line table.
What else you could do
What a different compiler or language does instead, and when that is better.
- Frame pointers instead of unwind tables:
-fno-omit-frame-pointermakes stack walking a pointer chase that any profiler can do cheaply, at the cost of one register and a small performance loss. Widely re-adopted for exactly this reason. - Ship symbol tables only, with no DWARF. You get function names in stack traces and nothing else — cheap, and often the right point for a stripped production binary paired with archived full symbols.
- Runtime-generated metadata: JIT compilers register frames with the runtime, and interpreted languages keep source positions in their own structures rather than in DWARF at all — see
[[bytecode]]and[[python-pipeline]]. - Source maps for a source-to-source pipeline, which solve the same problem in a completely different container — see
[[source-maps]]. - Reconstruct from the binary: disassemble and infer function boundaries and types statically. This is what a decompiler does, it is what you are left with when symbols are gone, and it is far worse than the information the compiler could simply have written down.
See it for yourself
The flag, dump or tool that shows you this directly.
- Line table:
llvm-dwarfdump --debug-line ./bin,readelf --debug-dump=decodedline ./bin, orobjdump --dwarf=decodedline ./bin.objdump -dS ./bininterleaves the disassembly with source. - Variable locations:
llvm-dwarfdump --debug-loclists ./bin(DWARF 5) or--debug-locfor older;llvm-dwarfdump --statistics ./binprints coverage figures for how much of each variable's scope actually has a location — the single best measure of debug-info quality. - Types and scopes:
llvm-dwarfdump --debug-info ./bin | less, orreadelf --debug-dump=info.pahole ./binuses the same data to print struct layouts with padding. - Unwind:
readelf --debug-dump=frames-interp ./bindecodes.eh_frameinto readable per-range rules. - Address to source:
addr2line -e ./bin -f -C -i 0x401136—-iis essential, since without it you lose every inlined frame;llvm-symbolizeris the equivalent and handles more formats. - Windows:
llvm-pdbutil dump --all app.pdb, or the Debug Interface Access SDK. macOS:dwarfdump app.dSYM,dsymutil --dump-debug-map app. - Size accounting:
size -A ./binshows per-section sizes so you can see what fraction is debug;bloaty ./binbreaks it down further and compares two binaries. - Try the orthogonality claim yourself: build with
-O2and with-O2 -g, thenobjdump -dboth and diff the instruction bytes. They should be identical.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Debug information makes the program slower." It adds sections to the file that are never loaded into memory during normal execution. It costs size and link time, and the instructions are the same — verify with a diff of the disassembly.
- "A variable has an address." It has a location list. In optimized code a local frequently has no single home, and for part of its scope no home at all.
- "Stripping the binary is a security measure." It removes names from the shipped artifact and does not remove the code or the behaviour. It also removes your ability to read a crash report unless the symbols were archived first.
- "If the debugger shows a value, the value is right." It shows what the location list claims for that address. At high optimization levels the list can be stale or absent, and a stale entry looks exactly like a good one.
- "
-gand-Oare alternatives." They are orthogonal flags.-g -O2is a valid and usually correct combination, and it is what shipped software should be built with.
Misconceptions
The claim, and what is actually true.
.eh_frame is required for exception handling and is present in optimized, non--g builds. Stripping it breaks the language runtime, not just the debugger.[[symbolication]].Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Machine code contains no names, no line numbers and no types — all of that was consumed by the compiler on the way down. So when a debugger shows you source, it is reading a separate description the compiler wrote alongside the code: a table saying which addresses came from which lines, a record of where each variable lives at each point in the program, a description of every type, and instructions for walking back up the stack. That description is DWARF on Linux and macOS and a PDB file on Windows, and it exists only if the build asked for it.
practical
Build shipped software with -g -O2, then split the debug information out and archive it — the flags do not conflict and never did. Verify orthogonality once for yourself by diffing the disassembly of -O2 against -O2 -g. When a stack trace is short or a variable is missing, look at the data rather than guessing: llvm-dwarfdump --statistics tells you what fraction of each variable's scope has a location, readelf --debug-dump=frames-interp tells you whether the unwind rules exist, and addr2line -i recovers the inlined frames that a naive symbolizer drops. And if build size is the problem, reach for -gsplit-dwarf or -g1 before reaching for no debug info at all.
advanced
The structural insight is that debug information is a *lossy inverse* of the compilation, maintained incrementally by passes that were designed to do something else. That framing explains all of its pathologies at once. It is lossy because some facts have no representation — a value that was rematerialised at three different points has three locations and no identity; a variable that was promoted into a loop-carried phi has no single home. It is maintained incrementally, so its accuracy degrades in proportion to how many passes touched the code and how well each of them was audited, which is why quality varies by pass and by version rather than uniformly. And it describes the compilation rather than the program, which is why two builds of identical source with different flags need entirely different debug data and why reproducibility work has to include it — see [[reproducible-compilation]]. The design alternative, taken by managed runtimes, is to keep the metadata *inside* the executing representation so it cannot drift; the cost is that the representation can never discard it.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
DW_OP_reg3 names RBX under x86-64 System V and something entirely different under AArch64 AAPCS; ELF carries .debug_* sections in the binary, Mach-O uses a debug map plus a separate .dSYM, and Windows uses a PDB keyed by GUID with no debug sections in the executable. Any concrete example here transfers as a shape and not as a spelling.llvm-dwarfdump --statistics reports per-variable location coverage precisely so this can be measured rather than guessed at, and the figures have improved steadily in both GCC and Clang. A conclusion drawn from one toolchain version does not transfer to another.-g1 does. Conformance therefore says nothing about how debuggable a binary will be.If you were asked this in an interview
- Name the four kinds of information a debugger needs from the compiler and say what breaks without each.
- Why does a variable have a location *list* rather than a location?
- Does
-gmake the program slower? Justify the answer and say how you would verify it.
Connections
- Programming Languages & Runtime Internals — How a managed runtime keeps source correspondence without a separate metadata formatJVM and CLR bytecode carries line tables and local-variable tables inside the class file, and a JIT registers its generated frames with the runtime rather than emitting DWARF. That runtime-side arrangement is owned there; why an ahead-of-time compiler needs an external format at all, and what it costs to keep accurate, is ours.
- DevOps / Production Engineering — Archiving and serving symbol files for deployed binariesDebug information is only useful in production if the symbols were kept and can be found later by build ID — a symbol server, a debuginfod endpoint, or an artifact store. Operating that is a delivery-engineering concern owned there, and without it every crash report from the field is a list of addresses.