3、GPIO驱动封装(下):输出控制与输入读取封装、中断回调机制设计、中断优先级分组实战、实现GPIO_IRQ_Register回调注册接口

上一节我们把GPIO的初始化封装搞定了。这一节,咱们来啃剩下的硬骨头——输出控制、输入读取,还有中断回调机制。说实话,中断回调这块是很多初学者翻车的地方,我当年也在这上面栽过跟头。

3.1 输出控制与输入读取的封装思路

先看输出控制。STM32的GPIO输出,说白了就是往ODR寄存器写0或1。但直接操作寄存器有个问题——可读性太差。你想想看,代码里到处是GPIOA->ODR |= 0x01,三个月后你自己都看不懂。

我习惯的做法是这样:

/* gpio_driver.h */
typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET   = 1
} GPIO_PinState;

void GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t Pin, GPIO_PinState State);
GPIO_PinState GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t Pin);
void GPIO_TogglePin(GPIO_TypeDef* GPIOx, uint16_t Pin);

实现起来其实很简单:

/* gpio_driver.c */
void GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t Pin, GPIO_PinState State)
{
    if (State == GPIO_PIN_SET) {
        GPIOx->BSRR = Pin;   /* 用BSRR设置,原子操作 */
    } else {
        GPIOx->BRR  = Pin;   /* 用BRR复位,也是原子操作 */
    }
}

GPIO_PinState GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t Pin)
{
    return (GPIO_PinState)((GPIOx->IDR & Pin) ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

void GPIO_TogglePin(GPIO_TypeDef* GPIOx, uint16_t Pin)
{
    GPIOx->ODR ^= Pin;
}
小技巧: 写输出时尽量用BSRR/BRR而不是直接操作ODR。BSRR是原子操作,不会被中断打断。我曾经在一个项目中用ODR做位操作,结果中断里也改了同一个引脚,导致输出毛刺——排查了两天才找到原因。

输入读取更简单,直接读IDR寄存器就行。但要注意:读取前要确保引脚已经配置成输入模式,并且外部有确定电平。悬空引脚读出来的值是不确定的。

3.2 中断回调机制设计

接下来是重头戏——中断回调。STM32的EXTI中断可以配置成上升沿、下降沿或双边沿触发。但HAL库那套回调机制太臃肿了,我们自己写一个轻量级的。

我的设计思路是这样的:

  • 用户注册一个回调函数,绑定到某个EXTI线
  • 中断发生时,驱动层自动调用注册的回调
  • 支持多个中断源,优先级可配置

先定义回调函数类型:

/* gpio_driver.h */
typedef void (*GPIO_IRQ_Callback)(void *arg);

typedef struct {
    uint8_t             irq_line;       /* EXTI线号 0~15 */
    GPIO_IRQ_Callback   callback;       /* 回调函数指针 */
    void               *arg;            /* 回调参数 */
    uint8_t             priority;       /* 中断优先级 */
    uint8_t             preempt_prio;   /* 抢占优先级 */
    uint8_t             sub_prio;       /* 子优先级 */
} GPIO_IRQ_Config_t;
核心设计: 回调函数带一个void*参数,这样用户可以把任意结构体传进去。我在做传感器驱动时,经常把传感器状态结构体作为参数传给回调,中断里直接更新数据。

3.3 中断优先级分组实战

STM32的中断优先级分组是个容易踩坑的地方。Cortex-M3/M4支持最多8位优先级,但具体用几位由分组决定。我见过有人把抢占优先级和子优先级搞混,导致中断嵌套完全乱套。

优先级分组由SCB->AIRCR寄存器控制:

分组 抢占优先级位数 子优先级位数 优先级总数
NVIC_PriorityGroup_0 0 4 16
NVIC_PriorityGroup_1 1 3 16
NVIC_PriorityGroup_2 2 2 16
NVIC_PriorityGroup_3 3 1 16
NVIC_PriorityGroup_4 4 0 16

我个人习惯用NVIC_PriorityGroup_2,也就是2位抢占优先级、2位子优先级。这样有4级抢占嵌套,每级下面有4个子优先级,大多数场景够用了。

警告: 优先级分组在整个系统中只能设置一次!我见过有人在初始化里设了Group_2,后来某个驱动又改成Group_4,结果中断优先级全乱套了。建议在main函数一开始就设置好,后面不要再改。

设置分组的代码:

/* 在系统启动时调用一次 */
void GPIO_IRQ_SetPriorityGroup(uint32_t group)
{
    /* 设置优先级分组 */
    NVIC_SetPriorityGrouping(group);
}

3.4 实现GPIO_IRQ_Register回调注册接口

好了,前面铺垫了这么多,终于到核心接口了。这个接口要做三件事:

  1. 配置EXTI触发方式
  2. 设置NVIC中断优先级
  3. 注册回调函数到内部表

先看内部数据结构:

/* gpio_driver.c */
#define MAX_EXTI_LINES   16

static GPIO_IRQ_Callback s_irq_callbacks[MAX_EXTI_LINES] = {NULL};
static void              *s_irq_args[MAX_EXTI_LINES]     = {NULL};

注册接口实现:

/* gpio_driver.c */
int GPIO_IRQ_Register(GPIO_IRQ_Config_t *config)
{
    uint8_t line = config->irq_line;
    
    if (line >= MAX_EXTI_LINES) {
        return -1;  /* 参数错误 */
    }
    
    /* 1. 配置EXTI触发方式 */
    EXTI->IMR |= (1 << line);          /* 取消中断屏蔽 */
    
    if (config->trigger & EXTI_TRIGGER_RISING) {
        EXTI->RTSR |= (1 << line);     /* 上升沿触发 */
    }
    if (config->trigger & EXTI_TRIGGER_FALLING) {
        EXTI->FTSR |= (1 << line);     /* 下降沿触发 */
    }
    
    /* 2. 配置NVIC优先级 */
    uint32_t preempt, sub;
    NVIC_DecodePriority(config->priority, config->preempt_prio, 
                        &preempt, &sub);
    NVIC_SetPriority(EXTI0_IRQn + line, 
                     NVIC_EncodePriority(config->priority, 
                                        config->preempt_prio, 
                                        config->sub_prio));
    NVIC_EnableIRQ(EXTI0_IRQn + line);
    
    /* 3. 注册回调 */
    s_irq_callbacks[line] = config->callback;
    s_irq_args[line]      = config->arg;
    
    return 0;
}

中断服务函数里调用回调:

/* gpio_driver.c */
void EXTI0_IRQHandler(void)
{
    if (EXTI->PR & (1 << 0)) {
        EXTI->PR = (1 << 0);  /* 清除中断标志 */
        
        if (s_irq_callbacks[0]) {
            s_irq_callbacks[0](s_irq_args[0]);
        }
    }
}
注意: 中断服务函数里要尽快执行,不要在回调里做耗时操作。我习惯在回调里只设置一个标志位,主循环里再处理具体逻辑。这样既保证了中断响应速度,又避免了回调重入问题。

使用示例:

/* 用户代码 */
void Button_IRQ_Handler(void *arg)
{
    /* 按键中断处理 */
    uint8_t *key_state = (uint8_t *)arg;
    *key_state = 1;
}

void main(void)
{
    GPIO_IRQ_Config_t btn_config;
    
    btn_config.irq_line      = 0;           /* PA0, EXTI0 */
    btn_config.callback      = Button_IRQ_Handler;
    btn_config.arg           = &g_key_state;
    btn_config.trigger       = EXTI_TRIGGER_FALLING;
    btn_config.preempt_prio  = 1;
    btn_config.sub_prio      = 0;
    
    GPIO_IRQ_Register(&btn_config);
    
    while(1) {
        if (g_key_state) {
            /* 处理按键事件 */
            g_key_state = 0;
        }
    }
}

下面这张图展示了整个GPIO中断回调的流程:

GPIO中断回调机制流程图 用户注册回调 GPIO_IRQ_Register() 配置EXTI触发方式 设置NVIC优先级 注册回调到内部表 等待外部中断触发 外部中断触发 CPU跳转到ISR 清除中断标志位 调用注册的回调函数 用户处理逻辑 返回主循环 注意 ISR中不要做 耗时操作 建议 回调中只设置 标志位

嗯,到这里GPIO驱动封装的核心内容就讲完了。从初始化到输出控制,从输入读取到中断回调,我们一步步搭起了一个轻量级但完整的驱动框架。这套设计思路不仅适用于GPIO,后面讲定时器、UART、SPI时都可以复用。