第三章 嵌入式环境特殊性:交叉编译、资源受限、硬件依赖对测试的影响
做嵌入式单元测试,跟做PC软件测试完全是两码事。
我刚开始带团队的时候,有个同事把PC上的单元测试框架直接搬过来,编译通过,跑起来也绿油油一片。结果烧到板子上,程序直接跑飞。查了一整天,发现是交叉编译器的内存对齐策略不一样。嗯,这种坑,踩过一次就忘不了。
这一章,我就把嵌入式环境对测试的三个核心影响掰开来讲:交叉编译、资源受限、硬件依赖。理解了这些,你才能写出真正能在嵌入式平台上跑通的测试。
3.1 交叉编译:你的测试不在本地跑
PC上的单元测试,编译、链接、运行都在同一台机器上。嵌入式不是这样。你在x86的Linux或Windows上写代码,用交叉编译器生成ARM或RISC-V的目标文件,然后烧到板子上跑——或者用模拟器跑。
这带来了几个麻烦:
- 编译环境不一致:交叉编译器的版本、优化选项、标准库实现,跟本地编译器可能差很多。我遇到过GCC 4.8和GCC 9.2对
volatile的处理不一样,导致测试在PC上通过,在目标板上失败。 - 运行时环境不同:PC上有完整的操作系统、文件系统、标准输入输出。嵌入式目标板上可能什么都没有,或者只有个裸机环境。你的测试代码里如果用了
printf,在PC上能打印,在板子上可能直接卡死。 - 调试困难:PC上可以用GDB单步调试,看变量值。交叉编译环境下,你得用JTAG或串口调试,效率低很多。
核心结论:嵌入式单元测试的编译和运行环境必须分离。测试代码在PC上编译,在目标板或模拟器上运行。这个分离,是所有桩函数和驱动设计的起点。
我个人的习惯是:在项目初期就搭建好交叉编译+模拟器的测试流水线。用QEMU模拟目标CPU,把测试镜像跑在模拟器上。这样至少能保证编译和基本逻辑正确。等板子到了,再在真实硬件上做回归。
3.2 资源受限:内存、Flash、CPU都紧张
嵌入式芯片的资源有多紧张?我做过一个项目,MCU只有64KB Flash和8KB RAM。你要在这上面跑单元测试,还要跑被测函数和桩函数。
你想想看,PC上随便一个测试框架的链接库就几百KB。嵌入式上根本塞不下。
资源受限带来的具体影响:
| 资源维度 | PC环境 | 嵌入式环境 | 对测试的影响 |
|---|---|---|---|
| RAM | 8GB+ | 2KB ~ 512KB | 测试用例不能开大数组,不能递归太深 |
| Flash | TB级 | 16KB ~ 2MB | 测试代码必须精简,不能链接完整标准库 |
| CPU频率 | 2GHz+ | 16MHz ~ 400MHz | 测试执行时间变长,不能做大量循环 |
| 外设 | 丰富 | 有限或无 | 测试不能依赖外设中断或DMA |
避坑指南:我曾经在测试一个状态机模块时,用了malloc动态分配测试数据。在PC上跑得好好的,烧到板子上就随机崩溃。查了两天才发现,目标板上的堆空间只有256字节,而我的测试数据需要512字节。从那以后,我在嵌入式测试中一律用静态数组,不用动态内存。
怎么应对资源受限?我建议:
- 测试代码静态分配:所有测试数据、桩函数缓冲区都用全局数组或静态变量,不用
malloc。 - 精简测试框架:不要用CUnit、Unity这种通用框架的完整版。自己写一个微型测试框架,只保留断言和测试用例注册功能。我写过的一个微型框架,整个代码不到200行,占用Flash不到2KB。
- 分批次测试:一次只编译和运行一个模块的测试用例。全部用例一起跑,Flash和RAM可能不够。
3.3 硬件依赖:你的代码跟外设绑死了
嵌入式软件最头疼的就是硬件依赖。一个函数可能直接操作GPIO寄存器、读取ADC值、发送UART数据。这些操作在PC上根本没法执行——没有对应的硬件地址。
举个例子:
// 直接操作硬件寄存器的函数
void led_on(void) {
*(volatile uint32_t *)0x40020C14 |= (1 << 13); // 设置GPIOB_BSRR
}
这个函数在PC上编译能通过,但一运行就会段错误——因为地址0x40020C14在PC的虚拟地址空间中不存在。
硬件依赖对测试的影响,说白了就是:被测函数里只要有一行硬件操作代码,你就没法在PC上直接测试它。
解决方案就是桩函数。我们把硬件操作封装成函数,然后在测试时用桩函数替换掉真实的硬件操作。比如:
// 硬件抽象层
void hal_gpio_set(uint32_t port, uint32_t pin) {
// 真实实现:操作寄存器
*(volatile uint32_t *)(port + 0x14) |= (1 << pin);
}
// 测试时用的桩函数
void __wrap_hal_gpio_set(uint32_t port, uint32_t pin) {
// 桩函数:只记录调用,不操作硬件
last_port = port;
last_pin = pin;
call_count++;
}
小技巧:我习惯在项目一开始就把所有硬件操作都封装成hal_xxx函数。这样后面写测试时,只需要链接桩函数库,不需要改被测代码。如果等到测试阶段才去抽硬件操作,那改动量就大了。
3.4 知识体系:嵌入式测试的三座大山
下面这张图,是我自己总结的嵌入式测试知识体系。每次带新人,我都会先让他们看这张图。
这张图把三个挑战和对应的解决方案串起来了。你注意看最下面的说明框——核心思路就是抽象。把硬件操作抽象成桩函数,把资源管理抽象成驱动,把运行环境抽象成模拟器。
3.5 总结:记住这三条
嵌入式环境对测试的影响,归根结底就是三条:
- 交叉编译让编译和运行分离,测试必须在目标环境验证。
- 资源受限逼着你精简测试代码,用静态分配代替动态分配。
- 硬件依赖迫使你使用桩函数和硬件抽象层,把不可控的硬件操作替换成可控的模拟行为。
我个人觉得,这三条里最难处理的是硬件依赖。因为硬件行为有时候是不确定的——比如外部中断的触发时机、ADC采样的噪声。这些在PC上根本模拟不出来。所以我的建议是:能用桩函数隔离的就隔离,隔离不了的就用硬件在环测试(HIL)。后面的章节,我会详细讲桩函数怎么设计,驱动怎么写,以及怎么在模拟器上跑测试。