4. 桩模块与驱动模块设计:Stub与Driver的概念、设计原则与实现方法

集成测试里有个绕不开的话题——Stub(桩模块)和Driver(驱动模块)。说白了,它们就是测试中的“替身演员”。

我刚开始做嵌入式集成测试时,总觉得写桩和驱动是浪费时间。后来在一个车载ECU项目里,底层驱动还没调通,上层逻辑又急着验证。硬着头皮写了几个桩模块,结果提前两周发现了三个致命bug。嗯,从那以后我再也不敢小看它们了。

4.1 为什么需要Stub和Driver?

嵌入式系统里,模块之间依赖关系复杂。你想想看,一个传感器采集模块,底层要调I²C驱动、DMA传输、中断处理。如果这些底层都没写好,上层逻辑怎么测?

这时候就需要“替身”上场:

  • Stub(桩模块):代替被调用的下层模块。上层调它,它返回预设数据。
  • Driver(驱动模块):代替调用方的上层模块。它主动调用被测模块,传入测试数据。

一句话记住:

  • Stub = 下层替身(被调方)
  • Driver = 上层替身(调用方)

4.2 Stub与Driver的核心概念

我习惯用“调用方向”来区分它们。画个图你就明白了:

Stub与Driver在集成测试中的位置 Driver(驱动模块) 模拟上层调用方 被测模块(SUT) 待验证的单元 Stub(桩模块) 模拟下层被调方 调用 传入测试数据 调用 返回预设值 关键区别 • Driver:主动调用被测模块,传递参数,接收返回值。它是“测试的发起者”。 • Stub:被被测模块调用,返回预设数据或执行预设行为。它是“测试的响应者”。 • 一个模块在测试中可能同时需要Driver和Stub,取决于它在调用链中的位置。 • 集成测试中,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,随时可以测。这就是替身演员的价值。