CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
C++ 语言入门/课时/调试与测试
RawBlame
课时目录12 节

C++ 语言入门

  1. 01C++ 是什么
  2. 02基础语法
  3. 03指针与引用
  4. 04面向对象
  5. 05内存与 RAII
  6. 06STL 容器与算法
  7. 07模板基础
  8. 08现代 C++ 特性
  9. 09多线程
  10. 10构建与依赖
  11. 11调试与测试
  12. 12综合练习

调试与测试

gdb、gtest/gmock、sanitizers 概念。

30 分钟

调试与测试

这一课你将能做到

  1. 用 -g -O0 编出可调试二进制,在 GDB 里下断点、单步、打印变量、看调用栈定位崩溃
  2. 对一个段错误程序完成「复现 → bt 定位 → print 取证 → 修复」的完整排障闭环
  3. 用 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)同法炮制。

讲清楚要点

  1. Debug/Release 双轨制:调试永远 -g -O0(或 CMake 的 Debug 模式),发布 -O2/-O3。优化会重排、内联代码,Release 里 print i 可能显示 <optimized out>、单步会跳行——不是 GDB 坏了,是代码已被优化得「面目全非」。
  2. 崩溃不是终点,是起点:SIGSEGV 时先 bt 再 print,九成问题当场定位。生产环境可开 core dump(ulimit -c unlimited),事后 gdb ./程序 core 离线取证。
  3. 单测的价值在锁定行为:不只测「对输入给对输出」,更要测边界(空容器、单元素、并列极值)。判题沙箱每次改动都要过全量评测回归,本质就是巨型单测集。
  4. 分层防线的组合拳:编译警告(-Wall -Wextra)挡低级错误 → assert 挡不变式破坏 → 单测挡回归 → ASan/TSan 挡内存与并发错误。每层都有漏网,叠加后漏网率指数下降。
  5. 日志与断点的分工:概率性 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

卡点急救

  1. GDB 报 No symbol table:编译没加 -g。重编后进 GDB;CMake 工程确认 CMAKE_BUILD_TYPE=Debug。
  2. ctest 显示 0 个测试:enable_testing() 必须在 add_test 之前写在根 CMakeLists;且要在 build 目录里跑 ctest 而不是源码目录。
  3. 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 跑同一套测试。

自测三问

  1. -g 和 -O0 各自的作用是什么?为什么调试要一起用?
  2. CHECK 和 REQUIRE(doctest)的区别是什么?
  3. ASan 报告的三段结构(错误类型/崩溃栈/分配栈)分别怎么读?

小结一句话

GDB 的 bt+print 重建崩溃现场、doctest 锁定行为回归、ASan 把内存错误显影——C++ 调试测试三件套对着 Java 的「IDE+JUnit+JVM」逐项对位,唯一区别是工具更底层、纪律要更严。

延伸

  • GDB 官方文档
  • doctest 官方教程
  • 下一课:综合练习——用十二课所学搭一个迷你判题器。
加载本节练习…
← 上一课构建与依赖下一课 →综合练习