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 register RAX at one instruction and to RBX at 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.