摘要:本文从零讲解嵌入式单元测试(UT)的落地方法。先厘清单元测试的核心概念,再分析单片机代码引入 UT 的必要性与常见挑战,随后以 C 语言项目为例,介绍基于 Unity 与 Ceedling 搭建测试环境、编写并运行测试用例的完整流程,并重点演示如何通过硬件抽象层(HAL)与 CMock 处理硬件依赖、验证调用关系,最后说明如何把 UT 集成到持续集成流程中,帮助开发者在没有硬件的情况下提前发现缺陷、降低回归风险。
1. 引言:单片机代码真的需要单元测试吗?
很多嵌入式开发者听到「单元测试」的第一反应是:单片机代码跑在硬件上,跟 PC 软件不一样,单元测试能有用吗?答案是肯定的。单元测试(Unit Test,简称 UT)并不是桌面软件的专利,它同样适用于嵌入式开发,而且越早引入,收益越大。
本文将从零开始,带你理解嵌入式单元测试的核心概念、为什么单片机代码需要 UT,以及如何搭建一套可落地的测试方案。
2. 什么是单元测试
单元测试是指对软件中的最小可测试单元(通常是函数或模块)进行验证的测试方式。它的核心思想是:把被测代码从整个系统中隔离出来,用可控的输入去验证输出是否符合预期。
在嵌入式场景中,这个「最小单元」可以是一个传感器驱动函数、一个协议解析函数,或者一个状态机处理逻辑。
3. 为什么单片机代码也需要 UT
很多人觉得单片机代码「跑起来没问题就行」,但实际开发中,以下痛点几乎每个嵌入式项目都会遇到:
- 硬件依赖导致调试困难:很多逻辑问题要等硬件就绪才能暴露,开发周期被拉长。
- 回归风险高:修改一个驱动函数,可能影响多个业务模块,手工验证难以覆盖全面。
- 缺陷修复成本高:问题越晚发现,定位和修复的成本越高,尤其是涉及底层驱动时。
- 团队协作缺乏保障:多人维护同一份代码时,没有自动化测试,很难保证改动不破坏已有功能。
单元测试正是解决这些问题的有效手段:它不依赖真实硬件,可以在 PC 上快速运行,让问题在开发早期就被发现。
4. 嵌入式单元测试的常见挑战
嵌入式单元测试并非没有门槛,主要挑战集中在以下几个方面:
- 硬件寄存器访问:代码直接操作寄存器,在 PC 上无法执行。
- 外设依赖:UART、I2C、SPI 等外设接口难以在测试环境中模拟。
- 中断与实时性:中断处理函数和实时逻辑难以在普通测试框架中验证。
- 交叉编译环境:目标平台与开发机架构不同,需要额外的工具链支持。
不过,这些挑战都有成熟的应对方案,例如使用模拟层(Mock)、硬件抽象层(HAL)隔离,以及选择支持交叉编译的测试框架。
5. 从零搭建嵌入式单元测试环境
下面以一个典型的 C 语言嵌入式项目为例,介绍如何搭建单元测试环境。这里推荐使用 Unity 或 Ceedling 这类轻量级测试框架,它们对嵌入式项目非常友好。
5.1 准备工具链
首先,你需要在开发机上安装以下工具:
- GCC 编译器(用于在 PC 上编译被测代码)
- CMake 或 Make(用于构建测试工程)
- Unity 测试框架(一个轻量级的 C 语言单元测试框架)
5.2 编写第一个测试用例
假设我们有一个简单的计算函数,代码如下:
// calculator.h #ifndef CALCULATOR_H #define CALCULATOR_H int add(int a, int b); #endif// calculator.c #include "calculator.h" int add(int a, int b) { return a + b; }对应的测试代码如下:
// test_calculator.c #include "unity.h" #include "calculator.h" void setUp(void) {} void tearDown(void) {} void test_add_positive_numbers(void) { TEST_ASSERT_EQUAL_INT(5, add(2, 3)); } void test_add_negative_numbers(void) { TEST_ASSERT_EQUAL_INT(-1, add(2, -3)); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_add_positive_numbers); RUN_TEST(test_add_negative_numbers); return UNITY_END(); }5.3 运行测试
在 PC 上编译并运行测试:
gcc -I. test_calculator.c calculator.c unity.c -o test_calculator ./test_calculator如果一切正常,你会看到类似下面的输出:
test_calculator.c:12:test_add_positive_numbers:PASS test_calculator.c:16:test_add_negative_numbers:PASS 2 Tests 0 Failures 0 Ignored OK6. 处理硬件依赖:Mock 与硬件抽象层
真实项目中,被测函数往往依赖硬件寄存器或外设。这时有两种常用策略:
- 硬件抽象层(HAL):把硬件操作封装成接口,测试时替换为桩实现。
- Mock 对象:使用 CMock 等工具自动生成模拟函数,验证调用关系和返回值。
例如,假设一个温度传感器驱动函数需要读取寄存器:
// sensor.c #include "sensor.h" #include "hal_i2c.h" int sensor_read_temperature(void) { uint8_t data[2]; hal_i2c_read(0x48, 0x00, data, 2); return (data[0] << 8) | data[1]; }在测试中,我们可以提供一个假的hal_i2c_read实现,让它返回预设数据,从而验证sensor_read_temperature的解析逻辑是否正确。
6.1 使用 CMock 自动生成 Mock
CMock 是 Ceedling 生态中用于自动生成 C 语言 Mock 函数的工具。它会解析头文件,为其中的每个函数生成对应的模拟实现,并自动记录调用次数、参数和返回值,方便在测试中做断言。
首先,在project.yml中启用 CMock,并声明需要模拟的头文件:
# project.yml :cmock: :plugins: - :ignore - :callback :mock_path: build/test/mocks :treat_externs: :include :unity: :defines: - UNITY_INCLUDE_DOUBLE接着,在测试文件中引入 CMock 自动生成的mock_hal_i2c.h,它会为hal_i2c_read生成对应的 Mock 函数:
// test_sensor.c #include "unity.h" #include "mock_hal_i2c.h" #include "sensor.h" void setUp(void) { // 每个测试用例前重置所有 Mock 的调用记录 hal_i2c_read_CMockExpectAnyArgsAndReturn(CMOCK_OK); } void tearDown(void) { // 校验所有 Mock 的预期调用是否都已发生 CMockVerify(); }下面给出一个完整的测试用例,验证sensor_read_temperature是否正确调用了hal_i2c_read,并正确解析返回的寄存器数据:
void test_sensor_read_temperature_parses_register_data(void) { uint8_t expected_data[2] = {0x01, 0x2C}; // 设置 Mock:期望以指定参数调用 hal_i2c_read,并返回预设数据 hal_i2c_read_Expect(0x48, 0x00, NULL, 2); hal_i2c_read_ReturnArrayThruPtr_data(expected_data, 2); // 调用被测函数 int temperature = sensor_read_temperature(); // 验证返回值:0x012C = 300 TEST_ASSERT_EQUAL_INT(300, temperature); } void test_sensor_read_temperature_calls_hal_i2c_read(void) { // 只关心调用关系,不关心返回值 hal_i2c_read_ExpectAnyArgs(); sensor_read_temperature(); // 验证 hal_i2c_read 确实被调用了一次 TEST_ASSERT_EQUAL_INT(1, hal_i2c_read_CallTimes); }其中,hal_i2c_read_Expect用于声明期望的调用参数,hal_i2c_read_ReturnArrayThruPtr_data用于让 Mock 把预设数据写入data指针指向的缓冲区,从而模拟真实硬件读取寄存器的行为。
要验证调用关系,CMock 提供了两种方式:
- Expect 断言:通过
hal_i2c_read_Expect声明期望的参数,如果实际调用参数不匹配,测试会直接失败。 - 调用计数:通过
hal_i2c_read_CallTimes检查函数被调用的次数,确保被测代码没有重复或遗漏调用。
最后,在main中注册这些测试用例并运行:
int main(void) { UNITY_BEGIN(); RUN_TEST(test_sensor_read_temperature_parses_register_data); RUN_TEST(test_sensor_read_temperature_calls_hal_i2c_read); return UNITY_END(); }运行测试后,如果一切正常,你会看到类似下面的输出:
test_sensor.c:20:test_sensor_read_temperature_parses_register_data:PASS test_sensor.c:30:test_sensor_read_temperature_calls_hal_i2c_read:PASS 2 Tests 0 Failures 0 Ignored OK7. 把 UT 集成到持续集成流程
单元测试的价值在于持续运行。建议把测试集成到 CI 流水线中,每次代码提交后自动执行:
- 在本地开发环境运行全部测试,确保改动不破坏已有功能。
- 在 CI 服务器上配置相同的测试任务,作为合并代码的前置检查。
- 将测试覆盖率纳入质量门禁,逐步提升代码质量。
8. 总结
嵌入式单元测试并不是「额外负担」,而是一项值得长期投入的工程实践。它帮助你在没有硬件的情况下验证核心逻辑,降低回归风险,提升代码质量。从一个小模块开始,逐步建立测试习惯,你会发现单片机开发也可以像桌面软件开发一样高效、可靠。