3、硬件抽象层(HAL)设计:HAL的作用、典型接口定义、GPIO/UART/I2C/SPI驱动抽象

好,咱们进入第三章。这一章我打算聊聊硬件抽象层,也就是HAL。

说实话,HAL这个词在嵌入式圈子里被用得太滥了。很多人觉得,不就是把寄存器操作包一层函数吗?嗯,没那么简单。我见过太多项目,号称有HAL,结果换了个MCU型号,代码改得跟重写似的。那叫封装,不叫抽象。

3.1 HAL到底解决了什么问题?

先想一个问题:你的应用代码,到底该不该知道它跑在STM32上,还是GD32上,或者是某款国产RISC-V芯片上?

答案显然是不该。应用层只关心“我要点亮一个LED”、“我要通过UART发一串数据”。至于这个LED是挂在PA5还是PB12,UART是USART1还是LPUART1,那是底层的事。

HAL的作用,就是在应用和硬件之间,砌一堵墙。这堵墙让应用层只跟接口打交道,不跟寄存器打交道。

HAL的核心价值:

  • 隔离变化:硬件换了,只改HAL实现,应用层纹丝不动。
  • 提高可测试性:可以写一个Mock的HAL,在PC上跑单元测试。
  • 团队并行开发:应用工程师和驱动工程师,各自盯着接口干活,互不耽误。

我个人习惯,在项目启动的第一周,先把HAL接口定死。哪怕底层驱动还没写一行代码,应用层也能基于接口定义,先把逻辑框架搭起来。这叫“接口先行”。

3.2 典型接口应该怎么定义?

接口定义这件事,说白了就是定协议。你给应用层提供什么函数,参数是什么,返回值是什么,都得想清楚。

我一般会把接口分成三类:初始化类、控制类、数据收发类。咱们拿几个最常用的外设举例。

3.2.1 GPIO抽象

GPIO是最简单的,但也是最容易写死的。很多人喜欢直接写 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, SET)。这好吗?这不好。因为应用层还得知道GPIOA和GPIO_PIN_5。

我建议的做法,是用一个枚举或者宏,把引脚抽象成一个逻辑编号。

/* hal_gpio.h */
typedef enum {
    HAL_GPIO_LED_STATUS,
    HAL_GPIO_BUTTON_USER,
    HAL_GPIO_RELAY_1,
    HAL_GPIO_RELAY_2,
    /* ... */
    HAL_GPIO_NUM_MAX
} hal_gpio_id_t;

typedef enum {
    HAL_GPIO_LEVEL_LOW  = 0,
    HAL_GPIO_LEVEL_HIGH = 1
} hal_gpio_level_t;

/* 初始化 */
void hal_gpio_init(hal_gpio_id_t id);

/* 写电平 */
void hal_gpio_write(hal_gpio_id_t id, hal_gpio_level_t level);

/* 读电平 */
hal_gpio_level_t hal_gpio_read(hal_gpio_id_t id);

/* 翻转 */
void hal_gpio_toggle(hal_gpio_id_t id);

你看,应用层调用的时候,只需要说 hal_gpio_write(HAL_GPIO_LED_STATUS, HAL_GPIO_LEVEL_HIGH)。至于这个LED到底对应哪个端口哪个引脚,那是 hal_gpio.c 里的一张映射表说了算。

我的经验:映射表最好用数组实现,不要用switch-case。数组查表是O(1),switch-case是O(n)。虽然GPIO数量不多,但养成好习惯很重要。

3.2.2 UART抽象

UART的抽象,重点在于数据收发模型。是轮询?是中断?还是DMA?接口设计上,我倾向于把底层机制藏起来,对外只提供同步或异步的读写接口。

/* hal_uart.h */
typedef enum {
    HAL_UART_1,
    HAL_UART_2,
    /* ... */
    HAL_UART_NUM_MAX
} hal_uart_id_t;

typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    /* 5, 6, 7, 8 */
    uint8_t  stop_bits;    /* 1, 2 */
    uint8_t  parity;       /* 0: none, 1: odd, 2: even */
} hal_uart_config_t;

/* 初始化 */
int hal_uart_init(hal_uart_id_t id, hal_uart_config_t *cfg);

/* 发送(阻塞) */
int hal_uart_send(hal_uart_id_t id, const uint8_t *data, uint32_t len, uint32_t timeout_ms);

/* 接收(阻塞) */
int hal_uart_recv(hal_uart_id_t id, uint8_t *buf, uint32_t len, uint32_t timeout_ms);

/* 异步发送(非阻塞,需要注册回调) */
int hal_uart_send_async(hal_uart_id_t id, const uint8_t *data, uint32_t len,
                        void (*cb)(void *ctx, int status), void *ctx);

这里我故意把 timeout_ms 暴露出来。为什么?因为我在项目中遇到过,某个传感器模块在异常情况下会一直不回复数据。如果没有超时机制,整个系统就卡死了。嗯,那次排查了很久,后来所有阻塞接口我都加了超时参数。

3.2.3 I2C抽象

I2C比UART复杂一点,因为它有设备地址、子地址、读写方向这些概念。接口设计上,我习惯把“发起一次I2C传输”作为最小操作单元。

/* hal_i2c.h */
typedef enum {
    HAL_I2C_1,
    HAL_I2C_2,
    /* ... */
    HAL_I2C_NUM_MAX
} hal_i2c_id_t;

typedef struct {
    uint32_t speed_hz;    /* 100000, 400000, 1000000 */
    uint8_t  addr_bits;   /* 7 or 10 */
} hal_i2c_config_t;

/* 初始化 */
int hal_i2c_init(hal_i2c_id_t id, hal_i2c_config_t *cfg);

/* 写寄存器 */
int hal_i2c_write_reg(hal_i2c_id_t id, uint8_t dev_addr,
                      uint16_t reg_addr, uint8_t reg_addr_len,
                      const uint8_t *data, uint32_t len);

/* 读寄存器 */
int hal_i2c_read_reg(hal_i2c_id_t id, uint8_t dev_addr,
                     uint16_t reg_addr, uint8_t reg_addr_len,
                     uint8_t *buf, uint32_t len);

/* 原始传输(用于特殊协议) */
int hal_i2c_transfer(hal_i2c_id_t id, uint8_t dev_addr,
                     hal_i2c_msg_t *msgs, uint32_t num_msgs);

注意:I2C的时序非常敏感。我曾经遇到过,某款MCU的I2C外设在高速模式下,SCL高电平时间不够,导致从设备不响应。后来在HAL层加了一个微调参数才搞定。所以,hal_i2c_config_t 里最好预留一个 uint32_t reserved 字段,给底层驱动做平台适配用。

3.2.4 SPI抽象

SPI的抽象,核心在于“全双工”。你发一个字节的同时,一定会收到一个字节。所以接口设计上,我通常把发送和接收合并到一个函数里。

/* hal_spi.h */
typedef enum {
    HAL_SPI_1,
    HAL_SPI_2,
    /* ... */
    HAL_SPI_NUM_MAX
} hal_spi_id_t;

typedef enum {
    HAL_SPI_MODE_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;

typedef struct {
    uint32_t     speed_hz;
    hal_spi_mode_t mode;
    uint8_t      data_bits;   /* 8 or 16 */
    uint8_t      cs_control;  /* 0: 软件控制CS, 1: 硬件自动CS */
} hal_spi_config_t;

/* 初始化 */
int hal_spi_init(hal_spi_id_t id, hal_spi_config_t *cfg);

/* 全双工传输 */
int hal_spi_transfer(hal_spi_id_t id, const uint8_t *tx_buf,
                     uint8_t *rx_buf, uint32_t len);

/* 只发送(接收数据丢弃) */
int hal_spi_send(hal_spi_id_t id, const uint8_t *data, uint32_t len);

/* 只接收(发送0xFF占位) */
int hal_spi_recv(hal_spi_id_t id, uint8_t *buf, uint32_t len);

你想想看,如果应用层每次调用SPI都要自己处理CS引脚,那这个抽象就失败了。CS引脚的控制,应该由HAL层在 hal_spi_transfer 内部自动完成。应用层只管传数据。

3.3 接口设计的原则与避坑

说了这么多接口定义,我总结几条原则。这些原则不是我发明的,是我踩坑踩出来的。

原则 说明 反面案例
最小接口原则 只暴露应用层真正需要的操作 把寄存器级别的 set_baudrate()、enable_fifo() 全暴露出去
参数原子化 用枚举和结构体,不要用魔数 hal_uart_init(1, 115200, 8, 1, 0) —— 谁看得懂?
返回值统一化 所有接口返回 int,0成功,负数为错误码 有的返回 bool,有的返回 void,有的返回 HAL_StatusTypeDef
错误码可扩展 预留自定义错误码范围 只定义 HAL_OK 和 HAL_ERROR,遇到新错误没法区分

我曾经踩过的一个坑:在一个项目里,我把所有HAL接口的返回值都定义成了 void。当时觉得,反正初始化不会失败。结果产品量产之后,有5%的板子在低温下I2C初始化失败,系统直接死机。因为没有返回值,上层根本不知道初始化没通过。从那以后,所有接口我都加了返回值,哪怕只是 int。

3.4 一张图看懂HAL的分层位置

说了这么多,咱们用一张SVG图来总结一下HAL在整个软件架构中的位置。

HAL在嵌入式软件架构中的位置 嵌入式软件分层架构 —— HAL 层位置 应用层 (Application) 服务层 (Service / Middleware) 硬件抽象层 (HAL) MCU驱动层 (Register / SDK) 硬件 (Hardware) HAL 接口示例 hal_gpio_write() hal_uart_send() hal_i2c_read_reg() hal_spi_transfer() HAL 核心作用 隔离硬件变化 统一接口风格 提高可移植性 支持单元测试 注意:HAL 层只定义接口,不实现具体硬件操作。具体实现由 MCU 驱动层完成。

这张图很清楚地展示了:应用层只跟服务层打交道,服务层只跟HAL打交道,HAL下面是MCU驱动层,再下面是硬件。每一层都只依赖它的下一层,而且依赖的是接口,不是实现。

3.5 小结

这一章咱们聊了HAL的设计思路。说白了,HAL就是给上层一个“干净”的接口,把硬件的复杂性藏起来。

我个人觉得,HAL设计的好坏,直接决定了这个项目能活多久。接口定得好,换平台就像换衣服;接口定得烂,换平台就像换骨头。