嵌入式软件如何做单元测试?从“测不了”到“天天跑”的实战之路 💡
你有没有过这样的经历?
深夜调试板子,串口打印一堆乱码,变量值莫名其妙变成 0xFFFF ;
烧了第十遍固件,发现还是那个早在三天前就埋下的逻辑错误;
换了个芯片平台,原来好好的驱动代码直接崩在初始化阶段……
而最扎心的是—— 这些问题,本可以在写完代码的5分钟内就被发现。
这,就是我们今天要聊的话题: 嵌入式软件到底能不能做单元测试?怎么才能真正落地?
别急着摇头说“不可能”。我曾经也这么认为。直到亲眼看到一个团队用 Ceedling 在 PC 上跑通了对电机控制算法、CAN 通信状态机、甚至中断回调函数的完整验证—— 没有一块开发板,没有一次烧录,每天自动执行上千个测试用例。
是的, 现代嵌入式开发,早就不该靠“上电看灯”来验证正确性了。
我们为什么长期“测不了”?
先坦白讲,嵌入式软件做单元测试确实难,但难点从来不在技术本身,而在思维惯性和工程条件。
传统模式下,我们习惯于:
- 把所有逻辑塞进
.c文件里,直接调用HAL_GPIO_WritePin(); - 初始化代码一上来就配置时钟、使能外设;
- 中断服务程序(ISR)里写一堆业务处理;
- 依赖 RTOS 的任务调度才能看到功能表现……
于是问题来了:
👉 没有硬件,怎么测?
👉 依赖操作系统,怎么独立运行?
👉 函数之间紧耦合,改一处全崩?
结果就是: 测试只能等到硬件到位、系统集成之后才开始。
可这时候发现问题,代价已经太大了。据 NASA 统计,缺陷越晚被发现,修复成本呈指数级上升—— 后期修复的成本可能是编码阶段的100倍以上!
所以,不是“不想测”,而是我们的代码结构和工具链没准备好。
那怎么办?
答案是: 把“不可测”的代码,变成“可测”的模块。
而这背后的核心思想,只有八个字:
关注逻辑,而非物理实现。
什么意思?举个例子。
你想测试“按下按钮后是否点亮LED”,真正需要验证的是:
- 当输入为“按钮按下”时,
- 输出是否为“设置GPIO高电平”。
至于这个“按钮”是真实引脚读取,还是模拟信号;
这个“点亮”是真的驱动电流流过LED,还是只是记录一次函数调用——
根本不重要。
只要逻辑是对的,移植到任何平台上都会工作。
这就是单元测试的本质: 隔离外部依赖,专注行为验证。
工具选型:为什么是 Ceedling + Unity + CMock?
市面上有不少测试框架,比如 Google Test(适合C++)、CppUTest,甚至有人用 Python 写自动化脚本去串口发指令。
但在纯 C 环境、资源受限、跨平台需求强烈的嵌入式领域, Ceedling 是目前最接地气的选择。
它不是一个单一工具,而是一套组合拳:
| 组件 | 角色 |
|---|---|
| Unity | 轻量级断言库,负责判断“对不对” |
| CMock | 自动桩生成器,负责“假装调用” |
| Ceedling | 构建系统,把一切串起来 |
它们都来自 ThrowTheSwitch 社区,专为嵌入式 C 开发打造, 零依赖、易集成、学习曲线平缓 ,特别适合原本不做测试的传统团队“冷启动”。
而且最关键的一点:
✅ 它允许你在 x86 主机上编译运行嵌入式代码逻辑 ,完全脱离目标硬件!
听起来有点魔幻?别急,下面一步步拆解它是怎么做到的。
Unity:让 C 语言也能优雅地写断言 🧪
很多人以为 C 语言没法好好写测试,因为没有异常机制、没有模板、甚至连字符串都不方便处理。
但 Unity 用一组宏解决了这个问题。
来看一段最简单的测试代码:
#include "unity.h"
void setUp(void) { }
void tearDown(void) { }
void test_should_add_two_numbers_correctly(void) {
int result = add(2, 3);
TEST_ASSERT_EQUAL_INT(5, result);
}
就这么几行,就已经是一个完整的测试用例了。
当你运行它时,如果 add(2,3) 返回不是 5,你会看到类似这样的输出:
test_add.c:12:test_should_add_two_numbers_correctly:FAIL: Expected 5 Was 6
清晰明了,定位精准。
Unity 提供了哪些实用的断言?
| 断言宏 | 用途 |
|---|---|
TEST_ASSERT_TRUE(expr) | 判断表达式为真 |
TEST_ASSERT_FALSE(expr) | 判断表达式为假 |
TEST_ASSERT_NULL(ptr) | 检查指针为空 |
TEST_ASSERT_EQUAL_INT(a,b) | 整数相等比较 |
TEST_ASSERT_EQUAL_FLOAT(a,b) | 浮点数比较(自动容差) |
TEST_ASSERT_EQUAL_HEX(a,b) | 十六进制比较,常用于寄存器值 |
TEST_ASSERT_MEMORY_EQUAL(expected, actual, len) | 内存块比较 |
TEST_IGNORE() | 跳过某个测试 |
特别是浮点数比较,Unity 支持带误差范围的对比:
TEST_ASSERT_FLOAT_WITHIN(0.001, 3.1415926, computed_pi);
这对传感器数据处理、数学运算类模块非常友好。
更进一步:能在目标板上跑吗?
当然可以!
Unity 只依赖 <stdio.h> 和 <setjmp.h> ,这意味着哪怕是在 Cortex-M3 上,只要有基本的半主机(semihosting)支持或串口重定向 printf ,就能把测试结果打出来。
不过实际项目中,我们更推荐的做法是:
➡️ 日常开发在 PC 上跑测试(快!)
➡️ 定期在目标板上回归验证一次(稳!)
这样既保证效率,又不失真实性。
CMock:告别“硬编码依赖”,轻松 mock 外设 🎭
如果说 Unity 是“检测员”,那 CMock 就是“演员”——它能帮你扮演任何你不想真的调用的函数。
比如你的代码长这样:
// sensor_control.c
#include "i2c_driver.h"
float read_temperature(void) {
uint8_t data[2];
if (i2c_read(TEMP_SENSOR_ADDR, data, 2) != I2C_OK) {
return -100.0f; // 错误标志
}
return ((data[0] << 8) | data[1]) * 0.0625f;
}
这段代码依赖 i2c_read() ,而这个函数会操作真实的 I2C 控制器。
想在 PC 上测试?不行,除非你模拟整个总线协议。
但我们关心的是逻辑分支:
- 如果 I2C 成功 → 正确解析温度
- 如果 I2C 失败 → 返回默认错误值
这些根本不需要真实通信!
这时候,CMock 就派上用场了。
第一步:给 i2c_driver.h 写个头文件接口
// i2c_driver.h
#ifndef I2C_DRIVER_H
#define I2C_DRIVER_H
#include <stdint.h>
typedef enum {
I2C_OK,
I2C_ERROR,
I2C_TIMEOUT
} i2c_status_t;
i2c_status_t i2c_read(uint8_t addr, uint8_t* buffer, uint8_t len);
#endif
第二步:让 CMock 自动生成 mock 版本
只需要在 project.yml 里声明:
:mocks:
:plugins:
- :ignore
- :expect_any_args
然后在测试文件中 include:
#include "unity.h"
#include "mock_i2c_driver.h" // ← 不是你写的!是 CMock 自动生成的
#include "sensor_control.h"
第三步:编写测试,设定期望行为
void test_read_temperature_should_return_25_degrees_when_i2c_returns_valid_data(void) {
uint8_t fake_raw[] = {0x19, 0x00}; // 25°C
i2c_read_IgnoreAndReturn(I2C_OK); // 告诉 mock:无论参数是什么,都返回 OK
float temp = read_temperature();
TEST_ASSERT_FLOAT_WITHIN(0.01, 25.0, temp);
}
void test_read_temperature_should_return_error_value_on_i2c_failure(void) {
i2c_read_ExpectAndReturn(TEMP_SENSOR_ADDR, NULL, 2, I2C_TIMEOUT);
float temp = read_temperature();
TEST_ASSERT_EQUAL_FLOAT(-100.0f, temp);
}
注意这里用了两个不同的方式:
-
i2c_read_IgnoreAndReturn():忽略参数,直接返回指定值 -
i2c_read_ExpectAndReturn(...):明确指定参数,验证是否按预期调用
后者还能检查函数有没有被正确调用,传参对不对,次数是不是一次。
这就叫 行为验证(Behavior Verification) ,比单纯的返回值检查更强。
如何处理最难搞的三大场景?🔥
很多工程师一听“单元测试”,第一反应就是:“这些东西你怎么测?”
下面我们挑三个最典型的“痛点”来说清楚。
场景一:硬件寄存器访问(如 STM32 HAL)
常见代码:
void led_init(void) {
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef init;
init.Pin = GPIO_PIN_5;
init.Mode = GPIO_MODE_OUTPUT_PP;
HAL_GPIO_Init(GPIOA, &init);
}
问题来了: HAL_GPIO_Init() 会直接写内存映射寄存器。
在 PC 上运行?直接段错误。
解法:抽象 + 依赖注入 + Mock
不要在业务代码里直接调用 HAL!
改成这样:
// driver/gpio_wrapper.h
typedef struct {
void (*init)(int pin);
void (*write)(int pin, int value);
int (*read)(int pin);
} gpio_driver_t;
void set_gpio_driver(const gpio_driver_t* driver); // 注入接口
void control_led(int state); // 使用抽象接口
实现层再绑定具体 HAL:
// platform/stm32_driver.c
static const gpio_driver_t stm32_gpio = {
.init = hal_gpio_init,
.write = hal_gpio_write,
.read = hal_gpio_read
};
void board_init(void) {
set_gpio_driver(&stm32_gpio);
}
测试时注入一个“假”驱动:
// test/mock_driver.c
static int expected_pin;
static int last_value;
void mock_init(int pin) { expected_pin = pin; }
void mock_write(int pin, int value) { last_value = value; }
const gpio_driver_t test_gpio_driver = {
.init = mock_init,
.write = mock_write
};
测试用例:
void test_led_turn_on_should_set_pin_high(void) {
set_gpio_driver(&test_gpio_driver);
control_led(1);
TEST_ASSERT_EQUAL(1, last_value);
}
你看, 完全没有碰到底层 HAL ,却完成了对“控制逻辑”的验证。
这种模式叫做 依赖注入(Dependency Injection) ,是提升可测试性的关键设计原则。
场景二:RTOS API(如 FreeRTOS)
代码里到处都是 xTaskCreate , vQueueSend , xSemaphoreTake ……
这些函数内部涉及任务调度、堆栈分配,在 PC 上无法原生运行。
解法:统一 mock 掉 RTOS 接口
使用 CMock 自动生成 mock_FreeRTOS.h :
#include "mock_FreeRTOS.h"
void test_message_queue_should_forward_data_to_handler(void) {
QueueHandle_t fake_queue = (QueueHandle_t)0x1234;
get_message_queue_ExpectAndReturn(fake_queue);
uint32_t received;
xQueueReceive_ExpectAndReturn(fake_queue, &received, portMAX_DELAY, pdTRUE);
xQueueReceive_IgnoreArg_pvBuffer(); // 忽略缓冲区指针
bool result = wait_for_message(&received);
TEST_ASSERT_TRUE(result);
}
通过这种方式,你可以验证:
- 是否成功获取了队列句柄
- 是否以正确的超时时间等待
- 是否处理了返回状态
而不用真的启动 FreeRTOS 内核。
💡 小技巧:可以把常用的 RTOS mock 配置封装成插件,一键启用。
场景三:中断服务程序(ISR)
ISR 通常是裸函数,没有参数,不能直接调用。
void EXTI0_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) {
log_event(EVENT_BUTTON_PRESSED); // 这部分才是重点!
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}
}
ISR 本身很难测,但里面的逻辑可以抽出来!
重构为:
void handle_button_interrupt(void) {
log_event(EVENT_BUTTON_PRESSED);
}
void EXTI0_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) {
handle_button_interrupt(); // 只保留必要的寄存器操作
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}
}
现在你可以单独测试 handle_button_interrupt() !
甚至可以 mock log_event() 来验证它是否被调用:
void test_handle_button_interrupt_should_log_event(void) {
log_event_Expect(EVENT_BUTTON_PRESSED);
handle_button_interrupt();
}
完美绕开中断上下文限制。
实际工程架构该怎么搭?🏗️
光会写测试还不够,得有一套可持续维护的工程结构。
这是我见过最高效的组织方式:
project-root/
├── src/
│ ├── app/ # 应用逻辑
│ ├── middleware/ # 协议栈、状态机等
│ └── driver/ # 驱动封装(非 HAL 直接调用)
│
├── test/
│ ├── test_app/
│ ├── test_middleware/
│ └── test_driver/
│
├── vendor/
│ └── unity_cmock/ # 第三方库
│
├── project.yml # Ceedling 配置
└── ceedling_override.sh # 自定义构建脚本(可选)
关键配置: project.yml 怎么写?
:project:
:use_exceptions: FALSE
:use_test_preprocessor: TRUE
:use_auxiliary_dependencies: TRUE
:build_root: build
:release_build:
:output: MyApp.elf
:use_assembly: FALSE
:defines:
:common: &common_defines
- UNIT_TESTING
:files:
:source:
- src/app/**
- src/middleware/**
:tools:
:test_compiler:
:executable: gcc
:arguments:
- -c
- -g
- -Wall
- -Werror
- -D$(DEFINES)
- -I"vendor/unity/src"
- -I"src"
:plugins:
:load_paths:
- vendor/ceedling/plugins
:enabled:
- stdout_pretty_tests_report
- module_generator
- gcov
- coverage
几个要点说明:
-
UNIT_TESTING宏可用于条件编译某些调试代码; - 所有源码路径必须包含;
- 启用
gcov插件可生成覆盖率报告; - 使用
stdout_pretty_tests_report让输出更美观;
运行命令也很简单:
ceedling test:all # 运行所有测试
ceedling test:test_sensor_control # 只跑某个测试
ceedling coverage:all # 生成覆盖率 HTML 报告
ceedling gcov # 查看详细覆盖情况
CI/CD 集成:让测试成为提交门槛 🚦
真正的工程化,不是“偶尔跑一下测试”,而是“每次提交都自动验证”。
GitLab CI 示例 .gitlab-ci.yml :
stages:
- test
- coverage
before_script:
- ruby --version
- gem install ceedling
unit_tests:
stage: test
script:
- ceedling test:all
artifacts:
when: on_failure
paths:
- build/logs/
coverage_report:
stage: coverage
script:
- ceedling coverage:all
artifacts:
paths:
- build/artifacts/coverage/
reports:
cobertura: build/artifacts/coverage/cobertura.xml
GitHub Actions 也一样,加个 workflow 文件就行。
一旦接入 CI,效果立竿见影:
- 新人提交代码前会自觉跑测试;
- PR 必须通过测试才能合并;
- 覆盖率趋势一目了然;
- 回归问题几乎不再发生。
这才是“质量内建”(Built-in Quality)的真实体现。
设计建议:怎样写出更容易测试的代码?✍️
最后分享一些我在多个项目中总结出的“可测试性设计法则”:
✅ 推荐做法
| 原则 | 示例 |
|---|---|
| 函数职责单一 | 一个函数只做一件事,比如“解析报文”或“校验CRC” |
| 避免全局变量 | 改用参数传递或上下文结构体 |
| 减少静态状态 | 静态变量会导致测试间污染 |
| 接口抽象化 | 用函数指针代替直接调用 HAL/RTOS |
| 分层清晰 | 应用层不直接依赖底层驱动 |
❌ 应避免的反模式
// ❌ 反模式1:直接调用 HAL
void process_sensor(void) {
if (HAL_ADC_Read(...) > THRESHOLD) {
HAL_UART_Transmit(...); // 紧耦合,无法 mock
}
}
// ✅ 正确做法:通过接口调用
void process_sensor(const driver_if_t* drv) {
if (drv->adc_read() > THRESHOLD) {
drv->uart_send(...);
}
}
// ❌ 反模式2:全局状态
static int initialized = 0;
void init_module(void) {
if (!initialized) { /* do init */ }
initialized = 1; // 下次测试会跳过初始化!
}
// ✅ 正确做法:显式管理状态
typedef struct {
int is_init;
int config_val;
} module_ctx_t;
void module_init(module_ctx_t* ctx) {
ctx->is_init = 1;
}
一点真心话 💬
我知道,很多嵌入式工程师听到“单元测试”会觉得这是“学院派”的东西,觉得“我们产品交付都来不及,哪有空写测试?”
我也曾这么想。
但后来我发现, 真正拖慢进度的,从来不是写测试的时间,而是反复调试、现场返修、客户投诉带来的隐形成本。
而单元测试,恰恰是最便宜的“保险”。
它不能防止所有问题,但它能:
- 让你更有信心地重构老旧代码;
- 让新人快速理解模块行为;
- 让每次变更都有迹可循;
- 让软件不再是“一次性艺术品”,而是可持续演进的工程资产。
更重要的是——
当你每天早上打开电脑,看到那一串绿色的 PASS ,你会有一种踏实感:
“我知道这部分代码是可靠的。”
这,就是工程化的魅力所在。
所以,不妨从今天开始,试试看:
- 给你最近写的模块写第一个测试;
- 用
mock_xxx.h替掉一个真实的驱动调用; - 在 CI 上看到第一次自动运行的结果。
也许一开始很慢,甚至觉得麻烦。
但坚持两周,你会发现:
原来写测试,比查 bug 还快。 😄
🚀 就像当年学会用 Git 一样,一旦跨过那个门槛,再也回不去了。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
1059

被折叠的 条评论
为什么被折叠?



