回调与测试:使用回调进行桩函数/模拟测试

说实话,很多嵌入式开发者对“测试”这件事,态度挺微妙的。

一方面知道很重要,另一方面又觉得——我代码都写好了,跑一下不就行了?

但等你真正遇到一个bug,藏在某个传感器驱动里,或者某个中断处理函数的边界条件下,你就知道什么叫“跑一下”根本不够用了。

我早年做车载项目时,就吃过这个亏。一个温度传感器的驱动,在实验室怎么测都正常,一上路就抽风。后来发现是模拟输入的数据太“干净”了,真实环境里的噪声和毛刺根本没覆盖到。

从那以后,我养成了一个习惯:写代码之前,先把桩函数和回调接口搭好

什么是桩函数?为什么需要它?

桩函数,说白了就是一个“假模块”。

你调用它的时候,它不干真活,而是返回你预设好的数据。

举个例子:你的代码里调用了 adc_read() 来读取电压值。在真实硬件上,这个函数会操作ADC寄存器,等待转换完成,返回结果。但在测试阶段,你不想每次都接硬件吧?

这时候,一个桩函数就派上用场了:

// 真实驱动
int adc_read(uint8_t channel) {
    // 操作硬件寄存器...
    return hardware_value;
}

// 桩函数
int adc_read_stub(uint8_t channel) {
    // 直接返回预设值,模拟不同场景
    static const int test_values[] = {1200, 1210, 1195, 1230};
    static int index = 0;
    return test_values[index++ % 4];
}

你看,桩函数不碰任何硬件。它只是按照你的测试意图,返回数据。

但问题来了——你怎么让生产代码和测试代码共用同一个调用接口?

嗯,这就是回调登场的地方。

回调:让桩函数“无缝替换”真实模块

我个人的做法是:把关键模块的调用,设计成回调函数指针

生产环境里,回调指向真实驱动函数。测试环境里,回调指向桩函数。

切换起来,只需要改一个指针赋值。不需要改业务逻辑代码。

// 定义回调类型
typedef int (*adc_read_func_t)(uint8_t channel);

// 全局回调指针
adc_read_func_t g_adc_read = NULL;

// 注册回调(生产环境调用)
void adc_driver_init(void) {
    g_adc_read = adc_read;  // 指向真实驱动
}

// 注册回调(测试环境调用)
void adc_test_init(void) {
    g_adc_read = adc_read_stub;  // 指向桩函数
}

// 业务代码——完全不用改
int get_battery_voltage(void) {
    if (g_adc_read == NULL) return -1;
    int raw = g_adc_read(BATTERY_CHANNEL);
    return raw * VOLTAGE_SCALE;
}

这个模式,说白了就是“依赖注入”的C语言版本。

你想想看,业务代码 get_battery_voltage() 根本不知道它调的是真驱动还是桩函数。它只关心一件事:给我一个值。

这就是回调在测试中的核心价值——解耦

一个完整的模拟测试案例

我们来做一个实际点的例子。假设你有一个温度监控模块,它每隔一段时间读取温度传感器,如果温度超过阈值,就报警。

传统写法可能是这样:

void temperature_monitor(void) {
    int temp = read_temperature_sensor();  // 硬编码调用
    if (temp > TEMP_THRESHOLD) {
        trigger_alarm();
    }
}

这种写法,测试起来很痛苦。你没法控制 read_temperature_sensor() 的返回值,除非你真的去加热传感器。

用回调改造后:

// 回调类型
typedef int (*temp_sensor_read_t)(void);
typedef void (*alarm_trigger_t)(void);

// 监控函数——接受回调参数
void temperature_monitor_cb(temp_sensor_read_t read_func, alarm_trigger_t alarm_func) {
    if (read_func == NULL || alarm_func == NULL) return;

    int temp = read_func();
    if (temp > TEMP_THRESHOLD) {
        alarm_func();
    }
}

现在,测试就变得非常轻松了:

// 桩函数:模拟温度传感器
int mock_temp_sensor(void) {
    return 85;  // 超过阈值,应该触发报警
}

// 桩函数:模拟报警器
int alarm_triggered = 0;
void mock_alarm(void) {
    alarm_triggered = 1;
    printf("报警触发!\n");
}

// 测试用例
void test_temperature_monitor(void) {
    alarm_triggered = 0;
    temperature_monitor_cb(mock_temp_sensor, mock_alarm);
    if (alarm_triggered) {
        printf("测试通过:温度超标时正确报警\n");
    } else {
        printf("测试失败:报警未触发\n");
    }
}

你看,整个测试过程不需要任何真实硬件。你只需要写几个简单的桩函数,就能覆盖各种边界情况。

核心思路:用回调把“依赖”变成“参数”。测试时传入桩函数,生产时传入真实函数。业务代码保持不变。

桩函数的高级用法:记录调用次数和参数

有时候,光返回值还不够。你还想知道:

  • 这个函数被调用了多少次?
  • 每次传入的参数是什么?
  • 调用顺序对不对?

我习惯在桩函数里加一些“记录”逻辑:

// 桩函数 + 调用记录
typedef struct {
    int call_count;
    int last_param;
    int last_return;
    int history[10];  // 记录最近10次参数
} adc_stub_record_t;

adc_stub_record_t g_adc_record = {0};

int adc_read_stub_with_record(uint8_t channel) {
    g_adc_record.call_count++;
    g_adc_record.last_param = channel;
    if (g_adc_record.call_count <= 10) {
        g_adc_record.history[g_adc_record.call_count - 1] = channel;
    }

    // 根据调用次数返回不同值
    if (g_adc_record.call_count == 1) return 1000;
    if (g_adc_record.call_count == 2) return 2000;
    return 1500;
}

// 测试时检查调用记录
void test_adc_calls(void) {
    // 重置记录
    memset(&g_adc_record, 0, sizeof(g_adc_record));

    // 执行业务代码...
    process_adc_data();

    // 验证
    if (g_adc_record.call_count == 3) {
        printf("调用次数正确\n");
    }
    if (g_adc_record.history[0] == CHANNEL_TEMP) {
        printf("第一次调用参数正确\n");
    }
}

这种做法,在调试复杂的状态机或者通信协议时特别有用。我曾经调试一个I2C驱动,就是靠这种带记录的桩函数,发现主芯片多发送了一个停止位——硬件上很难抓,但桩函数一跑就现原形。

避坑指南:回调桩函数测试的常见问题

我曾经踩过的一个坑:桩函数里用了全局变量,但测试用例之间没有重置这些变量。结果第二个测试用例跑的时候,桩函数还保留着上一个用例的状态,导致测试结果完全错误。

解决办法其实很简单:

  • 每个测试用例开始前,显式调用一个 reset_all_stubs() 函数
  • 或者,把桩函数的状态数据放在局部结构体里,每次测试重新初始化

另一个常见问题是:桩函数太“聪明”

有些人喜欢在桩函数里写复杂的逻辑,试图模拟真实硬件的所有行为。结果桩函数本身的bug比被测代码还多。

我的建议是:桩函数越简单越好。能返回常量就别写算法,能顺序取值就别搞随机。桩函数的目的是“可控”,不是“逼真”。

一个小技巧:如果你需要模拟真实硬件的时序行为(比如某个操作需要等待10ms),可以在桩函数里加一个简单的延时循环,或者直接返回一个“忙”状态码。这样既能测试超时处理逻辑,又不用真的等10ms。

用SVG梳理一下:回调桩函数测试的核心流程

下面这张图,把整个流程串起来了。你可以看到,回调接口就像是一个“切换开关”,让测试代码和生产代码共用同一套业务逻辑。

回调桩函数测试核心流程 业务代码 回调函数指针 真实驱动函数 桩函数/模拟函数 生产注册 测试注册 验证调用记录/返回值 真实硬件 通过回调接口,同一份业务代码可以在生产环境和测试环境之间无缝切换

从图上可以看得很清楚:业务代码只跟回调指针打交道。生产时指向真实驱动,测试时指向桩函数。两边互不干扰。

总结一下

回调在测试中的价值,说白了就是一句话:让不可控的依赖变得可控

  • 用回调接口解耦业务代码和底层驱动
  • 用桩函数模拟各种边界条件和异常场景
  • 用调用记录验证函数是否按预期执行

我个人的经验是:哪怕项目再小,也值得花半天时间把回调接口搭好。因为一旦出了问题,你节省的调试时间可能是几天甚至几周。

嗯,测试这件事,前期投入越大,后期越省心。


公众号:蓝海资料掘金营,微信deep3321