EthicsComputerCourses
Join training +91 90532 10052

Debugging with GDB & LLDB

C++ · Lesson 43 of 46 Advanced 8 min read Updated

When a program gives the wrong answer, beginners add std::println lines everywhere, recompile and guess. A debugger is faster: it pauses the running program at any line, shows the value of every variable, and lets you step through the code one line at a time. When a program crashes, the debugger shows exactly which line and which chain of function calls caused it.

On Linux the standard debugger is GDB (from the GNU project); on macOS it is LLDB (from LLVM), which also works on Linux. On Windows, Visual Studio has its own debugger. The ideas are the same everywhere, so learning one makes the others easy.

Build for debugging

A debugger needs debug information: a map from machine code back to your files, lines and variable names. Add -g when compiling, and turn optimisation off with -O0 (or use the debugger-friendly -Og) so that variables are not optimised away. In CMake, a Debug build type does this for you.

Here is a program that calculates strike rates for Kabir's last three innings. The answers are wrong.

C++
#include <cstddef>
#include <print>
#include <vector>

double strike_rate(int runs, int balls) {
    return runs / balls * 100;
}

int main() {
    std::vector<int> runs  = {45, 12, 88};
    std::vector<int> balls = {30, 20, 50};
    for (std::size_t i = 0; i < runs.size(); ++i) {
        std::println("Innings {}: strike rate {}", i + 1, strike_rate(runs[i], balls[i]));
    }
}
Terminal
g++ -std=c++23 -g -O0 cricket.cpp -o cricket
./cricket
Output
Innings 1: strike rate 100
Innings 2: strike rate 0
Innings 3: strike rate 100

45 runs from 30 balls should be 150, not 100. Let us find out why without adding a single print statement.

A first GDB session

Start GDB with the program's name, set a breakpoint (a place to pause) on the function, and run. The text after (gdb) is what you type.

Text
$ gdb ./cricket
(gdb) break strike_rate
Breakpoint 1 at 0x...: file cricket.cpp, line 6.
(gdb) run
Starting program: /home/kabir/cricket

Breakpoint 1, strike_rate (runs=45, balls=30) at cricket.cpp:6
6           return runs / balls * 100;
(gdb) print runs / balls
$1 = 1
(gdb) print runs * 100.0 / balls
$2 = 150

The program stopped before running line 6, and GDB showed the arguments. print evaluates any expression using the current variables. runs / balls is 1, not 1.5: both are int, so this is integer division, which throws away the fraction before the multiplication. The fix is to compute in double: return 100.0 * runs / balls;.

You can also see how you got here and look at the caller's variables:

Text
(gdb) backtrace
#0  strike_rate (runs=45, balls=30) at cricket.cpp:6
#1  0x... in main () at cricket.cpp:13
(gdb) up
#1  0x... in main () at cricket.cpp:13
13              std::println("Innings {}: strike rate {}", i + 1, strike_rate(runs[i], balls[i]));
(gdb) print i
$3 = 0
(gdb) print runs
$4 = std::vector of length 3, capacity 3 = {45, 12, 88}
(gdb) continue
Continuing.
Innings 1: strike rate 100

Breakpoint 1, strike_rate (runs=12, balls=20) at cricket.cpp:6
6           return runs / balls * 100;
(gdb) quit

The backtrace (call stack) lists the active function calls, newest first. up moves to the caller's frame, where i and the vectors live. GDB prints standard containers such as std::vector readably thanks to the library's pretty printers. Addresses (0x...) and paths differ on your machine.

The commands you will use every day

TaskGDBLLDB
Break at a functionbreak strike_rateb strike_rate
Break at a linebreak cricket.cpp:13b cricket.cpp:13
Start the programrunrun
Next line (step over calls)next (n)next (n)
Go into a function callstep (s)step (s)
Run until this function returnsfinishfinish
Continue to the next breakpointcontinue (c)continue (c)
Print a valueprint x (p x)p x, or v x for a variable
All local variablesinfo localsframe variable (v)
Call stackbacktrace (bt)bt
Move to the callerupup
Stop when a variable changeswatch totalwatchpoint set variable total
List and delete breakpointsinfo breakpoints, delete 1breakpoint list, breakpoint delete 1

The same session in LLDB looks very similar:

Text
$ lldb ./cricket
(lldb) b strike_rate
(lldb) run
(lldb) v runs balls
(int) runs = 45
(int) balls = 30
(lldb) p runs * 100.0 / balls
(double) 150

LLDB prints the type in brackets before each value (some versions also show a result name such as $0). Its other messages (process number, stop reason) differ from GDB's but carry the same information.

Conditional breakpoints and watchpoints

Pausing on every loop iteration gets boring when the bug appears only on the 900th. A conditional breakpoint pauses only when an expression is true:

Text
(gdb) break cricket.cpp:13 if i == 2
(gdb) run

A watchpoint pauses whenever a variable's value changes, which is perfect for the question "who is changing this?". Set it while the variable is in scope, for example watch total inside the function that owns total. GDB then shows the old value, the new value and the line that changed it.

Finding where a program crashes

A crash such as a segmentation fault (an access to memory the program does not own) usually prints nothing useful by itself. Run the program inside the debugger instead: it stops at the crashing line, and backtrace shows how you got there.

C++
#include <print>
#include <string>

struct Student {
    std::string name;
};

int main() {
    Student* topper = nullptr;           // the lookup "forgot" to find anyone
    std::println("{}", topper->name);    // null pointer dereference
}
Text
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
...
(gdb) backtrace
...
#1  0x... in main () at topper.cpp:10

The exact top frames differ because the crash happens inside library code that reads the string; look for the first frame in your file (here topper.cpp:10). On Linux, a crashed program can also leave a core dump file if you enable it with ulimit -c unlimited; gdb ./program core then opens the program exactly as it was when it crashed, which is how crashes on servers are investigated.

Debugging in VS Code and Visual Studio

Most developers debug inside their editor. In VS Code with Microsoft's C/C++ extension, add .vscode/launch.json, click in the margin to set a breakpoint (a red dot), and press F5:

JSON
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug cricket",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/cricket",
      "cwd": "${workspaceFolder}",
      "MIMode": "gdb"
    }
  ]
}

VS Code drives GDB (or LLDB with "MIMode": "lldb") for you and shows variables, the call stack and watch expressions in side panels. On Windows with MSVC, Visual Studio's debugger uses the same ideas with keys: F9 sets a breakpoint, F5 starts or continues, F10 steps over and F11 steps into. Build with /Zi (or the Debug configuration) to get debug information.

Common mistakes

  • Debugging an optimised build. At -O2, variables show as <optimized out> and lines jump around. Rebuild with -g -O0 or -Og.
  • Forgetting -g. Without it the debugger shows addresses instead of file names and lines.
  • Running an old executable. Rebuild after every change; the debugger loads whatever file is on disk.
  • Stepping into library code by accident. Use next to step over calls to the standard library and finish to get back out.
  • Stopping at the crash line only. Read the whole backtrace; the real mistake is often in a caller.

Key points

  • Compile with -g -O0 (or -Og) so the debugger can map machine code back to your source.
  • Breakpoints pause the program; next, step, finish and continue control it; print evaluates expressions.
  • backtrace shows the chain of calls, and up lets you inspect the caller's variables.
  • Conditional breakpoints and watchpoints find bugs that appear only sometimes or in unknown places.
  • GDB and LLDB use almost the same commands, and editors such as VS Code and Visual Studio drive them for you.

Exercise

This function should return the total runs scored in the first n innings, but first_n_total({45, 12, 88}, 2) returns 145 instead of 57. Explain how you would find the bug with GDB (which breakpoint, which commands), then fix it.

int first_n_total(const std::vector<int>& runs, std::size_t n) { int total = 0; for (std::size_t i = 0; i <= n; ++i) total += runs[i]; return total; }

Show solution

Set break first_n_total, run, then use next repeatedly and print i and print total after each step (or watch total). You see the loop body run for i equal to 0, 1 and 2: three innings instead of two, so <= should be <.

C++
#include <cstddef>
#include <print>
#include <vector>

int first_n_total(const std::vector<int>& runs, std::size_t n) {
    int total = 0;
    for (std::size_t i = 0; i < n && i < runs.size(); ++i) {
        total += runs[i];
    }
    return total;
}

int main() {
    std::println("{}", first_n_total({45, 12, 88}, 2));
    std::println("{}", first_n_total({45, 12, 88}, 10));
}
Output
57
145

The extra i < runs.size() check also stops the loop from reading past the end when n is larger than the number of innings.

Quick Quiz

1. Which flags make a build easiest to debug?

2. In GDB, what is the difference between next and step?

3. What does backtrace show?

4. You need to find which line changes total to a wrong value. Which tool fits best?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call