2. 单元测试框架选型:Unity vs Ceedling vs CMock,如何在MDK/IAR中集成
做嵌入式单元测试,选框架是个绕不开的坎。我记得刚带团队那会儿,大家还在用手动打桩、串口打印的方式验证调度器逻辑,每次改个优先级算法,光回归测试就要折腾一整天。后来我下定决心引入自动化测试框架,试了一圈,最后锁定了Unity、Ceedling和CMock这套组合拳。
说白了,这三兄弟各有分工:Unity是测试执行引擎,Ceedling是构建管理工具,CMock是自动打桩生成器。你想想看,如果只拿Unity裸写测试,那跟用assert.h区别不大;但加上Ceedling和CMock,整个测试工程就活了。
2.1 三兄弟的角色定位
先看一张图,理清它们之间的关系:
从图上能看出来,Ceedling是那个「管事的」。它负责扫描测试文件、调用编译器、链接Unity和CMock生成的代码,最后输出测试报告。我习惯把Ceedling比作嵌入式世界的Makefile生成器——你只需要写一个project.yml,它就能帮你搞定一切。
2.2 框架对比:什么时候选谁?
很多初学者会问:「我能不能只用Unity?」当然可以。但你要想清楚场景。我整理了一个对比表,方便你快速决策:
| 维度 | Unity 单独使用 | Unity + Ceedling | Unity + Ceedling + CMock |
|---|---|---|---|
| 适用场景 | 纯函数测试,无外部依赖 | 有模块依赖,但不需要复杂打桩 | 模块间耦合严重,需要隔离测试 |
| 配置复杂度 | 低(手动写main) | 中(写project.yml) | 中高(需定义mock头文件) |
| 打桩方式 | 手动写桩函数 | 手动写桩函数 | 自动生成桩函数 |
| 回归测试效率 | 低(每次手动编译) | 高(一键运行) | 高(自动管理桩) |
| 推荐指数 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
2.3 在MDK中集成:实战步骤
MDK(Keil)是ARM单片机开发的主流IDE。我曾在MDK 5.38上集成过这套框架,踩过不少坑。下面是我验证过的步骤:
- 下载源码:从GitHub拉取Unity、Ceedling和CMock的release包。注意版本对齐,我推荐Unity 2.5.x + Ceedling 0.31.x + CMock 2.5.x。
- 创建测试目录:在工程根目录下建
test/文件夹,里面放unity_config.h和test_runner.c。 - 配置Ceedling:在
test/下创建project.yml,指定源文件路径、编译器路径、输出格式。 - 编写测试用例:用Unity的宏写
TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE等断言。 - 添加后构建步骤:在MDK的Options for Target → User → After Build/Rebuild中,添加Ceedling命令行调用。
这里有个关键点——MDK默认的ARMCC编译器对Ceedling的Ruby脚本支持不太好。我曾经卡在这里两天,后来发现需要在Windows上装Ruby环境,并且把ceedling命令加到系统PATH里。
// project.yml 核心配置示例
:project:
:build_root: build
:test_file_prefix: test_
:paths:
:source:
- ../Src/
:test:
- .
:include:
- ../Inc/
:tools:
:test_compiler:
:executable: C:/Keil_v5/ARM/ARMCC/bin/armcc.exe
:arguments:
- -c
- --cpu Cortex-M4
- -O0
- --debug
- -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN
- -I"$": COLLECTION_PATHS_SOURCE
- <%= $ce_include_path %>
- <%= $ce_test_file_path %>
-std=c99以外的C标准。如果你用了C11的特性(比如匿名结构体),编译会报错。我建议在测试代码中只用C99语法,或者用GCC交叉编译器替代ARMCC。
2.4 在IAR中集成:差异与技巧
IAR Embedded Workbench的集成思路跟MDK类似,但有几个差异点:
- 编译器路径不同:IAR的编译器是
iccarm.exe,链接器是ilinkarm.exe。在project.yml中要对应修改。 - 调试信息格式:IAR默认输出
.out或.elf,Ceedling需要配置:test_linker的:executable为ilinkarm.exe。 - 预处理器宏:IAR用
-D定义宏,但语法跟ARMCC略有不同。我建议在unity_config.h中统一管理宏定义。
// IAR 编译器配置片段
:tools:
:test_compiler:
:executable: C:/Program Files/IAR Systems/Embedded Workbench 8.5/arm/bin/iccarm.exe
:arguments:
- --cpu Cortex-M4
- --no_cse
- --no_unroll
- --no_inline
- --no_tbaa
- --no_clustering
- --debug
- -e
- -On
- -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN
- -I"$": COLLECTION_PATHS_SOURCE
Error[Li005]: no definition for "__iar_program_start"。后来发现是Ceedling生成的main()跟IAR的启动文件冲突。解决方案是在project.yml中加上:test_linker: :arguments: --entry main,强制入口点。
2.5 一个完整的测试用例长什么样?
说了这么多理论,不如看个实际例子。假设我们要测试调度器中的任务插入函数:
// test_scheduler_insert.c
#include "unity.h"
#include "scheduler.h"
#include "mock_timer.h" // CMock 自动生成的桩头文件
static task_t task1, task2;
void setUp(void) {
scheduler_init();
task1.id = 1;
task1.priority = 10;
task2.id = 2;
task2.priority = 5;
}
void tearDown(void) {
// 清理工作
}
void test_insert_should_add_task_to_ready_queue(void) {
// 模拟定时器当前 tick
timer_get_tick_ExpectAndReturn(100);
// 执行插入
scheduler_insert_task(&task1);
// 验证就绪队列长度
TEST_ASSERT_EQUAL(1, scheduler_get_ready_count());
}
void test_insert_should_sort_by_priority(void) {
timer_get_tick_ExpectAndReturn(100);
timer_get_tick_ExpectAndReturn(100);
scheduler_insert_task(&task1); // priority 10
scheduler_insert_task(&task2); // priority 5
// 优先级高的(数值小)应该在队首
task_t *head = scheduler_get_next_task();
TEST_ASSERT_EQUAL(2, head->id);
}
你看,用CMock之后,timer_get_tick这个硬件依赖被自动打桩了。我们只需要用ExpectAndReturn声明「期望调用一次,返回100」,测试就能在PC上跑通,完全不需要硬件。
2.6 测试报告与持续集成
Ceedling默认输出两种报告格式:控制台文本和JUnit XML。我习惯把XML报告接入Jenkins或GitLab CI,这样每次提交代码后自动跑测试,失败时发邮件通知。
// 在 project.yml 中启用 XML 报告
:plugins:
:load_paths:
- vendor/plugins
:enabled:
- xml_tests_report
- html_tests_report
:reporting:
:build_time: true
:threshold: 100
关键指标:我要求团队每个调度器模块的单元测试覆盖率不低于85%。实测下来,用Unity + Ceedling + CMock这套组合,一个中等复杂度的调度器(约3000行C代码)的测试代码量大约是生产代码的1.5倍,但维护成本远低于手动测试。