4. 中断与事件系统:NVIC嵌套向量中断控制器

中断系统,说白了就是CPU的“紧急通道”。

我刚开始做汽车级开发时,总觉得中断嘛,不就是个回调函数?后来在ECU项目中,一个优先级没设对,直接导致CAN报文丢失,车子差点在路上趴窝。嗯,从那以后,我对中断的态度就变了——它不只是个功能,而是整个系统的“命门”。

4.1 NVIC:嵌套向量中断控制器

NVIC是ARM Cortex-M内核的中断管家。它管三件事:中断使能、中断挂起、中断优先级。

在STM32上,NVIC最多支持240个中断。但汽车级芯片通常不会全用,我一般只用到60个左右。你想想看,每个中断都要分配优先级,这活儿不轻。

核心概念:NVIC不是外设,它是内核的一部分。所以它的寄存器访问速度极快,几乎没有延迟。

我个人习惯,在项目启动阶段就把所有中断的优先级规划好。别等到写代码时再临时拍脑袋,那样容易出乱子。

4.2 中断优先级分组

STM32的中断优先级分两组:抢占优先级和子优先级。这两者的关系,我打个比方:

  • 抢占优先级:像公司里的部门经理。级别高的可以打断级别低的。
  • 子优先级:像部门里的员工。同级别经理下,员工按资历排队。

优先级分组由寄存器SCB->AIRCR决定。STM32支持4位优先级,分组方式如下:

分组 抢占优先级位数 子优先级位数 抢占优先级范围 子优先级范围
NVIC_PriorityGroup_0 0 4 0-15
NVIC_PriorityGroup_1 1 3 0-1 0-7
NVIC_PriorityGroup_2 2 2 0-3 0-3
NVIC_PriorityGroup_3 3 1 0-7 0-1
NVIC_PriorityGroup_4 4 0 0-15

避坑指南:我曾经在一个项目中,把分组设成了Group_0(无抢占优先级)。结果高优先级中断无法打断低优先级,导致一个紧急刹车信号被延时处理了200微秒。在汽车上,200微秒可能意味着事故。

我建议汽车级项目用Group_2或Group_3。这样既有足够的抢占层级,又能保留子优先级做精细调度。

4.3 抢占优先级与子优先级

这两者的区别,我用一个实际场景说明:

假设有两个中断:

  • 中断A:抢占优先级1,子优先级0
  • 中断B:抢占优先级1,子优先级1
  • 中断C:抢占优先级0,子优先级0

执行顺序是这样的:

  1. 如果A正在执行,B来了——B会等待,因为抢占优先级相同,子优先级低的先执行。
  2. 如果A正在执行,C来了——C会打断A,因为C的抢占优先级更高(0比1高)。
  3. 如果A和B同时来——A先执行,因为子优先级0比1高。

为什么会这样?因为抢占优先级决定“谁可以打断谁”,子优先级决定“谁先排队”。

4.4 中断向量表重定向

中断向量表,就是一张“中断函数地址表”。默认在Flash的起始地址0x08000000。

但在汽车级开发中,我们经常需要做IAP(在应用编程)。这时候就要把向量表搬到RAM里。

代码实现很简单:

// 重定向中断向量表到RAM
#define VECTOR_TABLE_RAM_BASE  0x20000000

void VectorTable_Relocate(void)
{
    // 1. 复制向量表到RAM
    memcpy((void*)VECTOR_TABLE_RAM_BASE, 
           (void*)SCB->VTOR, 
           sizeof(uint32_t) * 16);  // 只复制前16个系统异常
    
    // 2. 设置VTOR寄存器指向RAM
    SCB->VTOR = VECTOR_TABLE_RAM_BASE;
    
    // 3. 使能内存屏障,确保写入完成
    __DSB();
    __ISB();
}

小技巧:我习惯在重定向后,读回VTOR寄存器确认一下。曾经遇到过芯片复位后VTOR自动恢复默认值的情况,排查了半天才发现是硬件bug。

4.5 外部中断EXTI

EXTI是STM32的外部中断控制器。它负责把GPIO引脚上的电平变化,转换成中断信号。

配置EXTI的步骤:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为输入模式
  3. 将GPIO连接到EXTI线(通过SYSCFG_EXTICR寄存器)
  4. 配置EXTI触发方式(上升沿、下降沿、双边沿)
  5. 使能EXTI中断
  6. 配置NVIC优先级

代码示例:

void EXTI_Config(void)
{
    // 使能时钟
    RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    
    // 配置PA0为输入
    GPIOA->MODER &= ~(0x3 << 0);  // 00: 输入模式
    
    // 连接PA0到EXTI0
    SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
    SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;
    
    // 配置下降沿触发(汽车上常用,抗干扰好)
    EXTI->FTSR |= EXTI_FTSR_TR0;
    
    // 清除挂起位
    EXTI->PR |= EXTI_PR_PR0;
    
    // 使能中断
    EXTI->IMR |= EXTI_IMR_MR0;
    
    // 配置NVIC优先级
    NVIC_SetPriority(EXTI0_IRQn, 2);  // 抢占优先级2
    NVIC_EnableIRQ(EXTI0_IRQn);
}

注意:EXTI线0-15对应GPIO的PIN0-15。但每个EXTI线只能连接一个GPIO引脚。比如EXTI0只能连接PA0、PB0、PC0...中的一个。不能同时用PA0和PB0触发EXTI0。

4.6 事件触发与唤醒

事件和中断的区别,我一句话说清楚:

  • 中断:CPU必须响应,执行ISR。
  • 事件:CPU可以不响应,只触发硬件动作(如DMA传输、定时器启动)。

在汽车级低功耗设计中,事件唤醒特别重要。比如:

  • 车门打开事件唤醒ECU
  • CAN总线活动事件唤醒控制器
  • 按键按下事件唤醒显示屏

配置事件唤醒的代码:

void Event_Wakeup_Config(void)
{
    // 配置EXTI为事件模式(不是中断模式)
    EXTI->EMR |= EXTI_EMR_MR0;  // 使能事件
    
    // 配置唤醒源
    PWR->CSR |= PWR_CSR_EWUP1;  // 使能PA0作为唤醒源
    
    // 进入停止模式
    PWR->CR |= PWR_CR_LPDS;     // 低功耗深度睡眠
    PWR->CR |= PWR_CR_CWUF;     // 清除唤醒标志
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
    __WFI();  // 等待事件唤醒
}

我个人经验:事件唤醒的响应时间一般在几十微秒级别。如果要求更快的唤醒,可以考虑用SRAM保持模式。

4.7 实战:多级中断嵌套处理

好了,理论讲完了。咱们来点实战。

假设一个汽车门控系统:

  • 中断1:CAN接收中断(优先级1,子优先级0)——最紧急
  • 中断2:车门开关检测(优先级2,子优先级0)——中等
  • 中断3:LED闪烁定时器(优先级3,子优先级0)——最低

代码实现:

// 中断优先级分组:Group_2(2位抢占,2位子优先级)
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

// 配置CAN中断(最高优先级)
NVIC_SetPriority(CAN1_RX0_IRQn, 1 << 4 | 0);  // 抢占1,子0
NVIC_EnableIRQ(CAN1_RX0_IRQn);

// 配置GPIO中断(中等优先级)
NVIC_SetPriority(EXTI0_IRQn, 2 << 4 | 0);     // 抢占2,子0
NVIC_EnableIRQ(EXTI0_IRQn);

// 配置定时器中断(最低优先级)
NVIC_SetPriority(TIM2_IRQn, 3 << 4 | 0);      // 抢占3,子0
NVIC_EnableIRQ(TIM2_IRQn);

// CAN中断服务函数
void CAN1_RX0_IRQHandler(void)
{
    // 这里可以打断其他所有中断
    // 处理CAN报文
    uint32_t data = CAN_Receive();
    Process_CAN_Message(data);
}

// 车门检测中断
void EXTI0_IRQHandler(void)
{
    if(EXTI->PR & EXTI_PR_PR0)
    {
        // 这里只能被CAN中断打断
        // 读取车门状态
        uint8_t door_status = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
        Update_Door_Status(door_status);
        
        EXTI->PR |= EXTI_PR_PR0;  // 清除标志
    }
}

// LED定时器中断
void TIM2_IRQHandler(void)
{
    if(TIM2->SR & TIM_SR_UIF)
    {
        // 这里可以被任何中断打断
        // 翻转LED
        GPIO_ToggleBits(GPIOB, GPIO_Pin_0);
        
        TIM2->SR &= ~TIM_SR_UIF;  // 清除标志
    }
}

关键点:多级中断嵌套时,一定要考虑堆栈深度。每个中断嵌套一层,堆栈就多占几十字节。我曾经在项目中因为堆栈溢出导致系统随机死机,排查了三天才发现是中断嵌套太深,堆栈不够用。

我建议的做法:

  • 中断嵌套不超过3层
  • 每个ISR尽量短小,把耗时操作放到任务中
  • 在ISR中不要调用printf、malloc等不可重入函数
  • 使用volatile修饰共享变量

嗯,中断系统就讲到这里。下一章咱们聊聊定时器,那又是另一番天地了。