桩函数设计三大原则

做集成测试这么多年,我总结出一个道理:桩函数写得好不好,直接决定测试效率。写得太糙,测不出问题;写得太重,又拖慢进度。今天咱们就聊聊桩函数设计的三个核心原则——接口一致性、行为可控性、资源最小化。

这三个原则,说白了就是让桩函数「长得像、听话、不占地方」。我一个个拆开讲。

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;
}

参数类型、个数、顺序、返回值类型,一个都不能改。为什么?因为编译器做的是静态类型检查,你改了签名,编译直接报错。

⚠️ 我曾经踩过的坑:有一次我把 uint8_t 写成了 int,编译过了,但运行时参数传递错位,查了整整两天。从那以后,我养成了一个习惯——直接从头文件复制函数声明,绝不手打。

接口一致性还有一个容易被忽略的点:调用约定。比如 __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、甚至启动了定时器。这完全违背了桩函数的初衷。

资源最小化,说白了就是:桩函数只做最少的事,占用最少的资源。

  • 不要分配动态内存:嵌入式环境里,堆可能被测试框架占用
  • 不要启动硬件外设:桩函数是软件模拟,不是硬件驱动
  • 不要调用复杂算法:能返回固定值就别做计算
  • 不要使用全局大数组:能省则省,栈空间也有限
📌 我的经验:一个合格的桩函数,代码量应该控制在 10 行以内。如果超过 20 行,说明你设计得太重了,需要重新思考。

举个例子,一个「发送 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 避坑指南

最后,我把自己这些年踩过的坑整理一下,你写桩函数的时候可以对照着检查:

  1. 不要偷懒复制粘贴:我曾经从另一个模块复制桩函数,忘了改参数类型,结果调试了一下午。每次写桩函数,都从头文件复制声明。
  2. 不要忘记初始化控制结构:全局控制结构一定要在测试用例开始前清零,否则上次测试的残留数据会影响本次结果。
  3. 不要在桩函数里加打印:printf 会拖慢测试速度,而且输出会干扰测试结果分析。用调用计数代替打印。
  4. 不要假设调用顺序:被测代码可能因为优化或调度改变调用顺序,桩函数要能处理任意顺序的调用。
  5. 不要忽略 const 修饰:参数带 const 的,桩函数里也要保持 const,否则编译器会报警告。
✅ 记住一句话:桩函数是测试的「工具」,不是被测的「目标」。它越简单、越可控、越轻量,你的测试就越高效。