第4章:桩函数与模拟技术

好,咱们今天聊点实在的。桩函数和模拟技术,说白了就是给被测代码“搭台子”。你想想看,ECU里的一个函数,它可能要读CAN报文、要发LIN帧、要等FlexRay同步。可你跑单元测试的时候,这些硬件环境上哪儿找去?

我刚开始做嵌入式测试那会儿,就吃过这个亏。有一次测试一个雨量传感器的逻辑模块,代码里调用了CAN接收函数。我心想直接跑吧,结果测试用例一执行,程序直接卡死在等待CAN报文的循环里。嗯,从那以后我就明白了——没有桩函数,单元测试根本跑不起来。

4.1 什么是桩函数

桩函数(Stub),说白了就是一个“替身”。它替换掉真实的函数调用,返回你预设的数据。比如你的代码里调用了Can_ReadMessage(),在测试环境里,这个函数根本连CAN控制器都访问不到。那怎么办?写一个同名的桩函数,让它直接返回一个你准备好的报文数据。

我个人习惯把桩函数分成三类:

  • 返回值桩:只返回固定值,比如返回OK或ERROR
  • 行为桩:模拟一些简单行为,比如计数、记录调用次数
  • 复杂桩:模拟协议栈行为,比如模拟CAN报文的发送和接收流程

核心要点:桩函数不是用来测试桩本身的,而是用来隔离被测代码的外部依赖。你测试的是你的逻辑,不是CAN驱动好不好使。

4.2 如何编写桩函数

写桩函数其实不难,但有几个坑你得注意。我见过不少同事,桩函数写得比真实函数还复杂,这就本末倒置了。

来看一个最简单的例子。假设被测函数调用了这样一个接口:

// 原始接口
Std_ReturnType Can_ReadMessage(uint8_t channel, Can_MsgType *msg);

在测试环境里,我们写一个桩函数:

// 桩函数实现
static Can_MsgType stub_msg;
static uint8_t stub_call_count = 0;

Std_ReturnType Can_ReadMessage(uint8_t channel, Can_MsgType *msg) {
    stub_call_count++;
    // 复制预设的报文数据
    memcpy(msg, &stub_msg, sizeof(Can_MsgType));
    return E_OK;
}

// 测试辅助函数:设置桩的返回值
void Can_ReadMessage_SetReturn(Can_MsgType *msg) {
    memcpy(&stub_msg, msg, sizeof(Can_MsgType));
}

// 测试辅助函数:获取调用次数
uint8_t Can_ReadMessage_GetCallCount(void) {
    return stub_call_count;
}

你看,这个桩函数做了三件事:记录调用次数、返回预设数据、返回状态码。这就够了。我曾经遇到一个项目,测试工程师在桩函数里实现了完整的CAN协议解析,结果桩函数本身的bug比被测代码还多。记住,桩函数越简单越好。

我的小技巧:写桩函数时,先问自己三个问题——这个函数返回什么?它会影响什么全局状态?我需要验证它被调用了多少次?想清楚这三个问题,桩函数就写好了。

4.3 模拟外部依赖:CAN、LIN、FlexRay

这三种总线协议,在单元测试和集成测试里,模拟方式不太一样。我一个个说。

4.3.1 CAN总线模拟

CAN总线在ECU里用得最多。单元测试时,我们主要模拟两个场景:接收报文和发送报文。

接收报文的桩函数,我刚才已经演示过了。发送报文呢?其实更简单——你只需要验证被测代码是否调用了发送函数,以及传入了正确的参数。

// 发送报文的桩函数
static Can_MsgType stub_tx_msg;
static uint8_t stub_tx_flag = 0;

Std_ReturnType Can_WriteMessage(uint8_t channel, const Can_MsgType *msg) {
    stub_tx_flag = 1;
    memcpy(&stub_tx_msg, msg, sizeof(Can_MsgType));
    return E_OK;
}

// 验证发送的报文
uint8_t Can_WriteMessage_GetSentMsg(Can_MsgType *msg) {
    if (stub_tx_flag) {
        memcpy(msg, &stub_tx_msg, sizeof(Can_MsgType));
        stub_tx_flag = 0;
        return 1;
    }
    return 0;
}

这里要注意一个细节:CAN报文有ID、DLC、数据字节。验证的时候,这三个字段一个都不能少。我遇到过测试用例只验证了ID,结果DLC传错了,导致真实ECU上报文被丢弃。嗯,这种坑踩过一次就记住了。

4.3.2 LIN总线模拟

LIN总线比CAN简单,但有个特点——它是主从架构。单元测试时,如果你的模块是LIN主节点,你需要模拟从节点的响应。如果是从节点,你需要模拟主节点的调度。

我建议这样设计LIN的桩函数:

// LIN主节点调度表的桩函数
static uint8_t stub_lin_schedule[10];
static uint8_t stub_schedule_index = 0;

void Lin_ScheduleFrame(uint8_t channel, uint8_t frame_id) {
    if (stub_schedule_index < 10) {
        stub_lin_schedule[stub_schedule_index++] = frame_id;
    }
}

// 验证调度顺序
uint8_t Lin_GetScheduledFrame(uint8_t index) {
    if (index < stub_schedule_index) {
        return stub_lin_schedule[index];
    }
    return 0xFF;
}

LIN的时序要求比较严格。我记得有一次测试一个车窗控制模块,LIN报文的间隔时间没模拟对,结果测试通过了,但上车实测时车窗动作卡顿。后来我在桩函数里加了一个时间戳记录,专门用来验证报文间隔。

4.3.3 FlexRay总线模拟

FlexRay就复杂多了。它有静态段和动态段,还有同步帧和启动帧。单元测试时,我一般只模拟两个核心行为:

  • 同步帧的接收:模拟冷启动和时钟同步
  • 静态段报文的收发:模拟周期性的数据交换
// FlexRay静态段接收桩函数
static Fr_MsgType stub_fr_slots[256];
static uint8_t stub_fr_slot_count = 0;

Std_ReturnType Fr_ReadMessage(uint8_t channel, uint16_t slot_id, Fr_MsgType *msg) {
    // 查找预设的槽位数据
    for (uint8_t i = 0; i < stub_fr_slot_count; i++) {
        if (stub_fr_slots[i].slot_id == slot_id) {
            memcpy(msg, &stub_fr_slots[i], sizeof(Fr_MsgType));
            return E_OK;
        }
    }
    return E_NOT_OK;
}

特别注意:FlexRay的时序要求极高。桩函数里如果忽略了cycle计数和slot边界,测试结果可能和实际行为完全不符。我曾经因为这个原因,一个测试用例跑了三天才发现是桩函数写错了。

4.4 桩函数的管理策略

项目大了,桩函数会越来越多。怎么管理?我分享几个经验:

  1. 集中管理:把所有桩函数放在一个专门的目录下,比如test/stubs/
  2. 命名规范:桩函数文件名和原始模块名对应,比如stub_Can.cstub_Lin.c
  3. 版本控制:桩函数也要纳入版本管理,每次修改都要有记录
  4. 复用性:设计桩函数时考虑复用,不要每个测试用例都写一套新的

我个人习惯用一张表格来管理桩函数的状态:

桩函数名称 所属模块 模拟行为 测试用例覆盖
Can_ReadMessage CAN驱动 返回预设报文 正常接收、超时、错误帧
Lin_ScheduleFrame LIN驱动 记录调度顺序 正常调度、调度表切换
Fr_ReadMessage FlexRay驱动 按槽位返回数据 静态段、动态段、同步帧

这张表我每次做测试评审时都会拿出来过一遍。它能帮你快速定位问题——如果测试失败了,先看看是不是桩函数没模拟对。

总结一下:桩函数和模拟技术,是单元测试的基石。写桩函数时记住三个原则——简单、可控、可验证。模拟外部依赖时,抓住核心行为,不要过度设计。你想想看,如果你的桩函数比被测代码还复杂,那到底是在测试谁?

下一章我们会聊测试用例的设计方法,到时候我会结合桩函数,讲怎么设计高效的测试场景。嗯,今天就到这儿,有什么问题欢迎交流。