测试固件架构设计:分层模型、HAL与调度器
测试固件怎么写,才能既跑得快又容易维护?
我早年吃过不少亏。那时候写测试代码,恨不得把所有逻辑都塞进一个 main() 里。结果呢?换个MCU平台,重写一遍。加个测试项,改得心惊胆战。后来我慢慢摸索出一套分层思路,说白了就是「各司其职,互不干扰」。
这一章,我就把这套方法拆开来讲。
固件分层模型:把复杂问题切三刀
我个人习惯把测试固件分成三层。你想想看,一个测试固件要管三件事:跟硬件打交道、执行测试逻辑、跟外部通信。这三件事的性质完全不同,硬塞在一起就是灾难。
| 层级 | 职责 | 依赖 | 可移植性 |
|---|---|---|---|
| 应用层 | 测试用例、调度策略、结果判据 | 调用HAL接口 | 完全可移植 |
| 硬件抽象层(HAL) | 封装寄存器操作、外设驱动 | 依赖MCU型号 | 换MCU只需重写此层 |
| 硬件层 | MCU外设、GPIO、ADC、定时器等 | 物理芯片 | 不可移植 |
应用层只认HAL的接口函数,不关心底层是STM32还是GD32。HAL层把硬件差异全部挡在下面。这样做的好处很明显——我在项目里换过三次主控芯片,每次只改HAL层,测试用例一行代码没动。
核心原则:上层绝不直接操作寄存器,下层绝不包含业务逻辑。这条红线一旦跨过,代码就会变成一锅粥。
硬件抽象层(HAL)设计:接口要稳,实现要藏
HAL层设计得好不好,直接决定你后面加测试项时是「加一道菜」还是「拆一次厨房」。
我建议把HAL接口分成三类:
- 初始化类:
HAL_Init()、HAL_DeInit(),负责时钟、GPIO模式、外设使能。 - 控制类:
HAL_GPIO_Write()、HAL_ADC_Read()、HAL_UART_Send(),封装具体操作。 - 状态类:
HAL_GetStatus()、HAL_IsBusy(),返回硬件当前状态。
举个例子,一个简单的GPIO控制接口,我通常会这样设计:
/* hal_gpio.h - 硬件抽象层GPIO接口 */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H
#include <stdint.h>
#include <stdbool.h>
/* 端口枚举,与应用层解耦 */
typedef enum {
HAL_GPIO_PORT_A,
HAL_GPIO_PORT_B,
HAL_GPIO_PORT_C,
HAL_GPIO_PORT_MAX
} HAL_GPIO_Port_t;
/* 引脚号,与应用层无关 */
typedef enum {
HAL_GPIO_PIN_0,
HAL_GPIO_PIN_1,
/* ... 省略中间引脚 ... */
HAL_GPIO_PIN_15
} HAL_GPIO_Pin_t;
/* 模式枚举 */
typedef enum {
HAL_GPIO_MODE_OUTPUT,
HAL_GPIO_MODE_INPUT,
HAL_GPIO_MODE_AF, /* 复用功能 */
HAL_GPIO_MODE_ANALOG
} HAL_GPIO_Mode_t;
/* 初始化单个引脚 */
void HAL_GPIO_Init(HAL_GPIO_Port_t port,
HAL_GPIO_Pin_t pin,
HAL_GPIO_Mode_t mode);
/* 写引脚电平 */
void HAL_GPIO_Write(HAL_GPIO_Port_t port,
HAL_GPIO_Pin_t pin,
bool level);
/* 读引脚电平 */
bool HAL_GPIO_Read(HAL_GPIO_Port_t port,
HAL_GPIO_Pin_t pin);
#endif /* HAL_GPIO_H */
你看,应用层调用时根本不知道底层寄存器地址。它只需要说「把端口A的引脚5拉高」,HAL层去处理具体怎么操作。
我的经验:HAL接口的参数类型尽量用枚举和bool,别用魔法数字。我曾经在一个项目里看到 HAL_SetMode(0, 1, 3),三个月后没人能看懂那三个数字是什么意思。枚举自带可读性,还能让编译器帮你检查参数合法性。
测试调度器核心逻辑:别让测试用例「打架」
测试调度器是固件的大脑。它决定什么时候跑哪个测试,跑完怎么处理结果,出错了怎么恢复。
我设计调度器时,核心就三个问题:
- 谁先跑? —— 优先级与依赖关系
- 跑多久? —— 超时与看门狗管理
- 跑砸了怎么办? —— 错误处理与恢复策略
调度器的状态机其实不复杂,我画了一张图帮你理解:
调度器的核心代码,我习惯用一个简单的状态机来实现:
/* 调度器主循环 - 简化版 */
void TestScheduler_Run(void)
{
TestItem_t *current = NULL;
uint32_t tick_start;
int32_t ret;
while (1)
{
/* 1. 从就绪队列取最高优先级测试项 */
current = Scheduler_GetNextItem();
if (current == NULL)
{
/* 队列为空,进入低功耗空闲模式 */
HAL_EnterSleepMode();
continue;
}
/* 2. 设置超时 */
tick_start = HAL_GetTick();
current->status = TEST_STATUS_RUNNING;
/* 3. 执行测试 */
ret = current->test_func(current->param);
/* 4. 检查超时 */
if ((HAL_GetTick() - tick_start) > current->timeout_ms)
{
current->status = TEST_STATUS_TIMEOUT;
/* 喂狗,防止系统复位 */
HAL_Watchdog_Refresh();
/* 记录超时日志 */
Log_Write("Test %s TIMEOUT", current->name);
/* 执行恢复操作 */
current->recovery_func();
continue;
}
/* 5. 处理结果 */
if (ret == 0)
{
current->status = TEST_STATUS_PASS;
Log_Write("Test %s PASS", current->name);
}
else
{
current->status = TEST_STATUS_FAIL;
Log_Write("Test %s FAIL, code=%d", current->name, ret);
/* 错误恢复 */
current->recovery_func();
}
}
}
注意:千万不要在测试函数里做死循环等待!我见过有人用 while(HAL_GPIO_ReadPin() == 0); 这种写法,结果信号一直不来,整个系统卡死。正确的做法是:每次循环检查超时,超时了就强制跳出,走TIMEOUT状态。
避坑指南:我踩过的三个坑
讲完理论,分享几个真实教训。
坑一:HAL层接口设计得太「胖」。 我曾经把SPI、I2C、UART的初始化都塞进一个 HAL_Init() 里。结果某个项目只用UART,却不得不初始化所有外设,白白浪费几百微秒。后来我改成按需初始化,每个外设单独提供 HAL_XXX_Init()。
坑二:调度器里忘了喂看门狗。 测试固件跑环境应力筛选时,一个长时间测试项(比如EEPROM耐久测试)跑了几分钟,看门狗先超时复位了。测试数据全丢。从那以后,我每个测试项都强制要求实现「进度回调」,调度器在回调里喂狗。
坑三:测试用例之间共享全局变量。 两个测试用例共用一个全局标志位,前一个改了值,后一个没初始化就直接用。结果测试结果时好时坏,查了三天才发现。现在我的每个测试用例都自带独立上下文,用结构体传参,绝不共享全局变量。
总结一下:分层架构的核心是「接口稳定,实现灵活」。HAL层把硬件差异挡在下面,调度器把测试逻辑串起来。你只要把这两层设计好,后面加一百个测试项都不慌。