搜索

C++ 教程

C++基础

第1章 搭建开发环境与第一个程序 第2章 基本数据类型与变量 第3章 运算符与表达式 第4章 流程控制语句 第5章 数组与字符串 第6章 函数基础 第7章 指针基础 第8章 引用与const限定 第9章 动态内存管理 第10章 结构体与联合体 第11章 函数进阶

面向对象

第12章 面向对象编程入门 第13章 封装与访问控制 第14章 运算符重载 第15章 继承与派生 第16章 多态与虚函数

STL与进阶

第17章 模板编程基础 第18章 标准模板库(STL)容器 第19章 STL算法与迭代器 第20章 异常处理机制 第21章 文件输入输出 第22章 字符串类string 第23章 命名空间与作用域 第24章 类型转换操作符

现代C++

第25章 智能指针 第26章 移动语义与右值引用 第27章 Lambda表达式 第28章 多线程编程基础 第29章 模板元编程入门 第30章 设计模式基础 第31章 C++11/14新特性 第32章 C++17/20核心特性

实战进阶

第33章 性能优化技巧 第34章 调试与测试 第35章 项目结构与构建 第36章 网络编程入门 第37章 数据库连接操作 第38章 图形界面开发基础 第39章 综合项目实战 第40章 进阶学习路线

第34章 调试与测试

第34章 调试与测试

高级 👤 已掌握C++核心语法,希望写出健壮、可维护代码的开发者 🔧 在复杂项目中发现、定位、修复缺陷,并建立代码质量保障体系
本章你将学到
  • 使用GDB/LLDB进行高效命令行调试
  • 编写单元测试与集成测试的方法论
  • 利用Valgrind等工具检测内存与性能问题
核心概念断点调试单元测试

程序崩溃,输出是“Segmentation fault (core dumped)”。控制台没有更多信息,你只知道程序在某个时刻突然死了。这是每个C++开发者都会遇到的场景。调试不是猜测,而是有章可循的侦查过程;测试不是写完代码后的点缀,而是保障代码行为符合预期的基石。本章将带你系统掌握这两项让代码从“能跑”到“可靠”的关键技能。

系统化调试:从崩溃现场到问题根源

调试就像侦探破案,你需要收集线索(日志、核心转储)、询问证人(变量状态)、重现案发现场(复现步骤)。现代IDE的图形化调试器很方便,但掌握命令行调试器(GDB/LLDB)是基本功,尤其在服务器或嵌入式环境。

现象收集
→
复现与定位
→
分析与修复
↓↓↓
错误信息
核心转储
⇄
断点/单步
变量监视
⇄
逻辑推理
代码修正

首先,编译时必须带上调试符号。使用-g标志,并建议关闭优化(-O0),否则调试时行号可能对不上,变量可能被优化掉。

// 正确的编译命令
g++ -std=c++17 -g -O0 -o my_app main.cpp utils.cpp

核心调试命令速查

以下命令在GDB和LLDB中功能类似(命令可能略有不同,如GDB用run,LLDB用process launch)。假设我们调试一个名为my_app的可执行文件。

任务GDB命令LLDB命令说明
启动调试gdb ./my_applldb ./my_app加载可执行文件
运行程序run 或 rrun 或 r开始执行,可带参数
设置断点break main 或 b 42breakpoint set -n main 或 b 42在函数或行号处中断
继续运行continue 或 ccontinue 或 c运行到下一个断点
单步进入step 或 sstep 或 s进入函数内部
单步跳过next 或 nnext 或 n执行下一行,不进入函数
打印变量print x 或 p xframe variable x 或 p x查看变量值
查看回溯backtrace 或 btthread backtrace 或 bt显示调用栈
加载核心转储gdb ./my_app corelldb -c core ./my_app分析崩溃现场

我在项目中踩过一个坑:一个服务在客户环境随机崩溃,生成了core.12345文件。通过gdb ./my_server core.12345加载后,用bt命令立刻看到崩溃发生在std::vector::operator[],原因是越界访问。如果没有核心转储和调试符号,定位这种问题将如同大海捞针。

内存问题检测:Valgrind与AddressSanitizer

C++手动管理内存,内存泄漏、越界、使用已释放内存(use-after-free)是常见问题。这些错误有时不会立即导致崩溃,而是像“慢性病”一样逐渐耗尽资源或破坏数据。

Valgrind Memcheck

  • 工具: Valgrind
  • 检测: 内存泄漏、越界读写、未初始化内存使用
  • 命令: valgrind --leak-check=full ./my_app

AddressSanitizer (ASan)

  • 工具: 编译器插桩 (GCC/Clang)
  • 检测: 越界、use-after-free、double-free
  • 编译: -fsanitize=address -g

实际开发中,大部分团队会优先推荐AddressSanitizer,因为它速度比Valgrind快得多(约2倍),对性能影响较小。但Valgrind的Memcheck工具能检测未初始化内存的使用,这是ASan做不到的。我通常的流程是:开发时用ASan快速检查,发布前用Valgrind做深度扫描。

// 示例:一段有内存问题的代码
int* create_array(int size) {
    int* arr = new int[size]; // 动态分配
    return arr;
}
int main() {
    int* my_arr = create_array(10);
    my_arr[10] = 42; // 越界写入!
    // 忘记 delete[] my_arr; // 内存泄漏!
    return 0;
}

使用ASan编译并运行:g++ -fsanitize=address -g -o bad_memory bad_memory.cpp && ./bad_memory。你会立刻得到详细的错误报告,指出越界写入发生在哪一行。

⚠️ 常见误区

  • “我的程序没崩溃,所以没内存问题”:内存泄漏和越界读可能静默发生,直到生产环境引发不可预测行为。
  • 只在Debug版本测试:有些问题(如未初始化变量)在Release版本因优化可能表现不同,应在接近发布的构建配置下测试。
  • 忽视静态分析工具:如Clang-Tidy,能在编译前就发现许多潜在代码缺陷,应与动态测试工具结合使用。

单元测试:使用Google Test构建安全网

单元测试是什么? 想象你买了一台复杂机器,你不会等整机组装完再测试,而是先检查每个齿轮、电路板是否正常工作。单元测试就是对代码中最小可测试单元(通常是函数或类)进行验证。

技术定义:单元测试是一段自动化的代码,它调用被测试单元,给定特定的输入,验证其输出或行为是否符合预期。其核心价值在于快速反馈、防止回归、并作为代码行为的活文档。

Google Test (gtest)是C++社区最流行的单元测试框架之一。首先需要安装并链接它。

// 一个简单的数学函数及其测试
// math_utils.h
#pragma once
int add(int a, int b);
double divide(double numerator, double denominator);

// test_math_utils.cpp
#include "gtest/gtest.h"
#include "math_utils.h"

TEST(MathUtilsTest, AddPositiveNumbers) {
    EXPECT_EQ(add(2, 3), 5);
    EXPECT_EQ(add(0, 0), 0);
}

TEST(MathUtilsTest, DivideByNonZero) {
    EXPECT_DOUBLE_EQ(divide(10.0, 2.0), 5.0);
}

TEST(MathUtilsTest, DivideByZeroThrows) {
    EXPECT_THROW(divide(5.0, 0.0), std::invalid_argument);
}

int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}
编写测试用例 (TEST)
→
编译链接测试程序
→
运行测试 (RUN_ALL_TESTS)
→
分析测试报告

这里有个隐藏的坑:测试的断言分为EXPECT_*和ASSERT_*。如果EXPECT_EQ失败,测试会继续执行;如果ASSERT_EQ失败,当前测试函数会立即终止。根据是否需要后续验证来选择。

编者提示:编写可测试的代码,往往意味着良好的设计。这与第30章《设计模式基础》中强调的“依赖倒置”、“单一职责”等原则高度一致。例如,通过依赖注入替换硬编码的全局依赖,可以轻松在测试中注入模拟对象(Mock)。

测试驱动开发(TDD)与代码覆盖率

TDD是一种开发方法论:红-绿-重构循环。你先写一个会失败的测试(红),然后写最少代码让测试通过(绿),最后优化代码结构而不改变行为(重构)。这强迫你从使用者角度思考接口设计。

代码覆盖率

覆盖率工具(如GCC的gcov,LLVM的llvm-cov)衡量你的测试执行了源代码的多少比例。主要指标:

  • 行覆盖率:执行了代码总行数的百分比。
  • 分支覆盖率:更严格,衡量每个条件判断(如if/else)的所有分支是否都被测试到。

高覆盖率不能保证没bug,但低覆盖率一定意味着测试不充分。我通常要求核心模块的行覆盖率不低于80%,关键算法分支覆盖率要达到100%。

// 使用gcov收集覆盖率
// 1. 编译时加入覆盖率标识
g++ -std=c++17 --coverage -g -O0 -o my_app main.cpp
// 2. 运行程序(执行测试)
./my_app
// 3. 生成报告
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
// 打开 coverage_report/index.html 查看可视化报告

动手试一试

  1. 编写一个含有故意错误的程序(如数组越界、内存泄漏),分别使用Valgrind和AddressSanitizer检测,对比两者的输出信息。
  2. 为第6章《函数基础》中你写过的某个计算函数(如计算阶乘、斐波那契数列)编写Google Test单元测试。包含正常用例、边界用例(如输入为0、负数)和异常用例。
  3. 创建一个简单的类,如`Circle`,有半径属性和计算面积、周长的方法。使用TDD流程:先写测试,再实现类,最后用`gcov`检查测试覆盖率。

检验你的理解

  1. 程序在服务器上崩溃并生成了core文件,但你本地没有源代码。仅凭core文件,你能确定崩溃的代码行号吗?为什么?
  2. AddressSanitizer报告了一个“heap-use-after-free”错误。这可能是什么代码导致的?请写一个最小代码片段复现此错误。
  3. 一个单元测试使用了`EXPECT_EQ`来比较两个`double`类型的计算结果,测试偶尔会失败。可能的原因是什么?应该如何修正这个测试断言?

本章小结

  • 调试是系统性工程:掌握GDB/LLDB的核心命令(断点、单步、回溯、打印变量)是定位问题的基石,编译时务必带上-g标志。
  • 内存错误需主动检测:Valgrind和AddressSanitizer是发现内存泄漏、越界访问等“隐形杀手”的必备工具,应集成到开发流程中。
  • 单元测试是安全网:使用Google Test等框架为代码编写自动化测试,遵循TDD的“红-绿-重构”循环可以提升代码设计和可靠性。
  • 覆盖率量化测试完整性:利用gcov等工具测量代码覆盖率,重点关注行覆盖和分支覆盖,确保测试用例充分。
  • 工具链整合:将调试、内存检查、单元测试和覆盖率分析整合到你的构建系统(如CMake)中,实现一键化质量检查。

意见反馈

微信二维码 微信扫码关注 抖音二维码 抖音扫码关注