21、测试嵌入式代码:交叉编译测试框架、在模拟器上运行单元测试

嵌入式开发有个特点——你的代码最终要跑在ARM、RISC-V或者某个冷门MCU上。但你的开发机是x86的。这就带来一个很现实的问题:单元测试怎么跑?

我早年做车载项目时,遇到过这么个事。团队写了个底层驱动库,在PC上测得好好的,一烧到板子上就崩。查了三天,发现是字节对齐的问题。从那以后,我养成了一个习惯:测试环境必须和目标环境尽可能一致。要么交叉编译测试框架,要么在模拟器上跑。

这一章,我就把这两种方法掰开揉碎了讲给你听。

为什么不能直接在PC上测?

你想想看,嵌入式代码里经常有这些东西:

  • 直接操作寄存器地址(比如 *(volatile uint32_t*)0x40021000 = 0x01;
  • 依赖特定中断向量表
  • 使用芯片厂商提供的HAL库
  • 有严格的时序要求(比如I2C通信的延迟)

这些在PC上根本跑不了。强行跑,要么段错误,要么结果完全不对。

所以,我们得用两种思路来解决:

  1. 交叉编译测试框架——把测试程序编译成目标平台的二进制,然后传到板子上跑。
  2. 模拟器运行——在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直接连)
外设支持 完全真实 有限(取决于模拟器)
适用场景 集成测试、硬件相关测试 单元测试、算法测试

我的建议是:单元测试用模拟器,集成测试用真机。这样既保证了速度,又保证了最终的正确性。

一个完整的实战例子

为了让你看得更清楚,我画了一张流程图,展示整个测试流程:

嵌入式单元测试流程 嵌入式源码 测试代码 测试框架 交叉编译(arm-none-eabi-gcc) 方案A:烧录到真机运行 方案B:在QEMU模拟器运行 获取测试结果(串口/终端输出)

这个流程我用了很多年。说白了,就是三步:写代码 → 交叉编译 → 在目标环境运行。区别只在于第三步是用真机还是模拟器。

避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 浮点运算问题:有些MCU没有硬件浮点单元,但PC默认开了。交叉编译时一定要加 -mfloat-abi=soft,否则测试程序在板子上跑会触发硬件异常。
  • 堆栈大小:测试框架本身会占用堆栈。Unity还好,CppUTest如果用了mock,堆栈消耗会很大。我遇到过测试跑到一半就复位的情况,最后发现是堆栈溢出。解决办法是在链接脚本里把堆栈设大一点。
  • 模拟器不是万能的:QEMU模拟的外设有限。如果你的代码依赖某个特定外设(比如CAN控制器),模拟器可能不支持。这时候只能上真机。
  • 时间相关测试:在模拟器里,时间流逝和真实硬件不一样。如果你有超时判断的逻辑,在模拟器里可能永远不超时,或者瞬间超时。我的做法是用一个宏来控制时间基准,测试时用模拟时间,生产时用硬件定时器。

总结一下:交叉编译测试框架和模拟器运行,是嵌入式单元测试的两条腿。缺了哪条,走起路来都别扭。我个人习惯是:日常开发用QEMU跑单元测试,每周集成测试时烧到真机上跑一遍。这样既保证了效率,又保证了质量。

嗯,这一章的内容就到这里。记住,测试环境的选择没有银弹,关键是要理解你的代码到底依赖了什么硬件特性,然后选择最合适的方案。