C++ GDB: A Practical Debugger Guide

Key takeaways

Use C++ GDB for breakpoints, stepping, variable inspection, and backtraces. Covers -g builds, core analysis, and multithreaded debugging with practical examples.

Introduction

GDB (GNU Debugger) is the standard source-level debugger on Linux for C and C++. It lets you stop a running program at a chosen line, look at variables and memory, walk the call stack, and open a core file after a crash has already happened.

Printf debugging still has its place, but it forces a rebuild for every question you want to ask, and it changes timing enough to hide some race conditions. A debugger answers the next question immediately: once you are stopped in the wrong state, you can print anything reachable from the current frame, move up to the caller, or set a watchpoint and rerun. The price is that the binary has to carry debug information and, for comfortable stepping, has to be built with little or no optimization.


How this fits into a real workflow

Most of the time GDB is not used to single-step through a program from main. It is used in three narrower ways: to get a backtrace from a crash (live or from a core file), to stop at a suspicious function with a condition that matches the failing input, and to find out who writes to a variable with a watchpoint. The sections below follow that order of usefulness.

The mistake I made most often when starting out was debugging a release build and trusting what I saw. With -O2, GDB will happily show a line that has not executed yet, skip lines entirely, and report <optimized out> for the one variable you care about. It looks like the debugger is broken; in fact the compiler moved or removed the code. Rebuilding the same commit with -g -O0 usually makes the confusing behavior disappear, and if the bug disappears as well, that itself is strong evidence of undefined behavior or a race rather than a plain logic error.

GDB basics

Installation

# Ubuntu/Debian
sudo apt install gdb

# macOS (LLDB is often preferred)
brew install gdb

# Windows (MinGW)
# gdb is included with MinGW

Compile and run

# Include debug info (-g)
g++ -g program.cpp -o program

# Disable optimization (easier debugging)
g++ -g -O0 program.cpp -o program

# Or debug-friendly optimization
g++ -g -Og program.cpp -o program

# Start GDB
gdb ./program

# Run the program
(gdb) run

# Run with arguments
(gdb) run arg1 arg2

Key ideas:

  • -g: embeds debug info (symbols, line numbers, types) in DWARF format. It does not change the generated code, so -g -O2 is a valid combination for release builds you may need to analyze later.
  • -O0: no optimization (variables stay in memory and every line maps to real instructions)
  • -Og: optimizations that do not interfere with debugging; a reasonable default for day-to-day development builds

On macOS, Homebrew’s gdb must be code-signed before it is allowed to control other processes, and on Apple Silicon it is generally not usable at all; LLDB, which ships with Xcode, is the practical choice there. The commands map closely (b, bt, p, n, s work in both).


Essential commands

Execution control

# Run the program
(gdb) run                  # from the start
(gdb) run arg1 arg2        # with arguments
(gdb) continue (c)         # continue to next breakpoint
(gdb) next (n)             # next line (step over calls)
(gdb) step (s)             # next line (step into calls)
(gdb) finish               # run until current function returns
(gdb) until                # run until end of current loop
(gdb) quit (q)             # exit GDB

Breakpoints

# Set breakpoints
(gdb) break main                # at a function
(gdb) break file.cpp:42         # at file:line
(gdb) break MyClass::method     # at a method
(gdb) break +5                  # five lines ahead of current position

# Conditional breakpoints
(gdb) break factorial if n == 3
(gdb) condition 1 x > 100       # add condition to breakpoint 1

# Manage breakpoints
(gdb) info breakpoints          # list breakpoints
(gdb) delete 1                  # delete breakpoint 1
(gdb) delete                    # delete all breakpoints
(gdb) disable 1                 # disable breakpoint 1
(gdb) enable 1                  # enable breakpoint 1

next and step differ only at a line that calls a function: next runs the call to completion, step enters it. In C++ step frequently lands you inside std::vector::operator[] or a smart pointer’s operator->. Use finish to get back out, or tell GDB to never step into library code with skip -gfi /usr/include/c++/*/*.

For overloaded functions and templates, break MyClass::method sets one breakpoint with several locations, one per overload or instantiation; info breakpoints shows them as 1.1, 1.2 and so on. If a breakpoint is reported as pending, the symbol is in a shared library that has not been loaded yet, and GDB will resolve it when the library loads. Conditions are evaluated every time the location is hit, so a condition on a hot loop can make the program run noticeably slower under the debugger.

Inspecting variables

# Print variables
# Example session
(gdb) print var                 # value
(gdb) print &var                # address
(gdb) print *ptr                # pointed-to value
(gdb) print arr[0]              # array element
(gdb) print obj.member          # member

# Auto-print on each stop
(gdb) display var               # enable auto-print
(gdb) undisplay 1               # remove auto-print

# Inspect state
(gdb) info locals               # local variables
(gdb) info args                 # function arguments
(gdb) info variables            # static/global file scope (where available)

print evaluates C++ expressions, including member access and simple function calls, in the context of the currently selected frame. Standard library containers are readable (print vec shows the elements rather than three internal pointers) only because GDB loads the libstdc++ pretty-printers; if you see _M_impl and _M_start instead, those printers are not installed or not loaded for your toolchain. Calling functions from print (for example print vec.size()) can fail with “Cannot evaluate function — may be inlined” when the compiler never emitted an out-of-line copy of that template member.

Stack traces

# Stack traces
(gdb) backtrace (bt)            # full stack
(gdb) backtrace 5               # last five frames
(gdb) frame 0                   # select frame 0
(gdb) up                        # toward caller
(gdb) down                      # toward callee
(gdb) info frame                # current frame info

Frame 0 is always the innermost function, where execution stopped. When a crash happens inside the standard library or libc, frame 0 is rarely your bug; move up until you reach the first frame in your own code and inspect the arguments you passed. info locals and print always refer to the selected frame, which is why printing a variable “that clearly exists” sometimes fails: you are still looking at a different frame.


Hands-on examples

Example 1: Basic debugging

A minimal factorial example.

// program.cpp
#include <iostream>

int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

int main() {
    int result = factorial(5);
    std::cout << "Result: " << result << std::endl;
    return 0;
}

GDB session:

# Compile
$ g++ -g program.cpp -o program

# Start GDB
$ gdb ./program

# Set a breakpoint
(gdb) break factorial
Breakpoint 1 at 0x1189: file program.cpp, line 5.

# Run
(gdb) run
Starting program: ./program
Breakpoint 1, factorial (n=5) at program.cpp:5

# Inspect a variable
(gdb) print n
$1 = 5

# Continue to next hit
(gdb) continue
Breakpoint 1, factorial (n=4) at program.cpp:5

# Stack trace
(gdb) backtrace
#0  factorial (n=4) at program.cpp:5
#1  0x0000555555555195 in factorial (n=5) at program.cpp:6
#2  0x00005555555551b5 in main () at program.cpp:10

# Quit
(gdb) quit

Example 2: Conditional breakpoints

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    
    for (int i = 0; i < numbers.size(); ++i) {
        int value = numbers[i] * 2;
        std::cout << value << std::endl;
    }
    
    return 0;
}

GDB session:

# Break on the std::cout line (line 9) only when i == 5
(gdb) break 9 if i == 5
(gdb) run
# Stops only when i is 5

# Verify
(gdb) print i
$1 = 5
(gdb) print value
$2 = 12

The breakpoint sits on the output line rather than on the for line, because at that point value has already been computed for the current iteration. If you put it on the line that declares value, the stop happens before the assignment and print value shows whatever was left in that stack slot from the previous iteration, which is a common source of confusion.

Example 3: Watchpoints

#include <iostream>

int main() {
    int counter = 0;
    
    for (int i = 0; i < 10; ++i) {
        counter += i;
        if (counter > 20) {
            counter = 0;  // bug: reset here
        }
    }
    
    std::cout << "Final: " << counter << std::endl;
    return 0;
}

GDB session:

# Stop when counter changes
(gdb) watch counter
(gdb) run

# GDB stops on each change
Hardware watchpoint 2: counter
Old value = 0
New value = 1

# Continue and observe changes
(gdb) continue

A “hardware watchpoint” uses the CPU’s debug registers, so the program runs at full speed until the watched bytes change. x86-64 has only four such registers, and each covers at most 8 bytes; watch a large struct or too many variables and GDB falls back to a software watchpoint that single-steps the program and is very slow. A watchpoint on a local variable is deleted automatically when its frame exits. For heap memory that gets corrupted from somewhere unknown, the effective trick is to print the address first and watch the dereferenced address, watch *(int*)0x55555556aeb0, so the watchpoint survives scope changes.

Example 4: Core dump analysis

#include <iostream>

void crash() {
    int* ptr = nullptr;
    *ptr = 42;  // Segmentation fault!
}

int main() {
    crash();
    return 0;
}

Analyzing a core dump:

# Allow core files
$ ulimit -c unlimited

# Run
$ ./program
Segmentation fault (core dumped)

# Open the core in GDB
$ gdb ./program core

# Where did it crash?
(gdb) backtrace
#0  0x0000555555555189 in crash () at program.cpp:5
#1  0x00005555555551a5 in main () at program.cpp:9

# Inspect the crashing frame
(gdb) frame 0
#0  0x0000555555555189 in crash () at program.cpp:5
5           *ptr = 42;

# Inspect variables
(gdb) print ptr
$1 = (int *) 0x0

On many current distributions the core file does not appear in the working directory at all. cat /proc/sys/kernel/core_pattern tells you where it goes; if it starts with |/usr/lib/systemd/systemd-coredump, use coredumpctl list and coredumpctl gdb <pid> instead of looking for a file named core. The core must be opened with the exact binary that produced it. A rebuilt binary, even from the same source, has different addresses, and GDB then prints backtraces that look plausible but point at the wrong lines. Keeping the unstripped binary (or its split debug file) for every build you ship is what makes post-mortem analysis possible later.


Advanced features

Memory inspection

# Examine memory (x = examine)
(gdb) x/10x address     # 10 words in hex
(gdb) x/10d address     # 10 words in decimal
(gdb) x/10c address     # 10 bytes as characters
(gdb) x/s address       # null-terminated string
(gdb) x/10i address     # 10 instructions (disassembly)

# Example
(gdb) print &var
$1 = (int *) 0x7fffffffe3fc
(gdb) x/4x 0x7fffffffe3fc
0x7fffffffe3fc: 0x0000000a 0x00000000 0xf7dc2620 0x00007fff

Type information

# Types
(gdb) ptype var         # detailed type
(gdb) whatis var        # simple type

# Example
(gdb) ptype std::vector<int>
type = class std::vector<int, std::allocator<int>> {
  ...
}

Multithreaded debugging

# List threads
(gdb) info threads
  Id   Target Id         Frame
* 1    Thread 0x7ffff7fc0740 (LWP 12345) main () at main.cpp:10
  2    Thread 0x7ffff6fbf700 (LWP 12346) worker () at worker.cpp:5

# Switch thread
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6fbf700)]

# Backtrace for current thread
(gdb) backtrace

# Backtrace for all threads
(gdb) thread apply all backtrace

Reverse debugging

# Start recording
(gdb) record
(gdb) continue

# Execute backward
(gdb) reverse-step
(gdb) reverse-next
(gdb) reverse-continue
(gdb) reverse-finish

GDB’s built-in record works by logging every executed instruction, which makes the program run orders of magnitude slower and fails on some instructions (AVX code in modern libc is a common reason for “Process record does not support instruction” errors). It is fine for a short window around a bug: break just before the suspicious region, record, then continue to the failure and step backwards. For long-running programs, the rr recorder is the more practical tool; it records a whole run with low overhead and then lets you replay it under GDB, including reverse execution, as many times as you like.

By default, when one thread hits a breakpoint GDB stops all threads (all-stop mode), and next may let other threads run while you step. If another thread keeps hitting the same breakpoint and “steals” your stepping, set scheduler-locking step keeps the other threads paused while you step the current one. For deadlocks, attach to the hung process with gdb -p <pid> and run thread apply all bt; two threads each waiting in pthread_mutex_lock (or __lll_lock_wait) from different call paths is the classic signature of lock-order inversion.


Common problems

Problem 1: No debug info

# ❌ No debug info
$ g++ program.cpp -o program
$ gdb ./program
(gdb) list
No symbol table is loaded.

# ✅ Add -g
$ g++ -g program.cpp -o program
$ gdb ./program
(gdb) list
1       #include <iostream>
2
3       int factorial(int n) {
...

Fix: Always compile with -g when you need source-level debugging.

Problem 2: Variables optimized out

Example main:

#include <iostream>

int main() {
    int x = 10;
    int y = x * 2;
    int z = y + 5;
    std::cout << z << std::endl;
    return 0;
}
# ❌ -O3
$ g++ -g -O3 program.cpp -o program
$ gdb ./program
(gdb) break main
(gdb) run
(gdb) print x
$1 = <optimized out>

# ✅ -O0 or -Og
$ g++ -g -O0 program.cpp -o program
$ gdb ./program
(gdb) print x
$1 = 10

Fix: Use -O0 or -Og while debugging.

Problem 3: Stripped symbols

# ❌ After strip
$ strip program
$ gdb ./program
(gdb) break main
Function "main" not defined.

# ✅ Do not strip debug builds
# Or keep a separate symbol file
$ objcopy --only-keep-debug program program.debug
$ strip program
$ objcopy --add-gnu-debuglink=program.debug program

Fix: Do not strip binaries you still need to debug—or keep split debug info.

Problem 4: Multithreaded debugging

#include <iostream>
#include <thread>

void worker(int id) {
    for (int i = 0; i < 5; ++i) {
        std::cout << "Thread " << id << ": " << i << std::endl;
    }
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    
    t1.join();
    t2.join();
    
    return 0;
}

GDB session:

(gdb) break worker
(gdb) run
[New Thread 0x7ffff6fbf700 (LWP 12346)]
Thread 2 "program" hit Breakpoint 1, worker (id=1) at program.cpp:5

(gdb) info threads
  Id   Target Id         Frame
* 2    Thread 0x7ffff6fbf700 (LWP 12346) worker (id=1) at program.cpp:5
  1    Thread 0x7ffff7fc0740 (LWP 12345) 0x00007ffff7bc0a9d in __pthread_join

(gdb) thread 1
(gdb) backtrace

TUI mode

TUI (Text User Interface) shows source in the terminal alongside GDB.

# Start in TUI
$ gdb -tui ./program

# Or toggle while running
(gdb) tui enable
(gdb) tui disable

# Layouts
(gdb) layout src        # source
(gdb) layout asm        # disassembly
(gdb) layout split      # source + asm
(gdb) layout regs       # registers + source

# Window focus / refresh
Ctrl+X, A               # toggle TUI
Ctrl+X, O               # next window
Ctrl+L                  # refresh screen

Practical example: finding a bug

#include <iostream>
#include <vector>

double average(const std::vector<int>& numbers) {
    int sum = 0;
    for (int num : numbers) {
        sum += num;
    }
    return sum / numbers.size();  // bug: integer division!
}

int main() {
    std::vector<int> scores = {85, 92, 78, 95, 88};
    double avg = average(scores);
    std::cout << "Average: " << avg << std::endl;
    return 0;
}

Finding the bug with GDB:

# Build and run
$ g++ -g bug.cpp -o bug
$ ./bug
Average: 87  # expected: 87.6

$ gdb ./bug

(gdb) break average
(gdb) run
Breakpoint 1, average (numbers=...) at bug.cpp:5

(gdb) next
(gdb) next
...
(gdb) print sum
$1 = 438

(gdb) print numbers.size()
$2 = 5

(gdb) ptype sum
type = int
(gdb) ptype numbers.size()
type = std::size_t

# Issue: int / size_t promotes to integer division
# Fix: static_cast<double>(sum) / numbers.size()

GDB command cheat sheet

CategoryCommandDescription
RunrunStart the program
continue (c)Continue to next breakpoint
next (n)Next line (step over)
step (s)Next line (step into)
finishUntil current function returns
BreakpointsbreakSet a breakpoint
watchSet a watchpoint
info breakpointsList breakpoints
deleteDelete breakpoints
InspectprintPrint an expression
displayAuto-print each stop
info localsLocal variables
backtrace (bt)Call stack
MemoryxExamine memory
ptypeType information
Threadsinfo threadsList threads
threadSwitch thread

GDB, sanitizers or Valgrind

GDB tells you what the program state is at a moment you choose. It does not tell you when memory first went wrong. For use-after-free, buffer overflows and leaks, a build with AddressSanitizer usually finds the root cause faster, because it stops at the first invalid access instead of at the later crash; Valgrind does the same without recompiling, at a much higher runtime cost. A typical sequence is: sanitizer build to find the bad access, then GDB with a breakpoint or watchpoint on that location to understand how the program got there. You can combine them too: run the ASan binary under GDB with ASAN_OPTIONS=abort_on_error=1, and the report ends in SIGABRT, which GDB catches with the faulting stack still live and inspectable.

Commands you repeat every session belong in a .gdbinit file or in a script passed with gdb -x cmds.gdb ./program. Batch mode, gdb -batch -ex run -ex bt ./program, is useful in CI to print a backtrace automatically when a test binary crashes.

Next steps



Frequently Asked Questions (FAQ)

Q. Why does GDB show "" or jump between lines unpredictably while stepping?

A. The binary was built with optimization, so the compiler kept variables only in registers, reordered code or inlined functions, and the debug info can no longer map every variable and line exactly. Rebuild with -g -O0 (or -Og, which keeps only optimizations that do not hurt debugging) when you need to step through logic. If a crash only reproduces in the optimized build, keep the optimization level but add -g so backtraces and core dumps still have symbols.