1. 课程导入
嵌入式软件单元测试,说起来简单,做起来全是坑。我刚开始带项目的时候,团队写了个底层驱动模块,逻辑上觉得没问题,一跑测试——好家伙,依赖的硬件根本没准备好。那时候我就意识到,没有桩函数和驱动,单元测试在嵌入式领域就是个空话。
嵌入式单元测试的三大痛点
你想想看,嵌入式软件和普通桌面软件最大的区别是什么?
- 硬件依赖——代码直接操作寄存器、GPIO、ADC,测试时硬件可能还没做出来
- 实时性约束——中断、定时器、任务调度,这些在测试环境里根本模拟不了
- 资源受限——没有文件系统、没有标准库、甚至没有printf
说白了,你写了个UART发送函数,想测它能不能正确处理数据。但测试环境里没有UART硬件,怎么办?这时候就需要桩函数——它假装自己是硬件,替你完成交互验证。
核心观点:桩函数(Stub)模拟被调用模块的行为,驱动(Driver)模拟调用者环境。两者配合,才能把嵌入式代码从硬件中剥离出来单独测试。
一个真实案例
我记得有一次,团队开发一个温度采集模块。代码逻辑是这样的:
// 温度采集模块
float read_temperature(void) {
uint16_t raw = read_adc_channel(3); // 依赖ADC驱动
return (raw * 3.3f / 4096.0f - 0.5f) / 0.01f;
}
这个函数依赖 read_adc_channel(),而ADC驱动当时还在调试。没有桩函数,我们根本没法测温度转换逻辑对不对。后来我写了个简单的桩:
// 桩函数:模拟ADC读取
uint16_t read_adc_channel(uint8_t channel) {
// 返回固定值,模拟不同温度
static const uint16_t test_values[] = {2048, 1024, 512};
static int index = 0;
return test_values[index++ % 3];
}
配合驱动代码调用这个桩,我们很快就发现了温度计算公式里的一个符号错误。嗯,要是等硬件出来再测,至少耽误两周。
桩函数 vs 驱动:到底谁是谁?
很多初学者搞不清这两个概念。我画个图你就明白了:
我的经验:写桩函数时,别想着一次把所有情况都模拟全。先写最简单的固定返回值桩,跑通基本路径,再逐步增加复杂行为。我曾经一上来就写了个带状态机的桩,结果桩本身比被测代码还复杂,调试了两天才发现是桩写错了。
什么时候必须用桩函数?
不是所有测试都需要桩。我总结了几种必须用的情况:
| 场景 | 原因 | 典型例子 |
|---|---|---|
| 硬件未就绪 | 硬件还在设计/调试阶段 | ADC、DAC、Flash驱动 |
| 外部依赖不稳定 | 依赖的模块经常变更 | 通信协议栈、文件系统 |
| 异常场景难复现 | 硬件故障、超时、数据异常 | 看门狗超时、CRC校验失败 |
| 实时性要求高 | 测试环境无法模拟真实时序 | 中断处理、RTOS任务切换 |
避坑指南:我曾经在一个项目中,为了省事直接用了真实硬件做单元测试。结果每次跑测试都要插拔开发板,而且硬件偶尔会出莫名其妙的问题,导致测试结果不可复现。后来老老实实写了桩函数,测试效率提升了至少3倍。
驱动开发:不只是"调用一下"
驱动(Driver)是测试框架和被测模块之间的桥梁。它负责:
- 初始化测试环境
- 构造测试输入数据
- 调用被测函数
- 收集并验证输出结果
一个典型的驱动代码长这样:
// 驱动:测试温度采集模块
void test_read_temperature(void) {
// 1. 准备测试数据
float expected[] = {25.0f, 0.0f, -10.0f};
// 2. 调用被测函数
float result = read_temperature();
// 3. 验证结果
if (abs(result - expected[0]) < 0.1f) {
printf("PASS: temperature = %.2f\n", result);
} else {
printf("FAIL: expected %.2f, got %.2f\n", expected[0], result);
}
}
你可能会问:这不就是个简单的调用吗?其实不然。好的驱动设计要考虑测试覆盖率、边界条件、异常注入。我习惯把驱动和桩函数放在同一个测试文件中,方便维护。
本章小结
桩函数和驱动是嵌入式单元测试的基石。没有它们,你就是在跟硬件搏斗,而不是在测代码逻辑。记住三点:
- 桩函数模拟依赖——让被测模块脱离硬件独立运行
- 驱动提供环境——构造输入、调用函数、验证输出
- 两者配合——才能实现真正的自动化单元测试