4、固件分层架构:应用层、驱动层、硬件抽象层(HAL)的职责划分与接口设计

做医疗贴片固件这些年,我见过太多「一锅粥」式的代码。所有功能揉在一起,GPIO操作、算法逻辑、通信协议全塞在一个文件里。嗯,这种代码调试起来,简直像在拆炸弹——你永远不知道动哪根线会炸。

分层架构,说白了就是把代码按职责切分成几个独立的「房间」。每个房间只管自己的事,通过固定的「门」(接口)跟外界打交道。这样做的好处,我总结下来有三点:

  • 可移植性:换一颗MCU,只需要改最底层的东西
  • 可测试性:每一层都能单独测,不用等硬件就绪
  • 可维护性:新人接手,看接口文档就能干活,不用翻遍所有代码

我个人习惯把医疗贴片的固件分成三层:应用层、驱动层、硬件抽象层(HAL)。下面我逐一说说每层的职责和接口设计要点。

4.1 应用层:只管「做什么」

应用层是离业务最近的一层。它不关心底层用的是哪颗传感器、哪个型号的ADC,它只关心「我要采集心电数据」「我要发送报警信号」。

举个例子,一个心电监测贴片的应用层代码,大概长这样:

// 应用层代码示例
void ecg_monitoring_task(void)
{
    ecg_sample_t sample;
    
    // 调用驱动层接口,获取一次心电采样
    if (ECG_Driver_GetSample(&sample) == STATUS_OK)
    {
        // 调用算法模块处理数据
        if (ECG_Algorithm_DetectArrhythmia(&sample))
        {
            // 触发报警
            Alarm_Trigger(ALARM_ARRHYTHMIA);
        }
    }
}

你看,应用层根本不知道ECG_Driver_GetSample内部是怎么操作SPI或I2C的。它只管调用,拿到结果,然后做业务判断。

核心原则:应用层不应该出现任何寄存器操作、GPIO置位、中断向量配置。这些是底层的事。

我在项目中遇到过一件事:有个同事把SPI的片选控制直接写在了应用层的算法函数里。结果换了一颗MCU后,片选引脚变了,他得把所有算法函数翻一遍。嗯,这就是典型的职责混乱。

4.2 驱动层:封装硬件操作细节

驱动层是真正跟硬件寄存器打交道的部分。它负责把「读ADC」「写DAC」「配置定时器」这些操作封装成函数接口。

驱动层的接口设计,我建议遵循一个原则:每个函数只做一件事,且参数尽量简单

比如一个SPI Flash的驱动接口:

// 驱动层接口示例
int SPI_Flash_Read(uint32_t address, uint8_t *buffer, uint32_t length);
int SPI_Flash_Write(uint32_t address, const uint8_t *data, uint32_t length);
int SPI_Flash_EraseSector(uint32_t sector_address);

这些接口直接操作SPI寄存器、控制片选信号、处理时序。但上层调用者不需要知道这些细节。

我的经验:驱动层接口最好返回标准错误码(如STATUS_OK、STATUS_TIMEOUT、STATUS_ERROR),而不是直接返回硬件状态寄存器的值。这样上层处理错误时不用关心底层细节。

曾经有个项目,驱动层直接返回了SPI状态寄存器的原始值。应用层拿到后,得自己去解析哪一位是忙标志、哪一位是错误标志。后来我重构时,统一封装成了枚举类型,代码可读性提升了一大截。

4.3 硬件抽象层(HAL):隔离平台差异

HAL层是分层架构中最容易被忽视、但也是最有价值的一层。它的职责是:把MCU厂商提供的底层操作,封装成统一的接口

你想想看,不同厂商的MCU,GPIO操作函数名都不一样:

  • STM32:HAL_GPIO_WritePin()
  • NXP:GPIO_PinWrite()
  • TI:GPIO_write()

如果没有HAL层,你的驱动层就得针对每种MCU写一套。有了HAL层,驱动层只调用HAL接口,换MCU时只需要重写HAL层。

我设计的HAL接口通常长这样:

// HAL层接口示例
void HAL_GPIO_SetPin(GPIO_Pin_t pin, GPIO_Level_t level);
GPIO_Level_t HAL_GPIO_GetPin(GPIO_Pin_t pin);
void HAL_Delay_ms(uint32_t ms);
int HAL_SPI_Transfer(SPI_Handle_t *handle, uint8_t *tx, uint8_t *rx, uint32_t len);

注意:HAL层不要包含任何业务逻辑。它只是对MCU底层操作的「翻译」。我曾经见过有人在HAL层里加了个「如果温度超过50度就关断输出」的逻辑——这完全不应该出现在这里。

4.4 接口设计的关键要点

三层之间的接口设计,我总结了几个避坑指南:

  1. 接口参数用抽象类型:比如用GPIO_Pin_t而不是uint16_t,用SPI_Handle_t而不是void *。这样代码自文档化,别人一看就懂。
  2. 接口函数名统一风格:我个人习惯用「模块_动作_对象」的命名方式,比如ECG_Driver_InitFlash_Driver_Read
  3. 接口不要暴露内部状态:比如驱动层内部有个缓冲区,不要直接返回缓冲区指针给应用层。应用层不应该知道底层缓冲区的存在。
  4. 接口要有超时机制:医疗设备对可靠性要求高,所有可能阻塞的接口都应该有超时参数,防止死锁。

一个实用的接口设计示例

// 驱动层接口 - 带超时的ADC读取
typedef struct {
    uint32_t channel;
    uint32_t timeout_ms;
} ADC_ReadConfig_t;

int ADC_Driver_Read(ADC_ReadConfig_t *config, uint16_t *value);

这样设计,应用层可以灵活配置超时时间,驱动层内部实现时也清楚什么时候该返回超时错误。

4.5 实际项目中的分层案例

我记得一个真实的医疗贴片项目,用的是STM32L0系列MCU。分层结构是这样的:

层级 文件示例 职责
应用层 app_ecg.c, app_alarm.c, app_ble.c 心电采集逻辑、报警策略、蓝牙通信协议
驱动层 drv_ads1292.c, drv_nrf52832.c, drv_bq25180.c 封装外部芯片的寄存器操作
HAL层 hal_gpio.c, hal_spi.c, hal_timer.c 封装STM32L0的HAL库,提供统一接口

后来项目要换到NXP的LPC55系列,我们只重写了HAL层的几个文件,驱动层和应用层几乎没动。整个移植工作,两个人花了一周就搞定了。这就是分层架构的价值。

小技巧:在HAL层和驱动层之间,我习惯加一个「接口检查」的编译开关。比如:

#ifdef HAL_DEBUG
    // 检查传入的引脚号是否合法
    if (pin >= GPIO_PIN_MAX) return STATUS_INVALID_PARAM;
#endif

调试阶段打开,发布时关掉。既保证了开发效率,又不影响最终性能。

最后说一句:分层不是目的,可维护才是。别为了分层而分层,搞得接口满天飞。三层够用就别搞五层,简单才是王道。