调试与测试
这一课你将能做到
- 用
-g -O0编出可调试二进制,在 GDB 里下断点、单步、打印变量、看调用栈定位崩溃 - 对一个段错误程序完成「复现 → bt 定位 → print 取证 → 修复」的完整排障闭环
- 用 doctest 写单元测试并接入 CMake,用 AddressSanitizer 抓内存越界与泄漏
为什么要学
Java 的调试生态是「IDE 点点按钮 + JUnit + 框架兜底」;C++ 的崩溃直接来自操作系统(段错误信号),兜底的人是你自己。好消息是工具链同样成熟:GDB 调试器、doctest/GoogleTest 单测框架、AddressSanitizer 内存检查器,能力都不输 Java 生态,只是更「命令行、更贴近机器」。
判题沙箱是这三样工具的重度用户:评测用户代码前先过单测回归;线上偶发崩溃靠 GDB 的 core dump 事后取证;ASan 在开发期就把越界读写在测试中拦截。掌握本课,你面对「偶尔崩溃、压测才炸」的 C++ 程序就不再是瞎猜。
先建立图像
排障闭环(和 Java 调试同构,工具不同):
复现问题 → 断点/单步(GDB ≈ IDE 调试器)
→ 观察状态(print/bt ≈ Variables 面板 + 调用栈)
→ 提出假设 → 加验证(日志/断点条件)
→ 修复 → 单元测试锁定(doctest ≈ JUnit)
→ 静态/动态检查兜底(ASan ≈ JVM 的异常与堆转储)
编译两把刷子:
-g -O0 → 带调试信息、不优化,变量都在栈上原样可见(Debug 构建)
-O2 -DNDEBUG → 发布优化,assert 全部失效(Release 构建)
用 Debug 排查、Release 跑分,混用会「调试时好的、发布就崩」
跟着做
步骤 1:准备一个有 bug 的程序
新建 buggy.cpp——一个典型的越界崩溃:
#include <iostream>
#include <string>
#include <vector>
struct Submission {
int id;
int cost;
};
// 找出耗时最长的提交:注意 idx 的边界处理埋了雷
int slowestIdx(const std::vector<Submission>& subs) {
int idx = 0;
for (int i = 0; i <= subs.size(); ++i) { // ← <= 越界!size 是无符号,i 到 size 时非法
if (subs[i].cost > subs[idx].cost) // ← 读取越界内存,UB
idx = i;
}
return idx;
}
int main() {
std::vector<Submission> subs{{101, 120}, {102, 45}, {103, 200}};
int k = slowestIdx(subs);
std::cout << "最慢提交 id=" << subs[k].id << "\n";
return 0;
}
它可能「碰巧」输出正确(越界读到的垃圾数据恰好不大),也可能随机崩溃——这种薛定谔 bug 正是本课要教会你稳定拿下的类型。
步骤 2:GDB 四板斧定位崩溃
g++ -std=c++17 -g -O0 buggy.cpp -o buggy # -g 调试信息 -O0 关优化
gdb ./buggy
进入 GDB 后:
(gdb) break slowestIdx ← 在函数处下断点(也可 break buggy.cpp:12 按行)
(gdb) run ← 运行,停在断点
(gdb) next ← 单步(不进函数);step 进函数;continue 继续到下个断点
(gdb) print subs ← 打印整个 vector
$1 = std::vector of length 3, capacity 3 = {{id = 101, cost = 120}, ...}
(gdb) print subs.size()
$2 = 3
(gdb) continue
如果直接对崩溃现场取证(更有实战价值):去掉断点直接 run,程序段错误时 GDB 会接住:
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x00005555 in Submission::cost access ... buggy.cpp:13
(gdb) bt ← backtrace:调用栈,从上往下读就是崩溃链路
#0 slowestIdx (subs=...) at buggy.cpp:13
#1 main () at buggy.cpp:23
(gdb) frame 0 ← 切到 #0 帧
(gdb) print i
$3 = 3 ← i 越界到了 3,而 size 只有 3 —— 实锤
(gdb) quit
对比 Java:JVM 抛 ArrayIndexOutOfBounds 并打印完整栈让你看日志;C++ 直接 SIGSEGV,必须靠调试器重建「异常现场」。bt + frame + print 就是 C++ 版的异常栈阅读。
VS Code/CLion 的图形化断点调试底层同样是 GDB/LLDB——IDE 只是壳,命令行原理必须懂。
步骤 3:assert——让错误早死早超脱
#include <cassert>
#include <vector>
int slowestIdx(const std::vector<Submission>& subs) {
assert(!subs.empty() && "不能对空列表找最慢"); // Debug 生效;Release(NDEBUG) 自动消失
for (std::size_t i = 0; i < subs.size(); ++i) { // 修复:size_t 匹配无符号,条件改 <
if (subs[i].cost > subs[idx].cost) idx = i;
}
return idx;
}
assert 对应 Java 的 assert(默认开启)和 guava 的 Preconditions,定位是「开发期内部不变式检查」,绝不用于校验外部输入——Release 下它不存在,外部校验要走异常或错误码。
步骤 4:doctest 单元测试
doctest 是最轻量的 C++ 单测框架(单头文件、编译快)。先建 tests.cpp:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN // 声明:main 由 doctest 提供
#include "doctest.h"
#include <string>
#include <vector>
struct Submission { int id; int cost; };
// 被测函数:修好边界的版本
int slowestIdx(const std::vector<Submission>& subs) {
assert(!subs.empty());
int idx = 0;
for (int i = 1; i < static_cast<int>(subs.size()); ++i) {
if (subs[i].cost > subs[idx].cost) idx = i;
}
return idx;
}
TEST_CASE("耗时最大的提交被正确选中") {
std::vector<Submission> subs{{101, 120}, {102, 45}, {103, 200}};
CHECK(slowestIdx(subs) == 2); // CHECK:失败继续跑后面的断言
CHECK(slowestIdx({{1, 7}}) == 0); // 单元素边界
}
TEST_CASE("并列最快时取第一个") {
std::vector<Submission> subs{{1, 50}, {2, 50}, {3, 10}};
CHECK(slowestIdx(subs) == 0); // 锁定「严格大于才替换」的行为
}
CMake 接入(接上一课的工程):
add_executable(unit_tests tests.cpp)
# doctest 也支持 find_package(doctest REQUIRED) + doctest_add_test 的正式接法,
# 单头文件方案先把 doctest.h 放进 include/ 即可
enable_testing()
add_test(NAME unit_tests COMMAND unit_tests)
cmake -B build && cmake --build build
cd build && ctest # 运行全部测试,输出通过/失败统计
[doctest] test cases: 2 | 2 passed | 0 failed
对照 JUnit:TEST_CASE ≈ @Test 方法,CHECK ≈ assertEquals,ctest ≈ mvn test。行为锁定后,谁改坏了 slowestIdx,CI 立刻红。
步骤 5:AddressSanitizer——内存错误的显像管
把越界、use-after-free、泄漏在运行时当场抓住:
g++ -std=c++17 -g -fsanitize=address -O1 buggy.cpp -o buggy_asan
./buggy_asan
输出(节选,信息量大但结构固定):
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
READ of size 4 at 0x... thread T0
#0 slowestIdx buggy.cpp:13 ← 在哪崩的
#1 main buggy.cpp:23
...逃逸分析...
SUMMARY: AddressSanitizer: heap-buffer-overflow buggy.cpp:13
ASan 的定位精度是「行级 + 谁分配的这块内存」,比 GDB 事后取证还省事,代价是 2 倍内存与速度。团队实践:CI 全量测试用 ASan 跑一遍,开发期随手开;上一课的并发死锁/竞争则用 -fsanitize=thread(TSan)同法炮制。
讲清楚要点
- Debug/Release 双轨制:调试永远
-g -O0(或 CMake 的 Debug 模式),发布-O2/-O3。优化会重排、内联代码,Release 里print i可能显示<optimized out>、单步会跳行——不是 GDB 坏了,是代码已被优化得「面目全非」。 - 崩溃不是终点,是起点:SIGSEGV 时先
bt再print,九成问题当场定位。生产环境可开 core dump(ulimit -c unlimited),事后gdb ./程序 core离线取证。 - 单测的价值在锁定行为:不只测「对输入给对输出」,更要测边界(空容器、单元素、并列极值)。判题沙箱每次改动都要过全量评测回归,本质就是巨型单测集。
- 分层防线的组合拳:编译警告(
-Wall -Wextra)挡低级错误 → assert 挡不变式破坏 → 单测挡回归 → ASan/TSan 挡内存与并发错误。每层都有漏网,叠加后漏网率指数下降。 - 日志与断点的分工:概率性 bug 用断点反而复现不出(断点改变时序),靠日志重放;能稳定复现的用断点最省时。
你可能会懵的地方
-
GDB 里 print 结构体/vector 显示不全? 默认精简显示。
print subs._M_impl._M_start[1]太原始,正确姿势是确认 GDB 编译了 Python 支持并开启 pretty printer(发行版自带);或set print elements 0放开显示上限。 -
条件断点怎么下?
break buggy.cpp:13 if i == 3——只在条件满足时停。循环一万次只关心最后一次时,这就是救命题。对应 IDE 的 Conditional Breakpoint。 -
段错误但 bt 全是问号? 编译时漏了
-g,或崩溃在优化后的代码里。用 Debug 模式重编复现;若崩溃在系统库里,bt 仍能给出你自己栈帧的行号,从那里往回推。 -
doctest、GoogleTest、Catch2 怎么选? doctest 最轻最快,头文件即用,中小项目首选;GoogleTest 生态最全(mock 配套 gmock),大型项目标配;Catch2 语法优雅(BDD 风格)。JUnit 用户体验等价物,选一个用熟即可。
-
线上程序崩了没有调试器怎么办? 提前开启 core dump,事后
gdb 可执行文件 core文件完整还原现场;容器环境把 core 路径挂载出来。判题沙箱正是这么收集「用户程序崩溃」样本的——你在本站遇到 RE 时看到的崩溃类型就来自这套机制。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 用 Release 构建调试 | 优化扰乱断点与变量显示 | Debug(-g -O0)调试,Release 验证性能 |
| assert 校验用户输入 | Release 下 assert 被删除,校验形同虚设 | 外部输入走异常/错误码,assert 只管内部不变式 |
| 测试只测「正常路径」 | 边界一碰就崩 | 空容器/单元素/极值/并列全写用例 |
| 只靠日志肉眼比对内存错误 | 越界静默,泄漏缓慢累积 | ASan/TSan 自动化拦截,CI 必配 |
| 单测与被测代码手工拷贝同步 | 两份代码漂移 | 单测直接 include 同一份头文件/链接同一 target |
卡点急救
- GDB 报
No symbol table:编译没加-g。重编后进 GDB;CMake 工程确认CMAKE_BUILD_TYPE=Debug。 - ctest 显示 0 个测试:
enable_testing()必须在add_test之前写在根 CMakeLists;且要在 build 目录里跑ctest而不是源码目录。 - ASan 报错但看不懂:按「错误类型(heap-buffer-overflow 等)→ 崩溃栈 → 分配栈」三段读,先看 SUMMARY 定类型,再顺着 #0 帧回你的代码。
面试常问
-
如何定位一个偶现的段错误? 拿到崩溃场景三步走:稳定复现(缩小输入、保留现场);Debug 构建跑 GDB,SIGSEGV 时 bt 看调用栈、frame 切帧、print 取证;若偶发难复现,开 core dump 离线分析 + ASan 重放,嫌疑是越界/UAF 时 ASan 命中率最高。修复后补单测与断言锁定。
-
assert 与异常的使用边界? assert 表达「程序内部不变式」,违反即代码 bug,Debug 拦截、Release 消失;异常表达「运行期可恢复的错误状态」,如文件不存在、输入非法,必须线上处理。用 assert 校验外部输入等于线上裸奔,用异常管内部 bug 则污染错误路径。
-
单元测试在 C++ 里如何组织,和 JUnit 的对应关系? 断言宏(doctest 的 CHECK/REQUIRE)≈ JUnit 的 assertXxx;TEST_CASE/SUBCASE ≈ @Test 与嵌套用例;ctest 聚合执行 ≈ surefire/mvn test。C++ 特有之处:测试与被测代码同为编译单元,需在 CMake 里显式建测试 target 并链接;内存/并发错误还要叠加 ASan/TSan 跑同一套测试。
自测三问
-g和-O0各自的作用是什么?为什么调试要一起用?CHECK和REQUIRE(doctest)的区别是什么?- ASan 报告的三段结构(错误类型/崩溃栈/分配栈)分别怎么读?
小结一句话
GDB 的 bt+print 重建崩溃现场、doctest 锁定行为回归、ASan 把内存错误显影——C++ 调试测试三件套对着 Java 的「IDE+JUnit+JVM」逐项对位,唯一区别是工具更底层、纪律要更严。
延伸
- GDB 官方文档
- doctest 官方教程
- 下一课:综合练习——用十二课所学搭一个迷你判题器。