资讯动态

RISC-V中断优先级机制详解:从PLIC到APLIC的仲裁与迁移

发布时间:2026/9/11 14:04:23 来源:尧图企业网站定制
如果你是从 ARM Cortex-M 或者 x86 嵌入式平台切过来做 RISC-V 系统软件的第一次面对中断优先级时大概率是懵的翻遍指令集手册找不到“优先级寄存器”看芯片手册却冒出 PLIC、APLIC 这些缩写。我当初第一块 RISC-V 开发板就是在这上面栽了跟头——配置好中断源后发现低优先级设备疯狂打断高优先级处理后来才明白RISC-V 的优先级机制和 NVIC 完全是两套思路。这篇文章不打算从架构师视角讲规范条文而是从一个做驱动、调板子、被中断时序折磨过的工程师视角把 PLIC 和 APLIC 的优先级链路、仲裁逻辑、寄存器配置、实测方法、以及迁移到 AIA 时代的踩坑点全部梳理一遍。内容既适合刚接触 RISC-V 中断模型的嵌入式开发者也适合正在做 SoC 中断控制器选型或底层 BSP 移植的人。你会看到完整的中断处理代码、QEMU 上的实验方法以及我在多核上下文切换、阈值屏蔽这些细节上总结出来的经验。1. 先搞清一件事优先级不在 CPU 核里1.1 指令集只定义了“接口”不管排优先级RISC-V 指令集对中断的定义非常克制。它用 MTVEC、MSTATUS、MIE/MIP 这些 CSR 规定了中断如何进入异常向量、如何开关全局中断、如何 pending 一个外部中断请求但至于“同一个时刻有两个外设同时发中断哪个先处理”指令集一个字都没提。拿外部中断来说吧。CPU 在自己的 MIE/MIP 里只看到一位MEIPMachine External Interrupt Pending用来告诉软件“有一个外部中断来了”。这一位是 0 还是 1由外部的中断控制器通过一根信号线驱动。真正决定这一位该不该拉起来、拉起来之后代表哪个外设的是平台级中断控制器。这和 ARM Cortex-M 的 NVIC 完全不同。NVIC 把优先级分组、抢占、子优先级这些概念都固化在处理器内部外设的中断请求经过固定的优先级仲裁后直接进内核。RISC-V 选择把这块彻底解耦让半导体公司自己定义 PLIC 或 APLIC这样 ISA 保持精简SoC 设计者也有自由空间。代价就是关于优先级的所有细节你得对着每一份芯片手册、每一版规范单独学。1.2 一条完整的中断传递链路里有四个角色在我被 PLIC 折磨之前脑子里对“中断”的理解基本就是“外设拉一根线到 CPU”。后来在 RISC-V 上才发现完整的链路是四段式的外设产生中断信号一般是电平或边沿。平台级中断控制器PLIC 或 APLIC收集所有外设中断源结合优先级、使能位、阈值做仲裁选出“当前该处理的一个或一类”然后拉高 CPU 对应的外部中断线。CPU 收到MEIP或SEIP信号后查 MTVEC 进入 trap保存上下文。软件通过中断控制器提供的 claim/complete 寄存器或 IMSIC 寄存器拿到真正的中断源 ID分发处理最后向控制器确认完成。所以你看优先级的“决策”发生在第二步也就是中断控制器内部。CPU 核只是被动接收结果。理解了角色分工再看 PLIC 和 APLIC 就不容易乱。2. PLIC 优先级机制的硬件实现与配置套路2.1 从外设到 CPU 的一条裁决链priority、pending、enable、threshold标准 PLIC 的寄存器布局并不复杂核心就是下面这几类寄存器区域作用说明Priority每个中断源一个设置中断源优先级常见实现只支持 3 位有效位0 表示禁用Pending位图记录哪些中断源有请求硬件根据外设信号自动置位Enable按上下文排列的位图决定某个中断源对某个上下文是否开启一个上下文对应一个 Hart 的一个特权模式Threshold每个上下文一个设置该上下文的优先级门槛源优先级必须高于阈值才可能被选中Claim/Complete每个上下文一个读取当前最高优先级中断源处理完写回读清零 pending写触发完成仲裁逻辑用一个不等式就能概括pending 置位 enable 使能 priority threshold然后在这些满足条件的源里选 priority 最大的一个如果最大有并列再按中断源编号或其他实现定义的顺序仲裁。这里有几个容易搞反的点我必须单独强调一下。第一threshold 比较是“严格大于”如果你把阈值设为 7而你的系统优先级范围正好也是 0~7那等于把所有中断全屏蔽了没有源满足“priority 7”第二priority 的 0 值不是“最低优先级”而是“这个源被禁用”相当于 enable 位清零这个词义在 PLIC 语境里非常重要第三Priority 寄存器是全局的不是每个上下文一份而 Threshold 是每个上下文一份后面讲多核时你会看到这个差异带来的坑。2.2 Claim/Complete 为什么是 PLIC 的灵魂刚开始我习惯性地把 PLIC 当成一个“高级中断路由器”以为读一下 status 就能知道谁来了。但 PLIC 的设计其实是“读即取走”软件通过读取 Claim 寄存器不光得到当前最高优先级的中断源 ID还同时把该源的 pending 位清掉。也就是说同一时刻这个中断源只有一个人能“认领”走。处理完业务后软件必须把同一个源 ID 写回 Claim/Complete 寄存器通知 PLIC“我处理完了你可以允许新请求再次进来了。”如果只 Claim 不 Complete后续该源再来了也不会重新触发表现为中断丢失或者中断再也不来。为什么规范要设计成这样而不是像 ARM 那样靠硬件自动清除 pending我的理解是PLIC 需要同时服务多个 Hart 和多个特权模式如果不通过“读操作”来原子地完成选中与清除软件在多核环境下根本无法确认一个中断源是谁拿走的。顺手举个例子两个 Hart 的上下文同时等待中断外设源 5 和源 9 同时 pending优先级都是 7Hart A 去读自己的 Claim 拿到源 5Hart B 去读自己的 Claim 拿到源 9两者互不打扰。如果没有 claim 的原子性这种并行分发根本做不到。2.3 一段可直接参考的 PLIC 驱动初始化代码下面这段代码是我在裸机环境里常用的 PLIC 驱动骨架按标准 PLIC 地址偏移写的。不同 SoC 基地址不同但寄存器偏移是统一的你要做的就是把PLIC_BASE换成自己芯片手册里的值。#include stdint.h #define PLIC_BASE 0x0C000000UL #define PLIC_PRIORITY(base, src) ((base) 0x000004UL * (src)) #define PLIC_ENABLE_AREA(base) ((base) 0x002000UL) #define PLIC_THRESHOLD(base, ctx) ((base) 0x200000UL 0x04UL * (ctx)) #define PLIC_CLAIM(base, ctx) ((base) 0x200004UL 0x04UL * (ctx)) #define CONTEXT_M 0 // M-mode #define CONTEXT_S 1 // S-mode #define MAX_SOURCE 64 // 以你的 SoC 实际支持数为准 static inline uint32_t read32(uintptr_t addr) { return *(volatile uint32_t *)addr; } static inline void write32(uintptr_t addr, uint32_t val) { *(volatile uint32_t *)addr val; } void plic_source_set_priority(uint32_t src, uint32_t prio) { if (src 0 || src MAX_SOURCE) return; write32(PLIC_PRIORITY(PLIC_BASE, src), prio 0x7u); } void plic_source_enable(uint32_t ctx, uint32_t src) { uint32_t word src / 32u; uint32_t bit src % 32u; uintptr_t addr PLIC_ENABLE_AREA(PLIC_BASE) ctx * (MAX_SOURCE / 32u) * 4UL word * 4UL; write32(addr, read32(addr) | (1UL bit)); } void plic_threshold_set(uint32_t ctx, uint32_t threshold) { write32(PLIC_THRESHOLD(PLIC_BASE, ctx), threshold 0x7u); } uint32_t plic_claim(uint32_t ctx) { return read32(PLIC_CLAIM(PLIC_BASE, ctx)); } void plic_complete(uint32_t ctx, uint32_t src) { write32(PLIC_CLAIM(PLIC_BASE, ctx), src); }初始化顺序一般是这样先把所有中断源优先级清零防止残余 pending 干扰然后对你要用的源设置 priority、enable再设置阈值最后打开全局中断开关MSTATUS.MIE和MIE.MEIE。这里有个细节enable 寄存器区域的偏移是按上下文排列的位图每个上下文占用的字节数等于“最大中断源数 / 8 向上取整”我代码里用MAX_SOURCE / 32 * 4只是按 32 位位图算的具体看 SoC 支持多少源。中断处理函数的入口也很简单void external_irq_handler(void) { uint32_t src plic_claim(CONTEXT_M); if (src 0) { return; // 没有待处理源 } dispatch_device_irq(src); // 根据 src 查表分发 plic_complete(CONTEXT_M, src); }读 Claim 返回 0 表示当前没有可处理的中断这种情况偶尔会出现在多核同时抢中断的时候处理函数里要防御一下不然拿着 source 0 去 complete 会出问题。3. APLICAIA 时代的中断控制器重构了什么3.1 PLIC 的三个天花板逼出了 APLIC我在用 PLIC 做方案时遇到的麻烦远不止“没有抢占”这一点。第一个痛点是 PLIC 的上下文模型太呆板。它把所有中断源和所有 Hart 都放在一张大矩阵里每个源只能按“哪个 Hart 的哪个特权模式”来使能想做更细粒度的路由很难。第二个痛点是虚拟化支持先天不足。在虚拟化场景里虚拟机想要直接管理中断PLIC 需要软件做大量透传和模拟效率很低。第三个痛点是触发类型不可配。标准 PLIC 对电平触发和边沿触发的处理完全靠外部信号控制器自身没有 sourcecfg 这类配置寄存器遇到边沿脉冲太窄或者误触发你只能干瞪眼。所以 RISC-V 在 AIAAdvanced Interrupt Architecture规范里引入了新一套组件IMSIC、APLIC、ACLINT以及一批新的特权态 CSR。APLIC 的全称是 Advanced Platform-Level Interrupt Controller可以把它理解成“吸收了 PLIC 所有经验教训的下一代平台中断控制器”。3.2 Direct 与 MSI 两种交付模式优先级去哪了APLIC 最核心的变化是支持两种中断交付模式Direct 模式和 MSI 模式。Direct 模式逻辑上和 PLIC 接近APLIC 把仲裁出来的中断以电平/边沿信号的方式送给目标 Hart 的外部中断线。它保留了“每个中断源一个优先级”“每个域一个阈值”的仲裁框架用到的寄存器语义和 PLIC 类似只是换成了 IDCInterrupt Delivery Control这一组每目标寄存器来实现软件通过它读取当前最高优先级待处理源处理完成后按芯片手册定义的动作关闭本次交付。MSI 模式就有意思了。APLIC 不再直接拉中断线而是把每个中断源映射成一个独立的 MSI 写操作把一段内存写请求发给 IMSIC。IMSIC 在 CPU 侧负责接收这些 MSI 写入维护一个“中断文件”然后向 Hart 提起外部中断。优先级概念在这种模式下发生了转移APLIC 端主要把每个源绑定到唯一的向量号IMSIC 端按向量顺序或实现定义的策略选出当前要上报的中断软件通过读 IMSIC 的 topi 类寄存器直接拿到“当前最高优先级待处理中断”不再需要 PLIC 那种内存映射的 claim/complete 握手。我自己的体会是MSI 模式的最大价值在于“向量化”。传统 PLIC 把所有外部中断压缩成一个外部中断信号软件进 trap 后还要再去读 claim 才知道是谁。MSI 模式下每个源都有自己的向量处理逻辑可以在入口处直接按向量号分发省了一次额外 MMIO 读这对高吞吐外设场景非常友好。3.3 PLIC 与 APLIC 关键机制对比维度PLICAPLIC DirectAPLIC MSI IMSIC优先级仲裁源级 priority 与上下文 threshold保留类似架构按域和目标配置由 IMSIC 在 CPU 侧按向量顺序/策略呈现中断确认方式Claim 寄存器读取并清 pendingIDC 寄存器提供当前最高待处理源读 IMSIC topi 直接获得完成确认方式Complete 寄存器写回按实现完成 IDC 关闭动作写 EOI 或按 IMSIC 规范完成触发类型配置基本不可配sourcecfg 可配电平/边沿等同 Direct软件触发 pending不支持setipnum/clripnum 支持支持域隔离/多域弱上下文模型固定强domain 级配置强配合虚拟化更彻底对虚拟化支持一般较好最好这个表不是让你背而是做选型用的。如果只是简单的 MCU 类 SoCPLIC 完全够用代码简单、生态成熟。只要涉及多虚拟机、多域隔离或者对中断延迟要求极高的网络设备APLIC 加 IMSIC 基本是绕不开的。4. 实战验证中断优先级仲裁行为怎么测出来4.1 在 QEMU virt 上搭一个最小测试环境纸上谈兵没意思优先级到底怎么动的还是得上板或者上仿真。我建议你在 QEMU 的 riscv64 virt 机器上先做实验因为它自带一个标准 PLIC设备树节点完整调试起来不吃硬件。启动命令很简单qemu-system-riscv64 -machine virt -smp 2 -m 256M \ -bios fw_payload.elf -nographic想确认自己板子上的 PLIC 中断源编号可以先把设备树导出来看看qemu-system-riscv64 -machine virt -machine dumpdtbvirt.dtb dtc -I dtb -O dts -o virt.dts virt.dtb在 virt.dts 里你会看到类似这样的 PLIC 节点interrupt-controllerc000000 { compatible sifive,plic-1.0.0; reg 0x0 0xc000000 0x0 0x4000000; interrupt-controller; #interrupt-cells 1; interrupts-extended cpu0_intc 11, cpu0_intc 9, cpu1_intc 11, cpu1_intc 9; };这里interrupts-extended里的 11 和 9分别对应 RISC-V 标准外部中断的 M-mode 和 S-mode。你看PLIC 在设备树里同时接了两个 Hart 的两种特权模式实际用哪个由软件决定。4.2 三个实验把仲裁逻辑拆出来看实验一测“高优先级优先被 claim”。找两个外部中断源比如设备树里的 UART 和 virtio分别注册成 source A 和 source B。先都设成低优先级然后给两个设备同时注入中断记录你第一次plic_claim()拿到的到底是 A 还是 B。接着把 A 的 priority 调成最高再重复一次观察 claim 顺序变化。正常情况下高优先级源会先被认领。实验二测“priority 为 0 等于禁用”。把 source A 的优先级从 7 改成 0其他条件不变给 A 注入中断。你会发现它的 pending 位可能还在变化但 claim 永远拿不到它外部中断线也不会因为这个源被拉起。很多驱动 bug 就是这么来的——中途不小心向 priority 寄存器写了 0整个设备就“消失”了。实验三测“threshold 的严格大于语义”。把 context 的 threshold 设为 4然后给优先级分别为 3、4、5 的三个源同时注入请求。最后 claim 到的只可能是优先级 5 的源。优先级 4 的源虽然看起来“够高”但它不满足priority threshold同样被挡。这个实验我第一次做的时候也愣了一下因为惯性思维总觉得 4 大于等于 4 应该能过。4.3 没有硬件抢占的“优先级”实际到底怎么用实测之后你会意识到一个扎心的事实PLIC/APLIC 的优先级只决定“同时 pending 时谁先被仲裁选中”不决定“低优先级正在处理时高优先级能否打断它”。原因很简单CPU 外部中断只有一个入口进入 trap 后软件通常会把全局中断开关关掉。哪怕高优先级源已经 pending 到 PLICCPU 也不感知只有等软件重新打开中断新的 trap 才有机会进来。所以在 RISC-V 上想要“优先级抢占”一般靠软件伪造。做法是中断入口里做两段式处理第一阶段极短只做读取设备状态、清设备中断标志、写 complete 这三件事然后立刻重新打开全局中断第二阶段才正经处理业务。第二阶段处理的过程中如果有更高优先级中断 pending因为全局中断已经打开CPU 就能再次进 trap形成一个软件层面的抢占式嵌套。void external_irq_handler(void) { uint32_t src plic_claim(CONTEXT_M); if (src 0) return; fast_path_handle(src); // 尽量短清设备中断标志记录必要状态 plic_complete(CONTEXT_M, src); local_irq_enable(); // 打开全局中断给更高优先级源留窗口 deferred_handle(src); // 真正的业务逻辑允许被打断 }这套代码看起来简单但有个前提你的 trap 入口上下文保存/恢复必须是可重入的。如果每次进 trap 都是同样的保存流程第二次中断进来时会把第一次的上下文再压一层返回时才能正确弹出。很多轻量级 RTOS 移植没处理这个点直接开全局中断就会栈破坏这也是我觉得“软件抢占”比“硬件抢占”难的地方。5. 这里全是坑优先级配置与中断处理中的典型问题5.1 优先级寄存器只有 3 位有效位别惯性写 0xFF我见过不少从 ARM 过去的工程师配置优先级时直接写0xFF觉得数值越大越优先。在标准 PLIC 里Priority 寄存器通常只有 3 个有效位也就是 0~7写 0xFF 的结果要么被截断成 7要么根本写不进去取决于具体 SoC 实现。这个“有效位宽”规范允许实现自定义所以不要想当然必须看芯片手册或者实测。保险的做法是在驱动里加一个 mask比如前面代码里的prio 0x7然后统一把它定义为平台宏。RISC-V 生态里各个核的 PLIC 实现确实有点百花齐放有些支持 4 位有些支持 6 位凡是能通过寄存器读出来校验的初始化时最好做一次自检。5.2 Threshold 设错所有中断全部消失这种故障特别容易被误判成“外设没发中断”或者“中断线没接”。我调试一块新板子时外部中断一直不触发查了半天外设状态最后发现是初始化代码里把 threshold 设成了 7。因为priority threshold是严格大于threshold 为 7 时整个系统没有任何源能满足条件所有外部中断都被门控关掉了。如果你遇到“所有外部中断全部消失”的灵异问题先别怀疑硬件把 threshold 读出来看。正常裸机初始化应该设成 0Linux 内核的 PLIC 驱动在启动阶段也会把每个上下文阈值设为 0。还有一种情况某段代码临时把 threshold 调高做临界区保护返回时忘了恢复也会造成低优先级中断集体失联。5.3 多核环境下的优先级保存与恢复由于 Priority 寄存器是全局的而 Threshold/Claim/Complete 是每个上下文一份多核系统里最尴尬的场景是Hart 0 为了提高某个关键中断的响应速度把某个源优先级临时调高结果没有调回来导致 Hart 1 本来平均分配的仲裁结果被破坏。我建议的做法是核心业务优先级在系统启动时一次性初始化好运行期不要随便改源级优先级如果你真的需要动态调整必须用一个进程间同步机制保护或者只调整当前核自己上下文的 threshold。每个核在处理完中断退出前要把自己的 threshold 恢复成原值否则下次中断进来会被错误门控。上下文切换恢复时除了常规的通用寄存器、PC、MSTATUS还要注意把当前核的 PLIC threshold 保存好。RTOS 场景下任务 A 把 threshold 调高完成任务后切到任务 BB 不知道 threshold 被改过直接导致中断屏蔽异常。这种 bug 特别隐蔽因为它只在特定优先级组合下出现。5.4 Claim 了不 Complete等于亲手废掉了这个中断源中断处理里最忌讳的就是“读 Claim 后中途 return”。不管是分支判断写错还是处理函数里 assert 失败提前退出只要没走到 Complete 写回这个源在 PLIC 看来就永远处于“被占用”状态后续请求永远不会再次触发外部中断。调试时建议在函数出口统一处理不要每个分支都手写 Complete。C 语言里可以用 cleanup 风格或者 goto 统一出口即使后来在 fast path 里加了更多逻辑也不会漏。另一个相关问题是外设的“清中断标志位”和 Complete 的先后顺序。如果外设中断标志没清就 Complete电平触发模式下 PLIC 会立刻再次把 pending 置起来形成一个看起来像“中断风暴”的异常状态。正确顺序是先清外设中断标志再 Complete。6. 从 PLIC 升级到 APLIC代码层要动哪些地方6.1 设备树节点与中断控制器接口的变化如果一颗新 SoC 换了 APLIC设备树里最简单的变化就是节点 compatible 从sifive,plic-1.0.0变成riscv,aplic但真正影响驱动的是后面跟的配置结构。APLIC 的#interrupt-cells通常是 2第一个 cell 表示所属 domain第二个 cell 表示中断源编号。相比 PLIC 的单 cell 结构多了域这一层。典型节点长这样aplic: interrupt-controllerc000000 { compatible riscv,aplic; reg 0x0 0xc000000 0x0 0x4080000; interrupt-controller; #interrupt-cells 2; interrupts-extended cpu0_intc 11, cpu0_intc 9, cpu1_intc 11, cpu1_intc 9; };域机制带来的变化是同一个中断源可以明确归属到某个域域之间隔离这对多虚拟机或者混合关键性系统非常有用。以前在 PLIC 上做“安全中断和非安全中断隔离”要靠软件硬约束现在硬件上就能分开。6.2 驱动从 claim/complete 过渡到 IDC/IMSICAPLIC Direct 模式下过去读 PLIC Claim 寄存器的代码要改成读对应目标的 IDC 寄存器获取当前最高优先级待处理源处理完成后的确认动作也变了不再是一句简单的write32写回具体动作要以芯片实现为准。这里没法给一个通用寄存器偏移表因为 APLIC 规范留给实现的自由度比 PLIC 大不少建议以厂商手册和 SDK 驱动为基准。如果直接用 MSI 模式配合 IMSIC改动就更彻底了。中断入口处不需要再访问 MMIO 的 claim 寄存器而是直接读 IMSIC 提供的 topi 类 CSR拿到的就是你要处理的向量号。处理完写 EOI。整体链路少了“外部中断线拉起 - CPU trap - 软件再读 claim”的中间步骤对高频中断场景有明显收益。迁移驱动时的心理准备是这不是把PLIC_BASE换成APLIC_BASE就完事而是中断的生命周期语义变了。PLIC 的“读 claim 即清 pending写 complete 即再使能”这个模型在 APLIC/IMSIC 下不一定存在。你既不能默认读操作清状态也不能默认写哪个寄存器就算完成必须把外设和控制器两边的时序重新过一遍。6.3 我的迁移顺序建议如果现在要我从一个现成的 PLIC 项目迁到 APLIC我会分三步走而不是一步到位。第一步只换硬件描述不换驱动语义。也就是设备树先切成 APLIC 节点但让芯片厂商的 APLIC 工作在 Direct 模式看看有没有提供兼容 PLIC 行为的模式。这样原有的 claim/complete 流程大概率还能跑先把启动问题解决掉。具体兼容程度一定要对着规范核对不要假设。第二步把驱动切到 APLIC 的 IDC 语义。这段要重写中断控制器的底层访问函数但上层分发逻辑可以保持不变。第三步当你确认 IDC 语义稳定之后再评估要不要上 MSI 模式。如果外设流量不高比如普通 UART、GPIOMSI 带来的向量化收益不明显反而增加实现复杂度如果是网卡、NVMe 这类高速设备MSI 值得投入。从我个人的实际项目经验来看很多团队卡在第一步和第二步之间的原因是“兼容模式”和“Direct 模式”的边界没有搞清楚。兼容模式是为了让你能启动不等于它就是 APLIC 的最优形态。我在调一块新芯片时就遇到过兼容模式下一切正常、切到 MSI 后中断全部乱掉的 case归根结底是设备树里 target 配置和 IMSIC 的向量号没有对齐。这类东西规范写得很含蓄最后是靠读厂商的参考驱动一个个寄存器比对才定位到某个中断源被同时映射到了两个向量号IMSIC 只能处理一个另一个变成了永远 pending 的死请求。最后再分享一个小技巧如果你在 QEMU 上调试 APLIC/IMSIC优先用较新的 QEMU 版本并且注意 virt 机器的aia参数比如-machine virt,aiaaplic-imsic。旧版本默认只模拟 PLIC你写了 APLIC 设备树也没用。这个我也是踩了一晚上换来的经验希望你看完能少走这段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价