4. 模拟与桩函数:如何隔离硬件依赖进行纯逻辑测试
做嵌入式软件测试,最头疼的问题是什么?
我个人觉得,就是硬件依赖。你写了一个控制逻辑,想跑个单元测试,结果发现它调用了 GPIO 写引脚、ADC 读数据、UART 发字符串。没有硬件板子,这些函数根本跑不起来。就算有板子,每次测试都要烧录、接线、手动触发,效率低得让人抓狂。
所以这一章,我们来聊聊怎么把硬件依赖「切掉」,让测试只关注你写的逻辑本身。说白了,就是给代码打桩、做模拟。
4.1 为什么需要模拟与桩函数?
先看一个典型场景。你写了一个温度监控模块,它每隔 100ms 读一次 ADC,如果温度超过阈值就报警。代码大概长这样:
// temperature_monitor.c
void temperature_monitor_task(void) {
uint16_t adc_value = adc_read_channel(3); // 读ADC通道3
int32_t temperature = adc_to_temp(adc_value);
if (temperature > TEMP_THRESHOLD) {
alarm_trigger(ALARM_OVERHEAT);
}
}
这个函数依赖三个外部函数:adc_read_channel、adc_to_temp、alarm_trigger。如果没有硬件,adc_read_channel 返回什么?不知道。你没法验证 temperature_monitor_task 的逻辑对不对。
我在项目中遇到过类似的情况。有一次写电机控制算法,依赖霍尔传感器的读取函数。板子还没回来,算法逻辑又复杂,我硬着头皮先写测试。结果发现,只要把霍尔读取函数换成桩函数,返回固定值,整个控制逻辑的测试就能跑通了。那次之后,我养成了「先写桩、后写逻辑」的习惯。
4.2 桩函数 vs 模拟对象:概念与区别
很多初学者会把桩函数和模拟对象混为一谈。其实它们有明确的区别。我习惯用一个表格来对比:
| 特性 | 桩函数(Stub) | 模拟对象(Mock) |
|---|---|---|
| 主要目的 | 提供预设返回值 | 验证行为是否正确 |
| 是否验证调用 | 通常不验证 | 验证调用次数、参数、顺序 |
| 典型用法 | 替代传感器读取、硬件状态查询 | 验证中断处理、回调函数、消息发送 |
| 返回值复杂度 | 固定值或简单逻辑 | 可根据输入参数动态返回 |
| 测试侧重点 | 被测函数的数据处理逻辑 | 被测函数是否正确调用了外部接口 |
举个例子。如果你要测试温度阈值判断逻辑,用桩函数就够了——让 adc_read_channel 返回一个固定值,然后检查报警是否触发。但如果你要测试中断服务程序是否在正确时机调用了 adc_read_channel 三次,那就需要用模拟对象来记录调用次数。
4.3 如何编写桩函数:手写 vs 自动生成
写桩函数有两种方式:手写和用框架自动生成。我两种都用过,各有优劣。
4.3.1 手写桩函数
手写桩函数是最直接的方式。你只需要定义一个同名的函数,返回你想要的值。比如:
// test_stubs.c
#include "stub_defs.h"
static uint16_t stub_adc_value = 0;
void set_stub_adc_value(uint16_t val) {
stub_adc_value = val;
}
uint16_t adc_read_channel(uint8_t channel) {
(void)channel; // 忽略参数,返回预设值
return stub_adc_value;
}
然后在测试代码里:
// test_temperature_monitor.c
void test_temperature_below_threshold(void) {
set_stub_adc_value(500); // 模拟低温
temperature_monitor_task();
// 检查报警没有被触发
TEST_ASSERT_EQUAL(0, alarm_triggered_count);
}
void test_temperature_above_threshold(void) {
set_stub_adc_value(3500); // 模拟高温
temperature_monitor_task();
TEST_ASSERT_EQUAL(1, alarm_triggered_count);
}
手写的好处是灵活,你可以控制桩函数的内部逻辑。坏处是,如果依赖的函数很多,手写的工作量会很大。我记得有一次项目有 40 多个硬件抽象层函数,我手写桩函数写到手软。
4.3.2 使用框架自动生成
现在很多单元测试框架都支持自动生成桩函数。比如 Ceedling 搭配 CMock,或者 Unity 的扩展。你只需要提供一个头文件,框架会自动生成对应的桩函数。
以 CMock 为例,你只需要在测试配置里声明:
// 在 test/mocks 目录下,CMock 会自动解析 adc_driver.h
// 生成 mock_adc_driver.c 和 mock_adc_driver.h
// 测试代码里:
void test_temperature_monitor_with_mock(void) {
adc_read_channel_ExpectAndReturn(3, 3500);
temperature_monitor_task();
// CMock 会自动验证 adc_read_channel 是否被调用,参数是否正确
}
自动生成的桩函数通常还带有调用次数验证、参数检查等功能,相当于把桩函数和模拟对象合二为一了。
4.4 模拟与桩函数的架构设计原则
光会写桩函数还不够,你得知道在架构层面怎么设计,才能让测试变得容易。我总结了三条原则:
- 硬件抽象层(HAL)隔离:把所有硬件相关的操作封装在 HAL 层。业务逻辑只调用 HAL 接口,不直接操作寄存器。这样测试时只需要替换 HAL 层的桩函数。
- 依赖注入:把外部依赖通过参数或全局指针传入。比如温度监控模块可以接受一个
adc_read_func_t函数指针,测试时传入桩函数,生产环境传入真实函数。 - 接口最小化:每个模块对外暴露的接口越少,需要打的桩就越少。我见过一个模块暴露了 20 多个函数,测试时打了 15 个桩,维护成本极高。
下面这张图展示了典型的测试隔离架构:
4.5 实战案例:为 UART 通信模块编写桩函数
光说不练假把式。我们来看一个完整的实战案例。假设你有一个 UART 通信模块,负责发送和接收数据帧:
// uart_comm.h
void uart_comm_init(uint32_t baudrate);
bool uart_comm_send_frame(const uint8_t* data, uint16_t len);
bool uart_comm_receive_frame(uint8_t* buffer, uint16_t* len);
你要测试的是上层协议解析逻辑,它依赖 UART 通信模块。测试目标是:验证协议解析函数能否正确处理接收到的数据帧。
第一步,写桩函数:
// test_stubs_uart.c
#include "uart_comm.h"
static bool stub_send_result = true;
static const uint8_t* stub_rx_data = NULL;
static uint16_t stub_rx_len = 0;
static uint16_t stub_rx_index = 0;
void set_stub_send_result(bool result) {
stub_send_result = result;
}
void set_stub_rx_data(const uint8_t* data, uint16_t len) {
stub_rx_data = data;
stub_rx_len = len;
stub_rx_index = 0;
}
bool uart_comm_send_frame(const uint8_t* data, uint16_t len) {
(void)data;
(void)len;
return stub_send_result;
}
bool uart_comm_receive_frame(uint8_t* buffer, uint16_t* len) {
if (stub_rx_index < stub_rx_len) {
buffer[0] = stub_rx_data[stub_rx_index];
*len = 1;
stub_rx_index++;
return true;
}
return false;
}
第二步,写测试用例:
// test_protocol_parser.c
void test_parse_valid_frame(void) {
uint8_t test_frame[] = {0xAA, 0x01, 0x02, 0x03, 0x55};
set_stub_rx_data(test_frame, sizeof(test_frame));
protocol_parse_task(); // 被测函数
// 验证解析结果
TEST_ASSERT_EQUAL(PARSE_SUCCESS, get_last_parse_result());
TEST_ASSERT_EQUAL(0x01, get_parsed_command());
}
void test_parse_invalid_frame(void) {
uint8_t bad_frame[] = {0xAA, 0xFF, 0x00, 0x55};
set_stub_rx_data(bad_frame, sizeof(bad_frame));
protocol_parse_task();
TEST_ASSERT_EQUAL(PARSE_ERROR_CRC, get_last_parse_result());
}
你看,整个过程完全不需要真实的 UART 硬件。测试跑得飞快,而且每次结果都是可重复的。
uart_comm_receive_frame 的 len 参数传 NULL 时,桩函数直接崩溃了。后来我养成了习惯:桩函数也要做基本的参数检查,至少不能比真实函数更脆弱。
4.6 常见陷阱与最佳实践
最后,分享几个我在实际项目中踩过的坑,以及对应的最佳实践。
| 陷阱 | 后果 | 最佳实践 |
|---|---|---|
| 桩函数返回的值太「完美」 | 测试覆盖不到异常路径 | 桩函数要模拟各种边界值、错误码、超时情况 |
| 桩函数和真实函数行为不一致 | 测试通过,上板子就挂 | 桩函数的行为要基于接口文档,不要随意发挥 |
| 全局变量污染 | 测试用例之间互相影响 | 每个测试用例开始前重置桩函数的状态 |
| 过度使用模拟对象 | 测试代码变得复杂难维护 | 能用桩函数解决的问题,不要用模拟对象 |
嗯,这里要特别强调一下「全局变量污染」的问题。如果你在桩函数里用了静态变量来存储预设值,一定要在 setUp 函数里重置它们。否则,上一个测试用例设置的值会影响下一个测试用例的结果。我吃过这个亏,排查了半天才发现是桩函数的状态没清零。