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,轻量级。
下面这张图帮你快速理解两者的定位差异:
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退出)。这些数字能帮你判断测试用例是否覆盖了所有路径。
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折磨过几次,就会明白——测试不是浪费时间,而是在帮你省钱。尤其是嵌入式领域,一次硬件烧录的代价,往往比写测试代码高得多。