直接说结论:RISC-V 设备树里的中断绑定,最核心的坑不是手册读不懂,而是你根本不知道中断号应该填几、 parent 该指向谁、多个父节点出现时到底走哪条路由。很多工程师照着 ARM 平台的习惯写 dts,结果在 RISC-V 上要么中断不触发,要么一中断就死机,最后只能一句一句加 printk 在中断处理函数里查问题。这篇文章我把 RISC-V 设备树中断绑定的规范细节、节点写法、多父节点路由方案全部拆开讲,配合实际可操作的方法和排查技巧,适合刚接触 RISC-V 平台开发的嵌入式驱动工程师、BSP 工程师,以及正在做系统移植或调试中断异常的同学。
1. 内容整体设计与思路拆解
1.1 从问题出发:为什么 RISC-V 中断绑定容易出问题
先说一个很多人忽略的事实:RISC-V 内核本身只定义了非常少的异常和中断入口,具体到 SoC 上,绝大部分中断都是通过外置中断控制器(比如 PLIC、APLIC、CLINT)管理的。这意味着设备树里必须把外设产生的硬件中断线映射到对应的中断控制器节点上,内核的 irqchip 驱动才能完成中断号到 irq 的转换。一旦这个映射关系写错,驱动里申请的中断号和硬件实际触发的中断源就对不上,表现出来就是“中断永远不来”或者“来一个中断就触发错误处理”。
ARM 平台上有成熟的 GIC 中断控制器,几乎所有设备树节点都写interrupt-parent = <&gic>,然后interrupts = <0 42 IRQ_TYPE_LEVEL_HIGH>,格式基本统一。但 RISC-V 平台不一样,不同厂家用的中断控制器差异很大。有的用 PLIC 处理外部中断,有的用 APLIC + IMSIC 的组合,还有的简单 SoC 干脆直接把外设中断引到 CLINT 上。每种控制器的#interrupt-cells都不一样,有的只填一个中断号,有的还要加触发类型,有的甚至要区分 M 模式和 S 模式。
这就是为什么 RISC-V 设备树中断绑定不能照搬 ARM 经验。你得先搞清楚三个层面的问题:第一,你的外设中断信号最终接到哪个中断控制器;第二,这个控制器的设备树节点有没有声明interrupt-controller属性和#interrupt-cells;第三,外设节点引用父控制器时,中断号应该按照什么规则填写,是否有多父节点路由的需求。
1.2 制定完整的学习路径
我建议按照“规范 — 节点 — 路由”的顺序来理解整个中断绑定过程,这也符合实际调试时的排查顺序。规范层面要理解设备树中断相关的五个核心属性:interrupt-controller、#interrupt-cells、interrupt-parent、interrupts和interrupts-extended。节点层面要会看中断控制器节点本身长什么样、外设节点怎么引用它、不同 RISC-V 平台上的兼容字符串有什么讲究。路由层面则要掌握多父节点场景下,如何让一个设备的中断在不同的父控制器之间选择或映射。
上手方式上,我个人强烈建议用 QEMU virt 平台做实验。QEMU 的 RISC-V virt 机器内置了 PLIC,设备树导出的结构非常规范,而且可以随时用-machine dumpdtb把设备树导出到宿主机,反编译后再修改,反复测试都不会损坏硬件。等你在虚拟平台上完全理解了中断绑定机制,再移植到真实 SoC 上,遇到问题就能立刻定位是设备树问题还是驱动问题。
2. 核心规范与设备树节点解析
2.1 五个核心属性到底怎么理解
设备树描述中断关系最基础的五个属性,我用最直白的方式解释一遍。
interrupt-controller是一个空属性,它起到的就是“标记”作用。一个节点只要声明了它,内核就认为这个节点是一个中断控制器。经常有新手在中断控制器节点里忘记写这个属性,结果驱动的platform_get_irq永远返回 -EINVAL,怎么查都查不到原因。这个坑踩过一次之后你就记住了:凡是作为中断父节点的设备,必须声明interrupt-controller。
#interrupt-cells表示用几个 32 位无符号整数来描述一条中断。比如 ARM GIC 用了 3 个,前两个表示中断类型和中断号,第三个表示触发方式。RISC-V 平台的 PLIC 大多只用 1 个来表示中断源编号,部分实现用 2 个,第二个填触发类型宏。这个值的单位和含义完全由父节点定义,你去看任何中断控制器的设备树 binding 文档,第一件事就是确认#interrupt-cells到底是几。
interrupt-parent是外设节点用来指定“我的中断信号接到哪个控制器”的属性。它的值是一个 phandle,指向中断控制器节点。可以放在设备节点自身,也可以放在根节点或总线节点上作为默认值,子节点如果不显式指定就继承父级的interrupt-parent。
interrupts是外设节点描述具体中断条目的属性,格式由父节点的#interrupt-cells决定。一个外设可能有多个中断源,那就按父节点定义的 cell 格式重复填。比如父节点#interrupt-cells = <1>,那么interrupts = <7 9>表示外设使用 7 号和 9 号两个中断源,都得由父控制器解析。
interrupts-extended是处理多父节点路由最关键的属性。它的格式和interrupts不同:每一条中断先用一个 phandle 指定父控制器,后面再跟随该父控制器规定的 cell 格式。说白了就是给每一个中断单独指定父亲,而不是全设备共用同一个interrupt-parent。
2.2 中断控制器节点怎么写
RISC-V 常见的平台级中断控制器主要有 PLIC(Platform-Level Interrupt Controller)和 APLIC(Advanced Platform-Level Interrupt Controller)。PLIC 是传统方案,很多 RISC-V SoC 和虚拟机都在用;APLIC 是较新的 AIA(Advanced Interrupt Architecture)规范下的产物,用于替代 PLIC 的部分功能。这里用 QEMU virt 平台的 PLIC 节点举例。
plic0: interrupt-controller@c000000 { #interrupt-cells = <1>; compatible = "sifive,plic-1.0.0", "riscv,plic0"; reg = <0xc000000 0x4000000>; interrupts-extended = <&cpu0_intc 11>, <&cpu0_intc 9>, <&cpu1_intc 11>, <&cpu1_intc 9>; interrupt-controller; };注意看,interrupts-extended本身出现在 PLIC 节点里,这是因为 PLIC 作为中断控制器,它自己也要把“外部中断”这个信号上报给 CPU 核。RISC-V 规范规定:每个核的本地中断控制器接受 8 种异常,其中第 11 号一般是机器模式外部中断(MEIP),第 9 号是监管者模式外部中断(SEIP)。所以 PLIC 的interrupts-extended就把这些 CPU 外部中断和每个核的本地中断控制器绑定。
CPU 核的节点如下:
cpu0_intc: interrupt-controller { #interrupt-cells = <1>; compatible = "sifive,clint0", "riscv,cpu-intc"; interrupt-controller; };CLINT(Core Local Interruptor)在 RISC-V 设备树里通常也叫riscv,clint0,它负责管理定时器中断(MTIP/STIP)和软件中断(MSIP/SSIP)。很多 SoC 会把 CLINT 和 PLIC 分成两个节点,CLINT 管 CPU 私有中断,PLIC 管全平台外部中断。搞清楚这种分工后,你看到设备树里有些外设挂在 PLIC 下、有些挂在 CLINT 下,就不会觉得乱了。
2.3 外设节点的中断绑定格式
外设节点绑定中断的正确写法取决于它的中断父节点是谁。看一个典型的 UART 外设节点:
uart0: serial@10000000 { compatible = "ns16550a"; reg = <0x10000000 0x100>; interrupt-parent = <&plic0>; interrupts = <10 IRQ_TYPE_LEVEL_HIGH>; };这里 PLIC 的#interrupt-cells = <1>,但实例里却写了两个 cell,第二个是触发电平宏。这就产生了一个混乱:QEMU virt 的 PLIC 实际上支持 2 个 cell,第一个是中断源编号,第二个是触发类型。如果你的平台只支持 1 个 cell,那么第二个 cell 会被忽略,触发类型全部按控制器的默认方式处理。所以写设备树之前,必须确认 binding 文档里 PLIC 定义的#interrupt-cells到底是几,不要想当然。
如果外设的父节点是 CPU 本地中断控制器,比如某些定时器设备直接连到 CLINT,写法就简单了:
timer@2000000 { compatible = "riscv,timer"; interrupts-extended = <&cpu0_intc 5>, <&cpu1_intc 5>; };这里第 5 号中断是监管者模式定时器中断(STIP),4 号是机器模式定时器中断(MTIP)。CPU 本地中断控制器的#interrupt-cells = <1>,所以填一个数就够了。
3. 多父节点中断路由实战配置
3.1 为什么会有多父节点路由的需求
在实际 SoC 项目里,一个外设同时连接两个中断控制器的情况并不罕见。比如双核系统里,一个外设的中断既可以路由到主核的 PLIC,也可以路由到从核的 PLIC,软件通过写寄存器来选择最终目标。还有一种情况是 SoC 内部有安全和普通两个中断域,外设需要把不同状态的中断分别上报给不同域的控制器。
设备树里处理这种需求有两种方式:一是用interrupts-extended让一个外设节点分别绑定多个父控制器,二是用interrupt-map在总线节点里做中断域转换。前者更适合“一对多直接连接”的场景,后者更适合“总线上多个设备的中断统一路由”的场景。下面我分别讲。
3.2 使用 interrupts-extended 实现多父节点绑定
假设我们有一个外设节点eth0,它的发送完成中断连接到 PLIC0 的 23 号中断源,接收完成中断连接到 PLIC1 的 45 号中断源,并且两个 PLIC 的#interrupt-cells都是 2(中断号 + 触发类型)。这种情况下直接这样写:
eth0: ethernet@20000000 { compatible = "vendor,eth0"; reg = <0x20000000 0x10000>; interrupts-extended = <&plic0 23 IRQ_TYPE_LEVEL_HIGH>, <&plic1 45 IRQ_TYPE_LEVEL_HIGH>; };内核解析interrupts-extended时,会按照 phandle 找到对应父控制器,再根据父控制器的#interrupt-cells解析后续的 cell。所以你不需要在 eth0 节点里写interrupt-parent,因为每个中断都已经指定了自己的父节点。这种写法在驱动层看,platform_get_irq(dev, 0)返回第一个中断,platform_get_irq(dev, 1)返回第二个,顺序就是设备树里排列的顺序。
实际操作里有一个容易踩的坑:有些工程师会把interrupts-extended和interrupts同时写上去,这是不允许的。设备树规范明确说外设节点应该在interrupts和interrupts-extended里二选一。如果两个都写了,内核一般优先解析interrupts-extended,然后解析interrupts时由于双亲节点冲突,容易触发警告甚至解析失败。我的建议是:只要涉及多父节点,一律使用interrupts-extended,不要混用。
3.3 使用 interrupt-map 做中断域转换
interrupt-map的典型应用场景是 PCIe 设备的中断路由。PCIe 总线每个设备有 INTA、INTB、INTC、INTD 四个中断引脚,而根端口中断控制器只有有限的几个中断号,需要把 PCIe 设备的中断引脚映射到根端口控制器的中断号上。RISC-V 平台如果用到 PCIe,一样要走这个机制。
interrupt-map属性一般放在总线节点中,组成是一系列映射条目。每个条目的格式是:
<子设备地址单元 子中断单元 父控制器phandle 父设备地址单元 父中断单元>
配合interrupt-map-mask使用,先对子设备的地址和中断引脚做掩码匹配,命中后再路由到父控制器。看一个简化的例子:
pcie@0x30000000 { reg = <0x30000000 0x1000000>; interrupt-map-mask = <0 0 0 7>; interrupt-map = <0 0 0 1 &plic0 32 IRQ_TYPE_LEVEL_HIGH>, <0 0 0 2 &plic0 33 IRQ_TYPE_LEVEL_HIGH>, <0 0 0 3 &plic0 34 IRQ_TYPE_LEVEL_HIGH>, <0 0 0 4 &plic0 35 IRQ_TYPE_LEVEL_HIGH>; };这里interrupt-map-mask的最后一个 7 用来匹配 PCIe 中断引脚号(1 到 4)。第一个条目的最后四个数字分别代表 INTA、INTB、INTC、INTD,它们都被映射到 PLIC0 的 32 到 35 号中断源。如果某个 PCIe 设备只发出 INTA 中断,内核在遍历interrupt-map时用掩码匹配到第一条,然后通过 PLIC0 申请中断号 32。
整个路由逻辑中,最容易出错的是掩码位数不匹配。interrupt-map-mask的长度必须和被匹配的单元长度一致,否则匹配结果不可预测。利用dtc反编译设备树后,建议先手工把掩码和每条映射的地址部分对齐,确认没有多写少写,再交给内核解析。
4. 实操过程与核心环节实现
4.1 环境准备与最小验证平台
想快速验证设备树中断绑定是否正确,最好的办法就是用 QEMU。系统不必跑完整 Linux,先从一个最小设备树开始,逐步加节点,观察驱动注册情况。我的做法是:
- 用 QEMU virt 机器导出默认设备树:
qemu-system-riscv64 -machine virt -machine dumpdtb=virt.dtb - 用
dtc -I dtb -O dts virt.dtb -o virt.dts反编译成可读文本。 - 修改或者对照官方 virt 设备树,搞清楚每个中断控制器和外设节点的绑定关系。
- 写一个最简单的混合外设驱动模块,通过
platform_driver注册,在probe里调用platform_get_irq获取中断并申请。
这套流程不依赖真实硬件,可以反复折腾,非常适合把规范吃透。使用真实 SoC 开发板时,操作流程也一样,只是要把默认设备树换成板级厂商提供的 dts 源文件。
4.2 从零创建带中断绑定的外设节点
下面我以一个模拟的 GPIO 控制器为例,演示完整的中断绑定流程。首先在 SoC 设备树里添加中断控制器节点。假设这个 GPIO 控制器内部集成了中断功能,外部引脚中断源统一汇总到 SoC 的 PLIC0 的 66 号中断源。节点写法参考硬件手册:
gpio0: gpio@10002000 { compatible = "vendor,gpio"; reg = <0x10002000 0x1000>; gpio-controller; #gpio-cells = <2>; interrupt-parent = <&plic0>; interrupts = <66 IRQ_TYPE_LEVEL_HIGH>; interrupt-controller; #interrupt-cells = <2>; };注意这里有两组控制器属性:gpio-controller和interrupt-controller同时存在。#interrupt-cells是给下游设备的,表示 GPIO 扩展设备要申请中断时,需要提供两个 cell,一般是引脚号和触发类型。中间的interrupt-parent和interrupts是 GPIO 控制器自己作为“下游设备”向 PLIC0 上报中断的。很多工程师在这里混淆,把#interrupt-cells当成给 PLIC 用的,结果下游设备怎么填都报 wrong interrupt count。
接着,挂在这个 GPIO 控制器下的按键设备节点这样写:
key0 { compatible = "vendor,gpiokey"; interrupt-parent = <&gpio0>; interrupts = <3 IRQ_TYPE_EDGE_FALLING>; };内核解析到这里时,会从 GPIO0 节点获取#interrupt-cells,发现是 2,于是按两个 cell 解析。随后通过 irq domain 把 “GPIO 引脚 3 的下降沿” 映射成虚拟中断号,这个虚拟中断号再和 PLIC0 的 66 号中断建立关联。实际触发时,GPIO 控制器先收到 PLIC0 的 66 号中断,在它的中断处理函数里找出到底哪个引脚产生了事件,再调用 generic_handle_irq 派发给对应的虚拟中断域。
4.3 多父节点路由案例的完整配置
继续扩展场景:如果这个 GPIO 控制器支持把不同分组的中断路由到两个不同的 PLIC,比如 GPIO 0-15 引脚中断进 PLIC0,16-31 引脚中断进 PLIC1,那么 GPIO 控制器节点还可以同时向两个父控制器描述两种中断。这在下游设备看来仍然是一个中断控制器,但上游会有两套中断描述:
gpio0: gpio@10002000 { compatible = "vendor,gpio"; reg = <0x10002000 0x1000>; gpio-controller; #gpio-cells = <2>; interrupts-extended = <&plic0 66 IRQ_TYPE_LEVEL_HIGH>, <&plic1 88 IRQ_TYPE_LEVEL_HIGH>; interrupt-controller; #interrupt-cells = <2>; };这种写法意味着 GPIO 驱动在probe时要分别调用platform_get_irq(dev, 0)和platform_get_irq(dev, 1),并在两个中断处理函数里根据寄存器判断是哪个引脚组的事件。多父节点绑定的核心价值就在这:一个设备驱动可以管理多个中断控制器域的中断源,而不需要把interrupt-parent固定在唯一的父节点上。
这里有一个很关键的操作细节:使用interrupts-extended后,外设节点内部不需要也不应该写interrupt-parent。因为每个中断条目已经用 phandle 显式指定父控制器了,再写interrupt-parent会造成二义性。有些内核版本会报irq: no irq domain found for node,原因就是解析异常。如果你在排查类似问题,第一步就把interrupt-parent注释掉试试。
4.4 内核侧驱动验证与调试方法
设备树写完只是第一步,驱动侧能不能正确取到中断号才是真正的验证。Linux 内核里,平台驱动获取中断最常用的函数是platform_get_irq。它返回的是一个软件中断号(Linux irq number),不是硬件中断源编号。设备树解析流程中,中断控制器驱动会为每个硬件中断线申请一个 Linux irq 号,platform_get_irq拿到的就是转换后的结果。
为了确认绑定正确,我会在驱动probe里加一段最直接的调试代码:
static int my_probe(struct platform_device *pdev) { int irq0, irq1; irq0 = platform_get_irq(pdev, 0); dev_info(&pdev->dev, "irq0 = %d\n", irq0); if (irq0 < 0) return irq0; irq1 = platform_get_irq(pdev, 1); dev_info(&pdev->dev, "irq1 = %d\n", irq1); return devm_request_irq(&pdev->dev, irq0, my_isr, 0, "mydev", NULL); }打印出来的irq值是否合理,需要对照/proc/interrupts查看。如果irq0返回负数,最常见的三种原因:设备树节点没匹配到中断控制器;interrupt-parentphandle 指向错误;#interrupt-cells数量和实际填写数不一致。这些错误用dmesg都能看到线索,但错误信息有时不直接,可能要开启DEBUG级别的 irq 域日志才能定位。
调试 RISC-V 中断时,我强烈建议在启动参数里加上irq_debug或者debug_irqs,内核会在中断申请和释放时打印更详细的信息。另外,用trace_irq_matrix从 tracefs 观察中断矩阵状态,能够直观看到有多少个中断源被注册、每个中断源关联到哪个控制器。这套排查手段比盲目加日志高效得多。
5. 常见问题与排查技巧实录
5.1 中断绑定相关的典型报错速查表
我整理了一个常见问题对照表,都是实际调试中大概率遇到的。放在手边,遇到问题先按表格排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
platform_get_irq返回 -EINVAL | #interrupt-cells与interrupts条目数不匹配 | 反编译 dts,核对控制器节点的#interrupt-cells |
设备树解析时提示invalid phandle | interrupt-parent引用的节点不存在或拼写错误 | 检查 phandle 标签是否在 dtsi 中存在 |
中断注册成功但probe永远不执行 | compatible字符串与驱动不匹配 | 查看/sys/bus/platform/devices/.../modalias |
| 一触发中断就死机或启动卡死 | 中断号填写错误,触发到错误的中断源 | 将interrupts中的数值临时改为 0 观察行为 |
| 中断触发一次后不再触发 | 中断处理函数未正确清除外设挂起位 | 对照数据手册,确认清中断寄存器地址 |
| 多父节点路由时只有第一个中断生效 | interrupts-extended后面条目未被正确解析 | 检查每个 phandle 是否指向interrupt-controller声明的节点 |
这张表其实也体现了排查中断问题的通用思路:先看设备树解析有没有报错,再看 irq domain 映射是否成功,最后回到驱动处理函数去验证硬件行为。大多数问题都不是内核 irqchip 代码有 bug,而是设备树描述的“连接关系”与硬件实际连接不一致。
5.2 独家排查技巧:逆向推导中断号
有一次我在调一块自定义 RISC-V SoC 的外设中断,手册上写的中断源编号怎么都对不上,问我的人一口咬定设备树没问题。后来我把所有外设的interrupts数值全部改成 0,然后逐个外设手动触发中断,通过观察控制器挂起寄存器来确定真实的中断源编号。这个方法虽然土,但非常有效。
具体操作是:先把外设驱动注册成功,但不申请中断,也不使用platform_get_irq,直接通过内核对中断描述符的导出信息反查。比如在/proc/interrupts里看每个中断号对应的中断名称,结合设备树节点名,能快速判断中断号是否互串。如果设备树节点的中断申请到了别的外设中断号上,/proc/interrupts里该中断号的触发计数就会异常增加。
还有一个小技巧:cats /sys/kernel/debug/irq/irqs/<irq_num>可以看到该中断的处理器状态、触发计数、所属设备和控制器信息。当interrupts-extended在多父节点之间配对错误时,这个文件里显示的 irq domain 名称能直接指出问题出在哪个控制器上。配合设备树反编译文件,你甚至能画出完整的中断路由拓扑图。
5.3 实战中容易忽视的操作细节
最后补充几个设备树写法上的细节。interrupts属性的顺序很重要,驱动里platform_get_irq(dev, index)的 index 按设备树里的出现顺序递增,和硬件中断优先级没有任何关系。不要以为第一个中断就是最高优先级,优先级由各控制器的中断号或软件处理顺序决定。
phandle 的使用也有讲究:直接写数值 phandle(比如<&plic0>编译后就是一个 32 位数字)在源码里必须用标签引用,不要手写数字。因为dtc在编译时会给所有带标签的节点分配 phandle,手写数字很难保证和最后导出的 phandle 值一致,一出错就是灾难性的。保持标签引用,编译后需要确认时再反编译看实际值。
另一个细节是中断类型宏。设备树源码里IRQ_TYPE_LEVEL_HIGH、IRQ_TYPE_EDGE_FALLING这些宏来自include/dt-bindings/interrupt-controller/irq.h。如果你的 dtsi 文件没有#include这个头文件,编译时会报 undefined reference。遇到这类报错不要慌,加上 include 路径之后重新编译即可。
多父节点路由时,还要特别注意interrupts-extended的父节点不能是interrupts省略后的默认父节点。如果一个外设节点既想绑定到 PLIC0,又想绑定到 CLINT,你必须写成:
device@40000000 { interrupts-extended = <&plic0 12 IRQ_TYPE_LEVEL_HIGH>, <&cpu0_intc 3>; };而不是把 CLINT 的中断写进interrupts里。否则内核会按照interrupt-parent或根节点的默认父节点去解析,最终中断号错位。
我个人在实际操作中还有一个习惯:每写完一个设备树节点,都会用dtc重新编译并反编译一次,确认属性值和 phandle 与预期一致。特别是多父节点场景,反编译出来的内容能清楚看到interrupts-extended是否被拆成多条独立的中断映射。这一步操作极其简单,但几乎能过滤掉一半以上的低级错误。最后分享一个小技巧:在 QEMU 或开发板的早期启动阶段,用udevadm和设备模型结合核验设备树,比进入用户态之后再去cat各种节点要可靠得多。