4. 硬件抽象层(HAL)接口定义:GPIO、UART、SPI、I2C、Timer、ADC的通用API设计

好,咱们进入正题。硬件抽象层,说白了就是给MCU外设穿上一件“通用外衣”。不管底下是STM32、GD32还是NXP,上层应用看到的接口都一样。我做了这么多年嵌入式,见过太多项目因为驱动耦合太紧,换芯片时几乎重写所有代码。嗯,今天咱们就把这件事彻底解决掉。

4.1 接口设计核心原则

设计HAL接口前,先定几条规矩。我个人习惯遵循以下三点:

  • 最小完备性:接口数量要少,但必须覆盖所有常用场景。少一个不行,多一个冗余。
  • 无状态优先:函数不依赖内部全局变量,所有上下文通过句柄传递。这样才支持多实例。
  • 错误返回统一:所有API返回标准错误码,0表示成功,负数表示错误。

核心思想:接口只描述“做什么”,不描述“怎么做”。具体实现交给芯片相关的底层驱动。

4.2 GPIO通用API设计

GPIO是最基础的外设。我见过最糟糕的设计是把每个引脚的操作都写死成宏定义,换板子就得改几百行。咱们应该这样设计:

// gpio_hal.h
typedef struct {
    uint8_t port;      // 端口号,如0表示PORTA
    uint8_t pin;       // 引脚号,0-15
} gpio_pin_t;

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT_PP,    // 推挽输出
    GPIO_MODE_OUTPUT_OD,    // 开漏输出
    GPIO_MODE_AF_PP,        // 复用推挽
    GPIO_MODE_AF_OD         // 复用开漏
} gpio_mode_t;

typedef enum {
    GPIO_PULL_NONE,
    GPIO_PULL_UP,
    GPIO_PULL_DOWN
} gpio_pull_t;

// 核心API
int32_t gpio_init(gpio_pin_t pin, gpio_mode_t mode, gpio_pull_t pull);
int32_t gpio_set(gpio_pin_t pin, uint8_t level);
int32_t gpio_get(gpio_pin_t pin, uint8_t *level);
int32_t gpio_toggle(gpio_pin_t pin);
int32_t gpio_deinit(gpio_pin_t pin);

我的经验:gpio_pin_t用结构体而不是分开传两个参数,这样扩展性更好。比如以后要加中断功能,直接在结构体里加字段就行,不用改函数签名。

4.3 UART通用API设计

UART的坑主要在缓冲管理和流控。我曾经在一个项目里因为没处理好接收缓冲溢出,导致数据丢包查了三天。来看标准接口:

// uart_hal.h
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:偶
    uint32_t rx_buf_size;   // 接收缓冲大小
    uint32_t tx_buf_size;   // 发送缓冲大小
} uart_config_t;

typedef void* uart_handle_t;

// 核心API
uart_handle_t uart_init(uint8_t id, uart_config_t *config);
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_it(uart_handle_t handle, const uint8_t *data, uint32_t len);
int32_t uart_set_callback(uart_handle_t handle, uart_event_t event, void (*cb)(void *arg));
int32_t uart_deinit(uart_handle_t handle);
函数 阻塞/非阻塞 适用场景
uart_send 阻塞 调试打印、小数据量发送
uart_receive 阻塞 命令响应、固定长度接收
uart_send_it 非阻塞 大数据量、实时性要求高

注意:uart_handle_t用void*而不是具体结构体指针,这是为了隐藏底层实现。用户不需要知道里面是什么,只需要拿着句柄用就行。

4.4 SPI与I2C通用API设计

SPI和I2C都是总线协议,但特性差异很大。我建议把它们分开设计,不要强行统一成一个接口。先看SPI:

// spi_hal.h
typedef struct {
    uint32_t frequency;
    uint8_t mode;           // 0-3, 对应CPOL/CPHA组合
    uint8_t data_width;     // 8或16
    uint8_t bit_order;      // 0:MSB, 1:LSB
} spi_config_t;

typedef void* spi_handle_t;

spi_handle_t spi_init(uint8_t id, spi_config_t *config);
int32_t spi_transfer(spi_handle_t handle, const uint8_t *tx, uint8_t *rx, uint32_t len);
int32_t spi_transfer_dma(spi_handle_t handle, const uint8_t *tx, uint8_t *rx, uint32_t len);
int32_t spi_cs_control(spi_handle_t handle, uint8_t level);  // 片选控制
int32_t spi_deinit(spi_handle_t handle);

再看I2C。I2C有设备地址的概念,所以接口要带上地址参数:

// i2c_hal.h
typedef struct {
    uint32_t frequency;     // 标准100k, 快速400k, 高速3.4M
    uint8_t addr_width;     // 7位或10位地址
} i2c_config_t;

typedef void* i2c_handle_t;

i2c_handle_t i2c_init(uint8_t id, i2c_config_t *config);
int32_t i2c_master_write(i2c_handle_t handle, uint16_t addr, const uint8_t *data, uint32_t len);
int32_t i2c_master_read(i2c_handle_t handle, uint16_t addr, uint8_t *data, uint32_t len);
int32_t i2c_mem_write(i2c_handle_t handle, uint16_t addr, uint16_t mem_addr, 
                      uint8_t mem_addr_size, const uint8_t *data, uint32_t len);
int32_t i2c_mem_read(i2c_handle_t handle, uint16_t addr, uint16_t mem_addr,
                     uint8_t mem_addr_size, uint8_t *data, uint32_t len);
int32_t i2c_deinit(i2c_handle_t handle);

关键区别:SPI用片选线选设备,I2C用地址选设备。所以SPI的spi_cs_control是独立函数,而I2C的地址直接传进读写函数里。

4.5 Timer通用API设计

定时器是最灵活的外设,可以当计数器、PWM发生器、输入捕获用。接口设计要兼顾通用性和性能:

// timer_hal.h
typedef enum {
    TIMER_MODE_ONESHOT,     // 单次
    TIMER_MODE_PERIODIC,    // 周期
    TIMER_MODE_PWM,
    TIMER_MODE_INPUT_CAPTURE
} timer_mode_t;

typedef struct {
    timer_mode_t mode;
    uint32_t period_us;     // 周期,微秒
    uint32_t duty_us;       // PWM占空比,微秒
    uint8_t auto_reload;    // 是否自动重装载
} timer_config_t;

typedef void* timer_handle_t;

timer_handle_t timer_init(uint8_t id, timer_config_t *config);
int32_t timer_start(timer_handle_t handle);
int32_t timer_stop(timer_handle_t handle);
int32_t timer_set_period(timer_handle_t handle, uint32_t period_us);
int32_t timer_set_duty(timer_handle_t handle, uint32_t duty_us);
int32_t timer_register_callback(timer_handle_t handle, void (*cb)(void *arg));
int32_t timer_deinit(timer_handle_t handle);

我的习惯:时间单位统一用微秒,这样上层不用关心时钟分频系数。底层驱动自己算预分频值和重装载值,上层只管说“我要1ms中断一次”。

4.6 ADC通用API设计

ADC的难点在于触发方式和转换结果读取。我建议提供同步和异步两种模式:

// adc_hal.h
typedef struct {
    uint8_t channel;            // ADC通道
    uint8_t resolution;         // 8, 10, 12, 16位
    uint32_t sampling_time_ns;  // 采样时间,纳秒
    uint8_t continuous_mode;    // 是否连续转换
} adc_config_t;

typedef void* adc_handle_t;

adc_handle_t adc_init(uint8_t id, adc_config_t *config);
int32_t adc_read(adc_handle_t handle, uint32_t *value);              // 同步读取
int32_t adc_start_it(adc_handle_t handle);                           // 启动中断转换
int32_t adc_set_callback(adc_handle_t handle, void (*cb)(uint32_t value));
int32_t adc_calibrate(adc_handle_t handle);                          // 校准
int32_t adc_deinit(adc_handle_t handle);

4.7 接口设计总览图

下面这张图展示了各外设HAL接口的层级关系。你想想看,从应用层到底层驱动,中间就隔了这一层薄薄的抽象接口:

HAL接口层级架构图 应用层 (Application) 硬件抽象层 (HAL API) GPIO UART SPI I2C Timer ADC MCU抽象层 (句柄管理 + 错误码统一) 芯片驱动层 (寄存器操作 / LL库) 硬件层 (MCU外设寄存器) 应用层只与HAL API交互,不直接接触寄存器。换芯片只需重写最底层驱动。

4.8 接口设计避坑指南

最后分享几个我踩过的坑:

  • 别把配置和操作混在一起:初始化函数只做配置,不要在里面启动外设。启动是独立的行为。
  • 回调函数要带参数指针:void (*cb)(void *arg)比void (*cb)(void)灵活得多。用户可以把任意结构体传进去。
  • 超时机制不能省:所有阻塞操作都要有超时参数。我曾经见过一个UART接收没有超时,结果硬件故障后程序永远卡死在等待状态。
  • 句柄要检查有效性:每个API入口都要判断句柄是否为NULL。别问我为什么知道这个很重要。

曾经有一次,我在一个项目里把SPI的片选控制放在spi_transfer内部自动完成。结果遇到一个外设需要在传输前后单独控制片选时序,不得不临时加了一个spi_cs_control接口。从那以后,我所有SPI接口都把片选控制独立出来。

嗯,以上就是六个核心外设的HAL接口定义。这些接口我在多个项目里验证过,从Cortex-M0到M7都能用。你拿过去直接当模板,根据具体芯片调整底层实现就行。