做 RISC-V 设备树开发,最绕不开的一个坎就是中断绑定。很多人第一次看 OpenSBI 或 Linux 里的.dts文件,对着interrupt-parent、interrupts-extended、#interrupt-cells这些字段一头雾水,尤其当板子上不止一个中断控制器、同一个外设要响应来自不同中断域的信号时,整个人都麻了。这篇文章不打算给你堆一堆 spec 原文,我就从实际调板子的角度,把 RISC-V 设备树中断绑定的规范、节点怎么写、多父节点路由怎么落地,一条一条捋清楚。
这篇文章适合谁看?两种人。一是刚接触 RISC-V Linux/RTOS 驱动开发,想搞懂设备树中断属性为什么这么写的同学;二是做 SoC bringup,遇到"中断不触发"、"irq domain 挂不上"这种玄学问题,想知道根因和排查思路的工程师。我会先用一段篇幅把 RISC-V 中断硬件路径讲明白,再结合真实 DTS 片段拆解每个属性,最后用一个多父节点路由的完整示例串起来,并分享几个我实际踩过的坑。
1. RISC-V 中断路径:为什么设备树要这么设计
要理解设备树里的中断绑定,不能只背属性名。你得先明白一个中断从外设出发到 CPU 响应,中间过了哪几道手。RISC-V 的中断架构和 ARM 的 GIC 差异很大,如果你是从 ARM 平台转过来的,这一步最容易出认知偏差。
1.1 两级中断域:平台级中断控制器与核级中断控制器
RISC-V 特权架构把中断分成三类:软件中断(software interrupt)、定时器中断(timer interrupt)和外部中断(external interrupt)。前两类由 CLINT(Core Local Interruptor)这类单元管理,外部中断则统一交给 PLIC(Platform-Level Interrupt Controller)或其他平台自定义的中断控制器。关键点在于:PLIC 并不是直接把中断线拉进 CPU 的总线上去的,它通过一条中断信号线,把"有外部中断发生"这件事告诉某个 hart 的特定中断上下文,而这个 hart 核内还有一个自己的中断控制器节点,负责把 PLIC 报上来的信号和本地定时器、软中断一起仲裁处理。
这里就是 RISC-V 和 ARM 在设备树表达上的根本区别。ARM 的 GIC 在系统中通常是一个独立的、层级清晰的树根,外设中断直接指向 GIC。RISC-V 则天然是"两级中断域":第一级是 PLIC 这类平台级控制器,它处理所有外设中断;第二级是每个 CPU hart 内部的cpu-intc,它接收来自 PLIC 的 external interrupt 以及本地 timer/software 中断。设备树里必须把这两级都描述出来,中断信号才能一步步路由到 CPU。
1.2 从外设到 CPU 的信号链路
用一张简化链路来说明(省略具体地址):
UART 外设 --中断线--> PLIC --external interrupt--> hart0 的 cpu-intc --异常入口--> CPU0在设备树中,这条链路的描述方式是反向的。UART 设备节点通过interrupt-parent指向 PLIC,PLIC 节点则通过interrupts-extended指向每个 hart 的cpu-intc。也就是说,父节点声明自己能把中断信号交给谁,子节点声明自己从哪个父节点拿中断。这个反向关系理解透了,后面看 DTS 就不再是猜谜。
1.3 为什么"多父节点"在 RISC-V 上不是罕见需求
由于中断被拆成了平台级和核级两层,实际项目里你几乎必然遇到一个设备同时涉及多个中断源的情况。举个例子:一个带 DMA 功能的 SPI 控制器,DMA 完成中断走 PLIC,SPI 本身的传输错误中断也走 PLIC,但还有一个用于唤醒的低电平中断直接接到 GPIO 控制器;或者更常见的,PLIC 本身和某个 GPIO 中断控制器都作为中断父节点存在,一个音频编解码芯片既用 I2C 中断又用 GPIO 做数据就绪唤醒。这种场景下,"每个设备只有一个中断父节点"的传统写法(interrupt-parent+interrupts)就不够用了,必须上interrupts-extended。这也是标题里"多父节点路由"最直接的落点。
2. 核心属性拆解:从 interrupt-parent 到 interrupts-extended
设备树规范里和中断相关的属性不算多,但每个都有严格的语义和坑。这一节我挑重点逐个拆,不讲废话。
2.1 interrupt-controller 与 #interrupt-cells:中断域的定义
一个节点如果想当别人的中断父节点,必须同时具备两个条件:一是声明interrupt-controller(这个属性不带值),二是用#interrupt-cells说明在它的中断域里,一个中断需要几个 uint32 来描述。
对于标准的riscv,cpu-intc,#interrupt-cells固定为 1,这个值表示中断号。RISC-V 特权规范里规定了一个 hart 的中断号:3 是 machine software interrupt,7 是 machine timer interrupt,9 是 supervisor external interrupt,11 是 machine external interrupt。所以你会看到 PLIC 节点里写interrupts-extended = <&cpu0_intc 11>, <&cpu0_intc 9>, ...,意思是 PLIC 可以把中断抛给 hart0 的 machine 上下文(11)和 supervisor 上下文(9)。很多初学者把11当成 PLIC 自己的中断号,这是错的,它指的是 CPU hart 侧的中断上下文编号。
PLIC 本身的#interrupt-cells通常也是 1,但含义完全不同。它描述的是外设请求线在 PLIC 输入端的编号(context 之外的那个 interrupt ID),比如 UART 接在 PLIC 的第 3 号输入上,就写interrupts = <3>。
2.2 interrupts 与 interrupt-parent:最常见的设备中断写法
一个普通外设节点想要中断,标准写法是:
uart0: serial@10000000 { compatible = "ns16550a"; reg = <0x0 0x10000000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <3>; };这里的逻辑是:先指定interrupt-parent说明我属于哪个中断域,再用interrupts给出该域内的中断编号。interrupt-parent可以就近继承——如果父节点已经写了interrupt-parent,子节点可以不写,自动向上查找第一个带interrupt-parent的祖先。这个向上查找机制看起来方便,实际上是最容易出 bug 的地方。我在实际项目中见过好几回:某个节点没写interrupt-parent,结果继承到了 SoC 级总线的中断父节点而不是预期的 PLIC,中断号对不上,驱动 probe 直接就报invalid IRQ。
我的建议是:关键外设节点一律显式写interrupt-parent,不要依赖继承。虽然啰嗦一点,但可读性和可维护性都好得多。
2.3 interrupts-extended:多父节点路由的钥匙
interrupts-extended是解决"一个设备挂在多个中断控制器下"的标准方案。它的格式是交替出现的 phandle + 中断描述:
interrupts-extended = <&plic 3>, <&gpio0 5 IRQ_TYPE_LEVEL_LOW>;每两个 cell 一组(如果目标控制器#interrupt-cells大于 1,那就是 phandle + 多个 cell):第一个 cell 是中断父节点的 phandle,后面紧跟的就是该父节点中断域内的中断描述。使用interrupts-extended时,可以完全不写interrupt-parent,因为每个中断源都自带了 phandle。这两者天然互斥:interrupts-extended更灵活,能描述多个父节点;interrupts+interrupt-parent更简洁,但只能依赖单一父节点或继承。
有一点需要特别留意:interrupts-extended里的中断描述格式,是跟随每个 phandle 指向的那个节点的#interrupt-cells来的。同一个interrupts-extended属性里,可以混用不同 cells 数量的中断描述,这完全合法。比如上面的例子,&plic的#interrupt-cells是 1,所以后面只跟一个3;&gpio0的#interrupt-cells是 2,所以后面跟了5 IRQ_TYPE_LEVEL_LOW两个 cell。dts 编译器会分别校验,不会一刀切。
2.4 interrupt-names 与中断数组的对应关系
当一个设备节点有多个中断时,interrupt-names用来给每个中断起名字,方便驱动里按语义获取中断号,而不是硬编码 index。
interrupts-extended = <&plic 3>, <&plic 4>, <&gpio0 5 IRQ_TYPE_LEVEL_LOW>; interrupt-names = "rx", "tx", "wakeup";驱动里配合platform_get_irq_byname()或of_irq_get_byname()使用。这个字段的顺序必须严格和interrupts/interrupts-extended里的描述一一对应,否则驱动拿到的中断号和实际设备行为不匹配,调试时非常痛苦。我习惯在写 DTS 时就顺手写上interrupt-names,哪怕当前驱动不用,也能给后续维护的人提供语义信息。
3. 多父节点中断路由的几种典型布置方式
"多父节点路由"落到底层其实无非三种组合:外设同时挂在 PLIC 和另一个 secondary 控制器下、外设挂在某个 secondary 控制器下而 secondary 自己挂在 PLIC 下、以及外设直接挂在 CPU 的 cpu-intc 下绕过 PLIC。这一节我把这三类分别讲透。
3.1 外设同时挂在 PLIC 与 GPIO 控制器下
这是最典型的"多父节点"场景。GPIO 控制器本身有独立的中断输出,也拥有自己的中断域。当 GPIO 引脚都复用给某些需要边沿或电平唤醒功能的外设时,外设节点的唤醒中断就不能走 PLIC,必须走 GPIO 控制器。
假设系统里有这样一个 GPIO 控制器,它自身的中断输出接在 PLIC 的第 7 号输入上:
gpio0: gpio@10002000 { compatible = "vendor,gpio-controller"; reg = <0x0 0x10002000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <7>; gpio-controller; #gpio-cells = <2>; interrupt-controller; #interrupt-cells = <2>; };然后有一个通过 GPIO5 上报数据就绪事件的传感器:
pressure_sensor: pressure@48 { compatible = "vendor,pressure"; reg = <0x48>; interrupt-parent = <&gpio0>; interrupts = <5 IRQ_TYPE_LEVEL_LOW>; };这个传感器只有 GPIO 中断,不涉及 PLIC。如果传感器同时还有 I2C 协议层面的错误中断走 PLIC,就需要在同一个节点里使用interrupts-extended:
pressure_sensor: pressure@48 { compatible = "vendor,pressure"; reg = <0x48>; interrupts-extended = <&plic 42>, <&gpio0 5 IRQ_TYPE_LEVEL_LOW>; interrupt-names = "bus_err", "data_ready"; };这里 PLIC 的 42 号中断是传感器通过 I2C 控制器上报的协议错误中断(只是举例),GPIO0 的 5 号才是数据就绪。两个中断源来自不同中断域,interrupts-extended完美解决。
3.2 次级控制器级联到 PLIC:interrupt-map 与层级关系
有些情况下,板级设计里有一个 PCIe 控制器或一个老式 PIC(Programmable Interrupt Controller),它内部聚合了多个 PCI 设备的中断,自己再输出一条线到 PLIC。在外设看来,它的中断父节点是 PCIe 控制器,而不是 PLIC。这时候设备树里会出现一个"中间层"。
这种级联关系里有个很容易被忽略的问题:中断域转换。PLIC 域里的中断号7是 PCIe 控制器输出的那条线,但 PCIe 控制器域里的某个设备中断号可能是0、1、2、3。Linux irq domain 机制需要为每个节点建立映射关系。设备树里如果 PCIe 控制器支持标准 interrupt-map(比如经典 PCI binding),那还好;如果是一个自定义的次级控制器,厂商驱动里通常要显式创建 irq domain 并实现irq_domain_ops的翻译函数。
现实项目里,级联场景的坑多出在这里:厂商的次级控制器设备树节点忘记声明interrupt-controller,或者#interrupt-cells写错,导致 Linux 无法为它创建 irq domain,下游外设解析中断时直接报错。调试时看到irq: no irq domain found for <phandle>之类的日志,第一反应就是去查它是不是满足了中断父节点的两个必要条件。
3.3 直接绕过 PLIC,绑定到 cpu-intc
还有一种特殊场景:某些 per-hart 的本地中断(比如 hart 之间的 IPI、自定义的快速中断通道)不走 PLIC,而是直接在设备树里的某个节点上通过interrupt-parent指向cpu-intc,并使用 hart 的本地中断号。
cpu0_intc: interrupt-controller { #interrupt-cells = <1>; interrupt-controller; compatible = "riscv,cpu-intc"; }; intc_test: interrupt-test@20000000 { compatible = "vendor,intc-test"; reg = <0x0 0x20000000 0x0 0x1000>; interrupt-parent = <&cpu0_intc>; interrupts = <9>; // supervisor external,其实仍然是 PLIC 转上来的 };要提醒的是:cpu-intc的#interrupt-cells虽然只用一个 cell 表示中断号,但这个中断号是 RISC-V 特权架构定义好的那几个(3/7/9/11),不能用 0 到 15 随便填。如果你写了一个interrupts = <5>,大概率会被内核中断域代码直接拒绝,因为不在该中断域支持的范围内。而且 9(supervisor external)这个中断号实际对应的就是外部中断入口,绕一圈最终还是 PLIC 的信号,所以除非你明确知道自己在干什么,否则不要为了"绕过 PLIC 直接挂 CPU"而乱指。
3.4 属性选择决策表
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 单中断父节点,且父节点明确 | interrupt-parent+interrupts | 简洁,dts 编译校验直接 |
| 父节点可由祖先继承 | 仍建议显式interrupt-parent | 避免继承干扰,维护更安全 |
| 多中断源,同一父节点 | interrupt-parent+ 多个interruptscell | 一个域,不用重复 phandle |
| 多中断源,不同父节点 | interrupts-extended | 唯一合法方案 |
中断源包含不同#interrupt-cells的父节点 | interrupts-extended | 每组描述独立按对应控制器解析 |
| 需要驱动按语义获取中断 | 配合interrupt-names | 可读性最佳,防止 index 错位 |
4. 多父节点中断路由实战:从 DTS 到验证
理论说完,进入实战。这一节我构造一个接近真实项目的场景,给出完整 DTS 片段、编译方法和验证手段。你可以直接把下面的结构套到你自己的板子上。
4.1 一个组合场景的完整 DTS 示例
假设我们有一颗双核 RISC-V SoC,地址布局如下:
- 0x0C000000:PLIC 寄存器
- 0x10000000:UART0
- 0x10001000:UART1
- 0x10002000:GPIO0
- 0x20000000:外部传感器(通过 I2C 挂在 SoC 上,数据就绪信号接 GPIO0 的 pin5)
先写 CPU 节点的中断控制器和 PLIC 节点:
/ { #address-cells = <2>; #size-cells = <2>; compatible = "myvendor,myboard"; cpu0: cpu@0 { compatible = "riscv,cpu"; device_type = "cpu"; reg = <0x0 0x0>; cpu0_intc: interrupt-controller { #interrupt-cells = <1>; compatible = "riscv,cpu-intc"; interrupt-controller; }; }; cpu1: cpu@1 { compatible = "riscv,cpu"; device_type = "cpu"; reg = <0x0 0x1>; cpu1_intc: interrupt-controller { #interrupt-cells = <1>; compatible = "riscv,cpu-intc"; interrupt-controller; }; }; soc { #address-cells = <2>; #size-cells = <2>; ranges; plic: interrupt-controller@c000000 { compatible = "sifive,plic-1.0.0", "riscv,plic0"; #interrupt-cells = <1>; interrupt-controller; reg = <0x0 0x0c000000 0x0 0x4000000>; /* 这台板上 PLIC 可以同时向两个 hart 的 machine 和 supervisor 上下文发送外部中断 */ interrupts-extended = <&cpu0_intc 11>, <&cpu0_intc 9>, <&cpu1_intc 11>, <&cpu1_intc 9>; riscv,ndev = <127>; }; gpio0: gpio@10002000 { compatible = "myvendor,gpio0"; reg = <0x0 0x10002000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <7>; gpio-controller; #gpio-cells = <2>; interrupt-controller; #interrupt-cells = <2>; }; uart0: serial@10000000 { compatible = "ns16550a"; reg = <0x0 0x10000000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <3>; clocks = <&clk_uart0>; }; i2c0: i2c@10003000 { compatible = "myvendor,i2c"; reg = <0x0 0x10003000 0x0 0x1000>; interrupt-parent = <&plic>; interrupts = <4>; #address-cells = <1>; #size-cells = <0>; sensor: pressure@48 { compatible = "vendor,pressure"; reg = <0x48>; /* 一个中断走 PLIC(I2C 控制器代传的协议错误),一个走 GPIO0 */ interrupts-extended = <&plic 42>, <&gpio0 5 IRQ_TYPE_LEVEL_LOW>; interrupt-names = "bus_err", "data_ready"; }; }; }; };这个例子最核心的部分在sensor节点。它同时使用了 PLIC 的 42 号中断和 GPIO0 的 pin5 中断,通过interrupts-extended实现了跨中断域的信号绑定。
4.2 编译检查:dtc 与单元类型校验
写完 DTS 后,先不要急着进内核,用dtc编译检查。标准内核源码里自带编译 DTS 的方式:
make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- dtbs或者如果你只是想快速验证语法,直接用系统自带 dtc:
dtc -I dts -O dtb -o test.dtb test.dts dtc -I dtb -O dts -o test.dump.dts test.dtb编译通过只能说明语法没有大问题,不代表路由逻辑正确。真正有价值的是反编译dtc -I dtb -O dts后,检查中断描述有没有被优化掉或重排。有一个比较隐蔽的坑:如果某个中断父节点的符号没有在 DTS 里定义,dtc会在编译时报Reference to non-existent node or label;但如果 phandle 存在而#interrupt-cells的取值和实际使用不一致,dtc在特定检查项开启时(-Winterrupt_provider等)才会警告,默认未必隐藏问题。所以建议加显式检查项:
dtc -I dts -O dtb -W interrupt_provider -W node_name_chars -W unit_address_vs_reg test.dts4.3 内核启动阶段的验证手段
如果用 Linux 5.x 内核,启动日志里 smp 初始化之后会打印 irq domain 的注册信息。可以用-D DEBUG或者直接看/proc/interrupts来验证中断是否成功映射。
在板子起来后执行:
cat /proc/interrupts重点看两列:IRQ 号和对应的设备名。如果传感器驱动成功 probe,data_ready中断应该在/proc/interrupts里占一行,并且trig类型是 level-low;如果这一行始终不出现,要么是驱动没请求中断,要么是设备树解析阶段就失败了。
查看设备树实际解析结果可以用:
ls /proc/device-tree/soc/i2c0/pressure@48/ cat /proc/device-tree/soc/i2c0/pressure@48/interrupts-extended/proc/device-tree下看到的是扁平化之后二进制属性,interrupts-extended打印出来是一串十六进制数。你按 phandle 顺序去对照,应该能看到 PLIC 的 phandle 和 GPIO0 的 phandle 交替出现。如果长度不对,基本可以断定是 DTS 里#interrupt-cells和实际填写 cell 数不一致。
4.4 使用 perf 与 ftrace 验证中断响应路径
中级一点的验证是确认中断响应到达了预期 CPU。RISC-V 的 PLIC 可以配置中断上下文的目标 hart,即使 DTS 里写了两路(machine 和 supervisor),进程上下文能看到的中断通常都是 supervisor 中断。使用 ftrace 追踪:
trace-cmd record -e irq_handler_entry -e irq_handler_exit sleep 1 trace-cmd report如果触发传感器数据就绪中断,就应该看到对应的 irq number。对照cat /proc/interrupts的编号,确认和你 DTS 里设定的data_ready一致。如果 IRQ 号对不上,问题往往出在 GPIO 控制器的中断翻译回调里,而不是 PLIC 这段。
5. 我在实际调试中踩过的坑:根因与排查链路
这一节写的都是真实事故。我不喜欢给结论不给过程,所以每个坑我都把排查链路还原出来,你以后再遇到类似问题就知道往哪查。
5.1interrupt-names和interrupts-extended顺序错位
有一次我把一个音频编解码芯片的interrupts-extended写了三组中断:一组是 I2C 错误走 PLIC,一组是 FIFO 半满走 PLIC,一组是耳机插入检测走 GPIO。我在 DTS 里写得很规整,但interrupt-names我把顺序写反了,FIFO 半满和耳机插入检测的名字换了个位。驱动里用platform_get_irq_byname("headset")拿到的是 FIFO 中断号,导致软件插拔检测逻辑怎么都不触发。
排查过程:
- 一开始我怀疑 GPIO 中断配置有误,用
devmem直接读 GPIO 中断状态寄存器,发现插入耳机时 GPIO 引脚确实产生了电平变化。 - 进一步读 GPIO 控制器的 IRQ status 寄存器,确认中断在控制器侧已经 pending。
- 但
/proc/interrupts里对应 IRQ 行没有任何统计增加,说明 Linux 侧根本没收到。 - 打个 ftrace 看
irq_handler_entry,发现触发的中断号不是驱动注册的那个,而是另一个 IRQ 数目。 - 回头检查 DTS,才看到
interrupt-names顺序和实际中断列表错位。
这个坑的根因完全是我在 DTS 里"凭感觉"排列名字,没有严格对照。建议养成一个习惯:写完interrupts-extended后,紧接着写interrupt-names,并且按顺序一一注释,减少视觉错位。
5.2 继承的 interrupt-parent 指向了错误的中断域
另一个让我印象更深的坑是,一颗自定义 SoC 的 AXI 总线节点写了interrupt-parent = <&plic>,我当时有个 DMA 控制器挂在 AXI 总线上,DTS 里 DMA 节点自己没写interrupt-parent,我理所当然地以为它会继承总线节点的。实际上 Linux 的 irq domain 解析确实会向上查找,但那次它继承到的不是我在总线节点上写的那个,而是更上层某个soc节点里的interrupt-parent,那个 phandle 指向的根本不是 PLIC,而是同地址段另一个独立的 local irq 控制器。中断号解释完全错乱,DMA 中断每次触发都打到完全不相关的处理函数上。
从那以后我给自己定了个铁律:在 SoC 级设备树文件里,凡是真正用到中断的外设节点,一律显式写interrupt-parent,绝不靠继承。继承机制不是不能用,但它适合的是那种全树统一挂在同一个中断控制器下的简单系统。RISC-V 这种多中断域的系统里,隐式继承是给自己埋雷。
5.3riscv,ndev写小了导致高编号中断被忽略
PLIC 节点里有一个riscv,ndev属性,表示这个平台中断控制器支持的最大中断号。有一次我把这颗 SoC 的实际中断源配到了 130 多号,但 DTS 里riscv,ndev只写了 127。结果是:编号在 127 以内的中断全部正常,编号 128 到 136 的几个外设中断在注册阶段被 irq domain 直接判定为越界,irq_create_mapping返回无效,驱动 probe 失败。
这种问题启动日志往往不明显,因为 PLIC 驱动不会为每个越界中断打印告警,只能看到下游驱动报platform_get_irq返回负数。我当时用的排查手法比较笨但有效:把 DTS 里interrupts的编号逐个往下骑,发现大于 127 的中断全部注册失败,小于等于 127 的都能成功,立即锁定到riscv,ndev。
5.4 PLIC 的 external interrupts 未绑定到 cpu-intc
还有一种非常容易发生在自定义 SoC bringup 初期的错误:PLIC 节点写了interrupt-controller和#interrupt-cells,但忘了写interrupts-extended指向各 hart 的cpu-intc。看起来外设节点的interrupt-parent = <&plic>没问题,编译也过了,但 Linux 启动后所有外设中断都注册失败,因为 PLIC 中断域没有和 CPU 中断域建立联系。
内核日志里最典型的一段是:
irq: no irq domain found for /soc/serial@10000000 !注意这个日志的措辞是"no irq domain found for",主语是设备节点。很多人看到这行第一反应是外设节点写错了,其实根子在 PLIC 节点少了interrupts-extended。PLIC 作为一个interrupt-controller,它必须明确告诉内核"我把中断信号交给哪个 cpu-intc 的哪个上下文",没有这一环,整个域就是孤立的。
我当时补上interrupts-extended = <&cpu0_intc 9>, <&cpu1_intc 9>之后,重启一切正常。后来我总结了一条经验:在 RISC-V 设备树里,PLIC 节点是最容易被"两面夹击"的关键节点——向下缺了它,外设找不到中断控制器;向上缺了它,CPU 中断域和平台中断域之间就没有桥。
6. 多父节点中断绑定的进阶注意事项
前面已经把规范、节点、路由和坑都讲完了,这一节补充几个在复杂项目里才会用到的进阶知识点,我尽量说人话。
6.1 中断域翻译函数与 irq_domain_ops
如果你的次级中断控制器不是标准的 GPIO 控制器或 PCIe 控制器,而是一颗自己家的自定义中断聚合器,那么在 Linux 驱动里除了设备树要写对,还必须正确实现irq_domain_ops的map/translate回调。设备树只是描述了"拓扑",内核要真正把设备节点的interrupts数字翻译成 Linux IRQ 号,靠的就是中断域翻译函数。
很多不熟悉 irq domain 的工程师看到 DTS 里interrupt-parent写得完全正确,但驱动就是拿不到中断,往往是因为map回调里对 hwirq 到 virq 的映射没有正确处理,或者translate没有识别 interrupts-extended 的额外 cell。这时候查设备树是查不出问题的,得在驱动里加打印,确认irq_domain_alloc_irqs_parent是被谁调用、参数是什么。
6.2 设备树 phandle 与覆盖文件中的中断叠加
做 BSP 时,经常会在板级 dts 里 overlay 一个 SoC 级 dtsi。如果 SoC dtsi 里某个外设节点已经写了interrupt-parent = <&plic>,而板级 dts 想把它改到另一个中断父节点,直接重新赋值interrupt-parent是可以的,但要注意覆盖整个节点的interrupts。你不能只改interrupt-parent不重新写interrupts,因为中断描述是跟着新父节点的#interrupt-cells走的,原来的中断号可能在新域里含义完全不同。
更干净的做法是使用interrupts-extended绕过旧配置,因为它自带 phandle,不依赖interrupt-parent。我在量产板定制化时,尽量保留 SoC dtsi 不动,板级 dts 里只追加或覆盖必要属性,并且对中断类覆盖保持"整组重写"的原则,避免出现属性残留。
6.3 与 ACPI / 无设备树环境的中断绑定的对比
RISC-V 也有逐步走向 ACPI 的迹象,但现阶段 Linux 上主流还是设备树。设备树的好处是:它可以精确描述任意复杂的多个中断域拓扑,interrupts-extended本身就是为这种灵活性设计的。ACPI 的中断资源模型更依赖芯片厂商在 MADT/DSDT 里事先定义好的 GSI(Global System Interrupt),灵活性相对弱一些。所以短期内在 RISC-V 平台做中断控制,设备树仍是绕不开的基本功。
从维护角度看,一个足够清晰的 DTS 中断绑定,能减少大量底层驱动和主控逻辑之间的沟通成本。我在团队里推动过一个规矩:任何新增外设的 DTS 中断配置,必须有 30 秒能讲清楚的"中断路由图",否则不允许合入。这个方法很低级,但效果很好,因为它逼着写 DTS 的人真正理解自己在描述的硬件拓扑。
6.4 性能与延迟视角:中断父节点的选择不是随意为之
最后说一个偏架构层面的建议。中断挂在 PLIC 下是默认安全选择,但有些中断对延迟极其敏感,比如高精度定时器或硬件看门狗喂狗信号。PLIC 需要仲裁、跨上下文传递,中断响应的路径较长。如果你的 SoC 提供了独立的 per-hart 本地中断通道(比如某颗实时核的快速中断输入),那么在 DTS 里直接把该外设的中断父节点指到对应 hart 的cpu-intc是合理的。这不只是配置问题,而是硬件层次决定的。
不过这种用法要求你非常清楚核内外设的中断路径,并且确认该外设确实有一根直连 CPU 的中断线。否则 DTS 里写了interrupt-parent = <&cpu0_intc>,硬件上中断信号根本没接进去,那无论怎么调设备树都不会触发。我在一颗异构核 SoC 上见过类似的误区:软件侧把实时核的外设中断挂到主核的cpu-intc,硬件上完全不通,最后拿示波器测才发现信号根本没到主核。
所以最后一个提醒:设备树中断绑定写到一定程度,拼的不再是属性语法,而是对硬件管脚级连接、中断控制器的内部转发路径,以及内核 irq domain 机制三者的综合理解。语法只是表达,真正的难点永远在硬件拓扑和软件模型的映射上。