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 第三步:链接桩函数

写好了桩函数,怎么让编译器用桩函数替换真实函数?有三种常见方法:

  1. 条件编译法:在测试工程里用 #ifdef UNIT_TEST 包裹桩函数,编译时只编译桩函数版本。
  2. 链接替换法:把真实函数的源文件从测试工程中排除,只链接桩函数的源文件。
  3. 函数指针法:定义一个函数指针,测试时指向桩函数,生产时指向真实函数。这种方法更灵活,但代码侵入性稍大。

我个人最常用的是「链接替换法」。简单粗暴,不容易出错。你只需要在测试工程的Makefile或IDE配置里,把包含真实函数的.c文件去掉,换成包含桩函数的.c文件就行。

小技巧:我习惯把桩函数单独放在一个 stubs/ 目录下,每个硬件模块对应一个桩文件,比如 stub_adc.cstub_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返回什么值,它就返回什么值;你让定时器什么时候超时,它就什么时候超时。这样你的测试才能做到「可重复、可预测」。

嗯,这一章的内容就到这里。下一章咱们聊聊「测试框架的选择与搭建」,到时候我会分享一些我用过的框架对比,以及我踩过的坑。