4. 桩模块与驱动模块设计:Stub与Driver的概念、设计原则与实现方法
集成测试里有个绕不开的话题——Stub(桩模块)和Driver(驱动模块)。说白了,它们就是测试中的“替身演员”。
我刚开始做嵌入式集成测试时,总觉得写桩和驱动是浪费时间。后来在一个车载ECU项目里,底层驱动还没调通,上层逻辑又急着验证。硬着头皮写了几个桩模块,结果提前两周发现了三个致命bug。嗯,从那以后我再也不敢小看它们了。
4.1 为什么需要Stub和Driver?
嵌入式系统里,模块之间依赖关系复杂。你想想看,一个传感器采集模块,底层要调I²C驱动、DMA传输、中断处理。如果这些底层都没写好,上层逻辑怎么测?
这时候就需要“替身”上场:
- Stub(桩模块):代替被调用的下层模块。上层调它,它返回预设数据。
- Driver(驱动模块):代替调用方的上层模块。它主动调用被测模块,传入测试数据。
一句话记住:
- Stub = 下层替身(被调方)
- Driver = 上层替身(调用方)
4.2 Stub与Driver的核心概念
我习惯用“调用方向”来区分它们。画个图你就明白了:
4.3 设计原则
写桩和驱动不是随便写写就行的。我踩过不少坑,总结出几条原则:
4.3.1 最小化原则
Stub只实现被测模块实际调用的接口,不要画蛇添足。我曾经在一个项目中,把Stub写成了完整的功能模块,结果测试环境跟真实环境行为不一致,查了两天bug才发现是Stub写多了。
4.3.2 可控性原则
Stub的返回值必须可配置。我习惯用结构体或全局变量来控制Stub的行为:
/* Stub控制结构体 */
typedef struct {
int32_t return_value;
uint32_t delay_ms;
bool force_error;
} stub_control_t;
stub_control_t g_stub_ctrl = {0};
/* Stub实现 */
int32_t stub_read_sensor(void) {
if (g_stub_ctrl.force_error) {
return -1;
}
delay_ms(g_stub_ctrl.delay_ms);
return g_stub_ctrl.return_value;
}
4.3.3 可观测性原则
Driver要能记录被测模块的调用情况。我常用的做法是加调用计数和参数记录:
/* Driver调用记录 */
typedef struct {
uint32_t call_count;
int32_t last_param1;
int32_t last_param2;
int32_t last_result;
} driver_record_t;
driver_record_t g_driver_rec = {0};
/* Driver调用被测模块 */
int32_t driver_test_algorithm(void) {
int32_t result;
g_driver_rec.call_count++;
g_driver_rec.last_param1 = 100;
g_driver_rec.last_param2 = 200;
result = algorithm_process(
g_driver_rec.last_param1,
g_driver_rec.last_param2
);
g_driver_rec.last_result = result;
return result;
}
4.4 实现方法
实际项目中,我常用三种方式来实现Stub和Driver:
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 函数指针重定向 | C语言项目,模块间通过函数指针解耦 | 灵活,运行时切换 | 增加代码复杂度 |
| 条件编译 | 单元测试,编译时替换 | 简单,零运行时开销 | 需要修改源码 |
| 链接时替换 | 集成测试,链接不同目标文件 | 不改源码,干净 | 需要构建系统支持 |
4.4.1 函数指针重定向示例
/* sensor_driver.h */
typedef int32_t (*read_sensor_fn_t)(void);
extern read_sensor_fn_t g_read_sensor;
/* sensor_driver.c */
read_sensor_fn_t g_read_sensor = real_read_sensor;
int32_t read_temperature(void) {
return g_read_sensor();
}
/* test_stub.c */
int32_t stub_read_sensor(void) {
return 25; /* 固定返回25°C */
}
/* 测试初始化时替换 */
void test_setup(void) {
g_read_sensor = stub_read_sensor;
}
4.4.2 条件编译示例
/* uart_driver.h */
#ifdef UNIT_TEST
#define uart_send(data) stub_uart_send(data)
#define uart_recv() stub_uart_recv()
#else
#define uart_send(data) hal_uart_send(data)
#define uart_recv() hal_uart_recv()
#endif
4.4.3 链接时替换示例
/* 真实实现:real_flash.c */
int32_t flash_write(uint32_t addr, uint8_t *data, uint32_t len) {
/* 操作真实Flash硬件 */
}
/* 桩实现:stub_flash.c */
int32_t flash_write(uint32_t addr, uint8_t *data, uint32_t len) {
/* 只写入内存缓冲区 */
memcpy(&g_flash_buf[addr], data, len);
return 0;
}
/* Makefile中链接桩版本 */
# TEST_OBJS += stub_flash.o
# REAL_OBJS += real_flash.o
我的经验:
在资源受限的MCU上,我优先用条件编译。它不增加运行时开销,而且测试代码和产品代码可以共存。但要注意,别让测试宏污染了产品代码的逻辑。
4.5 避坑指南
这些年我踩过的坑,分享给你:
- Stub返回值太理想化:我曾经只返回正常值,结果真实场景下各种边界条件全崩了。现在我会在Stub里加入随机错误注入。
- Driver写得太复杂:Driver只是测试的发起者,别在里面写业务逻辑。否则你测的是Driver还是被测模块?
- 忘记清理状态:多个测试用例共享Stub时,状态会污染。每个用例开始前要重置Stub控制结构体。
- 忽略时序问题:嵌入式系统里,Stub的响应时间要模拟真实情况。我习惯在Stub里加可配置的延时。
特别注意:
Stub和Driver是测试替身,不是产品代码。测试完成后一定要移除或替换回真实模块。我见过有人把Stub留在产品代码里,结果生产环境跑的是假数据——那场面,相当尴尬。
4.6 实战案例:温控系统集成测试
最后看一个完整的例子。一个温控系统,包含三个模块:
- 温度采集模块(被测模块):调用传感器驱动,获取温度值
- 传感器驱动(需要Stub):操作I²C总线
- 控制逻辑模块(需要Driver):调用温度采集模块,做决策
/* 传感器驱动Stub */
static int32_t stub_sensor_value = 25;
int32_t stub_i2c_read(uint8_t dev_addr, uint8_t reg_addr) {
(void)dev_addr;
(void)reg_addr;
return stub_sensor_value;
}
/* 控制逻辑Driver */
void driver_control_test(void) {
int32_t temp;
/* 测试场景1:正常温度 */
stub_sensor_value = 25;
temp = read_temperature();
assert(temp == 25);
/* 测试场景2:高温告警 */
stub_sensor_value = 85;
temp = read_temperature();
assert(temp == 85);
assert(get_alarm_status() == true);
/* 测试场景3:传感器故障 */
stub_sensor_value = -1; /* 错误码 */
temp = read_temperature();
assert(temp == -1);
assert(get_error_status() == true);
}
这个例子虽然简单,但涵盖了Stub和Driver的核心用法。你想想看,如果没有Stub,你得先调通I²C驱动才能测温度采集。有了Stub,随时可以测。这就是替身演员的价值。