4. 模拟与桩函数:如何隔离硬件依赖进行纯逻辑测试

做嵌入式软件测试,最头疼的问题是什么?

我个人觉得,就是硬件依赖。你写了一个控制逻辑,想跑个单元测试,结果发现它调用了 GPIO 写引脚、ADC 读数据、UART 发字符串。没有硬件板子,这些函数根本跑不起来。就算有板子,每次测试都要烧录、接线、手动触发,效率低得让人抓狂。

所以这一章,我们来聊聊怎么把硬件依赖「切掉」,让测试只关注你写的逻辑本身。说白了,就是给代码打桩、做模拟。

4.1 为什么需要模拟与桩函数?

先看一个典型场景。你写了一个温度监控模块,它每隔 100ms 读一次 ADC,如果温度超过阈值就报警。代码大概长这样:

// temperature_monitor.c
void temperature_monitor_task(void) {
    uint16_t adc_value = adc_read_channel(3);  // 读ADC通道3
    int32_t temperature = adc_to_temp(adc_value);
    if (temperature > TEMP_THRESHOLD) {
        alarm_trigger(ALARM_OVERHEAT);
    }
}

这个函数依赖三个外部函数:adc_read_channel、adc_to_temp、alarm_trigger。如果没有硬件,adc_read_channel 返回什么?不知道。你没法验证 temperature_monitor_task 的逻辑对不对。

我在项目中遇到过类似的情况。有一次写电机控制算法,依赖霍尔传感器的读取函数。板子还没回来,算法逻辑又复杂,我硬着头皮先写测试。结果发现,只要把霍尔读取函数换成桩函数,返回固定值,整个控制逻辑的测试就能跑通了。那次之后,我养成了「先写桩、后写逻辑」的习惯。

核心思想: 桩函数(Stub)和模拟对象(Mock)是用来替代真实硬件依赖的替身。它们返回预设的值,或者记录调用信息,让测试只关注被测代码的逻辑。

4.2 桩函数 vs 模拟对象:概念与区别

很多初学者会把桩函数和模拟对象混为一谈。其实它们有明确的区别。我习惯用一个表格来对比:

特性 桩函数(Stub) 模拟对象(Mock)
主要目的 提供预设返回值 验证行为是否正确
是否验证调用 通常不验证 验证调用次数、参数、顺序
典型用法 替代传感器读取、硬件状态查询 验证中断处理、回调函数、消息发送
返回值复杂度 固定值或简单逻辑 可根据输入参数动态返回
测试侧重点 被测函数的数据处理逻辑 被测函数是否正确调用了外部接口

举个例子。如果你要测试温度阈值判断逻辑,用桩函数就够了——让 adc_read_channel 返回一个固定值,然后检查报警是否触发。但如果你要测试中断服务程序是否在正确时机调用了 adc_read_channel 三次,那就需要用模拟对象来记录调用次数。

我的建议: 刚开始做单元测试时,先从桩函数入手。等你对测试框架熟悉了,再引入模拟对象。一口吃不成胖子。

4.3 如何编写桩函数:手写 vs 自动生成

写桩函数有两种方式:手写和用框架自动生成。我两种都用过,各有优劣。

4.3.1 手写桩函数

手写桩函数是最直接的方式。你只需要定义一个同名的函数,返回你想要的值。比如:

// test_stubs.c
#include "stub_defs.h"

static uint16_t stub_adc_value = 0;

void set_stub_adc_value(uint16_t val) {
    stub_adc_value = val;
}

uint16_t adc_read_channel(uint8_t channel) {
    (void)channel;  // 忽略参数,返回预设值
    return stub_adc_value;
}

然后在测试代码里:

// test_temperature_monitor.c
void test_temperature_below_threshold(void) {
    set_stub_adc_value(500);  // 模拟低温
    temperature_monitor_task();
    // 检查报警没有被触发
    TEST_ASSERT_EQUAL(0, alarm_triggered_count);
}

void test_temperature_above_threshold(void) {
    set_stub_adc_value(3500);  // 模拟高温
    temperature_monitor_task();
    TEST_ASSERT_EQUAL(1, alarm_triggered_count);
}

手写的好处是灵活,你可以控制桩函数的内部逻辑。坏处是,如果依赖的函数很多,手写的工作量会很大。我记得有一次项目有 40 多个硬件抽象层函数,我手写桩函数写到手软。

4.3.2 使用框架自动生成

现在很多单元测试框架都支持自动生成桩函数。比如 Ceedling 搭配 CMock,或者 Unity 的扩展。你只需要提供一个头文件,框架会自动生成对应的桩函数。

以 CMock 为例,你只需要在测试配置里声明:

// 在 test/mocks 目录下,CMock 会自动解析 adc_driver.h
// 生成 mock_adc_driver.c 和 mock_adc_driver.h

// 测试代码里:
void test_temperature_monitor_with_mock(void) {
    adc_read_channel_ExpectAndReturn(3, 3500);
    temperature_monitor_task();
    // CMock 会自动验证 adc_read_channel 是否被调用,参数是否正确
}

自动生成的桩函数通常还带有调用次数验证、参数检查等功能,相当于把桩函数和模拟对象合二为一了。

注意: 自动生成的桩函数可能会让测试代码变得「啰嗦」。如果你只是需要简单的返回值,手写桩函数反而更清晰。别为了用框架而用框架。

4.4 模拟与桩函数的架构设计原则

光会写桩函数还不够,你得知道在架构层面怎么设计,才能让测试变得容易。我总结了三条原则:

  1. 硬件抽象层(HAL)隔离:把所有硬件相关的操作封装在 HAL 层。业务逻辑只调用 HAL 接口,不直接操作寄存器。这样测试时只需要替换 HAL 层的桩函数。
  2. 依赖注入:把外部依赖通过参数或全局指针传入。比如温度监控模块可以接受一个 adc_read_func_t 函数指针,测试时传入桩函数,生产环境传入真实函数。
  3. 接口最小化:每个模块对外暴露的接口越少,需要打的桩就越少。我见过一个模块暴露了 20 多个函数,测试时打了 15 个桩,维护成本极高。

下面这张图展示了典型的测试隔离架构:

测试隔离架构:模拟与桩函数 被测模块 温度监控逻辑 测试框架 Unity / Ceedling 桩函数层 adc_read_channel alarm_trigger 模拟对象层 调用次数验证 参数检查 硬件抽象层(HAL) 真实硬件驱动 / 寄存器操作 测试时替换路径 被测模块 → 桩函数/模拟对象 不经过真实 HAL 生产环境 → 真实 HAL

4.5 实战案例:为 UART 通信模块编写桩函数

光说不练假把式。我们来看一个完整的实战案例。假设你有一个 UART 通信模块,负责发送和接收数据帧:

// uart_comm.h
void uart_comm_init(uint32_t baudrate);
bool uart_comm_send_frame(const uint8_t* data, uint16_t len);
bool uart_comm_receive_frame(uint8_t* buffer, uint16_t* len);

你要测试的是上层协议解析逻辑,它依赖 UART 通信模块。测试目标是:验证协议解析函数能否正确处理接收到的数据帧。

第一步,写桩函数:

// test_stubs_uart.c
#include "uart_comm.h"

static bool stub_send_result = true;
static const uint8_t* stub_rx_data = NULL;
static uint16_t stub_rx_len = 0;
static uint16_t stub_rx_index = 0;

void set_stub_send_result(bool result) {
    stub_send_result = result;
}

void set_stub_rx_data(const uint8_t* data, uint16_t len) {
    stub_rx_data = data;
    stub_rx_len = len;
    stub_rx_index = 0;
}

bool uart_comm_send_frame(const uint8_t* data, uint16_t len) {
    (void)data;
    (void)len;
    return stub_send_result;
}

bool uart_comm_receive_frame(uint8_t* buffer, uint16_t* len) {
    if (stub_rx_index < stub_rx_len) {
        buffer[0] = stub_rx_data[stub_rx_index];
        *len = 1;
        stub_rx_index++;
        return true;
    }
    return false;
}

第二步,写测试用例:

// test_protocol_parser.c
void test_parse_valid_frame(void) {
    uint8_t test_frame[] = {0xAA, 0x01, 0x02, 0x03, 0x55};
    set_stub_rx_data(test_frame, sizeof(test_frame));
    
    protocol_parse_task();  // 被测函数
    
    // 验证解析结果
    TEST_ASSERT_EQUAL(PARSE_SUCCESS, get_last_parse_result());
    TEST_ASSERT_EQUAL(0x01, get_parsed_command());
}

void test_parse_invalid_frame(void) {
    uint8_t bad_frame[] = {0xAA, 0xFF, 0x00, 0x55};
    set_stub_rx_data(bad_frame, sizeof(bad_frame));
    
    protocol_parse_task();
    
    TEST_ASSERT_EQUAL(PARSE_ERROR_CRC, get_last_parse_result());
}

你看,整个过程完全不需要真实的 UART 硬件。测试跑得飞快,而且每次结果都是可重复的。

避坑指南: 我曾经在写桩函数时犯过一个错误——桩函数里忘了处理边界条件。比如 uart_comm_receive_frame 的 len 参数传 NULL 时,桩函数直接崩溃了。后来我养成了习惯:桩函数也要做基本的参数检查,至少不能比真实函数更脆弱。

4.6 常见陷阱与最佳实践

最后,分享几个我在实际项目中踩过的坑,以及对应的最佳实践。

陷阱 后果 最佳实践
桩函数返回的值太「完美」 测试覆盖不到异常路径 桩函数要模拟各种边界值、错误码、超时情况
桩函数和真实函数行为不一致 测试通过,上板子就挂 桩函数的行为要基于接口文档,不要随意发挥
全局变量污染 测试用例之间互相影响 每个测试用例开始前重置桩函数的状态
过度使用模拟对象 测试代码变得复杂难维护 能用桩函数解决的问题,不要用模拟对象

嗯,这里要特别强调一下「全局变量污染」的问题。如果你在桩函数里用了静态变量来存储预设值,一定要在 setUp 函数里重置它们。否则,上一个测试用例设置的值会影响下一个测试用例的结果。我吃过这个亏,排查了半天才发现是桩函数的状态没清零。