2. 硬件抽象层(HAL)设计:GPIO、UART、I2C、SPI等外设的接口抽象方法

做嵌入式开发久了,你会发现一个规律:换芯片是迟早的事。我见过太多项目,因为底层驱动和业务逻辑死死耦合在一起,换一次MCU几乎等于重写整个工程。说白了,这就是没有做好硬件抽象层(HAL)的后果。

这一章,我们就来聊聊怎么给GPIO、UART、I2C、SPI这些常见外设做接口抽象。不是讲理论,是讲实战中怎么落地。

2.1 为什么要做硬件抽象?

先看一个反面教材。很多新手写代码是这样的:

// 直接操作寄存器
void led_on(void) {
    GPIOA->BSRR = (1 << 5);  // PA5 置高
}

void led_off(void) {
    GPIOA->BRR = (1 << 5);   // PA5 置低
}

这段代码有什么问题?它把硬件细节写死了。如果下次换到STM32F4系列,GPIO寄存器名字变了;如果换到NXP的芯片,整个操作方式都不一样。你想想看,业务代码里到处都是这种直接寄存器操作,移植起来得多痛苦。

我个人习惯的做法是:先定义接口,再实现底层。接口层只描述"做什么",不关心"怎么做"。

核心原则:上层代码只依赖接口,不依赖具体硬件实现。这样换芯片时,只需要重写底层驱动,业务逻辑纹丝不动。

2.2 GPIO接口抽象

GPIO是最基础的外设,但抽象起来也有讲究。我见过有人把GPIO抽象成几十个函数,结果比直接操作寄存器还复杂。没必要。

一个够用的GPIO抽象层,只需要三个要素:

  • 初始化:配置引脚方向、上下拉、输出类型
  • 写操作:输出高/低电平
  • 读操作:读取引脚电平

来看一个我常用的接口定义:

// gpio_hal.h - 硬件抽象层接口
#ifndef GPIO_HAL_H
#define GPIO_HAL_H

#include <stdint.h>
#include <stdbool.h>

// GPIO引脚配置结构体
typedef struct {
    uint8_t     port;       // 端口号
    uint8_t     pin;        // 引脚号
    uint8_t     mode;       // 输入/输出
    uint8_t     pull;       // 上拉/下拉/浮空
    uint8_t     speed;      // 输出速度
} gpio_config_t;

// GPIO操作句柄
typedef void* gpio_handle_t;

// 接口函数声明
gpio_handle_t gpio_init(gpio_config_t *config);
void          gpio_set(gpio_handle_t handle, bool level);
bool          gpio_get(gpio_handle_t handle);
void          gpio_toggle(gpio_handle_t handle);
void          gpio_deinit(gpio_handle_t handle);

#endif

这里有个关键点:gpio_handle_t 用了 void*。为什么?因为不同芯片的GPIO对象类型不一样,用void指针可以屏蔽这些差异。底层实现里,这个指针会指向具体的硬件对象。

我的经验:句柄用void*而不是具体类型,是嵌入式接口抽象的一个常用技巧。它让接口层完全不依赖底层实现,编译时也不需要知道具体结构体大小。

底层实现示例(以STM32为例):

// gpio_hal_stm32.c
#include "gpio_hal.h"

typedef struct {
    GPIO_TypeDef *port;
    uint16_t      pin;
} stm32_gpio_t;

gpio_handle_t gpio_init(gpio_config_t *config) {
    stm32_gpio_t *obj = malloc(sizeof(stm32_gpio_t));
    // 根据config->port选择对应的GPIO_TypeDef
    obj->port = get_stm32_port(config->port);
    obj->pin  = 1 << config->pin;
    
    // 配置HAL库的GPIO初始化
    // ... 省略具体配置代码
    
    return (gpio_handle_t)obj;
}

void gpio_set(gpio_handle_t handle, bool level) {
    stm32_gpio_t *obj = (stm32_gpio_t *)handle;
    HAL_GPIO_WritePin(obj->port, obj->pin, 
                      level ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

2.3 UART接口抽象

UART的抽象比GPIO复杂一些,因为它涉及数据收发、中断、DMA等多种模式。我建议把UART抽象成三个层次:

层次 职责 典型接口
配置层 波特率、数据位、校验位等参数设置 uart_init(), uart_config()
数据层 单字节/多字节收发 uart_send(), uart_recv()
控制层 中断使能、DMA配置、流控 uart_irq_enable(), uart_dma_config()

来看一个精简但实用的UART接口:

// uart_hal.h
#ifndef UART_HAL_H
#define UART_HAL_H

#include <stdint.h>

typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    // 7 or 8
    uint8_t  parity;       // 0:none, 1:odd, 2:even
    uint8_t  stop_bits;    // 1 or 2
} uart_config_t;

typedef void* uart_handle_t;

// 回调函数类型
typedef void (*uart_rx_cb_t)(uint8_t data);

uart_handle_t uart_init(uart_config_t *config);
void          uart_send_byte(uart_handle_t handle, uint8_t data);
void          uart_send_buf(uart_handle_t handle, uint8_t *buf, uint32_t len);
uint8_t       uart_recv_byte(uart_handle_t handle);
int32_t       uart_recv_buf(uart_handle_t handle, uint8_t *buf, uint32_t max_len);
void          uart_set_rx_callback(uart_handle_t handle, uart_rx_cb_t cb);
void          uart_deinit(uart_handle_t handle);

#endif

这里我特别想强调回调函数的设计。我在项目中遇到过一个问题:不同芯片的UART中断处理方式差异很大。有的用中断向量表,有的用事件回调。所以我在接口层只定义回调函数类型,具体怎么注册、怎么触发,交给底层实现去处理。

避坑指南:我曾经在UART抽象层里直接用了HAL库的UART_HandleTypeDef作为句柄类型,结果换到另一家芯片时,发现它的UART对象结构体完全不同,不得不改接口定义。后来我统一用void*,再也没踩过这个坑。

2.4 I2C接口抽象

I2C的抽象核心在于事务模型。I2C通信本质上是主设备发起的一系列操作:起始信号、发送地址、读写数据、停止信号。好的抽象应该让上层开发者感觉不到这些细节。

我推荐用"事务"的方式来抽象I2C:

// i2c_hal.h
#ifndef I2C_HAL_H
#define I2C_HAL_H

#include <stdint.h>
#include <stdbool.h>

typedef struct {
    uint32_t speed;         // 标准模式100k, 快速模式400k
    uint8_t  address_bits;  // 7 or 10
} i2c_config_t;

typedef void* i2c_handle_t;

// I2C事务操作
bool i2c_master_write(i2c_handle_t handle, uint8_t dev_addr, 
                      uint8_t *data, uint32_t len);
bool i2c_master_read(i2c_handle_t handle, uint8_t dev_addr, 
                     uint8_t *data, uint32_t len);
bool i2c_mem_write(i2c_handle_t handle, uint8_t dev_addr, 
                   uint16_t mem_addr, uint8_t addr_len,
                   uint8_t *data, uint32_t len);
bool i2c_mem_read(i2c_handle_t handle, uint8_t dev_addr, 
                  uint16_t mem_addr, uint8_t addr_len,
                  uint8_t *data, uint32_t len);

i2c_handle_t i2c_init(i2c_config_t *config);
void         i2c_deinit(i2c_handle_t handle);

#endif

注意看,这里我提供了i2c_mem_write和i2c_mem_read。为什么?因为大多数I2C传感器(比如温度传感器、加速度计)都是通过寄存器地址来访问的。如果只提供基础的读写接口,上层每次都要自己拼装地址字节,很麻烦。

我的建议:接口抽象不是越薄越好,也不是越厚越好。关键看你的应用场景。如果你的项目里I2C设备全是寄存器型传感器,那就把寄存器读写封装进去。如果只是接一些简单的IO扩展芯片,基础读写就够了。

2.5 SPI接口抽象

SPI的抽象和I2C类似,但有一个关键区别:SPI是全双工的。也就是说,每次发送数据的同时也在接收数据。这个特性必须在接口中体现出来。

// spi_hal.h
#ifndef SPI_HAL_H
#define SPI_HAL_H

#include <stdint.h>
#include <stdbool.h>

typedef struct {
    uint32_t speed;
    uint8_t  mode;          // 0,1,2,3 (CPOL/CPHA组合)
    uint8_t  data_width;    // 8 or 16
    bool     msb_first;     // MSB/LSB优先
} spi_config_t;

typedef void* spi_handle_t;

// SPI全双工传输
bool spi_transfer(spi_handle_t handle, uint8_t *tx_buf, 
                  uint8_t *rx_buf, uint32_t len);

// 简化接口:只发送,丢弃接收数据
bool spi_send(spi_handle_t handle, uint8_t *data, uint32_t len);

// 简化接口:只接收,发送0xFF
bool spi_recv(spi_handle_t handle, uint8_t *data, uint32_t len);

// 片选控制(如果硬件没有自动CS)
void spi_cs_set(spi_handle_t handle, bool level);

spi_handle_t spi_init(spi_config_t *config);
void         spi_deinit(spi_handle_t handle);

#endif

这里有个细节:spi_transfer的tx_buf和rx_buf都是指针,如果只想发送不接收,rx_buf传NULL;只想接收不发送,tx_buf传NULL。这种设计在底层实现时很灵活。

2.6 接口抽象的整体架构

把上面几个外设的抽象层放在一起,整个HAL的架构是这样的:

硬件抽象层(HAL)整体架构 应用层 (Application) HAL接口层 (gpio_hal.h, uart_hal.h, i2c_hal.h, spi_hal.h) 具体芯片实现 (gpio_hal_stm32.c, uart_hal_nxp.c, ...) MCU硬件层 (寄存器/HAL库/LL库) 关键设计要点: 1. 应用层只调用HAL接口,不直接操作寄存器 2. HAL接口层定义统一的函数签名和数据结构 3. 具体芯片实现层针对不同MCU编写,可替换 4. 换芯片时只需替换实现层,接口和应用层不动 5. 句柄统一使用void*,屏蔽底层类型差异

2.7 接口抽象的最佳实践

最后,总结几条我在实际项目中总结出来的经验:

  1. 接口要稳定:接口定义好后不要轻易改。我见过一个项目,HAL接口改了三次,结果上层代码跟着改了三遍,比不抽象还累。
  2. 错误处理要统一:所有接口函数的返回值要一致。我个人习惯用bool表示成功/失败,用int32_t返回错误码(正数表示成功,负数表示错误码)。
  3. 不要过度抽象:如果你的项目只用一款芯片,而且永远不会换,那抽象到够用就行。抽象是有成本的——多了一层函数调用,多了一些内存开销。
  4. 考虑测试性:好的抽象层应该方便做单元测试。比如,你可以写一个"模拟UART"的实现,把数据输出到一个环形缓冲区,这样测试上层逻辑时就不需要真实硬件。
一句话总结:硬件抽象层的本质,是用接口定义"做什么",用实现决定"怎么做"。接口稳定,实现灵活,这就是HAL的核心价值。