3. 中断系统与NVIC:中断优先级管理、外部中断(EXTI)配置、中断服务函数编写

中断,说白了就是让CPU放下手头的事,先去处理一个更紧急的任务。我刚开始学嵌入式时,总觉得中断是个很玄乎的东西,后来做项目多了才发现,没有中断的系统就像一个人只能排队做事,效率低得可怜。

GD32V的中断系统基于ARM Cortex-M3内核的NVIC(嵌套向量中断控制器)。嗯,这里要注意,NVIC是内核自带的,不是GD32V独有的,但GD32V在它上面做了不少扩展。咱们这节课就把中断优先级管理、外部中断配置和中断服务函数编写这三个核心问题讲透。

3.1 NVIC中断优先级管理

NVIC支持最多240个中断,GD32V实际用到了80多个。每个中断都可以设置抢占优先级和子优先级。你想想看,这就像公司里的紧急程度分级——抢占优先级高的可以打断低的,同级的就看子优先级谁高谁先处理。

核心概念:抢占优先级决定是否允许中断嵌套,子优先级决定同抢占级时的响应顺序。

3.1.1 优先级分组

GD32V通过SCB->AIRCR寄存器来配置优先级分组。说白了就是决定用多少位来表示抢占优先级,多少位表示子优先级。我习惯用以下分组方式:

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

我个人项目中用得最多的是NVIC_PriorityGroup_2,4级抢占优先级够用,4级子优先级也能满足大部分场景。如果你系统里中断嵌套层级很深,可以考虑Group_3或Group_4。

3.1.2 优先级配置函数

配置优先级分组的代码很简单,但要注意必须在系统初始化时设置,中途改会出问题。我曾经在一个项目里因为中途改了优先级分组,导致中断响应乱套,排查了两天才找到原因。

/* 设置优先级分组为Group_2:4级抢占优先级,4级子优先级 */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

/* 配置具体中断的优先级 */
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;  /* 抢占优先级 */
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         /* 子优先级 */
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

小技巧:数值越小优先级越高。抢占优先级0是最高的,可以打断任何其他中断。

3.2 外部中断(EXTI)配置

外部中断是GD32V最常用的中断类型之一。它可以把GPIO引脚上的电平变化转换成中断信号。说白了就是:你按个按钮,或者传感器输出一个跳变,CPU就能立刻知道。

3.2.1 EXTI结构框图

先看一张我画的EXTI结构图,帮你理清信号流向:

GD32V EXTI外部中断结构图 GPIO引脚 边沿检测 软件中断 中断屏蔽 挂起寄存器 NVIC 说明: 1. GPIO引脚信号经过边沿检测(上升沿/下降沿/双边沿) 2. 软件中断可以强制触发EXTI,用于调试或软件模拟 3. 中断屏蔽寄存器控制是否允许中断传递到NVIC

3.2.2 EXTI配置步骤

配置EXTI其实就五步,我总结了一个口诀:开时钟、配GPIO、选边沿、开中断、写服务函数。咱们一步步来:

  1. 使能GPIO时钟和AFIO时钟——没有时钟,外设就是死的
  2. 配置GPIO为输入模式——外部中断必须是输入引脚
  3. 选择中断触发边沿——上升沿、下降沿还是双边沿
  4. 使能EXTI中断线——打开中断通道
  5. 配置NVIC优先级——告诉CPU这个中断有多紧急
/* 完整EXTI配置示例:PA0引脚上升沿触发中断 */
void EXTI_Configuration(void)
{
    GPIO_InitPara GPIO_InitStructure;
    EXTI_InitPara EXTI_InitStructure;
    NVIC_InitPara NVIC_InitStructure;
    
    /* 1. 使能时钟 */
    RCC_APB2PeriphClock_Enable(RCC_APB2PERIPH_GPIOA | RCC_APB2PERIPH_AFIO, ENABLE);
    
    /* 2. 配置PA0为浮空输入 */
    GPIO_InitStructure.GPIO_Pin = GPIO_PIN_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_MODE_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    /* 3. 连接EXTI线0到PA0 */
    GPIO_EXTILineConfig(GPIO_PORT_SOURCE_GPIOA, GPIO_PIN_SOURCE_0);
    
    /* 4. 配置EXTI线0:上升沿触发 */
    EXTI_InitStructure.EXTI_LINE = EXTI_LINE0;
    EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
    EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising;
    EXTI_InitStructure.EXTI_LINEEnable = ENABLE;
    EXTI_Init(&EXTI_InitStructure);
    
    /* 5. 配置NVIC优先级 */
    NVIC_InitStructure.NVIC_IRQChannel = EXTI0_1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}

注意:GD32V的EXTI线0和1共用一个中断向量EXTI0_1_IRQn,线2和3共用EXTI2_3_IRQn,线4到15共用EXTI4_15_IRQn。在中断服务函数里需要判断具体是哪条线触发的。

3.3 中断服务函数编写

中断服务函数(ISR)是中断系统的灵魂。写得好,系统稳如泰山;写得不好,各种奇怪问题就来了。我见过有人把延时函数写在ISR里,结果整个系统卡死——嗯,这就是典型的坑。

3.3.1 ISR编写原则

原则 说明 我的经验
短小精悍 ISR里只做最必要的事 通常只设置标志位,主循环处理
避免阻塞 不要用延时、等待循环 曾经踩过坑,ISR里等串口发送完...
保护共享数据 ISR和主循环共享变量要加volatile 不加volatile,优化后变量值不对
清除中断标志 退出前必须清除中断标志位 否则会一直进中断,系统假死

3.3.2 标准ISR模板

下面是我常用的ISR模板,以EXTI0_1为例:

/* 全局标志位,volatile不能少 */
volatile uint8_t g_exti0_flag = 0;

void EXTI0_1_IRQHandler(void)
{
    /* 判断是哪条线触发的中断 */
    if(EXTI_GetIntBitState(EXTI_LINE0) == SET)
    {
        /* 设置标志位,告诉主循环有事件发生 */
        g_exti0_flag = 1;
        
        /* 清除中断标志位 */
        EXTI_ClearIntBitState(EXTI_LINE0);
    }
    
    /* 如果还有EXTI1的处理,继续判断 */
    if(EXTI_GetIntBitState(EXTI_LINE1) == SET)
    {
        /* 处理EXTI1的中断 */
        EXTI_ClearIntBitState(EXTI_LINE1);
    }
}

/* 主循环中处理中断事件 */
int main(void)
{
    /* 初始化代码... */
    
    while(1)
    {
        if(g_exti0_flag)
        {
            g_exti0_flag = 0;  /* 清除标志位 */
            
            /* 在这里处理实际业务逻辑 */
            /* 比如:读取传感器、更新显示、发送数据等 */
            printf("EXTI0中断触发!\n");
        }
        
        /* 其他任务... */
    }
}

个人习惯:我通常会在ISR里只做三件事:读状态、设标志、清标志。所有耗时操作都放到主循环里。这样既保证了中断响应速度,又避免了ISR里出问题。

3.3.3 避坑指南

我曾经在一个工业控制项目里,因为中断服务函数里调用了printf,导致系统在中断频繁触发时死机。后来查了三天才发现,printf内部用了互斥锁,中断里调用会导致死锁。从此我定了个规矩:ISR里绝对不调用任何可能阻塞的函数。

另外,共享变量一定要加volatile。我记得有一次优化等级开到O2后,主循环里死活检测不到中断标志位变化,就是因为没加volatile,编译器把变量优化到寄存器里了。

3.4 实战:按键中断控制LED

咱们来个完整的例子:按键PA0触发中断,控制PC13上的LED翻转。这个例子虽然简单,但包含了中断系统的所有核心要素。

/* 按键中断控制LED */
volatile uint8_t led_state = 0;

void EXTI0_1_IRQHandler(void)
{
    if(EXTI_GetIntBitState(EXTI_LINE0) == SET)
    {
        led_state = !led_state;  /* 翻转LED状态 */
        GPIO_SetBits(GPIOC, GPIO_PIN_13, led_state);
        EXTI_ClearIntBitState(EXTI_LINE0);
    }
}

int main(void)
{
    /* 系统初始化 */
    SystemCoreClockUpdate();
    SysTick_Config(SystemCoreClock / 1000);
    
    /* 配置LED引脚PC13 */
    GPIO_InitPara GPIO_InitStructure;
    RCC_APB2PeriphClock_Enable(RCC_APB2PERIPH_GPIOC, ENABLE);
    GPIO_InitStructure.GPIO_Pin = GPIO_PIN_13;
    GPIO_InitStructure.GPIO_Mode = GPIO_MODE_OUT_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_SPEED_50MHZ;
    GPIO_Init(GPIOC, &GPIO_InitStructure);
    
    /* 配置EXTI中断 */
    EXTI_Configuration();
    
    while(1)
    {
        /* 主循环可以做其他事情 */
        /* 按键中断会自动处理LED翻转 */
    }
}

这个例子跑起来后,你按一下按键,LED就翻转一次。中断响应时间通常在几十纳秒级别,比轮询方式快了几个数量级。

总结一下:中断系统是嵌入式开发的基石。优先级分组决定了中断的嵌套能力,EXTI配置让GPIO有了"感知"能力,而ISR编写质量直接决定了系统的稳定性。记住:ISR要短、要快、要干净。