43、调试与测试:GDB调试基础(断点/单步/查看变量)、assert断言、单元测试入门

说实话,写代码这件事,没人能一次写对。我干了十几年C语言开发,见过太多人把时间花在「猜bug」上——改一行代码,跑一下,不对,再改一行,再跑。这效率太低了。

真正的老手是怎么做的?用工具。GDB、assert、单元测试,这三样东西用好了,你的调试效率能翻好几倍。今天我就把这套方法论掰开揉碎讲给你听。

核心观点:调试不是碰运气,而是用科学的方法缩小问题范围。GDB让你看到程序内部,assert帮你提前拦住错误,单元测试让你放心重构。

一、GDB调试基础:断点、单步、查看变量

GDB是Linux下最经典的调试器。我刚开始用的时候也觉得命令行麻烦,但用顺手之后,比那些IDE调试器灵活太多了。

1.1 编译时别忘了-g

很多人问我:「为什么我的GDB看不到源码和变量名?」答案很简单——编译时没加-g选项。

gcc -g -o myprogram myprogram.c

-g会在可执行文件中加入调试符号信息。没有它,GDB看到的只是一堆内存地址,你根本不知道哪个变量叫什么名字。

小技巧:我习惯在Makefile里把-g-O0一起用。-O0禁止优化,这样变量不会被优化掉,单步调试时每一步都符合你的预期。

1.2 启动GDB的两种方式

// 方式一:直接调试
gdb ./myprogram

// 方式二:先运行,再附加到进程
gdb attach <pid>

方式一适合从头调试。方式二呢?我遇到过一种情况:程序跑着跑着突然卡住了,这时候用gdb attach上去,看看它在干什么,特别管用。

1.3 断点:让程序停在你想停的地方

断点就是告诉GDB:「执行到这一行,停下来等我。」

(gdb) break main          // 在main函数入口设断点
(gdb) break 42            // 在第42行设断点
(gdb) break myfunc        // 在myfunc函数入口设断点
(gdb) break file.c:100    // 在file.c的第100行设断点
(gdb) info breakpoints    // 查看所有断点
(gdb) delete 1            // 删除编号为1的断点

我个人最常用的是break 行号。比如我知道某个函数里有个循环可能有问题,就在循环开始那行设个断点,然后一步步看。

1.4 单步执行:一步步看程序怎么跑

设好断点后,用run启动程序。程序会在断点处停下,然后你就可以单步执行了。

(gdb) run                // 开始运行
(gdb) next               // 下一步(不进入函数内部)
(gdb) step               // 下一步(进入函数内部)
(gdb) finish             // 执行完当前函数,返回到调用处
(gdb) continue           // 继续执行,直到下一个断点

nextstep的区别,说白了就是:next把函数调用当成一步,step会钻进函数里面。我调试时一般先用next快速过,发现某个函数返回值不对,再用step钻进去看细节。

1.5 查看变量:看看内存里到底存了什么

(gdb) print x            // 打印变量x的值
(gdb) print &x           // 打印变量x的地址
(gdb) display x          // 每次停下都自动打印x
(gdb) info locals        // 打印所有局部变量
(gdb) backtrace          // 查看函数调用栈

有一次我调试一个链表操作,发现指针总是指向错误地址。用print p->next一看,果然是个野指针。这种问题光靠看代码很难发现,但GDB一查就现原形。

注意:查看指针时,记得用*p解引用。我见过有人直接print p,看到一串地址就懵了——你得看地址指向的内容才有意义。

二、assert断言:把错误扼杀在摇篮里

assert是C标准库<assert.h>提供的一个宏。它的作用很简单:如果条件为假,就打印错误信息并终止程序。

#include <assert.h>

int divide(int a, int b) {
    assert(b != 0);  // 除数不能为0
    return a / b;
}

为什么要用assert?说白了,就是「早发现,早治疗」。如果某个条件不满足,程序继续跑下去只会产生更诡异的结果,不如直接停下来告诉你哪里错了。

2.1 assert的使用场景

  • 函数前置条件:比如指针不能为NULL,除数不能为0
  • 函数后置条件:比如返回值必须在某个范围内
  • 不可能发生的情况:比如switch的default分支
void process_data(int *data, int len) {
    assert(data != NULL);   // 指针不能为空
    assert(len > 0);        // 长度必须为正数
    
    for (int i = 0; i < len; i++) {
        assert(data[i] >= 0);  // 数据必须非负
        // 处理数据...
    }
}

我的习惯:在开发阶段,assert全部打开。发布时,用-DNDEBUG编译选项关掉assert,不影响性能。但注意——assert里不要写有副作用的表达式,比如assert(++x > 0),因为发布版里这行代码根本不会执行。

2.2 assert vs if-else

对比项 assert if-else
用途 调试时检查「不应该发生」的错误 处理「可能发生」的错误
发布版行为 被移除(定义NDEBUG后) 保留
典型场景 检查内部逻辑一致性 检查用户输入、文件是否存在等

你想想看,用户输入错误是正常的,用if处理。但程序内部逻辑出问题,那一定是代码写错了,用assert直接炸掉,逼你修bug。

三、单元测试入门:给代码上保险

单元测试是什么?就是针对函数或模块写测试代码,验证它们的行为是否符合预期。我见过太多人写完代码不测试,直接集成,结果出了问题根本不知道是哪个模块的锅。

3.1 一个简单的单元测试框架

C语言没有官方的单元测试框架,但我们可以自己写一个简单的。核心思路就是:调用函数,检查结果,报告失败。

#include <stdio.h>
#include <assert.h>

// 被测试的函数
int add(int a, int b) {
    return a + b;
}

// 测试用例
void test_add_positive() {
    assert(add(2, 3) == 5);
    printf("test_add_positive: PASS\n");
}

void test_add_negative() {
    assert(add(-1, -2) == -3);
    printf("test_add_negative: PASS\n");
}

void test_add_zero() {
    assert(add(0, 5) == 5);
    printf("test_add_zero: PASS\n");
}

int main() {
    test_add_positive();
    test_add_negative();
    test_add_zero();
    printf("所有测试通过!\n");
    return 0;
}

这个例子虽然简单,但已经包含了单元测试的核心思想:每个测试用例只测一个功能点,测试结果一目了然。

3.2 使用CUnit框架

实际项目中,我推荐用CUnit这样的成熟框架。它提供了测试套件、断言宏、测试报告等功能。

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>

int add(int a, int b) {
    return a + b;
}

void test_add_positive(void) {
    CU_ASSERT(add(2, 3) == 5);
}

void test_add_negative(void) {
    CU_ASSERT(add(-1, -2) == -3);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("add_test_suite", 0, 0);
    CU_add_test(suite, "test_add_positive", test_add_positive);
    CU_add_test(suite, "test_add_negative", test_add_negative);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

单元测试的好处:

  • 修改代码后,跑一遍测试就知道有没有破坏原有功能
  • 写测试的过程,逼你想清楚函数的输入输出
  • 测试本身就是最好的文档——它告诉你函数应该怎么用

四、知识体系总览

下面这张图总结了本章的核心内容。你可以把它当成一个调试与测试的「作战地图」。

调试与测试知识体系 GDB调试 • 编译加 -g 选项 • 设置断点 (break) • 单步执行 (next/step) • 查看变量 (print) • 查看调用栈 (backtrace) • 条件断点 • 观察点 (watch) assert断言 • 检查前置条件 • 检查后置条件 • 检查不可能情况 • 发布时用 NDEBUG 关闭 • 避免副作用表达式 • 与 if-else 分工明确 单元测试 • 测试单个函数/模块 • 每个用例测一个点 • 使用断言检查结果 • 自动化运行 • 回归测试保安全 • 测试即文档 目标:尽早发现错误,精确定位问题,确保代码质量

五、避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路。

  • 我曾经在assert里写了assert(p = malloc(100)),少写了一个等号。结果发布版里p根本没分配内存,程序跑得飞起,但数据全乱了。记住:assert里不要有赋值操作。
  • 我曾经调试一个多线程程序,用GDB单步跟踪,结果发现每次跑到同一个地方就卡住。后来才知道是死锁——GDB停住一个线程,其他线程还在抢锁。这种情况建议用thread apply all bt看所有线程的调用栈。
  • 我曾经写单元测试时,一个测试函数里测了五六个功能点。结果失败了,我根本不知道是哪个点出了问题。后来老老实实一个函数只测一个点,问题定位快多了。

调试和测试,说白了就是给代码上「双保险」。GDB帮你事后查问题,assert帮你事前拦问题,单元测试帮你持续保证质量。这三样东西用好了,你的代码会越来越稳。


公众号:蓝海资料掘金营,微信deep3321