桩函数设计三大原则
做集成测试这么多年,我总结出一个道理:桩函数写得好不好,直接决定测试效率。写得太糙,测不出问题;写得太重,又拖慢进度。今天咱们就聊聊桩函数设计的三个核心原则——接口一致性、行为可控性、资源最小化。
这三个原则,说白了就是让桩函数「长得像、听话、不占地方」。我一个个拆开讲。
3.1 接口一致性原则
什么叫接口一致性?就是桩函数的函数名、参数列表、返回值类型,必须跟真实函数一模一样。这听起来是废话,但我见过太多人在这上面翻车。
举个例子,真实函数长这样:
// 真实函数:读取温度传感器
int32_t read_temperature(uint8_t sensor_id, float* temp_value);
桩函数就必须是:
// 桩函数:签名必须完全一致
int32_t stub_read_temperature(uint8_t sensor_id, float* temp_value) {
// 桩逻辑
*temp_value = 25.5f;
return 0;
}
参数类型、个数、顺序、返回值类型,一个都不能改。为什么?因为编译器做的是静态类型检查,你改了签名,编译直接报错。
接口一致性还有一个容易被忽略的点:调用约定。比如 __stdcall 和 __cdecl 的区别,在嵌入式环境里,有些编译器会生成不同的符号名。桩函数必须跟真实函数用同一个调用约定。
| 一致性维度 | 检查项 | 常见错误 |
|---|---|---|
| 函数名 | 大小写、下划线、命名空间 | 拼写错误、漏掉前缀 |
| 参数列表 | 类型、个数、顺序、const 修饰 | uint8_t 写成 int、漏掉指针 |
| 返回值 | 类型、void 或非 void | int32_t 写成 int |
| 调用约定 | __cdecl / __stdcall / __fastcall | 默认约定不一致 |
3.2 行为可控性原则
桩函数的核心价值是什么?是让测试用例能控制被测代码的执行路径。如果桩函数的行为不可控,那跟用真实函数有什么区别?
我个人习惯的做法是:给桩函数加一个「控制结构」。最常见的方式是用全局变量做开关。
// 桩函数控制结构
typedef struct {
int32_t return_value; // 预设返回值
float output_value; // 预设输出参数
uint32_t call_count; // 调用次数统计
int32_t (*custom_callback)(uint8_t, float*); // 自定义回调
} stub_read_temp_control_t;
stub_read_temp_control_t g_stub_read_temp_ctrl = {0};
// 桩函数实现
int32_t stub_read_temperature(uint8_t sensor_id, float* temp_value) {
g_stub_read_temp_ctrl.call_count++;
if (g_stub_read_temp_ctrl.custom_callback) {
return g_stub_read_temp_ctrl.custom_callback(sensor_id, temp_value);
}
*temp_value = g_stub_read_temp_ctrl.output_value;
return g_stub_read_temp_ctrl.return_value;
}
这样设计的好处是:测试用例可以在运行前设置桩的行为,运行后检查调用次数。
- 固定模式:预设返回值,每次调用都一样
- 序列模式:预设一组返回值,按调用顺序依次返回
- 回调模式:传入自定义函数,完全由测试用例控制
你想想看,如果没有行为可控,你怎么测试「传感器读取失败」这个分支?真实传感器可能永远不会失败,但桩函数可以随时返回 -1。
3.3 资源最小化原则
这个原则很多人不重视。我见过有人写的桩函数,里面开了 4KB 的数组、调用了 malloc、甚至启动了定时器。这完全违背了桩函数的初衷。
资源最小化,说白了就是:桩函数只做最少的事,占用最少的资源。
- 不要分配动态内存:嵌入式环境里,堆可能被测试框架占用
- 不要启动硬件外设:桩函数是软件模拟,不是硬件驱动
- 不要调用复杂算法:能返回固定值就别做计算
- 不要使用全局大数组:能省则省,栈空间也有限
举个例子,一个「发送 CAN 报文」的桩函数,正确的做法是:
// ✅ 好的桩函数:只记录调用,不真的发报文
int32_t stub_can_send(uint32_t id, uint8_t* data, uint8_t len) {
g_stub_can_send_ctrl.call_count++;
g_stub_can_send_ctrl.last_id = id;
memcpy(g_stub_can_send_ctrl.last_data, data, len);
g_stub_can_send_ctrl.last_len = len;
return 0;
}
而不是:
// ❌ 坏的桩函数:真的去初始化 CAN 控制器
int32_t stub_can_send(uint32_t id, uint8_t* data, uint8_t len) {
can_init(CAN1, 500000); // 不要!
can_send_message(CAN1, id, data, len); // 不要!
return 0;
}
为什么?因为桩函数可能会被调用几百次,每次初始化 CAN 控制器,测试速度会慢到让你怀疑人生。
3.4 三个原则的关系
这三个原则不是孤立的,它们互相制约。我画了一张图,帮你理清关系:
3.5 避坑指南
最后,我把自己这些年踩过的坑整理一下,你写桩函数的时候可以对照着检查:
- 不要偷懒复制粘贴:我曾经从另一个模块复制桩函数,忘了改参数类型,结果调试了一下午。每次写桩函数,都从头文件复制声明。
- 不要忘记初始化控制结构:全局控制结构一定要在测试用例开始前清零,否则上次测试的残留数据会影响本次结果。
- 不要在桩函数里加打印:printf 会拖慢测试速度,而且输出会干扰测试结果分析。用调用计数代替打印。
- 不要假设调用顺序:被测代码可能因为优化或调度改变调用顺序,桩函数要能处理任意顺序的调用。
- 不要忽略 const 修饰:参数带 const 的,桩函数里也要保持 const,否则编译器会报警告。