4. 汽车电子典型环境应力

单元测试,说白了就是给函数做“体检”。你写了一个模块,它能不能跑?边界条件处理了没?异常输入会不会崩?这些不能靠拍脑袋,得靠工具。

嵌入式 C 语言的单元测试,跟桌面端不太一样。资源受限、硬件依赖多、交叉编译环境复杂。我这些年用过不少框架,最后常驻工具箱的是三个:CUnit、Unity、CMock。它们各有脾气,选对了能省一半力气。

4.1 三大框架的定位与选型

先看一张对比表,帮你快速建立印象:

框架 核心特点 适用场景 资源开销 个人评价
CUnit 类 JUnit 风格,测试注册 + 运行器 PC 端模拟测试、Linux 环境 中等,需动态内存 成熟稳定,但太重,不适合裸机
Unity 纯 C 宏驱动,无动态分配 嵌入式目标板、资源受限 MCU 极低,ROM/RAM 友好 我的首选,裸机项目几乎都用它
CMock 自动生成 Mock 桩代码 需要隔离硬件依赖的测试 生成代码较大,需配合 Unity 好用,但别滥用,否则测试比代码还长
我的习惯:如果测试跑在开发机上(比如用 GCC 模拟),我选 CUnit。如果测试要烧到 STM32 或 MSP430 上跑,Unity 是唯一靠谱的选择。CMock 通常跟 Unity 搭配,专门对付那些“碰硬件”的模块。

4.2 CUnit:桌面模拟测试的老将

CUnit 的结构很清晰:你写测试函数,注册到 Suite 里,然后跑。它提供了断言宏、测试运行器、结果输出。

一个典型例子:

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>

/* 被测函数 */
int add(int a, int b) {
    return a + b;
}

/* 测试用例 */
void test_add_positive(void) {
    CU_ASSERT(add(2, 3) == 5);
}

void test_add_negative(void) {
    CU_ASSERT(add(-1, -2) == -3);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("Suite_Add", NULL, NULL);
    CU_add_test(suite, "test positive", test_add_positive);
    CU_add_test(suite, "test negative", test_add_negative);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

嗯,这里要注意:CUnit 需要动态分配内存来管理测试注册表。在 Linux 或 Windows 上没问题,但放到 FreeRTOS 的堆里,可能就炸了。我在一个项目中试过把 CUnit 移植到 VxWorks,折腾了两天,最后还是换成了 Unity。

避坑:CUnit 的 CU_initialize_registry() 会调用 malloc。如果你的嵌入式环境没有堆,或者堆很小,就别用它。我曾经在一个只有 4KB RAM 的 MCU 上试过,直接 HardFault。

4.3 Unity:嵌入式单元测试的标配

Unity 的设计哲学是“极简”。它不依赖任何标准库函数,所有断言都是宏展开。你甚至可以在中断服务函数里调用它(虽然我不建议这么干)。

看代码:

#include "unity.h"

/* 被测函数 */
int divide(int a, int b) {
    if (b == 0) return -1;  /* 错误处理 */
    return a / b;
}

/* 测试用例 */
void setUp(void) {}    /* 每个测试前执行 */
void tearDown(void) {} /* 每个测试后执行 */

void test_divide_normal(void) {
    TEST_ASSERT_EQUAL_INT(2, divide(6, 3));
}

void test_divide_by_zero(void) {
    TEST_ASSERT_EQUAL_INT(-1, divide(5, 0));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_divide_normal);
    RUN_TEST(test_divide_by_zero);
    return UNITY_END();
}

Unity 的断言宏很丰富:TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE、TEST_ASSERT_BITS(位比较,嵌入式神器)。我最常用的是 TEST_ASSERT_EQUAL_HEX32,调试寄存器值的时候一目了然。

关键点:Unity 的 setUp 和 tearDown 是空函数,你可以自己填充。比如在 setUp 里初始化 GPIO,在 tearDown 里清理状态。这样每个测试用例都是独立的,不会互相污染。

4.4 CMock:自动生成 Mock 与 Stub

嵌入式代码最头疼的是硬件依赖。你测一个温度采集函数,它调用了 I2C_Read()。没有硬件,你怎么测?

CMock 就是干这个的。你给它一个头文件,它自动生成 Mock 函数。你可以在测试里设置“期望”:这个函数应该被调用几次?参数是什么?返回值是什么?

举个例子,假设有头文件 i2c_driver.h:

/* i2c_driver.h */
int I2C_Read(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint8_t len);

用 CMock 生成 Mock 后,测试可以这样写:

#include "unity.h"
#include "mock_i2c_driver.h"  /* CMock 生成的头文件 */

/* 被测函数:读取温度传感器 */
int read_temperature(void) {
    uint8_t raw;
    if (I2C_Read(0x48, 0x00, &raw, 1) != 0) {
        return -1;
    }
    return (int)raw * 10;  /* 假设每 LSB 代表 0.1°C */
}

void test_read_temperature_ok(void) {
    uint8_t expected_raw = 0x1A;  /* 26°C */
    I2C_Read_ExpectAndReturn(0x48, 0x00, NULL, 1, 0);
    /* 注意:CMock 会捕获 data 指针,我们需要设置返回值 */
    I2C_Read_ReturnArrayThruPtr_data(&expected_raw, 1);

    int temp = read_temperature();
    TEST_ASSERT_EQUAL_INT(260, temp);  /* 26.0°C */
}

看着有点绕?其实逻辑很简单:你告诉 CMock,“I2C_Read 会被调用,参数应该是这些,返回值应该是那个,并且通过 data 指针返回这个值”。CMock 会在测试运行时检查实际调用是否匹配。

我的经验:CMock 生成的代码量不小。一个中等复杂度的驱动头文件,可能生成 2-3KB 的 Mock 代码。在资源紧张的 MCU 上,我通常只在 PC 模拟环境里用 CMock,目标板测试还是手写 Stub 更可控。

4.5 测试桩(Stub)与模拟(Mock)技术

这两个概念经常被混用,但区别很大。我画了一张图来说明:

Stub vs Mock 对比图 Stub(测试桩) “替身演员” 固定返回值,无行为验证 例如:I2C_Read 总是返回 0 实现简单,适合快速隔离 Mock(模拟对象) “考官” 验证调用次数、参数顺序 例如:I2C_Read 必须被调用 2 次 实现复杂,适合行为验证 选择建议 只想让函数“不报错” → 用 Stub 需要验证“函数是否按预期被调用” → 用 Mock 手写 Stub CMock 生成 混合使用

简单来说:

  • Stub:你手写一个函数,返回固定值。比如 int GetTemperature(void) { return 25; }。它不关心被调了几次,参数是什么。适合快速隔离硬件。
  • Mock:它记录调用信息,并验证是否符合预期。比如“GetTemperature 应该被调用 3 次,且每次间隔 100ms”。CMock 就是干这个的。
我曾经踩过的坑:在一个电机控制项目里,我用 Stub 替换了 PWM 驱动,测试通过。结果上板后电机不转。查了半天,发现 Stub 返回的“成功”掩盖了驱动初始化顺序的错误。后来改用 Mock,严格验证了初始化函数的调用顺序,才把问题揪出来。所以,Stub 适合测逻辑,Mock 适合测交互。

4.6 实战:组合使用 Unity + CMock

最后给一个完整的实战流程。假设我们要测试一个“温度报警”模块,它依赖 I2C 驱动和 GPIO 驱动。

  1. 准备头文件:i2c_driver.h、gpio_driver.h
  2. 用 CMock 生成 Mock:ruby cmock.rb i2c_driver.h gpio_driver.h
  3. 编写测试用例:在 test_alarm.c 里包含 mock_i2c_driver.h 和 mock_gpio_driver.h
  4. 设置期望:比如“温度超过阈值时,GPIO 应该被拉高”
  5. 编译运行:用 Unity 的 runner 脚本自动生成 main 函数

代码骨架:

/* test_alarm.c */
#include "unity.h"
#include "mock_i2c_driver.h"
#include "mock_gpio_driver.h"

/* 被测函数声明 */
extern void alarm_check(void);

void test_alarm_trigger_when_high_temp(void) {
    /* 模拟 I2C 读取到高温 85°C */
    uint8_t temp_val = 0x55;  /* 85 * 10 = 0x55 */
    I2C_Read_ExpectAndReturn(0x48, 0x00, NULL, 1, 0);
    I2C_Read_ReturnArrayThruPtr_data(&temp_val, 1);

    /* 期望 GPIO 被拉高 */
    GPIO_WritePin_Expect(ALARM_PIN, 1);

    alarm_check();
}

嗯,这里要注意:CMock 的 ExpectAndReturn 系列函数,参数顺序是“期望的输入参数 + 返回值”。ReturnArrayThruPtr_data 是告诉 CMock:“当函数通过指针写数据时,把这段内容写进去”。

核心原则:测试不是越多越好。我一般只对“有外部依赖”的模块用 Mock,纯算法模块直接用 Unity 断言。记住,测试代码也是负债,维护它需要成本。把精力花在边界条件和异常路径上,比覆盖 100% 的普通路径更有价值。