早两年给一颗自研的 RISC-V 多核 SoC 做验证时,我踩到了一个特别尴尬的场景:板子上插了 PCIe 网卡,MSI 中断进来之后,传统 PLIC 这边只能把它当成一个 INTx 电平中断来伺候;等到要上虚拟化,guest 的外部中断更是无从下手,只能靠 hypervisor 软件转发,延迟惨不忍睹。项目组最后拍板,直接从 PLIC 迁到 RISC-V AIA 架构,拆成 APLIC 加 IMSIC 两个组件分别接管中断源和中断送达。这篇文章我就把这次迁移的全过程——为什么要迁、APLIC 和 IMSIC 各自管什么、中断号怎么重新洗牌、设备树和驱动怎么改、以及我实际排过的那些坑——完整记录下来。
不管你是做 RISC-V CPU 核验证、SoC 中断控制器集成,还是写裸机固件和 Linux irqchip 驱动,这份笔记应该都能帮你省下不少弯路。
1. 旧秩序的裂缝:PLIC 在多核、虚拟化与 PCIe 面前的三重失灵
要理解这次迁移的动机,得先回到 PLIC 的设计初衷。PLIC(Platform-Level Interrupt Controller)当年是为了解决 RISC-V 基础规范里"外部中断无标准控制器"的问题,把一堆平台中断源集中起来,仲裁出优先级最高的那个,再以电平方式拉高目标 hart 的外部中断线。它的模型很简单:中断源进来,挂在 pending 数组里,然后和 enable、threshold、priority 一起参与仲裁,CPU 软件侧读一个全局 claim 寄存器拿到中断号,处理完写同一个地址完成中断。这套机制对早期中小规模 SoC 确实够用,但放到现在的多核、虚拟化、PCIe 场景里,是真的撑不住了。
1.1 全局 claim/complete 成了多核系统的新瓶颈
PLIC 内部每个 hart context 有自己的 enable、threshold 和 claim/complete 寄存器,但中断源仲裁这件事是全局的——所有 pending 中断都在同一个仲裁器里比优先级。CPU 核数少的时候无所谓,核数一多,多个核同时抢 claim 寄存器,这个全局仲裁点就是一片隐形的锁。SPEC 文档里没有明文禁止多核同时 claim,但硬件实现为了保证一致性必然要做串行化处理,中断延迟在核数翻倍后会变得非常难看。我实测过一颗 16 核 SoC,在 8 核同时跑网络中断时,claim 路径的平均延迟比 4 核时高了将近一倍,这就是仲裁器串行化的直接后果。
1.2 虚拟化场景下 PLIC 没有任何位置
PLIC 的世界里根本没有"虚拟机"这个概念。它不知道什么是 guest,更不知道什么是 VS-mode。在虚拟化场景中,硬件过来的中断必须先落到 host 的 M-mode 或者 S-mode,然后 hypervisor 软件去读 claim、拿中断号、再软件注入给对应 guest。这一来一回多出来的开销,是 ns 级别的性能损失。相比之下,x86 的中断控制器早就把 interrupt remapping 和 guest 投递做进了硬件里。RISC-V 要支撑 KVM 这种虚拟化栈,就必须有一个能直接把外部中断投递到 guest 的机制,而 PLIC 完全做不到。
1.3 PCIe 的 MSI 语义在 PLIC 这里水土不服
PCIe 设备中断的天然形态是 MSI/MSI-X,本质上是一次特定的内存写事务。但 PLIC 的输入是电平/边沿信号线,要把 MSI 变成 PLIC 能认识的电平信号,就得多加一层桥接逻辑,把 MSI 拦截下来转成 wire 信号,再走 PLIC 的仲裁流程。这条路径不仅长,而且完全背离了"中断是内存写"的高效语义。加上 PLIC 的中断源编号空间有限(source 字段通常只有 10~11 位,最多支持 1023 个),真到了要做大规模 PCIe 交换的 SoC,PLIC 根本不够用。
PLIC 的优先级设计也比较潦草,标准实现通常只有 7 个可配置优先级(减去一个保留值),碰上复杂的 QoS 场景基本白搭。这些缺陷叠加在一起,AIA 架构的出现几乎是必然的。
2. 新架构全景:APLIC 管“源”,IMSIC 管“送达”
RISC-V AIA(Advanced Interrupt Architecture)规范出来之后,最大的变化是它把传统 PLIC 一个控制器干的事,拆成了两个组件:APLIC 负责管理平台上的有线中断源(wired interrupts),IMSIC 负责接收并递送 MSI。这俩可以独立使用,也可以搭配使用。
2.1 APLIC:延续“平台中断控制器”的角色,但调度方式完全不同
APLIC 全称 Advanced Platform-Level Interrupt Controller,从定位上它是 PLIC 的继承者,但内部逻辑做了大幅重构。它引入了一个关键概念叫中断域(domain)。一个 APLIC 可以包含多个 domain,每个 domain 有自己的中断源集合、配置寄存器组和独立的投递关系。不同 domain 之间可以相互隔离,这在多租户或者异构计算场景下特别好用。
APLIC 的寄存器模型和 PLIC 有很大区别。每个中断源都有一个 sourcecfg 寄存器,用来配置这个中断源的模式:0 表示 inactive,也可以配成 detached、direct 或者 MSI 模式。direct 模式下的中断会直接通过 IDC 送到指定的 hart,MSI 模式下 APLIC 会把中断源打包成一个内存写事务,发到目标 IMSIC。每个接收端(一个 hart 配合一个特权模式或 guest)都有一个 IDC(Interrupt Delivery Control)单元,它里面保存了这个接收端的中断状态、优先级阈值和 claim/complete 寄存器。所以即使是 direct 模式,APLIC 也把原来 PLIC 的全局 claim 打散到了每个 IDC 上,缓解了多核竞争。
2.2 IMSIC:每个 hart 一个 MSI 信箱
IMSIC(Incoming MSI Controller)和 APLIC 分工明确:它只负责接收 MSI,并且只服务它绑定的那个 hart。从体系结构上看,每个 hart 都会有一个自己专属的 IMSIC 实例(可以通过共享内存实现多文件,但语义上是 per-hart 的),IMSIC 内部按中断文件(interrupt file)来组织,每个文件通常对应一个特权模式(M-mode、S-mode、VS-mode 等),文件之间有独立的 MMIO 地址页,互不干扰。
每个中断文件里能用的中断 slot 只有 63 个(ID 从 1 到 63,0 保留),所以单个文件的中断容量并不大,但好处是结构极其简单:MSI 写进来,直接 setipnum 对应的 slot,如果使能了,就拉高该 hart 对应特权模式的外部中断线。CPU 软件读 claimi 拿到中断 ID 后处理,处理完写同一个地址完成中断。整个过程没有任何全局仲裁,完全是"管好自己那一亩三分地"的路子。
2.3 两者怎么配合:一个管源,一个管送达
很多刚接触 AIA 的人会搞混 APLIC 和 IMSIC 的分工。简单记:APLIC 站在中断源一侧,知道"哪个设备产生了中断",IMSIC 站在 hart 一侧,负责"中断如何在 hart 内部被感知和处理"。APLIC 可以工作在 direct 模式,直接把中断线引到 hart;也可以工作在 MSI 模式,把中断方式编码成内存写发给 IMSIC。在 MSI 模式下,IMSIC 看到的是 APLIC 投递过来的标准 MSI,APLIC 在这个过程中其实扮演了一个"MSI 生成器"的角色。
以 PCIe 设备为例:PCIe MSI 本身就可以直接寻址到 IMSIC,根本不需要 APLIC 参与;而板上的 UART 这类老式有线中断则要通过 APLIC 做模式转换,生成 MSI 再发给 IMSIC。这样一来,无论是传统外设还是 PCIe 设备,都能统一走 MSI 路径,这比 PLIC 时代"有线中断一个系统、MSI 另一个系统"的割裂状态要干净得多。
3. 从一个中断的完整旅程看三种投递路径
在迁移之前,最好把中断从产生到处理的完整数据流在脑子里过一遍。我习惯用三条路径来对比,理解了这三条路径的差异,后面所有代码和寄存器改动都是顺水推舟。
3.1 PLIC 老路径:设备 → 全局仲裁 → 单点 claim
老路径的时序大致是:
- 设备拉高中断线,PLIC 检测到信号后锁存 pending。
- pending 中断进入全局仲裁器,和所有其他 pending 中断比优先级,再和当前 hart context 的 threshold 比较。
- 仲裁通过后,PLIC 拉高目标 hart 的外部中断线。
- CPU 进入异常向量,软件读取 PLIC 的 claim 寄存器(注意,这是全局唯一的),拿到中断号。
- 软件处理完中断后,向同一个 claim/complete 寄存器写入该中断号,硬件收到写操作后才清除 pending,允许下一条中断进来。
这段流程的问题在第 4 步。全局 claim 寄存器意味着所有 hart 都在访问同一个地址,仲裁器必须保证不会有两个 hart 同时拿到同一个中断号,所以内部必然有一把"无形的锁"。核多了以后,这条锁就是延迟的天花板。
3.2 APLIC direct 路径:每源独立直送,claim 打散到 IDC
APLIC 的 direct 模式和 PLIC 的最大区别,是每个中断源都有独立的投递通道,不需要在 APLIC 内部做全局仲裁。中断源产生事件后,APLIC 根据 sourcecfg 和 target 寄存器的配置,直接把中断送到目标 hart 的 IDC,由 IDC 来负责挂起和优先级仲裁。
所以对应的处理流程变成了:
- 设备中断信号进入 APLIC 的 sourcecfg,source 被配置为 direct 模式。
- APLIC 不再做统一的优先级仲裁,而是依据 target 寄存器把中断请求发给指定 hart 的 IDC。
- IDC 判断 threshold 和 enable,确认后拉高该 hart 的外部中断线。
- 软件读取该 IDC 的 claim 寄存器,拿到这个 hart 被投递的中断号。
- 软件写回 IDC 的 complete 寄存器完成中断。
从这里能明显看到,原来"全局单点"的 claim 被拆到了每个 hart 的 IDC 上,多核场景下每个核只访问自己的 IDC,路径短了一半,竞争也没了。代价是软件必须清楚当前中断属于哪个 IDC,不能像以前那样傻乎乎地读一个固定地址。
3.3 APLIC MSI + IMSIC 路径:中断变成一次内存写
这条路径就是把 APLIC 变成"发件人",IMSIC 变成"收件信箱":
- 设备触发中断后,APLIC 中对应 source 被配置为 MSI 模式。
- APLIC 根据 target 寄存器里的地址和数据,生成一次 MSI 内存写事务,直接发往对应 hart 的 IMSIC 的 setipnum 地址,数据里编码了中断文件号和中断 ID。
- IMSIC 内部把 setipnum 写成对应文件里的 pending 位,如果该中断已使能,就拉高 hart 对应特权模式的外部中断线。
- 软件读取该中断文件的 claimi 寄存器(读操作),拿到中断 ID。
- 软件处理完成后再往 claimi 寄存器写一次(写操作),完成中断。
这里有一个经常被忽略的点:IMSIC 的 claim 和 complete 共用同一个 MMIO 地址,读是 claim,写是 complete。这和 PLIC 的"读 claim、写 complete 到同一地址"的做法形式上一致,但寄存器的访问粒度变成 per-file 了,软件侧必须清楚地知道自己在操作哪个文件。
我把三种路径整理成一个表格,方便对照:
| 维度 | PLIC 老路径 | APLIC direct | APLIC MSI + IMSIC |
|---|---|---|---|
| 仲裁位置 | PLIC 全局仲裁器 | IDC 本地仲裁 | IMSIC 文件内仲裁 |
| 中断投递方式 | 拉高外部中断线 | 拉高外部中断线 | MSI 内存写 |
| claim 寄存器位置 | 全局唯一地址 | 每个 IDC 独立 | 每个中断文件独立 |
| 多核扩展性 | 差,存在全局锁点 | 好,无全局竞争 | 好,天然 per-hart |
| 虚拟化支持 | 不支持 | 通过 IDC 支持 guest | 支持 guest 文件 |
| PCIe MSI 支持 | 不原生支持 | 不适用 | 原生支持 |
| 典型场景 | 老式 SoC | 中小规模嵌入式 | 大规模多核/虚拟化 |
4. 迁移起步:设备树、驱动层与裸机初始化的同步改动
搞清楚了架构,真正的体力活就来了。从 PLIC 迁移到 APLIC 和 IMSIC,不是简单换个寄存器基地址就行,它牵扯到设备树描述、中断号重新映射、底层初始化和驱动模型的全面调整。这一步我按三个层面拆开讲。
4.1 设备树:plic节点换成aplic和imsic节点
老设备树里,外设的中断控制器引用通常长这样:
uart0: serial@10000000 { compatible = "snps,dw-apb-uart"; reg = <0x0 0x10000000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <33>; }; plic: interrupt-controller@c000000 { compatible = "sifive,plic-1.0.0"; reg = <0x0 0xc000000 0x0 0x4000000>; interrupt-controller; #interrupt-cells = <1>; interrupts-extended = <&cpu0_intc 11 &cpu1_intc 11>; };迁移后,有线中断先通过 APLIC,MSI 中断通过 IMSIC。设备树得拆成两个中断控制器节点:
uart0: serial@10000000 { compatible = "snps,dw-apb-uart"; reg = <0x0 0x10000000 0x0 0x1000>; interrupt-parent = <&aplic>; interrupts = <33 1>; /* 部分实现可能用 2 个 cell,一个表示中断号,一个表示触发类型 */ }; aplic: interrupt-controller@c000000 { compatible = "riscv,aplic"; reg = <0x0 0xc000000 0x0 0x4000>; interrupt-controller; #interrupt-cells = <2>; interrupts-extended = <&cpu0_intc 11 &cpu1_intc 11>; }; imsic: imsic@24000000 { compatible = "riscv,imsics"; reg = <0x0 0x24000000 0x0 0x20000>; interrupt-controller; #interrupt-cells = <1>; interrupts-extended = <&cpu0_intc 11 &cpu1_intc 11>; };注意 APLIC 节点里的interrupts-extended,它描述的是 APLIC 的 IDC 分别接到哪些 hart、哪条外部中断线上。这里用的cpu0_intc 11,11 对应的是 S-mode 外部中断(SEIP),M-mode 则对应 9(MEIP)。具体用哪个,取决于你的裸机模式是跑 M-mode 还是 S-mode,以及 APLIC 的 IDC 实际连接到了哪条线。
还有一个容易忽略的点:IMSIC 节点里同样也要列interrupts-extended,列出这个 IMSIC 服务哪些 hart。在 Linux 里,IMSIC 驱动会根据这些属性建立"文件 → 中断线 → CPU"的映射,如果这里写错,驱动加载时就根本找不到对应的 INTC 域。
4.2 Linux 驱动层:从irq-sifive-plic到irq-riscv-aplic和irq-imsic
Linux 内核这边,AIA 支持不是天生就有的。老内核里只有 PLIC 驱动(drivers/irqchip/irq-sifive-plic.c),AIA 的 APLIC 和 IMSIC 驱动在 6.x 后续版本才逐步合入主线。迁移前一定要先确认内核版本够新,否则设备树写得再对,驱动不认识riscv,aplic兼容字符串也是白搭。
APLIC 和 IMSIC 驱动的核心作用,是把设备树里的中断控制器注册为标准 Linux irqchip,并且把外设子节点里的中断号翻译成 Linux 的虚拟 IRQ 号。翻译过程有两个重大变化:
一是中断号的语义发生了变化。PLIC 时代,外设的interrupts = <33>里的 33 就是 PLIC 的 source ID,驱动直接把它映射成 irq 号。APLIC 时代,中断号依然是 APLIC 内部的 source ID,但最终软件处理时,拿到的是 IDC claim 寄存器里的值;IMSIC 时代更特殊,APLIC 在生成 MSI 时,会把源中断号编码进 MSI 的 data 字段,IMSIC 侧读取的是这个编码后的中断 ID,不一定和 APLIC 的 source ID 相同,中间需要一层映射关系。
二是完成中断机制变了。PLIC 是老一套generic_handle_irq+ 写 complete;APLIC direct 模式要读对应 IDC 的 claim/complete;IMSIC 模式要操作 per-file 的 claimi/completei。这些细节都被封装在 irqchip 驱动里了,普通驱动作者基本无感,但如果你是移植 BSP 的,一定要在中断处理路径上核对清楚,别让两层驱动各干各的。
4.3 裸机/固件:启动初始化代码的寄存器级改动
如果你不是跑 Linux,而是在写裸机程序、RTOS 或者 OpenSBI 固件,那初始化代码就要自己动手改了。下面给一份 APLIC + IMSIC 搭配使用的伪代码,展示初始化主流程:
/* APLIC 初始化:假设中断源 0~31 走 direct,32~63 走 MSI */ void aplic_init(void) { /* 1. 配置 domain,开启中断使能,选择投递模式 */ mmio_write_32(APLIC_BASE + DOMAINCFG_OFFSET, DOMAINCFG_IE | DOMAINCFG_DM_MSI); /* 2. 逐个配置 source */ for (int i = 0; i < 32; i++) { /* direct 模式:配置 sourcecfg,设置 target */ mmio_write_32(APLIC_BASE + SOURCECFG(i), SOURCECFG_DIRECT); mmio_write_32(APLIC_BASE + TARGET(i), TARGET_HART_ID | TARGET_GUEST_ID); } for (int i = 32; i < 64; i++) { /* MSI 模式:配置 sourcecfg,设置 target 地址和数据 */ mmio_write_32(APLIC_BASE + SOURCECFG(i), SOURCECFG_MSI); mmio_write_64(APLIC_BASE + TARGET(i), MSI_ADDR(imsic_base, hart_id, file_id) | MSI_DATA(irq_id)); } } /* IMSIC 初始化:每个 hart 自己执行 */ void imsic_init(void) { uintptr_t imsic_file_base = get_imsic_file_base(); /* 使能文件,设置优先级阈值 */ mmio_write_32(imsic_file_base + FILE_CFG_OFFSET, FILE_CFG_ENABLE); /* 打开中断对应的 enable 位 */ mmio_write_64(imsic_file_base + ENABLE_OFFSET, 0xFFFF); }核心的寄存器偏移和字段定义要以最终的 AIA 规范为准,不同实现可能还有细微差异。裸机初始化时最容易犯的错是只配了 APLIC 忘了配 IMSIC 的 enable,结果中断源那边已经激起来了,IMSIC 这边却没有使能对应 slot,导致中断线一直挂起,却始终进不了中断处理函数。
下面这张表把 PLIC 和 AIA 的寄存器操作对应关系列一下,迁移早期对着查非常有用:
| 操作 | PLIC | APLIC direct | IMSIC |
|---|---|---|---|
| 全局使能 | priority + enable | domaincfg.IE + sourcecfg | 文件 enable |
| 每个中断源使能 | enable 位图 | sourcecfg 模式 + target | enable 位 |
| 优先级配置 | priority 寄存器 | IDC 内阈值/优先级 | 文件内优先级 |
| 中断阈值 | threshold 寄存器 | IDC threshold | file threshold |
| 读取中断号 | 读 claim 寄存器 | 读 IDC claim | 读 claimi |
| 完成中断 | 写 complete 寄存器 | 写 IDC complete | 写 claimi |
4.4 中断服务程序中段必变:不能再用“一个全局入口”的思维
在切换之后,中断服务程序的结构也要调整。以前 PLIC 的中断服务程序通常是"读全局 claim → 统一分发 → 写 complete",这套代码在 AIA 下有两个隐患。
第一个隐患是中断号的获取方式。APLIC direct 模式下,读错了位置的 claim 寄存器会拿到错误数据;IMSIC 模式下,如果没有按文件先选对 MMIO 页,claim 读出来也是错的。
第二个隐患是多核负载均衡。PLIC 时代一个中断源可以在多个 hart context 之间切换,跑 RTOS 时经常故意把网络中断绑到核心 0,把存储中断绑到核心 1。APLIC direct 模式里 target 寄存器虽然是可配的,但配置完一次后,投递目标就固定了;要动态迁移,软件必须重新改写 target 寄存器,并且要处理好"迁移过程中中断源是否还在 pending"的边界。IMSIC 模式下则天然支持更灵活的投递路径,因为它本质上是内存写,只要 APLIC 的 target 地址写到哪里,中断就会投到哪里。
5. 实测定排障:一次 APLIC + IMSIC 迁移中遇到的五个真坑
迁移过程中最痛苦的从来不是架构理解,而是实际调板时那一堆"看起来哪里都对了但就是不行"的诡异问题。下面这五个坑是我真实踩过的,按排查链路写出来,希望能帮你少走弯路。
5.1 坑一:direct 模式的 IDC 中断被 threshold 挡住了
现象:APLIC 的 sourcecfg 配了 direct,target 也指向了 hart 0,外设触发中断后,hart 0 的外部中断线一直不拉高,CPU 永远进不了中断。
排查链路:我先用调试器读了 APLIC 的 target 和 sourcecfg,确认中断源确实配成了 direct 且目标地址正确。接着检查了外设中断信号,确认设备确实把中断拉起来了。最后回头读 hart 0 对应 IDC 的寄存器,发现 IDC 的 threshold 配置成了 0xFFFFFFFF,所有中断的优先级都小于 threshold,直接被挡在门外。
解决:把 IDC 的 threshold 改成合理值(比如 0),同时确认对应中断源在 IDC 中已经使能。这个问题在开发初期特别常见,因为很多人还用着 PLIC 时代"默认阈值就好"的惯性思维,忽略了每个 IDC 都要单独配 threshold。
5.2 坑二:沿用 PLIC 的全局 claim 地址去读 IDC,拿到的中断号是错的
现象:中断能进来,软件读完"claim"之后,拿到的中断号和设备树里声明的对不上,导致所有设备的中断处理都错乱。
排查链路:这部分排障花了我一个下午。一开始以为是 APLIC 的中断号分配逻辑变了,反复查 sourcecfg 和 target 的中断 ID 编码,都对不上。后来我无意间看了一眼驱动里的寄存器地址,发现代码读的还是原来 PLIC 的基地址 + CLAIM 偏移。在 APLIC direct 模式下,这个地址对应的根本不是 IDC 的 claim 寄存器,读出来当然是一堆无意义的噪声数据。
解决:把中断处理函数里"读 claim"和"写 complete"的地址改成该 hart 对应 IDC 的 base + claim 偏移,问题立刻消失。这个坑想提醒大家:设备树改了寄存器地址之后,代码里所有的中断控制寄存器访问也要跟着改,别只换外设驱动的基地址,忘了 irqchip 部分那个"藏得最深"的 claim 地址。
5.3 坑三:IMSIC 中断文件的特权模式对应关系错乱
现象:APLIC 已经把 MSI 发到了 IMSIC,IMSIC 也收到了,软件也到了异常入口,但读 claimi 返回 0,或者读到的是上一个文件残留的中断号。
排查链路:我们用软件触发的方式(直接向某个中断文件的 setipnum 写值)验证每个文件的中断号,发现把事件写到 file 1,CPU 的 S-mode 却感知到了中断。看寄存器基地址时发现,文件 0 和文件 1 的 MMIO 页大小、偏移我搞反了,驱动里初始化文件 0 的代码实际操作的是文件 1 的寄存器。
解决:重新核对 IMSIC 的寄存器布局,确认每个文件和目标特权模式的对应关系。AIA 规范里,中断文件与特权模式的映射是实现可定义的,所以务必以自家 SoC 的芯片手册为准,不要想当然地认为"文件 0 一定是 M-mode,文件 1 一定是 S-mode"。部分实现会把 M-mode 放在文件 0,也有实现会把文件 0 预留给虚拟化环境。
5.4 坑四:MSI 目标地址的字节序/对齐问题导致中断静默丢失
现象:APLIC 配置为 MSI 模式后,外设确实产生了外部中断事件,但没有任何 hart 收到中断,也没有总线错误。看起来就像是中断凭空消失了。
排查链路:用逻辑分析仪抓总线,发现 APLIC 确实发起了对 IMSIC setipnum 地址的写事务,而且事务长度、地址都是对的。但再往下查,发现写入的数据字段里,中断文件号对应的位段和 IMSIC 侧解析的位段不一致——APLIC 侧按小端序组织数据,IMSIC 侧解析时却按大端序读,结果中断文件号被解释成了另一个非法值,IMSIC 直接丢弃了这条 MSI。
解决:统一两端的字节序配置。RISC-V 规范允许小端或大端,但 APLIC 和 IMSIC 这两个 IP 如果在 SoC 内部总线上的连接方式不同,就可能出现这种字节序错位。迁移调试时,建议先用软件向 IMSIC 的 setipnum 写已知值,再读回 pending 位来验证字节序,确认无误后再配置 APLIC 的 MSI 目标地址。
5.5 坑五:设备树里多列了 CPU,导致 IMSIC 驱动以为所有核都能收该中断
现象:系统在 CPU0 上跑得好好的,一旦负载均衡把中断分配到 CPU1,中断就彻底丢了。
排查链路:开始以为是调度问题,后来强制把中断绑核到 CPU0 就正常,绑到 CPU1 就不行。去查 IMSIC 设备树节点时发现,interrupts-extended属性里同时列了 CPU0 和 CPU1 的 S-mode 外部中断线,但 SoC 实现里,外设对应的 APLIC target 只投递到了 CPU0 的 IMSIC。IMSIC 驱动根据设备树建立了"这个控制器服务两个 CPU"的假象,实际硬件资源并不支持。
解决:把设备树改成只列出物理上真实连接到的 CPU。如果确实希望负载均衡,正确做法不是在设备树里硬列所有 CPU,而是通过 IMSIC 的多文件机制,为每个 CPU 分别建立中断文件,再把 APLIC 的 target 按需指向不同的文件。
最后的小结:迁移的节奏与建议
迁移到 AIA 这件事,我的个人建议分三步走。第一步,如果你的场景还不需要虚拟化,也没有大容量 PCIe 中断需求,可以先只用 APLIC direct 模式替换 PLIC,改动最平滑,中断处理代码结构和 PLIC 很接近,只是把全局 claim 换成了 IDC claim。第二步,等业务出现 PCIe MSI 需求,再引入 IMSIC,但可以只让 PCIe 中断走 MSI 路径,传统外设还是走 APLIC direct。第三步,真正做虚拟化时,再把 guest 中断文件打开,把需要直通虚拟机的中断源全部切到 MSI 模式。
最后分享一个调试心得:迁移初期不要急着接真实外设,先用软件触发来验证通路。APLIC 和 IMSIC 都是内存映射控制器,完全可以在固件里直接向 IMSIC 的 setipnum 写值模拟 MSI,确认每个 hart、每个文件、每个 slot 的中断都能正确到达 CPU,再接入真实外设。这套"软件触发验证通路"的思路在后续调试中断亲和性、优先级配置时也一直能用上,常年有效。