最近有朋友在调一块新的板卡,驱动写完、模块也加载进去了,probe 却始终不被调用。他把 dts 反复看了半天也没看出问题,最后我让他把驱动里的compatible字符串和设备树里的字符串逐个字符对比,才发现差了一截——设备树里写的是"acme,board-ctrl",驱动表里写的是"acme,board-ctrl-v2"。就这么个字符级误差,整个内核直接当设备不存在。
这个场景在平台设备驱动和设备树(DTS)相关的开发里实在太常见了。平台设备驱动负责管理那些不挂在 USB、PCI、I2C 这类标准总线上、但又真实存在的内建硬件,而设备树则是描述这些硬件"长什么样、资源在哪、怎么连接"的现代方式。这篇文章把两者之间的关系彻底理一遍,从设备树节点如何变成 platform_device,到 compatible 如何匹配驱动,再到 reg、interrupts、clocks 这些资源到底怎么被解析成内核 API,最后再聊几个实际项目中容易踩的坑。
1. 从板级代码到设备树:硬件资源描述方式的转变
1.1 老派做法:platform_device 是写在代码里的
很多人第一次接触设备树时都有一种"割裂感":以前写驱动就是直接初始化寄存器,设备树一出现,怎么冒出来一堆.dts、.dtsi、.dtb文件,还得搞清楚驱动为什么突然不 probe 了。要理解这种不适感,得先看看早期 Linux 平台的硬件描述方式。
早期 ARM 平台的典型做法是:硬件资源描述直接用 C 代码写死在板级文件里。比如要为某块板子上的一颗外设构造 platform_device,你得手动填写 resource 数组:
static struct resource board_ctrl_resources[] = { { .start = 0x20000, .end = 0x207ff, .flags = IORESOURCE_MEM, }, { .start = 45, .end = 45, .flags = IORESOURCE_IRQ, }, }; static struct platform_device board_ctrl_device = { .name = "board-ctrl", .id = 0, .resource = board_ctrl_resources, .num_resources = ARRAY_SIZE(board_ctrl_resources), }; static int __init board_init(void) { platform_device_register(&board_ctrl_device); return 0; }代码本身不复杂,但问题在于"每换一块板子,就要改一段板级代码,重新编译内核"。换一个寄存器地址、改一个中断号,都得找到对应板级文件去改,再重新生成内核镜像。ARM 生态里芯片型号和板卡型号又多,这种做法的维护成本很快失控。
这种方式还有一个逻辑上的问题:硬件描述和驱动逻辑搅在一起。同一个驱动可能服务多块板卡,而每块板卡的资源定义散落在各自不同的板级文件中,驱动想要对接,靠的是平台设备注册时传入的platform_data指针。数据结构一旦不统一,驱动就变得很难复用。
1.2 设备树的本质:一种可携带的自描述硬件清单
设备树(Device Tree)改变的不是"描述硬件"这个行为,而是把描述从"源代码"变成了"独立数据"。完整的链路是这样的:
DTS 是设备树源文件(Device Tree Source),由开发者编写;通过设备树编译器编译成 DTB 二进制文件(Device Tree Blob);bootloader 启动内核时,把 DTB 放入内存并将地址传给内核;内核启动早期解析 DTB,把原本零散的硬件信息解析成统一的设备模型。
对你来说,最直接的变化是:硬件资源描述不再参与内核编译,而是作为一个独立输入被传递。同一份内核镜像,只要 bootloader 传入的 DTB 不同,就能适配不同的开发板。改寄存器地址、改中断号,通常只需要修改 DTS 再重新生成 DTB,没必要重新编译整个内核。
这里要澄清一个常见误解:设备树不是"给驱动提供一个配置文件",它是真真实实参与系统启动的二进制结构。内核里有一个完整的设备树解析器,会从根节点开始递归遍历所有节点,把每个节点转换成对应的设备对象。对于没有总线归属的节点,转换结果就是 platform_device。这个过程发生在驱动注册之前。
1.3 DTS 到底在描述哪些信息
一个设备树节点通常承载四类信息,这也是你在阅读 .dtsi 文件时最需要关注的部分:
- 层级与归属:节点挂在哪个父节点下,父节点代表总线和控制器;
- 编址规则:地址和长度在子节点中如何解释,由
#address-cells和#size-cells决定; - 资源清单:寄存器区域、中断编号、时钟、GPIO、供电等;
- 兼容性:
compatible = "厂商,型号",这是驱动匹配硬件的关键字段。
设备树本质上解决的是"硬件资源的描述、传递和管理"这三个问题。描述是为了让人和工具看懂;传递是为了让 bootloader 和内核解耦;管理则是让驱动可以在运行时动态获取资源,而不是把一切写死在代码里。
2. 平台设备与平台驱动的匹配链路
2.1 设备树节点是如何变成 platform_device 的
一旦内核拿到 DTB,第一件事就是创建设备模型。这个动作通常发生在启动早期的init_machine或者of_platform_populate路径中:内核遍历设备树的顶层节点,遇到没有特殊总线归属的节点,就将其构造为platform_device,并挂到 Linux 设备模型的虚拟平台总线上。
这个过程不是随便挑节点,而是有一套规则。常见的转化对象包括:
- 位于 SoC 内部、直接挂在根总线下的控制器,比如串口、SPI 控制器、以太网 MAC、SRAM 区域;
- 带有
compatible属性且没有挂在具体总线控制器下的设备; - 通过
simple-bus等兼容标记标识的中间节点,其下子节点也会被递归展开。
如果你在/sys/bus/platform/devices/下看到一串以base或者虚拟地址命名的设备目录,比如20000.board-ctrl,那就是设备树节点被转换成平台设备后的产物。这一步说明设备树节点已经成功被内核识别,接下来就看驱动能不能匹配上。
/sys/bus/platform/devices/ ├── 20000.board-ctrl ├── 18000000.serial └── spi@0x180000002.2 compatible 与 of_match_table:驱动识别硬件的关键
设备已经被识别,驱动要做的就是向平台总线注册自己,然后等待匹配。匹配的核心依据是compatible字符串,驱动侧通过of_device_id表声明自己支持哪些硬件:
static const struct of_device_id board_ctrl_of_match[] = { { .compatible = "acme,board-ctrl", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, board_ctrl_of_match); static struct platform_driver board_ctrl_driver = { .probe = board_ctrl_probe, .remove = board_ctrl_remove, .driver = { .name = "board_ctrl", .of_match_table = board_ctrl_match, }, }; module_platform_driver(board_ctrl_driver);匹配规则并不复杂:设备树节点里的compatible会与of_match_table的条目逐一比对,只要完全一致就算匹配成功,随后平台总线调用驱动的probe函数。
但要注意两个容易出问题的细节:
- 如果节点写有多个 compatible,比如
compatible = "acme,board-ctrl-v2", "acme,board-ctrl",内核会按顺序匹配,先匹配最具体的 v2,匹配不上再用通用项作为 fallback; - 驱动侧的
compatible不能写在driver.name里,必须放在of_match_table,否则平台总线无法完成设备树匹配。
MODULE_DEVICE_TABLE这一行也很重要。它生成的模块别名会让modprobe在设备出现时自动加载驱动。很多朋友开发时喜欢手动insmod,一进制成品系统就发现驱动模块起不来,八成就是少了这一行。
2.3 probe 之后,资源从哪里拿
当probe被调用,说明匹配已经完成,设备模型已经给驱动准备好了一个struct platform_device *指针。这个指针后面挂着从设备树解析出来的所有资源。
在内核里,设备树节点的reg寄存器区域、interrupts中断信息,会被统一转换成一组struct resource,然后挂到platform_device的resource数组中。驱动要做的是通过platform_get_resource()和platform_get_irq()按顺序取出。
示例:
static int board_ctrl_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -EINVAL; base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; ... }每次拿资源时都做空指针检查,这是我在实际项目里被教训出来的习惯。设备树和驱动异步解耦,节点没写完、属性拼错都可能造成资源缺失,直接使用空指针的后果远比一次报错严重。
3. 设备树节点翻译成内核资源:逐项拆解
3.1 地址资源:reg 与 platform_get_resource
要正确理解设备树里的地址,必须先理解#address-cells和#size-cells。这两个属性定义在父节点上,告诉解析器"地址字段占几个 cell,长度字段占几个 cell"。
cell 在设备树里就是一个 32 位无符号整数的基本单位。通常:
#address-cells = <1>表示地址是一个 32 位值;#size-cells = <1>表示长度也是一个 32 位值;- 64 位地址的 SoC 上常见
#address-cells = <2>。
所以一个节点写reg = <0x20000 0x800>,在父节点配置为#address-cells = <1>和#size-cells = <1>的前提下,含义是"从 0x20000 开始,长度 0x800 字节"。
这段信息解析之后,会变成一个IORESOURCE_MEM资源。驱动用platform_get_resource(pdev, IORESOURCE_MEM, 0)拿到起始地址和结束地址,再用devm_ioremap_resource()建立内核虚拟地址映射。
我曾经见过有人把地址写错后,readl读回来全是0xffffffff,还怀疑硬件坏了。实际上多半是reg地址越界、映射失败,或者访问的地址根本不在节点描述的范围里。遇到这种问题,先检查设备树的 reg 和 SoC 手册中的地址区间是否一致,别急着怀疑硬件。
3.2 中断资源:interrupts 与 platform_get_irq
设备树中断是初学者最容易懵的地方,因为中断编号的语义完全取决于中断控制器。
比如常见的中断控制器配置中,interrupts = <0 23 4>这样的写法通常表示"这是全局中断 SPI,中断号为 23,触发类型为高电平有效"。但具体含义不能只看三个数字,要看中断控制器的#interrupt-cells属性。
还经常遇到interrupt-parent。设备树里每个节点默认使用父节点的中断控制器,但如果中断控制器不在直接父节点上,就要显式声明interrupt-parent = <&intc>。漏写这一条,中断可能会被路由到错误的中断域,导致request_irq成功但中断永远不触发。
中断获取在驱动里通常只有一行:
irq = platform_get_irq(pdev, 0);然后配合devm_request_irq()注册中断处理函数。如果返回-ENXIO,说明设备树节点没有解析到中断;如果中断注册成功但触发不了,重点排查interrupt-parent和触发类型。
3.3 时钟、GPIO、供电这些资源怎么描述
寄存器只是平台设备资源的一部分,真正的嵌入式外设往往会用到时钟、GPIO、供电轨。这些资源不会出现在resource数组中,而是被解析成内核的通用框架对象。
时钟的例子:
clk = devm_clk_get(&pdev->dev, "core"); clk_prepare_enable(clk);设备树里对应:
clocks = <&clk_controller 5>; clock-names = "core";clocks属性的第一个 cell 是时钟控制器和时钟索引,第二个 cell 的含义由时钟控制器的 binding 决定。clock-names则给这个时钟起了个名字,让驱动可以用名字获取,而不是依赖索引。用名字比用索引可读性强得多,也能承受设备树节点的顺序调整。
GPIO 同样通过属性描述。比如:
reset-gpios = <&gpio0 8 0>;驱动用devm_gpiod_get(&pdev->dev, "reset", GPIOD_OUT_LOW)获取对应的 GPIO 描述符。注意这里 API 会自动去掉属性名里的-gpios后缀,所以reset-gpios对应的是"reset"。
供电轨用vcc-supply这类属性描述,驱动通过devm_regulator_get()获取供电调节器。这一类资源的特点是:驱动代码不再关心具体硬件如何连接,只需要声明"我要一个叫 reset 的 GPIO、一个叫 core 的时钟",内核会从设备树里自动完成关联。
4. 真实项目中排查设备树问题的思路
4.1 probe 没有被调用,先怀疑匹配
如果我只能给出一条排查设备树相关问题的经验,那就是:当 probe 没有被调用时,不要第一时间怀疑内核没解析设备,先查匹配。
把排查链路固定下来:
- 确认设备树节点已经生成平台设备:查看
/sys/bus/platform/devices/下是否有对应目录; - 确认识别到 compatible:在设备目录下看
of_node/compatible文件内容; - 确认驱动已被加载:
lsmod或查看内核日志中模块是否加载成功; - 对比 compatible 字符串:设备树里的字符串必须和
of_match_table完全一致,包括前缀和大小写。
顺序不能反过来。很多人一上来就用printk在 probe 里打点,结果 probe 压根没进,打印自然没有。先花十秒钟看看系统里有没有这个设备,往往能省掉一小时的排查。
4.2 地址看着没问题,为什么访问还是异常
地址访问异常是另一个高频问题。常见原因有两类。
第一类是设备树里的 reg 区域和实际硬件资源不匹配。比如 SoC 手册里寄存器区间是 0x20000-0x20100,设备树只写了reg = <0x20000 0x80>。内核通过devm_ioremap_resource()做映射时,会检查资源范围与父区域是否有重叠,如果没重叠,映射可能裁剪或失败。驱动访问超出映射范围的地址,读回来就是0xffffffff,写操作则可能引发总线错误。
第二类是访问地址确实在 CPU 地址空间中,但硬件模块被某个总线桥或时钟门控挡住了。设备树里的reg只是描述了地址,并不保证设备被解复位、时钟被打开。如果你看到readl返回全F,先看设备的电源域和时钟状态,特别是在新加节点的时候,设备树里漏掉clocks属性导致时钟没开启,就会表现出这种"地址对但读不通"的诡异现象。
4.3 中断不触发,先查中断父节点和触发类型
平台驱动的中断问题,很大比例不是驱动代码的问题,而是设备树中断描述问题。
排查步骤:
platform_get_irq如果返回负数,说明节点没有正确解析到中断,检查interrupt-parent和interrupts属性;- 中断能获取但驱动不触发,先用工具或
cat /proc/interrupts看中断是否真的注册成功; - 检查触发类型是否符合硬件实际。设备树里写的是边沿触发,但硬件实际上发出的是电平信号,中断只触发一次后就不再产生中断,这是最常见的假象。
设备树里的interrupts中的 flags 由中断控制器定义,必须查对应控制器的 binding。不要凭经验猜测,比如一些中断控制器用1表示上升沿、4表示高电平,但不同控制器的编码可能完全不同。
4.4 把 dmesg 和 sysfs 当作第一现场
设备树调试有一个好处:大部分信息可以从内核日志和 sysfs 中看到。在问题现场我的习惯是:
dmesg | grep -E "platform|acme|board-ctrl" ls -l /sys/bus/platform/devices/ cat /sys/bus/platform/devices/20000.board-ctrl/of_node/compatibledmesg里如果能看到OF: fdt: ...或者驱动 probe 相关日志,就能在早期判断设备树解析是否成功。sysfs 下的of_node目录是设备树节点的实时视图,里面能看到compatible、reg这些原始属性值,拿它和.dts源文件对比,能快速定位是编译问题还是运行时问题。
还有一个技巧:用dtc工具反向编译运行时设备树。启动进入系统后,把/sys/firmware/devicetree/base的内容导出来,或者直接看 dmesg 构建的调试信息,能看到 bootloader 最终传给内核的设备树内容。有些问题不是你的.dts写错,而是 bootloader 在启动时覆盖了部分节点,这种问题光看源码是看不出来的。
5. 进阶:DMA、电源域和设备树协同的现代管理方式
5.1 dma-coherent 与 dma-ranges:简化 DMA 映射的关键属性
平台设备如果要做 DMA 传输,设备树里的缓存一致性和地址映射描述至关重要。dma-coherent属性表示设备与 CPU 缓存保持一致,驱动使用 DMA API 时,内核会少做很多缓存同步操作。虽然对于某些硬件来说,不映射 DMA 区域直接读写也能跑,但在实际项目里,这往往导致不稳定、偶发数据错乱。
dma-ranges则描述设备侧 DMA 地址到 CPU 地址的映射关系。另一个常见场景是:SoC 的某个总线控制器在设备树中作为父节点,子设备节点通过dma-ranges指定 DMA 地址偏移。如果 DMA 地址与 CPU 地址一致,这项可以不写;一旦不一致,漏写dma-ranges,驱动用dma_alloc_coherent分配内存后,硬件访问的内存可能完全错位。
这里建议:凡是涉及 DMA 的平台设备,驱动里不要手动做旧式总线地址转换,统一使用dma_alloc_coherent、dma_map_single这套 DMA API。设备树把一致性属性和映射关系描述清楚,内核会自动处理大多数底层差异。
5.2 power-domains、regulator 和 OPP 表的联动
现代 SoC 上,一个外设往往要依赖一组电源状态。设备树通过power-domains属性把设备和电源域绑定,驱动在 probe 时用dev_pm_domain_attach()或设备电源管理接口,内核会确保在驱动访问硬件之前,对应电源域已经上电。
与电源配套的还有 regulator。一个设备可能有多个供电轨,比如vcc-core、vcc-io。设备树里用厂商自定的*-supply属性描述,驱动通过devm_regulator_get(&pdev->dev, "vcc-core")获取。这里的核心思想,和时钟、GPIO 一样:驱动只按名字消费资源,不关心资源背后的硬件实现。
opp表则是描述设备的频率和电压裕量组合。CPU 或者 GPU 这类需要动态调频调压的设备,会通过operating-points-v2节点把不同频率对应的电压列出来。驱动配合调频框架使用,而不是自己在初始化代码里硬编码一堆电压值。
5.3 用 pinctrl 和默认状态,少写控制引脚代码
很多朋友写平台驱动时,花大量精力在 init 函数里配置引脚复用。这在设备树成熟的平台上是浪费的:pinctrl子系统和设备树自带默认配置机制。
设备树里一个常见写法是在设备节点内部声明pinctrl-names和pinctrl-0:
&uart { pinctrl-names = "default"; pinctrl-0 = <&uart_default_pins>; status = "okay"; };内核在设备驱动 probe 时,会自动应用default状态对应的引脚配置,驱动代码里完全不需要再去调用 pinmux 相关接口。需要进入低功耗状态时,可以再定义sleep状态,驱动通过pinctrl_select_state()切换。
我遇到的很多驱动,代码中手工配置引脚的逻辑往往比真正驱动逻辑还长。迁移到设备树平台后,这部分完全可以交给 pinctrl。如果还有人在驱动里看到gpio_direction_output用于初始化引脚多功能复用,那基本是从旧平台移植代码留下的后遗症。
5.4 一个重要的经验:设备树只描述,不实现
设备树开发里最容易偏离方向的心态是:把设备树当"万能配置中心",什么逻辑都往里写。实际上,设备树的基本哲学是"描述硬件,不描述策略、不实现行为"。
判断一条数据该不该放设备树,可以问自己三个问题:
- 这个信息是否属于硬件物理属性?
- 驱动程序在其他平台上是否需要不同值?
- 如果换一块实现相同功能的板卡,这个字段是否需要改变?
如果三个问题都是"是",那么它适合进设备树。如果只是代码实现细节,比如某个超时时间、缓冲区大小、算法开关,那更合适放在驱动代码或模块参数中。
设备树是给系统做硬件描述的骨架,平台设备驱动则是承载行为的实体。两者各司其职,才能发挥这套机制真正的优势。我见过不少人试图在设备树里加自定义属性来控制软件行为,短期看确实少改代码,但长期维护时,别人根本分不清哪些属性是标准绑定、哪些是私有扩展,这才是最麻烦的。
就我个人实际项目中的体会来说,调试设备树问题最能提升效率的习惯就是"少猜、多查、先看内核怎么看"。把 compatible 作为第一道关卡,把 sysfs 和 dmesg 当作现场证据,再结合设备树反向编译工具确认最终运行时数据,绝大多数问题都能在五分钟内定位。设备树和平台设备驱动这套机制,刚接触时确实绕,但只要理解了"描述与实现分离"这个核心思路,后面的工作就会顺畅很多。