2. 中断控制器基础:中断向量表、中断优先级、中断嵌套、中断屏蔽与使能、中断响应流程

好,咱们进入中断控制器的核心内容。说实话,中断控制器这玩意儿,我刚开始做嵌入式那会儿,总觉得它就是个“信号中转站”,没啥技术含量。直到有一次,我在一个工业控制项目里,因为中断优先级没配好,导致系统在高负载下频繁死机……嗯,从那以后,我再也不敢小看它了。

今天咱们就把中断控制器的几个关键概念掰开揉碎了讲。你想想看,CPU 是个“单线程”的家伙,它一次只能干一件事。但外设们可不管这些,随时都可能“喊”它。这时候,中断控制器就是那个“秘书”,帮 CPU 排好队、分好轻重缓急。

2.1 中断向量表

中断向量表,说白了就是一张“地址映射表”。每个中断源都有一个唯一的编号,叫中断号。CPU 收到中断后,会根据这个中断号,去向量表里找到对应的处理函数地址,然后跳过去执行。

我个人习惯把向量表理解成一个函数指针数组。比如在 ARM Cortex-M 系列里,向量表通常放在 Flash 的起始地址(0x00000000)。

// 伪代码示意:中断向量表
typedef void (*isr_func_t)(void);

isr_func_t vector_table[256] = {
    &Reset_Handler,      // 0: 复位
    &NMI_Handler,        // 1: 不可屏蔽中断
    &HardFault_Handler,  // 2: 硬件错误
    &MemManage_Handler,  // 3: 内存管理
    &BusFault_Handler,   // 4: 总线错误
    &UsageFault_Handler, // 5: 用法错误
    // ... 后面是外设中断
    &TIM2_IRQHandler,    // 28: 定时器2
    &USART1_IRQHandler,  // 37: 串口1
};

这里有个坑,我必须要提醒你:向量表的地址必须对齐。Cortex-M 要求向量表按 512 字节对齐(也就是地址的低 9 位为 0)。我曾经见过一个同事,把向量表放在 RAM 里做动态重映射,结果忘了对齐,系统一进中断就跑飞了。

注意: 在 Cortex-M 上,如果你要动态修改向量表(比如做 bootloader),记得先设置 SCB->VTOR 寄存器,把向量表基地址指过去。而且,这个地址必须按 512 字节对齐。

2.2 中断优先级

中断优先级,就是给中断排个“座次”。高优先级的中断可以打断低优先级的中断。这个机制在实时系统中至关重要。

不同的芯片对优先级的实现不太一样。我拿最常见的 ARM Cortex-M 来举例。它支持 8~256 级优先级(具体看芯片实现),而且有个很巧妙的设计:抢占优先级子优先级

优先级分组 抢占优先级位数 子优先级位数 说明
NVIC_PriorityGroup_0 0 4 只有子优先级,无抢占
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级抢占,无子优先级

你可能会问:“为什么要分这么细?” 其实就是为了灵活。比如,你可以把定时器中断设为最高抢占优先级,保证它永远不被其他中断打断。而把串口中断设为低优先级,允许它被其他更紧急的中断打断。

我的经验: 在配置优先级时,我习惯把时间敏感的中断(如定时器、PWM)放在高抢占优先级,把数据量大的中断(如 DMA、USB)放在低优先级。这样既能保证实时性,又不会因为频繁进出中断而浪费 CPU。

2.3 中断嵌套

中断嵌套,就是高优先级中断打断低优先级中断的执行。这个机制让系统能及时响应紧急事件。

举个例子:假设系统正在处理串口接收中断(优先级 5),这时候定时器中断(优先级 1)来了。因为定时器优先级更高,CPU 会暂停串口中断处理,先去执行定时器中断。等定时器中断处理完了,再回来继续处理串口中断。

但这里有个问题:嵌套深度。如果中断嵌套太深,栈空间可能会被耗尽。我记得在一个项目中,我用了 3 层嵌套,结果栈溢出导致系统崩溃。排查了好久才发现是栈大小没配够。

// 中断嵌套的栈使用示意
void USART1_IRQHandler(void) {
    // 进入时,栈已使用 100 字节
    // 处理中...
    // 此时 TIM2 中断发生,CPU 自动压栈
    // 栈使用增加到 200 字节
    // TIM2 处理完返回,栈恢复到 100 字节
    // 继续处理 USART1
}
警告: 中断嵌套深度建议控制在 3 层以内。每层嵌套至少预留 200 字节的栈空间。如果你用 RTOS,还要考虑任务栈和中断栈的共享问题。

2.4 中断屏蔽与使能

中断屏蔽,就是暂时“关掉”某个中断。使能就是“打开”它。这个操作在驱动开发中太常见了。

在 Cortex-M 上,中断使能/屏蔽是通过 NVIC 的寄存器来控制的。每个中断对应一个 bit。

// 使能中断
NVIC_EnableIRQ(USART1_IRQn);  // 设置 NVIC->ISER[0] 的对应位

// 屏蔽中断
NVIC_DisableIRQ(USART1_IRQn); // 设置 NVIC->ICER[0] 的对应位

// 设置挂起(软件触发中断)
NVIC_SetPendingIRQ(USART1_IRQn); // 设置 NVIC->ISPR[0]

// 清除挂起
NVIC_ClearPendingIRQ(USART1_IRQn); // 设置 NVIC->ICPR[0]

这里有个细节:中断屏蔽和中断挂起是两回事。屏蔽只是不让 CPU 响应,但中断请求信号仍然会被记录下来(挂起位会置 1)。等你重新使能中断后,CPU 会立即响应这个挂起的中断。

我曾经犯过一个错误:在临界区里屏蔽了中断,但忘了清除挂起位。结果退出临界区后,中断一下子涌进来,系统直接卡死。所以,在操作中断屏蔽时,一定要考虑挂起位的处理

小技巧: 在写临界区代码时,我习惯用“保存-屏蔽-恢复”的模式:
uint32_t primask = __get_PRIMASK(); // 保存当前中断状态
__disable_irq();                     // 屏蔽所有中断
// ... 临界区代码 ...
__set_PRIMASK(primask);              // 恢复之前的状态
这样能避免因为中断状态改变而导致的 bug。

2.5 中断响应流程

最后,咱们把整个流程串起来。一个完整的中断响应,大致分这么几步:

  1. 中断请求:外设产生中断信号,发送给中断控制器。
  2. 中断仲裁:中断控制器根据优先级,决定哪个中断先被响应。
  3. CPU 响应:CPU 完成当前指令后,检测到中断请求。
  4. 自动压栈:CPU 自动将当前程序的上下文(PC、LR、PSR、R0-R3、R12)压入栈中。
  5. 向量读取:CPU 从中断向量表中读取对应中断号的处理函数地址。
  6. 跳转执行:CPU 跳转到中断服务函数(ISR)执行。
  7. 中断返回:ISR 执行完毕后,执行中断返回指令(如 BX lr),CPU 自动出栈恢复上下文。

在 Cortex-M 上,这个过程是硬件自动完成的,非常高效。但有一点要注意:中断延迟。从外设发出中断请求,到 CPU 开始执行 ISR 的第一条指令,这个时间就是中断延迟。

影响中断延迟的因素有:

  • 当前指令的执行时间(比如正在执行除法或浮点运算)
  • 中断屏蔽状态
  • 更高优先级中断的存在
  • 总线访问延迟(比如正在访问低速外设)
核心要点: 中断响应流程中,最容易被忽视的是“自动压栈”这一步。在 Cortex-M3/M4 上,压栈需要 12 个时钟周期。如果你的 ISR 要求极低延迟(比如 1us 以内),可以考虑使用“咬尾中断”或“晚到中断”等硬件优化特性。

好了,中断控制器的基础就讲到这里。这些东西看起来简单,但实际项目中处处是坑。下一节咱们会深入具体的中断控制器驱动配置,到时候我会拿实际代码来演示。记住一句话:中断配置无小事,一个优先级设错,可能整个系统就崩了