4、模拟与桩函数:什么是桩函数、如何编写桩函数、模拟外部依赖(如ADC、定时器)
好,咱们进入第四章。这一章聊的东西,我个人觉得是嵌入式测试里最「解渴」的部分——桩函数和模拟。
你想想看,做单片机开发,最头疼的是什么?不是逻辑写不对,而是逻辑跑不起来。为什么?因为你的代码依赖了太多外部硬件:ADC采样、定时器中断、串口收发、I2C传感器……这些硬件在PC上根本不存在。你总不能每次测试都焊一块板子吧?
嗯,这时候就需要桩函数出场了。
4.1 什么是桩函数?
桩函数(Stub),说白了就是一个「假函数」。它长得跟真函数一模一样——名字、参数、返回值类型都相同,但内部实现被替换成了一段简单的、可控的代码。
我举个例子。你写了一个温度采集模块,里面调用了 ADC_GetValue() 来读取电压。在真实硬件上,这个函数会操作ADC寄存器,返回一个12位的数字量。但在PC测试环境里,没有ADC硬件,这个函数根本跑不了。
怎么办?写一个桩函数:
// 真实ADC函数(在硬件上运行)
uint16_t ADC_GetValue(uint8_t channel)
{
// 操作寄存器,启动转换,等待完成...
return ADC_DR; // 返回硬件寄存器值
}
// 桩函数(在PC测试中运行)
uint16_t ADC_GetValue(uint8_t channel)
{
// 不碰任何硬件,直接返回一个预设值
return 1234; // 模拟ADC采样值
}
你看,函数名、参数、返回值完全一致。测试代码里调用的还是 ADC_GetValue(),但实际执行的是桩函数。这样你的温度计算逻辑就能在PC上跑起来了。
核心要点:桩函数不是用来「测试硬件」的,而是用来「隔离硬件」的。它让你只测试自己的逻辑代码,把外部依赖全部替换掉。
4.2 如何编写桩函数?
写桩函数其实有套路。我总结了三个步骤,你照着做就行。
4.2.1 第一步:确定需要打桩的函数
不是所有函数都需要打桩。只有那些「外部依赖」的函数才需要。常见的包括:
- 硬件抽象层函数(ADC、GPIO、定时器、PWM)
- 操作系统API(任务创建、信号量、消息队列)
- 第三方库函数(加密、校验、协议栈)
- 文件系统操作(读写、打开、关闭)
我个人的习惯是:先看被测函数的调用关系图。凡是箭头指向「硬件层」或「外部库」的,都考虑打桩。
4.2.2 第二步:设计桩函数的行为
桩函数不是随便返回一个固定值就完事了。你需要根据测试用例来设计它的行为。常见模式有:
| 桩函数类型 | 适用场景 | 示例 |
|---|---|---|
| 固定返回值 | 测试正常流程 | ADC始终返回0x0FFF |
| 参数化返回值 | 测试不同输入 | 根据channel参数返回不同值 |
| 状态机返回值 | 测试时序逻辑 | 第一次调用返回0,第二次返回1 |
| 错误注入 | 测试异常处理 | 返回错误码或超时 |
举个例子,假设你要测试一个「超时重试」逻辑:
// 桩函数:模拟定时器超时
static int call_count = 0;
uint32_t Timer_GetTick(void)
{
call_count++;
if (call_count < 3) {
return 0; // 前两次调用,模拟未超时
} else {
return 1000; // 第三次调用,模拟超时
}
}
这样你就能测试「超时后是否执行了重试逻辑」。我在项目中遇到过类似场景,当时用这种状态机桩函数,一下子就抓到了一个重试次数溢出的bug。
4.2.3 第三步:链接桩函数
写好了桩函数,怎么让编译器用桩函数替换真实函数?有三种常见方法:
- 条件编译法:在测试工程里用
#ifdef UNIT_TEST包裹桩函数,编译时只编译桩函数版本。 - 链接替换法:把真实函数的源文件从测试工程中排除,只链接桩函数的源文件。
- 函数指针法:定义一个函数指针,测试时指向桩函数,生产时指向真实函数。这种方法更灵活,但代码侵入性稍大。
我个人最常用的是「链接替换法」。简单粗暴,不容易出错。你只需要在测试工程的Makefile或IDE配置里,把包含真实函数的.c文件去掉,换成包含桩函数的.c文件就行。
小技巧:我习惯把桩函数单独放在一个 stubs/ 目录下,每个硬件模块对应一个桩文件,比如 stub_adc.c、stub_timer.c。这样管理起来非常清晰。
4.3 模拟外部依赖:ADC、定时器、串口
好,咱们来点实战。我挑三个最常用的外设,讲讲怎么给它们写桩函数。
4.3.1 模拟ADC
ADC的典型用法是:启动转换 → 等待完成 → 读取结果。桩函数要模拟这个流程。
// 真实ADC头文件(adc.h)
uint16_t ADC_Read(uint8_t channel);
void ADC_StartConversion(uint8_t channel);
uint8_t ADC_IsConversionDone(void);
// 桩函数实现(stub_adc.c)
static uint16_t mock_adc_values[8] = {0}; // 8个通道的模拟值
static uint8_t mock_channel = 0;
void ADC_StartConversion(uint8_t channel)
{
mock_channel = channel;
// 模拟转换时间,这里什么都不做
}
uint8_t ADC_IsConversionDone(void)
{
return 1; // 模拟立即完成
}
uint16_t ADC_Read(uint8_t channel)
{
return mock_adc_values[channel]; // 返回预设值
}
// 测试辅助函数:设置模拟值
void ADC_SetMockValue(uint8_t channel, uint16_t value)
{
mock_adc_values[channel] = value;
}
你看,我额外加了一个 ADC_SetMockValue() 函数。这不是ADC原本的接口,而是测试用的辅助函数。它让测试代码可以灵活控制ADC返回什么值。
测试代码就可以这样写:
void test_temperature_calculation(void)
{
// 模拟ADC返回1.2V对应的数字量
ADC_SetMockValue(0, 2457); // 假设12位ADC,1.2V对应2457
float temp = CalculateTemperature(0);
// 验证计算结果
TEST_ASSERT_FLOAT_WITHIN(0.5, 25.0, temp);
}
注意:模拟ADC时,别忘了考虑「转换时间」。如果你的代码依赖ADC转换完成的时序,桩函数里可以加一个简单的计数器来模拟延迟。我曾经因为忽略了这一点,导致一个PID控制器的测试一直通不过,折腾了半天才发现是时序问题。
4.3.2 模拟定时器
定时器是嵌入式系统里最常用的外设之一。它的桩函数稍微复杂一点,因为涉及到时间流逝的概念。
// 真实定时器接口
void Timer_Init(void);
void Timer_Start(uint32_t period_ms);
uint8_t Timer_IsExpired(void);
uint32_t Timer_GetTick(void);
// 桩函数实现(stub_timer.c)
static uint32_t mock_tick = 0;
static uint32_t mock_period = 0;
static uint8_t mock_expired = 0;
void Timer_Init(void)
{
mock_tick = 0;
mock_expired = 0;
}
void Timer_Start(uint32_t period_ms)
{
mock_period = period_ms;
mock_expired = 0;
}
uint8_t Timer_IsExpired(void)
{
return mock_expired;
}
uint32_t Timer_GetTick(void)
{
return mock_tick;
}
// 测试辅助函数:模拟时间流逝
void Timer_Elapse(uint32_t ms)
{
mock_tick += ms;
if (mock_tick >= mock_period) {
mock_expired = 1;
}
}
这里的关键是 Timer_Elapse() 函数。测试代码通过调用它来「快进」时间,而不需要真的等那么久。
测试用例示例:
void test_led_blink_timing(void)
{
Timer_Start(500); // 启动500ms定时器
// 模拟只过了300ms
Timer_Elapse(300);
TEST_ASSERT_FALSE(Timer_IsExpired());
// 再模拟过200ms,总共500ms
Timer_Elapse(200);
TEST_ASSERT_TRUE(Timer_IsExpired());
// 验证LED状态翻转
TEST_ASSERT_TRUE(IsLedOn());
}
4.3.3 模拟串口
串口的桩函数,我一般会用一个环形缓冲区来模拟收发数据。
// 真实串口接口
void UART_SendByte(uint8_t data);
uint8_t UART_ReceiveByte(void);
uint8_t UART_IsDataAvailable(void);
// 桩函数实现(stub_uart.c)
static uint8_t tx_buffer[256];
static uint8_t rx_buffer[256];
static uint16_t tx_index = 0;
static uint16_t rx_read = 0;
static uint16_t rx_write = 0;
void UART_SendByte(uint8_t data)
{
tx_buffer[tx_index++] = data;
}
uint8_t UART_ReceiveByte(void)
{
return rx_buffer[rx_read++];
}
uint8_t UART_IsDataAvailable(void)
{
return (rx_read != rx_write);
}
// 测试辅助函数:模拟接收数据
void UART_InjectReceivedData(uint8_t data)
{
rx_buffer[rx_write++] = data;
}
// 测试辅助函数:获取发送的数据
uint8_t* UART_GetTransmittedData(uint16_t* len)
{
*len = tx_index;
return tx_buffer;
}
这样,测试代码可以「假装」串口收到了数据,然后检查代码是否正确地发送了响应。
总结一下:写桩函数的核心思想就四个字——「可控替代」。把不可控的硬件行为,变成可控的软件行为。你让ADC返回什么值,它就返回什么值;你让定时器什么时候超时,它就什么时候超时。这样你的测试才能做到「可重复、可预测」。
嗯,这一章的内容就到这里。下一章咱们聊聊「测试框架的选择与搭建」,到时候我会分享一些我用过的框架对比,以及我踩过的坑。