
1. 先把RISC-V的中断链路画出来如果你最近在调RISC-V的中断十有八九会被PLIC和APLIC这两个缩写搞得头大。两个都是中断控制器都负责管理外设中断的优先级名字长得像但底层的实现思路其实很不一样。这篇文章我打算把自己折腾PLIC和APLIC的全过程以及研究RISC-V中断优先级机制的一些笔记整理成一份能直接对着写代码的实战参考。这篇文章适合正在做RISC-V内核、驱动、固件开发的工程师也适合想搞明白Linux中断子系统irqchip的人。看完你能解决这些具体问题PLIC里的priority和threshold到底怎么配合、为什么中断来了要先去claim再complete、APLIC和PLIC在优先级配置上的差异是什么、以及中断一直不进handler时应该按什么顺序排查。1.1 中断控制器在整条链路中的位置RISC-V体系里的中断不是一张网而是一条链。外设中断产生后并不是直接捅进CPU核而是先汇聚到一个中断控制器由中断控制器做一轮“筛选”选出当前最值得处理的那一个再通过一根物理中断线通知CPU。这根线通常是IRQ引脚CPU核收到信号后会去查自己的CSR寄存器确认是哪种中断来了再跳转到对应的trap入口。整个链条大致是这样的处理器外设 → 中断控制器PLIC/APLIC → CPU核IRQ引脚 → mip/mie等CSR判定 → trap handlerPLIC和APLIC就处在中间这一层它们干的事情本质上是一件事把众多外设中断源仲裁出一个最重要的告诉CPU。至于CPU收到之后怎么处理、要不要嵌套、要不要延迟这些决策权在软件手里不在中断控制器手里。上一句值得多读一遍这是理解RISC-V中断体系和MCU中断体系最大的不同点。MCU里常见的NVIC通常是全自动的中断控制器不只做仲裁还会控制优先级抢占、尾链、中断延迟等一堆细节。RISC-V的PLIC/APLIC则相对“老实”它们保证的是“有中断时我按优先级告诉你该处理谁”但绝不替软件决定“这个中断能不能打断当前正在处理的那个”。1.2 CPU核侧的几个关键开关mstatus、mie、mip在深入PLIC/APLIC之前得先把CPU核侧的开关理清因为很多中断“不生效”的问题其实不是中断控制器配置错了而是CPU核侧的开关没打开。RISC-V里和中断直接相关的寄存器主要是mstatus、mie、mip。mstatus里有一个全局中断开关位MIE它管住的是M模式下所有中断的进入资格相当于总闸。mie是各中断源的本地开关比如MEIE对应M模式外部中断MTIE对应M模式定时器中断MSIE对应M模式软件中断。mip则是中断状态寄存器对应位为1表示有对应的中断请求在等待。看中断请求是否送达CPU核最直接的方式是看mip中的external interrupt pending位。在M模式下是MEIP在S模式下是SEIP。这里有个坑外部中断请求到达后mip中的pending位会一直保持为1但如果mstatus.MIE被清零了CPU不会进入trap这个pending就被“挂起”住。这种状态下中断并没有丢失只是暂时被屏蔽了很符合RISC-V把控制权交给软件的设计哲学。机器模式还有一个非常实用的机制叫中断委托通过mideleg寄存器配置。mideleg的bit位对应中断原因编号比如外部中断在S模式下对应的原因是9如果把mideleg的bit 9写为1那么S-mode external interrupt就会直接委托给S模式处理不需要M模式先接管再转交。Linux跑在S模式日常看到的中断原因基本都是scause9就是这个委托机制在起作用。裸机S模式程序如果发现外部中断永远不进handler第一件要查的事就是mideleg有没有配好。1.3 中断仲裁与CPU处理顺序的分工很多刚开始接触的人会把PLIC/APLIC的优先级和CPU处理中断的顺序混在一起。其实这两者分工明确PLIC/APLIC只负责在众多外设中断里选出“哪一个外设”优先上报而一旦多个不同来源的中断同时到达CPU核比如外部中断、定时器中断、软件中断同时置位CPU先响应哪一个这个顺序由RISC-V架构规定通常按实现约定的固定优先级次序处理。也就是说外设与显示器之间的“优先排队”由PLIC/APLIC决定而“外部中断、软件中断、定时器中断之间的打架”由CPU核的trap逻辑决定。后者基本不用软件操心硬件会按约定选一个进入trap剩下的会保持pending等待下一次响应。理解了这条边界再去配置PLIC/APLIC时思路会清爽很多。2. PLIC的优先级仲裁核心寄存器与配置全解析PLIC全称是Platform Level Interrupt Controller是RISC-V里最常见的中断控制器。QEMU的virt机器、一大堆国产SoC、SiFive相关平台都是PLIC。先强调一个最容易踩的坑PLIC里priority数值越大优先级越高数值0表示禁止该中断源。这和ARM NVIC“数值越小优先级越高”的直觉是反的我第一次调的时候就在这里栽过跟头。2.1 PLIC寄存器地图PLIC的寄存器布局是按功能分区排布的搞懂这张地图后面排查问题就能按图索骥。以常见的实现为例假设PLIC基地址为0x0C000000各功能块大致如下功能偏移说明Priority Registers0x000000每个中断源一个32位寄存器按中断号顺序排列Pending Registers0x001000每32个中断源用一个word表示是否有未处理请求Enable Registers0x002000按context分块每个context有独立的enable位Priority Threshold0x200000按context分块每个context有独立的thresholdClaim/Complete0x200004按context分块读取claim获取中断号写入complete结束服务注意PLIC里引入了context的概念。一个context可以理解为一个“接收中断的目标”一般是“一个CPU核心一种权限模式”的组合。比如双核SoC每个核有M和S两种模式PLIC内部就有4个context。Linux通常只用S-mode context所以配置PLIC时enable和threshold都要选对context否则会出现“中断配置给了核0的S模式但当前运行在核1”这种错位情况。2.2 优先级到底怎么比大小PLIC的仲裁逻辑用一句话概括在所有处于pending状态的中断源中选出priority数值最大、且大于当前context threshold的那个中断源。如果没有任何中断源的priority大于thresholdPLIC就不会上报中断请求。这个过程就像是所有外设在挂号窗口排队每个外设手里拿一个数字数字越大病情越急。窗口只看两件事一是这个外设的挂号数字是不是大于门口贴的“最低收治分”threshold二是排队的人里谁的数字最大。数字大的先被叫到号。有个细节值得注意多个中断源的priority相同时PLIC规范并没有强制规定先选哪个多数实现会选中断ID较小的那个。所以如果你的系统里有两个中断同时到达、优先级还一样高不要指望PLIC帮你分先后软件层面要有自己的处理顺序。priority寄存器写0表示永久屏蔽该中断源这个设计很巧妙。它让“配置优先级”和“屏蔽中断”两个操作合并成了一个动作驱动初始化时统一写priority值想要临时禁用某个中断直接把它改成0就行比单独维护enable位还要方便。2.3 claim/complete为什么非要这个“握手协议”PLIC最容易被忽视但也最关键的操作是claim/complete握手。CPU收到外部中断进入trap后必须从claim寄存器读一次这个读操作会返回当前context中最高优先级的中断号同时自动清除该中断源的pending状态。之后CPU处理完业务要把同一个中断号写回complete寄存器PLIC看到complete后才知道这个中断服务结束了可以继续上报后续中断。为什么PLIC不设计成让软件直接读pending寄存器来确认处理谁因为pending位是共享状态如果多个核同时读pending再各自决定处理很容易产生竞态。claim寄存器作为唯一入口硬件层面做了原子化处理保证每个中断号同一时间只被一个context读走省掉了软件加锁的麻烦。这里要强调complete务必写回claim读到的原始值。我见过有人在complete地址上随手写了0x1结果PLIC认为服务者搞错了对象后续中断全部卡住现象就是“第一个中断进去了后面的中断再也没响应”。这种问题非常隐蔽排查起来又费时间建议在代码里把claim和complete成对封装成宏避免手滑。2.4 threshold过滤低优先级中断的“一票否决”threshold是PLIC优先级机制里另一个重要旋钮。前面说了中断源的priority必须严格大于threshold才能被PLIC上报。threshold0时所有priority非0的中断都能通过threshold7假设priority最大值为7时所有中断都被挡住PLIC整体处于沉默状态。threshold最常见的用法是作为“全局中断抑制开关”。比如系统进入某个对实时性要求极高的阶段软件不希望任何外设中断来打扰可以直接把threshold设成最大值瞬间屏蔽所有PLIC中断。恢复时把threshold写回0即可不需要逐个操作每个中断源。另外一个巧妙用法是动态调整中断服务等级。比如系统里网卡中断priority为5串口中断priority为6正常情况下串口优先。如果某段时间系统在做大量网络转发希望网卡也别被串口压得太狠可以临时把threshold设为5这样priority为5的网卡中断也能正常通过而priority低于5的中断全部被挡在外面。相当于软件随时手动调节“收治线”这种方式比逐个修改中断优先级要灵活且快速。2.5 从初始化到响应一套完整的PLIC流程把前面这些串起来就是一个完整的PLIC初始化与中断响应用例。下面这段代码是裸机S模式环境下比较典型的写法QEMU virt机器和很多SoC都能直接套用。#define PLIC_BASE 0x0C000000UL #define PLIC_PRIORITY(i) (PLIC_BASE 0x000000 4 * (i)) #define PLIC_ENABLE(ctx,i) (PLIC_BASE 0x002000 0x80 * (ctx) 4 * ((i) / 32)) #define PLIC_THRESHOLD(ctx) (PLIC_BASE 0x200000 0x1000 * (ctx)) #define PLIC_CLAIM(ctx) (PLIC_BASE 0x200004 0x1000 * (ctx)) #define UART_IRQ 10 #define S_CONTEXT 0 void plic_init(void) { /* 1. 给UART中断源设置优先级这里用6 */ writel(6, PLIC_PRIORITY(UART_IRQ)); /* 2. 在S-mode context 0中使能UART中断 */ uint32_t reg readl(PLIC_ENABLE(S_CONTEXT, UART_IRQ)); reg | (1u (UART_IRQ % 32)); writel(reg, PLIC_ENABLE(S_CONTEXT, UART_IRQ)); /* 3. threshold设为0任何非0优先级都能通过 */ writel(0, PLIC_THRESHOLD(S_CONTEXT)); /* 4. 打开CPU侧的S模式中断开关 */ csr_set(sstatus, SSTATUS_SIE); csr_set(sie, SIE_SEIE); }中断trap进来之后处理逻辑的核心就是claim和completevoid s_trap_handler(void) { uint32_t cause read_csr(scause); if (cause 9) { /* S-mode external interrupt */ uint32_t irq readl(PLIC_CLAIM(S_CONTEXT)); /* 此时irq就是本次需要处理的中断号 */ handle_device_irq(irq); /* 处理完毕写回complete */ writel(irq, PLIC_CLAIM(S_CONTEXT)); } }第一次调PLIC时我习惯在claim之后立刻在串口上打印irq号确认硬件链路是通的再去做具体设备的中断服务。一个能跑通的claim/complete流程相当于把PLIC这一层验证完毕之后外设的中断逻辑出问题就能缩小排查范围。3. APLICAIA规范下的中断控制器新方案APLICAdvanced Platform Level Interrupt Controller来自RISC-V的AIAAdvanced Interrupt Architecture规范。如果你用的是比较新的RISC-V SoC或者新版QEMU可能会遇到它。APLIC从名字上看是PLIC的进阶版但实际用下来会发现它在设计哲学上做了不少改变优先级机制的简化尤其明显。3.1 为什么会有APLIC从PLIC到APLIC不是拍脑袋升级而是PLIC在实践中有几个不太好绕开的问题。第一是优先级配置的冗余PLIC为每个context都维护了一套完整的enable和priority配置如果平台上有很多CPU核光重复配置这些寄存器就要消耗大量软件工作。第二是PLIC不支持MSI中断它只能靠一根物理中断线来通知CPU不够灵活。第三是PLIC对中断触发类型没有明确配置能力软件很难统一处理电平触发和边沿触发带来的差异。AIA规范就是冲着这些问题去的APLIC作为AIA的重要组成部分专门优化了平台级中断的中投递与优先级处理。它既保留了传统中断控制器的能力又把MSI这种在PCIe等场景下被验证过的机制统一进来。3.2 direct模式和MSI模式投递方式的两种走法APLIC的工作模式分为direct模式和MSI模式这两种模式解决的是“中断选出来后怎么通知CPU核”这个问题。direct模式在功能上很像PLIC中断选出来之后直接通过硬件信号线通知CPU核CPU通过读取claim寄存器拿到中断号属于“物理线寄存器握手”的经典方式。这种方式延迟低、依赖少适合小型嵌入式系统。MSI模式则完全不同APLIC仲裁出一个中断后不是拉一根信号线而是通过总线写一个预设的内存地址把中断信息和中断号以写操作的形式“投递”到目标。这种方式在核数多、虚拟化场景下很有优势因为每个虚拟机或每个核都有自己的中断文件地址写操作天然带目的地语义减少了物理中断线的局限性。在MSI模式下通常还需要配合IMSICIncoming MSI Controller来接收和管理写入的中断信息。IMSIC为每个hart维护一组中断文件APLIC做的是平台级仲裁IMSIC做的是核级接收两者配合起来形成新的中断通道。3.3 APLIC在优先级机制上的变化APLIC在优先级机制上和PLIC有个重要区别也常被忽视PLIC为每个context都维护独立的priority配置每个CPU核可以对同一个中断源抱有不同优先级APLIC则在域domain级别统一维护每个中断源的priority也就是一个中断源只能有一个优先级所有target共用同一个配置。这意味着APLIC简化了配置模型。好处是初始化代码更简洁不需要按核按模式重复配置同一种东西坏处是失去了PLIC那种“同一外设对不同CPU核可以有不同紧急程度”的灵活性。APLIC还取消了PLIC里按context分配threshold寄存器的做法。APLIC仲裁时只比较中断源的priority值选出全局最高的那一个不再提供“某个target是否要设置门槛”这一选项。它通过target enable寄存器来实现类似过滤效果software在为目标使能中断时直接决定某target是否接收某中断源粒度比threshold更粗但语义更清晰。对于触发类型APLIC在sourcecfg寄存器里支持把每个中断源配置为level触发或edge触发。这对驱动编写很友好不用在外设侧猜测控制器到底识别什么类型的信号。PLIC规范里没有这个能力实践里通常依靠外设自行持续保持有效电平APLIC把这个模糊地带明确了。3.4 APLIC direct模式下的配置实操下面以direct模式为例给出一个APLIC初始化骨架。不同SoC的寄存器偏移会有些差异但几个关键域大同小异。#define APLIC_BASE 0x0C000000UL #define APLIC_DOMAINCFG (APLIC_BASE 0x0000) #define APLIC_SOURCECFG(i) (APLIC_BASE 0x0004 4 * (i)) #define APLIC_SETIPNUM (APLIC_BASE 0x000C) #define APLIC_CLRIPNUM (APLIC_BASE 0x0010) static void aplic_direct_init(uint32_t uart_irq, uint32_t trigger_type) { /* 1. 配置domaincfg为direct模式并使能domain */ uint32_t cfg readl(APLIC_DOMAINCFG); cfg ~DOMAINCFG_IVE_MSI; cfg | DOMAINCFG_EN; writel(cfg, APLIC_DOMAINCFG); /* 2. 配置中断源触发类型电平或边沿 */ writel(trigger_type, APLIC_SOURCECFG(uart_irq)); /* 3. 设置该中断源的优先级 */ writel(6, APLIC_PRIORITY(uart_irq)); /* 4. 使能对应target */ aplic_target_enable(uart_irq, TARGET_S_CONTEXT_0); /* 5. 打开CPU侧中断 */ csr_set(sstatus, SSTATUS_SIE); csr_set(sie, SIE_SEIE); }direct模式下CPU进入trap之后同样需要claim/complete来获取和释放中断。但APLIC对edge和level两种中断的clear策略不一致level触发的中断在外部信号撤销后APLIC硬件会自动清除pending状态edge触发的中断pending状态会锁存软件处理完必须写clripnum主动清除否则中断不会被再次触发这个区别很容易踩坑。GPIO按键这种典型edge中断如果忘了清clripnum按键第二次就再也触发不了中断用轮询读GPIO又一切正常。我第一次遇到这个现象时排查了很久后来想到APLIC的边沿锁存机制才意识到自己没按要求清pending。3.5 PLIC与APLIC对照表把两代中断控制器的核心差异列成一张表方便做技术选型时快速看。维度PLICAPLIC开发规范PLIC SpecAIA Speccontext概念多套独立配置域内统一配置每源优先级每个context各一份domain内一份threshold有per-context无触发类型配置不支持支持level/edge配置投递方式物理中断线direct模式或MSI模式MSI支持不支持支持配置复杂度高多核时重复配置多低域级统一灵活性可按核差异化配置全局一致选哪一套其实很大程度取决于SoC平台。老平台PLIC已经跑得很成熟Linux驱动也稳定新平台想支持MSI或需要简化多核配置APLIC是趋势。软件层面两者不是完全互斥的驱动可以抽象一层把优先级配置和claim/complete操作统一封装起来换平台时只改底层寄存器操作。4. 优先级实战配置顺序、调试心得与避坑清单前面讲了原理和寄存器这一章进入实战环节。我在多个RISC-V平台上调过PLIC和APLIC也帮同事排查过不少中断问题积累了一些可以直接参考的经验。优先级看起来只是一个“比大小”的逻辑但实际项目中真正出问题的往往不是“比大小”而是配置顺序、上下文、触发方式这些边角料。4.1 设备树和Linux irqchip从哪里入手在Linux系统里中断控制器对应的设备树节点会是第一步线索。PLIC节点通常长这样plic: interrupt-controllerc000000 { compatible sifive,plic-1.0.0; #address-cells 0; #interrupt-cells 1; interrupt-controller; reg 0x0 0xc000000 0x0 0x4000000; interrupts-extended cpu0_intc 11, cpu0_intc 9; };interrupts-extended里的两个数字值得解释一下cpu0_intc就表示CPU核的中断控制器11对应M-mode external interruptMEI9对应S-mode external interruptSEI。所以这个节点告诉系统PLIC同时连接到了核的M模式和S模式Linux跑在S模式时会使用后面的9号通道。如果设备树里是APLICcompatible通常是“riscv,aplic”之类。看到这个就可以确定当前平台走的是AIA路线。Linux里的irqchip驱动irq-plic.c和irq-aplic.c分别对应两套实现驱动注册时会创建irq domain把硬件中断源映射成Linux的虚拟中断号。排查问题时先确认设备树节点是否存在、中断号是否正确能过滤掉一大半低级错误。特别是自己移植内核时中断控制器的设备树节点如果没有被正确解析后面所有request_irq都会返回失败但平台代码本身可能不报错。4.2 申请中断时priority和enable的顺序问题驱动的标准套路是先request_irq再操作中断控制器寄存器但优先级相关配置有一个推荐顺序先配中断控制器侧的优先级和enable再打开CPU侧全局中断开关。原因很实际如果CPU侧全局中断开关处于打开状态而中断控制器侧的中断还没有配好优先级和enable外设一旦产生中断CPU会立刻收到一个“没有关联处理逻辑”的中断请求。很多平台对这种情况的处理是直接进入trap然后挂掉或者进入一个空handler表现为系统随机卡死。反过来如果先把中断控制器配置好CPU侧总开关先关着中断来了也只是在pending状态等待等软件准备好之后再把全局中断打开中断就会按预期触发handler。这个顺序等同于“先挂好号码牌再开放就诊窗口”不会出现有人冲进诊室但医生还没到位的尴尬。我曾经见过一个调试案例驱动代码里先执行了local_irq_enable()再写PLIC的enable位结果每次加载驱动后系统就异常就是不稳定的随机死机。把顺序调整为先配PLIC再开CPU中断后现象立刻消失。这种问题平时不起眼关键时刻会浪费大量时间。4.3 想要更高优先级的中断软件嵌套要注意什么RISC-V的PLIC/APLIC在硬件层面并不会自动打断当前正在处理的中断handler。CPU进入trap时会把sstatus.SIE清零也就是说在默认的trap流程里整个handler执行期间所有外部中断都被挡在外面。如果系统里某个高优先级中断必须及时响应而另一个较慢的低优先级handler还在慢慢处理业务就需要软件手动做“嵌套中断”。嵌套中断的做法不复杂在低优先级handler保存完现场、拿到不需要再改动的数据之后手动把sstatus.SIE置1重新打开中断门。此时如果来了更高优先级的外部中断CPU就会先跳进那个高优先级handler处理完再回到低优先级handler继续执行。但这里有几个硬性风险必须提前想清楚。首先嵌套中断的handler必须可重入低优先级handler里如果持有某个自旋锁高优先级handler又去抢同一把锁系统会直接死锁。其次中断上下文里不能调用可能导致睡眠的函数否则嵌套场景下调度器状态会出现不可预期的问题。最后嵌套层数不要无限制放开每嵌套一层都要消耗额外的栈空间嵌入式系统栈通常不大嵌套太多会栈溢出。一个实际可用的做法是只在确有必要的中断源上开启嵌套比如高精度的PWM同步信号、对时延极其敏感的协议包一般外设中断保持默认的非嵌套模式就好。千万不要为了“提高响应性能”而全局开启嵌套性能没提升多少稳定性反而先崩了。4.4 中断不响应的排查清单我在各种群里看到最多的求助就是“为什么我的中断不触发”。这个问题涉及的环节多整理出一份排查清单很有价值。下面这个顺序是我个人调试时固定的检查路径从外设侧到控制器侧再到CPU侧一层一层排除。检查点检查方法常见原因外设是否真产生了中断读外设状态寄存器或直接看PLIC pending位外设配置不对中断信号根本没到达控制器中断源priority是否为0查看PLIC priority寄存器priority写0等于永久屏蔽enable位是否置1查看对应context的enable寄存器enable被漏配或配置到了别的contextthreshold是否过高查看context thresholdthreshold过大把需要的中断过滤掉了触发类型是否正确APLIC配置sourcecfglevel/edge选错pending状态不稳定CPU核侧全局中断是否打开查看mstatus/sstatus的SIE位进trap后忘了开或初始化时没开委托是否配置正确检查mideleg外部中断还留在M模式S模式收不到claim后是否complete检查handler是否写回completecomplete缺失导致后续中断被卡死还有个很实用的排查技巧在Linux命令行下直接用devmem2读PLIC的pending寄存器快速判断外设中断有没有到达控制器。比如UART中断号是10pending寄存器地址就是0x0C001000读出来看bit 10是不是1如果外设触发后这个bit一直为0说明问题在外设或连接路径上不用再往控制器方向查了。如果是APLIC平台还多了一个软件注入中断的调试方法。APLIC提供了setipnum寄存器直接写一个中断号进去就能模拟该中断源的挂起。这对我这种习惯用硬件断点调试的人特别香因为它可以绕过外设单独验证控制器到CPU核这一段链路是否正常。PLIC没有这个能力之前排PLIC时只能靠反复触发外设来验证。4.5 关于“优先级”的个人体会RISC-V中断优先级机制从一开始就把“决定权”交给了软件。硬件负责仲裁出当前最高优先级的中断源但软件可以随时通过threshold、enable、全局中断开关来干预甚至阻断整个流程。这跟很多MCU中断控制器大包大揽的风格很不一样。我在实际调板过程中的体会是不要把PLIC/APLIC当作一个会替你管理一切的中断管家而要把它理解成一个“很守规矩的调度员”。你告诉它哪些中断重要它就会按重要程度通知你但你自己要决定开关门的时间要决定什么时候接诊、什么时候拒绝进门。摸清这个边界之后优先级机制的配置和维护都会变得很自然。最后再分享一个小技巧写驱动时把priority值定义成枚举或宏不要散落裸数字。比如用IRQ_PRIO_CRITICAL、IRQ_PRIO_NORMAL、IRQ_PRIO_DEBUG这类命名后面调整优先级分配方案时只需要改头文件不用在几十个驱动文件里找数字。我在一个项目里因为把priority值写成了魔法数字后期做实时性优化时花了接近一天时间做全局替换那种滋味确实不好受。