3、GPIO驱动封装(下):中断配置、回调函数注册、多实例支持的实现技巧
上一讲我们把GPIO的基本读写封装好了。但说真的,做嵌入式产品,光会读写IO口,那只是入门水平。真正让GPIO“活”起来的,是中断。我见过不少工程师,中断回调写得一团糟,最后整个系统跑飞了都不知道怎么查。
这一讲,咱们就聊聊GPIO中断的封装、回调函数的注册机制,还有怎么让驱动支持多个实例。嗯,这些都是实战中绕不开的硬骨头。
3.1 中断配置的封装思路
先看一个典型场景:你有一个按键,按下时触发中断。不同芯片的GPIO中断寄存器长得完全不一样。STM32有EXTI,NXP有PORT中断,ESP32有GPIO中断矩阵。但本质上,它们都做同一件事——检测引脚电平变化,然后跳到一个函数去执行。
我的做法是:把中断配置抽象成三个步骤。
- 选择触发方式:上升沿、下降沿、双边沿、高电平、低电平。
- 使能中断通道:把GPIO的中断信号连接到NVIC(或等效的中断控制器)。
- 注册中断服务函数:告诉芯片,中断来了该跑哪段代码。
下面是我常用的枚举定义:
typedef enum {
GPIO_INT_TRIGGER_RISING, // 上升沿触发
GPIO_INT_TRIGGER_FALLING, // 下降沿触发
GPIO_INT_TRIGGER_BOTH, // 双边沿触发
GPIO_INT_TRIGGER_LOW, // 低电平触发
GPIO_INT_TRIGGER_HIGH // 高电平触发
} gpio_int_trigger_t;
你可能会问:“电平触发和边沿触发到底怎么选?”
我个人的经验是:按键、编码器这类信号,用边沿触发;外部传感器输出持续电平的,用电平触发。电平触发有个坑——如果中断服务函数执行时间太长,会反复触发,导致系统卡死。我曾经在一个温控项目里吃过这个亏,后来老老实实改成了边沿触发加软件消抖。
3.2 回调函数注册机制
中断服务函数(ISR)不能写死。为什么?因为你的驱动库可能被用在不同的项目里,每个项目对中断的处理逻辑都不一样。比如按键中断,有的项目要计数,有的要唤醒系统,有的要切换LED状态。
所以,我们需要一个回调函数注册机制。
说白了,就是留一个函数指针的“插槽”,让上层应用把它的处理函数挂进来。
typedef void (*gpio_irq_handler_t)(void *arg);
typedef struct {
gpio_port_t port;
gpio_pin_t pin;
gpio_int_trigger_t trigger;
gpio_irq_handler_t handler;
void *arg;
} gpio_irq_cfg_t;
注册函数长这样:
gpio_error_t gpio_irq_register(gpio_irq_cfg_t *cfg) {
if (cfg == NULL || cfg->handler == NULL) {
return GPIO_ERR_INVALID_PARAM;
}
// 把配置写入内部表格
irq_table[cfg->port][cfg->pin] = *cfg;
// 配置硬件中断寄存器
hal_gpio_int_config(cfg->port, cfg->pin, cfg->trigger);
// 使能中断
hal_gpio_int_enable(cfg->port, cfg->pin);
return GPIO_OK;
}
void *arg 非常有用。你可以把结构体指针、设备句柄甚至一个计数值传进去。这样同一个回调函数可以处理多个不同的中断源,只要通过arg区分就行。
那中断来了以后,ISR里怎么调用回调?
void GPIOA_IRQHandler(void) {
// 读取中断状态寄存器,判断是哪个引脚
uint32_t pending = READ_REG(GPIOA->ISR);
for (int i = 0; i < 16; i++) {
if (pending & (1 << i)) {
// 查表,找到对应的回调
gpio_irq_cfg_t *cfg = &irq_table[GPIO_PORTA][i];
if (cfg->handler) {
cfg->handler(cfg->arg);
}
// 清除中断标志
WRITE_REG(GPIOA->ICR, (1 << i));
}
}
}
注意看,这里有个关键点:中断标志必须在回调执行完之后再清除。如果先清除再调用回调,万一回调执行期间同一个中断又来了,就会丢失。我踩过这个坑,排查了整整两天。
3.3 多实例支持的实现技巧
什么叫多实例?就是同一个驱动,同时控制多个GPIO引脚,每个引脚有自己的配置、自己的回调、自己的状态。你不能把所有引脚的中断处理都写在一个全局函数里,那样代码根本没法维护。
我的做法是:用表格管理所有实例。
| 字段 | 说明 | 示例值 |
|---|---|---|
| port | GPIO端口号 | GPIO_PORTA |
| pin | 引脚号 | 5 |
| trigger | 触发方式 | GPIO_INT_TRIGGER_FALLING |
| handler | 回调函数指针 | button_press_handler |
| arg | 回调参数 | &button1_state |
| enabled | 是否使能 | true |
这个表格的大小取决于芯片的GPIO总数。比如STM32F103有112个引脚,那就定义一个112项的数组。每个引脚一个槽位,没用的引脚留空。
#define GPIO_MAX_PINS 112
static gpio_irq_cfg_t irq_table[GPIO_MAX_PINS];
注册时,通过端口和引脚号计算出索引:
static int irq_index(gpio_port_t port, gpio_pin_t pin) {
return port * 16 + pin; // 假设每个端口16个引脚
}
这样,不管你有多少个GPIO中断,驱动层都能统一管理。上层应用只需要关心它自己的那一个引脚,完全不用管其他引脚的中断是怎么配置的。
3.4 整体架构图
下面这张图展示了GPIO中断驱动的整体调用关系。你可以看到,上层应用通过回调函数与驱动层解耦,驱动层通过表格管理所有实例,硬件抽象层负责真正的寄存器操作。
3.5 避坑指南与实战建议
讲到这里,我把这些年踩过的坑总结一下:
- 中断里别做复杂运算。回调函数里尽量只做标志位设置或数据拷贝,耗时操作放到主循环里处理。我曾经在中断里做浮点运算,结果系统响应延迟了200微秒,整个控制环路都崩了。
- 注意中断优先级。GPIO中断的优先级不要设得太高,否则会抢占系统滴答定时器,导致延时函数不准。我一般把GPIO中断设为中等优先级。
- 多实例表格要加保护。如果在中断里修改了表格(比如动态注册/注销),主循环也在读表格,就会出现竞态。最简单的办法:注册操作关中断,注销操作也关中断。
- 回调函数不能阻塞。如果回调里调用了延时函数或者等待信号量,整个中断系统就卡住了。记住,中断服务函数是“借来的时间”,越快还回去越好。