21、测试嵌入式代码:交叉编译测试框架、在模拟器上运行单元测试
嵌入式开发有个特点——你的代码最终要跑在ARM、RISC-V或者某个冷门MCU上。但你的开发机是x86的。这就带来一个很现实的问题:单元测试怎么跑?
我早年做车载项目时,遇到过这么个事。团队写了个底层驱动库,在PC上测得好好的,一烧到板子上就崩。查了三天,发现是字节对齐的问题。从那以后,我养成了一个习惯:测试环境必须和目标环境尽可能一致。要么交叉编译测试框架,要么在模拟器上跑。
这一章,我就把这两种方法掰开揉碎了讲给你听。
为什么不能直接在PC上测?
你想想看,嵌入式代码里经常有这些东西:
- 直接操作寄存器地址(比如
*(volatile uint32_t*)0x40021000 = 0x01;) - 依赖特定中断向量表
- 使用芯片厂商提供的HAL库
- 有严格的时序要求(比如I2C通信的延迟)
这些在PC上根本跑不了。强行跑,要么段错误,要么结果完全不对。
所以,我们得用两种思路来解决:
- 交叉编译测试框架——把测试程序编译成目标平台的二进制,然后传到板子上跑。
- 模拟器运行——在PC上模拟目标CPU,让测试程序以为自己跑在嵌入式环境里。
核心原则:测试环境越接近生产环境,测试结果越可信。但也要平衡成本和效率。
方案一:交叉编译测试框架
说白了,就是用目标平台的交叉编译器,把你的测试代码和测试框架(比如Unity、CMock、CppUTest)一起编译成目标文件,然后扔到板子上执行。
我个人的习惯是这么做的:
1. 准备交叉编译工具链
比如ARM Cortex-M系列,用 arm-none-eabi-gcc。安装方式各家不同,但核心是拿到编译器、链接器、以及对应的C库。
# 以ARM GCC为例
sudo apt install gcc-arm-none-eabi
# 验证
arm-none-eabi-gcc --version
2. 编写测试代码
假设我们有个简单的计算函数:
// calc.c
int add(int a, int b) {
return a + b;
}
// calc.h
#ifndef CALC_H
#define CALC_H
int add(int a, int b);
#endif
测试代码用Unity框架写:
// test_calc.c
#include "unity.h"
#include "calc.h"
void setUp(void) {}
void tearDown(void) {}
void test_add_positive(void) {
TEST_ASSERT_EQUAL_INT(5, add(2, 3));
}
void test_add_negative(void) {
TEST_ASSERT_EQUAL_INT(-1, add(2, -3));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_add_positive);
RUN_TEST(test_add_negative);
return UNITY_END();
}
3. 交叉编译测试程序
arm-none-eabi-gcc \
-I./unity \
-I./src \
-mcpu=cortex-m4 \
-mthumb \
-nostartfiles \
-T linkerscript.ld \
-o test_calc.elf \
test_calc.c calc.c unity.c
这里要注意几个点:
-mcpu和-mthumb指定目标CPU架构-nostartfiles告诉编译器不要链接PC的启动文件-T linkerscript.ld提供链接脚本,告诉编译器代码段、数据段放哪里
我曾经踩过的坑:链接脚本写错了,导致测试程序加载到板子上后,全局变量全部初始化为0。查了两天,发现是.bss段地址没对齐。所以,链接脚本一定要和你的芯片内存映射严格对应。
4. 把测试程序传到板子上运行
通常用OpenOCD + GDB,或者直接用串口烧录。运行结果通过串口打印出来:
openocd -f board/stm32f4discovery.cfg -c "program test_calc.elf verify reset exit"
# 然后打开串口终端看输出
screen /dev/ttyUSB0 115200
输出大概长这样:
Unity test run 1 of 1
-----------------------
test_add_positive: PASS
test_add_negative: PASS
-----------------------
2 tests 0 failures 0 ignored
方案二:在模拟器上运行单元测试
交叉编译虽然真实,但有个问题——每次改代码都要烧录,太慢了。尤其是调试阶段,改一行代码等30秒烧录,谁受得了?
所以,我更喜欢用模拟器。在PC上模拟一个目标CPU,测试程序跑在模拟器里,速度飞快。
常用的模拟器
| 模拟器 | 适用平台 | 特点 |
|---|---|---|
| QEMU | ARM、RISC-V、x86等 | 开源、支持多种外设模拟、速度较快 |
| Renode | ARM、RISC-V等 | 支持多核、外设丰富、调试能力强 |
| Unicorn | ARM、MIPS、x86等 | 轻量级、只模拟CPU指令、无外设 |
我个人最常用的是QEMU。它既能模拟CPU,又能模拟一些常见外设(UART、GPIO、Timer等)。对于单元测试来说,够用了。
用QEMU跑单元测试的步骤
还是用上面的 add 函数举例。这次我们编译成能在QEMU模拟的ARM平台上运行的程序。
# 编译,这次不需要链接脚本,因为QEMU有默认的启动环境
arm-none-eabi-gcc \
-I./unity \
-I./src \
-mcpu=cortex-m3 \
-mthumb \
-specs=rdimon.specs \
-o test_calc_qemu.elf \
test_calc.c calc.c unity.c
注意这里用了 -specs=rdimon.specs,这是QEMU提供的半主机(semihosting)支持。说白了,就是让嵌入式程序可以通过QEMU的调试接口,把printf输出到PC的终端上。
然后启动QEMU运行:
qemu-system-arm \
-machine lm3s6965evb \
-cpu cortex-m3 \
-nographic \
-kernel test_calc_qemu.elf
输出结果和之前类似,但这次是在PC终端上直接看到的,不需要烧录。
小技巧:QEMU支持GDB调试。你可以用 -s -S 参数启动,然后在另一个终端用 arm-none-eabi-gdb 连接上去,单步调试测试代码。这对于排查测试失败的原因非常有帮助。
两种方案的对比
| 维度 | 交叉编译 + 真机运行 | 模拟器运行 |
|---|---|---|
| 真实性 | 最高(真实硬件) | 较高(取决于模拟精度) |
| 速度 | 慢(烧录 + 运行) | 快(直接在PC上跑) |
| 调试便利性 | 一般(需要调试器硬件) | 好(GDB直接连) |
| 外设支持 | 完全真实 | 有限(取决于模拟器) |
| 适用场景 | 集成测试、硬件相关测试 | 单元测试、算法测试 |
我的建议是:单元测试用模拟器,集成测试用真机。这样既保证了速度,又保证了最终的正确性。
一个完整的实战例子
为了让你看得更清楚,我画了一张流程图,展示整个测试流程:
这个流程我用了很多年。说白了,就是三步:写代码 → 交叉编译 → 在目标环境运行。区别只在于第三步是用真机还是模拟器。
避坑指南
最后,分享几个我实际项目中遇到的坑:
- 浮点运算问题:有些MCU没有硬件浮点单元,但PC默认开了。交叉编译时一定要加
-mfloat-abi=soft,否则测试程序在板子上跑会触发硬件异常。 - 堆栈大小:测试框架本身会占用堆栈。Unity还好,CppUTest如果用了mock,堆栈消耗会很大。我遇到过测试跑到一半就复位的情况,最后发现是堆栈溢出。解决办法是在链接脚本里把堆栈设大一点。
- 模拟器不是万能的:QEMU模拟的外设有限。如果你的代码依赖某个特定外设(比如CAN控制器),模拟器可能不支持。这时候只能上真机。
- 时间相关测试:在模拟器里,时间流逝和真实硬件不一样。如果你有超时判断的逻辑,在模拟器里可能永远不超时,或者瞬间超时。我的做法是用一个宏来控制时间基准,测试时用模拟时间,生产时用硬件定时器。
总结一下:交叉编译测试框架和模拟器运行,是嵌入式单元测试的两条腿。缺了哪条,走起路来都别扭。我个人习惯是:日常开发用QEMU跑单元测试,每周集成测试时烧到真机上跑一遍。这样既保证了效率,又保证了质量。
嗯,这一章的内容就到这里。记住,测试环境的选择没有银弹,关键是要理解你的代码到底依赖了什么硬件特性,然后选择最合适的方案。