
做多核 RISC-V第一道绕不过去的坎儿就是核间中断 IPI。我调第一版双核 SoC 的时候第二个核怎么都拉不起来排查了半天发现根本不是 bootloader 的问题而是 hart0 压根没把“起床信号”送到 hart1——IPI 链路没通后面所有 SMP 逻辑都是空中楼阁。IPIInter-Processor Interrupt是所有 SMP 系统的地基核热插拔、调度器任务迁移、RCU 回调、甚至 panic 时叫停所有核全都依赖它。RISC-V 的 IPI 实现特别有意思它不像 ARM 那样有一整套 GIC而是经历了两代方案的演进。老方案是基于 CLINT 的 MSIP 寄存器往目标核的 MSIP 位写 1 就能触发软件中断新方案是 AIA 规范里的 IMSIC把 IPI 当成一条 MSI 消息直接“投递”到目标核的中断控制器。这篇文章我会从自己做双核/四核 CPU 集成和 Linux 移植的实测经历出发把这两代方案的原理、编程流程、差异对照和踩坑点一次讲透适合做 SoC 集成、BSP/Linux 移植和底层验证的工程师参考。先交代清楚文中涉及的寄存器偏移和地址以最常见的实现为蓝本不同 SoCSiFive、Andes、平头哥等会略有差异落地时一定要以你手里的芯片用户手册为准。我不会把话讲死但会把链路讲透让你拿到手册后能少走弯路。1. 想清楚一件事IPI 到底是谁在给谁“发消息”1.1 哪些场景离不开 IPI很多人觉得 IPI 就是个“高级一点的中断”实际写代码才发现它无处不在。最简单的例子是多核启动系统上电后只有一个 hart通常是 hart0在跑其余 hart 都停在约定好的入口等一个“允许运行”的标记。如果全靠共享内存轮询功耗难看不说时序上也容易出现“主核还没写标记、从核已经开跑”的竞态。用 IPI 就干净了从核在 WFIWait For Interrupt里睡着主核准备好一切后发一个 IPI 把它叫醒。再看操作系统层面。Linux 的调度器要把任务从当前核迁移到另一个核时需要让目标核重新检查 runqueue这个动作叫 reschedule IPI。还有 stop IPI比如 panic 时把所有核停下来抓现场、function call IPI让某个核去执行一段回调内核里的 smp_call_function 就是干这个的、RCU 相关的 IPI以及虚拟化场景下 vCPU 被外部事件唤醒时的“踢一脚”。没有 IPI多核系统就是一群各干各的“独狼”根本组不成团队。所以 IPI 的本质一句话就能说清一个核或设备/虚拟化层主动产生一个事件去打断另一个核正在执行的指令流并让它跳到一个约定的处理函数里。搞清楚这个本质再看 RISC-V 的两种实现很多细节就顺了。1.2 两代方案的本质区别RISC-V 之前很长一段时间里核间中断没有统一规范大家各做各的。比较常见的是 SiFive 的 CLINTCore Local Interruptor靠一组内存映射寄存器来表示“哪个核有软件中断待处理”这就是 MSIPMachine Software Interrupt Pending。等 AIAAdvanced Interrupt Architecture规范落地后推出了 IMSICIncoming Message Signaled Interrupt Controller它是专门为 MSI 消息设计的每核中断控制器IPI 变成了一条“有地址、有数据”的消息投递。这两个方案的差异用一张表能看得很清楚对比项CLINT/MSIPIMSICAIA中断模型写 1/0 到内存映射寄存器改一个 pending 位向目标核的中断控制器页写入 32 位消息中断信息量单 bit只有“有/无”具体类型靠软件猜支持向量号最多可区分 2047 种 IPI 类型接收侧中断类型固定为软件中断mcause3 / scause1路由为外部中断mcause11 / scause9优先级/阈值控制无eithreshold、topei可做优先级过滤虚拟化支持无支持 guest interrupt file可注入虚拟 IPI扩展性全局共享一套 MMIO核多了是瓶颈每核独立页天然适合多核与虚拟化老方案胜在简单、实现成本低小规模系统完全够用新方案胜在语义丰富、扩展性好是当前 RISC-V 走向服务器和虚拟化场景的必经之路。下面两章分别展开最后用一组实战代码把两套流程串起来。2. 第一代方案CLINT 的 MSIP 寄存器慢在哪、怎么用2.1 CLINT 长什么样内存映射的“开关矩阵”CLINT 本质上是一个挂在系统总线上的外设里面放了一堆和中断相关的寄存器。最常见的布局是基地址很多 SiFive 方案上是 0x02000000开始是一段 msip 数组每个 hart 占 4 字节再往后是 mtimecmp 数组每个 hart 占 8 字节最后是 64 位的 mtime一个全局自由运行计时器。msip 数组就是 IPI 的核心。对 hart n它的 MSIP 寄存器地址是base 4 * n。这个寄存器只有一个有效位bit 0写 1 表示“给 hart n 挂起一个机器软件中断”写 0 表示“清除这个挂起状态”。代码上就是一个极简的 MMIO 写操作#define CLINT_BASE 0x02000000UL #define CLINT_MSIP(hart) (CLINT_BASE 0x0000UL ((hart) 2)) static inline void clint_send_ipi(uint32_t hart) { volatile uint32_t *msip (volatile uint32_t *)CLINT_MSIP(hart); *msip 1; /* 置 1目标 hart 的 mip.MSIP 变为 1 */ } static inline void clint_clear_ipi(uint32_t hart) { volatile uint32_t *msip (volatile uint32_t *)CLINT_MSIP(hart); *msip 0; /* 清 0目标 hart 的 mip.MSIP 变为 0 */ }这里有个关键认知从 hart 自己的视角看mip.MSIP 这个位是只读的软件没法通过写 mip 寄存器来清除它。MSIP 的置位和清除都只能由“外部”通过写 CLINT 的 MMIO 寄存器完成。这跟 RISC-V 里其它中断位有本质区别我第一次写裸机 IPI 程序就在这上面栽了跟头ISR 里拼命写 mip 想清中断结果中断永远清不掉陷入死循环。2.2 一条 MSIP IPI 的完整生命周期现在把一条 IPI 从发起到处理完的完整生命周期走一遍。假设 hart0 要给 hart1 发 IPIhart0 执行clint_send_ipi(1)也就是对 CLINT 的 msip[1] 写 1。CLINT 硬件把 hart1 的 mip.MSIP 位置 1。hart1 如果满足三个条件就会进入中断异常mstatus.MIE1全局中断使能、mie.MSIE1软件中断使能、mip.MSIP1中断挂起。hart1 跳转到 mtvec 指向的异常入口mcause的最高位为 1 表示中断低 12 位值为 3也就是 machine software interrupt。ISR 里先调用clint_clear_ipi(hart1)写 0 清掉挂起位再根据事先约定好的共享内存 mailbox 判断这次 IPI 是什么类型的请求执行对应处理。接收侧的初始化大致是这样static void hart1_ipi_init(void) { set_csr(mstatus, MSTATUS_MIE); /* 全局开中断 */ set_csr(mie, MIP_MSIP); /* 使能软件中断 */ /* mtvec 已在启动时指向 trap_entry */ } void trap_entry(void) { unsigned long cause read_csr(mcause); if ((cause MCAUSE_INTR) ((cause MCAUSE_CAUSE) 3)) { clint_clear_ipi(1); handle_ipi_mailbox(); } }如果你是在 S-mode 跑 Linux这条路径通常会被 SBI 固件包一层操作系统通过sbi_send_ipi()把 IPI 请求交给 M-mode 固件去写 MSIP。但裸机或自研内核场景下上面的代码就是全部真相。2.3 MSIP 方案的四个硬伤MSIP 在单核或少核场景下很好用但规模一上来就会暴露问题。第一是全局共享 MMIO 的瓶颈。所有核发 IPI 都要去抢同一块 CLINT 地址空间的总线访问权核数一多写 MSIP 的延迟和总线竞争会明显上升。更麻烦的是这个问题伴随缓存一致性的额外负担MSIP 寄存器是普通 MMIO不经过缓存每次写都是一次完整的对外总线事务。第二是信息量太少。MSIP 只有一个 bit它只能告诉接收核“你有一个软件中断”至于这个中断是 reschedule、是 stop、还是函数调用请求接收核完全不知道只能去共享内存里读 mailbox。于是多了一层软件约定的耦合发送方要先写 mailbox 再发 IPI接收方要读 mailbox 才知道干什么还要处理“写了 mailbox 但 IPI 还没到”的乱序问题。这个竞态非常隐晦我在调试时见过多次“mailbox 已经被新任务覆盖、但前一个 IPI 才刚到达”的情况。第三是完全不支持虚拟化和优先级。虚拟机里的 vCPU 想要互相发 IPI如果没有硬件辅助hypervisor 只能截获 guest 写 MSIP 的动作然后模拟路径长、开销大。而 AIA 之前的方案里根本没有优先级概念所有软件中断一视同仁这在延迟敏感场景下很难做调度优化。第四是触发语义太死板。MSIP 是电平型挂起位你往 msip 写 1只要不清 0它就一直是 1。如果发送方连续发两次 IPI而接收方处理速度很快、还没来得及清 0 前又写了一次 1第二次写就完全丢失。正确做法是“清 0 → 再置 1”中间还要保证有足够的时间让接收方看到清 0这在高速多核场景下会引入额外延迟。3. 第二代方案IMSIC 把 IPI 当成消息投递3.1 从“改寄存器”到“发消息”AIA 规范推翻了“IPI 就是置位一个 bit”的套路改成了一种更现代的模型IPI 是一次 MSIMessage Signaled Interrupt消息投递。所谓 MSI就是中断不需要一根专门的物理中断线而是由发送方对接收方的某段 MMIO 地址发起一次写操作写的数据里携带中断信息。落到 IMSIC 上每个 hart 都有一组自己的中断控制寄存器页。发送方想给目标 hart 发 IPI只需要往目标 hart 对应的setipnum寄存器写一个 32 位数据即可。这个动作在总线上看和一块 PCIe 网卡发 MSI 完全一样——事实上这正是 IMSIC 的设计初衷无论是 CPU 发 IPI还是设备发中断走的是同一条消息通路。我在调试时经常跟同事说IMSIC 把 IPI 从“喊一嗓子”变成了“寄一封信”信封上有收件人地址信纸上写了事由和优先级快递员硬件负责把它放到正确的邮箱里。写入的数据里低 12 位是向量号vector用来区分不同 IPI 类型。比如向量 1 表示 reschedule向量 2 表示 stop CPU向量 3 表示 function call接收方拿到向量后直接跳转处理不再需要去 poll mailbox。向量 0 是无效的规范里明确写 0 的消息会被忽略这个设计避免了软件误发 0 导致的各种匪夷所思的问题。3.2 IMSIC 寄存器地图每核一页各管各的IMSIC 的寄存器布局是每核独立一页常见实现里一页 4KB页内偏移固定。核心寄存器就那几个用熟了之后比 CLINT 还简单偏移寄存器作用0x00setipnum写操作向本文件递交一条 MSI发送 IPI 就写它读操作取走当前最高优先级 pending 中断的向量并清除该 pending即 claim0x04setipnum_lesetipnum 的小端变体主要给大小端不一致场景使用0x08clripnum写操作完成一个中断后回写向量表示“我已经处理完了”complete/EOI0x70eidelivery中断投递总开关写 1 后pending 的中断才被允许送达 hart0x72eithreshold16 位中断阈值低于该阈值的中断不投递0x78topei只读当前最高优先级 pending 且已使能的中断信息低 12 位是向量号这里最反直觉的是setipnum寄存器“读和写是两回事”。写它是“发中断”读它却变成了“收中断”。我第一次看到这段代码时愣了好一会儿后来想通了这就是 MSI 邮箱的物理体现发送方往邮箱里投信写接收方从邮箱里取信读而“取走最高优先级的那封信”这个动作本身就是中断应答。3.3 接收侧的 claim/complete 编程序列IMSIC 的接收侧编程跟传统中断最大差别就是必须显式完成 claim 和 complete 两个动作。所谓 claim就是读取 setipnum拿到当前优先级最高的 pending 中断向量同时硬件自动把这个中断的 pending 位清掉避免没处理完就重复进入。所谓 complete就是处理完之后把同一个向量写回 clripnum告诉 IMSIC“这个中断我处理完了可以接受下一个同向量或同优先级的中断了”。#define IMSIC_BASE 0x28000000UL /* 以实际 SoC 手册为准 */ #define IMSIC_FILE_SIZE 0x1000UL #define IMSIC_SETIPNUM 0x00UL #define IMSIC_CLRIPNUM 0x08UL #define IMSIC_EIDELIVERY 0x70UL #define IMSIC_EITHRESHOLD 0x72UL #define IMSIC_ADDR(hart, reg) \ (volatile uint32_t *)(IMSIC_BASE (hart) * IMSIC_FILE_SIZE (reg)) static void imsic_init(uint32_t hart) { /* 先把阈值设 0避免阈值把普通 IPI 挡在门外 */ *(volatile uint16_t *)IMSIC_ADDR(hart, IMSIC_EITHRESHOLD) 0; /* 打开投递总开关这步忘了设中断永远不进来 */ *IMSIC_ADDR(hart, IMSIC_EIDELIVERY) 1; } static void imsic_send_ipi(uint32_t target_hart, uint32_t vector) { /* 往目标核的 setipnum 写向量号完成一次消息投递 */ *IMSIC_ADDR(target_hart, IMSIC_SETIPNUM) vector; } static void imsic_handle_ipi(void) { uint32_t hart read_csr(mhartid); /* claim读 setipnum返回向量并清除 pending */ uint32_t vec *IMSIC_ADDR(hart, IMSIC_SETIPNUM); if (vec IPI_VEC_RESCHEDULE) do_reschedule(); else if (vec IPI_VEC_STOP) do_stop(); /* ... 其它向量 ... */ /* complete处理完回写同向量到 clripnum */ *IMSIC_ADDR(hart, IMSIC_CLRIPNUM) vec; }为什么必须有 complete 这一步因为 IMSIC 要保证同一个向量或同一优先级的中断不会被连续轰炸。你在处理向量 1 的过程中如果同样的向量 1 又来了硬件会把它记成 pending但要等你写完 clripnum 之后才允许再次投递。漏写 complete 的经典症状是第一个 IPI 正常处理第二个 IPI 永远不来——这是我在移植时踩过的最隐蔽的坑之一。另外注意发送方的imsic_send_ipi写完 setipnum 之后最好加一条内存屏障比如fence w,w再去做其它事。我在高主频、多发射的核上实测过不加屏障时偶发“发送方已经认为 IPI 发出、接收方却没收到”的情况加屏障后彻底消失。3.4 虚拟化场景下的扩展guest interrupt fileIMSIC 能成为 AIA 核心部件的另一个原因是它天生支持虚拟化。每个 hart 的 IMSIC 不止一个中断文件除了物理文件给 M/S-mode 用还可以实现多个 guest interrupt file每个文件对应一个虚拟机。hypervisor 想给某个 guest 注入一个虚拟 IPI 时直接往对应 guest 文件的 setipnum 写向量号即可guest 里的 vCPU 会以一个虚拟外部中断的形式收到它整个过程不需要 trap 到 hypervisor路径非常短。这个能力对跑 SMP guest 至关重要。KVM 或其它 hypervisor 要把一个 vCPU 从 WFI 里踢醒传统做法是通过系统调用模拟一次 IPI开销很大有了 IMSIC 的 guest 文件一次普通 MMIO 写就完成了。寄信的人连“收件人是否在睡觉”都不用关心消息先挂在邮箱里vCPU 一旦允许中断就会被投递。这套机制让 RISC-V 在虚拟化场景下的 IPI 延迟和 x86 的 posted interrupt 有了对标的基础。4. 双核 IPI 最小实战从 MSIP 迁到 IMSIC4.1 实战场景与总体流程下面用一个最简单的双核裸机场景把两套方案串起来hart0 作为主核初始化完成后向 hart1 发送 IPI把一条“工作任务 A”的通知投递给 hart1hart1 收到后打印状态位、完成任务并清状态。为了对照我会同时给出 MSIP 和 IMSIC 两份关键代码。整体流程分六步两个 hart 各自完成栈和 mtvec 初始化进入公共入口。hart0 完成 IMSIC或 CLINT初始化打开 hart1 的中断投递使能。hart1 使能对应中断位后执行 WFI 睡眠。hart0 调用发送函数向 hart1 投递 IPI 向量。hart1 被唤醒进入 trap根据 mcause 判断是软件中断还是外部中断。处理完成后返回 WFI等待下一条 IPI。4.2 关键代码与配置先看 trap 入口它同时兼容两套方案用 mcause 的低位区分void __attribute__((interrupt)) trap_entry(void) { unsigned long cause read_csr(mcause); unsigned long code cause MCAUSE_CAUSE; uint32_t hart read_csr(mhartid); if (!(cause MCAUSE_INTR)) { /* 异常而非中断这里先不管 */ return; } if (code 3) { /* 老方案机器软件中断来自 CLINT MSIP */ clint_clear_ipi(hart); handle_ipi_mailbox(); } else if (code 11) { /* 新方案机器外部中断来自 IMSIC */ uint32_t vec imsic_claim(hart); handle_ipi_vector(hart, vec); imsic_complete(hart, vec); } }主核发送侧MSIP 版本和 IMSIC 版本放在一起对比更直观static void hart0_main(void) { /* 公共初始化栈、mtvec、页表等 */ /* 老方案使能 hart1 的软件中断 */ /* 这一步在 MSCP 场景由 hart0 直接写 CLINT 即可 但 hart1 的 mie.MSIE 和 mstatus.MIE 需要它自己开好 */ /* 新方案让 hart1 的 IMSIC 处于可投递状态 */ imsic_init(1); /* 确保前面的配置对 hart1 可见 */ __asm__ volatile(fence w,w ::: memory); /* 发送 IPI按你的系统选择其中一种 */ // clint_send_ipi(1); /* MSIP 老方案 */ imsic_send_ipi(1, IPI_VEC_WORK_A); /* IMSIC 新方案 */ /* 等待 hart1 完成任务 */ while (!task_done[1]) ; }从核侧则要自己开好对应中断使能位。这里有个特别容易错的地方MSIP 方案开的是中断使能位mie.MSIEbit 3而 IMSIC 方案开的是mie.MEIEbit 11因为 IMSIC 的消息在常见配置下走的是外部中断通道而不是软件中断通道。如果从核只开了 MSIE 没开 MEIEIMSIC 的中断永远不会响但你在寄存器里看 eidelivery、pending 全是对的非常迷惑static void hart1_secondary(void) { /* 公共初始化 */ /* 老方案使能机器软件中断 */ // set_csr(mie, MIP_MSIP); /* 新方案使能机器外部中断 */ set_csr(mie, MIP_MEIP); set_csr(mstatus, MSTATUS_MIE); while (1) { __asm__ volatile(wfi); /* 醒来后实际工作在 trap_entry 里完成 */ } }接收侧的向量分发很简单static void handle_ipi_vector(uint32_t hart, uint32_t vec) { switch (vec) { case IPI_VEC_WORK_A: task_done[hart] do_work_a(); break; case IPI_VEC_WORK_B: /* ... */ break; default: /* 未知向量至少可以做一次记录排查 */ break; } }4.3 常见问题自查表实际调试中IPI 出问题时的表象往往很相似——“从核没反应”但根因五花八门。我把踩过的和帮同事排查过的典型问题整理成一张速查表症状可能原因排查/修复方法从核永远醒不来WFI 一直睡中断使能位没开对MSIE vs MEIE用 debugger/printf 读 mie、mstatus 确认从核能醒但一直卡在同一个 ISR老方案清 MSIP 失败mip.MSIP 一直为 1确认 ISR 里写的是 CLINT 地址不是写 mip第一个 IPI 正常第二个丢失漏了 IMSIC 的 complete 写 clripnum确认处理完回写向量断了别处中断源后 IPI 也没了IMSIC 的 eidelivery 被复位为 0初始化时显式写 1低优先级 IPI 不响应eithreshold 设高了比如设了 1阈值设 0或确保中 断优先级不低于阈值发送方写 setipnum 后接收方长时间没收到缺少内存屏障写操作被缓冲发送后加fence w,w或等价的强屏障读 setipnum 拿到 0但明明发过向量号写成了 0被硬件忽略检查发送侧向量值必须非 0同一类型 IPI 连续触发时丢事件这是 level 型语义不是队列需要发送侧“处理完再发一次”或在软件侧改用计数 mailbox这些坑看着小但在多核环境里排查成本极高因为每个核都在并发跑日志乱序、寄存器视图不一致、时序一抖就复现不出来。我的建议是先关掉其它中断源只留 IPI用最简单的“收到一次 IPI 置一个计数”来验证链路链路通了再加复杂度。4.4 三条独家避坑心得最后分享三条不太容易从手册里看出来的经验。第一条关于自发送。裸机调试时我经常让一个核给自己发 IPI 来验证链路但 MSIP 方案里自发送要格外小心如果mstatus.MIE还没打开就先写了 MSIP等开中断后第一个 IPI 会触发但如果 ISR 在清 0 之前又写了一次 1就会变成“永远处理不完”。所以自发送场景建议直接上 IMSICclaim/complete 的语义清楚得多。第二条关于虚拟化环境的验证。如果你在公司环境里跑的是带 AIA 的 QEMU 虚拟机比如-machine virt,aiaaplic-imsic可以用它来做 IMSIC 流程的预研但千万别把 QEMU 的行为当成硬件真相。QEMU 的 IMSIC 实现偏向“功能正确”不太会暴露真实硬件上内存序、总线延迟、页地址对齐这些问题。芯片回来后在 FPGA 或样片上复跑一遍才算数。第三条关于 regresssion 测试。IPI 最容易在“高频率、短时间、跨核并发”的压力下出问题。我在每次修改总线矩阵或缓存一致性逻辑后都会跑一段“全核互发 IPI mailbox 校验”的压测一般跑满几十分钟不丢一个事件才敢合入。别小看这个动作我当时有好几个隐蔽的 cache line 对齐问题都是靠它抓出来的。我在实际项目中有一个特别深的体会IPI 看似只是一条中断但它横跨处理器核、总线、中断控制器、固件四个层面任何一个层面有一点小问题表象都会是“另一个核不理我”。把 MSIP 和 IMSIC 两代方案的链路走通一遍你对整颗 SoC 的中断路径、内存序模型和虚拟化支持都会比原来清晰一大截。如果你正在做多核 RISC-V 的集成或移植我的建议是先写一个最小双核 IPI demo比对着手册把这套代码里面的每一条写操作都讲清楚为什么然后再去碰操作系统——地基稳了上面盖楼才不慌。