3. 硬件抽象层设计:HAL层的作用、GPIO驱动封装、UART驱动封装、I2C驱动封装

说到硬件抽象层,很多人第一反应是「又多了一层代码」。其实不然。我刚开始做嵌入式那会儿,也喜欢直接操作寄存器,觉得这样才够「底层」、够「硬核」。直到有一次,项目中途换了MCU型号,我花了整整两周去改底层驱动——从那以后,我再也不敢跳过HAL层了。

说白了,HAL层就是给上层应用提供一套统一的接口,把底层硬件差异藏起来。你想想看,今天用STM32,明天换GD32,后天可能又换成AT32。如果每个外设的调用方式都写在业务代码里,那改起来真要命。

HAL层的核心价值:

  • 隔离变化:硬件寄存器、引脚映射、时钟配置的变化被封装在HAL内部
  • 统一接口:上层代码只关心「初始化、读、写、控制」,不关心具体怎么实现
  • 可移植性:换芯片时,只改HAL层,业务代码几乎不动
  • 可测试性:可以写Mock HAL来模拟硬件行为,方便单元测试

下面这张图,是我个人习惯用来描述HAL层在整个固件架构中位置的。你可以看到,HAL层夹在硬件和上层中间,像个翻译官。

硬件抽象层架构图 硬件抽象层(HAL)在固件架构中的位置 应用层(业务逻辑、协议栈、任务调度) 硬件抽象层(HAL) GPIO驱动 UART驱动 I2C驱动 MCU硬件层(寄存器、外设、引脚) 💡 说明: HAL层对上提供统一API,对下直接操作寄存器。 换芯片时,只需要重写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层比改业务代码还痛苦。记住一个原则:够用就好,别过度设计。