3. 测试环境搭建

做嵌入式单元测试,跟做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 知识体系:嵌入式测试的三座大山

下面这张图,是我自己总结的嵌入式测试知识体系。每次带新人,我都会先让他们看这张图。

嵌入式测试三座大山 嵌入式单元测试三大特殊挑战 交叉编译 资源受限 硬件依赖 编译环境不一致 运行时环境不同 调试困难 RAM/Flash不足 CPU频率低 外设有限 直接操作寄存器 依赖外设中断 硬件时序敏感 解决方案:桩函数 + 驱动 + 模拟器 桩函数隔离硬件 替换寄存器操作 驱动管理资源 静态分配/分批次 模拟器运行 QEMU/硬件仿真 核心思路:把硬件依赖和资源限制通过桩函数和驱动抽象掉,让测试在可控环境中运行

这张图把三个挑战和对应的解决方案串起来了。你注意看最下面的说明框——核心思路就是抽象。把硬件操作抽象成桩函数,把资源管理抽象成驱动,把运行环境抽象成模拟器。

3.5 总结:记住这三条

嵌入式环境对测试的影响,归根结底就是三条:

  1. 交叉编译让编译和运行分离,测试必须在目标环境验证。
  2. 资源受限逼着你精简测试代码,用静态分配代替动态分配。
  3. 硬件依赖迫使你使用桩函数和硬件抽象层,把不可控的硬件操作替换成可控的模拟行为。

我个人觉得,这三条里最难处理的是硬件依赖。因为硬件行为有时候是不确定的——比如外部中断的触发时机、ADC采样的噪声。这些在PC上根本模拟不出来。所以我的建议是:能用桩函数隔离的就隔离,隔离不了的就用硬件在环测试(HIL)。后面的章节,我会详细讲桩函数怎么设计,驱动怎么写,以及怎么在模拟器上跑测试。