27、测试驱动嵌入式开发:在资源受限环境下如何应用TDD?使用模拟硬件层。
嵌入式开发,说白了就是“戴着镣铐跳舞”。
内存小、CPU慢、外设多、调试难。很多嵌入式工程师一听TDD就摇头:“我连printf都打不了几个,还写测试?”
嗯,这个想法我太熟悉了。我早年做车载ECU开发时,也这么想过。直到有一次,一个简单的GPIO电平翻转逻辑,因为时序没算准,导致整个电机控制模块在高速下乱跳。那晚我在示波器前蹲了四个小时,最后发现是代码里一个if条件写反了。
从那以后,我下定决心:再难,也要把测试做起来。
嵌入式TDD的三大痛点
先别急着写代码。我们得搞清楚,嵌入式环境到底难在哪。
- 硬件依赖:你的代码跑在真实芯片上,依赖GPIO、UART、SPI、定时器。这些外设没法在PC上直接跑。
- 资源受限:Flash可能只有64KB,RAM只有8KB。你没法塞一个完整的测试框架进去。
- 调试困难:没有标准输出,没有文件系统,断点调试有时还会影响时序。
这三个问题,说白了就是:代码和硬件绑得太死。
那怎么办?
我的答案是:模拟硬件层。
什么是模拟硬件层?
模拟硬件层,就是给底层硬件写一个“替身”。
这个替身跑在PC上,行为跟真实硬件一样,但你可以控制它、观察它、甚至让它出错。
举个例子。你有一个LED闪烁的模块,它调用了一个硬件函数 HAL_GPIO_TogglePin()。在真实硬件上,这个函数会操作寄存器。但在测试环境里,我们用一个模拟函数代替它——这个模拟函数只记录“我被调用了”,然后翻转一个软件变量。
这样一来,你的业务逻辑就可以在PC上完整测试了。
核心思想:把硬件依赖抽象成接口,测试时注入模拟实现。
架构设计:分层与依赖注入
我个人习惯把嵌入式代码分成三层:
- 硬件抽象层(HAL):直接操作寄存器的代码。这部分不测试,或者用硬件在环测试。
- 驱动层:封装HAL,提供更友好的接口。比如“初始化LED”、“设置PWM占空比”。
- 应用层:业务逻辑。比如“按键按下三次后点亮LED”。
TDD主要针对应用层和驱动层。HAL层我们通过模拟来替代。
下面这张图展示了整个架构:
实战:用模拟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