3. HAL层核心接口定义:GPIO、UART、I2C、SPI、定时器、中断控制器

接口定义这事儿,说白了就是给硬件操作画个「标准脸谱」。不管底下是 STM32 还是 GD32,是 NXP 还是国产 RISC-V,到了 HAL 层,它们都得长一个样。我这些年移植驱动踩过的坑,十有八九都是接口没统一好。

嗯,咱们直接进入正题。这一章我把六个最常用的外设接口规范掰开揉碎讲清楚。

3.1 GPIO 接口规范

GPIO 看似简单,但坑最多。我见过有人把引脚电平翻转封装成 gpio_toggle(),结果在 RTOS 里被中断抢占,电平状态全乱套了。

我个人习惯,GPIO 接口至少包含以下四类操作:

操作类型 函数原型 说明
初始化 hal_gpio_init(pin, mode, pull, speed) 配置模式、上下拉、速率
写操作 hal_gpio_write(pin, level) 输出高低电平
读操作 hal_gpio_read(pin) 返回当前电平状态
中断配置 hal_gpio_irq_config(pin, trigger, callback) 边沿触发、回调注册

关键设计点:引脚编号必须统一。我建议用「端口号 + 引脚号」编码成一个 uint16_t,高 8 位是端口,低 8 位是引脚。这样移植时只需要改宏定义,不用动业务代码。

// 引脚编码示例
#define GPIO_PIN(port, pin)  ((uint16_t)(((port) << 8) | (pin)))
#define GPIO_PORT(pin)       ((uint8_t)((pin) >> 8))
#define GPIO_INDEX(pin)      ((uint8_t)(pin & 0xFF))

// 使用方式
uint16_t led_pin = GPIO_PIN('B', 5);  // PB5
hal_gpio_init(led_pin, GPIO_MODE_OUTPUT, GPIO_PULL_NONE, GPIO_SPEED_LOW);
hal_gpio_write(led_pin, 1);

避坑指南:我曾经在某个项目里把 GPIO 中断回调设计成全局函数指针,结果多个外设共用同一个引脚中断时,回调被覆盖了。后来改成链表注册方式,每个引脚可以挂多个回调,按优先级执行。

3.2 UART 接口规范

UART 的接口设计,核心在于「异步收发」和「流控」。我见过最糟糕的设计是把接收缓冲区和发送缓冲区都放在接口结构体里——移植时内存布局全乱套。

正确的做法是:接口只定义操作,数据缓冲区由上层管理。

// UART 操作接口
typedef struct {
    int32_t (*init)(uint32_t baud, uint8_t data_bits, uint8_t parity, uint8_t stop_bits);
    int32_t (*send)(const uint8_t *data, uint32_t len, uint32_t timeout_ms);
    int32_t (*receive)(uint8_t *buf, uint32_t len, uint32_t timeout_ms);
    int32_t (*send_dma)(const uint8_t *data, uint32_t len);
    int32_t (*receive_dma)(uint8_t *buf, uint32_t len, void (*callback)(uint32_t));
    void    (*irq_handler)(void);
} hal_uart_ops_t;
函数 阻塞/非阻塞 典型场景
send 阻塞(带超时) 调试日志、AT 指令
receive 阻塞(带超时) 简单命令响应
send_dma 非阻塞 大批量数据发送
receive_dma 非阻塞 高速数据采集

注意:超时参数必须用毫秒,不要用滴答数。不同平台的滴答周期不一样,用毫秒才能保证移植后行为一致。我吃过这个亏,从 FreeRTOS 移植到 RT-Thread 时,所有超时都得重算。

3.3 I2C 接口规范

I2C 的接口设计,我个人认为最重要的是「事务」概念。不要拆成单个字节读写,那样效率太低。应该以「起始条件 + 地址 + 数据 + 停止条件」为一个完整事务。

// I2C 事务接口
int32_t hal_i2c_master_transmit(uint16_t dev_addr, const uint8_t *tx_data, uint32_t tx_len, uint32_t timeout_ms);
int32_t hal_i2c_master_receive(uint16_t dev_addr, uint8_t *rx_data, uint32_t rx_len, uint32_t timeout_ms);
int32_t hal_i2c_mem_write(uint16_t dev_addr, uint16_t mem_addr, uint8_t addr_len, const uint8_t *data, uint32_t len);
int32_t hal_i2c_mem_read(uint16_t dev_addr, uint16_t mem_addr, uint8_t addr_len, uint8_t *data, uint32_t len);

设计要点:地址长度参数 addr_len 支持 8 位和 16 位。很多传感器用 8 位寄存器地址,但 EEPROM 用 16 位。接口必须兼容两者。

你想想看,如果每个 I2C 设备都自己拼凑起始条件和停止条件,那代码得多乱?统一成事务接口后,底层驱动只需要关心时序,上层应用只需要关心数据。

3.4 SPI 接口规范

SPI 的接口设计,核心在于「模式」和「片选」。我见过有人把片选控制放在 SPI 接口外面,结果每个调用都要手动拉低拉高,代码冗余不说,还容易漏掉。

正确的做法是:片选由 SPI 驱动内部管理,上层只传数据。

// SPI 接口
int32_t hal_spi_transmit(uint8_t *tx_data, uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);
int32_t hal_spi_transmit_dma(uint8_t *tx_data, uint8_t *rx_data, uint32_t len, void (*callback)(void));
参数 说明
tx_data 发送数据缓冲区,可传 NULL(只接收)
rx_data 接收数据缓冲区,可传 NULL(只发送)
len 数据长度(字节)
timeout_ms 超时时间

经验之谈:SPI 的时钟极性和相位(CPOL/CPHA)应该在初始化时配置好,不要在每次传输时修改。我曾经在某个项目里为了兼容两个不同模式的传感器,每次切换都重新配置 SPI,结果导致时钟毛刺,数据错位。后来改成两个 SPI 实例,各管各的。

3.5 定时器接口规范

定时器的接口设计,我建议分三层:基础定时、PWM、输入捕获。不要把所有功能塞到一个接口里。

// 基础定时器接口
int32_t hal_timer_init(uint32_t freq_hz);
int32_t hal_timer_start(void);
int32_t hal_timer_stop(void);
uint64_t hal_timer_get_us(void);
void     hal_timer_register_callback(void (*callback)(void));

// PWM 接口
int32_t hal_pwm_init(uint32_t freq_hz);
int32_t hal_pwm_set_duty(uint8_t channel, uint16_t duty);
int32_t hal_pwm_start(uint8_t channel);
int32_t hal_pwm_stop(uint8_t channel);

// 输入捕获接口
int32_t hal_capture_init(uint8_t channel, uint8_t edge);
uint32_t hal_capture_get_value(uint8_t channel);

关键点:定时器的回调函数必须在中断上下文中执行,所以回调里不能做耗时操作。我一般会在回调里只做一个标志位或一个信号量,真正的处理放到任务级。

3.6 中断控制器接口规范

中断控制器是 HAL 层的「交通警察」。接口设计要解决三个问题:使能/禁止、优先级设置、中断号映射。

// 中断控制器接口
int32_t hal_irq_enable(uint32_t irq_num);
int32_t hal_irq_disable(uint32_t irq_num);
int32_t hal_irq_set_priority(uint32_t irq_num, uint32_t priority);
int32_t hal_irq_register(uint32_t irq_num, void (*handler)(void *arg), void *arg);
void    hal_irq_global_enable(void);
void    hal_irq_global_disable(void);
函数 作用 注意事项
hal_irq_enable 使能指定中断 不要嵌套调用
hal_irq_disable 禁止指定中断 注意临界区保护
hal_irq_set_priority 设置优先级 不同平台优先级数值含义可能相反
hal_irq_register 注册中断处理函数 支持传入自定义参数

重要:中断号必须统一。不同芯片的中断号定义千差万别,我建议在 HAL 层重新定义一套中断号枚举,然后在移植层做映射。比如 IRQ_UART1 映射到 STM32 的 37 号中断,映射到 GD32 的 42 号中断。

3.7 接口关系总览

这六个接口不是孤立的。GPIO 可以模拟 I2C 和 SPI,定时器可以触发 DMA,中断控制器管理所有外设的中断。下面这张图展示了它们之间的关系:

HAL 层核心接口关系图 应用层 / 中间件 HAL 接口层(统一 API) GPIO UART I2C SPI 定时器 中断控制器 芯片驱动层(平台相关实现) 说明 应用层通过统一 HAL 接口调用,不关心底层芯片差异。驱动层实现具体芯片的寄存器操作。

嗯,这套接口规范我用了好几年,在七八个不同架构的芯片上验证过。从 Cortex-M0 到 M7,从国产 RISC-V 到老牌 8051,只要接口定义得够干净,移植工作量能减少 70% 以上。

最后说一句:接口定义不是一次就能定死的。我建议先定义最小功能集,跑通后再扩展。比如 GPIO 先只做输入输出,中断功能后面再加。这样迭代起来更灵活,不会一开始就被接口设计卡住。