测试固件架构设计:分层模型、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),三个月后没人能看懂那三个数字是什么意思。枚举自带可读性,还能让编译器帮你检查参数合法性。

测试调度器核心逻辑:别让测试用例「打架」

测试调度器是固件的大脑。它决定什么时候跑哪个测试,跑完怎么处理结果,出错了怎么恢复。

我设计调度器时,核心就三个问题:

  1. 谁先跑? —— 优先级与依赖关系
  2. 跑多久? —— 超时与看门狗管理
  3. 跑砸了怎么办? —— 错误处理与恢复策略

调度器的状态机其实不复杂,我画了一张图帮你理解:

测试调度器核心状态机 IDLE (空闲) READY (就绪) RUNNING (运行) COMPLETE (完成) ERROR (错误) TIMEOUT (超时) 有测试项入队 调度器选中 测试通过 断言失败 超时触发 继续下一项 记录日志后复位 喂狗后重试 调度器设计要点: 1. 每个测试项必须有超时保护,防止死锁拖垮整个测试流程。 2. 错误状态不直接回IDLE,先执行恢复操作(如复位外设、喂狗)。 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层把硬件差异挡在下面,调度器把测试逻辑串起来。你只要把这两层设计好,后面加一百个测试项都不慌。