回调与测试:使用回调进行桩函数/模拟测试
说实话,很多嵌入式开发者对“测试”这件事,态度挺微妙的。
一方面知道很重要,另一方面又觉得——我代码都写好了,跑一下不就行了?
但等你真正遇到一个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