4、硬件层面优化(二):中断控制器设计(NVIC、GIC、PLIC)与优先级抢占策略

好,我们接着聊硬件层面的优化。上一讲我们聊了中断源和信号路径,今天重点落在中断控制器上。说白了,中断控制器就是CPU的“秘书”,它决定哪个中断能进、哪个得排队、哪个能插队。

我个人习惯把中断控制器比作一个会议室的门口。NVIC、GIC、PLIC就是三种不同风格的“门卫”。它们的设计哲学完全不同,直接决定了你的系统延迟能做到多低。

4.1 三种主流中断控制器架构

先看个总览表格,心里有个底:

特性 NVIC (Cortex-M) GIC (Cortex-A) PLIC (RISC-V)
中断源数量 最多240个 最多1020个 可配置,通常1024个
优先级位数 8位可配置(实际使用4位常见) 8位(GICv2)或16位(GICv3) 可配置,通常7位
抢占模型 硬件原生支持嵌套抢占 硬件支持,但需软件配合 纯软件管理,硬件只做仲裁
中断分组 抢占优先级+子优先级 Group优先级+子优先级 无分组,单一优先级
典型延迟 12个时钟周期左右 几十到上百周期 取决于软件处理,通常较慢

4.2 NVIC:Cortex-M的硬实时利器

NVIC是我最喜欢的中断控制器,没有之一。为什么?因为它把“确定性”做到了极致。

核心设计:每个中断都有独立的优先级寄存器。硬件自动比较当前中断优先级和待处理中断的优先级。如果新来的中断优先级更高,硬件直接触发抢占——注意,是硬件自动完成的,不需要软件干预。

我在项目中遇到过一个问题:一个电机控制应用,要求PWM中断必须在5微秒内响应。当时用的Cortex-M4,NVIC的抢占优先级设了4位,子优先级设了4位。我把PWM中断的抢占优先级设为0(最高),其他所有中断设为1或更低。结果延迟稳定在1.2微秒以内,非常漂亮。

关键配置代码(CMSIS标准):
// 设置PWM中断为最高抢占优先级
NVIC_SetPriority(TIM1_UP_IRQn, 0);  // 抢占优先级0,子优先级0

// 设置UART中断为较低优先级
NVIC_SetPriority(USART1_IRQn, 0x30); // 抢占优先级3,子优先级0

// 使能中断
NVIC_EnableIRQ(TIM1_UP_IRQn);
NVIC_EnableIRQ(USART1_IRQn);

嗯,这里要注意:NVIC的优先级分组寄存器(SCB->AIRCR)决定了你使用多少位做抢占优先级、多少位做子优先级。我个人习惯用4位抢占+4位子优先级,这样有16个抢占等级,够用了。

避坑指南:我曾经犯过一个错误——把NVIC的优先级分组设成了7位抢占+1位子优先级。结果发现只有2个子优先级,根本不够用。后来改成4+4,世界清净了。建议你根据实际中断数量来选分组,别贪多。

4.3 GIC:多核系统的中断交响乐

GIC是ARM为Cortex-A系列设计的中断控制器。它比NVIC复杂得多,因为要处理多核、虚拟化、安全世界等问题。

GICv2 vs GICv3:

  • GICv2:最多8个PE(处理单元),中断ID范围0-1019
  • GICv3:最多256个PE,中断ID范围0-65535,支持直接注入中断(LPI)

GIC的抢占策略和NVIC不太一样。GIC支持两种中断模型:

  1. 1-N模型:一个中断只发给一个CPU。适合确定性要求高的场景。
  2. N-N模型:一个中断可以发给多个CPU。适合负载均衡场景。

我个人在调试一个4核A72系统时,发现GIC的优先级抢占有个坑:GIC的抢占是在中断确认(IAR寄存器读取)之后才生效的。也就是说,如果两个中断同时到达,CPU先确认一个,然后才会检查另一个的优先级。这会导致一个微小的窗口期——大约几个总线周期。

注意:GICv3引入了一个新特性——抢占掩码(Preemption Mask)。你可以设置一个阈值,只有优先级高于阈值的中断才能抢占当前中断。这在高实时性系统中非常有用。比如,你可以把阈值设为0x80,那么只有优先级低于0x80的中断才能互相嵌套,优先级高于0x80的中断(比如NMI)则不受限制。

4.4 PLIC:RISC-V的软件化中断管理

PLIC是RISC-V架构的中断控制器。说实话,我第一次接触PLIC时有点不适应——它把很多硬件该做的事交给了软件。

PLIC的核心设计:

  • 每个中断源有一个优先级寄存器(通常0-7,0表示禁用)
  • 每个HART(硬件线程)有一个中断使能位图
  • 每个HART有一个优先级阈值寄存器
  • 中断仲裁:硬件只做简单的优先级比较,不处理抢占

你想想看,这意味着什么?意味着抢占逻辑完全由软件实现。硬件只告诉你“有中断来了”,至于要不要抢占当前中断,那是软件的事。

我在一个RISC-V项目中,需要实现类似NVIC的硬件抢占效果。当时我写了一个中断分发器,在中断入口处检查当前中断优先级是否高于正在执行的中断。如果是,就保存上下文并执行新中断;否则,把新中断挂起。

PLIC中断处理伪代码:
void plic_handler(void) {
    uint32_t irq = PLIC_CLAIM;  // 读取当前最高优先级中断
    
    // 检查是否允许抢占
    if (irq_priority[irq] > current_priority) {
        // 保存当前上下文
        save_context();
        current_priority = irq_priority[irq];
        
        // 执行中断服务
        handle_irq(irq);
        
        // 恢复上下文
        current_priority = previous_priority;
        restore_context();
    } else {
        // 挂起中断,稍后处理
        pend_irq(irq);
    }
    
    PLIC_COMPLETE = irq;  // 通知PLIC处理完成
}

这种软件化设计的优点是灵活,缺点是延迟不可控。我曾经测过一个PLIC系统,在最坏情况下,中断响应延迟能达到几百微秒——因为软件要处理优先级比较、上下文切换等操作。

4.5 优先级抢占策略的实战选择

讲完三种控制器,我们来聊聊怎么选策略。我个人总结了三条原则:

  1. 硬实时任务用固定优先级抢占:比如电机控制、PWM生成。把最高优先级给它们,其他任务靠边站。
  2. 软实时任务用轮转或时间片:比如网络协议栈、文件系统。用子优先级或软件调度来实现公平。
  3. 非实时任务用后台处理:比如日志输出、统计计算。放在最低优先级,有空才做。

我曾经在一个项目中,把中断优先级分成了三层:

层级 抢占优先级 典型中断 响应时间要求
紧急 0-3 PWM、ADC过采样、故障检测 < 5 μs
普通 4-7 UART、SPI、I2C < 100 μs
后台 8-15 定时器、RTC、DMA完成 < 1 ms

这个分层策略帮我解决了一个大问题:之前UART中断偶尔会打断PWM中断,导致电机抖动。把PWM放到最高优先级后,问题彻底消失。

一个小技巧:如果你用的是NVIC,可以利用“子优先级”来做中断间的公平调度。比如,把两个同优先级的UART中断设成不同的子优先级,这样它们不会互相抢占,而是按顺序执行。我经常用这个方法来避免中断风暴。

4.6 总结与避坑

好了,这一讲的核心内容就这些。我最后再啰嗦几句:

  • NVIC适合单核硬实时系统,延迟低、确定性好。
  • GIC适合多核复杂系统,功能丰富但配置复杂。
  • PLIC适合追求灵活性的RISC-V系统,但需要软件配合。

我曾经踩过一个坑:在一个GICv2系统上,我忘了配置中断路由寄存器(GICD_ITARGETSR),结果中断全发到了CPU0,其他三个核闲着。后来加上路由配置,系统吞吐量提升了3倍。嗯,细节决定成败。

下一讲我们会聊中断延迟的软件优化,包括中断服务函数怎么写才能最快、中断嵌套怎么管理。到时候见。