4. 硬件抽象层(HAL)设计:GPIO、SPI、UART、I2C的抽象接口定义与实现
好,咱们进入第四章。这一章我打算聊聊硬件抽象层,也就是HAL层。说实话,这是整个通信协议栈里最容易被低估的一层。很多人觉得,不就是封装一下寄存器操作吗?有什么难的?
嗯,我早年也这么想。直到有一次,我把一个跑在STM32上的项目,要移植到GD32上。那叫一个痛苦。所有外设驱动全散在业务代码里,改一个串口收发,得翻十几个文件。从那以后,我彻底服了——没有HAL层,移植就是噩梦。
4.1 为什么需要硬件抽象层?
说白了,HAL层就是给上层软件一个统一的接口。不管底层是STM32、GD32、还是NXP,上层调用的函数名、参数、行为,必须一模一样。
你想想看,你的消息队列调试接口,要读写GPIO、要收发SPI数据、要打印UART日志。如果这些操作直接调用芯片厂商的库,换一颗MCU,所有代码都得重写。这谁受得了?
核心目标: 上层代码零修改,底层驱动随便换。
4.2 抽象接口的设计原则
我个人习惯,设计HAL接口时,遵循三条原则:
- 最小化:接口只暴露必要的功能,不贪多。比如GPIO,就是初始化、写、读、中断控制。别把什么复用功能、速度配置全塞进来。
- 无状态:接口函数不依赖内部全局状态。每次调用,所需信息都通过参数传入。这样多任务调用才安全。
- 可测试:接口定义要方便Mock。比如定义一个结构体指针作为句柄,测试时可以传入模拟句柄。
我的经验: 接口参数尽量用枚举和结构体,别用魔法数字。我曾经接手过一个项目,GPIO写电平传0和1,但另一处传HIGH和LOW,查了半天才发现是定义冲突。
4.3 四大外设的抽象接口定义
下面我给出GPIO、SPI、UART、I2C的典型抽象接口。注意,这里用的是纯C语言,结构体函数指针的方式。这种方式在单片机里最灵活,也最省资源。
4.3.1 GPIO抽象接口
/* gpio_hal.h */
#ifndef __GPIO_HAL_H__
#define __GPIO_HAL_H__
#include <stdint.h>
/* GPIO电平定义 */
typedef enum {
GPIO_LEVEL_LOW = 0,
GPIO_LEVEL_HIGH = 1
} gpio_level_t;
/* GPIO方向定义 */
typedef enum {
GPIO_DIR_INPUT = 0,
GPIO_DIR_OUTPUT = 1
} gpio_dir_t;
/* GPIO中断触发方式 */
typedef enum {
GPIO_IRQ_RISING_EDGE = 0,
GPIO_IRQ_FALLING_EDGE = 1,
GPIO_IRQ_BOTH_EDGE = 2
} gpio_irq_mode_t;
/* GPIO句柄:不透明指针,具体实现隐藏 */
typedef struct gpio_handle_t gpio_handle_t;
/* GPIO操作接口结构体 */
typedef struct {
int32_t (*init)(gpio_handle_t *handle, uint32_t pin, gpio_dir_t dir);
int32_t (*write)(gpio_handle_t *handle, uint32_t pin, gpio_level_t level);
int32_t (*read)(gpio_handle_t *handle, uint32_t pin, gpio_level_t *level);
int32_t (*toggle)(gpio_handle_t *handle, uint32_t pin);
int32_t (*irq_register)(gpio_handle_t *handle, uint32_t pin,
gpio_irq_mode_t mode, void (*callback)(void *arg), void *arg);
int32_t (*irq_enable)(gpio_handle_t *handle, uint32_t pin, uint8_t enable);
} gpio_ops_t;
/* 对外接口 */
int32_t gpio_init(gpio_handle_t *handle, uint32_t pin, gpio_dir_t dir);
int32_t gpio_write(gpio_handle_t *handle, uint32_t pin, gpio_level_t level);
int32_t gpio_read(gpio_handle_t *handle, uint32_t pin, gpio_level_t *level);
int32_t gpio_toggle(gpio_handle_t *handle, uint32_t pin);
int32_t gpio_irq_register(gpio_handle_t *handle, uint32_t pin,
gpio_irq_mode_t mode, void (*callback)(void *arg), void *arg);
int32_t gpio_irq_enable(gpio_handle_t *handle, uint32_t pin, uint8_t enable);
#endif /* __GPIO_HAL_H__ */
你看,这里用了gpio_handle_t这个不透明指针。具体实现里,这个结构体可以包含端口基地址、引脚号、时钟使能标志等。上层根本不需要知道这些细节。
4.3.2 SPI抽象接口
/* spi_hal.h */
#ifndef __SPI_HAL_H__
#define __SPI_HAL_H__
#include <stdint.h>
/* SPI模式 */
typedef enum {
SPI_MODE_0 = 0, /* CPOL=0, CPHA=0 */
SPI_MODE_1 = 1, /* CPOL=0, CPHA=1 */
SPI_MODE_2 = 2, /* CPOL=1, CPHA=0 */
SPI_MODE_3 = 3 /* CPOL=1, CPHA=1 */
} spi_mode_t;
/* SPI数据位宽 */
typedef enum {
SPI_DATA_8BIT = 8,
SPI_DATA_16BIT = 16
} spi_data_width_t;
/* SPI句柄 */
typedef struct spi_handle_t spi_handle_t;
/* SPI操作接口 */
typedef struct {
int32_t (*init)(spi_handle_t *handle, uint32_t baudrate,
spi_mode_t mode, spi_data_width_t width);
int32_t (*deinit)(spi_handle_t *handle);
int32_t (*transmit)(spi_handle_t *handle, const uint8_t *tx_data,
uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);
int32_t (*transmit_async)(spi_handle_t *handle, const uint8_t *tx_data,
uint8_t *rx_data, uint32_t len,
void (*callback)(void *arg), void *arg);
int32_t (*select)(spi_handle_t *handle, uint8_t cs_pin, uint8_t level);
} spi_ops_t;
/* 对外接口 */
int32_t spi_init(spi_handle_t *handle, uint32_t baudrate,
spi_mode_t mode, spi_data_width_t width);
int32_t spi_deinit(spi_handle_t *handle);
int32_t spi_transmit(spi_handle_t *handle, const uint8_t *tx_data,
uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);
int32_t spi_transmit_async(spi_handle_t *handle, const uint8_t *tx_data,
uint8_t *rx_data, uint32_t len,
void (*callback)(void *arg), void *arg);
int32_t spi_select(spi_handle_t *handle, uint8_t cs_pin, uint8_t level);
#endif /* __SPI_HAL_H__ */
注意: SPI的片选(CS)引脚,我建议由HAL层管理,而不是交给上层。因为不同MCU的CS控制方式差异很大——有的用GPIO,有的用硬件NSS。统一在HAL层处理,上层只管调用spi_select就行。
4.3.3 UART抽象接口
/* uart_hal.h */
#ifndef __UART_HAL_H__
#define __UART_HAL_H__
#include <stdint.h>
/* UART数据位 */
typedef enum {
UART_DATA_5BIT = 5,
UART_DATA_6BIT = 6,
UART_DATA_7BIT = 7,
UART_DATA_8BIT = 8
} uart_data_bits_t;
/* UART停止位 */
typedef enum {
UART_STOP_1BIT = 0,
UART_STOP_2BIT = 1
} uart_stop_bits_t;
/* UART校验 */
typedef enum {
UART_PARITY_NONE = 0,
UART_PARITY_EVEN = 1,
UART_PARITY_ODD = 2
} uart_parity_t;
/* UART句柄 */
typedef struct uart_handle_t uart_handle_t;
/* UART操作接口 */
typedef struct {
int32_t (*init)(uart_handle_t *handle, uint32_t baudrate,
uart_data_bits_t data_bits, uart_stop_bits_t stop_bits,
uart_parity_t parity);
int32_t (*deinit)(uart_handle_t *handle);
int32_t (*send)(uart_handle_t *handle, const uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t (*receive)(uart_handle_t *handle, uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t (*send_async)(uart_handle_t *handle, const uint8_t *data,
uint32_t len, void (*callback)(void *arg), void *arg);
int32_t (*receive_async)(uart_handle_t *handle, uint8_t *data,
uint32_t len, void (*callback)(void *arg), void *arg);
int32_t (*set_rx_callback)(uart_handle_t *handle,
void (*callback)(uint8_t byte, void *arg), void *arg);
} uart_ops_t;
/* 对外接口 */
int32_t uart_init(uart_handle_t *handle, uint32_t baudrate,
uart_data_bits_t data_bits, uart_stop_bits_t stop_bits,
uart_parity_t parity);
int32_t uart_deinit(uart_handle_t *handle);
int32_t uart_send(uart_handle_t *handle, const uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t uart_receive(uart_handle_t *handle, uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t uart_send_async(uart_handle_t *handle, const uint8_t *data,
uint32_t len, void (*callback)(void *arg), void *arg);
int32_t uart_receive_async(uart_handle_t *handle, uint8_t *data,
uint32_t len, void (*callback)(void *arg), void *arg);
int32_t uart_set_rx_callback(uart_handle_t *handle,
void (*callback)(uint8_t byte, void *arg), void *arg);
#endif /* __UART_HAL_H__ */
UART这里,我特别加了一个set_rx_callback接口。为什么?因为调试接口经常需要中断方式接收数据。上层注册一个回调,每收到一个字节就通知上层。这样消息队列的接收线程就可以被唤醒,不用轮询。
4.3.4 I2C抽象接口
/* i2c_hal.h */
#ifndef __I2C_HAL_H__
#define __I2C_HAL_H__
#include <stdint.h>
/* I2C速率模式 */
typedef enum {
I2C_SPEED_STANDARD = 100000, /* 100kHz */
I2C_SPEED_FAST = 400000, /* 400kHz */
I2C_SPEED_FAST_PLUS = 1000000 /* 1MHz */
} i2c_speed_t;
/* I2C句柄 */
typedef struct i2c_handle_t i2c_handle_t;
/* I2C操作接口 */
typedef struct {
int32_t (*init)(i2c_handle_t *handle, i2c_speed_t speed);
int32_t (*deinit)(i2c_handle_t *handle);
int32_t (*master_write)(i2c_handle_t *handle, uint8_t dev_addr,
const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t (*master_read)(i2c_handle_t *handle, uint8_t dev_addr,
uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t (*master_write_read)(i2c_handle_t *handle, uint8_t dev_addr,
const uint8_t *write_data, uint32_t write_len,
uint8_t *read_data, uint32_t read_len,
uint32_t timeout_ms);
int32_t (*slave_write)(i2c_handle_t *handle, const uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t (*slave_read)(i2c_handle_t *handle, uint8_t *data,
uint32_t len, uint32_t timeout_ms);
} i2c_ops_t;
/* 对外接口 */
int32_t i2c_init(i2c_handle_t *handle, i2c_speed_t speed);
int32_t i2c_deinit(i2c_handle_t *handle);
int32_t i2c_master_write(i2c_handle_t *handle, uint8_t dev_addr,
const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t i2c_master_read(i2c_handle_t *handle, uint8_t dev_addr,
uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t i2c_master_write_read(i2c_handle_t *handle, uint8_t dev_addr,
const uint8_t *write_data, uint32_t write_len,
uint8_t *read_data, uint32_t read_len,
uint32_t timeout_ms);
int32_t i2c_slave_write(i2c_handle_t *handle, const uint8_t *data,
uint32_t len, uint32_t timeout_ms);
int32_t i2c_slave_read(i2c_handle_t *handle, uint8_t *data,
uint32_t len, uint32_t timeout_ms);
#endif /* __I2C_HAL_H__ */
避坑指南: I2C的master_write_read接口,我建议做成原子操作。因为很多I2C器件(比如传感器)需要先写寄存器地址,再读数据。如果分成两次调用,中间可能被其他任务打断,导致总线状态错乱。我曾经在FreeRTOS下踩过这个坑,后来统一成组合接口才解决。
4.4 接口实现示例:以GPIO为例
光有接口定义还不够,得看具体怎么实现。我以STM32为例,展示GPIO的HAL实现。
/* gpio_hal_stm32.c */
#include "gpio_hal.h"
#include "stm32f1xx_hal.h"
/* GPIO句柄结构体(具体实现) */
struct gpio_handle_t {
GPIO_TypeDef *port; /* GPIO端口基地址 */
uint16_t pin; /* 引脚号 */
gpio_dir_t dir; /* 方向 */
uint8_t is_init; /* 是否已初始化 */
};
/* 全局句柄实例(示例:PA0) */
static gpio_handle_t gpio_handle_pa0 = {
.port = GPIOA,
.pin = GPIO_PIN_0,
.dir = GPIO_DIR_INPUT,
.is_init = 0
};
/* 获取句柄指针(实际项目中可通过配置表动态创建) */
gpio_handle_t* gpio_get_handle(uint32_t pin_id) {
/* 这里简化处理,实际应有映射表 */
return &gpio_handle_pa0;
}
int32_t gpio_init(gpio_handle_t *handle, uint32_t pin, gpio_dir_t dir) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
if (handle == NULL) return -1;
/* 使能GPIO时钟 */
if (handle->port == GPIOA) {
__HAL_RCC_GPIOA_CLK_ENABLE();
} else if (handle->port == GPIOB) {
__HAL_RCC_GPIOB_CLK_ENABLE();
}
/* ... 其他端口 */
/* 配置GPIO */
GPIO_InitStruct.Pin = handle->pin;
if (dir == GPIO_DIR_OUTPUT) {
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
} else {
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
}
HAL_GPIO_Init(handle->port, &GPIO_InitStruct);
handle->dir = dir;
handle->is_init = 1;
return 0;
}
int32_t gpio_write(gpio_handle_t *handle, uint32_t pin, gpio_level_t level) {
if (handle == NULL || !handle->is_init) return -1;
if (handle->dir != GPIO_DIR_OUTPUT) return -2;
HAL_GPIO_WritePin(handle->port, handle->pin,
(level == GPIO_LEVEL_HIGH) ? GPIO_PIN_SET : GPIO_PIN_RESET);
return 0;
}
int32_t gpio_read(gpio_handle_t *handle, uint32_t pin, gpio_level_t *level) {
if (handle == NULL || !handle->is_init || level == NULL) return -1;
*level = (HAL_GPIO_ReadPin(handle->port, handle->pin) == GPIO_PIN_SET)
? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW;
return 0;
}
/* 其他函数实现类似,此处省略 */
4.5 整体架构图
下面这张图,展示了HAL层在整个软件架构中的位置。你看,上层应用和通信协议栈,只跟HAL接口打交道。底层驱动怎么实现,它们完全不关心。
4.6 接口对比与选择建议
不同项目对HAL接口的需求不一样。我整理了一个对比表,帮你快速决策。
| 特性 | 函数指针结构体 | 条件编译 | 虚函数表(C++) |
|---|---|---|---|
| 实现方式 | 结构体包含函数指针 | #ifdef/#endif | C++虚函数 |
| 代码量 | 中等 | 小 | 大 |
| 运行时开销 | 一次间接跳转 | 无 | 一次间接跳转 |
| 可扩展性 | 高(运行时切换) | 低(编译时固定) | 高 |
| 适用场景 | 多平台、多实例 | 单平台、快速原型 | 大型C++项目 |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
我的建议: 对于通信协议栈这种需要频繁移植的场景,函数指针结构体是最佳选择。它既有C语言的轻量,又具备运行时多态的能力。我最近三个项目都用的这个方案,移植效率提升至少50%。
4.7 实现时的注意事项
最后,分享几个我在实际项目中踩过的坑:
- 句柄管理:句柄的创建和销毁要配对。我建议用静态数组预分配句柄,避免动态内存分配带来的碎片问题。
- 超时处理:所有阻塞接口都要有超时参数。我曾经没加超时,结果SPI从机没响应,整个系统卡死。后来统一加了超时,配合看门狗,稳得很。
- 错误码定义:错误码要统一。我习惯用负数表示错误,0表示成功,正数表示特殊状态。这样上层判断起来很直观。
- 中断安全:如果接口可能在中断中调用,记得在实现里做临界区保护。比如用
__disable_irq()/__enable_irq(),或者用FreeRTOS的taskENTER_CRITICAL()。
重要: 千万不要在HAL接口里直接调用printf或assert。这些调试手段应该放在上层。HAL层只做一件事:把硬件操作抽象成统一接口。保持它的纯净,后续维护会轻松很多。