Debugging with GDB

A debugger turns a program into something that can be stopped and inspected. The compiler writes the variable names and the line numbers into the binary when it is built with -g, and GDB reads them back: break stops at a line, print shows a value, next runs one line.

The batch form used here (-batch -ex ...) runs the commands from the shell and exits, which fits a terminal and a script. Lesson 003 used it for a first look; this lesson uses it on a crash and on a loop.

The debugger is a tool for the question the compiler and the tests cannot answer: what were the values at this moment. A backtrace from a crash and one printed variable often replace an hour of adding output statements.

Build with symbols

The -g flag keeps the source in the binary: line numbers, function names, variable names and their types. Without it the debugger can still stop at addresses, which is rarely useful.

The program sums 1 to a limit that comes from the command line, or 5 when there is none. It is the subject of the next four sections, compiled once with -g and run once normally.

-O2 and -g can be used together, and the optimiser then removes variables and reorders lines, which makes stepping harder. Use -O0 while debugging, as lesson 102 explains.

#include <iostream> #include <string> int sum_to(int limit) { int total = 0; for (int value = 1; value <= limit; ++value) { total += value; } return total; } int main(int argc, char** argv) { int limit = argc > 1 ? std::stoi(argv[1]) : 5; std::cout << sum_to(limit) << '\n'; }

g++ -g -std=c++23 -Wall -Wextra sum.cpp -o sum
./sum 4

10

A breakpoint

break sum_to stops at the first line of the function, run starts the program, and print shows a variable. next runs the current line and stops at the next one, which is how a loop is walked one iteration at a time.

The transcript prints limit as 5 and total as 0 before the loop runs. The breakpoint address changes with every build and every run, because the executable is position independent.

The -ex options run in order and the program stops at the first breakpoint. A second continue would run to the next hit, and finish would run to the end of the function.

#include <iostream> #include <string> int sum_to(int limit) { int total = 0; for (int value = 1; value <= limit; ++value) { total += value; } return total; } int main(int argc, char** argv) { int limit = argc > 1 ? std::stoi(argv[1]) : 5; std::cout << sum_to(limit) << '\n'; }

g++ -g -std=c++23 -Wall -Wextra sum.cpp -o sum
gdb -q -batch -ex 'set disable-randomization off' -ex 'break sum_to' -ex run -ex 'print limit' -ex next -ex 'print total' ./sum

Breakpoint 1 at 0x227c: file sum.cpp, line 6.

Breakpoint 1, sum_to (limit=5) at sum.cpp:6
6 int total = 0;
$1 = 5
7 for (int value = 1; value <= limit; ++value) {
$2 = 0

A crash and its backtrace

A segmentation fault is a signal, and GDB stops when it arrives. bt prints the call stack: the frame where the program died comes first, and the frames below it are the callers.

The program writes through a null pointer. The backtrace names main and the line, and the address of the fault is printed with the signal.

The addresses and the process id change on every run; the function names and the line do not. That is why a backtrace from a user is worth more than the sentence "it crashed".

#include <iostream> int main() { int* pointer = nullptr; std::cout << "before the crash\n"; std::cout << *pointer << '\n'; }

g++ -g -std=c++23 -Wall -Wextra crash.cpp -o crash
gdb -q -batch -ex 'set disable-randomization off' -ex run -ex 'bt 3' ./crash

before the crash

Program received signal SIGSEGV, Segmentation fault.
0x00006285150321d2 in main () at crash.cpp:8
8 std::cout << *pointer << '\n';
#0 0x00006285150321d2 in main () at crash.cpp:8

Arguments and argv

set args passes the command line to the program that GDB will start. Breaking at main shows argc and argv before any of the program's own code runs.

The transcript prints argc as 2 and argv[1] as the string "7". The pointer value changes per run, and the string it points at is what the program reads.

The alternative is gdb --args ./sum 7, which sets the arguments and starts the debugger in one command. Both forms are useful, and the second is shorter when the debugger is started from a shell.

#include <iostream> #include <string> int sum_to(int limit) { int total = 0; for (int value = 1; value <= limit; ++value) { total += value; } return total; } int main(int argc, char** argv) { int limit = argc > 1 ? std::stoi(argv[1]) : 5; std::cout << sum_to(limit) << '\n'; }

g++ -g -std=c++23 -Wall -Wextra sum.cpp -o sum
gdb -q -batch -ex 'set disable-randomization off' -ex 'set args 7' -ex 'break main' -ex run -ex 'print argc' -ex 'print argv[1]' ./sum

Breakpoint 1 at 0x22b7: file sum.cpp, line 14.

Breakpoint 1, main (argc=2, argv=0x7ffc022b2668) at sum.cpp:14
14 {
$1 = 2
$2 = 0x7ffc022b3e16 "7"

Walking the loop

Two next steps enter the loop, and info locals prints every local variable of the current frame with its value. The transcript shows value at 1 and total at 0, which is the state after the first iteration started.

Stepping is slower than reading and faster than guessing. When a variable has the wrong value, the question is which line wrote it: a watchpoint on the variable stops the program at the write, which is the next step up from clicking next.

The compiler can remove a variable at -O2, and print then answers that it has no such symbol. That answer is about the build, not about the program.

#include <iostream> #include <string> int sum_to(int limit) { int total = 0; for (int value = 1; value <= limit; ++value) { total += value; } return total; } int main(int argc, char** argv) { int limit = argc > 1 ? std::stoi(argv[1]) : 5; std::cout << sum_to(limit) << '\n'; }

g++ -g -std=c++23 -Wall -Wextra sum.cpp -o sum
gdb -q -batch -ex 'set disable-randomization off' -ex 'break sum_to' -ex run -ex next -ex next -ex 'info locals' -ex 'print total' ./sum

Breakpoint 1 at 0x227c: file sum.cpp, line 6.

Breakpoint 1, sum_to (limit=5) at sum.cpp:6
6 int total = 0;
7 for (int value = 1; value <= limit; ++value) {
8 total += value;
value = 1
total = 0
$1 = 0

Exercises

  1. Run gdb on sum with a breakpoint at main and print argv[0] and argv[1].
  2. Introduce a wrong initial value for total and find the line with a watchpoint.
  3. Run the crash program without the debugger and compare the one-line shell message with the backtrace.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does -g add to the binary?

    Answer

    Debug symbols with names and line numbers

    GDB reads the symbols to show variables and source lines.

  2. What does break sum_to do?

    Answer

    It stops the program when the function is entered

    The breakpoint stops execution at the function's first instruction.

  3. What does next do at a breakpoint?

    Answer

    It runs one source line

    next steps over a call, while step steps into it.

  4. What does bt print?

    Answer

    The call stack

    The backtrace lists the frames from the crash or the breakpoint outward.

  5. This program is built with g++ -g and run under gdb -batch -ex 'break sum_to' -ex run -ex 'print limit'. What does the print show? int sum_to(int limit) { int total = 0; for (int i = 1; i <= limit; ++i) total += i; return total; } int main() { return sum_to(5); }

    Answer

    $1 = 5

    The breakpoint stops at the entry of the function, where the parameter already holds the argument 5.

  6. This program is built with g++ -g and debugged with gdb -q -batch -ex 'break sum_to' -ex run -ex next -ex 'print total' ./q. What does the print show? #include <iostream> int sum_to(int limit) { int total = 0; for (int i = 1; i <= limit; ++i) total += i; return total; } int main() { std::cout << sum_to(5); }

    Answer

    $1 = 0

    The breakpoint stops at the entry of the function, and next runs the line that initialises total to zero; the loop has not run yet.

  7. This program is run under gdb -batch -ex run -ex 'bt 3'. What does GDB report? int main() { int* pointer = nullptr; std::cout << *pointer; }

    Answer

    SIGSEGV and the line of the dereference in main

    The null dereference faults, and the backtrace names main and the source line.

  8. GDB is started with -ex 'set args 7' -ex 'break main' -ex run. The program reads argc and argv. What does print argc show? int main(int argc, char** argv)

    Answer

    2, the program name and the one argument

    argc counts the program name and the argument 7, so it is 2.

  9. Why is the program built with -g before the GDB session?

    Answer

    So the debugger can show names and source lines

    Without symbols GDB shows addresses instead of variables and lines.

  10. What does -batch do?

    Answer

    It runs the commands and exits without the prompt

    The flag makes GDB usable from a script.

The code on this page was built with g++ 15.2.0 and GDB 16.3 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev