How Debug Symbols and the -g Flag Work in C
When compiling C programs, passing the -g flag instructs
the compiler to generate debug symbols, which are metadata tables
embedded into the executable. This article explains what debug symbols
are, the format they use, and the exact mechanism by which debuggers
utilize this metadata to map raw machine code addresses back to
human-readable C source files, line numbers, and variable names.
What Is the -g Flag?
By default, a C compiler such as GCC or Clang translates source code into optimized machine instructions without retaining contextual details about the original code structure. The resulting binary contains raw addresses, registers, and memory offsets.
Adding the -g flag instructs the compiler's code
generator to emit debugging information alongside the generated machine
code. This data is typically formatted using the DWARF (Debugging With
Attributed Record Formats) standard on Linux and macOS, or CodeView/PDB
on Windows. Debug symbols significantly increase the binary's file size
on disk, but they are placed in non-allocatable sections (such as
.debug_info and .debug_line), meaning they do
not consume additional RAM at runtime unless explicitly loaded by a
debugger like GDB or LLDB.
What Debug Symbols Contain
Debug symbols act as a data dictionary that describes the high-level program structure. Key components include:
- File and Line Tables: Cross-references connecting binary instruction addresses to source file paths and line numbers.
- Symbol Names: Original identifiers for functions, global variables, and local variables.
- Type Information: Descriptions of data types, memory layouts, struct field offsets, and array dimensions.
- Variable Scopes and Locations: Rules detailing where a variable lives at any point during execution (e.g., in a specific register or at an offset relative to the stack frame pointer).
How Machine Code Maps to Source Code
The bridge between raw bytes executed by the CPU and the original C code relies on two primary DWARF tables: the Line Number Program and the Debug Information Entries (DIEs).
1. Address-to-Line Mapping
(.debug_line)
The CPU tracks execution using an instruction pointer (such as
RIP on x86-64), which holds the memory address of the next
machine instruction. To resolve this address to a C code line, the
debugger queries the .debug_line section.
Instead of storing a massive static table of every instruction address, compilers store a bytecode program known as the DWARF Line Number Program. When initialized, the debugger runs this byte code to reconstruct an address-to-source matrix.
This matrix contains entries such as:
| Memory Address | Source File | Line Number | Column |
|---|---|---|---|
0x0000000000401126 |
main.c |
5 | 1 |
0x0000000000401132 |
main.c |
6 | 9 |
0x0000000000401140 |
main.c |
7 | 5 |
When an application crashes or hits a breakpoint at
0x00401132, the debugger performs a binary search on this
table to display line 6 of main.c. When stepping to the
next line ("next" or "step"), the debugger sets a temporary breakpoint
at the next address associated with a different line number.
2. Resolving
Variables and Memory (.debug_info)
Mapping variable names to runtime values is handled by the
.debug_info section through hierarchical records called
Debug Information Entries (DIEs). Each DIE contains a tag specifying its
type (e.g., DW_TAG_variable,
DW_TAG_subprogram) followed by attributes:
- Name and Scope: Connects the identifier name (e.g.,
counter) to its enclosing function block. - Type Reference: Points to a type DIE defining the size and layout (e.g., a 4-byte signed integer).
- Location Expressions (
DW_AT_location): Defines where the variable resides in memory. For an unoptimized local variable, this is usually an offset from the stack base pointer (such as[RBP - 0x08]). For optimized code, the location might change throughout execution, mapping the variable to registerRAXat one instruction and toRBXat another.
When you print a variable's value in a debugger, the tool locates the appropriate DIE for the current instruction pointer, calculates the memory address using the location expression, reads the raw bytes from that memory location, and formats the output according to the type definition.