4. 单元测试实战:CUnit/Unity框架使用、桩函数与驱动函数编写、代码覆盖率分析

单元测试,说白了就是给代码做“体检”。你写了一个函数,它到底跑得对不对?边界条件处理了没?异常情况会不会崩?这些问题,光靠肉眼review是看不出来的。我做了这么多年嵌入式,见过太多“看起来没问题,一跑就翻车”的案例了。

这一章,我们就来聊聊嵌入式C语言的单元测试怎么做。我会带着你从框架选型开始,一步步搞定桩函数、驱动函数,最后再看看代码覆盖率这个“照妖镜”。

4.1 为什么嵌入式单元测试这么“难”搞?

嵌入式开发和纯软件不一样。你写个Web应用,单元测试直接跑在开发机上就行。但嵌入式代码呢?它依赖硬件寄存器、外设中断、RTOS调度……这些在PC上根本跑不起来。

举个例子,你写了一个I2C驱动函数:

uint8_t i2c_write(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint8_t len) {
    // 等待总线空闲
    while (I2C->SR1 & I2C_SR1_BUSY);
    // 发送起始条件
    I2C->CR1 |= I2C_CR1_START;
    // ... 省略中间步骤
    return 0;
}

这个函数直接操作了硬件寄存器 I2C->SR1 和 I2C->CR1。你在PC上编译,连这些寄存器地址都没有,怎么测?

所以,嵌入式单元测试的核心思路就是——隔离。把被测函数从硬件依赖中“拔”出来,用软件模拟硬件行为。这就是桩函数和驱动函数的由来。

4.2 框架选型:CUnit vs Unity

C语言单元测试框架不少,但真正在嵌入式领域用得多的,就两个:CUnit 和 Unity。我个人的习惯是:

  • 如果项目跑在Linux或Windows上做测试,用CUnit,功能全。
  • 如果项目是交叉编译到ARM/MCU上,或者测试环境资源受限,用Unity,轻量级。

下面这张图帮你快速理解两者的定位差异:

CUnit vs Unity 框架对比 CUnit 自动化测试框架,功能丰富 Unity 轻量级测试框架,适合MCU 测试用例注册/套件管理 极简API,无动态内存分配 支持XML/HTML输出报告 原生支持交叉编译 适合PC/Linux环境 适合MCU/RTOS环境 选择建议 PC端自动化测试 → CUnit | MCU固件单元测试 → Unity

4.3 Unity框架实战:从安装到第一个测试用例

Unity的安装简单到令人发指。它只有三个文件:unity.c、unity.h、unity_internals.h。直接拷到你的测试工程里就行。

来看一个完整的例子。假设我们要测试一个计算校验和的函数:

// calc.h
#ifndef CALC_H
#define CALC_H
#include <stdint.h>
uint8_t calc_checksum(uint8_t *data, uint8_t len);
#endif

// calc.c
#include "calc.h"
uint8_t calc_checksum(uint8_t *data, uint8_t len) {
    uint8_t sum = 0;
    for (uint8_t i = 0; i < len; i++) {
        sum += data[i];
    }
    return sum;
}

现在,我们来写它的单元测试:

// test_calc.c
#include "unity.h"
#include "calc.h"

void setUp(void) {
    // 每个测试用例执行前调用,可以用来初始化
}

void tearDown(void) {
    // 每个测试用例执行后调用,可以用来清理
}

void test_checksum_normal(void) {
    uint8_t data[] = {0x01, 0x02, 0x03, 0x04};
    TEST_ASSERT_EQUAL_UINT8(0x0A, calc_checksum(data, 4));
}

void test_checksum_empty(void) {
    uint8_t data[] = {};
    TEST_ASSERT_EQUAL_UINT8(0x00, calc_checksum(data, 0));
}

void test_checksum_single(void) {
    uint8_t data[] = {0xFF};
    TEST_ASSERT_EQUAL_UINT8(0xFF, calc_checksum(data, 1));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_checksum_normal);
    RUN_TEST(test_checksum_empty);
    RUN_TEST(test_checksum_single);
    return UNITY_END();
}

编译运行:

gcc -o test_calc test_calc.c calc.c unity.c
./test_calc

输出结果:

test_calc.c:12:test_checksum_normal:PASS
test_calc.c:17:test_checksum_empty:PASS
test_calc.c:22:test_checksum_single:PASS

-----------------------
3 Tests 0 Failures 0 Ignored
OK
我的习惯:每个测试用例只测一个逻辑点。比如上面我拆成了三个用例:正常数据、空数据、单字节边界。这样哪个挂了,一眼就能定位。

4.4 桩函数(Stub)与驱动函数(Driver)

这是嵌入式单元测试的“硬核”部分。我遇到过很多新手,一碰到硬件依赖就卡住了。其实思路很简单:

  • 桩函数(Stub):模拟被调用函数的行为。比如你的函数调用了 HAL_GPIO_WritePin(),你就写一个假的版本,返回固定值或记录调用次数。
  • 驱动函数(Driver):模拟调用者,驱动被测函数执行。说白了,main() 函数里的 RUN_TEST() 就是一种驱动。

来看一个实际场景。假设我们有一个温度传感器驱动,它依赖硬件I2C:

// temp_sensor.c
#include "temp_sensor.h"
#include "i2c_drv.h"

float temp_read(void) {
    uint8_t buf[2];
    if (i2c_read(0x48, 0x00, buf, 2) != 0) {
        return -1.0f;  // 读取失败
    }
    int16_t raw = (buf[0] << 8) | buf[1];
    return raw * 0.0625f;
}

这里 i2c_read() 是硬件相关的。我们要测 temp_read(),就得写一个桩函数:

// test_temp_sensor.c
#include "unity.h"
#include "temp_sensor.h"

// 桩函数:模拟I2C读取
static uint8_t mock_buf[2];
static int mock_ret;

int i2c_read(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint8_t len) {
    // 记录调用参数(可选)
    // 返回预设的模拟数据
    if (len >= 2) {
        buf[0] = mock_buf[0];
        buf[1] = mock_buf[1];
    }
    return mock_ret;
}

void setUp(void) {
    mock_buf[0] = 0;
    mock_buf[1] = 0;
    mock_ret = 0;
}

void test_temp_read_normal(void) {
    // 模拟传感器返回 0x1A00 = 26.0°C
    mock_buf[0] = 0x1A;
    mock_buf[1] = 0x00;
    float temp = temp_read();
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 26.0f, temp);
}

void test_temp_read_fail(void) {
    // 模拟I2C读取失败
    mock_ret = -1;
    float temp = temp_read();
    TEST_ASSERT_FLOAT_WITHIN(0.01f, -1.0f, temp);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_temp_read_normal);
    RUN_TEST(test_temp_read_fail);
    return UNITY_END();
}
我曾经踩过的坑:桩函数里忘记重置全局状态。比如上面 mock_buf 和 mock_ret,如果不在 setUp() 里清零,上一个用例的残留数据会污染下一个用例。这种bug非常隐蔽,查起来很痛苦。

4.5 代码覆盖率分析

测试写完了,你怎么知道测得全不全?代码覆盖率就是答案。我常用的工具有两个:gcov(配合GCC)和 LCOV(生成可视化报告)。

覆盖率有几种维度:

覆盖率类型 含义 嵌入式场景建议
语句覆盖 每行代码是否被执行过 基础要求,必须达到
分支覆盖 每个if/else、switch case是否都走到 强烈建议,能发现逻辑遗漏
条件覆盖 每个布尔子表达式是否取过true/false 安全关键系统(如汽车、医疗)必须做
MC/DC覆盖 每个条件独立影响结果 DO-178C Level A要求,一般项目不用

用gcov跑覆盖率很简单。编译时加两个flag:

gcc -fprofile-arcs -ftest-coverage -o test_calc test_calc.c calc.c unity.c
./test_calc
gcov calc.c

你会得到一个 calc.c.gcov 文件,里面每行代码前面标注了执行次数:

        -:    0:Source:calc.c
        -:    1:#include "calc.h"
        -:    2:
        1:    3:uint8_t calc_checksum(uint8_t *data, uint8_t len) {
        1:    4:    uint8_t sum = 0;
        4:    5:    for (uint8_t i = 0; i < len; i++) {
        3:    6:        sum += data[i];
        -:    7:    }
        1:    8:    return sum;
        -:    9:}

看到第5行的 4 和第6行的 3 了吗?这说明循环体执行了3次(len=4时,i从0到3),循环条件判断了4次(第4次判断为false退出)。这些数字能帮你判断测试用例是否覆盖了所有路径。

我的经验:不要盲目追求100%覆盖率。有些异常处理代码(比如内存分配失败)在单元测试里很难触发。我一般定个“80/20”规则:核心逻辑分支覆盖达到90%以上,异常处理用代码review来补。

4.6 实战:一个完整的测试工程结构

最后,分享一个我常用的嵌入式单元测试工程目录结构:

project/
├── src/                    # 产品代码
│   ├── calc.c
│   ├── calc.h
│   ├── temp_sensor.c
│   └── temp_sensor.h
├── test/                   # 测试代码
│   ├── unity/              # Unity框架源码
│   │   ├── unity.c
│   │   ├── unity.h
│   │   └── unity_internals.h
│   ├── stubs/              # 桩函数
│   │   └── i2c_drv_stub.c
│   ├── test_calc.c
│   ├── test_temp_sensor.c
│   └── Makefile            # 测试专用Makefile
└── coverage/               # 覆盖率报告输出目录

这样做的好处是:产品代码和测试代码完全分离,桩函数集中管理,不会污染产品代码。我每次提交代码前,都会跑一遍 make test && make coverage,确保新代码不会破坏已有功能。

嗯,单元测试这件事,刚开始做的时候会觉得“多此一举”。但等你被线上bug折磨过几次,就会明白——测试不是浪费时间,而是在帮你省钱。尤其是嵌入式领域,一次硬件烧录的代价,往往比写测试代码高得多。