2. 嵌入式软件集成测试基础

各位做嵌入式开发的同行,咱们今天来聊聊桩函数。说实话,我刚入行那会儿,听到「桩函数」这三个字,第一反应是——这玩意儿跟打桩有什么关系?后来被项目逼着用了半年,才真正体会到它的价值。

桩函数,英文叫 Stub Function,是集成测试里最基础也最实用的工具之一。它不复杂,但用好了能省下大把调试时间。

2.1 桩函数的定义

先给个最直白的定义:桩函数是一个轻量级的替代函数。它用来替换被测模块所依赖的、尚未开发完成或无法直接调用的真实函数。

举个例子。你正在测试一个温度采集模块,它内部调用了 I2C_Read() 来读取传感器数据。但这时候硬件还没焊好,或者 I2C 驱动还没写完。怎么办?

你写一个假的 I2C_Read(),不访问真实硬件,直接返回一个预设的温度值。这个假的函数,就是桩函数。

桩函数的本质:用可控的模拟行为,替代不可控或不可用的真实依赖。

我习惯把桩函数比作「替身演员」。主角(真实函数)还没到场,替身先上去把戏走一遍,让导演(测试用例)能继续往下拍。

2.2 桩函数的核心作用

桩函数到底能帮我们解决什么问题?我总结了四个最关键的用途。

作用 说明 典型场景
解耦依赖 将被测模块与外部依赖隔离 硬件驱动、操作系统API、第三方库
模拟异常 制造真实环境中难以触发的边界条件 超时、错误码、数据溢出
加速调试 跳过耗时或不可重复的流程 网络通信、文件读写、传感器采样
并行开发 模块A开发时,模块B的接口先用桩顶替 团队协作、前后端分离

我记得有一次做车载ECU的集成测试,需要模拟CAN总线上的各种错误帧。真实的总线环境里,错误帧出现的概率极低,靠等根本等不起。我写了个桩函数,专门在特定条件下返回错误码,半天就把异常处理路径测了个遍。要是没有桩,这活儿至少得干一周。

我的经验:桩函数最值钱的能力不是「让代码跑通」,而是「让代码在你想让它出错的时候出错」。可控性,才是桩的灵魂。

2.3 桩函数与驱动函数的区别

很多新手容易把桩函数和驱动函数搞混。这两个概念确实长得像,但角色完全不同。

驱动函数(Driver Function)是主动调用被测模块的代码。它负责构造输入、调用被测函数、收集输出。说白了,驱动函数是「测试的发起者」。

桩函数是被被测模块调用的代码。它负责模拟被测模块的下层依赖。桩函数是「测试的配合者」。

我画了张图,把两者的关系说清楚。

桩函数 vs 驱动函数:角色与调用关系 驱动函数 (Driver) 被测模块 (SUT) 桩函数 (Stub) 调用被测函数 调用依赖函数 驱动函数 主动发起测试,构造输入,收集输出 桩函数 被动响应调用,模拟下层依赖行为

从图上能看得很清楚:驱动函数是「从上往下」调用被测模块,桩函数是「从下往上」被被测模块调用。方向完全相反。

我再用一个表格把关键区别列出来,方便你对照。

对比维度 驱动函数 (Driver) 桩函数 (Stub)
调用方向 调用被测模块 被被测模块调用
角色定位 测试的发起者 测试的配合者
主要职责 构造输入、触发执行、验证输出 模拟依赖、返回预设值、记录调用
开发时机 测试开始时编写 依赖不可用时编写
复杂度 通常较复杂,包含测试逻辑 通常较简单,返回固定值即可
复用性 低,每个测试用例通常单独写 高,多个测试可共用同一个桩

我曾经踩过的坑:刚做集成测试时,我把桩函数写得跟真实函数一样复杂,里面塞了各种状态机和延时逻辑。结果被测模块的bug没找到,桩函数自己先出了bug。后来学乖了——桩函数越简单越好,能返回固定值就别搞花活。

2.4 一个简单的代码示例

光说不练假把式。咱们看一段实际代码,感受一下桩函数和驱动函数是怎么配合的。

假设被测模块是一个温度报警函数 CheckTemperature(),它内部调用了 ReadSensor() 来获取温度值。

/* 被测模块:temperature_alarm.c */
#include "sensor_driver.h"

int CheckTemperature(void)
{
    int temp = ReadSensor();  /* 依赖外部函数 */
    if (temp > 85) {
        return 1;  /* 报警 */
    }
    return 0;      /* 正常 */
}

现在 ReadSensor() 还没写好。我们写一个桩函数来替代它。

/* 桩函数:stub_sensor.c */
static int stub_temperature = 25;  /* 预设值 */

void SetStubTemperature(int value)
{
    stub_temperature = value;
}

int ReadSensor(void)
{
    return stub_temperature;  /* 不读硬件,直接返回预设值 */
}

再写一个驱动函数来调用被测模块。

/* 驱动函数:test_temperature_alarm.c */
#include "temperature_alarm.h"
#include "stub_sensor.h"

void Test_HighTemperature(void)
{
    SetStubTemperature(90);       /* 桩:设置高温 */
    int result = CheckTemperature(); /* 驱动:调用被测函数 */
    if (result == 1) {
        printf("PASS: 高温报警正确触发\n");
    } else {
        printf("FAIL: 高温报警未触发\n");
    }
}

void Test_NormalTemperature(void)
{
    SetStubTemperature(30);       /* 桩:设置正常温度 */
    int result = CheckTemperature();
    if (result == 0) {
        printf("PASS: 正常温度未误报\n");
    } else {
        printf("FAIL: 正常温度误报\n");
    }
}

你看,驱动函数负责「调」和「验」,桩函数负责「配」和「演」。各司其职,清清楚楚。

一个小技巧:我习惯在桩函数里加一个调用计数器。每次桩被调用时,计数器加1。这样驱动函数可以检查「被测模块到底调了几次依赖函数」,对排查逻辑分支特别有用。

2.5 什么时候该用桩,什么时候不该用

桩函数不是万能的。用多了反而让测试变得脆弱。我根据自己的经验,列了几个判断标准。

  • 该用桩的场景:
    • 依赖的硬件尚未就绪(传感器、外设、通信接口)
    • 依赖的操作系统API在测试环境中不可用
    • 依赖的第三方库有许可证或平台限制
    • 需要模拟异常、超时、错误码等边界条件
    • 依赖函数的执行时间过长(如文件写入、网络请求)
  • 不该用桩的场景:
    • 依赖函数已经稳定可用,且执行速度快
    • 测试的目标就是验证依赖函数本身的正确性
    • 桩函数的复杂度已经接近甚至超过真实函数
    • 过度使用桩导致测试与真实环境严重脱节

说白了,桩是一个「权衡工具」。用得好,事半功倍;用得滥,测试就变成了自欺欺人。