80 / 80 · C++11 · 约 14 分钟
诊断与测量:Warnings、Sanitizers 与 Debugger
让编译警告、运行时插桩和调试器分别回答不同的问题,再以可重复的输入检验修复。必须区分未定义、未指定和实现定义行为;Sanitizer 没有报告只说明本次执行未触发已启用的检查,性能结论也必须来自独立测量。
先识别规则,再选择工具
未定义行为让标准不再约束程序,例如有符号整数溢出或越界访问;不能把一次输出当成合法语义。未指定行为是在允许结果中选择,实现无需说明具体选择,例如不相互依赖的函数实参的求值次序。实现定义行为则要求实现给出文档,例如普通 char 是否有符号。后二者不是“随便执行什么都行”。
因此诊断从契约开始:输入范围、对象寿命、索引界限分别是什么?下例在加法前检查边界,而不是先计算可能溢出的和再比较。失败时输出参数保持原值,既能捕获边界错误,也能验证接口对调用者的承诺。
警告与插桩:固定一次可复现执行
保存为 diagnostics.cpp。先执行 g++ -std=c++11 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -Og diagnostics.cpp -o diag,再运行 ./diag。从首个诊断及其上下文开始修正,不要用强制转换批量消音;记录编译器版本、完整命令和输入才能复现。警告集不是完整的语言错误判定器。
装有 Clang 及其 sanitizer 运行库时,执行 clang++ -std=c++11 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer diagnostics.cpp -o diag-san,再执行 ./diag-san。ASan 主要查内存访问错误,UBSan 覆盖部分未定义操作;多文件构建应为相关源文件插桩,并在最终链接也带 sanitizer 选项。
调试器定位首个错误状态
执行 gdb ./diag,依次输入 break checked_add、run、print a、print b、bt。用 next 按源行前进,step 进入调用,continue 到下一断点,quit 退出。崩溃位置可能只是损坏被发现的位置;要沿调用栈找到第一个违反不变量的写入,必要时在对象仍存活期间设置 watchpoint。
优化可能让局部变量显示为 optimized out,也会改变单步顺序。保留原问题的优化配置作为复现,再用易调试构建辅助理解。插桩通过不是证明:未执行分支、未插桩的依赖和工具不覆盖的规则仍有风险;并发数据竞争还需单独使用适用平台上的 ThreadSanitizer。
先确认正确,再比较性能
用 g++ -std=c++11 -O2 -g diagnostics.cpp -o diag-opt 建立非插桩优化构建。下面的小程序是正确性探针,不是有意义的基准。真实测量应固定输入规模、编译选项和机器负载,先预热,再重复运行;同时记录中位数、波动和单位操作成本,而不是只挑最短一次。
用 steady_clock 包围待测工作,并让结果在计时区间外产生可观察用途,防止工作被消除;但仅有输出仍不能阻止常量折叠,应使用真实运行时输入并检查优化结果。把分配和 I/O 放到计时区间内还是外要由问题决定。不要拿 sanitizer、调试器下的耗时评判发布版本,也不要用一次快慢证明算法复杂度。
容易答错的地方
- UBSan 默认对部分错误报告后继续运行;示例显式关闭 undefined 检查的恢复,防止把继续运行误认为错误已被安全处理。
- assert 适合表达调试和测试契约,不是外部输入校验;发布配置可能禁用断言,必须让必要的检查保留在普通控制流中。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <cassert>
#include <iostream>
#include <limits>
bool checked_add(int a, int b, int& result) noexcept {
const int low = std::numeric_limits<int>::min();
const int high = std::numeric_limits<int>::max();
if ((b > 0 && a > high - b) ||
(b < 0 && a < low - b)) {
return false;
}
result = a + b;
return true;
}
int main() {
int result = 99;
bool ok = checked_add(12, 30, result);
assert(ok && result == 42);
ok = checked_add(std::numeric_limits<int>::max(), 1, result);
assert(!ok && result == 42);
ok = checked_add(std::numeric_limits<int>::min(), -1, result);
assert(!ok && result == 42);
ok = checked_add(std::numeric_limits<int>::min(), 0, result);
assert(ok && result == std::numeric_limits<int>::min());
ok = checked_add(-5, 7, result);
assert(ok && result == 2);
std::cout << result << '\n';
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread tooling-diagnostics.cpp -o example && ./example预期结果
2
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
有人把检查写成 int sum = a + b; if (sum < a) return false;,并用“Sanitizer 没报错”证明正确。指出两处逻辑问题,并解释示例为什么不会在边界检查中再次溢出。
查看参考答案
第一,若有符号加法溢出,在比较以前已经发生 UB,事后检查无效。第二,合法负数加法也会让 sum < a,因此该条件不能表示一般有符号溢出。未报错仅覆盖实际输入和已启用检查。正确实现对 b > 0 比较 a 与 max - b,对 b < 0 比较 a 与 min - b;前一减法结果位于可表示范围,后一减法也位于 min 到 0 之间,不需要求 -b,因此 b 等于最小值也安全。确认安全后才执行 a + b,并让失败路径不写 result。
继续查证
- cppreference:Undefined behavior 与行为分类
- Clang:AddressSanitizer 的编译、链接与检测范围
- Clang:UndefinedBehaviorSanitizer 与恢复选项
- GDB:A Sample GDB Session
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。