4. 单元测试框架选型与搭建:Ceedling/Unity/CMock在嵌入式环境中的移植与配置
说到嵌入式单元测试,很多人第一反应是:“我的代码跑在STM32上,怎么在PC上测?”
这个问题我十年前也问过自己。那时候项目组还在用手动打桩的方式做测试,每次改完一个模块,光搭测试环境就要半天。后来接触了Ceedling这套工具链,才算是真正把测试效率提起来了。
这一章,我就带你把这套东西跑起来。
4.1 为什么是Ceedling + Unity + CMock
先说说这三兄弟的分工:
- Unity:轻量级测试框架,纯C实现,专门为嵌入式设计。它不依赖标准库,不依赖操作系统,一个头文件加一个C文件就能跑。
- CMock:自动生成Mock桩代码的工具。你给它一个头文件,它自动生成对应的模拟函数,省去手动写桩的体力活。
- Ceedling:胶水工具,把Unity和CMock粘在一起,还集成了构建系统、测试运行器、覆盖率统计。
说白了,Ceedling就是帮你把“写测试、生成桩、编译运行、看结果”这一整套流程自动化了。
核心优势:这套工具链完全用Ruby + C实现,不依赖任何嵌入式IDE,可以在Windows/Linux/macOS上统一运行。你只需要一个交叉编译器,就能在PC上测试目标板代码。
4.2 工具链安装与环境搭建
我习惯在Linux环境下做开发,但Windows下用WSL或者Cygwin也一样。下面以Ubuntu为例:
# 安装Ruby(Ceedling基于Ruby)
sudo apt-get install ruby-full build-essential
# 安装Ceedling
gem install ceedling
# 验证安装
ceedling version
安装完成后,创建一个新项目:
ceedling new my_project
cd my_project
你会看到这样的目录结构:
my_project/
├── src/ # 你的源代码
├── test/ # 测试用例
├── lib/ # 第三方库
├── vendor/ # Unity/CMock源码
├── build/ # 编译输出
└── project.yml # 配置文件
嗯,这里要注意:vendor/目录下已经自带了Unity和CMock的源码,你不需要手动下载。Ceedling在创建项目时自动拉取了最新版本。
4.3 在嵌入式环境中的移植与配置
嵌入式环境最大的问题是:你的代码依赖硬件寄存器、外设库、RTOS。这些在PC上都没有。
怎么办?我的做法是——分层隔离。
4.3.1 配置交叉编译器
打开project.yml,告诉Ceedling用哪个编译器:
:tools:
:test_compiler:
:executable: arm-none-eabi-gcc
:arguments:
- -mcpu=cortex-m4
- -mthumb
- -specs=nano.specs
- -I"src/"
- -I"test/"
- ${1}
:test_linker:
:executable: arm-none-eabi-gcc
:arguments:
- ${1}
- -o ${2}
我曾经在一个项目中,因为忘了加-specs=nano.specs,链接时疯狂报错,折腾了半天才发现是newlib的浮点支持问题。所以,交叉编译器的参数一定要和你的目标芯片完全匹配。
4.3.2 处理硬件依赖
对于直接操作寄存器的代码,我通常用两种方式处理:
- 函数指针注入:把硬件操作封装成函数指针,测试时替换成Mock。
- 条件编译:在测试环境下,用模拟的寄存器地址替换真实地址。
举个例子,假设你的代码里有这样的宏:
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
在测试时,可以改成:
#ifdef UNIT_TESTING
extern uint32_t mock_GPIOA_ODR;
#define GPIOA_ODR mock_GPIOA_ODR
#else
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
#endif
小技巧:在project.yml中加上:defines: UNIT_TESTING,这样所有测试编译都会自动定义这个宏,不需要手动加。
4.4 测试用例编写规范
工具搭好了,怎么写测试?我总结了一套规范,团队里用了三年,效果不错。
4.4.1 文件命名与组织
| 源文件 | 测试文件 | 说明 |
|---|---|---|
src/temperature.c |
test/test_temperature.c |
测试文件统一加test_前缀 |
src/driver/uart.c |
test/driver/test_uart.c |
目录结构保持一致 |
src/hal/timer.c |
test/hal/test_timer.c |
方便查找对应关系 |
4.4.2 测试用例结构模板
每个测试文件的基本骨架:
#include "unity.h"
#include "temperature.h"
// 全局变量,用于测试前后状态管理
static int setup_flag = 0;
// 每个测试用例执行前调用
void setUp(void)
{
setup_flag = 1;
// 初始化被测模块
temperature_init();
}
// 每个测试用例执行后调用
void tearDown(void)
{
setup_flag = 0;
// 清理资源
temperature_deinit();
}
// 测试用例:正常温度转换
void test_temperature_celsius_to_fahrenheit(void)
{
// Arrange
int celsius = 100;
// Act
int fahrenheit = celsius_to_fahrenheit(celsius);
// Assert
TEST_ASSERT_EQUAL_INT(212, fahrenheit);
}
// 测试用例:边界值
void test_temperature_celsius_to_fahrenheit_zero(void)
{
TEST_ASSERT_EQUAL_INT(32, celsius_to_fahrenheit(0));
}
// 测试用例:负数
void test_temperature_celsius_to_fahrenheit_negative(void)
{
TEST_ASSERT_EQUAL_INT(-4, celsius_to_fahrenheit(-20));
}
注意:setUp()和tearDown()是Unity框架的约定函数名。每个测试用例运行前后都会自动调用它们,确保测试之间互不干扰。我曾经见过有人把全局变量初始化写在测试用例里,结果用例顺序一变就全挂了。
4.4.3 断言使用规范
Unity提供了丰富的断言宏,我常用的几个:
| 断言宏 | 用途 | 示例 |
|---|---|---|
TEST_ASSERT_EQUAL_INT |
比较整型值 | TEST_ASSERT_EQUAL_INT(42, result); |
TEST_ASSERT_EQUAL_FLOAT |
比较浮点数(带精度) | TEST_ASSERT_EQUAL_FLOAT(3.14, pi); |
TEST_ASSERT_TRUE |
判断是否为真 | TEST_ASSERT_TRUE(is_ready); |
TEST_ASSERT_NULL |
判断指针是否为空 | TEST_ASSERT_NULL(ptr); |
TEST_ASSERT_EQUAL_STRING |
比较字符串 | TEST_ASSERT_EQUAL_STRING("OK", msg); |
4.4.4 使用CMock处理外部依赖
假设你的温度模块调用了硬件I2C驱动:
// temperature.c
#include "i2c_driver.h"
int read_temperature_sensor(void)
{
uint8_t data[2];
if (i2c_read(0x48, 0x00, data, 2) != I2C_OK) {
return -1;
}
return (data[0] << 8) | data[1];
}
在project.yml中配置CMock:
:cmock:
:mock_path: test/mocks
:includes:
- i2c_driver.h
:treat_externs: :include
然后在测试文件中:
#include "unity.h"
#include "temperature.h"
#include "mock_i2c_driver.h"
void test_read_temperature_sensor_success(void)
{
// 设置Mock行为:i2c_read返回成功
i2c_read_ExpectAndReturn(0x48, 0x00, NULL, 2, I2C_OK);
// 设置Mock输出参数
i2c_read_ExpectArrayAnyArgs(0x48, 0x00, NULL, 2, I2C_OK);
int result = read_temperature_sensor();
TEST_ASSERT_TRUE(result >= 0);
}
void test_read_temperature_sensor_failure(void)
{
i2c_read_ExpectAndReturn(0x48, 0x00, NULL, 2, I2C_ERROR);
int result = read_temperature_sensor();
TEST_ASSERT_EQUAL_INT(-1, result);
}
关键点:CMock生成的Mock函数名规则是原函数名_ExpectAndReturn。它会自动检查参数是否匹配,如果不匹配,测试会直接失败。这比手动写桩要可靠得多。
4.5 运行测试与查看结果
一切就绪后,运行测试只需要一条命令:
ceedling test:all
输出示例:
Test 'test_temperature.c'
-------------------------------
PASS: test_temperature_celsius_to_fahrenheit
PASS: test_temperature_celsius_to_fahrenheit_zero
PASS: test_temperature_celsius_to_fahrenheit_negative
PASS: test_read_temperature_sensor_success
PASS: test_read_temperature_sensor_failure
-------------------------------
5 tests, 5 passed, 0 failed, 0 ignored
OVERALL: PASS
如果只想跑单个文件:
ceedling test:test_temperature
4.6 知识体系总览
下面这张图概括了本章的核心逻辑:
4.7 常见问题与避坑
- 链接错误:undefined reference to 'main'——Ceedling会自动生成main函数,你不需要自己写。如果出现这个错误,检查是否在
src/目录下放了包含main函数的文件。 - Mock函数不匹配——CMock要求Mock函数的调用顺序和参数必须与
Expect完全一致。我曾经因为调换了两个Mock调用的顺序,查了整整一个下午。 - 浮点数比较失败——Unity的
TEST_ASSERT_EQUAL_FLOAT默认精度是小数点后4位。如果精度不够,可以用TEST_ASSERT_FLOAT_WITHIN指定容忍范围。
我的习惯:每次提交代码前,跑一遍ceedling test:all。如果测试全绿,再提交。这个习惯帮我拦截了至少80%的回归问题。