4、GPIO抽象层(二):实现中断回调机制、边缘触发与电平触发、中断优先级管理

好,咱们接着上一章往下聊。上一章我们把GPIO的基本读写和方向控制搞定了,这一章要动真格的了——中断。说实话,中断是嵌入式系统里最核心、也最容易出bug的地方之一。我做了这么多年,见过太多因为中断处理不当导致的“灵异事件”。

这一章,我们重点解决三个问题:中断回调机制怎么设计边缘触发和电平触发到底怎么选、以及中断优先级怎么管。嗯,这三个问题搞明白了,你的GPIO抽象层才算真正有了“灵魂”。

4.1 为什么需要中断回调机制?

先问一个问题:你写代码的时候,是希望CPU一直轮询检查某个引脚的电平变化,还是希望引脚变化时主动通知CPU?

答案很明显。轮询就像你每隔5分钟去门口看看快递到了没,而中断是快递员按门铃。哪个省电、哪个高效,不用我多说。

但问题来了:不同的应用场景,中断触发后要执行的动作完全不同。比如按键按下要消抖,传感器触发要读取数据,电机堵转要紧急停止。如果把这些逻辑都写在中断服务函数里,那代码就成了一锅粥。

所以,我们需要一个回调机制。说白了,就是让用户注册一个函数指针,当中断发生时,框架自动调用这个函数。用户只管写自己的业务逻辑,不用关心底层中断是怎么配置的。

核心思想:中断回调 = 中断触发 + 函数指针调用。把“中断发生了做什么”这件事,交给用户去定义。

4.2 回调机制的实现——一个简单的例子

我个人习惯用结构体来管理回调。这样清晰,也方便扩展。来看代码:

/* gpio_callback.h */
#ifndef _GPIO_CALLBACK_H_
#define _GPIO_CALLBACK_H_

#include "gpio_core.h"

/* 回调函数类型定义 */
typedef void (*gpio_irq_handler_t)(void *arg);

/* 回调注册结构体 */
typedef struct {
    gpio_pin_t      pin;            /* 引脚号 */
    gpio_irq_handler_t handler;     /* 回调函数指针 */
    void           *arg;            /* 回调参数 */
    uint8_t         priority;       /* 中断优先级 */
} gpio_irq_callback_t;

/* API声明 */
int gpio_irq_register(gpio_pin_t pin, gpio_irq_handler_t handler, void *arg);
int gpio_irq_unregister(gpio_pin_t pin);

#endif /* _GPIO_CALLBACK_H_ */

你看,这个结构体里除了函数指针,我还加了一个arg参数。为什么?因为很多时候回调函数需要知道是哪个设备触发了中断。比如一个按键中断,你可能想传一个按键ID进去。这个设计细节,我在项目中吃过亏才加上的。

再来看看实现:

/* gpio_callback.c */
#include "gpio_callback.h"

/* 静态回调表,最多支持16个中断回调 */
static gpio_irq_callback_t s_callback_table[GPIO_IRQ_MAX_NUM];
static uint8_t s_callback_count = 0;

int gpio_irq_register(gpio_pin_t pin, gpio_irq_handler_t handler, void *arg)
{
    if (handler == NULL) {
        return -1;  /* 回调函数不能为空 */
    }
    if (s_callback_count >= GPIO_IRQ_MAX_NUM) {
        return -2;  /* 回调表已满 */
    }

    /* 检查是否重复注册 */
    for (int i = 0; i < s_callback_count; i++) {
        if (s_callback_table[i].pin == pin) {
            return -3;  /* 该引脚已注册 */
        }
    }

    /* 注册回调 */
    s_callback_table[s_callback_count].pin      = pin;
    s_callback_table[s_callback_count].handler  = handler;
    s_callback_table[s_callback_count].arg      = arg;
    s_callback_count++;

    /* 使能引脚中断(底层实现) */
    gpio_hal_irq_enable(pin, true);

    return 0;
}

/* 中断分发函数——由底层中断服务函数调用 */
void gpio_irq_dispatch(gpio_pin_t pin)
{
    for (int i = 0; i < s_callback_count; i++) {
        if (s_callback_table[i].pin == pin) {
            if (s_callback_table[i].handler != NULL) {
                s_callback_table[i].handler(s_callback_table[i].arg);
            }
            break;
        }
    }
}

小提示:回调函数里不要做耗时操作。我曾经见过有人在回调里调用printf,结果中断一触发,系统直接卡死。回调里应该只做标记、置标志位、或者唤醒任务,真正的处理放到主循环或任务里。

4.3 边缘触发 vs 电平触发——怎么选?

这是很多新手容易搞混的地方。我简单说一下区别:

  • 边缘触发:检测信号从低到高(上升沿)或从高到低(下降沿)的瞬间。只触发一次。
  • 电平触发:检测信号保持在高电平或低电平。只要电平不变,就会持续触发。

你想想看,这两种方式应用场景完全不同。

触发方式 特点 典型应用 注意事项
上升沿触发 信号从低变高时触发一次 按键按下、脉冲计数 需要消抖处理
下降沿触发 信号从高变低时触发一次 按键释放、下降沿检测 同上
高电平触发 信号为高时持续触发 紧急停止、过流保护 注意中断风暴
低电平触发 信号为低时持续触发 低电平复位、唤醒 同上

我个人建议:能用边缘触发,就别用电平触发。为什么?因为电平触发太容易出问题了。比如一个传感器输出低电平表示报警,如果你用电平触发,只要报警状态不解除,中断就会一直触发。你的CPU啥也别干了,光处理中断了。这就是所谓的“中断风暴”。

警告:电平触发一定要配合硬件消抖或软件超时机制。我曾经在一个项目中用了电平触发检测电机过流,结果没加防抖,电机一堵转,中断每秒触发几万次,系统直接崩溃。后来改成边缘触发+软件滤波,问题才解决。

在抽象层里,我们需要提供配置接口:

/* 触发方式枚举 */
typedef enum {
    GPIO_IRQ_TRIGGER_RISING,    /* 上升沿触发 */
    GPIO_IRQ_TRIGGER_FALLING,   /* 下降沿触发 */
    GPIO_IRQ_TRIGGER_BOTH,      /* 双边沿触发 */
    GPIO_IRQ_TRIGGER_HIGH,      /* 高电平触发 */
    GPIO_IRQ_TRIGGER_LOW        /* 低电平触发 */
} gpio_irq_trigger_t;

/* 配置中断触发方式 */
int gpio_irq_set_trigger(gpio_pin_t pin, gpio_irq_trigger_t trigger);

4.4 中断优先级管理——别让重要的事等太久

中断优先级,说白了就是“谁更紧急”。比如电机堵转需要立即停止,而按键按下可以稍微等一下。如果优先级设置不当,高优先级的中断可能会被低优先级的中断阻塞,导致响应延迟。

我见过最典型的错误:把所有中断都设成同一个优先级。结果一个高频中断把其他中断全堵死了,系统响应一塌糊涂。

在抽象层里,我习惯这样设计优先级管理:

/* 优先级分组——根据MCU特性定义 */
typedef enum {
    GPIO_IRQ_PRIO_CRITICAL = 0,  /* 最高优先级,用于紧急保护 */
    GPIO_IRQ_PRIO_HIGH    = 1,   /* 高优先级,用于实时性要求高的 */
    GPIO_IRQ_PRIO_MEDIUM  = 2,   /* 中等优先级,一般传感器 */
    GPIO_IRQ_PRIO_LOW     = 3    /* 低优先级,按键、用户交互等 */
} gpio_irq_priority_t;

/* 设置中断优先级 */
int gpio_irq_set_priority(gpio_pin_t pin, gpio_irq_priority_t prio);

这里要注意:不同MCU的中断优先级实现方式不同。有的支持抢占优先级和子优先级,有的只有简单的优先级编号。所以抽象层要做一层映射。

举个例子,STM32的NVIC支持4位优先级,可以分成抢占优先级和子优先级。而有些低端MCU只有2位优先级。我们的抽象层要屏蔽这些差异。

/* 底层实现示例——针对STM32 */
static void gpio_hal_set_priority_impl(gpio_pin_t pin, uint8_t priority)
{
    /* 将抽象层的优先级映射到NVIC的优先级 */
    uint8_t nvic_prio = priority & 0x0F;  /* 只取低4位 */
    HAL_NVIC_SetPriority(get_irqn_from_pin(pin), nvic_prio, 0);
}

经验之谈:中断优先级不是越多越好。我一般只分3-4级,多了反而容易搞乱。记住一个原则:时间敏感的中断优先级高,数据量大的中断优先级低

4.5 完整的回调流程——串起来看看

好了,我们把上面这些串起来,看看一个完整的中断回调流程是什么样的:

  1. 用户调用 gpio_irq_register() 注册回调函数,并指定触发方式和优先级。
  2. 底层配置GPIO中断,使能NVIC。
  3. 当引脚电平变化满足触发条件时,硬件产生中断。
  4. 中断服务函数(ISR)调用 gpio_irq_dispatch()
  5. gpio_irq_dispatch() 查找回调表,调用对应的回调函数。
  6. 回调函数执行用户逻辑(通常只是置标志位或唤醒任务)。
  7. 中断返回,CPU继续执行主循环。

这个流程看起来简单,但每一步都有坑。比如第4步,ISR里不能做复杂操作,否则会影响其他中断。第5步,回调表查找要快,我一般用哈希表或者直接索引,不用遍历。

4.6 避坑指南——我踩过的那些雷

最后,分享几个我实际项目中遇到的坑,希望能帮你少走弯路:

  • 中断嵌套导致栈溢出:高优先级中断嵌套低优先级中断,如果每个中断都占用大量栈空间,很容易溢出。解决办法:中断里只做最少的操作,复杂逻辑放到任务里。
  • 回调函数重入问题:如果同一个中断连续触发两次,回调函数可能被重入。我习惯在回调里加一个原子操作标志位,防止重入。
  • 中断延迟抖动:有些MCU的中断响应时间不固定,比如有缓存预取、总线仲裁等。如果你的应用对时间要求极高(比如PWM捕获),建议用硬件定时器而不是GPIO中断。
  • 忘记清除中断标志:这是最基础的错误,但也是最容易犯的。不清除标志位,中断会一直触发。我早期做项目时就因为这个,排查了整整两天。

嗯,这一章的内容就到这里。中断回调机制、触发方式选择、优先级管理,这三个东西搞明白了,你的GPIO抽象层就基本成型了。下一章,我们会把GPIO和定时器结合起来,实现PWM输出和输入捕获。到时候你会发现,有了这个抽象层,写应用代码简直不要太爽。