硬件抽象层(HAL)设计:让代码与芯片解耦

做嵌入式开发久了,你会发现一个扎心的事实:换芯片,重写代码。尤其是那些直接操作寄存器的底层驱动,换个厂商、换个系列,几乎等于重来。我早年做一款智能家居网关,从 STM32 换到 GD32,光 GPIO 和 UART 的移植就折腾了两周。从那以后,我下定决心——必须把硬件抽象层做好。

说白了,硬件抽象层就是给上层应用一个“统一接口”,底层具体怎么操作寄存器、怎么配置时钟,全封装在 HAL 里。上层只管调用 HAL_GPIO_WritePin(),至于这个引脚是 STM32 的 PA0 还是 GD32 的 PB1,跟它没关系。

2.1 抽象接口设计原则

我总结了几条硬性规则,你在设计 HAL 时最好也遵守:

  • 接口统一:同一类外设,函数名、参数类型、返回值风格保持一致。比如 GPIO 的写操作,不管哪个芯片,都是 void gpio_write(uint8_t pin, uint8_t level)。
  • 参数抽象:不要暴露寄存器地址、位域定义。用枚举、结构体代替。比如引脚号用 GPIO_PIN_0 而不是 0x0001。
  • 错误处理标准化:返回值统一用 int8_t,0 表示成功,负数表示错误码。这样上层可以统一做异常处理。
  • 初始化与去初始化:每个外设都要有 _init() 和 _deinit(),方便低功耗模式下的外设开关。

核心思想:HAL 是“契约”,不是“实现”。上层只认契约,底层换实现,上层不用改。

2.2 GPIO 抽象接口设计

GPIO 是最基础的外设。我见过很多新手直接把 GPIOA->ODR 写到业务代码里,结果换芯片时哭都来不及。

我的做法是这样的:

// gpio_hal.h — 芯片无关的 GPIO 接口
#ifndef GPIO_HAL_H
#define GPIO_HAL_H

#include <stdint.h>

typedef enum {
    GPIO_PIN_0 = 0,
    GPIO_PIN_1,
    // ... 最多到 GPIO_PIN_15
    GPIO_PIN_MAX
} gpio_pin_t;

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_AF,       // 复用功能
    GPIO_MODE_ANALOG
} gpio_mode_t;

typedef enum {
    GPIO_PULL_NONE,
    GPIO_PULL_UP,
    GPIO_PULL_DOWN
} gpio_pull_t;

typedef struct {
    gpio_pin_t  pin;
    gpio_mode_t mode;
    gpio_pull_t pull;
    uint8_t     speed;   // 0:low, 1:medium, 2:high
} gpio_config_t;

// 初始化 GPIO
int8_t gpio_init(gpio_config_t *cfg);

// 写引脚电平
void gpio_write(gpio_pin_t pin, uint8_t level);

// 读引脚电平
uint8_t gpio_read(gpio_pin_t pin);

// 翻转引脚
void gpio_toggle(gpio_pin_t pin);

// 去初始化
void gpio_deinit(gpio_pin_t pin);

#endif

你看,这里没有出现任何寄存器名字。上层代码只需要包含 gpio_hal.h,然后调用 gpio_init() 即可。底层实现放在 gpio_hal_stm32.c 或 gpio_hal_gd32.c 里,编译时通过链接文件选择。

我的经验:引脚号不要用数字硬编码。我习惯在项目头文件里用宏定义别名,比如 #define LED_PIN GPIO_PIN_5。这样改硬件时,只改这一个地方。

2.3 UART 抽象接口设计

UART 比 GPIO 复杂一些,涉及波特率、数据位、停止位、校验位,还有收发缓冲。我设计时把配置和操作分开:

// uart_hal.h
typedef enum {
    UART_BAUD_9600   = 9600,
    UART_BAUD_115200 = 115200,
    UART_BAUD_921600 = 921600
} uart_baud_t;

typedef enum {
    UART_DATA_8BIT,
    UART_DATA_9BIT
} uart_data_bits_t;

typedef enum {
    UART_STOP_1BIT,
    UART_STOP_2BIT
} uart_stop_bits_t;

typedef enum {
    UART_PARITY_NONE,
    UART_PARITY_EVEN,
    UART_PARITY_ODD
} uart_parity_t;

typedef struct {
    uint8_t          port_id;    // 0:UART1, 1:UART2...
    uart_baud_t      baud;
    uart_data_bits_t data_bits;
    uart_stop_bits_t stop_bits;
    uart_parity_t    parity;
} uart_config_t;

// 初始化 UART
int8_t uart_init(uart_config_t *cfg);

// 发送一字节
int8_t uart_send_byte(uint8_t port_id, uint8_t data);

// 接收一字节(阻塞,带超时)
int8_t uart_recv_byte(uint8_t port_id, uint8_t *data, uint32_t timeout_ms);

// 发送缓冲区
int8_t uart_send_buf(uint8_t port_id, uint8_t *buf, uint16_t len);

// 接收缓冲区(非阻塞,返回实际接收字节数)
int16_t uart_recv_buf(uint8_t port_id, uint8_t *buf, uint16_t max_len);

// 去初始化
void uart_deinit(uint8_t port_id);

这里有个细节:port_id 是逻辑编号,不是硬件地址。上层说“我要用 UART1”,底层自己映射到具体寄存器。我曾经在项目里用 port_id=0 表示调试串口,port_id=1 表示蓝牙模块,换芯片时只需要改底层映射表。

注意:超时参数一定要加。我见过不少设备因为接收阻塞死等,导致整个系统卡死。给个超时,至少能恢复。

2.4 SPI 与 I2C 抽象接口设计

SPI 和 I2C 都是总线协议,设计思路类似:配置总线参数,然后读写。我直接给代码:

// spi_hal.h
typedef enum {
    SPI_MODE_0,  // CPOL=0, CPHA=0
    SPI_MODE_1,
    SPI_MODE_2,
    SPI_MODE_3
} spi_mode_t;

typedef enum {
    SPI_SPEED_LOW,
    SPI_SPEED_MEDIUM,
    SPI_SPEED_HIGH
} spi_speed_t;

typedef struct {
    uint8_t      bus_id;
    spi_mode_t   mode;
    spi_speed_t  speed;
    uint8_t      cs_pin;   // 片选引脚(逻辑编号)
} spi_config_t;

int8_t spi_init(spi_config_t *cfg);
int8_t spi_transfer(uint8_t bus_id, uint8_t *tx, uint8_t *rx, uint16_t len);
void   spi_cs_control(uint8_t bus_id, uint8_t level);
void   spi_deinit(uint8_t bus_id);

// i2c_hal.h
typedef enum {
    I2C_SPEED_STANDARD,  // 100kHz
    I2C_SPEED_FAST       // 400kHz
} i2c_speed_t;

typedef struct {
    uint8_t     bus_id;
    i2c_speed_t speed;
} i2c_config_t;

int8_t i2c_init(i2c_config_t *cfg);
int8_t i2c_write(uint8_t bus_id, uint8_t addr, uint8_t *data, uint16_t len);
int8_t i2c_read(uint8_t bus_id, uint8_t addr, uint8_t *data, uint16_t len);
int8_t i2c_write_read(uint8_t bus_id, uint8_t addr, 
                      uint8_t *wdata, uint16_t wlen,
                      uint8_t *rdata, uint16_t rlen);
void   i2c_deinit(uint8_t bus_id);

你发现没有?SPI 的 cs_pin 也是逻辑编号,不是硬件引脚。这样换芯片时,只需要改底层映射,上层代码纹丝不动。

2.5 如何做到芯片无关?——分层映射表

这是关键。我每个 HAL 模块都会配一个“芯片映射表”,放在单独的 hal_map.c 里:

// hal_map.h — 芯片无关的映射表接口
typedef struct {
    uint8_t  logic_id;   // 逻辑编号
    uint32_t periph_base; // 外设基地址(芯片相关)
    uint32_t pin_mask;    // 引脚掩码
    uint8_t  af_num;      // 复用功能编号
} hal_map_entry_t;

// 获取映射条目
const hal_map_entry_t* hal_map_get(uint8_t logic_id);

底层实现里,每个芯片都有一份自己的映射表:

// hal_map_stm32.c
const hal_map_entry_t gpio_map[] = {
    {GPIO_PIN_0, (uint32_t)GPIOA, GPIO_PIN_0, 0},
    {GPIO_PIN_1, (uint32_t)GPIOA, GPIO_PIN_1, 0},
    // ...
};

// hal_map_gd32.c
const hal_map_entry_t gpio_map[] = {
    {GPIO_PIN_0, (uint32_t)GPIOA, GPIO_PIN_0, 0},
    // GD32 的 GPIO 基地址可能不同,但逻辑编号一致
};

这样,HAL 实现函数里通过 hal_map_get() 拿到硬件信息,然后操作寄存器。上层完全不知道底层细节。

一句话总结:芯片无关 = 逻辑接口 + 映射表 + 分层实现。上层只认逻辑编号,底层自己认硬件地址。

2.6 整体架构图

下面这张图展示了 HAL 在整个软件分层中的位置:

硬件抽象层架构图 硬件抽象层(HAL)在软件分层中的位置 应用层(业务逻辑、协议栈) 硬件抽象层 (HAL) 统一接口:gpio_init() / uart_send_byte() / spi_transfer() / i2c_write() 映射表:逻辑编号 → 硬件地址 芯片无关,只依赖标准 C 芯片驱动层(寄存器操作) STM32 实现 stm32_gpio.c / stm32_uart.c GD32 实现 gd32_gpio.c / gd32_uart.c 其他芯片实现 xxx_gpio.c / xxx_uart.c 硬件层(芯片外设寄存器) 核心 解耦点 换芯片 只改这里 上层 无需修改 直接复用

2.7 移植适配实战要点

讲完设计,说说移植时容易踩的坑。我这些年总结了几条:

问题 原因 解决方案
引脚复用配置不一致 不同芯片的 AF 编号不同 映射表里单独维护 af_num,HAL 实现里查表配置
时钟使能寄存器不同 STM32 用 RCC,GD32 用 RCU 在 HAL 初始化函数里调用芯片特定的时钟使能宏
中断向量号不同 UART1 中断号在不同芯片可能不同 用条件编译或映射表,不要硬编码
DMA 通道映射不同 外设与 DMA 的关联关系芯片各异 单独封装 DMA 配置函数,HAL 里不直接操作 DMA 寄存器

我曾经踩过的坑:有一次移植 SPI 驱动,STM32 的 SPI1 在 APB2 总线上,GD32 的 SPI1 在 APB1 总线上。我忘了改时钟使能函数,结果 SPI 死活不工作。查了两天才发现。后来我强制要求:每个 HAL 初始化函数第一行必须是时钟使能,而且必须从映射表里读总线信息。

2.8 低功耗场景下的 HAL 设计

既然课程主题是低功耗设计,HAL 也得考虑功耗。我的做法是:

  • 外设去初始化:每个 _deinit() 函数必须关闭外设时钟、复位寄存器、释放引脚。进入睡眠前调用 uart_deinit(),醒来再 uart_init()。
  • 引脚状态管理:GPIO 去初始化时,把引脚设为模拟输入或下拉,避免漏电流。我见过因为引脚浮空导致多耗 50μA 的案例。
  • 批量操作接口:提供 uart_send_buf() 而不是只发单字节,减少 CPU 唤醒次数。发送完一包数据再进睡眠。
  • 超时机制:所有阻塞操作都带超时,防止外设异常导致系统无法进入低功耗。

我的习惯:在 HAL 层加一个 hal_power_save() 函数,里面遍历所有已初始化的外设,自动调用 _deinit()。醒来调用 hal_power_restore() 重新初始化。这样上层只需要在睡眠前后各调一个函数,省心。

2.9 小结

硬件抽象层设计,说白了就是“定接口、建映射、分层实现”。接口要统一、参数要抽象、错误要标准化。映射表是解耦的关键,换芯片只改底层映射和驱动文件,上层代码纹丝不动。

我见过很多团队,HAL 层设计得过于复杂,把芯片特有的功能(比如 STM32 的备用功能)也抽象进去,结果接口臃肿不堪。记住:HAL 只抽象“通用功能”,芯片特有的高级功能留给芯片驱动层,上层如果需要,可以通过 hal_ioctl() 之类的扩展接口访问。