3. GPIO驱动封装(进阶篇):添加中断回调注册机制,实现按键消抖与外部中断统一管理
上一章我们把GPIO的基本读写封装好了。但说实话,做嵌入式产品,光会点灯读按键远远不够。真正让项目“活”起来的,是中断。
我记得刚入行那会儿,在一个工业控制面板项目里,用轮询方式检测按键。结果呢?CPU大部分时间都在“傻等”按键按下,其他任务全被拖慢。后来改成中断驱动,整个系统才顺畅起来。今天我们就来聊聊,怎么把GPIO中断也封装得漂亮、可复用。
3.1 为什么需要中断回调注册机制?
先问一个问题:你手头有10个按键,每个按键按下都要执行不同的动作。你会怎么写?
很多新手会在中断服务函数里写一长串switch-case:
void EXTI0_IRQHandler(void)
{
if(按键A按下) 执行动作A;
else if(按键B按下) 执行动作B;
// ... 越来越长
}
这种写法,说白了就是“硬编码”。换一个项目,所有代码都得重写。我在项目中吃过这个亏——客户中途改了3次按键功能,每次都要改中断函数,改完还得全面回归测试,痛苦不堪。
更好的做法是什么?注册回调函数。把“中断来了做什么”这件事,从中断处理函数里剥离出来,变成一个可配置的函数指针。这样,中断处理函数只负责“通知”,具体“干活”的逻辑交给上层应用去注册。
3.2 回调注册机制的设计
我们先设计一个简单的回调注册结构。我个人习惯用结构体把中断相关的配置打包在一起:
typedef struct {
uint16_t pin; // 引脚编号
GPIO_PinState trigger_edge; // 触发边沿
uint32_t debounce_ms; // 消抖时间(ms)
void (*callback)(void* arg); // 回调函数指针
void* arg; // 回调参数
} GPIO_InterruptConfig_t;
这里有个细节:callback 参数用了 void*。为什么?因为不同按键按下后,需要传递的数据类型可能完全不同——有的只需要一个状态值,有的需要传递一个结构体指针。用 void* 可以兼容所有情况。
然后我们维护一个全局的配置表:
#define MAX_INTERRUPT_CALLBACKS 16
static GPIO_InterruptConfig_t s_interruptTable[MAX_INTERRUPT_CALLBACKS];
static uint8_t s_callbackCount = 0;
注册接口很简单:
int GPIO_RegisterInterruptCallback(GPIO_InterruptConfig_t* config)
{
if (s_callbackCount >= MAX_INTERRUPT_CALLBACKS) {
return -1; // 表满了
}
s_interruptTable[s_callbackCount++] = *config;
return 0;
}
3.3 按键消抖:硬件还是软件?
说到按键,就绕不开消抖。机械按键按下和释放的瞬间,会产生电平抖动,持续时间通常5~20ms。如果不处理,一次按下可能触发多次中断。
硬件消抖(RC滤波+施密特触发器)当然好,但会增加BOM成本。我大部分项目都选择软件消抖,效果完全够用。
软件消抖的核心思路就一句话:检测到边沿后,不立即响应,等一段时间再确认电平状态。
在中断回调框架下,消抖逻辑可以放在回调函数里,也可以放在中断处理函数里。我建议放在回调函数里——这样中断处理函数保持轻量,消抖策略可以灵活替换。
一个典型的消抖回调实现:
void KeyDebounceCallback(void* arg)
{
uint16_t pin = (uint16_t)(uint32_t)arg;
// 延时消抖(注意:不能在中断里用HAL_Delay!)
// 这里假设我们在任务上下文中调用
DelayMs(20);
// 再次读取电平
if (GPIO_ReadPin(GPIOA, pin) == GPIO_PIN_RESET) {
// 确认按键按下,执行实际逻辑
ExecuteKeyAction(pin);
}
}
3.4 外部中断统一管理
STM32的外部中断线(EXTI)是有限的。以STM32F103为例,只有16条EXTI线(EXTI0~EXTI15),但GPIO引脚可能有几十个。这意味着多个引脚可能共享同一条中断线。
怎么管理?我画了一张图来说明:
你看,整个架构分三层:
- 硬件层:STM32的EXTI控制器,负责检测电平变化并产生中断。
- 驱动封装层:我们的回调注册表,负责把硬件中断映射到用户回调函数。
- 应用层:用户写的具体业务逻辑,每个按键或传感器都有自己的回调。
这样做的好处很明显:硬件变了?只改驱动封装层。业务逻辑变了?只改应用层。互不干扰。
3.5 中断处理函数的分发实现
有了注册表,中断处理函数就变成了一个“分发器”。以EXTI0为例:
void EXTI0_IRQHandler(void)
{
// 检查中断标志位
if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
// 遍历注册表,找到对应pin的回调
for (uint8_t i = 0; i < s_callbackCount; i++) {
if (s_interruptTable[i].pin == GPIO_PIN_0) {
// 这里不直接执行回调,而是设置标志位
s_interruptTable[i].pending = 1;
break;
}
}
// 清除中断标志
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
等等,为什么这里不直接调用回调函数?
原因有两个:
- 中断上下文限制:回调函数里可能包含延时、打印、甚至I2C操作,这些在中断里做很危险。
- 消抖需要时间:消抖延时不能在中断里做。
所以正确的做法是:中断里只设置一个 pending 标志,然后在主循环或RTOS任务中轮询这个标志,发现置位后再执行回调(包含消抖逻辑)。
3.6 完整的使用示例
最后,我们看看应用层怎么用这套机制。假设板子上有两个按键:KEY1(PA0)和KEY2(PA1)。
// 按键1的回调函数
void Key1_Handler(void* arg)
{
// 消抖处理
DelayMs(20);
if (GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
printf("KEY1 pressed!\r\n");
// 执行具体动作...
}
}
// 按键2的回调函数
void Key2_Handler(void* arg)
{
DelayMs(20);
if (GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
printf("KEY2 pressed!\r\n");
// 执行具体动作...
}
}
// 初始化
void App_Init(void)
{
GPIO_InterruptConfig_t cfg;
// 注册KEY1
cfg.pin = GPIO_PIN_0;
cfg.trigger_edge = GPIO_MODE_IT_FALLING;
cfg.debounce_ms = 20;
cfg.callback = Key1_Handler;
cfg.arg = NULL;
GPIO_RegisterInterruptCallback(&cfg);
// 注册KEY2
cfg.pin = GPIO_PIN_1;
cfg.trigger_edge = GPIO_MODE_IT_FALLING;
cfg.debounce_ms = 20;
cfg.callback = Key2_Handler;
cfg.arg = NULL;
GPIO_RegisterInterruptCallback(&cfg);
// 配置NVIC,使能中断
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
HAL_NVIC_SetPriority(EXTI1_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI1_IRQn);
}
// 主循环
void main(void)
{
App_Init();
while (1) {
// 轮询pending标志,执行回调
GPIO_ProcessPendingCallbacks();
// 其他任务...
}
}
你看,应用层代码非常干净。想增加一个新按键?写一个回调函数,注册一下就行。中断处理函数、消抖逻辑、硬件配置全都被封装好了。
嗯,这一章的内容就到这里。记住:好的驱动封装,不是功能有多强,而是别人用起来有多顺手。