27、测试驱动嵌入式开发:在资源受限环境下如何应用TDD?使用模拟硬件层。

嵌入式开发,说白了就是“戴着镣铐跳舞”。

内存小、CPU慢、外设多、调试难。很多嵌入式工程师一听TDD就摇头:“我连printf都打不了几个,还写测试?”

嗯,这个想法我太熟悉了。我早年做车载ECU开发时,也这么想过。直到有一次,一个简单的GPIO电平翻转逻辑,因为时序没算准,导致整个电机控制模块在高速下乱跳。那晚我在示波器前蹲了四个小时,最后发现是代码里一个if条件写反了。

从那以后,我下定决心:再难,也要把测试做起来。

嵌入式TDD的三大痛点

先别急着写代码。我们得搞清楚,嵌入式环境到底难在哪。

  • 硬件依赖:你的代码跑在真实芯片上,依赖GPIO、UART、SPI、定时器。这些外设没法在PC上直接跑。
  • 资源受限:Flash可能只有64KB,RAM只有8KB。你没法塞一个完整的测试框架进去。
  • 调试困难:没有标准输出,没有文件系统,断点调试有时还会影响时序。

这三个问题,说白了就是:代码和硬件绑得太死

那怎么办?

我的答案是:模拟硬件层

什么是模拟硬件层?

模拟硬件层,就是给底层硬件写一个“替身”。

这个替身跑在PC上,行为跟真实硬件一样,但你可以控制它、观察它、甚至让它出错。

举个例子。你有一个LED闪烁的模块,它调用了一个硬件函数 HAL_GPIO_TogglePin()。在真实硬件上,这个函数会操作寄存器。但在测试环境里,我们用一个模拟函数代替它——这个模拟函数只记录“我被调用了”,然后翻转一个软件变量。

这样一来,你的业务逻辑就可以在PC上完整测试了。

核心思想:把硬件依赖抽象成接口,测试时注入模拟实现。

架构设计:分层与依赖注入

我个人习惯把嵌入式代码分成三层:

  1. 硬件抽象层(HAL):直接操作寄存器的代码。这部分不测试,或者用硬件在环测试。
  2. 驱动层:封装HAL,提供更友好的接口。比如“初始化LED”、“设置PWM占空比”。
  3. 应用层:业务逻辑。比如“按键按下三次后点亮LED”。

TDD主要针对应用层驱动层。HAL层我们通过模拟来替代。

下面这张图展示了整个架构:

嵌入式TDD分层架构与模拟硬件层 应用层(业务逻辑) 按键检测、状态机、协议解析、控制算法 ← 这是TDD的主战场 → 驱动层(硬件封装) LED驱动、按键扫描、PWM控制、UART收发 ← 可测试,但需要模拟HAL → 硬件抽象层(HAL) 寄存器操作、中断向量 ← 真实硬件,不测试 → 模拟硬件层(Mock HAL) 模拟GPIO、模拟定时器、模拟UART ← 测试时注入,可控制可断言 → 编译时切换

实战:用模拟GPIO测试LED闪烁逻辑

光说不练假把式。我们直接上代码。

假设你有一个LED模块,它的头文件长这样:

// led.h
typedef struct {
    void (*toggle)(void);
} LedHardware;

void Led_Init(LedHardware *hw);
void Led_Blink(LedHardware *hw, int times);

注意看,这里没有直接调用HAL函数。而是通过一个函数指针 toggle 来操作硬件。这就是依赖注入。

真实硬件上的实现可能是:

// led_hw_real.c
#include "stm32f1xx_hal.h"

static void real_toggle(void) {
    HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
}

LedHardware RealLed = { .toggle = real_toggle };

但在测试中,我们用模拟实现:

// led_hw_mock.c
#include <stdbool.h>

static int toggle_count = 0;
static bool last_state = false;

static void mock_toggle(void) {
    toggle_count++;
    last_state = !last_state;
}

LedHardware MockLed = { .toggle = mock_toggle };

// 测试辅助函数
void MockLed_Reset(void) {
    toggle_count = 0;
    last_state = false;
}

int MockLed_GetToggleCount(void) {
    return toggle_count;
}

bool MockLed_GetState(void) {
    return last_state;
}

现在,我们可以写测试了:

// test_led.c
#include "unity.h"
#include "led.h"
#include "led_hw_mock.c"  // 注意:测试时编译模拟文件

void setUp(void) {
    MockLed_Reset();
}

void test_Led_Blink_should_toggle_three_times(void) {
    Led_Blink(&MockLed, 3);
    TEST_ASSERT_EQUAL_INT(3, MockLed_GetToggleCount());
}

void test_Led_Blink_should_end_in_off_state(void) {
    Led_Blink(&MockLed, 2);
    TEST_ASSERT_FALSE(MockLed_GetState());
}

小技巧:我习惯把模拟实现放在一个单独的 .c 文件里,测试时直接编译它,生产代码编译真实HAL。通过构建系统(比如CMake的target)来切换,干净利落。

避坑指南:模拟硬件层的常见陷阱

我曾经在一个项目中,模拟UART收发做得太“完美”,结果真实硬件上死活收不到数据。排查了半天,发现模拟实现里没有考虑波特率误差和中断延迟。

这里有几个坑,你一定要注意:

  • 不要模拟时序:模拟硬件层只模拟“行为”,不模拟“时间”。真实硬件上的微秒级延迟,在PC上跑不出来。测试时只验证逻辑正确性,时序问题交给硬件在环测试。
  • 小心全局状态:模拟实现里经常有全局变量(比如toggle_count)。每个测试用例开始前,一定要重置它们。否则测试之间会互相影响。
  • 接口要足够细:如果你的模拟函数只返回“成功/失败”,那测试能覆盖的场景就很有限。我建议模拟接口尽量暴露内部状态,比如“寄存器值”、“中断标志位”、“缓冲区内容”。
  • 不要模拟整个芯片:你不需要模拟一个完整的STM32。只需要模拟你当前模块用到的几个外设接口。够用就好。

特别注意:模拟硬件层不是万能的。它无法发现硬件本身的bug(比如芯片勘误手册里写的那种)。但它能帮你把应用逻辑的bug扼杀在PC上。等代码烧到芯片里,你只需要关心硬件相关的问题。

构建系统与测试组织

嵌入式项目的构建系统,我推荐用CMake。它支持多目标编译,可以轻松切换测试环境和生产环境。

一个典型的项目结构长这样:

project/
├── src/
│   ├── app/          # 应用层代码
│   ├── driver/       # 驱动层代码
│   ├── hal/          # 真实HAL实现
│   └── main.c
├── test/
│   ├── mock/         # 模拟硬件层
│   ├── unity/        # 测试框架
│   └── test_led.c    # 测试用例
├── CMakeLists.txt
└── build/

CMakeLists.txt里,关键配置是:

# 生产目标
add_executable(firmware.elf
    src/app/led.c
    src/driver/led_driver.c
    src/hal/hal_gpio_real.c
    src/main.c
)

# 测试目标
add_executable(test_led
    src/app/led.c
    src/driver/led_driver.c
    test/mock/hal_gpio_mock.c
    test/test_led.c
    test/unity/unity.c
)

你看,测试目标编译的是 hal_gpio_mock.c,而不是 hal_gpio_real.c。就这么简单。

总结

嵌入式TDD,说白了就是“把硬件隔离出去,让逻辑先跑起来”。

模拟硬件层是桥梁。它让你在PC上就能验证90%的代码逻辑。剩下的10%,比如中断响应时间、外设初始化顺序,再拿到真实硬件上去调。

我个人经验是:一个模块如果不能在PC上测试,那它的设计大概率有问题。试着把硬件依赖抽象出来,你会发现代码结构更清晰,调试时间也大幅缩短。

嗯,今天就聊到这。下次你写嵌入式代码时,不妨先问问自己:这个函数,我能用模拟硬件层测试吗?


公众号:蓝海资料掘金营,微信deep3321