嵌入式软件单元测试(一)——嵌入式单元测试从零开始:为什么你的单片机代码也需要UT?
2026/9/12 2:02:20 网站建设 项目流程

摘要:本文从零讲解嵌入式单元测试(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 OK

6. 处理硬件依赖: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 OK

7. 把 UT 集成到持续集成流程

单元测试的价值在于持续运行。建议把测试集成到 CI 流水线中,每次代码提交后自动执行:

  • 在本地开发环境运行全部测试,确保改动不破坏已有功能。
  • 在 CI 服务器上配置相同的测试任务,作为合并代码的前置检查。
  • 将测试覆盖率纳入质量门禁,逐步提升代码质量。

8. 总结

嵌入式单元测试并不是「额外负担」,而是一项值得长期投入的工程实践。它帮助你在没有硬件的情况下验证核心逻辑,降低回归风险,提升代码质量。从一个小模块开始,逐步建立测试习惯,你会发现单片机开发也可以像桌面软件开发一样高效、可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询