3、硬件抽象层(HAL)设计:GPIO、UART、I2C、SPI等外设的通用接口定义

好,咱们进入正题。硬件抽象层,说白了就是给上层软件一个统一的“遥控器”。不管底下接的是STM32还是GD32,上层调用的函数名、参数、行为都得一样。我做了这么多年嵌入式,见过太多项目因为HAL设计得稀烂,移植时改代码改到崩溃。

这一章,咱们就聊聊怎么把GPIO、UART、I2C、SPI这几个最常用的外设,抽象成干净、可移植的接口。

3.1 为什么需要HAL?

你想想看,一个产品从方案到量产,换主控芯片是常有的事。可能是成本压力,可能是供货问题。如果代码里到处都是寄存器操作,那换芯片就等于重写。

HAL的作用就是在这中间加一层“缓冲”。

核心目标:上层业务代码只跟HAL接口打交道,不直接触碰任何寄存器。换芯片时,只需要重写HAL层的实现,上层代码一行不动。

我个人习惯把HAL设计成两个部分:

  • 接口头文件(interface):纯函数声明,不依赖任何芯片。这是给上层看的“合同”。
  • 实现文件(implementation):针对具体芯片的代码。这是给底层工程师填的“坑”。

3.2 GPIO接口抽象

GPIO看似简单,但抽象不好也容易出问题。我记得有一次,项目里有人把GPIO的初始化参数直接写死在结构体里,结果换了个引脚封装不同的芯片,改了一整天。

我推荐的GPIO接口长这样:

/* hal_gpio.h - 硬件抽象层GPIO接口 */

typedef enum {
    HAL_GPIO_MODE_INPUT,
    HAL_GPIO_MODE_OUTPUT,
    HAL_GPIO_MODE_AF,       /* 复用功能 */
    HAL_GPIO_MODE_ANALOG
} hal_gpio_mode_t;

typedef enum {
    HAL_GPIO_PULL_NONE,
    HAL_GPIO_PULL_UP,
    HAL_GPIO_PULL_DOWN
} hal_gpio_pull_t;

typedef enum {
    HAL_GPIO_SPEED_LOW,
    HAL_GPIO_SPEED_MEDIUM,
    HAL_GPIO_SPEED_HIGH
} hal_gpio_speed_t;

/* 统一的GPIO配置结构体 */
typedef struct {
    uint16_t         pin;       /* 引脚号,如 GPIO_PIN_0 */
    hal_gpio_mode_t  mode;
    hal_gpio_pull_t  pull;
    hal_gpio_speed_t speed;
} hal_gpio_config_t;

/* 接口函数 */
void hal_gpio_init(void *port, hal_gpio_config_t *config);
void hal_gpio_set(void *port, uint16_t pin);
void hal_gpio_reset(void *port, uint16_t pin);
uint8_t hal_gpio_read(void *port, uint16_t pin);
void hal_gpio_toggle(void *port, uint16_t pin);

设计技巧:注意看 void *port 这个参数。不同芯片的GPIO端口类型不一样(STM32是GPIO_TypeDef *,NXP是GPIO_Type *)。用void指针,实现文件里再强制转换,接口就干净了。

3.3 UART接口抽象

UART的抽象稍微复杂一点,因为涉及中断和回调。我在项目中踩过一个坑:把接收回调直接绑死在中断里,结果想换个DMA方式接收,接口全得改。

正确的做法是:把“数据收发”和“传输方式”解耦。

/* hal_uart.h */

typedef enum {
    HAL_UART_BAUD_9600,
    HAL_UART_BAUD_115200,
    HAL_UART_BAUD_921600
} hal_uart_baud_t;

typedef enum {
    HAL_UART_DATABITS_8,
    HAL_UART_DATABITS_9
} hal_uart_databits_t;

typedef enum {
    HAL_UART_STOPBITS_1,
    HAL_UART_STOPBITS_2
} hal_uart_stopbits_t;

typedef enum {
    HAL_UART_PARITY_NONE,
    HAL_UART_PARITY_EVEN,
    HAL_UART_PARITY_ODD
} hal_uart_parity_t;

typedef struct {
    hal_uart_baud_t     baud;
    hal_uart_databits_t databits;
    hal_uart_stopbits_t stopbits;
    hal_uart_parity_t   parity;
} hal_uart_config_t;

/* 回调函数类型 - 由上层注册 */
typedef void (*hal_uart_rx_cb_t)(uint8_t data);
typedef void (*hal_uart_tx_cb_t)(void);

/* 接口函数 */
void hal_uart_init(void *handle, hal_uart_config_t *config);
void hal_uart_send(void *handle, uint8_t *data, uint16_t len);
void hal_uart_send_byte(void *handle, uint8_t data);
uint8_t hal_uart_receive_byte(void *handle);
void hal_uart_register_rx_callback(void *handle, hal_uart_rx_cb_t cb);
void hal_uart_register_tx_callback(void *handle, hal_uart_tx_cb_t cb);

避坑指南:我曾经在一个项目里把 hal_uart_send 设计成阻塞的,结果上层调用时忘了关中断,导致系统卡死。后来我改成所有发送接口都支持超时参数,或者在接口注释里明确标注“阻塞/非阻塞”。这个习惯救了我好几次。

3.4 I2C接口抽象

I2C的抽象难点在于“多主机”和“时钟延展”。很多芯片的I2C外设实现细节不同,但上层只关心“发数据”和“收数据”。

我习惯把I2C抽象成“主机模式”和“从机模式”两套接口。这里以主机模式为例:

/* hal_i2c.h */

typedef enum {
    HAL_I2C_SPEED_STANDARD,  /* 100kHz */
    HAL_I2C_SPEED_FAST,      /* 400kHz */
    HAL_I2C_SPEED_FAST_PLUS  /* 1MHz */
} hal_i2c_speed_t;

typedef struct {
    hal_i2c_speed_t speed;
    uint8_t         own_address;  /* 从机模式下使用 */
} hal_i2c_config_t;

/* 主机模式接口 */
void hal_i2c_init(void *handle, hal_i2c_config_t *config);
int32_t hal_i2c_master_write(void *handle, uint8_t slave_addr,
                              uint8_t *data, uint16_t len, uint32_t timeout_ms);
int32_t hal_i2c_master_read(void *handle, uint8_t slave_addr,
                             uint8_t *data, uint16_t len, uint32_t timeout_ms);
int32_t hal_i2c_master_write_read(void *handle, uint8_t slave_addr,
                                   uint8_t *wdata, uint16_t wlen,
                                   uint8_t *rdata, uint16_t rlen,
                                   uint32_t timeout_ms);

关键设计:返回值用 int32_t,负数表示错误码,0表示成功,正数表示实际传输的字节数。这样上层可以精确知道传输结果,而不是只知道“成功/失败”。

3.5 SPI接口抽象

SPI的抽象相对简单,但要注意“数据帧格式”的差异。有些芯片支持8位、16位甚至32位帧,有些只支持8位。

/* hal_spi.h */

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 enum {
    HAL_SPI_ROLE_MASTER,
    HAL_SPI_ROLE_SLAVE
} hal_spi_role_t;

typedef struct {
    hal_spi_mode_t mode;
    hal_spi_role_t role;
    uint32_t       clock_hz;  /* 目标时钟频率 */
    uint8_t        data_bits; /* 8, 16, 32 */
} hal_spi_config_t;

/* 接口函数 */
void hal_spi_init(void *handle, hal_spi_config_t *config);
int32_t hal_spi_transmit(void *handle, uint8_t *tx_data, uint16_t len);
int32_t hal_spi_receive(void *handle, uint8_t *rx_data, uint16_t len);
int32_t hal_spi_transmit_receive(void *handle, uint8_t *tx_data,
                                  uint8_t *rx_data, uint16_t len);

经验之谈:SPI的 hal_spi_transmit_receive 是最常用的接口。很多传感器需要先发命令再收数据,一次调用搞定,比分开调用效率高得多。

3.6 接口设计总览

下面这张图,是我做HAL设计时脑子里一直挂着的结构。它帮你理清“接口”和“实现”的关系。

硬件抽象层接口设计总览 硬件抽象层(HAL)接口设计总览 应用层 (Application) HAL 接口层 (Interface) hal_gpio.h | hal_uart.h | hal_i2c.h | hal_spi.h 纯函数声明,不依赖任何芯片 HAL 实现层 (Implementation) hal_gpio_stm32.c | hal_uart_nxp.c | hal_i2c_esp32.c 针对具体芯片的寄存器操作 MCU 硬件层 (Hardware) 换芯片时:只重写“HAL实现层”的代码,接口层和应用层完全不动 这就是硬件抽象层的核心价值——隔离变化

3.7 接口设计原则总结

做了这么多年,我总结了几条HAL接口设计的铁律:

原则 说明 反面案例
参数通用化 用枚举和结构体代替宏定义和魔数 #define GPIO_MODE_0 0x01
句柄不透明 用 void * 传递外设句柄 直接暴露 GPIO_TypeDef *
返回值标准化 统一用 int32_t 返回状态 有的返回 bool,有的返回 int
超时参数化 阻塞操作必须带超时 死等直到硬件响应
回调注册化 中断事件通过回调通知上层 在中断里直接调用业务函数

特别提醒:千万不要在HAL接口里暴露芯片特有的寄存器结构。我曾经见过一个项目,HAL接口直接返回了 USART_TypeDef *,结果换芯片时,上层代码里到处都是 ->DR、->SR 这种寄存器访问,HAL形同虚设。

3.8 移植适配实战要点

最后,聊聊移植时最容易出问题的几个地方:

  1. 时钟配置差异:不同芯片的外设时钟使能方式不同。STM32用RCC,NXP用CCM。这个必须在HAL实现层里封装好。
  2. 中断优先级:有些芯片支持4位优先级,有些支持3位。HAL接口里不要暴露优先级数值,用枚举(如 HAL_IRQ_PRIO_LOW)来抽象。
  3. DMA传输完成标志:有的芯片用中断标志,有的用状态寄存器轮询。HAL接口统一用回调来通知传输完成。
  4. 引脚复用配置:这是GPIO移植的大坑。我建议在HAL实现层里单独做一个 hal_gpio_mux() 函数,专门处理引脚复用,不要跟初始化混在一起。

嗯,这一章的内容就到这里。接口定义清楚了,后面写实现代码和上层业务代码都会轻松很多。记住:好的HAL设计,是让你在换芯片时能睡个安稳觉。