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 API,HAL API只认接口规范,接口规范与具体芯片无关。你只要把中间这层接口规范定好了,换芯片就是换底层实现的事儿。
总结一下接口定义的三条铁律:
- 参数结构体化:所有配置参数打包成结构体,不要用散装参数。这样扩展新参数时不用改函数签名。
- 返回值统一化:所有API返回int32_t,0表示成功,负数表示错误码。上层统一判断,不用记各种返回值类型。
- 超时参数化:所有可能阻塞的操作都带timeout_ms参数。没有超时的接口,在异常情况下就是一颗定时炸弹。
嗯,接口规范这块儿,先说到这儿。你拿着这四套模板去定义你自己的HAL层,至少能保证80%的代码在换平台时不用动。剩下的20%,等你真正遇到坑了,咱们在后面的调试章节里再细聊。