写 C++ 的人,大多数时候靠 printf / std::cout 调试:加一行输出、重新编译、跑一遍、再加一行……程序小的时候够用,程序一大、一涉及指针和多线程,这招就开始力不从心。调试器能做到的事情多得多:让程序停在任意一行、一行一行往下走、钻进函数里看、看函数返回了什么、随时打印甚至修改变量、在变量被改的一瞬间停下来、在程序崩溃时保留现场。

这个系列就专门讲这件事:用 GDB 和 LLDB 调试 C++ 程序。每一篇都配一段可以直接编译的小程序,所有命令和输出都在真实环境里跑过。

这是「C++ 调试实战」系列的第 0 篇。本篇不讲具体命令的细节,只做三件事:搞清楚编译时要加什么选项、把调试器装好、跑通第一次调试会话。

系列导航

篇 主题 你会学到
00 调试前的准备(本篇) -g / -O0、GDB 与 LLDB 的安装、第一次调试会话
01 断点 按行 / 按函数下断点、条件断点、临时断点、启用 / 禁用 / 删除
02 运行与单步 run / continue / next / step / finish / until,看函数返回值
03 查看与修改变量 print、格式化输出、STL 容器、display、修改变量、调用函数、看内存
04 调用栈与栈帧 backtrace、切换栈帧、递归调试
05 观察点 变量一被修改就停下:watch / rwatch / awatch
06 崩溃现场分析 段错误、core dump、AddressSanitizer
07 多线程调试 线程切换、attach 到卡死进程定位死锁、ThreadSanitizer
08 IDE 图形化调试与速查表 VS Code 调试配置、GDB / LLDB 命令对照总表

如果你只想先学会最常用的那几招——下断点、运行到断点、单步、步入函数、跑到函数返回、打印变量——读 00~03 这四篇就够了。

一、调试器到底在做什么?

先建立一个直觉。调试器(debugger)本身是一个普通程序,它借助操作系统提供的能力(Linux 上是 ptrace,macOS 上是 Mach 异常端口和 debugserver)去控制另一个进程:

  • 暂停和继续被调试的进程;
  • 读写它的内存和寄存器;
  • 在某条指令上打断点——本质是把那条指令临时替换成一条「陷入」指令(x86 上是 int3,ARM64 上是 brk),程序执行到这里就会停下来,把控制权交还给调试器。

但机器只认地址和寄存器,你想说的是「停在 orders.cpp 第 12 行」「打印变量 total」。把源码行号、变量名、类型翻译成地址和内存布局的,是编译器生成的调试信息(Linux / macOS 上的格式叫 DWARF)。没有调试信息,调试器就只能看汇编。

所以调试的前提只有一条:编译时生成调试信息,并且别让优化把代码改得面目全非。

二、编译选项:-g 与 -O0

2.1 最常用的组合

1
2
g++     -std=c++20 -g -O0 orders.cpp -o orders    # Linux / GCC
clang++ -std=c++20 -g -O0 orders.cpp -o orders # macOS / Clang
选项 作用
-g 生成调试信息(行号表、变量名、类型)
-O0 关闭优化,每一行源码老老实实对应一段机器码,变量都在内存里
-Og GCC 提供的「对调试友好的优化」,比 -O0 快,但偶尔仍会出现变量看不到的情况

用 CMake 的项目,直接选 Debug 构建类型即可,它默认就带 -g(并且不开优化):

1
2
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

2.2 不加 -g 会怎样?

我们用下面第四节的示例程序做个对比。去掉 -g 编译后,GDB 完全不认识源码:

1
2
3
4
5
6
7
8
9
(gdb) break orders.cpp:16
No symbol table is loaded. Use the "file" command.
(gdb) break line_total
Breakpoint 1 at 0x19b0
(gdb) run

Breakpoint 1, 0x0000aaaaaaaa19b0 in line_total(Order const&) ()
(gdb) print total
No symbol "total" in current context.

按文件行号下断点失败;按函数名下断点虽然成功了(函数名在符号表里),但停下来只显示一个地址,没有行号,局部变量也看不了。

2.3 开了 -O2 又会怎样?

加了 -g 但同时开 -O2:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) break line_total
Breakpoint 1 at 0x1180: line_total. (2 locations)
(gdb) run

Breakpoint 1.2, line_total (o=...) at orders.cpp:12
12 double total = o.quantity * o.price;
(gdb) print total
$1 = <optimized out>
(gdb) bt
#0 line_total (o=...) at orders.cpp:13
#1 sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:22
#2 0x0000aaaaaaaa0e64 in main () at orders.cpp:33

三个现象都很典型:

  • 2 locations:line_total 被内联进了 sum_orders,同时保留了一份独立函数体,一个断点落在两个地方;
  • <optimized out>:变量 total 被放进了寄存器,或者干脆被算掉了,调试器找不到它;
  • 调用栈里 #1 没有地址:这一帧是内联展开出来的「虚拟帧」。

优化版程序当然也能调,但学习阶段先统一用 -g -O0,排除干扰。

三、准备调试器

3.1 macOS:LLDB

macOS 上 GDB 需要自己签名、配置很麻烦,直接用系统自带的 LLDB。装好 Xcode Command Line Tools 就有:

1
2
xcode-select --install     # 已装过会提示 already installed
lldb --version

3.2 Linux:GDB

1
2
sudo apt install g++ gdb   # Debian / Ubuntu
gdb --version

LLDB 在 Linux 上也能装(apt install lldb),两者可以混用:GCC 编出来的程序用 LLDB 调、Clang 编出来的用 GDB 调,都没问题。

3.3 在 Docker 容器里用 GDB

容器默认禁止 ptrace,直接在容器里跑 GDB 会看到类似 ptrace: Operation not permitted 或 warning: Error disabling address space randomization 的报错。启动容器时加上两个参数:

1
2
3
4
5
6
docker run --rm -it \
--cap-add=SYS_PTRACE \
--security-opt seccomp=unconfined \
-v "$PWD":/work -w /work \
ubuntu:26.04 bash
# 容器内:apt update && apt install -y g++ gdb
参数 作用
--cap-add=SYS_PTRACE 允许容器内进程使用 ptrace 控制其他进程
--security-opt seccomp=unconfined 放开 seccomp 过滤,GDB 关闭地址随机化(ASLR)需要用到 personality 系统调用

3.4 本系列的实测环境

环境 编译器 调试器
Docker(Ubuntu 26.04,aarch64) GCC 15.2 GNU gdb 17.1
macOS(Apple Silicon) Apple Clang 21 LLDB(Xcode 自带)

文中所有输出都来自这两个环境。你在 x86_64 机器上跑,地址会不一样(例如 GDB 在 aarch64 上关掉 ASLR 后程序加载在 0xaaaaaaaa....,x86_64 上通常是 0x5555555.....),命令和结构完全一致。

四、贯穿全系列的示例程序

前四篇共用一个小程序:计算几笔订单的总价,数量满 10 件打九折。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// orders.cpp
#include <iostream>
#include <string>
#include <vector>

struct Order {
std::string name;
int quantity;
double price;
};

double line_total(const Order& o) {
double total = o.quantity * o.price;
if (o.quantity >= 10) {
total *= 0.9; // 满 10 件打九折
}
return total;
}

double sum_orders(const std::vector<Order>& orders) {
double sum = 0.0;
for (const auto& o : orders) {
sum += line_total(o);
}
return sum;
}

int main() {
std::vector<Order> orders = {
{"apple", 3, 2.5},
{"banana", 12, 1.0},
{"cherry", 5, 4.0},
};
double total = sum_orders(orders);
std::cout << "total = " << total << '\n';
return 0;
}

后文会频繁引用行号,这里标一下关键位置:

行号 代码
11–17 line_total() 函数,12 行计算、14 行打折、16 行 return
19–25 sum_orders() 函数,22 行调用 line_total
27–36 main(),33 行调用 sum_orders

编译运行:

1
2
3
g++ -std=c++20 -g -O0 orders.cpp -o orders
./orders
# total = 38.3

3×2.5 + 12×1.0×0.9 + 5×4.0 = 7.5 + 10.8 + 20 = 38.3,结果正确。后面几篇会刻意制造一些 bug 让调试器来抓。

五、第一次调试会话

目标:停在 line_total 里,往下走一行,看看 total 算出来是多少,然后让程序跑完。

5.1 GDB 版

1
gdb -q ./orders        # -q 不打印版权信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(gdb) break line_total
Breakpoint 1 at 0x19b0: file orders.cpp, line 12.
(gdb) run

Breakpoint 1, line_total (o=...) at orders.cpp:12
12 double total = o.quantity * o.price;
(gdb) next
13 if (o.quantity >= 10) {
(gdb) print total
$1 = 7.5
(gdb) continue

Breakpoint 1, line_total (o=...) at orders.cpp:12
12 double total = o.quantity * o.price;
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000aaaaaaaa19b0 in line_total(Order const&) at orders.cpp:12
breakpoint already hit 2 times
(gdb) delete 1
(gdb) continue
total = 38.3
[Inferior 1 (process 16) exited normally]
(gdb) quit

逐条解读:

  1. break line_total:在函数 line_total 入口下断点,GDB 告诉你它落在 orders.cpp 第 12 行;
  2. run:启动程序,程序跑到断点停下,显示即将执行的那一行(第 12 行还没执行);
  3. next:执行完第 12 行,停在第 13 行;
  4. print total:此时 total 已经算好,是 3 × 2.5 = 7.5;
  5. continue:继续运行,第二次调用 line_total 时又停下;
  6. info breakpoints:查看断点列表,能看到已经命中 2 次;
  7. delete 1 删除断点,continue 让程序一口气跑完,正常退出。

5.2 LLDB 版

1
2
clang++ -std=c++20 -g -O0 orders.cpp -o orders.mac
lldb ./orders.mac
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(lldb) b line_total
Breakpoint 1: where = orders.mac`line_total(Order const&) + 8 at orders.cpp:12:20, address = 0x00000001000004f0
(lldb) run
Process 9362 launched: '/tmp/cppdebug/orders.mac' (arm64)
Process 9362 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x00000001000004f0 orders.mac`line_total(o=0x0000000100611740) at orders.cpp:12:20
11 double line_total(const Order& o) {
-> 12 double total = o.quantity * o.price;
^
13 if (o.quantity >= 10) {
(lldb) next
Process 9362 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100000514 orders.mac`line_total(o=0x0000000100611740) at orders.cpp:13:9
12 double total = o.quantity * o.price;
-> 13 if (o.quantity >= 10) {
^
14 total *= 0.9; // 满 10 件打九折
(lldb) p total
(double) 7.5
(lldb) breakpoint delete 1
1 breakpoints deleted; 0 breakpoint locations disabled.
(lldb) continue
total = 38.3
Process 9362 exited with status = 0 (0x00000000)
(lldb) quit

说明:macOS 上的可执行文件在文中统一加 .mac 后缀,以便和 Linux 版区分。LLDB 默认在停下时显示前后各 3 行源码,为了节省篇幅,本系列的 LLDB 输出统一用 settings set stop-line-count-before 1 和 settings set stop-line-count-after 1 调成了前后各 1 行(这两行设置命令不再重复展示)。

LLDB 的输出更啰嗦,但信息也更多:

  • -> 12 箭头指向即将执行的行,^ 精确到列(第 20 列,也就是 o.quantity * o.price 表达式的开始);
  • stop reason 告诉你为什么停下:breakpoint 1.1(断点 1 的第 1 个位置)、step over(单步跳过);
  • frame #0 显示当前函数、参数值和地址。

六、GDB 与 LLDB:两种命令风格

两者功能基本对等,最大的区别在命令风格:

  • GDB:动词式短命令,break、run、next、print,大都可以缩写成一两个字母(b、r、n、p);
  • LLDB:「名词 + 动词 + 选项」的结构化命令,比如 breakpoint set --file orders.cpp --line 22、frame variable、thread step-over。好在 LLDB 预置了大量兼容 GDB 的别名,b、run、next、step、finish、p、bt 都能直接用。

本系列的写法是:每个操作先给 GDB 命令,再给 LLDB 命令,LLDB 优先用简短别名,必要时给出完整形式。第 08 篇会整理一张完整对照表,先看最常用的几个:

操作 GDB LLDB
启动调试器 gdb ./prog lldb ./prog
下断点 break line_total / b orders.cpp:22 b line_total / b orders.cpp:22
运行 run / r run / r
继续 continue / c continue / c
单步(不进函数) next / n next / n
单步(进函数) step / s step / s
跑到当前函数返回 finish finish
打印变量 print x / p x p x / frame variable x(v x)
调用栈 backtrace / bt bt
退出 quit / q quit / q

两个随手就能用上的小技巧:

  • 直接回车 = 重复上一条命令。连续单步时只需要敲一次 next,后面一路回车;
  • 不会就问:GDB 里 help break、apropos watch;LLDB 里 help breakpoint set、apropos watch。

七、小结

要点 内容
调试的前提 编译时加 -g 生成调试信息,学习阶段用 -O0 关闭优化
不加 -g 不能按行下断点,看不到行号和局部变量
开优化 函数被内联、变量 <optimized out>
调试器 macOS 用 LLDB;Linux 用 GDB;Docker 需加 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined
最小流程 break → run → next / step → print → continue

C++ 调试实战系列第 0 篇完。下一篇:断点——按行、按函数、按条件,让程序停在你想停的地方。