第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 桩函数的管理策略
项目大了,桩函数会越来越多。怎么管理?我分享几个经验:
- 集中管理:把所有桩函数放在一个专门的目录下,比如
test/stubs/ - 命名规范:桩函数文件名和原始模块名对应,比如
stub_Can.c、stub_Lin.c - 版本控制:桩函数也要纳入版本管理,每次修改都要有记录
- 复用性:设计桩函数时考虑复用,不要每个测试用例都写一套新的
我个人习惯用一张表格来管理桩函数的状态:
| 桩函数名称 | 所属模块 | 模拟行为 | 测试用例覆盖 |
|---|---|---|---|
| Can_ReadMessage | CAN驱动 | 返回预设报文 | 正常接收、超时、错误帧 |
| Lin_ScheduleFrame | LIN驱动 | 记录调度顺序 | 正常调度、调度表切换 |
| Fr_ReadMessage | FlexRay驱动 | 按槽位返回数据 | 静态段、动态段、同步帧 |
这张表我每次做测试评审时都会拿出来过一遍。它能帮你快速定位问题——如果测试失败了,先看看是不是桩函数没模拟对。
总结一下:桩函数和模拟技术,是单元测试的基石。写桩函数时记住三个原则——简单、可控、可验证。模拟外部依赖时,抓住核心行为,不要过度设计。你想想看,如果你的桩函数比被测代码还复杂,那到底是在测试谁?
下一章我们会聊测试用例的设计方法,到时候我会结合桩函数,讲怎么设计高效的测试场景。嗯,今天就到这儿,有什么问题欢迎交流。