4. 驱动封装基础

好,咱们进入第四章。硬件抽象层,简称 HAL。这词儿听起来挺唬人,说白了就是给上层软件一个统一的接口,把底下硬件的差异给藏起来。你想想看,今天用 STM32,明天换 GD32,后天可能又用 NXP 的芯片。要是每次换芯片都得重写应用代码,那项目就别想按时交付了。

我个人习惯,在设计 HAL 时,脑子里始终绷着一根弦:接口要稳定,实现要灵活。上层应用只跟 HAL 接口打交道,它不关心你用的是寄存器操作还是标准库,更不关心你接的是哪个引脚。

4.1 HAL 设计模式:分层与回调

HAL 的设计,我一般会采用两种核心模式:分层结构和回调机制。

分层结构很好理解。从上到下,大致是:

  • 应用层:调用 HAL 接口,比如 HAL_GPIO_WritePin()。
  • HAL 接口层:定义统一的函数原型,不涉及具体寄存器。
  • HAL 实现层:针对特定 MCU,实现接口函数。这里会操作寄存器或调用 SDK。
  • 硬件层:就是芯片本身了。

回调机制呢,主要用于异步处理。比如 UART 接收中断来了,HAL 层不会直接处理业务逻辑,而是调用一个注册好的回调函数。这样,应用层就能灵活地决定收到数据后干什么。

核心原则:接口层只声明“能做什么”,实现层才决定“怎么做”。

我曾经在一个项目里,因为赶进度,直接在中断服务函数里写了业务逻辑。结果后来要移植到另一款芯片,中断向量表不一样,回调函数又没抽出来,改得我头皮发麻。从那以后,我坚持所有中断处理都走 HAL 回调。

4.2 GPIO 抽象层实现

GPIO 是最基础的外设。抽象层要解决什么问题?说白了,就是让应用层不用关心引脚号、端口号、时钟使能这些细节。

我通常会定义一个结构体来描述 GPIO 的配置:

/* hal_gpio.h */
typedef struct {
    uint32_t    Port;       /* GPIO 端口,如 GPIOA, GPIOB */
    uint16_t    Pin;        /* 引脚号,如 GPIO_PIN_0 */
    uint8_t     Mode;       /* 输入、输出、复用等 */
    uint8_t     Pull;       /* 上拉、下拉、浮空 */
    uint8_t     Speed;      /* 输出速度 */
} HAL_GPIO_Config_t;

/* 接口声明 */
void HAL_GPIO_Init(HAL_GPIO_Config_t *config);
void HAL_GPIO_WritePin(HAL_GPIO_Config_t *config, uint8_t state);
uint8_t HAL_GPIO_ReadPin(HAL_GPIO_Config_t *config);

你看,应用层只需要填充这个结构体,然后调用 HAL_GPIO_Init() 就行。至于底层是操作 ODR 寄存器还是 BSRR 寄存器,那是实现层的事。

我的习惯:在实现层里,我会用一个静态数组来映射“虚拟引脚”到“物理引脚”。这样,如果硬件改版,只需要修改这个映射表,应用代码一行都不用动。

4.3 UART 抽象层实现

UART 的抽象,难点在于数据收发模式。是轮询?是中断?还是 DMA?接口设计上,我倾向于提供统一的发送/接收函数,而把模式选择放在初始化参数里。

/* hal_uart.h */
typedef struct {
    uint32_t        BaudRate;
    uint8_t         WordLength;
    uint8_t         StopBits;
    uint8_t         Parity;
    uint8_t         Mode;       /* 轮询/中断/DMA */
    void            (*RxCallback)(uint8_t data); /* 接收回调 */
} HAL_UART_Config_t;

void HAL_UART_Init(HAL_UART_Config_t *config);
void HAL_UART_Transmit(uint8_t *pData, uint16_t Size);
void HAL_UART_Receive_IT(HAL_UART_Config_t *config); /* 启动中断接收 */

这里有个关键点:接收回调函数。当一帧数据接收完成,HAL 实现层会调用这个回调。应用层只需要注册自己的处理函数即可。这样,UART 的底层中断处理逻辑就被完全封装起来了。

注意:回调函数是在中断上下文中执行的。不要在回调里做耗时操作,比如打印日志或申请内存。正确的做法是,用消息队列把数据扔给任务处理。

我记得有一次,同事在 UART 回调里直接调用了一个延时函数,结果整个系统的中断响应都变慢了,按键反应迟钝。查了半天才发现是这里的问题。

4.4 I2C 与 SPI 抽象层实现

I2C 和 SPI 都是总线协议,它们的抽象思路类似:面向设备。每个挂在总线上的设备,都对应一个设备句柄。

以 I2C 为例:

/* hal_i2c.h */
typedef struct {
    uint8_t     SlaveAddress;
    uint32_t    Speed;
    void        (*DelayUs)(uint32_t us); /* 微秒延时函数,用于模拟时序 */
} HAL_I2C_Config_t;

int HAL_I2C_Master_Transmit(HAL_I2C_Config_t *dev, uint8_t *pData, uint16_t Size);
int HAL_I2C_Master_Receive(HAL_I2C_Config_t *dev, uint8_t *pData, uint16_t Size);

SPI 的抽象也类似,只是参数里多了 CPOL、CPHA 这些时钟极性配置。

这里我想强调一点:错误处理。I2C 总线很容易出现“总线锁死”的情况(SCL 被拉低)。所以,我的 HAL 实现里,每次操作都会加一个超时机制。如果超时,就复位总线状态,并返回错误码。

外设 核心抽象对象 关键配置参数 常见错误处理
GPIO 引脚 端口、引脚号、模式、上下拉 无(直接操作寄存器)
UART 串口实例 波特率、数据位、停止位、模式 超时、溢出、帧错误
I2C 从设备 从机地址、速率、延时函数 总线锁死、NACK、超时
SPI 从设备 CPOL、CPHA、数据位宽、速率 模式错误、超时

4.5 跨平台移植策略

这是 HAL 设计的终极目标。怎么让代码在不同 MCU 之间平滑迁移?我的策略是:隔离变化。

具体做法是,把 HAL 实现层再细分成两部分:

  • 通用实现:与芯片无关的逻辑,比如超时计算、环形缓冲区管理。
  • 芯片适配层:直接操作寄存器或调用芯片 SDK 的部分。

移植的时候,你只需要重写“芯片适配层”的代码。通用实现部分,一个字都不用改。

举个例子,UART 的发送函数:

/* 通用实现:hal_uart_common.c */
void HAL_UART_Transmit(uint8_t *pData, uint16_t Size) {
    /* 这里可以加一些通用逻辑,比如加锁、超时计算 */
    HAL_UART_Transmit_Impl(pData, Size); /* 调用芯片适配层 */
}

/* 芯片适配层:hal_uart_stm32.c */
void HAL_UART_Transmit_Impl(uint8_t *pData, uint16_t Size) {
    /* 操作 STM32 的 USART 寄存器 */
    for(uint16_t i = 0; i < Size; i++) {
        while(!(USART1->SR & USART_SR_TXE));
        USART1->DR = pData[i];
    }
}

你看,移植到 GD32 时,我只需要新建一个 hal_uart_gd32.c,重新实现 HAL_UART_Transmit_Impl() 就行。上层应用和通用实现代码,完全不用动。

移植清单:每次移植新平台,你只需要关注:1. 时钟配置;2. 引脚复用;3. 中断向量表;4. 寄存器操作。其他都是体力活。

嗯,这里还要提一个坑。我曾经移植 I2C 驱动时,发现新芯片的 I2C 外设不支持“重复起始条件”。结果我的通用实现里用了这个特性,导致通信失败。所以,通用实现部分,也要考虑不同芯片的特性差异。必要时,通过条件编译或函数指针来适配。

HAL 跨平台移植架构图 应用层 (Application) HAL 接口层 (API) HAL 通用实现层 (Common) 芯片适配层 (MCU Specific) 硬件层 (Hardware) 移植时只需重写 芯片适配层代码 接口与通用实现 保持不变

好了,这一章的内容就这些。HAL 设计的关键,在于接口的稳定性和实现的隔离性。把这两点做好了,你的代码就能在各种芯片之间游刃有余。