硬件抽象层(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 在整个软件分层中的位置:
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() 之类的扩展接口访问。