第3章 硬件抽象层(HAL)设计
硬件抽象层,圈内人常叫它HAL。说白了,就是在芯片寄存器和你写的应用代码之间,加一层“翻译官”。
我早年做项目时,遇到过一件挺尴尬的事。一个产品用了STM32,后来客户要求换成GD32。当时代码里全是直接操作寄存器,GPIO_BSRR、USART_DR这些地址写得到处都是。换芯片?等于重写一遍驱动。那感觉,就像你装修房子,结果瓷砖全贴死了,想换个风格得砸墙。
从那以后,我养成了一个习惯——不管项目多急,先把HAL层搭起来。今天这章,我就把HAL层怎么设计、接口怎么定、移植怎么搞,掰开了讲给你听。
3.1 HAL层到底在干什么
HAL层的作用,用一句话概括:把硬件差异关在底层,让上层代码只跟接口打交道。
你想想看,GPIO输出高电平,在STM32上是写ODR寄存器,在NXP的LPC系列上是写GPIO->SET,在国产芯片上可能又是另一套。但业务逻辑需要的是“点亮LED”,而不是“往0x40020C14写1”。
HAL层就是干这个的。它提供一组统一的函数,比如:
HAL_GPIO_WritePin()—— 管你底层是哪个寄存器,上层只管调用HAL_UART_Transmit()—— 底层是轮询、中断还是DMA,上层不关心HAL_SPI_Transfer()—— 时钟极性、相位这些细节,封装在内部
核心原则:HAL层只做“硬件操作”,不做“业务逻辑”。判断按键是长按还是短按,那是上层的事;HAL只管“读到引脚电平”。
3.2 四大基础外设的HAL接口定义
接口怎么定?我个人的经验是:参数尽量简单,返回值尽量统一。别搞花里胡哨的结构体嵌套,移植的时候你会哭的。
3.2.1 GPIO接口
GPIO是最基础的外设。接口设计要覆盖:初始化、写电平、读电平、翻转、中断配置。
/* GPIO HAL接口示例 */
typedef struct {
uint8_t port; /* GPIO端口号,如0=A,1=B */
uint8_t pin; /* 引脚号,0~15 */
uint8_t mode; /* 输入/输出/复用/模拟 */
uint8_t pull; /* 上拉/下拉/浮空 */
uint8_t speed; /* 低速/中速/高速 */
} HAL_GPIO_Config_t;
int32_t HAL_GPIO_Init(HAL_GPIO_Config_t *cfg);
int32_t HAL_GPIO_DeInit(uint8_t port, uint8_t pin);
int32_t HAL_GPIO_WritePin(uint8_t port, uint8_t pin, uint8_t level);
int32_t HAL_GPIO_ReadPin(uint8_t port, uint8_t pin);
int32_t HAL_GPIO_TogglePin(uint8_t port, uint8_t pin);
int32_t HAL_GPIO_AttachInterrupt(uint8_t port, uint8_t pin,
uint8_t trigger, void (*callback)(void));
避坑指南:我曾经把GPIO端口号直接定义成GPIOA、GPIOB这样的宏,结果换芯片时发现人家的端口命名是P0、P1。后来统一改成数字编号,移植时做个映射表就行,省心多了。
3.2.2 UART接口
UART接口要覆盖:初始化、发送、接收(轮询/中断/DMA)、中断回调。
/* UART HAL接口示例 */
typedef struct {
uint8_t uart_id; /* UART编号,0/1/2 */
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:7/8/9 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验:无/奇/偶 */
} HAL_UART_Config_t;
int32_t HAL_UART_Init(HAL_UART_Config_t *cfg);
int32_t HAL_UART_DeInit(uint8_t uart_id);
int32_t HAL_UART_Transmit(uint8_t uart_id, uint8_t *data, uint16_t len, uint32_t timeout);
int32_t HAL_UART_Receive(uint8_t uart_id, uint8_t *data, uint16_t len, uint32_t timeout);
int32_t HAL_UART_Transmit_IT(uint8_t uart_id, uint8_t *data, uint16_t len);
int32_t HAL_UART_Receive_IT(uint8_t uart_id, uint8_t *data, uint16_t len);
void HAL_UART_IRQHandler(uint8_t uart_id); /* 中断入口 */
3.2.3 SPI接口
SPI接口要注意:全双工通信、时钟极性相位、片选管理。
/* SPI HAL接口示例 */
typedef struct {
uint8_t spi_id; /* SPI编号 */
uint32_t clk_freq; /* 时钟频率 */
uint8_t mode; /* 0/1/2/3,对应CPOL和CPHA组合 */
uint8_t data_order; /* MSB先还是LSB先 */
} HAL_SPI_Config_t;
int32_t HAL_SPI_Init(HAL_SPI_Config_t *cfg);
int32_t HAL_SPI_DeInit(uint8_t spi_id);
int32_t HAL_SPI_Transfer(uint8_t spi_id, uint8_t *tx_data,
uint8_t *rx_data, uint16_t len);
int32_t HAL_SPI_Transfer_IT(uint8_t spi_id, uint8_t *tx_data,
uint8_t *rx_data, uint16_t len);
3.2.4 I2C接口
I2C接口要处理:主机/从机模式、多字节读写、超时处理。
/* I2C HAL接口示例 */
typedef struct {
uint8_t i2c_id; /* I2C编号 */
uint32_t clk_freq; /* 标准模式100k/快速模式400k */
uint8_t own_addr; /* 从机模式下本机地址 */
} HAL_I2C_Config_t;
int32_t HAL_I2C_Init(HAL_I2C_Config_t *cfg);
int32_t HAL_I2C_DeInit(uint8_t i2c_id);
int32_t HAL_I2C_Master_Transmit(uint8_t i2c_id, uint16_t dev_addr,
uint8_t *data, uint16_t len, uint32_t timeout);
int32_t HAL_I2C_Master_Receive(uint8_t i2c_id, uint16_t dev_addr,
uint8_t *data, uint16_t len, uint32_t timeout);
int32_t HAL_I2C_Mem_Write(uint8_t i2c_id, uint16_t dev_addr,
uint16_t mem_addr, uint8_t *data, uint16_t len);
int32_t HAL_I2C_Mem_Read(uint8_t i2c_id, uint16_t dev_addr,
uint16_t mem_addr, uint8_t *data, uint16_t len);
3.3 多平台移植接口设计
接口定义好了,怎么让它在不同芯片上跑起来?这里有个关键设计——平台适配层。
我习惯把HAL层拆成两层:
- 上层:通用HAL接口 —— 就是上面那些函数声明,所有平台都一样
- 下层:平台适配层 —— 每个芯片一套,实现具体的寄存器操作
移植的时候,你只需要重写平台适配层。通用HAL接口的调用者,一行代码都不用改。
移植接口清单:每个平台适配层必须实现以下函数,否则编译报错。
| 模块 | 必须实现的函数 | 说明 |
|---|---|---|
| GPIO | HAL_GPIO_Init, HAL_GPIO_WritePin, HAL_GPIO_ReadPin |
至少实现这三个,其他可选 |
| UART | HAL_UART_Init, HAL_UART_Transmit, HAL_UART_Receive |
中断版可选,但建议实现 |
| SPI | HAL_SPI_Init, HAL_SPI_Transfer |
全双工传输是核心 |
| I2C | HAL_I2C_Init, HAL_I2C_Master_Transmit, HAL_I2C_Master_Receive |
主机模式读写必须支持 |
3.4 HAL层整体架构
下面这张图,是我做HAL层设计时常用的分层结构。你看一眼就明白了。
你看,从上到下,调用关系非常清晰。应用层只认通用HAL接口。换芯片时,你只需要在平台适配层里加一个新文件,比如hc32_hal.c,把GPIO、UART这些函数重新实现一遍。上层代码?完全不用动。
注意:平台适配层的函数名、参数、返回值,必须和通用HAL接口完全一致。我曾经见过有人偷懒,在适配层里改了函数参数类型,结果上层调用时传参不匹配,编译过了但运行就崩。嗯,这种bug最难查。
3.5 移植实战要点
最后,分享几个移植时容易踩的坑:
- 时钟配置别写死在HAL里 —— 时钟树每个芯片都不一样。我习惯在HAL初始化前,先调一个
SystemClock_Config(),这个函数放在平台适配层外面,由用户自己实现。 - 中断向量表要单独处理 —— 不同芯片的中断号不同。我一般会在平台适配层里做一个中断映射表,把HAL层的中断ID转成芯片实际的中断号。
- 超时机制用滴答定时器 —— 别用死等。每个平台适配层都要实现一个
HAL_GetTick()函数,返回毫秒级时间戳。这样超时判断就统一了。 - 调试信息输出口 —— 我习惯在HAL层里留一个
HAL_Debug_Printf()的弱定义。移植时,你可以重定向到串口、SWO或者RTT,调试起来方便很多。
我的习惯:每次移植完一个新平台,我会先跑一个“HAL自检程序”。这个程序会依次测试GPIO翻转、UART回环、SPI读写、I2C扫描。全部通过,我才敢说移植完成了。别嫌麻烦,这一步能省掉后面80%的调试时间。