2. 嵌入式C语言单元测试框架选型:Unity、Ceedling、CMock 对比与选择
做嵌入式开发的人,一听到「单元测试」四个字,第一反应往往是:「我们这ROM才64K,跑什么测试框架?」
我当年也是这么想的。直到有一次,一个I2C驱动的小bug,让整批设备在低温环境下随机死机,排查了整整两周。从那以后,我彻底改变了看法——不是要不要测的问题,而是怎么在资源受限的环境下,把测试跑起来。
今天咱们就聊聊嵌入式C语言里最常用的三驾马车:Unity、Ceedling、CMock。它们不是互相替代的关系,而是一套组合拳。
一句话总结三者的关系:
- Unity —— 轻量级测试执行器,负责「跑测试」
- CMock —— 自动Mock生成器,负责「造替身」
- Ceedling —— 项目级构建管理工具,负责「管全局」
2.1 Unity:嵌入式测试的「最小可行方案」
Unity 是 ThrowTheSwitch 社区推出的纯C语言测试框架。它的核心设计哲学就一句话:能在任何C编译器上跑。
我习惯把它叫做「裸机测试框架」——因为它真的不依赖操作系统、不依赖标准库、不依赖动态内存分配。你把它两个.c文件丢进工程,就能开始写测试。
2.1.1 Unity 的核心能力
- 断言宏:TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE、TEST_ASSERT_STRING 等20+种
- 测试分组:通过 setUp() 和 tearDown() 管理测试夹具
- 测试运行器:自动生成 main() 函数,顺序执行所有测试用例
- 极低资源占用:ROM ~5KB,RAM ~几百字节
// Unity 测试用例示例
#include "unity.h"
#include "my_math.h"
void setUp(void) {
// 每个测试用例执行前调用
}
void tearDown(void) {
// 每个测试用例执行后调用
}
void test_add_positive_numbers(void) {
TEST_ASSERT_EQUAL_INT32(5, add(2, 3));
}
void test_add_negative_numbers(void) {
TEST_ASSERT_EQUAL_INT32(-5, add(-2, -3));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_add_positive_numbers);
RUN_TEST(test_add_negative_numbers);
return UNITY_END();
}
我的经验:Unity 最适合「裸机驱动层」的测试。比如GPIO、UART、SPI的寄存器操作函数。这些代码通常不涉及外部依赖,一个Unity文件就能搞定。
2.2 CMock:当被测函数调用了别人
写单元测试时,最头疼的问题是什么?——依赖。
你测一个温度传感器驱动,它内部调用了 I2C_Write() 和 I2C_Read()。如果I2C驱动还没写好,或者硬件没连上,你怎么测?
CMock 就是来解决这个问题的。它根据头文件自动生成Mock函数,让你能「模拟」外部依赖的行为。
2.2.1 CMock 的工作原理
- 你提供一个头文件(比如 i2c_driver.h)
- CMock 解析这个头文件,生成对应的Mock实现
- 在测试中,你可以设置Mock函数的期望参数和返回值
- 测试结束后,CMock 自动验证所有Mock调用是否符合预期
// 使用 CMock 模拟 I2C 驱动
#include "unity.h"
#include "mock_i2c_driver.h"
#include "temperature_sensor.h"
void test_temperature_sensor_reads_correctly(void) {
// 设置Mock期望:I2C_Read 被调用一次,返回 0x1A
I2C_Read_ExpectAndReturn(0x50, 0x00, 0x1A);
// 执行被测函数
uint8_t temp = temperature_sensor_read();
// 验证结果
TEST_ASSERT_EQUAL_UINT8(25, temp);
}
曾经踩过的坑:CMock 对函数指针和回调的支持比较弱。如果你的代码大量使用函数指针做回调注册,CMock 生成的Mock可能会让你改到头秃。我建议这类场景用手写Mock替代。
2.3 Ceedling:把一切串起来的「管家」
Unity 和 CMock 都是库级别的工具。但实际项目中,你还需要:
- 管理测试文件与源文件的依赖关系
- 自动发现并注册测试用例
- 生成测试覆盖率报告
- 集成到CI/CD流水线
Ceedling 就是干这个的。它基于 Ruby 开发,底层调用 Rake 构建系统,把 Unity + CMock 整合成一个完整的测试工程。
2.3.1 Ceedling 的项目结构
my_project/
├── src/ # 产品源代码
│ ├── main.c
│ └── temperature_sensor.c
├── test/ # 测试文件
│ ├── test_temperature_sensor.c
│ └── test_main.c
├── support/ # 辅助文件
│ └── build_helpers.rb
└── project.yml # Ceedling 配置文件
2.3.2 核心配置项(project.yml)
| 配置项 | 说明 | 我的推荐值 |
|---|---|---|
| :release_build | 是否同时构建Release版本 | true(确保测试与发布一致) |
| :unity | Unity框架路径 | 使用Ceedling内置版本 |
| :cmock | CMock配置(Mock生成规则) | strict_mock: true |
| :plugins | 插件列表(覆盖率、内存检测等) | gcov, memcheck |
| :defines | 全局宏定义 | UNITY_INCLUDE_DOUBLE |
2.4 三者的对比与选型建议
说了这么多,到底怎么选?我整理了一张对比表,你一看就明白:
| 维度 | Unity | CMock | Ceedling |
|---|---|---|---|
| 定位 | 测试执行器 | Mock生成器 | 项目管理器 |
| 依赖 | 无(纯C) | Ruby + 头文件解析 | Ruby + Rake |
| ROM占用 | ~5KB | 生成代码,取决于Mock数量 | 不部署到目标机 |
| 适用场景 | 裸机驱动、纯算法 | 有外部依赖的模块 | 中大型项目、CI集成 |
| 学习曲线 | 低(1天上手) | 中(需理解Mock概念) | 中高(需Ruby基础) |
| 覆盖率支持 | 需手动集成gcov | 不直接支持 | 内置gcov插件 |
我的选型建议:
- 小项目(< 5人月):只用Unity,手写Mock。简单直接,不引入Ruby依赖。
- 中项目(5-20人月):Unity + CMock,手动管理测试构建。适合团队有C功底但不想学Ruby。
- 大项目(> 20人月):Unity + CMock + Ceedling 全套。自动化程度高,CI友好,长期维护成本低。
2.5 整体架构关系图
下面这张图,把三者的协作关系画清楚了:
2.6 避坑指南
最后,分享几个我这些年踩过的坑,希望能帮你少走弯路:
我曾经犯过的错:
- 在STM32裸机上直接跑Unity —— 忘了关中断。测试跑着跑着,定时器中断进来把栈搞崩了。解决方案:测试时关全局中断,或者把测试放到PC模拟环境跑。
- CMock 对结构体指针参数的处理 —— 默认是按值比较。如果你的函数参数是结构体指针,CMock 只会比较指针地址,不会比较结构体内容。需要手动配置 :treat_as 映射。
- Ceedling 在Windows上的路径问题 —— Ruby 的路径分隔符和Windows不一致。建议统一用正斜杠,或者在 project.yml 里显式指定 :path_prefix。
嗯,框架选型这块,说白了就是根据项目规模选合适的工具链。别一上来就上全套,也别一直手写测试到天荒地老。找到那个平衡点,你的测试效率能翻好几倍。