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
嵌入式单元测试的核心流程
下面这张图,是我做嵌入式单元测试的标准流程,分享给大家:
这个流程里,步骤2和步骤3a是嵌入式特有的。PC端开发很少需要Mock硬件,但在嵌入式里,这是家常便饭。
避坑指南:我踩过的三个坑
讲完理论,分享几个我实际踩过的坑,希望能帮大家少走弯路。
坑一:测试代码污染了发布版本
我曾经把测试用的Mock函数忘记用条件编译包裹,结果发布版本里包含了测试桩,导致生产环境行为异常。从那以后,我强制要求所有测试代码必须放在 #ifdef UNIT_TEST 块内,并且CI里加了一道检查:发布版本不得包含任何测试符号。
坑二:忽略了栈深度
有一次在STM32上跑单元测试,测试框架本身占用了大量栈空间,导致被测函数调用时栈溢出。排查了半天才发现。现在我的做法是:在测试用例里显式检查栈使用量,超过阈值直接报错。
坑三:Mock写得太复杂
刚开始做单元测试时,我恨不得把所有外设都Mock一遍。结果Mock代码比被测代码还多,维护成本极高。后来我学乖了:只Mock当前测试用例涉及到的硬件依赖,其他的一律用Stub(空函数)代替。
小结
单元测试不是银弹,但它是嵌入式软件质量的守门员。记住三个关键词:隔离、可重复、自动化。PC端和嵌入式端的核心区别,说白了就是硬件依赖的处理方式不同。