
调 RISC-V 平台十有八九会在设备树中断绑定上栽跟头。我见过最典型的问题外设的中断号写对了驱动也 probe 成功了可一申请 IRQ 就报-EINVAL或者中断路由到了某个核另一个核怎么都收不到更隐蔽的是 PLIC 节点里interrupts-extended的上下文顺序写反导致 S-mode 驱动永远拿不到中断。这些问题不解决cat /proc/interrupts只能看到满屏问号和 0 次数。这篇文章说的是设备树中断绑定重点放在 RISC-V 平台上的规范解析、节点写法以及多父节点中断路由的实战。很多人一看到“路由”两个字容易联想到软路由、策略路由其实这里说的是中断信号怎么从外设分发到中断控制器、再送到哪个 CPU 中断上下文的过程。无论你调的是 ARM 的 RK3568还是 RISC-V 上的 D1、K230 这类 SoC设备树中断绑定语法都是同一套区别只在中断控制器自己的#interrupt-cells定义。所以这套内容不仅适用于 RISC-V也适合所有做嵌入式 Linux 驱动和 BSP 的工程师。下文我按“先看规范、再拆节点、后讲实战、最后说排错”的顺序展开全程用 RISC-V PLIC 和实际节点示例说话。跟着走一遍中断绑定这块基本不会被忽悠。1. RISC-V 中断体系与设备树的对应关系1.1 RISC-V 的异常与中断CSR 比特位如何映射到设备树RISC-V 的中断不像 ARM 那样统一叫 GIC它是分层描述的。每个 CPU 核有自己的一套中断控制 CSRs比如mie、mip、sie、sip。外部中断在mie里的对应位是 bit 11M-mode 外部中断和 bit 9S-mode 外部中断。设备树里那些11、9的数字不是随便编的就是 CSR 里的中断位编号。这就能解释为什么 RISC-V 的 CPU 节点下要挂一个riscv,cpu-intc。在设备树里每个 CPU 核都带一个中断控制器节点专门描述这个核本地能接收哪些中断。典型写法长这样cpu0: cpu0 { compatible riscv; reg 0; cpu0_intc: interrupt-controller { compatible riscv,cpu-intc; interrupt-controller; #interrupt-cells 1; }; };这里的#interrupt-cells 1表示用 1 个 cell 来表示 CPU 上下文中的中断号通常就是11或9这类值。如果没搞清楚这一层看 PLIC 节点时就会犯迷糊为什么一个中断控制器要接那么多 CPU 中断控制器原因就是 RISC-V 的全局中断控制器PLIC要把同一路中断分发到不同 CPU 的不同特权模式。设备树里interrupts-extended cpu0_intc 11, cpu0_intc 9这类写法并不是说外设直接用interrupts-extended访问 CPU而是在描述 PLIC 的输出接到了哪些 CPU 上下文上。1.2 PLIC、CLINT、APLIC/IMSIC不同中断控制器在设备树里的角色RISC-V 平台最常见的两个中断控制器是 CLINT 和 PLIC。CLINT 负责定时器中断和软件中断PLIC 负责外部中断。在设备树里CLINT 往往是riscv,clint0PLIC 往往是sifive,plic-1.0.0或者riscv,plic0。名字不同解析规则也不同但核心属性是一致的interrupt-controller声明自己是中断控制器。#interrupt-cells声明需要几个 cell 来描述一个中断。interrupts-extended或interrupt-parent/interrupts声明自己的中断输出接到了哪里。一个标准 PLIC 节点的简化写法plic: interrupt-controllerc000000 { compatible sifive,plic-1.0.0; reg 0x0 0xc000000 0x0 0x4000000; interrupt-controller; #interrupt-cells 1; interrupts-extended cpu0_intc 11, cpu0_intc 9; riscv,ndev 127; };注意riscv,ndev这个属性它告诉内核这个 PLIC 最大支持的中断源编号。外设节点里interrupts xxx的值不能超过它否则内核解析时会直接报错或丢弃。新一点的 RISC-V AIA 规范把中断体系又更新了出现了 APLIC 和 IMSIC。APLIC 相当于增强版 PLICIMSIC 负责 MSI 类型的中断。AIA 平台的设备树节点格式和传统 PLIC 不完全一样#interrupt-cells和属性名要仔细看对应 binding 文档不能想当然套老写法。2. 中断绑定三件套interrupt-parent、interrupts、interrupts-extended2.1 interrupt specifier 与 #interrupt-cells 的关系设备树里描述一个中断最少需要两部分信息中断控制器在哪里以及中断号/触发方式是什么。interrupt-parent用来指定中断控制器interrupts用来写中断控制器视角下的中断 specifier。“中断 specifier”的样子完全由中断控制器的#interrupt-cells决定。比如 PLIC 的#interrupt-cells 1那么interrupts 10就表示 PLIC 的第 10 号中断。如果你的中断控制器是#interrupt-cells 2那interrupts里就要跟两个 cell比如 GPIO 控制器常见的是引脚号 触发标志。很多人写错是因为把 ARM GIC 的习惯带过来。GIC 的#interrupt-cells通常是 3所以interrupts 0 15 4这种三个值在 RISC-V 上完全不适用。RISC-V PLIC 下写10 4反而会让内核把多余的 cell 当成下一个中断的起始位置解析错位后可能直接不 probe。所以记住第一原则先查中断控制器的 binding确认#interrupt-cells是几再写interrupts。2.2 interrupt-parent 的继承和覆盖设备树规范里interrupt-parent不是每个节点都必须写。如果一个节点没有显式写interrupt-parent内核在解析时会向上一级父节点找直到找到一个带interrupt-parent的节点或者interrupt-controller节点。这就带来一个坑父节点写错子节点跟着全错。比如 SoC 一级总线上如果写了interrupt-parent plic那下面所有外设节点默认都把中断发给 PLIC。某些外设偏偏不走 PLIC直接连到了 GPIO 控制器或者某个专用中断控制器那它必须在自己的节点里重新指定interrupt-parent否则就会被父级属性带偏。写法上最常规的单父节点外设是这样的uart0: serial10000000 { compatible ns16550a; reg 0x0 0x10000000 0x0 0x1000; interrupt-parent plic; interrupts 10; };如果不想每个外设都写interrupt-parent可以在它们共同的父节点上统一写一次。但如果一个节点下挂了几十个外设只有一两个特例我建议还是统一写在父节点然后在特例外设里单独覆盖。这样设备树文件看起来更干净排查时也更容易一眼看出异常。2.3 interrupts-extended一个设备接多个中断控制器的正规写法interrupts-extended是interrupt-parentinterrupts的替代方案它的优势在于可以同时引用多个中断控制器。格式每个条目都以一个 phandle 开头后面跟着该中断控制器需要的 cells。比如一个外设有两个中断输出一个连到 PLIC一个连到 GPIO 控制器。写成keypad: keypad... { compatible vendor,keypad; reg 0x0 0x... 0x0 0x...; interrupts-extended plic 32, gpio0 5 2; interrupt-names intc, wakeup; };这里的plic 32是 PLIC 的 32 号中断gpio0 5 2是 GPIO 0 的第 5 引脚触发类型是下降沿数字 2 对应IRQ_TYPE_EDGE_FALLING。用interrupts-extended时就不要再写interrupt-parent和interrupts了二者是互斥的。内核解析时of_irq_parse_one会优先检查interrupts-extended如果存在就直接按它解析没有才去走interrupt-parent流程。混用就容易出现“改了interrupts没反应”的怪现象。3. 多父节点路由实战从需求到完整节点3.1 先分清“中断路由”和“策略路由”做网络的人听到“路由”两个字脑子里是路由表、下一跳、策略路由甚至软路由、旁路由。但设备树里的中断路由完全是另一回事。中断路由解决的是某个外设产生的中断信号最终要能被哪一个中断控制器接收、再送到哪个 CPU 的哪个特权模式去处理。RISC-V 里PLIC 可以把同一路外部中断分发到多个 CPU 上下文而设备树描述的就是这份“分发关系”。所以这里说的“多父节点路由”本质是外设节点里有多个中断父控制器或者有一个中间的 interrupt-map 把子中断重新映射到不同的上层控制器。3.2 外设同时连 PLIC 和 GPIO 的完整节点示例一个实际场景系统里有一个按键控制器平时用 PLIC 普通中断处理按键事件同时它还有一个独立的唤醒中断线连到 GPIO 控制器用来在休眠时唤醒系统。这样在设备树里就需要同时描述两个中断来源。完整节点写法如下keypad { compatible vendor,gpio-keys-ext; reg 0x0 0x20000000 0x0 0x1000; interrupt-parent plic; interrupts 32; wake-gpios gpio0 5 GPIO_ACTIVE_LOW; interrupts-extended plic 32, gpio0 5 2; interrupt-names intc, wakeup; };等等这段代码里我同时写了interrupt-parent/interrupts和interrupts-extended这是故意的吗不是这是我要提醒的反面教材。实际编写时只保留interrupts-extended即可keypad { compatible vendor,gpio-keys-ext; reg 0x0 0x20000000 0x0 0x1000; interrupts-extended plic 32, gpio0 5 2; interrupt-names intc, wakeup; };驱动侧用platform_get_irq_byname(pdev, intc)和platform_get_irq_byname(pdev, wakeup)分别拿两路中断。这里有个实用技巧interrupt-names能大幅降低代码阅读成本强烈建议在interrupts-extended里每个条目都配一个名字。如果某个中断源不需要被 Linux 申请但你又必须在设备树里把它描述清楚可以用interrupts-extended把它引到某个 dummy 中断控制器上。不过这种绕法不建议新手用容易把自己绕晕。3.3 中断控制器级联与 interrupt-map 的实际用法多父节点路由还有一种常见场景是级联。SoC 内部有一个中间中断控制器它把若干外部中断汇总后再分别输出到 PLIC 和其它控制器。这种场景用interrupt-map来描述最合适。interrupt-map相当于一个映射表子节点发出的中断号经过映射后变成指定父中断控制器上的中断号。先看示例irq_bridge: interrupt-bridge { compatible vendor,irq-bridge; interrupt-controller; #interrupt-cells 1; interrupt-map-mask 0xffffffff; interrupt-map 0 plic 10 1 plic 11 2 gpio0 5 2 ; }; keypad: keypad... { compatible vendor,keypad; reg 0x0 0x20000000 0x0 0x1000; interrupt-parent irq_bridge; interrupts 2; };子节点keypad说自己用的是irq_bridge的第 2 号中断。内核在解析时发现irq_bridge有interrupt-map就会查表把2映射成gpio0 5 2最终中断被送给 GPIO0 控制器处理。interrupt-map-mask是匹配子中断 specifier 的掩码。如果子节点#interrupt-cells是 1通常写0xffffffff表示所有 bit 都参与匹配。如果你把某些 bit 屏蔽掉可以用这几个 bit 做分类匹配但那样复杂度高很多实际项目里很少这么用。使用interrupt-map时最容易出的问题映射表里的父中断控制器后续数量超过一个时很容易写错行列。建议每个映射项单独一行并用注释标明来源和去向比如interrupt-map /* 子节点中断号 父控制器 父中断号 */ 0 plic 10 1 plic 11 2 gpio0 5 2;这样维护起来比挤成一排清晰得多。4. 内核解析流程与驱动侧对应4.1 of_irq_parse_one 到底怎么读设备树设备树中断绑定能不能生效最后由内核的of_irq_parse_one函数决定。这个函数负责把一个中断描述解析成标准的struct of_phandle_args然后交给上层创建 Linux IRQ 号。它的处理顺序是如果节点有interrupts-extended直接遍历这个属性根据其中的 phandle 找到对应的中断控制器。如果没有interrupts-extended查找节点的interrupt-parent。如果节点自身没有interrupt-parent沿父节点向上找直到找到带该属性的节点。读取父中断控制器的#interrupt-cells据此从interrupts属性中切出正确数量的 cell。将 phandle 和 cells 组合成of_phandle_args返回。如果你在设备树里同时写了interrupt-parent和interrupts-extended内核不会因为属性存在就全都读一遍而是优先走interrupts-extended。很多人只改了interrupts里的数字没注意设备树里还残留着interrupts-extended结果自然没变化。这里再补充一个容易忽略的点interrupts是可以用数组形式描述多个中断的但它们都属于同一个interrupt-parent。比如interrupts 10 11;表示这个设备需要两个中断号分别是 10 和 11并且都发往同一个父控制器。这种情况下不要用interrupts-extended除非两个中断分属不同控制器。4.2 驱动侧申请中断的几个常见入口设备树解析成功后驱动一般通过platform_get_irq或platform_get_irq_byname拿到 IRQ 号再用devm_request_irq注册中断处理函数。struct platform_device *pdev to_platform_device(dev); int irq platform_get_irq(pdev, 0); if (irq 0) { dev_err(dev, failed to get irq: %d\n, irq); return irq; } ret devm_request_irq(dev, irq, my_isr, IRQF_TRIGGER_HIGH, dev_name(dev), priv); if (ret) { dev_err(dev, failed to request irq %d, ret%d\n, irq, ret); return ret; }如果platform_get_irq返回负数多半是设备树解析失败。如果返回 0往往是设备树里没写interrupts或者父控制器没找到。注意 0 是无效 IRQ 号不能直接当成 valid irq 继续申请。在某些情况下设备树里配置的中断触发类型会被request_irq里的 flag 覆盖这一点在 RISC-V PLIC 上特别要小心。PLIC 的#interrupt-cells 1只写了中断号没有触发类型 cell所以真实触发类型完全依赖硬件默认和驱动侧 flag。如果 PLIC 只支持电平触发驱动里非要申请边沿触发中断可能一直不触发也可能一直触发导致 CPU 被打满。4.3 启动日志和 /proc/interrupts 的表现中断绑定失败的现场通常很直接。启动时可能看到[ 0.456789] serial 10000000.serial: failed to get irq或者[ 0.123456] irq: no irq domain found for /soc/interrupt-controllerc000000 !出现no irq domain found说明内核没把该中断控制器注册成 irq domain常见原因是节点缺少interrupt-controller属性或者compatible不匹配。设备树里中断控制器节点哪怕描述得再完整只要驱动没 probeirq domain 就不会创建。问题一旦过了解析阶段中断驱动起来了可以用cat /proc/interrupts查看各中断号的触发次数。要是发现某个外设的中断号出现在CPU0列而CPU1列全是 0那是 PLIC 上下文没配好或者驱动用了irq_set_affinity限制了亲和性。RISC-V 多核下PLIC 节点里interrupts-extended的描述顺序会影响中断可以投递到的 CPU 域范围这点需要结合平台手册确认。5. 验证与避坑清单5.1 设备树编译与校验手段写设备树推荐三步走。第一步用 dtc 编译检查。编译时打开警告dtc -I dts -O dtb -o output.dtb your.dts 21 | grep -i interrupt如果语法有误这里会直接暴露。第二步用内核提供的dt-validate或dtbs_check校验 binding。内核文档树里通常有对应中断控制器的 yaml 文件比如Documentation/devicetree/bindings/interrupt-controller/sifive,plic-1.0.0.yaml。校验命令大体是make dtbs_check或者单独验证指定 dts。第三步启动后在板上检查实际解析结果。挂载 debugfs 或直接在/proc/device-tree下看节点内容是否和预期一致。尤其确认interrupts-extended里的 phandle 是否指向了正确节点避免因为拼接 dts 时 phandle 变化导致错位。5.2 常见问题速查表现象常见原因解决方向platform_get_irq返回-EINVAL#interrupt-cells不匹配导致解析错位核对父中断控制器 binding确认每个中断条目 cell 数量platform_get_irq返回-ENOENT节点上没写interrupts或interrupts-extended检查节点是否继承到了正确interrupt-parentno irq domain found中断控制器节点缺少interrupt-controller或驱动没 probe补齐属性确认 compatible 匹配中断永远不触发PLIC 不支持边沿触发驱动却设了边沿改用电平触发或加外部边沿转电平逻辑中断风暴CPU 占用 100%电平极性反了或者中断号被错误复用到其它设备对照硬件原理图确认电平/号位只有某个核能收到中断PLIC 的interrupts-extended上下文不完整按需列出 CPU 上下文必要时调整亲和性这张表是我整理项目问题时最常用的排错框架。真到了现场我基本是“先看设备树解析再看 irq domain 注册最后看触发条件”三步走。大部分问题都能在第二步之前定位。5.3 几条来自实操的硬经验最后补几条写 RISC-V 设备树中断绑定时的硬经验。第一PLIC 的 0 号中断源是保留的不要写interrupts 0。很多芯片 PLIC 的最大中断源编号是 1023 或 191但 0 都不可用。写 0 会导致申请中断时行为诡异有时直接-EINVAL有时申请到 0 这个无效 IRQ 号。第二interrupts-extended里的 phandle 一定要指向interrupt-controller节点而不是它挂载的总线节点。最常见就是把plic写成了soc。虽然 dts 语法不会报错但内核解析时找不到中断控制器属性最终表现为所有中断都无效。第三用#address-cells和#size-cells影响 reg 的时候新手容易把interrupts-extended里的 cell 数量和地址 cell 搞混。interrupts-extended的 cell 数只跟目标中断控制器的#interrupt-cells有关跟总线地址位数没关系。第四设备树属性没有“运行时热修改”的说法。中断绑定改完必须重新编译 dtb 并重启。别在板上手改/proc/device-tree那只是镜像改完不会生效。最后再说明一下不要被“路由”两个字带偏。RISC-V 设备树里的多父节点路由指的是中断信号在中断控制器之间的分发和映射它既不是 IP 包转发也不是策略路由。把设备树里的interrupt-map理解成一张中断映射表把interrupts-extended理解成一个设备多根中断线的描述方式基本就能覆盖绝大多数嵌入式场景的需求。我在实际项目里最深的体会是中断绑定问题十有六七不是驱动写错而是设备和设备树之间“对齐”出了问题。要么是#interrupt-cells对不上要么是中断上下文的目标模式没考虑清楚。写设备树像签合同每个数字都要有依据尤其 RISC-V 这种把中断路由分散到各级控制器的架构数据源头一旦粗心后面调试成本会成倍放大。