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 的工作原理

  1. 你提供一个头文件(比如 i2c_driver.h)
  2. CMock 解析这个头文件,生成对应的Mock实现
  3. 在测试中,你可以设置Mock函数的期望参数和返回值
  4. 测试结束后,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 整体架构关系图

下面这张图,把三者的协作关系画清楚了:

Unity、CMock、Ceedling 协作架构图 嵌入式C单元测试框架协作架构 Ceedling 项目管理 / 构建 / CI集成 Unity 测试执行 / 断言 / 报告 CMock Mock生成 / 依赖模拟 测试用例 test_*.c 被测代码 src/*.c 覆盖率报告 gcov / lcov 调用测试运行器 触发Mock生成 Mock函数注入 调用被测函数 插桩数据 说明:Ceedling 作为顶层调度器,统一管理 Unity 的执行和 CMock 的生成,最终输出测试结果与覆盖率报告

2.6 避坑指南

最后,分享几个我这些年踩过的坑,希望能帮你少走弯路:

我曾经犯过的错:

  • 在STM32裸机上直接跑Unity —— 忘了关中断。测试跑着跑着,定时器中断进来把栈搞崩了。解决方案:测试时关全局中断,或者把测试放到PC模拟环境跑。
  • CMock 对结构体指针参数的处理 —— 默认是按值比较。如果你的函数参数是结构体指针,CMock 只会比较指针地址,不会比较结构体内容。需要手动配置 :treat_as 映射。
  • Ceedling 在Windows上的路径问题 —— Ruby 的路径分隔符和Windows不一致。建议统一用正斜杠,或者在 project.yml 里显式指定 :path_prefix。

嗯,框架选型这块,说白了就是根据项目规模选合适的工具链。别一上来就上全套,也别一直手写测试到天荒地老。找到那个平衡点,你的测试效率能翻好几倍。