3. 硬件抽象层设计:HAL层的作用、GPIO驱动封装、UART驱动封装、I2C驱动封装
说到硬件抽象层,很多人第一反应是「又多了一层代码」。其实不然。我刚开始做嵌入式那会儿,也喜欢直接操作寄存器,觉得这样才够「底层」、够「硬核」。直到有一次,项目中途换了MCU型号,我花了整整两周去改底层驱动——从那以后,我再也不敢跳过HAL层了。
说白了,HAL层就是给上层应用提供一套统一的接口,把底层硬件差异藏起来。你想想看,今天用STM32,明天换GD32,后天可能又换成AT32。如果每个外设的调用方式都写在业务代码里,那改起来真要命。
HAL层的核心价值:
- 隔离变化:硬件寄存器、引脚映射、时钟配置的变化被封装在HAL内部
- 统一接口:上层代码只关心「初始化、读、写、控制」,不关心具体怎么实现
- 可移植性:换芯片时,只改HAL层,业务代码几乎不动
- 可测试性:可以写Mock HAL来模拟硬件行为,方便单元测试
下面这张图,是我个人习惯用来描述HAL层在整个固件架构中位置的。你可以看到,HAL层夹在硬件和上层中间,像个翻译官。
3.1 GPIO驱动封装
GPIO是嵌入式开发里最基础的外设。但基础不代表简单。我见过太多人把GPIO操作直接写在业务代码里,比如 GPIOA->ODR |= (1 << 5)。这样写,一旦引脚号变了,或者换芯片了,改起来就是一场噩梦。
我个人习惯的做法是:先定义一组枚举和结构体,把引脚、模式、上下拉、速度这些参数封装起来。
/* gpio_hal.h */
#ifndef GPIO_HAL_H
#define GPIO_HAL_H
#include <stdint.h>
/* GPIO引脚号枚举 */
typedef enum {
GPIO_PIN_0 = 0,
GPIO_PIN_1 = 1,
GPIO_PIN_2 = 2,
GPIO_PIN_3 = 3,
GPIO_PIN_4 = 4,
GPIO_PIN_5 = 5,
GPIO_PIN_6 = 6,
GPIO_PIN_7 = 7,
GPIO_PIN_8 = 8,
GPIO_PIN_9 = 9,
GPIO_PIN_10 = 10,
GPIO_PIN_11 = 11,
GPIO_PIN_12 = 12,
GPIO_PIN_13 = 13,
GPIO_PIN_14 = 14,
GPIO_PIN_15 = 15
} gpio_pin_t;
/* GPIO模式枚举 */
typedef enum {
GPIO_MODE_INPUT, /* 输入模式 */
GPIO_MODE_OUTPUT_PP, /* 推挽输出 */
GPIO_MODE_OUTPUT_OD, /* 开漏输出 */
GPIO_MODE_AF_PP, /* 复用推挽 */
GPIO_MODE_AF_OD, /* 复用开漏 */
GPIO_MODE_ANALOG /* 模拟模式 */
} gpio_mode_t;
/* GPIO上下拉枚举 */
typedef enum {
GPIO_PULL_NONE, /* 无上下拉 */
GPIO_PULL_UP, /* 上拉 */
GPIO_PULL_DOWN /* 下拉 */
} gpio_pull_t;
/* GPIO配置结构体 */
typedef struct {
gpio_pin_t pin;
gpio_mode_t mode;
gpio_pull_t pull;
uint8_t speed; /* 0:低速, 1:中速, 2:高速 */
} gpio_config_t;
/* HAL接口函数 */
void gpio_hal_init(gpio_config_t *config);
void gpio_hal_write_pin(gpio_pin_t pin, uint8_t value);
uint8_t gpio_hal_read_pin(gpio_pin_t pin);
void gpio_hal_toggle_pin(gpio_pin_t pin);
#endif /* GPIO_HAL_H */
你看,上层代码只需要调用 gpio_hal_init() 传入配置结构体,根本不用关心底层寄存器怎么操作。换芯片时,只需要重写 gpio_hal.c 里的实现,接口完全不变。
我的经验:GPIO配置结构体里最好预留一个 uint32_t reserved 字段。有些芯片有特殊功能(比如驱动能力、施密特触发),预留字段可以避免以后改结构体定义导致兼容性问题。
3.2 UART驱动封装
UART驱动封装,核心在于处理好「收发缓冲」和「中断回调」。我记得有一次做物联网网关,串口数据量一大,丢包丢得我怀疑人生。后来发现是接收缓冲太小,又没有做环形缓冲。
下面是我常用的UART HAL接口设计:
/* uart_hal.h */
#ifndef UART_HAL_H
#define UART_HAL_H
#include <stdint.h>
/* UART配置结构体 */
typedef struct {
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验位:0=无, 1=奇校验, 2=偶校验 */
uint8_t rx_buf_size; /* 接收缓冲区大小 */
uint8_t tx_buf_size; /* 发送缓冲区大小 */
} uart_config_t;
/* UART句柄结构体(不透明指针) */
typedef struct uart_handle uart_handle_t;
/* HAL接口函数 */
uart_handle_t* uart_hal_init(uint8_t uart_id, uart_config_t *config);
void uart_hal_send_byte(uart_handle_t *handle, uint8_t data);
void uart_hal_send_buffer(uart_handle_t *handle, uint8_t *data, uint16_t len);
uint8_t uart_hal_receive_byte(uart_handle_t *handle);
int16_t uart_hal_receive_buffer(uart_handle_t *handle, uint8_t *buf, uint16_t max_len);
void uart_hal_set_rx_callback(uart_handle_t *handle, void (*callback)(uint8_t data));
void uart_hal_deinit(uart_handle_t *handle);
#endif /* UART_HAL_H */
这里有个关键设计:句柄(handle)。句柄是个不透明指针,上层代码看不到内部实现细节。这样换芯片时,句柄结构体里可以放不同的寄存器基址、DMA通道号、中断号,完全不影响上层。
我曾经踩过的坑:UART接收回调里直接做数据处理,导致中断时间过长,丢掉了后续字节。后来我改成「回调只做数据入队,处理逻辑放在主循环或任务里」,问题就解决了。记住:中断服务函数要短平快。
3.3 I2C驱动封装
I2C比UART复杂一些,因为它有「主从模式」、「起始条件」、「停止条件」、「应答位」这些概念。而且I2C的时序要求比较严格,不同芯片的I2C外设实现差异也大。
我设计I2C HAL时,重点抽象出三个操作:写寄存器、读寄存器、写然后读(用于复合操作)。
/* i2c_hal.h */
#ifndef I2C_HAL_H
#define I2C_HAL_H
#include <stdint.h>
/* I2C配置结构体 */
typedef struct {
uint32_t speed; /* 时钟频率:100000(标准), 400000(快速) */
uint8_t addr_mode; /* 地址模式:0=7位, 1=10位 */
uint8_t own_address; /* 本机地址(从机模式使用) */
} i2c_config_t;
/* I2C句柄 */
typedef struct i2c_handle i2c_handle_t;
/* I2C传输状态枚举 */
typedef enum {
I2C_OK = 0,
I2C_BUSY = -1,
I2C_TIMEOUT = -2,
I2C_NACK = -3,
I2C_ERROR = -4
} i2c_status_t;
/* HAL接口函数 */
i2c_handle_t* i2c_hal_init(uint8_t i2c_id, i2c_config_t *config);
i2c_status_t i2c_hal_master_write(i2c_handle_t *handle, uint8_t slave_addr,
uint8_t *data, uint16_t len, uint32_t timeout_ms);
i2c_status_t i2c_hal_master_read(i2c_handle_t *handle, uint8_t slave_addr,
uint8_t *buf, uint16_t len, uint32_t timeout_ms);
i2c_status_t i2c_hal_master_write_read(i2c_handle_t *handle, uint8_t slave_addr,
uint8_t *wdata, uint16_t wlen,
uint8_t *rbuf, uint16_t rlen,
uint32_t timeout_ms);
void i2c_hal_deinit(i2c_handle_t *handle);
#endif /* I2C_HAL_H */
你注意到没有?i2c_hal_master_write_read 这个接口特别有用。很多传感器芯片的读取流程是:先写寄存器地址,再读数据。如果上层自己分两步做,中间可能被其他任务打断。封装成一个原子操作,就安全多了。
我的建议:I2C驱动一定要加超时参数。有些从设备会拉低时钟线(时钟拉伸),如果没有超时机制,主设备会一直死等。我曾经遇到过一块OLED屏偶尔卡死,加了超时重试后,系统稳定性明显提升。
3.4 三种驱动封装的对比
为了方便你理解,我把三种外设的封装要点整理成了一张表:
| 外设 | 核心抽象 | 关键设计点 | 常见坑 |
|---|---|---|---|
| GPIO | 引脚号 + 模式 + 电平 | 配置结构体、读写接口分离 | 引脚复用冲突、上下拉未配置 |
| UART | 波特率 + 收发缓冲 + 回调 | 环形缓冲、中断回调轻量化 | 中断处理过长导致丢包 |
| I2C | 从机地址 + 读写操作 + 超时 | 写-读原子操作、超时重试 | 时钟拉伸死等、NACK未处理 |
嗯,到这里,HAL层的三个核心驱动封装就讲完了。你可能会问:为什么只讲GPIO、UART、I2C?因为这三个外设覆盖了嵌入式开发中80%以上的场景。SPI、ADC、PWM这些,封装思路其实大同小异。
最后说一句:HAL层不是越厚越好。我见过有人把HAL层封装得比业务代码还复杂,结果换芯片时改HAL层比改业务代码还痛苦。记住一个原则:够用就好,别过度设计。