4. 单元测试基础

各位同学,今天我们来聊聊单元测试。说实话,我刚入行那会儿,对单元测试也是半信半疑。总觉得「代码能跑就行,测什么测」。直到有一次,一个定时器溢出bug在产线批量暴露,返工成本让我直接傻眼。从那以后,我再也不敢跳过单元测试了。

什么是单元测试?

单元测试,说白了就是验证代码最小可测单元的行为是否符合预期。在嵌入式世界里,这个「最小单元」通常是一个函数,或者一个模块的接口。

举个例子:你写了一个计算CRC校验的函数,单元测试就是给它不同的输入数据,看它算出来的CRC值对不对。就这么简单。

单元测试的核心三要素:

  • 隔离性:被测单元不依赖外部硬件、外设或其他模块
  • 可重复性:同样的输入,永远得到同样的输出
  • 自动化:一键运行,不需要人工干预

我个人的习惯是,每写完一个函数,顺手就把它的单元测试写了。别等到整个模块写完再补,那时候你连函数签名都忘了,补测试的动力基本为零。

嵌入式单元测试 vs PC端测试:到底差在哪?

很多从PC端转过来的同学,一开始都会踩坑。你以为单元测试就是assert一下?在嵌入式里,事情远没那么简单。

下面这张表,是我这些年总结的核心区别:

对比维度 PC端单元测试 嵌入式单元测试
运行环境 直接在开发机上运行 交叉编译,在宿主机或模拟器上运行
硬件依赖 几乎无依赖 强依赖寄存器、外设、中断
资源限制 内存、CPU充裕 RAM/ROM有限,需考虑栈深度
实时性要求 不关心 需考虑时序、中断抢占
测试框架 JUnit、pytest、Google Test Ceedling、Unity、CMock、Tessy
Mock/Stub 可选 几乎必须(隔离硬件)

你看,嵌入式单元测试最大的痛点就是硬件依赖。你没法在PC上直接跑一个读写GPIO寄存器的函数,因为根本没有那个硬件地址。

嵌入式单元测试的三大挑战

1. 硬件隔离:怎么测一个操作寄存器的函数?

我曾经遇到一个项目,驱动层直接操作物理地址。比如:

// 原始代码:直接操作寄存器
void led_on(void) {
    *(volatile uint32_t *)0x40021000 |= (1 << 12);
}

这种代码在PC上跑,直接段错误。怎么办?我的做法是抽象硬件访问层:

// 改造后:通过函数指针访问硬件
static void (*write_reg)(uint32_t addr, uint32_t val) = NULL;

void led_on(void) {
    uint32_t tmp = read_reg(0x40021000);
    tmp |= (1 << 12);
    write_reg(0x40021000, tmp);
}

// 测试时注入Mock函数
void test_led_on(void) {
    uint32_t mock_reg = 0;
    write_reg = mock_write;  // 注入Mock
    read_reg = mock_read;
    
    led_on();
    TEST_ASSERT_EQUAL(0x1000, mock_reg);
}

我的经验:硬件抽象层(HAL)是嵌入式单元测试的基石。哪怕项目再小,也建议把硬件操作封装成函数指针或弱符号。这样测试时替换掉,成本极低。

2. 中断与并发:测试时中断来了怎么办?

嵌入式系统里,中断无处不在。你测一个函数,结果执行到一半被中断打断,修改了全局变量——测试结果就不可靠了。

我建议的做法是:

  • 单元测试阶段关中断:测试用例执行期间,禁止中断响应
  • 单独测试中断服务函数:把ISR当作普通函数调用,测试其逻辑
  • 使用临界区保护:被测函数如果涉及共享数据,必须加锁或关中断保护

注意:千万不要在单元测试里依赖真实中断。你没法控制中断到来的精确时机,测试结果会变成「概率性通过」——这是最坑人的情况。

3. 资源受限:测试代码放哪?

嵌入式MCU的Flash和RAM都很金贵。你总不能把测试框架和所有用例都烧到板子上吧?

我的做法是宿主机测试为主,板级测试为辅:

  • 90%的单元测试在PC上交叉编译运行(用QEMU模拟或直接编译为本地可执行文件)
  • 只有涉及真实硬件的集成测试,才烧录到开发板上跑
  • 测试代码通过条件编译排除出发布版本
// 条件编译示例
#ifdef UNIT_TEST
    #include "unity.h"
    // 测试用例
    void test_calc_checksum(void) {
        uint8_t data[] = {0x01, 0x02, 0x03};
        uint8_t result = calc_checksum(data, 3);
        TEST_ASSERT_EQUAL(0x06, result);
    }
#endif

嵌入式单元测试的核心流程

下面这张图,是我做嵌入式单元测试的标准流程,分享给大家:

嵌入式单元测试核心流程 1. 分析被测单元 2. 识别硬件依赖 有硬件依赖? 是 否 3a. 编写Mock/Stub 3b. 直接编写测试用例 4. 交叉编译 & 运行 5. 分析测试报告

这个流程里,步骤2和步骤3a是嵌入式特有的。PC端开发很少需要Mock硬件,但在嵌入式里,这是家常便饭。

避坑指南:我踩过的三个坑

讲完理论,分享几个我实际踩过的坑,希望能帮大家少走弯路。

坑一:测试代码污染了发布版本

我曾经把测试用的Mock函数忘记用条件编译包裹,结果发布版本里包含了测试桩,导致生产环境行为异常。从那以后,我强制要求所有测试代码必须放在 #ifdef UNIT_TEST 块内,并且CI里加了一道检查:发布版本不得包含任何测试符号。

坑二:忽略了栈深度

有一次在STM32上跑单元测试,测试框架本身占用了大量栈空间,导致被测函数调用时栈溢出。排查了半天才发现。现在我的做法是:在测试用例里显式检查栈使用量,超过阈值直接报错。

坑三:Mock写得太复杂

刚开始做单元测试时,我恨不得把所有外设都Mock一遍。结果Mock代码比被测代码还多,维护成本极高。后来我学乖了:只Mock当前测试用例涉及到的硬件依赖,其他的一律用Stub(空函数)代替。

小结

单元测试不是银弹,但它是嵌入式软件质量的守门员。记住三个关键词:隔离、可重复、自动化。PC端和嵌入式端的核心区别,说白了就是硬件依赖的处理方式不同。