
大家好,我是蟹老板~
差不多10年前吧,我刚进做嵌入式 Linux 开发的时候,每天干得最多的活,就是去内核的 arch/arm/mach-xxx 目录下改板级文件。那时候真的痛苦啊,板子换个 GPIO 管脚,或者多接个片选,我都得满屏幕去找那一堆密密麻麻的结构体定义,改完重新编译内核,几百个源文件跟着动。
后来Linux内核引入设备树机制,老的硬编码方式被淘汰。说实话,刚开始我特别抵触。本来写驱动就够头大了,又多了一套全新的语法规则,一堆节点、属性、参数记不住,改完 DTS 设备不生效、驱动 probe 不进的问题天天遇到。
但摸爬滚打十来年,回过头来看,设备树是驱动开发的减负神器。
它把硬件资源描述和驱动逻辑彻底拆分了。驱动代码只管实现功能,硬件的地址、引脚、中断、电源这些可变信息,全部丢给设备树管理。同样一份驱动代码,换十块不同硬件的板子,只需要改几行 DTS 配置,不用动核心代码,这灵活性真的绝了。
现在不管是嵌入式Linux、Android底层、工控驱动开发,设备树都是刚需技能。
设备树的本质很简单,它是一棵描述硬件的树。

比如 CPU 外面挂了哪些外设,外设寄存器地址是多少,中断号是多少,用了哪个 GPIO,复位脚接哪里,时钟从哪里来,电源由谁供。这些东西以前经常写死在板级 C 代码里,板子一换,代码跟着改,维护起来痛苦得很。
设备树把这些板级差异抽出来,放到 DTS 里。
同一个驱动可以服务很多板子。不同板子的差异,不再靠驱动里一堆 #ifdef 来硬撑,而是由设备树告诉内核。
比如一个 UART 驱动,它不应该关心“你这块板子的 UART0 基地址到底是 0x02020000 还是 0x30860000”。驱动只要知道:内核已经把这个设备创建出来了,资源也挂在 device 上了,我去拿就行。
这就是设备树最爽的地方。硬件描述归硬件描述。驱动逻辑归驱动逻辑。解耦,才好维护。
DTS 是设备树源文件。它是人看的文本文件,一般对应某一块具体开发板。比如:
imx6ull-myboard.dtsrk3568-evb.dtsstm32mp157c-dk2.dtsDTSI 是设备树包含文件。这个东西一般描述芯片级公共资源,或者某个系列板卡共用的部分。比如 SoC 内部 UART、I2C、SPI、GPIO 控制器这些东西,通常会放在 .dtsi 里。
板级 .dts 再去 include 它,然后补充自己这块板子的差异。
DTB 是编译后的二进制设备树。内核启动时真正吃进去的是 DTB,不是 DTS。
简单说:
DTS/DTSI 给人看、给人写DTB 给 bootloader 和 kernel 用dtc 负责把 DTS 编译成 DTB有时候你改了 DTS,板子却没变化,别急着怀疑人生。很可能是你改的是源码,烧进去的还是旧 DTB。
关系搞清楚了吧?DTSI 是公共头文件,DTS 是板级配置文件,DTB 是最终给内核吃的二进制。内核是看不懂文本格式的 DTS 的,它只能识别二进制数据。我们编写的源码,必须通过 dtc 工具编译成 dtb 文件,烧录到板子中,uboot 启动时加载 dtb,内核才能正常解析硬件信息。

设备树到底在开机哪个阶段生效?这个问题很多做了一两年开发的人都答不上来。不懂启动流程,后续排查 DTS 不生效、匹配失败的问题,根本无从下手。
设备树通常在系统启动时由 bootloader 传给 Linux 内核。
大概流程是这样:
上电 ↓BootROM ↓U-Boot / 其他 bootloader ↓加载 kernel image ↓加载 dtb ↓把 dtb 地址传给 kernel ↓kernel 解析 dtb ↓根据设备树创建设备 ↓匹配驱动,调用 probe内核启动早期会把扁平化的 DTB 解析成内核内部的数据结构。后面你在 /proc/device-tree 看到的内容,就是内核解析后的设备树视图。
也就是说,DTS 不是驱动运行时才读的配置文件。它在系统启动阶段就参与了硬件设备的创建。
很多 platform 设备怎么来的?不是热插拔来的。不是 PCI 那样扫总线扫出来的。而是内核根据设备树节点创建出来的。这个点特别关键。你要是理解了,后面 probe 为什么进、为什么不进,就容易想明白了。

咱们先看一个简单的设备树长啥样:
/dts-v1/;/ { model = "My Custom Board"; compatible = "myvendor,myboard"; #address-cells = <1>; #size-cells = <1>; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x10000000>; }; soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ranges; uart0: serial@10000000 { compatible = "vendor,my-uart"; reg = <0x10000000 0x1000>; status = "okay"; }; };};别看它短,设备树核心东西已经在里面了。有根节点 /。有属性,比如 model、compatible、#address-cells。有子节点,比如 memory@80000000、soc、serial@10000000。有 label,比如 uart0:。有 reg 描述地址资源。有 status 控制设备是否启用。
你后面看到的复杂设备树,只是这个模型一层层展开。树嘛,本质就是节点套节点。别被几千行 DTS 吓住,拆开看都是这些玩意儿。
设备树的基本组成单位就两个:节点和属性。
节点用花括号包起来,格式是 node-name@unit-address。属性就是 key = value; 的形式,value 可以是字符串、数字、数组等各种类型。
每个节点都有一个节点名和一个路径。根节点的路径是 /,/memory@0 就是根节点下面的一个子节点。
节点像目录,属性像目录里的配置项。
比如:
led0 { compatible = "gpio-leds"; label = "status-led"; gpios = <&gpio1 3 GPIO_ACTIVE_LOW>;};led0 是节点。compatible、label、gpios 是属性。
属性可以有值,也可以没有值。没有值的属性就是布尔属性。只要它存在,就表示 true。比如:
gpio-controller;interrupt-controller;这俩就没有等号,也没有具体值。意思是这个节点具备某种能力。
设备树不是随便写的文本。每个硬件节点有哪些属性,属性怎么写,通常要看对应的 binding 文档。binding 可以理解为“这个硬件的 DTS 说明书”。

DTS 语法看着像C语言,但细节差异还蛮大的:
第一,所有节点和属性语句,必须以分号结尾。少一个分号,编译直接报错,报错提示还特别不直观,新手很难定位。
第二,节点可以无限嵌套,层级结构清晰,外设节点统一挂在对应总线节点下。比如I2C设备挂在 i2c 总线节点下,SPI设备挂在 spi 总线节点下,不能随便乱放。
第三,属性值支持多种数据格式,字符串、整数、数组、引用都可以,不同格式写法完全不同,后面会逐一细讲。
第四,支持头文件引用,也就是 #include,可以引入 dtsi 公共配置和内核标准宏定义,比如GPIO高低电平、中断触发方式宏。
第五,支持节点覆盖和属性追加,这是 DTS 复用的核心,后面重点讲。

很多人觉得节点名字随便写。其实真不是。规范的节点命名,是内核识别、设备管理、调试查询的基础。命名不规范,虽然大概率不影响驱动匹配,但会导致 sysfs 设备目录混乱、调试命令查不到设备、后续维护看不懂代码。
设备树节点名通常长这样:
node-name@unit-address比如:
serial@10000000i2c@021a0000spi@30820000gpio@0209c000@ 前面是节点类型名。@ 后面是 unit-address。如果节点有 reg 属性,通常节点名里就应该带 unit-address,并且 unit-address 一般对应 reg 里的第一个地址。
比如:
uart0: serial@10000000 { reg = <0x10000000 0x1000>;};这里 serial@10000000 和 reg 的地址对得上。这个不是为了好看。很多检查工具会根据节点名和 reg 做校验,写乱了会报错。

node-name 就是设备节点的通用名称,用来标识设备的硬件类型,必须简洁直观、见名知意。
比如 UART 节点通常叫 serial,I2C 控制器叫 i2c,SPI 控制器叫 spi,GPIO 控制器叫 gpio。
设备树规范对 node-name 的字符范围其实挺宽松的,但Linux内核的编码风格把它收窄了——只能用小写字母、数字和破折号。别在节点名里用大写字母和下划线,虽然编译器可能不报错,但 review 的时候会被骂。

@ 后面的 unit-address,指的是设备的物理起始地址,或者总线设备的索引号。
拥有独立寄存器物理地址的外设,必须加上这个地址。比如UART、I2C、SPI控制器,都有固定物理地址,节点必须写 uart@0x12340000 这种格式。
没有独立物理地址的从属设备,比如I2C总线上的传感器、LED设备,不需要加 @ 和地址,直接写节点名即可。
还有一种特殊情况,同一总线多个相同设备,用索引号区分。比如 i2c@0、i2c@1,代表第0路、第1路I2C总线。

DTS 的属性值不是随便写的,每种数据类型都有固定写法。

字符串属性很好认,双引号包起来。
status = "okay";model = "My Custom Board";compatible = "vendor,my-device";字符串最常见的用途就是描述名字、状态、匹配信息。
不过有个坑,status = "ok" 和 status = "okay" 不是一回事。很多地方大家都写 "okay",别随手简写。你少敲两个字母,省不了多少时间,查 bug 倒能多查俩小时。

字符串列表就是多个字符串挨着写:
compatible = "vendor,my-sensor", "vendor,generic-sensor";clock-names = "core", "bus";reset-names = "apb", "core";compatible 最常见。它可以从最具体的硬件型号写到较通用的兼容型号。比如:
compatible = "vendor,chip-v2", "vendor,chip";内核匹配时,只要驱动的 of_match_table 里命中其中一个,就可能绑定成功。
clock-names、reset-names 这种属性也常用字符串列表。驱动里可以按名字获取资源,比按下标拿更稳一点。

整数是 DTS 核心数据类型,所有寄存器地址、中断号、引脚编号、时钟参数,默认32位整数。
尖括号里的每一个数值,就是一个 cell 单元,支持十进制和十六进制,开发中基本都用十六进制。
reg = <0x00001000 0x00001000>;interrupts = <5 IRQ_TYPE_EDGE_RISING>;所以如果你要描述 64 位地址,不能直接把它当一个普通数字塞进去,通常要拆成两个 cell,高 32 位和低 32 位。
比如地址 0x0000000123450000 可以写成:
reg = <0x00000001 0x23450000 0x00001000>;前提是父节点的 #address-cells = <2>,#size-cells = <1>。
这里父节点规则很重要。reg 怎么解释,不是子节点自己说了算,而是父节点的 cell 配置说了算。

现在的ARM64、RK3588、瑞芯微新一代芯片,都是64位物理地址。单个32位 cell 存不下。
DTS 的处理方式是两个32位 cell 拼接成一个64位地址。
看这个例子:
soc { #address-cells = <2>; #size-cells = <2>; ranges; device@123450000 { compatible = "vendor,my-device"; reg = <0x00000001 0x23450000 0x00000000 0x00001000>; };};这里 reg 的前两个 cell 是地址:
0x00000001 0x23450000 -> 0x0000000123450000后两个 cell 是 size:
0x00000000 0x00001000 -> 0x1000刚开始看会觉得反人类。看多了就习惯了。嵌入式就是这样,很多东西不是难,是细节密,密得像毛衣,扯错一根线整片都变形。

布尔属性最特殊,没有属性值,存在即为真,不存在即为假。
很多外设的使能、翻转、高阻态配置,都用布尔属性。
spi-cpha;spi-cpol;no-wakeup;比如 spi-cpha 代表SPI时钟相位模式开启,写上这个属性就生效,删掉就关闭,不用赋值。

phandle 是设备树里的引用机制,相当于设备树的指针,用来跨节点引用硬件资源。
设备引用中断控制器、GPIO控制器、时钟控制器等场景基本都绕不开它。
你可以先给一个节点打 label:
clk0: clock-controller@10030000 { compatible = "vendor,clk"; #clock-cells = <1>;};然后别的节点引用它:
uart0: serial@10000000 { compatible = "vendor,uart"; clocks = <&clk0 2>;};&clk0 就是引用 clock controller 节点。后面的 2 是参数,具体含义由 clock provider 定义。可能表示第 2 路时钟,也可能是某个时钟 ID。这个别猜,看 binding。

compatible 是设备树里最重要的属性,没有之一。它是驱动和设备之间的“接头暗号”。
它告诉内核:这个节点描述的硬件,兼容哪些设备模型。格式通常是 "manufacturer,model"。比如 "ti,omap2-i2c" 表示TI公司生产的omap2系列芯片上的I2C控制器。
驱动里也会声明自己支持哪些 compatible 字符串。内核拿设备树节点的 compatible 和驱动里的匹配表一对,匹配上了,就有机会调用 probe。
比如 DTS 里:
mydev@10000000 { compatible = "mycompany,mydev-v1"; reg = <0x10000000 0x1000>;};驱动里:
static conststruct of_device_id mydev_of_match[] = { { .compatible = "mycompany,mydev-v1" }, { }};MODULE_DEVICE_TABLE(of, mydev_of_match);这俩字符串必须对上。多一个字符,少一个字符,大小写不一致,都不行。

标准写法是如下,这是内核强制的规范,不要乱改格式。
"厂商名,设备名"比如:
compatible = "nxp,imx6ull-uart";compatible = "rockchip,rk3568-i2c";compatible = "st,stm32mp157-spi";如果硬件有多个兼容层级,可以这样写:
compatible = "vendor,chip-v2", "vendor,chip-v1";含义是这个设备最具体是 chip-v2,同时兼容 chip-v1。
顺序一般是把最具体的放前面,把通用的放后面。驱动可以匹配具体型号,也可以匹配通用型号。

在Linux驱动里,会有一个 of_device_id 结构体数组,叫 of_match_table。
给大家看一段标准驱动匹配代码,一眼就能看懂对应关系:
static conststruct of_device_id demo_of_match[] = { { .compatible = "demo,mydev" }, { }};MODULE_DEVICE_TABLE(of, demo_of_match);staticstruct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo-mydev", .of_match_table = demo_of_match, },};module_platform_driver(demo_driver);设备树节点:
demo@10000000 { compatible = "demo,mydev"; reg = <0x10000000 0x1000>; status = "okay";};只要内核创建了这个 platform device,且 compatible 匹配,驱动就会被绑定,probe 就会被调用。
当然,这里有个前提:节点要启用,父节点也要启用,驱动要成功注册,模块要加载,相关总线也要初始化。

当然可以,而且项目中特别常用。
很多硬件设备新旧型号兼容、多芯片通用一套驱动,就会写多个 compatible 字符串。
比如:
compatible = "vendor,abc123-rev2", "vendor,abc123";这表示设备具体型号是 abc123-rev2,但也兼容 abc123。
驱动可以写得细一点:
static conststruct of_device_id demo_match[] = { { .compatible = "vendor,abc123-rev2", .data = &rev2_data }, { .compatible = "vendor,abc123", .data = &base_data }, { }};然后在 probe 里拿匹配数据:
conststruct of_device_id *match;match = of_match_device(demo_match, &pdev->dev);if (match && match->data) { data = match->data;}这在同系列芯片兼容驱动里特别常见。

这里整理几个我踩过的几个坑点:
第一,大小写不匹配。DTS 和驱动的字符串必须大小写完全一致,大写小写混用直接匹配失败,很多人忽略这点。
第二,多余空格、换行符。复制代码的时候多带一个空格,肉眼看不出来,但是内核匹配严格区分,直接匹配失败。
第三,驱动没编译进内核。很多人 DTS 写对了,但是驱动模块没编译、没加载,内核找不到对应驱动,自然匹配失败。
第四,节点 status 被设为 disabled。设备直接被禁用,compatible 再对也没用。

reg 是设备树里描述硬件寄存器地址的核心属性,只要是有物理寄存器的外设,全部都要写 reg。
reg 用来描述设备的地址资源。
对于 MMIO 外设,它通常描述寄存器基地址和大小:
uart0: serial@10000000 { compatible = "vendor,uart"; reg = <0x10000000 0x1000>;};这表示 UART0 的寄存器从 0x10000000 开始,长度 0x1000。
驱动里可以拿到这段资源,然后做 ioremap。
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);base = devm_ioremap_resource(&pdev->dev, res);现在更常见的写法是:
base = devm_platform_ioremap_resource(pdev, 0);if (IS_ERR(base)) return PTR_ERR(base);简单,少犯错。

reg 的格式由父节点决定。
父节点里有两个属性:
#address-cells = <1>;#size-cells = <1>;意思是:子节点的 reg 中,地址占 1 个 cell,大小占 1 个 cell。
所以子节点可以写:
reg = <0x10000000 0x1000>;如果父节点这么写:
#address-cells = <2>;#size-cells = <2>;那子节点 reg 就要按 2 个 cell 地址、2 个 cell 大小来写:
reg = <0x00000001 0x00000000 0x00000000 0x00001000>;这里最容易错的是,你去看子节点,发现子节点自己没有 #address-cells,然后懵了。别懵,看父节点。reg 的解释权在父节点手里。

reg 可以有多组地址资源。
比如一个设备有两段寄存器:
dev@10000000 { compatible = "vendor,multi-reg"; reg = <0x10000000 0x1000>, <0x10010000 0x2000>; reg-names = "ctrl", "data";};驱动里可以按下标拿:
ctrl = devm_platform_ioremap_resource(pdev, 0);data = devm_platform_ioremap_resource(pdev, 1);也可以按名字拿资源:
res = platform_get_resource_byname(pdev, IORESOURCE_MEM, "ctrl");我个人更喜欢配 reg-names。资源一多,按 0、1、2 去猜,半年后你自己都看不懂。

在64位系统(比如ARM64)上,地址总线超过32位,所以 #address-cells 通常是 <2>。64 位地址常见于 64 位 SoC 或者大地址空间设备。
父节点:
#address-cells = <2>;#size-cells = <2>;子节点:
dev@100000000 { compatible = "vendor,big-device"; reg = <0x00000001 0x00000000 0x00000000 0x00010000>;};表示:
地址 0x0000000100000000大小 0x0000000000010000别把 64 位地址直接写成一个 cell。DTS 里 <0x100000000> 这种写法很容易让你以为自己写了 64 位,实际并不是这么回事。
platform 驱动里一般这样拿:
static int demo_probe(struct platform_device *pdev){ void __iomem *base; base = devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); writel(0x1, base + 0x00); return 0;}如果要拿资源信息:
struct resource *res;res = platform_get_resource(pdev, IORESOURCE_MEM, 0);if (!res) return -ENODEV;dev_info(&pdev->dev, "start=%pa size=%pa\n", &res->start, &resource_size(res));这里注意,寄存器地址是物理地址。驱动不能直接解引用物理地址,必须 ioremap 成内核虚拟地址后再访问。
坑1:address-cells/size-cells 不匹配。父节点定义的 cell 数和子节点 reg 里写的数量不一致,编译可能不报错,但内核解析出来全是错的。
坑2:地址写错了。寄存器基地址写错一个数字,操作的就是完全不同的硬件。
坑3:长度不够。reg 的长度如果小于实际寄存器范围,操作到后面的寄存器会触发总线错误。

ranges 属性平时开发用的少,但一旦用到总线地址转换、子总线设备配置,不懂 ranges 直接寸步难行。
ranges 描述子总线地址到父总线地址的映射关系。
听起来有点绕。用大白话来说就是:子节点看到的地址,怎么转换成 CPU 能访问的地址。
比如:
soc { #address-cells = <1>; #size-cells = <1>; ranges = <0x0 0x10000000 0x01000000>; uart@2000 { reg = <0x2000 0x1000>; };};这里 soc 子总线地址 0x0 映射到父总线地址 0x10000000,大小 0x01000000。
所以 uart@2000 的实际 CPU 地址是:
0x10000000 + 0x2000 = 0x10002000这就是 ranges 的意义。

如果你看到一个节点里只有一个 ranges;,后面没跟任何值。
ranges;这表示子地址空间和父地址空间是 1:1 映射。
也就是说,子总线的地址和CPU看到的物理地址是完全一样的,不需要转换。这在一些简单的SoC内部总线上很常见。
比如很多 simple-bus 节点会这样写:
soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ranges;};别把空 ranges 当成没写。它是有意义的。如果完全不写 ranges,含义就不是简单等价了,某些情况下地址转换会出问题。

标准格式:子总线地址 父总线地址 映射长度。
具体占用几个 cell,由父节点的 address-cells 和 size-cells 共同决定。
ranges = <0x00000000 0x10000000 0x08000000>;子总线0地址开始的8M空间,对应父总线0x10000000开始的8M空间,内核访问子设备地址时,自动偏移0x10000000。
如果父地址是 64 位,就变成:
ranges = <0x0 0x00000001 0x10000000 0x08000000>;
第一,PCIe、AXI等高速总线的地址映射,必须配置 ranges。
第二,芯片内部二级总线、子控制器地址偏移配置。
第三,多核架构下的独立地址空间转换。

● 不理解映射关系:这是最核心的。你必须清楚你的硬件地址映射关系,才能写出正确的 ranges。多看芯片手册!
● cell 数量搞错:ranges 里每个部分的 cell 数量很容易算错,导致地址解析错误。
● 漏写 ranges:如果地址不是1:1映射,但你忘了写 ranges,或者写成了空的 ranges;,驱动去访问寄存器时就会访问错误的地址,导致系统崩溃或设备无响应。

中断是嵌入式开发的核心功能,设备树把中断配置标准化之后,所有外设中断全部通过 DTS 配置,不用在驱动里硬编码。
板子上所有中断都由一个统一的中断控制器管理,常见的就是GIC控制器。
中断控制器节点必须包含 interrupt-controller 布尔属性,代表这是中断管理核心。同时需要定义 #interrupt-cells,指定子设备中断参数的 cell 数量。
gic: interrupt-controller@1f000000 { compatible = "arm,gic-400"; interrupt-controller; #interrupt-cells = <3>; reg = <0x1f000000 0x10000>;};interrupt-controller 表示它是中断控制器。#interrupt-cells 表示引用它时,需要几个 cell 来描述一个中断。
不同控制器格式不一样。ARM GIC 常见是 3 个 cell,比如中断类型、中断号、触发方式。别背死。看对应 binding。

普通外设想要使用中断,只需要两个配置:interrupt-parent 指定中断控制器,interrupts 填写中断参数。
uart0: serial@10000000 { compatible = "vendor,uart"; reg = <0x10000000 0x1000>; interrupt-parent = <&gic>; interrupts = <0 45 4>;};有些设备也会用 interrupts-extended,把 interrupt parent 和 interrupt specifier 写在一起:
interrupts-extended = <&gic 0 45 4>;这种写法在复杂拓扑里更清楚。

这完全取决于中断控制器的 #interrupt-cells 的定义。
对于ARM的GIC(通用中断控制器),#interrupt-cells = <3>,三个字段的含义是:
<0 33 4> 的中断号是 32 + 33 = 65。4,表示上升沿触发。所以 <0 33 4> 的意思就是:一个SPI类型的中断,中断号为65,上升沿触发。

可以。如果父节点写了:
interrupt-parent = <&gic>;子节点不写时,可以继承父节点的 interrupt parent。
比如:
/ { interrupt-parent = <&gic>; soc { uart0: serial@10000000 { interrupts = <0 45 4>; }; };};这样 uart0 也能知道自己的中断控制器是 gic。
不过我个人建议,复杂板子里别太依赖脑内继承。能写清楚就写清楚,尤其是一个系统里有 GPIO 中断控制器、PMIC 中断控制器、SoC GIC 多层嵌套的时候。以后排查问题的人,可能就是三个月后的你。

在驱动中,使用 platform_get_irq 来获取中断号。
platform 驱动里常见写法:
static irqreturn_t demo_irq(int irq, void *dev_id){ return IRQ_HANDLED;}static int demo_probe(struct platform_device *pdev){ int irq; int ret; irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; ret = devm_request_irq(&pdev->dev, irq, demo_irq, 0, dev_name(&pdev->dev), pdev); if (ret) return ret; return 0;}如果有多个中断,可以配 interrupt-names:
interrupts = <0 45 4>, <0 46 4>;interrupt-names = "rx", "tx";驱动中按名字拿:
irq = platform_get_irq_byname(pdev, "rx");
第一,中断触发方式写错,电平触发和边沿触发混用,导致中断频繁误触发、不触发。
第二,中断号和硬件手册不匹配,直接导致中断注册失败。
第三,忘记写 interrupt-parent,内核找不到中断控制器,初始化报错。

GPIO是我们调试用的最多的资源,LED、按键、复位、片选全部依赖 GPIO。
板子的所有 GPIO 由 GPIO 控制器统一管理,控制器节点会定义引脚数量、寄存器地址、编号规则。
内核源码中 dtsi 文件都会提前定义好 gpio0、gpio1、gpio2 等控制器,我们只需要引用即可。
GPIO 控制器节点一般长这样:
gpio1: gpio@0209c000 { compatible = "vendor,gpio"; reg = <0x0209c000 0x4000>; gpio-controller; #gpio-cells = <2>; interrupt-controller; #interrupt-cells = <2>;};关键属性:
gpio-controller;#gpio-cells = <2>;gpio-controller 表示它提供 GPIO。#gpio-cells 表示引用一个 GPIO 需要几个 cell。
常见两个 cell:
GPIO 编号GPIO flag比如:
<&gpio1 3 GPIO_ACTIVE_LOW>含义是 gpio1 控制器上的第 3 个 GPIO,低电平有效。

通过 phandle 引用 GPIO 控制器,填写引脚编号和电气属性。
普通设备引用 GPIO 常见这样写:
reset-gpios = <&gpio1 5 GPIO_ACTIVE_LOW>;irq-gpios = <&gpio2 7 GPIO_ACTIVE_HIGH>;enable-gpios = <&gpio3 2 GPIO_ACTIVE_HIGH>;属性名一般是 xxx-gpios。比如复位脚用 reset-gpios,使能脚用 enable-gpios,中断脚有些设备会用 irq-gpios 或 interrupt-gpios。
具体叫什么,不是你想叫什么就叫什么。看驱动。看 binding。
如果驱动里写的是:
gpiod_get(dev, "reset", GPIOD_OUT_HIGH);那 DTS 里通常要写:
reset-gpios = <&gpio1 5 GPIO_ACTIVE_LOW>;因为 gpiod API 会根据 "reset" 找 reset-gpios。你写成 rst-gpios,驱动就找不到。它不会因为你懂缩写就懂你。

这两个宏定义在 include/dt-bindings/gpio/gpio.h 里。
● GPIO_ACTIVE_HIGH (值为0):表示引脚输出高电平时,设备被激活(比如LED亮)。
● GPIO_ACTIVE_LOW (值为1):表示引脚输出低电平时,设备被激活(比如LED亮)。
这个非常重要! 一定要根据原理图来定。如果原理图上LED的负极接GPIO,那GPIO输出低电平LED才亮,这里就必须用 GPIO_ACTIVE_LOW。

除了 gpios,还有一些设备会定义自己特定的GPIO属性,比如:
● reset-gpios:复位引脚。
● enable-gpios:使能引脚。
● wake-gpios:唤醒引脚。
它们的格式和 gpios 是一样的。

现代驱动建议用 descriptor API。
struct gpio_desc *reset_gpio;reset_gpio = devm_gpiod_get(&pdev->dev, "reset", GPIOD_OUT_HIGH);if (IS_ERR(reset_gpio)) return PTR_ERR(reset_gpio);gpiod_set_value(reset_gpio, 0);msleep(10);gpiod_set_value(reset_gpio, 1);DTS 对应:
reset-gpios = <&gpio1 5 GPIO_ACTIVE_LOW>;如果 GPIO 可选:
reset_gpio = devm_gpiod_get_optional(&pdev->dev, "reset", GPIOD_OUT_LOW);if (IS_ERR(reset_gpio)) return PTR_ERR(reset_gpio);别再用老式 GPIO number API 到处飞了。新代码尽量用 gpiod。可读性好,极性处理也更靠谱。
第一,电平有效状态和硬件原理图相反,设备不工作。
第二,GPIO被其他外设复用,引脚被 pinctrl 占用,导致GPIO配置失效。这是最隐蔽的坑,下一节 pinctrl 专门讲。
第三,引脚编号计算错误,很多芯片GPIO是分组的,容易算错编号。

这一节解决所有人的终极疑惑:DTS 里GPIO引脚、电平、驱动逻辑全对,设备就是不工作,到底为什么?
99%的情况,都是 pinctrl 配置问题。
pinctrl 管的是引脚复用和引脚配置。
一个 SoC 引脚通常不是单一功能。它可能既能做 GPIO,也能做 UART TX,也能做 SPI MOSI,还能做 PWM 输出。
你在 DTS 里写了 GPIO,只是说“我要用这个 GPIO 控制器的某个 pin”。但这个 pin 当前是不是复用成 GPIO,还得看 pinctrl。
所以你 GPIO 写对了,驱动也拿到了,电平就是不变。这时候别急着骂 GPIO 子系统。看看 pinctrl。
pinctrl 负责把引脚切到正确功能,还可能配置上下拉、驱动能力、slew rate 等电气参数。

pinctrl 的配置通常也放在设备树里,分为两部分:
pinctrl 节点:定义各种引脚状态(state)。uart0: serial@10000000 { compatible = "vendor,uart"; reg = <0x10000000 0x1000>; pinctrl-names = "default"; pinctrl-0 = <&uart0_pins>; status = "okay";};pinctrl { uart0_pins: uart0-pins { pins = "GPIO1_A0", "GPIO1_A1"; function = "uart0"; bias-disable; };};不同芯片厂商写法差异巨大。有的用 pins/function,有的用宏,有的用数组。比如 NXP、Rockchip、ST、TI,各家风格都不完全一样。所以别拿 A 平台 DTS 套 B 平台。

pinmux 管复用功能,比如这个引脚到底是 GPIO,还是 I2C SDA,还是 UART RX。
pinconf 管电气配置,比如上拉、下拉、驱动强度、输入使能、输出模式。
可以这么理解:
pinmux 决定这个脚干什么活pinconf 决定这个脚怎么干活举个不严谨但好懂的例子。pinmux 像岗位分配,你是干后端还是干测试。pinconf 像工作方式,你是远程办公还是坐班,键盘声音大不大。

I2C 设备:
&i2c1 { pinctrl-names = "default"; pinctrl-0 = <&i2c1_pins>; status = "okay"; sensor@40 { compatible = "vendor,temp-sensor"; reg = <0x40>; };};SPI 设备:
&spi1 { pinctrl-names = "default"; pinctrl-0 = <&spi1_pins>; status = "okay"; flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; };};GPIO LED:
leds { compatible = "gpio-leds"; status_led { label = "status"; gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; default-state = "off"; };};如果这个 LED 引脚默认不是 GPIO 功能,那还得在相关 pinctrl 里把它配成 GPIO。
第一,引脚被默认复用为其他功能,未重新配置 pinmux,GPIO永远不生效。
第二,pinctrl 节点名称引用错误,绑定失败。
第三,引脚冲突:两个或多个设备都想用同一个引脚,后加载的驱动会报错。

嵌入式外设想要工作,前提必须有时钟。没有时钟,外设寄存器直接锁死,完全不响应操作。
设备树统一管理时钟资源,驱动不用硬编码时钟频率,灵活性拉满。
设备树里的 clock 分两类角色。
提供时钟的叫 clock provider。使用时钟的叫 clock consumer。
比如时钟控制器节点:
clk: clock-controller@10030000 { compatible = "vendor,clk"; #clock-cells = <1>;};UART 使用这个时钟:
uart0: serial@10000000 { compatible = "vendor,uart"; reg = <0x10000000 0x1000>; clocks = <&clk 5>;};&clk 指向 clock provider。5 是 clock ID。具体 ID 是多少,看芯片的 clock binding 或头文件宏。

如果一个设备只有一个时钟,可以这样:
clocks = <&clk 5>;如果有多个时钟,建议配名字:
clocks = <&clk 5>, <&clk 6>;clock-names = "core", "bus";驱动里按名字获取:
core_clk = devm_clk_get(&pdev->dev, "core");bus_clk = devm_clk_get(&pdev->dev, "bus");这样可读性好,也不怕顺序看错。按下标也能拿,但资源一多,时间一久,你会感谢当初写名字的自己。

一个外设可能有功能时钟、总线时钟、参考时钟。
比如:
crypto@30000000 { compatible = "vendor,crypto"; reg = <0x30000000 0x10000>; clocks = <&clk 10>, <&clk 11>, <&clk 12>; clock-names = "core", "ahb", "ref";};驱动中:
core = devm_clk_get(dev, "core");ahb = devm_clk_get(dev, "ahb");ref = devm_clk_get(dev, "ref");ret = clk_prepare_enable(ahb);ret = clk_prepare_enable(core);实际 enable 顺序要看硬件手册。有些外设要求先开 bus clock,再开 core clock。顺序错了,寄存器访问可能直接卡住,或者读出来全是 0。

常见写法:
struct clk *clk;int ret;clk = devm_clk_get(&pdev->dev, NULL);if (IS_ERR(clk)) return PTR_ERR(clk);ret = clk_prepare_enable(clk);if (ret) return ret;remove 或错误路径里要关闭:
clk_disable_unprepare(clk);如果用 devm 管资源,内存释放可以自动,但时钟 enable/disable 这种运行状态一般还是要自己处理好。尤其 probe 中间失败时,要有清晰的错误路径。别让外设时钟开着就走了,这种问题短期看不出来,长期就是功耗和稳定性问题。

第一,时钟没开。设备树里配了时钟,但驱动里忘了 clk_prepare_enable()。
第二,时钟频率不对。设备需要的时钟频率和实际提供的不匹配,外设工作异常。
第三,clock-names 拼写错误。驱动里写的名字和设备树里对不上,devm_clk_get() 返回错误。

硬件外设上电后大概率处于异常状态,需要先复位、再初始化,才能正常工作。设备树通过 reset 属性统一配置复位资源。

reset controller 节点一般这样:
rst: reset-controller@10040000 { compatible = "vendor,reset"; #reset-cells = <1>;};#reset-cells 表示引用一个 reset 需要几个参数。
普通设备引用:
resets = <&rst 3>;reset-names = "core";3 可能表示 reset line ID。具体含义仍然看 binding。
比如一个以太网控制器:
ethernet@20000000 { compatible = "vendor,eth"; reg = <0x20000000 0x10000>; resets = <&rst 12>, <&rst 13>; reset-names = "mac", "phy";};如果只有一个 reset,也可以不写名字:
resets = <&rst 12>;但多 reset 强烈建议写 reset-names。否则驱动里按下标控制,真的太容易错。
驱动里常见写法:
struct reset_control *rst;int ret;rst = devm_reset_control_get(&pdev->dev, "core");if (IS_ERR(rst)) return PTR_ERR(rst);reset_control_assert(rst);udelay(10);ret = reset_control_deassert(rst);if (ret) return ret;有些设备需要上电后先 assert,再 deassert。有些设备要求时钟打开后再释放 reset。有些还要求电源稳定一段时间。这个顺序必须看硬件手册。别把 reset 当成随便拉一下的 GPIO,它背后可能牵涉整个模块状态机。
reset ID 写错。reset-names 和驱动对不上。assert 不 deassert,设备一直躺尸。reset 前时钟没开。reset。reset 顺序错。reset line 是共享的,随便动可能影响别的模块。驱动里要用 shared reset API 还是 exclusive reset API,也得看场景。这东西写错了,现象经常是设备完全没反应。安静得很,日志也不一定有线索。最烦的就是这种。
所有外设工作都需要电源,电压不对、电源未开启,设备直接不工作。regulator 就是设备树的电源管理配置。

regulator 是内核的电源管理框架。
设备不光要地址、中断、GPIO,它还要吃电。比如一个传感器可能需要 vdd、vio 两路电源。一个摄像头可能需要模拟电、数字电、IO 电,还要按顺序上电。
设备树里用 regulator 描述这些电源。它可以是真实 PMIC 的一路输出,也可以是固定电源。
固定电源常见写法:
vcc_3v3: regulator-3v3 { compatible = "regulator-fixed"; regulator-name = "vcc_3v3"; regulator-min-microvolt = <3300000>; regulator-max-microvolt = <3300000>; regulator-always-on;};如果这个电源由 GPIO 控制:
vcc_sensor: regulator-sensor { compatible = "regulator-fixed"; regulator-name = "vcc_sensor"; regulator-min-microvolt = <1800000>; regulator-max-microvolt = <1800000>; gpio = <&gpio1 8 GPIO_ACTIVE_HIGH>; enable-active-high;};不同内核版本和 binding 对 GPIO 属性写法可能有差异,新项目看当前 binding,别抄十年前博客。
设备引用电源一般写成 xxx-supply:
sensor@40 { compatible = "vendor,temp-sensor"; reg = <0x40>; vdd-supply = <&vcc_3v3>; vio-supply = <&vcc_1v8>;};驱动里获取时,名字对应 vdd、vio:
vdd = devm_regulator_get(dev, "vdd");vio = devm_regulator_get(dev, "vio");所以 DTS 里是 vdd-supply,驱动里拿的是 "vdd"。这点挺容易绕。
常见写法:
struct regulator *vdd;int ret;vdd = devm_regulator_get(&pdev->dev, "vdd");if (IS_ERR(vdd)) return PTR_ERR(vdd);ret = regulator_enable(vdd);if (ret) return ret;退出时:
regulator_disable(vdd);有些设备还需要设置电压:
ret = regulator_set_voltage(vdd, 1800000, 1800000);if (ret) return ret;注意顺序。很多硬件对上电时序很敏感。电源、reset、clock、pinctrl 谁先谁后,不是凭心情排队的。
xxx-supply 名字和驱动不一致。regulator 节点没启用。enable。regulator-always-on 滥用,导致功耗下不去。电源问题特别像玄学。设备偶尔起来,偶尔不起来。冷启动不行,热复位可以。你以为是驱动 race,最后发现是上电延时不够。唉,都是泪。
status 是最简单但最容易被忽略的属性,直接决定设备是否被内核加载。

status 用来控制设备节点是否启用。
常见写法:
status = "okay";status = "disabled";status = "fail";okay 表示启用。disabled 表示禁用。fail 表示设备异常,加载失败。
也有一些规范里的其他状态值,但日常开发里最常用的就是这3个。
如果一个设备节点写了:
status = "disabled";那内核通常不会为它创建可用设备,驱动也就不会 probe。
不管节点配置多完美,只要 status 设为 disabled,内核会直接忽略当前节点,不会创建设备、不会匹配驱动,所有配置全部失效。
比如 SoC .dtsi 里先定义一个 I2C 控制器:
i2c1: i2c@021a0000 { compatible = "vendor,i2c"; reg = <0x021a0000 0x4000>; status = "disabled";};具体板级 .dts 里启用:
&i2c1 { status = "okay"; sensor@40 { compatible = "vendor,temp-sensor"; reg = <0x40>; };};这是很常见的模式。SoC 里有很多控制器,但不是每块板子都用。芯片级 dtsi 先放着,板级 dts 按实际硬件启用。
最常见的问题就是继承了 dtsi 的 disabled 状态,DTS 未覆盖修改。
很多公共 dtsi 文件中,多余的外设都会默认设置 status = "disabled"。我们自己需要开启设备时,必须在板级 DTS 中重新覆写 status = "okay",不然设备永远不会生效。
还有一个小坑,部分新手会写错字段值,比如写成“enable”“on”这类自定义字段,内核只识别标准的 okay、disabled、fail,写错直接判定设备异常。
只启用了子设备,忘了启用父总线。比如:
&i2c1 { sensor@40 { status = "okay"; };};但 i2c1 自己还是 disabled。那子设备也起不来。
还有一种是覆盖顺序问题。你前面写了 status = "okay";,后面另一个 include 又覆盖成 status = "disabled";,最后生效的是后面的。DTSinclude 和覆盖顺序别乱搞,不然你会怀疑自己眼睛。
这两个节点属于设备树的全局特殊节点,不对应任何硬件外设,却是系统启动、设备别名映射、启动参数传递的核心。平时写外设驱动用的少,但调试系统启动、串口匹配、根文件系统挂载时,绝对离不开。

aliases 直译就是别名,核心作用是给设备节点起简短别名,替代冗长的原始设备路径,方便内核识别和 uboot 传递参数。
所有别名配置都统一放在根节点下的 aliases 子节点中,格式固定:别名 = &设备节点标签。
aliases { serial0 = &uart0; serial1 = &uart1; i2c0 = &i2c1;};常用于稳定设备编号,比如 serial0 可能决定哪个 UART 是 /dev/ttyS0 或控制台候选。不同平台具体行为会有差异,但 aliases 的作用就是给节点一个稳定别名。
常见用途:
serial0 = &uart0;ethernet0 = ð0;mmc0 = &sdhci0;i2c0 = &i2c1;spi0 = &spi1;在多 UART、多网卡、多 MMC 场景下,aliases 很有用。否则设备编号可能受探测顺序影响。今天是 eth0,明天变 eth1,脚本直接裂开。
chosen 节点是设备树中虚拟节点,没有对应的硬件,纯粹用来给内核传递启动参数。
uboot 启动内核时,会将 bootargs 启动参数写入 chosen 节点,内核解析这个节点的参数,完成根文件系统挂载、控制台配置、内核调试参数开启等操作。
常见写法:
chosen { bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2 rw"; stdout-path = "serial0:115200n8";};bootargs 是内核启动参数。stdout-path 用来指定早期输出控制台。
实际项目里,bootloader 也可能动态修改 chosen 节点,比如填入 bootargs、initrd 地址等。
最常见就是控制台和启动参数。
比如你串口没打印,除了检查 UART、pinctrl、clock,还要看:
cat /proc/cmdlinecat /proc/device-tree/chosen/stdout-path有时候你以为 UART 驱动坏了,其实 kernel console 根本没指过去。
还有一些系统会在 chosen 里传 framebuffer、kaslr seed、initrd 信息。普通驱动开发不用一上来全研究,先把 bootargs 和 stdout-path 搞清楚就够用了。
第一,别名错乱导致设备编号漂移。比如调试时I2C设备编号从 i2c-0 变成 i2c-1,应用层程序读写设备失败,就是 aliases 排序异常导致的。
第二,bootargs 被 uboot 动态覆盖。很多新手在 DTS 中写好了启动参数,但 uboot 环境变量中配置了默认 bootargs,会优先覆盖 DTS 配置,导致修改不生效。
第三,串口别名和 bootargs 控制台不匹配,开机黑屏无日志,新手排查难度极高。
aliases 指向了 disabled 节点。stdout-path 写错串口。DTS 里的 bootargs。DTS 里改了 chosen,但 U-Boot 环境变量又把它改回去了。遇到启动参数问题,别只看源码 DTS。板端看 /proc/cmdline 才是最终答案。源码是理想,板端才是现实。

label 是 DTS 源码里的标签,写在节点最前面,方便全局引用。
比如:
uart0: serial@10000000 { compatible = "vendor,uart"; reg = <0x10000000 0x1000>;};uart0 就是 label。有了 label,其他地方可以用 &uart0 引用这个节点:
aliases { serial0 = &uart0;};label 主要是给人写 DTS 时方便引用。
phandle 是内核编译 DTS 时,自动生成的唯一数字ID。
我们代码中写的 label 是给人看的,方便编码;phandle 是给内核看的数字指针,内核通过这个唯一ID定位设备节点,实现资源引用。
clocks = <&clk 3>;编译成 DTB 后,&clk 会变成对应节点的 phandle 数值。
正常开发中我们不用手动写 phandle,编译 dtb 时工具会自动分配,只有在反编译 dtb 文件时,才能看到节点内部的 phandle 数值。
节点名是硬件类型标识,必须遵循内核规范;label 是自定义快捷标签,仅用于代码引用,不影响硬件匹配和内核解析。
举个例子
uart0: serial@10000000 { ...};uart0 是 label。serial@10000000 是节点名。
引用时用 label:&uart0。路径访问时看节点名:/soc/serial@10000000。
很多新人会把这俩混起来。尤其是覆盖节点时,&uart0 是引用 label,不是引用 uart0 这个节点名。
第一,label 重复定义,编译 DTS 报错。同一个工程中,标签必须全局唯一,重复会直接编译失败。
第二,引用未定义的 label,编译提示 undefined reference,新手复制代码经常踩这个坑。
第三,混淆 label 和节点名,试图通过节点名引用设备,导致引用失效,必须通过 label 加 & 符号引用。
label 重名。label。include 顺序导致 label 还没定义。label 用。DTS 大了以后,label 命名要有规律。不然满屏 &xxx1、&xxx2、&mydev,看着像案发现场。
设备树最核心的工程化思想就是复用。如果每块板子都从零写完整 DTS,代码冗余爆炸,维护成本极高。include 引用和节点覆盖,就是设备树工程的核心精髓。

一个 SoC 会被很多板子使用。
SoC 内部外设是一样的,比如 UART、I2C、SPI、GPIO、clock controller、interrupt controller。
不同的是板级连接。比如这块板子用了 I2C1,那块板子用了 I2C3;这块板子 UART0 接调试口,那块板子 UART2 接蓝牙。
所以公共部分放 .dtsi。板级差异放 .dts。
这就是 dtsi 的意义。把这些通用、不变的配置抽离成 dtsi 公共文件,所有板级 DTS 直接引用,只需要编写当前板子独有的外设配置,极大减少代码量,统一代码规范。
设备树的 include 语法和C语言头文件完全一致,支持相对路径和内核源码绝对路径:
常见写法:
/dts-v1/;#include "soc.dtsi"/ { model = "My Board"; compatible = "vendor,myboard";};有些地方也会看到:
#include <dt-bindings/gpio/gpio.h>#include <dt-bindings/interrupt-controller/irq.h>这些头文件提供宏,比如:
GPIO_ACTIVE_LOWIRQ_TYPE_LEVEL_HIGH用宏比直接写数字好太多。你写 <&gpio1 3 1>,半年后谁知道那个 1 是啥。写 GPIO_ACTIVE_LOW,一眼明白。
dtsi 中的通用配置不一定适配当前板子,我们可以在 DTS 中直接重写同名节点,覆盖原有配置。
如果 dtsi 里有:
i2c1: i2c@021a0000 { status = "disabled";};板级 dts 可以这样覆盖:
&i2c1 { status = "okay";};也可以追加子节点:
&i2c1 { status = "okay"; sensor@40 { compatible = "vendor,temp-sensor"; reg = <0x40>; };};这是 DTS 最常见的复用方式。SoC 负责定义“有什么”。板子负责说明“用哪些,怎么接”。
除了覆盖原有属性,我们还可以给原有节点追加新的属性配置。
比如原有 uart0 只有基础配置,我们需要新增DMA、中断相关属性,直接追加即可:
&uart0 { pinctrl-names = "default"; pinctrl-0 = <&uart0_pins>; status = "okay";};如果原节点已经有同名属性,后面的会覆盖前面的。
比如 dtsi 里 status = "disabled";,dts 里 status = "okay";,最终就是 okay。但如果你多层 include,后面又来一次 disabled,那最终还是 disabled。设备树这东西遵循后写覆盖前写,顺序很重要。
第一,修改 dtsi 公共文件。之前说过,这是大忌!同步内核源码、升级内核版本时,公共文件修改会引发大量冲突,自定义修改一律放 DTS。
第二,节点覆盖时写错 label,修改的不是目标设备,配置永远不生效。
第三,重复 include 导致属性重复定义,编译告警甚至报错。
include 顺序错。label 在被引用前没定义。dtsi,结果板级 dts 后面又覆盖回去了。include,导致 GPIO_ACTIVE_LOW 这类宏找不到。还有一个项目里常见的人祸:大家都往同一个板级 dts 里塞东西,没有分区,没有注释,最后文件长到三四千行。后面新同事进来打开一看,沉默,关掉,去倒水。
学完所有语法,必须打通设备树和驱动的联动逻辑。不然只会写 DTS,不懂内核匹配原理,出问题排查不出来。

内核解析 DTB 后,会遍历设备树节点。
对于符合条件的节点,内核会创建 platform device 或其他总线设备。
比如挂在 simple-bus 下的 MMIO 外设,常常会被创建成 platform device。
设备创建之后,驱动模型开始匹配。驱动注册时,内核拿 device 和 driver 做匹配。设备树设备主要靠 compatible 匹配。匹配成功后,调用驱动的 probe。
所以流程是:
DTB 节点 ↓内核解析 ↓创建设备对象 ↓驱动注册 ↓compatible 匹配 ↓probe 调用驱动通过 of_match_table 来声明自己能驱动哪些设备。
static conststruct of_device_id demo_of_match[] = { { .compatible = "demo,mydev" }, { }};staticstruct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo-mydev", .of_match_table = demo_of_match, },};这里最关键的是 .of_match_table = demo_of_match,没有它,设备树匹配就没戏。
当然,有些平台也可以通过 name 匹配,但做设备树驱动时,别指望这些旁门。老老实实写 of_match_table。
一个简单 platform 驱动:
#include <linux/module.h>#include <linux/platform_device.h>#include <linux/of.h>#include <linux/io.h>static int demo_probe(struct platform_device *pdev){ void __iomem *base; int irq; base = devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; dev_info(&pdev->dev, "demo probe ok, irq=%d\n", irq); return 0;}static int demo_remove(struct platform_device *pdev){ dev_info(&pdev->dev, "demo remove\n"); return 0;}static conststruct of_device_id demo_match[] = { { .compatible = "demo,mydev" }, { }};MODULE_DEVICE_TABLE(of, demo_match);staticstruct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo-mydev", .of_match_table = demo_match, },};module_platform_driver(demo_driver);MODULE_LICENSE("GPL");对应 DTS:
demo@10000000 { compatible = "demo,mydev"; reg = <0x10000000 0x1000>; interrupts = <0 45 4>; status = "okay";};这一套能跑起来,你就掌握设备树和 platform 驱动的主线了。
probe 被调用需要几个条件同时满足。
设备树节点存在。
节点状态可用。
父总线可用。
内核根据节点创建了 device。
驱动已经注册。
compatible匹配成功。如果驱动是模块,还要模块加载。
所以 probe 不进,不一定是 compatible 错。它只是最常见原因之一。排查时要一层层看,不要上来就“驱动坏了”。驱动背锅太多年了,也该歇歇。
DTB。status 还是 disabled。disabled。compatible 拼错。of_match_table 没挂到 driver。DTS 路径写错,覆盖没生效。最实用的排查命令:
find /proc/device-tree -name compatible | xargs grep -a "demo,mydev"dmesg | grep -i demols /sys/bus/platform/devices/ls /sys/bus/platform/drivers/如果 /proc/device-tree 里都没有你的节点,那就别看驱动了。内核压根没看到它。
理论讲再多不如上手实操,这一节带大家从零完成LED设备树适配。
加 LED 前先看原理图,很多 bug 就是从“不看原理图”开始的。
你至少要确认:
LED 接哪个 GPIO。
LED 是高电平亮还是低电平亮。
这个 pin 是否需要
pinctrl配置成 GPIO。GPIO 所在 bank 是哪个。
是否有电源或使能控制。
比如原理图显示 LED 接在 GPIO1_IO03,低电平点亮。那 DTS 里大概会写:
gpios = <&gpio1 3 GPIO_ACTIVE_LOW>;Linux 里常用 gpio-leds 驱动。
#include <dt-bindings/gpio/gpio.h>/ { leds { compatible = "gpio-leds"; status_led: status-led { label = "status"; gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; default-state = "off"; }; };};如果这个 GPIO 需要 pinctrl,记得配:
&iomuxc { led_pins: led-pins { pins = "GPIO1_IO03"; function = "gpio"; bias-disable; };};leds { compatible = "gpio-leds"; pinctrl-names = "default"; pinctrl-0 = <&led_pins>; status-led { label = "status"; gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; default-state = "off"; };};具体 pinctrl 写法看平台。上面只是示意,不要直接跨平台复制。
如果在内核源码里编译:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- myboard.dtb或者:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs单独用 dtc:
dtc -I dts -O dtb -o myboard.dtb myboard.dts反编译检查:
dtc -I dtb -O dts -o dump.dts myboard.dtb有时候源码里 include 和宏展开后你看不清,反编译能看到最终结果。虽然格式不太美,但够用。
启动后看 LED 类设备:
ls /sys/class/leds/如果有 status:
echo 1 > /sys/class/leds/status/brightnessecho 0 > /sys/class/leds/status/brightness看当前触发器:
cat /sys/class/leds/status/trigger如果 LED 没反应,先看:
dmesg | grep -i ledcat /proc/device-tree/leds/status-led/gpios别忘了 cat 设备树里的二进制属性可能乱码,这是正常的。字符串能直接看,cell 不一定直观。
sysfs 有 LED,但板子没反应,查 pinctrl。sysfs 没 LED,查 compatible = "gpio-leds" 有没有写对。pinmux 冲突。DTS 改了没生效,查 /proc/device-tree。LED 是最简单的设备,也是最适合练 DTS 的设备。你能把 LED 从原理图一路打通到 sysfs,设备树基本门已经推开一半了。
I2C是传感器、触摸屏、RTC设备最常用的总线,学会I2C设备树配置,能搞定80%的外设适配。
SoC dtsi 里通常已有 I2C 控制器:
i2c1: i2c@021a0000 { compatible = "vendor,i2c"; reg = <0x021a0000 0x4000>; interrupts = <0 36 4>; clocks = <&clk 12>; #address-cells = <1>; #size-cells = <0>; status = "disabled";};板级 dts 启用它:
&i2c1 { pinctrl-names = "default"; pinctrl-0 = <&i2c1_pins>; clock-frequency = <400000>; status = "okay";};I2C 子设备挂在这个节点下面。
比如添加一个温度传感器,地址是 0x40:
&i2c1 { status = "okay"; temp_sensor@40 { compatible = "vendor,temp-sensor"; reg = <0x40>; interrupt-parent = <&gpio2>; interrupts = <5 IRQ_TYPE_LEVEL_LOW>; vdd-supply = <&vcc_3v3>; };};注意 I2C 设备的 reg 是 I2C 从设备地址,不是寄存器物理地址。这点非常重要。
temp_sensor@40 里的 @40 也对应 I2C 地址。
I2C 总线节点通常写:
#address-cells = <1>;#size-cells = <0>;这表示 I2C 子设备地址占 1 个 cell,没有 size。
所以 reg = <0x40>; 表示从地址 0x40。不是 reg = <0x40 0x100>;。I2C 子设备不是 MMIO,不需要 size。你要访问设备内部寄存器,那是驱动通过 I2C transaction 去读写,不是在 DTS 里把内部寄存器范围写出来。
I2C 驱动也可以用设备树匹配:
static conststruct of_device_id temp_of_match[] = { { .compatible = "vendor,temp-sensor" }, { }};MODULE_DEVICE_TABLE(of, temp_of_match);staticstruct i2c_driver temp_driver = { .probe = temp_probe, .driver = { .name = "temp-sensor", .of_match_table = temp_of_match, },};module_i2c_driver(temp_driver);当 I2C 控制器起来后,内核会根据子节点创建 i2c_client,然后匹配 I2C 驱动。所以 I2C 子设备 probe 不进时,除了看子设备 compatible,还要看 I2C 控制器有没有起来。
pinctrl 没配置 SDA/SCL。#size-cells 没设成 0。reset 没释放。compatible 不匹配。I2C 调试常用:
i2cdetect -y 1i2cdump -y 1 0x40dmesg | grep -i i2c不过 i2cdetect 不是万能的。有些设备不响应 quick command,看不到不代表一定不存在。别拿它当圣旨。
SPI速率比I2C快很多,常用于屏幕、Flash、高速传感器,配置逻辑和I2C类似,但细节差异很大。
SPI 控制器一般在 SoC dtsi 里:
spi1: spi@30820000 { compatible = "vendor,spi"; reg = <0x30820000 0x10000>; interrupts = <0 60 4>; clocks = <&clk 20>; #address-cells = <1>; #size-cells = <0>; status = "disabled";};板级启用:
&spi1 { pinctrl-names = "default"; pinctrl-0 = <&spi1_pins>; status = "okay";};比如挂一个 SPI NOR Flash:
&spi1 { status = "okay"; flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; };};这里 reg = <0> 表示片选 0。不是地址。SPI 子设备的 unit-address 通常就是 chip select 编号。
SPI 总线节点通常也是:
#address-cells = <1>;#size-cells = <0>;所以子设备 reg = <0>; 表示 CS0。如果有两个设备:
flash@0 { reg = <0>;};adc@1 { reg = <1>;};就是一个挂 CS0,一个挂 CS1。
如果片选是 GPIO 控制,还可能在 SPI 控制器节点写 cs-gpios:
&spi1 { cs-gpios = <&gpio1 10 GPIO_ACTIVE_LOW>, <&gpio1 11 GPIO_ACTIVE_LOW>;};常见 SPI 子设备属性有:
spi-max-frequency = <10000000>;spi-cpol;spi-cpha;spi-cs-high;spi-cpol 和 spi-cpha 决定 SPI mode。比如 mode 0 是 CPOL=0、CPHA=0,不写这两个布尔属性通常就是 mode 0。mode 3 则通常需要:
spi-cpol;spi-cpha;具体还得看控制器和设备 binding。
pinctrl 没配。bits-per-word 和设备不一致。spi-max-frequency 不是你想写多高就写多高。板级走线、设备规格、控制器能力,都得考虑。别把 50MHz 当默认快乐值,波形不行的时候,它会让你很不快乐。不会编译、验证设备树,开发根本无从谈起。这一节手把手教大家实操命令。
dtc 是 device tree compiler。是设备树专用编译工具,内核编译环境自带,无需额外安装。
它可以把 DTS 编译成 DTB,也可以把 DTB 反编译成 DTS。
常见命令:
dtc -I dts -O dtb -o out.dtb in.dtsdtc -I dtb -O dts -o out.dts in.dtb但在内核项目里,更推荐通过内核 Makefile 编译 dtb,因为它会处理 include 路径、dt-bindings 头文件、平台规则等。
内核源码中:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- myboard.dtbARM64:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- myboard.dtb或者编译所有 dtb:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs生成位置通常在:
arch/arm/boot/dts/arch/arm64/boot/dts/vendor/不同架构路径不一样。
反编译:
dtc -I dtb -O dts -o dump.dts board.dtb如果想看板端当前运行的设备树,也可以从 /sys/firmware/fdt 反编译:
dtc -I dtb -O dts -o running.dts /sys/firmware/fdt有些系统没有 /sys/firmware/fdt,可以看 /proc/device-tree。
反编译出来的 DTS 可能 label 丢失,宏也会变成数字。这正常。DTB 是二进制结果,不保留所有源码信息。
除了 make 命令,还要确认 dts 是否加入 Makefile。比如:
dtb-$(CONFIG_ARCH_VENDOR) += myboard.dtb如果你新加了一个 myboard.dts,但 Makefile 没加,make dtbs 可能根本不会编它。这坑非常朴素,也非常常见。
最直接看:
ls /proc/device-tree/cat /proc/device-tree/modelcat /proc/device-tree/compatible看某个节点:
ls /proc/device-tree/soc/cat /proc/device-tree/chosen/bootargs看启动日志:
dmesg | grep -i "Machine model"dmesg | grep -i "OF:"如果支持:
ls /sys/firmware/fdt然后反编译当前 fdt。
验证 DTS 是否生效,不要靠“我明明拷进去了”。要看内核实际看到什么。
dtb,启动用的是 B dtb。dtb,你却替换了 rootfs 里的 dtb。dtb,你只替换了裸 dtb。这类问题很折磨人,因为源码和运行结果不一致。遇到时别猜,直接在板端查 /proc/device-tree。
嵌入式调试拼的就是速度,知晓这些命令,排查问题效率提升十倍。

ls /proc/device-tree/find /proc/device-tree -maxdepth 3 -type d查看某个节点:
ls /proc/device-tree/soc/serial@10000000/属性是文件,节点是目录。字符串属性可以直接 cat:
cat /proc/device-tree/modelcell 属性 cat 出来可能乱码,这是因为它是二进制数据。
cat /proc/device-tree/soc/serial@10000000/compatible有些字符串列表中间用 \0 分隔,终端显示可能连在一起。可以用:
tr '\0' '\n' < /proc/device-tree/soc/serial@10000000/compatible这个命令挺好用,建议记一下。
cat /proc/device-tree/soc/i2c@021a0000/status如果没有 status,有些情况下会按默认可用处理。但项目里最好明确写 okay 或 disabled,别靠默认语义赌。
cat /proc/cmdlinecat /proc/device-tree/chosen/bootargs如果两者不一样,以 /proc/cmdline 为准看内核运行参数。
控制台问题看:
cat /proc/device-tree/chosen/stdout-pathls /sys/bus/platform/devices/找某个设备:
ls /sys/bus/platform/devices/ | grep 10000000如果设备树有节点,但 platform device 没创建,要看节点是否在合适的总线下,父节点 compatible 是否能触发子节点 populate。
查看 platform 驱动:
ls /sys/bus/platform/drivers/看某个设备绑定到哪个驱动:
readlink /sys/bus/platform/devices/10000000.serial/driver手动绑定有时可以用:
echo 10000000.serial > /sys/bus/platform/drivers/demo-mydev/bind但这只是调试手段,不是正规修 bug 方法。正常系统应该自动匹配。
dmesg | grep -i ofdmesg | grep -i platformdmesg | grep -i gpiodmesg | grep -i pinctrldmesg | grep -i regulatordmesg | grep -i clk驱动 probe 里多打一点日志,不丢人。我以前也觉得“高手不靠 printk”。后来线上板子死活不起,JTAG 又不方便,最后还是 dev_info() 救命。别装,能定位问题就是好工具。
以下内容为我个人多年踩坑汇总的排查清单:
按这个顺序查:
DTS是否编进最终DTB。板端
/proc/device-tree是否有节点。节点
status是否okay。父节点是否
okay。
compatible是否和驱动完全一致。驱动是否加载。
of_match_table是否挂上。设备是否被对应总线创建出来。
命令:
find /proc/device-tree -name compatible | xargs grep -a "your,device"ls /sys/bus/platform/devices/lsmod | grep your_driverdmesg | grep -i your_driver别上来就改驱动。先看设备有没有被创建。
如果 platform_get_irq() 失败,看 interrupts。
如果 devm_platform_ioremap_resource() 失败,看 reg 和父节点 cell。
如果 devm_gpiod_get() 失败,看 xxx-gpios 名字是否和驱动一致。
如果 devm_clk_get() 失败,看 clocks 和 clock-names。
如果 devm_regulator_get() 失败,看 xxx-supply。
每个资源都有一条 DTS 到驱动 API 的映射链。把链条画出来,很多问题就清楚了。
外设没反应,别只看一个点。
reset。clock。pinctrl。比如 I2C 设备不响应,可能不是 I2C 地址错,而是电源没上。SPI 读 ID 全是 0xff,可能是 MISO 上拉,也可能是 CS 没拉到,也可能是 mode 错。驱动世界没有单一真相,只有一堆嫌疑人。
这个问题太常见了,单独拎出来。
检查:
dtb 文件时间是否更新。dtb。dtb 是否替换。/proc/device-tree 是否能看到新属性。最可靠的方式是在 DTS 里临时加个明显属性:
debug-tag = "hello-dts-2026";启动后看:
cat /proc/device-tree/path/to/node/debug-tag能看到,说明 DTB 生效。看不到,别再改驱动了。你改的是空气。
最后给大家分享我多年调试 DTS 的核心经验。
DTS 是硬件描述,不是许愿池。
原理图上怎么接,DTS 就应该怎么写。
regulator 提供。reset 是高有效还是低有效。这些都来自原理图和芯片手册。你不看硬件资料,DTS 写得再漂亮也没用。
DTS 和驱动是配套看的。
驱动里写:
devm_gpiod_get(dev, "reset", ...)DTS 里就该有:
reset-gpios = <...>;驱动里写:
devm_clk_get(dev, "core")DTS 里就该有:
clock-names = "core";驱动里匹配:
.compatible = "vendor,mydev"DTS 里就该写一样的 compatible。
不要只看 DTS,也不要只看驱动。它俩是一对。你拆开看,就像只看左鞋找右脚,能不别扭吗。
这是我踩坑后最想刻在工位上的一句话。
DTS 改了不代表系统用了。编译了不代表烧进去了。烧进去了不代表 bootloader 加载了。bootloader 加载了不代表没有被 overlay 改掉。最后以内核看到的为准。
所以调试第一步:
cat /proc/device-tree/model第二步:
find /proc/device-tree -name compatible | grep -i your第三步:
dtc -I dtb -O dts -o running.dts /sys/firmware/fdt能确认运行时设备树,再继续查驱动。否则你可能在一个不存在的战场上打仗。
设备树不是写给驱动作者自嗨的。它是写给内核看的硬件说明书。
写 DTS 的顺序,我建议这样:
dtsi。DTS。DTB 生效。probe。这个流程不华丽,但管用。工程里管用比优雅重要多了。
设备树看起来内容很多,compatible、reg、ranges、interrupts、GPIO、pinctrl、clock、reset、regulator,一大串,但它的核心逻辑很简单。
它描述硬件。
它让内核知道板子上有什么。
它让驱动不用把板级信息写死。
它通过 compatible 完成驱动匹配。
它通过 reg、interrupts、GPIO、clock、reset、regulator 这些属性,把硬件资源交给驱动。
驱动 probe 之后,再通过内核 API 把这些资源取出来。
设备树真正难的是细节。
cell 数要看父节点。reg 是从地址。reg 是片选。pinctrl 不配,脚可能根本不是你想要的功能。clock 不开,寄存器可能读不动。reset 不释放,设备永远醒不过来。regulator 不 enable,外设就是没电。status disabled,probe 连门都进不去。compatible 拼错一个字符,驱动和设备擦肩而过。