硬件抽象层(HAL)设计(下):UART与SPI/I2C驱动封装
上一讲我们把HAL层的概念和GPIO封装讲透了。这一讲,咱们直接上硬菜——UART、SPI、I2C这三种最常用的串行总线驱动封装。说实话,我在项目里见过太多人把HAL层写成了“搬运层”,就是把芯片厂商的库函数又包了一层,根本没起到抽象的作用。
为什么会这样?因为很多人没想明白一个问题:HAL层到底要隐藏什么?
答案是:隐藏硬件寄存器操作和平台依赖。上层代码调用HAL接口时,不应该知道当前用的是STM32还是GD32,也不应该关心中断是怎么注册的。
UART驱动封装:从轮询到中断
先看UART。我习惯把UART驱动拆成三层:
| 层级 | 职责 | 示例函数 |
|---|---|---|
| 硬件操作层 | 直接读写寄存器 | uart_reg_write_byte() |
| HAL封装层 | 提供平台无关接口 | hal_uart_send() |
| 应用接口层 | 格式化输出、协议封装 | uart_printf() |
这里有个关键点:HAL层只做一件事——把数据发出去或收回来,不处理协议。我曾经见过一个同事把AT指令解析写进了HAL层,结果换了个4G模块,整个驱动都得重写。
来看一个典型的HAL UART发送接口:
// hal_uart.h
typedef enum {
HAL_UART_OK,
HAL_UART_BUSY,
HAL_UART_TIMEOUT,
HAL_UART_ERROR
} hal_uart_status_t;
typedef struct {
uint32_t baudrate;
uint8_t data_bits; // 5, 6, 7, 8
uint8_t stop_bits; // 1, 2
uint8_t parity; // 0: none, 1: odd, 2: even
void (*tx_cplt_cb)(void);
void (*rx_cplt_cb)(uint8_t data);
} hal_uart_config_t;
hal_uart_status_t hal_uart_init(hal_uart_config_t *cfg);
hal_uart_status_t hal_uart_send(uint8_t *data, uint16_t len);
hal_uart_status_t hal_uart_receive(uint8_t *buf, uint16_t len, uint32_t timeout);
注意看配置结构体里的回调函数指针。这就是中断管理和回调机制的核心——把中断处理逻辑交给上层,HAL层只负责触发。
__weak)定义默认实现。这样上层可以覆盖,也可以不实现。我在一个低功耗项目里就用这个技巧,让UART在休眠时自动关闭接收中断。
SPI/I2C驱动封装:同步与异步的取舍
SPI和I2C的封装思路类似,但有个重要区别:SPI通常是主设备主动发起,I2C则有地址仲裁机制。我在封装时会把它们统一成“总线操作”模型:
// hal_spi.h
typedef struct {
uint32_t clock_hz;
uint8_t mode; // 0~3
uint8_t bit_order; // 0: MSB first, 1: LSB first
void (*cs_ctrl)(uint8_t level);
} hal_spi_config_t;
hal_uart_status_t hal_spi_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len);
// hal_i2c.h
typedef struct {
uint32_t clock_hz;
uint16_t addr; // 7-bit or 10-bit
uint8_t addr_mode; // 0: 7-bit, 1: 10-bit
} hal_i2c_config_t;
hal_uart_status_t hal_i2c_write(uint16_t dev_addr, uint8_t *data, uint16_t len);
hal_uart_status_t hal_i2c_read(uint16_t dev_addr, uint8_t *reg, uint8_t *buf, uint16_t len);
这里有个设计取舍:SPI用同步方式,I2C用异步方式。为什么?
- SPI常用于高速数据流(如LCD、SD卡),同步DMA传输效率更高
- I2C常用于低速传感器配置,异步中断方式更省CPU
当然,这不是绝对的。我在一个音频项目里就把SPI改成了中断方式,因为DMA通道不够用了。嗯,这就是HAL层的好处——上层代码一行都不用改。
中断管理与回调机制:别让中断成为噩梦
中断管理是嵌入式开发的深水区。我见过最惨的一次,是同事在中断里调了printf(),结果系统直接死锁。中断回调机制的核心原则就一条:中断里只做最轻量的事。
我的回调设计规范:
- 回调函数在中断上下文中执行,不能阻塞、不能调用延时函数
- 回调只传递事件信号,不处理数据。数据通过环形缓冲区传递
- 提供注册/注销接口,支持多个回调链
// 中断回调注册接口
typedef void (*hal_irq_callback_t)(void *arg);
typedef struct {
hal_irq_callback_t callback;
void *arg;
uint8_t priority;
} hal_irq_handler_t;
hal_uart_status_t hal_irq_register(uint8_t irq_num, hal_irq_handler_t *handler);
hal_uart_status_t hal_irq_unregister(uint8_t irq_num);
实战:温湿度传感器(DHT11)驱动
理论说完了,咱们动手写个DHT11驱动。DHT11用的是单总线协议,时序要求很严格。但有了HAL层,我们可以把时序操作封装起来,上层只关心读数据。
先看DHT11的通信时序:
有了HAL层,DHT11驱动可以写成这样:
// dht11.c
#include "hal_gpio.h"
#include "hal_timer.h"
static hal_gpio_pin_t dht11_pin;
void dht11_init(uint8_t gpio_port, uint8_t gpio_pin) {
dht11_pin.port = gpio_port;
dht11_pin.pin = gpio_pin;
hal_gpio_set_mode(&dht11_pin, HAL_GPIO_MODE_OUTPUT_OD);
hal_gpio_write(&dht11_pin, 1);
}
int dht11_read(float *humidity, float *temperature) {
uint8_t data[5] = {0};
uint8_t i, j;
// 1. 主机发送起始信号
hal_gpio_write(&dht11_pin, 0);
hal_timer_delay_ms(20); // 拉低至少18ms
hal_gpio_write(&dht11_pin, 1);
hal_timer_delay_us(30); // 释放20-40μs
// 2. 等待DHT11响应
hal_gpio_set_mode(&dht11_pin, HAL_GPIO_MODE_INPUT);
if (hal_gpio_read(&dht11_pin) != 0) return -1; // DHT11未拉低
while (hal_gpio_read(&dht11_pin) == 0); // 等待拉高
while (hal_gpio_read(&dht11_pin) != 0); // 等待拉低
// 3. 读取40位数据
for (i = 0; i < 5; i++) {
for (j = 0; j < 8; j++) {
while (hal_gpio_read(&dht11_pin) == 0); // 等待高电平
hal_timer_delay_us(40); // 在40μs时采样
if (hal_gpio_read(&dht11_pin) == 1) {
data[i] |= (1 << (7 - j)); // 高电平持续>40μs → 1
}
while (hal_gpio_read(&dht11_pin) == 1); // 等待低电平
}
}
// 4. 校验
if ((data[0] + data[1] + data[2] + data[3]) != data[4]) {
return -2; // 校验失败
}
*humidity = data[0] + data[1] / 10.0f;
*temperature = data[2] + data[3] / 10.0f;
return 0;
}
- 所有GPIO操作都通过
hal_gpio_*接口,换平台只需改HAL层 - 延时函数用
hal_timer_delay_*,不依赖具体定时器 - 数据读取用
hal_gpio_read(),不直接操作寄存器
你看,整个驱动里没有一行寄存器操作。这就是HAL层的价值——把硬件细节关在笼子里。我后来把这个驱动从STM32移植到GD32,只改了HAL层的GPIO和定时器实现,DHT11驱动代码一个字都没动。
最后总结一下HAL层封装的核心原则:
- 接口统一:不同平台用同一套API
- 隐藏细节:上层不关心寄存器、中断号、时钟配置
- 回调分离:中断处理和数据解耦
- 可测试:HAL层可以单独模拟测试
嗯,这一讲的内容就到这。记住:好的HAL层设计,能让你的代码在10年后换个芯片还能跑。我手上有几个2015年的项目,去年换芯片时,应用层代码几乎没动——这就是HAL层的威力。