C++ 调试实战(00):调试前的准备——编译选项、调试器与第一次会话
写 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 | g++ -std=c++20 -g -O0 orders.cpp -o orders # Linux / GCC |
| 选项 | 作用 |
|---|---|
-g |
生成调试信息(行号表、变量名、类型) |
-O0 |
关闭优化,每一行源码老老实实对应一段机器码,变量都在内存里 |
-Og |
GCC 提供的「对调试友好的优化」,比 -O0 快,但偶尔仍会出现变量看不到的情况 |
用 CMake 的项目,直接选 Debug 构建类型即可,它默认就带 -g(并且不开优化):
1 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug |
2.2 不加 -g 会怎样?
我们用下面第四节的示例程序做个对比。去掉 -g 编译后,GDB 完全不认识源码:
1 | (gdb) break orders.cpp:16 |
按文件行号下断点失败;按函数名下断点虽然成功了(函数名在符号表里),但停下来只显示一个地址,没有行号,局部变量也看不了。
2.3 开了 -O2 又会怎样?
加了 -g 但同时开 -O2:
1 | (gdb) break line_total |
三个现象都很典型:
2 locations:line_total被内联进了sum_orders,同时保留了一份独立函数体,一个断点落在两个地方;<optimized out>:变量total被放进了寄存器,或者干脆被算掉了,调试器找不到它;- 调用栈里
#1没有地址:这一帧是内联展开出来的「虚拟帧」。
优化版程序当然也能调,但学习阶段先统一用 -g -O0,排除干扰。
三、准备调试器
3.1 macOS:LLDB
macOS 上 GDB 需要自己签名、配置很麻烦,直接用系统自带的 LLDB。装好 Xcode Command Line Tools 就有:
1 | xcode-select --install # 已装过会提示 already installed |
3.2 Linux:GDB
1 | sudo apt install g++ gdb # Debian / Ubuntu |
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 | docker run --rm -it \ |
| 参数 | 作用 |
|---|---|
--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 | // orders.cpp |
后文会频繁引用行号,这里标一下关键位置:
| 行号 | 代码 |
|---|---|
| 11–17 | line_total() 函数,12 行计算、14 行打折、16 行 return |
| 19–25 | sum_orders() 函数,22 行调用 line_total |
| 27–36 | main(),33 行调用 sum_orders |
编译运行:
1 | g++ -std=c++20 -g -O0 orders.cpp -o orders |
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 | (gdb) break line_total |
逐条解读:
break line_total:在函数line_total入口下断点,GDB 告诉你它落在orders.cpp第 12 行;run:启动程序,程序跑到断点停下,显示即将执行的那一行(第 12 行还没执行);next:执行完第 12 行,停在第 13 行;print total:此时total已经算好,是3 × 2.5 = 7.5;continue:继续运行,第二次调用line_total时又停下;info breakpoints:查看断点列表,能看到已经命中 2 次;delete 1删除断点,continue让程序一口气跑完,正常退出。
5.2 LLDB 版
1 | clang++ -std=c++20 -g -O0 orders.cpp -o orders.mac |
1 | (lldb) b line_total |
说明: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 篇完。下一篇:断点——按行、按函数、按条件,让程序停在你想停的地方。








