2. 硬件抽象层(HAL)设计:HAL设计原则、MCU外设驱动封装与接口规范

硬件抽象层,说白了就是给上层软件一个“统一遥控器”。不管底下是STM32还是GD32,上层调用的接口都一样。我刚开始做嵌入式那会儿,项目换了个MCU型号,底层驱动几乎重写了一遍,那叫一个痛苦。后来才明白,HAL层就是用来解决这个问题的。

2.1 HAL设计三大原则

我个人习惯把HAL设计原则归纳为三条,记牢了,设计时就不容易跑偏。

原则 核心思想 避坑点(我踩过的)
接口统一性 同一类外设(如GPIO),无论哪个MCU,接口函数名、参数顺序、返回值类型保持一致 我曾经把参数顺序搞反了,上层代码跟着全改了一遍,教训深刻
最小依赖 HAL层只依赖MCU寄存器操作,不依赖RTOS、文件系统等上层组件 有次HAL里调了malloc,结果在裸机环境下直接崩了
可移植性 更换MCU时,只替换HAL层源文件,上层业务代码一行不改 这是检验HAL设计好不好的金标准

核心观点:HAL层是“隔离墙”,不是“功能库”。它只做一件事——把MCU寄存器的差异封装掉,让上层代码感觉不到MCU的存在。

2.2 MCU外设驱动封装实战

我们一个一个来看。每个外设我都会给出接口规范,以及我在项目中实际用过的封装思路。

2.2.1 GPIO封装

GPIO是最基础的外设。封装时要注意:不要直接把寄存器地址暴露给上层。

/* hal_gpio.h - 硬件抽象层GPIO接口 */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include "hal_common.h"  /* 包含基础类型定义 */

/* GPIO引脚方向 */
typedef enum {
    HAL_GPIO_DIR_INPUT  = 0,
    HAL_GPIO_DIR_OUTPUT = 1
} hal_gpio_dir_t;

/* GPIO电平状态 */
typedef enum {
    HAL_GPIO_LOW  = 0,
    HAL_GPIO_HIGH = 1
} hal_gpio_level_t;

/* GPIO配置结构体 - 上层只需关心这几个参数 */
typedef struct {
    uint8_t         port;       /* 端口号,如0表示PORTA */
    uint8_t         pin;        /* 引脚号,0~15 */
    hal_gpio_dir_t  direction;  /* 输入/输出 */
    uint8_t         pull_up;    /* 是否上拉:0=无,1=上拉 */
} hal_gpio_cfg_t;

/* 接口函数 */
void hal_gpio_init(hal_gpio_cfg_t *cfg);
void hal_gpio_set_level(uint8_t port, uint8_t pin, hal_gpio_level_t level);
hal_gpio_level_t hal_gpio_get_level(uint8_t port, uint8_t pin);
void hal_gpio_toggle(uint8_t port, uint8_t pin);

#endif /* HAL_GPIO_H */

我的习惯:GPIO的port和pin用uint8_t就够了,别用int。嵌入式里能省则省,你想想看,一个int是4字节,uint8_t才1字节,结构体里省下来的空间,积少成多。

2.2.2 UART封装

UART封装的关键在于“收发分离”和“超时处理”。我曾经在一个项目里,UART接收没有超时机制,结果对方设备掉线了,程序一直卡在等待接收的死循环里。

/* hal_uart.h */
#ifndef HAL_UART_H
#define HAL_UART_H

#include "hal_common.h"

/* UART配置 */
typedef struct {
    uint8_t      uart_id;      /* UART编号:0=UART1, 1=UART2 ... */
    uint32_t     baudrate;     /* 波特率 */
    uint8_t      data_bits;    /* 数据位:5/6/7/8 */
    uint8_t      stop_bits;    /* 停止位:1/2 */
    uint8_t      parity;       /* 校验:0=无, 1=奇校验, 2=偶校验 */
} hal_uart_cfg_t;

/* 接口函数 */
void    hal_uart_init(hal_uart_cfg_t *cfg);
int32_t hal_uart_send(uint8_t uart_id, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_uart_recv(uint8_t uart_id, uint8_t *buf, uint32_t max_len, uint32_t timeout_ms);
void    hal_uart_irq_handler(uint8_t uart_id);  /* 中断处理函数,由底层调用 */

#endif /* HAL_UART_H */

注意:hal_uart_send和hal_uart_recv的返回值,我统一用int32_t。正数表示实际发送/接收的字节数,负数表示错误码(如-1=超时,-2=参数错误)。这样上层可以通过返回值判断是否成功,而不是靠全局变量。

2.2.3 I2C封装

I2C封装最麻烦的是“从机地址”和“读写方向”的组合。我见过不少新手把地址左移一位再或上读写位,结果搞混了。我的做法是:上层直接给7位地址,HAL层内部处理移位。

/* hal_i2c.h */
#ifndef HAL_I2C_H
#define HAL_I2C_H

#include "hal_common.h"

/* I2C传输标志 */
#define HAL_I2C_FLAG_START      0x01  /* 发送起始条件 */
#define HAL_I2C_FLAG_STOP       0x02  /* 发送停止条件 */
#define HAL_I2C_FLAG_RESTART    0x04  /* 发送重复起始条件 */

/* I2C配置 */
typedef struct {
    uint8_t      i2c_id;       /* I2C编号 */
    uint32_t     speed_hz;     /* 速率:100000=标准, 400000=快速 */
} hal_i2c_cfg_t;

/* 接口函数 */
void hal_i2c_init(hal_i2c_cfg_t *cfg);
int32_t hal_i2c_master_write(uint8_t i2c_id, uint8_t slave_addr_7bit,
                             const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_i2c_master_read(uint8_t i2c_id, uint8_t slave_addr_7bit,
                            uint8_t *buf, uint32_t len, uint32_t timeout_ms);
int32_t hal_i2c_master_write_read(uint8_t i2c_id, uint8_t slave_addr_7bit,
                                  const uint8_t *wdata, uint32_t wlen,
                                  uint8_t *rdata, uint32_t rlen,
                                  uint32_t timeout_ms);

#endif /* HAL_I2C_H */

关键设计:hal_i2c_master_write_read这个接口,专门应对“先写寄存器地址,再读数据”的场景。很多传感器都是这种协议,一个接口搞定,上层不用自己拼两次传输。

2.2.4 SPI封装

SPI封装要注意“全双工”特性。我习惯把发送和接收放在同一个函数里,因为SPI本来就是同时收发。

/* hal_spi.h */
#ifndef HAL_SPI_H
#define HAL_SPI_H

#include "hal_common.h"

/* SPI模式 */
typedef enum {
    HAL_SPI_MODE_0 = 0,  /* CPOL=0, CPHA=0 */
    HAL_SPI_MODE_1,      /* CPOL=0, CPHA=1 */
    HAL_SPI_MODE_2,      /* CPOL=1, CPHA=0 */
    HAL_SPI_MODE_3       /* CPOL=1, CPHA=1 */
} hal_spi_mode_t;

/* SPI配置 */
typedef struct {
    uint8_t        spi_id;       /* SPI编号 */
    hal_spi_mode_t mode;         /* 模式 */
    uint32_t       speed_hz;     /* 速率 */
    uint8_t        data_width;   /* 数据宽度:8或16 */
} hal_spi_cfg_t;

/* 接口函数 */
void hal_spi_init(hal_spi_cfg_t *cfg);
int32_t hal_spi_transfer(uint8_t spi_id, const uint8_t *tx_data,
                         uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);
/* 如果只发不收,可以调用hal_spi_transfer并传rx_data=NULL */
/* 如果只收不发,可以调用hal_spi_transfer并传tx_data=NULL */

#endif /* HAL_SPI_H */

小技巧:hal_spi_transfer里,如果tx_data为NULL,HAL层自动发送0xFF(空闲电平);如果rx_data为NULL,HAL层只发不收,忽略接收数据。这样上层代码更简洁。

2.3 HAL层接口规范

接口规范是HAL层的“宪法”。我总结了下面几条,团队里一直这么用,没出过大问题。

  1. 命名规范:所有HAL接口以hal_开头,外设类型紧随其后,如hal_gpio_init、hal_uart_send。
  2. 参数顺序:先外设ID,再数据指针,再长度,最后超时时间。比如hal_uart_send(uart_id, data, len, timeout_ms)。
  3. 返回值规范:int32_t,正数或0表示成功(具体含义因函数而异),负数表示错误码。
  4. 超时机制:所有可能阻塞的操作都必须有超时参数,单位统一用毫秒。0表示不等待(轮询一次就返回)。
  5. 中断处理:HAL层提供hal_xxx_irq_handler函数,由MCU底层的中断服务函数调用,HAL层内部处理中断逻辑。

我曾经犯过的错:一开始没统一超时单位,有的函数用毫秒,有的用系统tick,结果上层调用时还得换算,出了好几次bug。后来统一用毫秒,再也没出过这类问题。

2.4 HAL层整体架构图

下面这张图展示了HAL层在整个嵌入式软件架构中的位置,以及各外设模块之间的关系。

HAL层架构与接口关系图 应用层(业务代码) HAL接口层(统一API:hal_gpio_xxx, hal_uart_xxx ...) 外设驱动封装层(GPIO/UART/I2C/SPI具体实现) GPIO驱动 UART驱动 I2C驱动 SPI驱动 MCU寄存器层(硬件相关,不同MCU差异在此隔离) 硬件层(MCU芯片及外设电路)

2.5 实战中的避坑指南

最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路。

  • 不要过度抽象:HAL层不是万能的。有些MCU特有的功能(比如DMA的链式传输),强行抽象反而会丢失特性。我的做法是:在HAL接口里加一个void *reserved参数,留给底层扩展。
  • 中断处理要小心:HAL层的irq_handler函数里,尽量不要做复杂操作。我曾经在UART中断里调了hal_gpio_toggle,结果GPIO操作又触发了中断,死循环了。中断里只做数据搬运,复杂逻辑放到任务级处理。
  • 测试先行:每写完一个外设的HAL封装,立刻写一个测试用例。比如GPIO就写个点灯程序,UART就写个回环测试。别等到所有外设都写完了再测,那时候定位问题就难了。
  • 版本管理:HAL层的头文件(.h)要视为“公共接口”,一旦发布,尽量不要改。如果非要改,必须通知所有使用方,并更新版本号。我见过因为偷偷改了头文件,导致整个项目编译不过的惨案。

总结一下:HAL层设计的核心就是“隔离变化”。把MCU相关的寄存器操作封装在底层,上层只关心功能逻辑。做到这一点,你的代码就能轻松移植到不同MCU上,项目维护成本也会大幅降低。