前阵子帮一个做RISC-V SoC的朋友调Bug,现象很典型:他把UART的中断“优先级”在自己写的软件调度器里配成了最高,结果DMA中断一来,UART的字符照样被顶掉,两边抢着发数据,串口直接乱码。查到最后,他压根没动PLIC的priority寄存器——软件层的“优先级”和硬件仲裁层的“优先级”完全是两套东西。这个误解几乎每个从ARM/STM32切到RISC-V的人都会踩一次:ARM的NVIC把抢占优先级做在硬件里,而RISC-V把本地中断、平台中断、MSI、优先级仲裁拆散到了CLINT/PLIC/APLIC/IMSIC好几套组件里,文档又散,很少有人给你从头串一遍。
这篇文章就干一件事:把RISC-V的中断优先级机制从原理到实操完整拆一遍。先讲本地中断(MEI/MSI/MTI)和平台中断的分工,再拆PLIC的仲裁流程、claim/complete的原子性、阈值水位线的作用,然后讲AIA规范下APLIC的direct与MSI两种模式怎么把优先级逻辑“搬了家”,最后给一版实测排错清单和配置模板。适合正在做RISC-V嵌入式开发、准备把中断控制器从PLIC迁到APLIC、或者想搞明白“中断优先级到底谁说了算”的读者。默认你懂一点RISC-V特权架构(mtvec、mie、mip这些),但涉及寄存器的细节我会逐个写清楚。
1. 本地中断与平台中断的分工:先把“优先级”拆成两半
很多优先级讨论都混在一起说,这是最大的乱源。RISC-V里中断首先分成两类:本地中断(Local Interrupt)和平台中断(Platform/Global Interrupt)。
1.1 三条本地中断线和它们背后的控制器
每个硬件线程(HART)的mip/mie里只有三个标准中断位:MSIP(软件中断)、MTIP(定时器中断)、MEIP(外部中断)。这三个都叫本地中断,各自的产生源头是:
- 软件中断:由CLINT的MSIP寄存器或核间写操作触发,常用于操作系统调度和IPI。
- 定时器中断:由CLINT的mtime比较器触发,当
mtime >= mtimecmp时拉高MTIP。 - 外部中断:由PLIC或APLIC(AIA)拉高MEIP,代表“全局外设里有事件要处理”。
注意,这三个位只是“入口”,真正的源在CLINT和PLIC/APLIC里。CLINT负责本地定时器和软件中断,PLIC/APLIC负责汇总所有外设中断。这两者的优先级是分开处理的,不能放在一张表里比较。
1.2 中断委派(delegation)会削掉一半复杂性
RISC-V通过mideleg把某个中断委派给S模式。比如Linux系统里,mideleg通常把S软件中断、S定时器中断、S外部中断都委派下去,这样中断不会每次都陷到M模式,而是直接进S模式的stvec处理。
委派决定的是“处理模式”,不是优先级。委派后,中断的优先级比较仍然发生在目标模式内部。举例:S外部中断委派后,在S模式下是否响应取决于sstatus.SIE;如果此时M模式正在处理更紧急的事并关闭了mstatus.MIE,那这个S外部中断只能等着。它并不会因为“优先级高”而强行打断M模式。
所以当你讨论“优先级”时,第一步先问自己:这个中断在哪个模式处理?同一个模式里的多个中断怎么排序?跨模式的中断几乎都是靠“先关中断再切换”来保证原子性的,硬件不帮你做跨模式抢占。
1.3 本地中断的“默认优先级”其实没有标准答案
很多人以为RISC-V规定了MEI > MSI > MTI的固定顺序,其实规范只说实现自定义,业界习惯上倾向这个顺序,但并不强制。你在软件里写调度逻辑时,如果赌了“定时器来了必然先于软件中断”,在换一颗SoC或换一个RISC-V核时可能就会踩到行为差异。
我个人的建议是:本地中断之间不要依赖硬件的先后顺序,用软件开关中断来保证关键路径的原子性。真正需要精细优先级控制的是平台中断,也就是PLIC/APLIC管的这一大摊子,这才是本文的重点。
2. PLIC仲裁的血肉细节:priority、threshold与claim/complete
PLIC是经典RISC-V平台里最常用的全局中断控制器,SiFive、QEMU virt、一堆自研SoC都在用。它的设计思路很朴素:所有外设中断汇总进来,按优先级排序,然后上报给某个HART的外部中断线。
2.1 一张寄存器布局图看清PLIC全貌
PLIC整体是MMIO访问的,假设基地址为PLIC_BASE,核心寄存器按以下偏移分布:
| 功能 | 偏移公式 | 说明 |
|---|---|---|
| 源优先级 | 0x000000 + 4 * source | 每个源一个优先级寄存器,source从1开始,0保留 |
| Pending | 0x001000 + 4 * (source / 32) | 每个bit代表一个源是否有pending请求 |
| Enable | 0x002000 + 0x80 * context + 4 * (source / 32) | 每个context(目标HART+模式)有一组enable位 |
| 优先级阈值 | 0x200000 + 0x1000 * context | 低于或等于该值的中断不上报 |
| Claim/Complete | 0x200004 + 0x1000 * context | 读返回最高优先级中断ID,写回同一个ID完成中断 |
每个“源”是一个外设中断线,source编号从1开始,0是保留。设备树里的riscv,ndev规定了最大源编号,驱动才知道该管到几号。
这里要特别注意:enable是per-context的,priority是全局的。也就是说,源5的优先级在所有HART看来都是同一个值,但每个HART可以独立决定要不要接收源5的请求(enable),以及接收前要跨过什么水位线(threshold)。
2.2 仲裁那一刻:谁被选中,谁继续等待
PLIC的仲裁逻辑可以概括成一句话:在“已enable + 已pending + 优先级高于当前context阈值”的源里,选优先级最高的那个;如果优先级相同,实现通常选source编号最小的(规范允许实现自定义平局规则,但低编号优先是最常见的做法)。
举一个具体例子。假设三个源同时pending:
- 源3:I2C,priority = 1
- 源5:GPIO,priority = 5
- 源7:UART,priority = 5
- 当前context阈值 = 0
仲裁结果:源5和源7优先级都是5,最高,平局时选低编号,所以源5被选中。源7继续pending,源3继续pending。这个行为的实际影响是:如果源5是一个高频中断源且长期pending,源7会一直被压着。所以不要把两个高频中断配成同一个最高优先级,低编号的会饿死高编号的。
我还见过有人误以为PLIC会“记住上次分给谁”,实现轮流调度(round-robin),实际上经典PLIC规范里没有这回事。想避免饿死,只能把关键源的优先级错开。
2.3 Claim/Complete不是“读一个数再写回去”那么简单
Claim和Complete的语义是理解PLIC的核心,也是很多Bug的源头。
Claim:读claim寄存器。硬件会做三件事:从所有可上报的pending源里选出最高优先级的那个,把它的ID返回给你,同时原子地清除PLIC侧的pending位。
Complete:把刚才claim到的ID写回同一个寄存器。这一步告诉PLIC“我处理完了”,此后这个源才可能再次上报新事件。
对于边沿触发(edge-triggered)的外设,claim清除pending后,必须等设备产生新的边沿,CPU才会再收到中断。对于电平触发(level-triggered)的外设,情况完全不同:如果设备的中断源没被清除,电平还拉着,claim后PLIC会立刻重新置起pending位。这就是中断风暴的常见来源。
正确的电平触发处理顺序是:先让设备清掉中断源,再写complete。如果先写complete再去清设备,PLIC会瞬间再次上报同一个源,造成重复进入trap。
2.4 阈值是水位线,不是优先级
PLIC的threshold寄存器是一个容易用错的东西。它的判定规则是:只有当源优先级严格大于threshold时,这个源才允许上报。阈值是0时,所有优先级1以上的源都能上报;阈值配成7(假设优先级范围1~7)时,所有源都被挡住。
对比一下ARM NVIC:NVIC没有全局阈值,只有优先级分组。PLIC的threshold更像是一个“临时水闸”——比如系统进入低功耗模式、或者某段代码不允许被打断时,整体抬一下阈值,把所有低于某个等级的中断先压住。这比逐个disable源高效得多,因为它只写一个寄存器。
优先级为0的源在PLIC里等于永久禁用,这是硬件mask的手段。很多人想“临时禁用一个中断”时去写priority=0,但忘了它只是mask,pending位并不会被清掉。恢复priority后,如果设备一直有请求,中断会立刻重新触发,并不算“遗漏”,只是“积压”。
3. APLIC:AIA把优先级中枢打散之后,direct与MSI模式各怎么玩
RISC-V的AIA(Advanced Interrupt Architecture)规范出现后,PLIC的地位开始被挑战。APLIC(Advanced Platform-Level Interrupt Controller)和IMSIC(Incoming MSI Controller)是AIA的两个核心组件。这一章我们只看优先级语义的变化。
3.1 PLIC的三个痛点,催生了APLIC
PLIC在设计上是一个集中式仲裁器,这在小型SoC上没问题,但对现代大规模芯片有几个明显短板:
- 不支持虚拟化。虚拟机(Guest)的中断需要在宿主机(Hypervisor)和Guest之间隔离,PLIC的context模型里没有Guest的概念。
- 不支持标准MSI。MSI(Message Signaled Interrupt)是现代PCIe等设备的标准中断方式,PLIC只有wire-based一条路。
- 集中仲裁器扩展性差。所有源的电平汇总到一颗仲裁器,多die、多簇、多域场景下布线复杂,一个域的故障还可能影响全局仲裁。
AIA的答案是:把“外设中断的产生”和“中断的投递与优先级管理”解耦。APLIC负责接收外设的wire中断(也可以软件注入),然后决定用直接模式(direct)还是MSI模式把事件送出去。IMSIC则驻留在每个HART旁边,负责接收MSI,并按优先级上报给该HART。
3.2 Direct模式:熟悉的配方,不同的寄存器
APLIC的direct模式本质上继承了PLIC的玩法,仍然走外部中断线,但仍然有重要差异。
direct模式下,域(domain)里的每个source仍有一个优先级寄存器(sourceprio),仲裁逻辑也是“选最高优先级的pending源”。每个target(对应一个HART的某个模式)有一个独立的阈值寄存器,只有源优先级严格高于阈值才放行。Claim/Complete通过每个target的IDC(Interrupt Delivery Control)里的claimi寄存器完成,读返回源ID,写回去完成。
但注意,寄存器布局和PLIC完全不同:APLIC引入了domaincfg、sourcecfg、target enable数组等一套新结构,支持按域做粗粒度的隔离和开关。如果你拿着PLIC的驱动代码直接对着APLIC寄存器改偏移,大概率会翻车。
3.3 MSI模式:优先级仲裁下沉到每个核的IMSIC
MSI模式是APLIC相对PLIC最有颠覆性的变化,也是迁移时最容易懵的地方:在MSI模式下,APLIC里根本没有“源优先级”寄存器了。
MSI模式下,每个源在APLIC里只配置一个“目标”信息(目标IMSIC的地址和MSI data),当事件发生时,APLIC向目标IMSIC发送一个写请求,也就是MSI。IMSIC收到后,在自己的中断文件(interrupt file)里置起对应位的pending。
至此,优先级仲裁从“中央仲裁器”搬到了“每个HART自己的IMSIC”里:
- 每个中断identity(对应一个MSI)在IMSIC中都有一个可配置的优先级,软件通过
eiprio这类CSR数组来设置。 - IMSIC在所有pending且enable的identity里选出优先级最高的,作为
mtopi(Machine Top Pending Interrupt)报告给核,同时拉高MEIP。 - 软件读取
mtopi得到最高优先级中断的ID,然后用mclaimi完成claim,处理完再写mclaimi完成complete。
这种设计把“全局统一仲裁”变成了“每个核本地仲裁”,好处是去中心化了,性能压力分散;更重要的是,IMSIC为每个Guest准备了一套独立的中断文件,虚拟化场景下Guest中断可以不经过宿主机软件逐次转发。
3.4 PLIC/APLIC直接模式/APLIC MSI模式对照表
| 维度 | PLIC | APLIC Direct模式 | APLIC MSI模式 |
|---|---|---|---|
| 所属规范 | 平台级PLIC规范 | AIA | AIA |
| 优先级配置位置 | 每个source一个priority | 每个source一个sourceprio | 每个identity在IMSIC里配eiprio |
| 仲裁发生位置 | PLIC中央仲裁 | APLIC域内仲裁 | 每个HART的IMSIC本地仲裁 |
| 上报方式 | 拉高外部中断线 | 拉高外部中断线 | 发送MSI写IMSIC |
| 阈值 | 每个context一个threshold | 每个target一个threshold | 每个HART一个eithreshold |
| Claim方式 | MMIO读claim寄存器 | MMIO读IDC.claimi | CSR读mclaimi |
| 虚拟化支持 | 无 | 有限(域隔离) | 强(Guest中断文件) |
| 适合场景 | 小规模SoC、裸机/RTOS | 需要wire式上报的大规模SoC | 服务器、PCIe、虚拟化场景 |
对正在自研RISC-V核的人来说,选择其实很清楚:如果只跑裸机或RTOS,PLIC更简单,逻辑也省;如果目标是跑Linux而且要兼容虚拟化,APLIC+IMSIC是往后几年的正确方向,Linux mainline在6.9以后已经逐步合入了APLIC相关irqchip驱动,生态没有障碍。
4. 实测排错:优先级配置的四个误区和一套调试打法
说实话,原理看懂了,真机上还是会出各种幺蛾子。下面这四类问题是我在实际调试中见得最多的,每一类都对应一条血泪经验。
4.1 误区一:把priority当成“抢占优先级”
“我把UART配成最高优先级了,怎么DMA还能打断它?”——这句话我听过不下五次。
PLIC和APLIC都不做抢占。它们只负责“下一次上报谁”,不负责“打断正在处理的谁”。当你claim了一个中断,进入处理函数后,硬件不会因为来了个更高优先级的中断而自动把你踢出去。要实现嵌套,必须软件在中断处理函数里手动重新打开mstatus.MIE,同时保证现场保存/恢复正确。
这里有个细节值得多说一句:PLIC的网关在某个源被claim后,会让该源保持“非活跃”直到complete;但其他源如果pending且优先级高于阈值,PLIC的外部中断线仍然会一直有效。也就是说,只要还有可上报的中断,PLIC输出就一直拉着。如果你在handler里重开了MIE,马上会再进一次trap,此时你可以在不完成上一个中断的情况下claim第二个。这就是软件实现嵌套的物理基础。但优先级本身不产生嵌套,嵌套完全由你的软件策略决定。
4.2 误区二:在Linux里调硬件优先级,结果驱动根本不写
Linux的irqchip驱动(比如irq-plic.c)对待PLIC priority的方式是:初始化时把所有源的priority写成一个固定值(通常是1),后续enable/disable只操作enable寄存器。也就是说,在Linux默认状态下,所有外设中断的硬件优先级完全一样,仲裁退化成“低编号优先”。
这不是Linux偷懒,而是为了对齐通用irq子系统的“mask/unmask”接口:通用框架只认开关,不认那么多级优先级。如果你确实需要硬件多级优先级,在Linux下得自己改驱动或在板级初始化里显式写priority寄存器,并且要想清楚优先级与线程化中断(threaded IRQ)之间的交互,否则容易做出一个“看起来配了优先级,实际调度全乱”的系统。
4.3 误区三:电平触发外设的complete时机
前面说了,电平触发源的pending在claim后会立刻被重新置位,如果设备侧没清中断源,哪怕你马上写complete,PLIC也会立刻再次上报。典型故障现象:CPU忙到几乎全耗在中断处理上,系统卡死或响应极其缓慢。
排查这类问题,先确认设备的中断源是否清除干净,再看complete写的时机。时序上必须先“清设备”再“写complete”。有些驱动为了省事,在入口先claim,在出口complete,看起来没问题,但只要设备清中断需要时间,中间窗口就会产生重复触发。
4.4 误区四:多核共享source,以为PLIC会做负载均衡
PLIC的enable是per-context的,两个HART可以同时enable同一个源。但这不意味着硬件会轮流分配或者做负载均衡。仲裁只有一个全局pending位,谁先claim谁拿走,另一个HART压根不知道这事发生过。
想用多核分担中断,正确做法是在enable层面就把源分片:核0管UART、GPIO,核1管I2C、SPI、DMA。在软件层面做动态负载均衡是另一个话题,硬件控制器帮不了你。APLIC在direct模式下也一样,target的enable是独立设置的,源不会自动“飘”到另一个target去。
4.5 调试三板斧:打印仲裁现场、用QEMU对照、抓信号线
遇到优先级相关的诡异问题时,我一般按下面这套顺序排查:
打印仲裁现场。在trap入口把mcause、mepc、mstatus、PLIC/APLIC的threshold和claim值都打出来。先确认进中断的那一刻,claim寄存器返回的ID是不是你期望的源。如果不是,说明仲裁结果没按你想的来,优先检查enable和threshold。
写一个最小复现程序。在裸机环境写一个小测试:把源3配成priority 1,源5配成priority 7,分别触发,记录claim顺序。这样能把寄存器行为从业务代码里剥离出来,是最直接的定性手段。
用QEMU对照行为。QEMU的virt平台提供了PLIC和AIA实现,行为相对规范。先在QEMU里验证你的仲裁假设,再回真机找SoC手册核对差异。很多看似“硬件Bug”的优先级问题,最后都是SoC实现没按规范做,或者驱动寄存器偏移写错。
抓线。调试级别不够时,用逻辑分析仪或示波器抓PLIC/APLIC到核的中断线,看它是否一直拉高,能快速区分“没被选中”和“选中了但没进trap”这两种情况。
4.6 一套可参考的裸机配置模板
最后给一个我在小型多外设系统里常用的配置思路,不是标准答案,但至少能避开饿死和风暴:
| 源 | 优先级 | 目标 | 说明 |
|---|---|---|---|
| UART RX | 7 | HART0 | 时间敏感,最高 |
| GPIO按键 | 5 | HART0 | 中高,但低频 |
| DMA完成 | 4 | HART0 | 中 |
| I2C | 2 | HART0 | 低速,可等 |
| 调试串口 | 1 | HART0 | 可有可无 |
阈值保持0,不轻易动。两个高频源尽量避免相同优先级。如果你的系统里中断数量很多,优先用“分核”而不是“调优先级”来降低竞争。
我个人在项目里的体会是:把PLIC/APLIC的优先级功能当成一个“准入控制”而不是“调度器”来用,压力会小很多。硬件只帮你决定“谁有资格进来”,进来了之后怎么排队、怎么嵌套、怎么均衡,都是软件自己的事。这套思维方式一旦建立,无论以后碰到CLIC、PLIC还是APLIC,都不会再被“优先级”这三个字绕晕。真到了要迁移APLIC MSI模式的时候,记得优先级的权柄已经握在IMSIC手里,去APLIC寄存器里找sourceprio是找不到的。