3. HAL层接口定义:GPIO、UART、I2C/SPI接口规范

接口定义这事儿,说白了你得先定规矩。HAL层能不能复用、移植快不快,全看接口定义得干不干净。我见过太多项目,接口写得跟裹脚布似的,换个芯片平台,驱动代码得重写大半——那还叫什么HAL?

这一章,咱们把GPIO、UART、I2C、SPI这四类最常用的外设接口规范掰开揉碎。你照着这个套路去定义,至少能保证你的HAL层在STM32、NXP、GD32之间来回切,不改应用层代码。

核心原则:接口只描述“做什么”,不描述“怎么做”。底层寄存器操作、时钟配置、中断向量——这些全部封装在HAL内部,对外只暴露语义清晰的API。

3.1 GPIO接口规范

GPIO看似简单,坑其实不少。我记得刚入行那会儿,有个项目GPIO中断触发老是不对,查了两天才发现是接口定义里没区分“上升沿”和“双边沿”。嗯,这种细节,接口层就得定死。

3.1.1 枚举与结构体定义

/* GPIO引脚方向 */
typedef enum {
    GPIO_DIR_INPUT  = 0,
    GPIO_DIR_OUTPUT = 1,
    GPIO_DIR_ALT    = 2   /* 复用功能 */
} gpio_dir_t;

/* 上拉/下拉配置 */
typedef enum {
    GPIO_PULL_NONE  = 0,
    GPIO_PULL_UP    = 1,
    GPIO_PULL_DOWN  = 2
} gpio_pull_t;

/* 中断触发方式 */
typedef enum {
    GPIO_IRQ_NONE      = 0,
    GPIO_IRQ_RISING    = 1,
    GPIO_IRQ_FALLING   = 2,
    GPIO_IRQ_BOTH      = 3,
    GPIO_IRQ_LOW_LEVEL = 4,
    GPIO_IRQ_HIGH_LEVEL= 5
} gpio_irq_t;

/* GPIO配置结构体 */
typedef struct {
    uint8_t      pin;       /* 引脚号 */
    gpio_dir_t   dir;       /* 方向 */
    gpio_pull_t  pull;      /* 上下拉 */
    gpio_irq_t   irq_mode;  /* 中断模式 */
    uint8_t      init_val;  /* 初始输出值 */
} gpio_cfg_t;
我的习惯:init_val这个字段很多人会漏掉。你想想看,如果输出引脚上电默认是高电平,但你的外设需要低电平启动——没有这个字段,你就得在初始化之后再单独调一次写函数,多一次函数调用不说,中间还会产生一个毛刺。

3.1.2 标准API函数原型

/* 初始化/去初始化 */
int32_t hal_gpio_init(gpio_cfg_t *cfg);
int32_t hal_gpio_deinit(uint8_t pin);

/* 读写操作 */
int32_t hal_gpio_write(uint8_t pin, uint8_t value);
int32_t hal_gpio_read(uint8_t pin, uint8_t *value);
int32_t hal_gpio_toggle(uint8_t pin);

/* 中断注册 */
typedef void (*gpio_irq_cb_t)(void *arg);
int32_t hal_gpio_irq_register(uint8_t pin, gpio_irq_cb_t cb, void *arg);
int32_t hal_gpio_irq_unregister(uint8_t pin);
int32_t hal_gpio_irq_enable(uint8_t pin, uint8_t enable);
避坑指南:我曾经在一个项目里把中断回调函数指针直接放在gpio_cfg_t结构体中。结果发现,同一个引脚在不同场景下需要注册不同的回调——结构体初始化一次就定死了。后来我改成独立的register/unregister接口,灵活多了。

3.2 UART接口规范

UART这玩意儿,协议简单,但接口设计最容易出幺蛾子。尤其是数据接收,你是用轮询、中断还是DMA?接口层必须把选择权留给上层,而不是在HAL里写死。

3.2.1 参数配置结构体

typedef enum {
    UART_BAUD_9600   = 9600,
    UART_BAUD_19200  = 19200,
    UART_BAUD_115200 = 115200,
    UART_BAUD_921600 = 921600
} uart_baud_t;

typedef enum {
    UART_DATA_5B = 5,
    UART_DATA_6B = 6,
    UART_DATA_7B = 7,
    UART_DATA_8B = 8
} uart_data_bits_t;

typedef enum {
    UART_STOP_1   = 1,
    UART_STOP_1_5 = 2,  /* 1.5位停止位 */
    UART_STOP_2   = 3
} uart_stop_bits_t;

typedef enum {
    UART_PARITY_NONE = 0,
    UART_PARITY_ODD  = 1,
    UART_PARITY_EVEN = 2
} uart_parity_t;

typedef enum {
    UART_MODE_TX       = 0x01,
    UART_MODE_RX       = 0x02,
    UART_MODE_TX_RX    = 0x03,
    UART_MODE_TX_DMA   = 0x11,
    UART_MODE_RX_DMA   = 0x22,
    UART_MODE_TXRX_DMA = 0x33
} uart_mode_t;

typedef struct {
    uint8_t         port;        /* 串口号 */
    uart_baud_t     baud;        /* 波特率 */
    uart_data_bits_t data_bits;  /* 数据位 */
    uart_stop_bits_t stop_bits;  /* 停止位 */
    uart_parity_t   parity;      /* 校验位 */
    uart_mode_t     mode;        /* 工作模式 */
    uint32_t        rx_buf_size; /* 接收缓冲区大小 */
} uart_cfg_t;

3.2.2 标准API函数原型

int32_t hal_uart_init(uart_cfg_t *cfg);
int32_t hal_uart_deinit(uint8_t port);

/* 发送:阻塞/非阻塞 */
int32_t hal_uart_send(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_uart_send_nonblock(uint8_t port, uint8_t *data, uint32_t len);

/* 接收:阻塞/非阻塞 */
int32_t hal_uart_recv(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
int32_t hal_uart_recv_nonblock(uint8_t port, uint8_t *buf, uint32_t len);

/* 接收回调(非阻塞模式下使用) */
typedef void (*uart_rx_cb_t)(uint8_t port, uint8_t *data, uint32_t len);
int32_t hal_uart_rx_callback_register(uint8_t port, uart_rx_cb_t cb);
为什么要有timeout_ms参数?你想想看,如果串口线松了,阻塞发送一直等下去,整个系统就卡死了。给个超时,上层可以决定是重试还是报错。我习惯把超时默认设成1000ms,特殊场景再单独调。

3.3 I2C接口规范

I2C的接口设计,核心在于处理好“主从关系”和“多字节传输”。我见过有人把I2C的start/stop/ack都暴露给上层——那还不如直接用寄存器操作呢。HAL层要做的,是把这些时序细节吃掉。

3.3.1 参数配置与传输结构体

typedef enum {
    I2C_SPEED_STANDARD = 100000,  /* 100kHz */
    I2C_SPEED_FAST     = 400000,  /* 400kHz */
    I2C_SPEED_HIGH     = 3400000  /* 3.4MHz */
} i2c_speed_t;

typedef struct {
    uint8_t      bus;       /* I2C总线号 */
    i2c_speed_t  speed;     /* 速率 */
    uint8_t      addr_bits; /* 7位或10位地址 */
    uint16_t     own_addr;  /* 从机模式下的自身地址 */
} i2c_cfg_t;

/* 传输描述符 */
typedef struct {
    uint16_t slave_addr;   /* 从机地址 */
    uint8_t  *tx_buf;      /* 发送数据缓冲区 */
    uint32_t tx_len;       /* 发送数据长度 */
    uint8_t  *rx_buf;      /* 接收数据缓冲区 */
    uint32_t rx_len;       /* 接收数据长度 */
    uint32_t timeout_ms;   /* 超时时间 */
} i2c_xfer_t;

3.3.2 标准API函数原型

int32_t hal_i2c_init(i2c_cfg_t *cfg);
int32_t hal_i2c_deinit(uint8_t bus);

/* 主模式:写/读/写后读(复合操作) */
int32_t hal_i2c_master_write(i2c_xfer_t *xfer);
int32_t hal_i2c_master_read(i2c_xfer_t *xfer);
int32_t hal_i2c_master_write_read(i2c_xfer_t *xfer);

/* 从模式:注册接收回调 */
typedef void (*i2c_slave_rx_cb_t)(uint8_t bus, uint8_t *data, uint32_t len);
typedef void (*i2c_slave_tx_cb_t)(uint8_t bus, uint8_t **data, uint32_t *len);
int32_t hal_i2c_slave_register_callback(uint8_t bus, i2c_slave_rx_cb_t rx_cb, i2c_slave_tx_cb_t tx_cb);
我曾经踩过的坑:I2C的write_read复合操作,很多芯片的HAL库是分两次调用的——先发写,再发读。但有些传感器(比如MPU6050)要求中间不能有stop信号。所以接口层必须提供一次性的write_read函数,内部用restart信号连接。否则上层应用得自己拼时序,那HAL层就失职了。

3.4 SPI接口规范

SPI的接口设计,难点在于“模式多样性”。CPOL、CPHA、数据位宽、片选控制——这些参数如果不在接口层统一,移植的时候能把你逼疯。

3.4.1 参数配置结构体

typedef enum {
    SPI_MODE_0 = 0,  /* CPOL=0, CPHA=0 */
    SPI_MODE_1 = 1,  /* CPOL=0, CPHA=1 */
    SPI_MODE_2 = 2,  /* CPOL=1, CPHA=0 */
    SPI_MODE_3 = 3   /* CPOL=1, CPHA=1 */
} spi_mode_t;

typedef enum {
    SPI_ROLE_MASTER = 0,
    SPI_ROLE_SLAVE  = 1
} spi_role_t;

typedef enum {
    SPI_DATA_MSB_FIRST = 0,
    SPI_DATA_LSB_FIRST = 1
} spi_bit_order_t;

typedef struct {
    uint8_t         bus;         /* SPI总线号 */
    spi_role_t      role;        /* 主/从 */
    spi_mode_t      mode;        /* 时钟极性/相位 */
    uint32_t        freq;        /* 时钟频率 */
    spi_bit_order_t bit_order;   /* 位序 */
    uint8_t         data_bits;   /* 数据位宽:8/16/32 */
    uint8_t         cs_pin;      /* 片选引脚(主模式) */
    uint8_t         cs_auto;     /* 硬件自动片选使能 */
} spi_cfg_t;

3.4.2 标准API函数原型

int32_t hal_spi_init(spi_cfg_t *cfg);
int32_t hal_spi_deinit(uint8_t bus);

/* 全双工传输 */
int32_t hal_spi_transfer(uint8_t bus, uint8_t *tx_data, uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);

/* 半双工:只写/只读 */
int32_t hal_spi_write(uint8_t bus, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_spi_read(uint8_t bus, uint8_t *data, uint32_t len, uint32_t timeout_ms);

/* 片选控制(软件片选时使用) */
int32_t hal_spi_cs_control(uint8_t bus, uint8_t assert);
关于片选:我个人建议接口层同时支持硬件片选和软件片选。硬件片选快,但有些芯片的片选时序有特殊要求(比如CS拉低后需要延迟几个时钟周期)。软件片选灵活,但会增加一次GPIO操作的开销。接口层提供cs_auto字段,让上层自己选。

3.5 接口规范对比总表

外设 核心配置参数 关键API数量 常见坑点
GPIO 方向、上下拉、中断触发方式 7 中断回调注册时机、初始电平毛刺
UART 波特率、数据位、停止位、校验位、模式 8 超时处理、DMA与中断模式切换
I2C 速率、地址位宽、主从模式 7 复合操作的restart信号、时钟延展
SPI CPOL/CPHA、位序、数据位宽、片选方式 6 片选时序、全双工与半双工混用

3.6 接口设计核心逻辑图

下面这张图,把HAL层接口设计的核心逻辑串起来了。你写任何外设的HAL接口,都可以套这个框架。

HAL层接口设计核心逻辑 应用层代码 HAL API 接口层(统一语义) GPIO规范 UART规范 I2C规范 SPI规范 接口要素 枚举定义 结构体配置 函数原型 接口要素 枚举定义 结构体配置 函数原型 接口要素 枚举定义 结构体配置 函数原型 接口要素 枚举定义 结构体配置 函数原型 芯片平台(STM32 / NXP / GD32 ...) 接口与平台解耦

这张图想表达的核心就一句话:应用层只认HAL API,HAL API只认接口规范,接口规范与具体芯片无关。你只要把中间这层接口规范定好了,换芯片就是换底层实现的事儿。

总结一下接口定义的三条铁律:
  1. 参数结构体化:所有配置参数打包成结构体,不要用散装参数。这样扩展新参数时不用改函数签名。
  2. 返回值统一化:所有API返回int32_t,0表示成功,负数表示错误码。上层统一判断,不用记各种返回值类型。
  3. 超时参数化:所有可能阻塞的操作都带timeout_ms参数。没有超时的接口,在异常情况下就是一颗定时炸弹。

嗯,接口规范这块儿,先说到这儿。你拿着这四套模板去定义你自己的HAL层,至少能保证80%的代码在换平台时不用动。剩下的20%,等你真正遇到坑了,咱们在后面的调试章节里再细聊。