
搞 Linux 驱动的人十个里有八个是从字符设备或者杂项设备入门的。led 驱动写一遍、按键驱动写一遍感觉也就那么回事。可一旦真要把一块新板子跑起来或者把一个驱动从 A 平台挪到 B 平台最先卡住你的往往不是 C 语言而是设备树 DTS 和 platform 驱动这套机制。第 006 篇我不想再单独讲语法而是把“平台设备驱动”和“设备树”这两件事放在一起拆明白设备树到底描述了什么platform 驱动靠什么和它握手为什么这套东西被大家称为硬件资源的现代描述与管理。这篇文章适合已经会写基础驱动、但看到设备树就打怵的人也适合准备做新板子适配、想在 boot 阶段少走弯路的工程师。1. 设备树到底解决了什么问题1.1 老办法板级文件加平台数据时间往回推十几年嵌入式内核还不是现在这个样子。当时很多平台的内核里会放一堆“板级文件”里面用platform_device和platform_data把硬件的接线、地址、中断号、GPIO 全部写死。比如xxx_board.c里的某个结构体专门描述这块板子上的按键、LED、网卡等外围设备。结构体里写着gpio 23之类的宏内核编译时把这些宏展开驱动运行时再通过平台数据去拿。这套做法在今天看来问题非常明显换一个板子哪怕只是把 LED 从一个 GPIO 挪到另一个 GPIO也要去改内核源码重新编译整个内核再烧进设备里验证。如果这次改的引脚又恰好和别的外设有冲突还要回到板级文件里继续排查 pinmux、中断复用这些问题。我最早在某块测试板上调一个 LED 的时候就经历过这种循环改宏、编内核、烧内核、点不亮、再改宏。一整个下午全耗在“重新编译”上。而且一个内核镜像基本只能对应一种板子板子一多内核目录下的各种板级文件也跟着爆炸。1.2 设备树的做法用数据描述硬件设备树的核心思想简单说就是“把硬件的描述从代码里拿出来”。DTSDevice Tree Source是一份可读的硬件描述文件它不关心驱动逻辑只回答两个问题板上有什么硬件这些硬件挂在哪、用什么中断、用哪个 GPIO、依赖什么时钟这些问题全部以属性和节点的方式写在 DTS 里。内核启动时bootloader 会把编译好的 DTBDevice Tree Blob传给内核。内核解析这份 DTB把里面的节点变成一个个内核设备再根据compatible字符串找到对应的驱动。这样一来驱动是通用的硬件描述是独立的。同一个内核镜像配合不同的 DTB就能跑不同的板子。换板子很多时候只需要换 DTB不再需要重新编译整个内核。这才是“资源描述与管理”真正的含义描述归描述逻辑归逻辑。1.3 从DTS到DTB再到内核DTS 是给人看的文本内核真正要的是 DTB。中间负责转换的工具叫 DTC即 Device Tree Compiler。平时在终端里执行dtc -I dts -O dtb -o xxx.dtb xxx.dts就是把 DTS 编译成 DTB。反过来也可以用dtc -I dtb -O dts把 DTB 反编译回可读文本调试的时候特别有用后面我会再说。内核拿到 DTB 之后会把这份二进制解析成一棵device_node树每个 node 对应树里的一个节点。内核的设备模型再以这些节点为基础去创建platform_device、i2c_client、spi_device等设备。可以说DTS 是源头DTB 是载体device_node是内核里的“活体”而 platform 驱动就是在这个活体上找资源、干活的那个角色。2. 读懂DTS里的硬件资源描述2.1 节点、compatible和“资源属性”设备树最基础的语法就是节点和属性。一个节点用花括号包起来节点里可以继续嵌套子节点这跟 JSON 或者目录结构很像。下面是一个非常典型的设备节点/dts-v1/; / { compatible example,myboard; #address-cells 1; #size-cells 1; mydevice10000000 { compatible example,mydevice; reg 0x10000000 0x1000; interrupt-parent gpio1; interrupts 16 IRQ_TYPE_EDGE_FALLING; led-gpios gpio1 15 GPIO_ACTIVE_HIGH; status okay; }; };最外层的/是根节点。根节点下的compatible通常描述整块板子的型号和厂商比如example,myboard。不过它更大的作用是给内核识别平台用的驱动匹配时不太会拿根节点的 compatible 去匹配。节点mydevice10000000表示一个挂在某地址上的设备。后面的10000000通常是这个设备在父总线上的地址也就是 unit address它不是随便写的最好和reg里的首地址一致。节点内部用compatible example,mydevice声明“我是个什么设备”驱动就靠这一串字符串找到它。2.2 reg与地址的cell规则reg是设备树里最重要的资源属性之一用来描述设备在总线上占用的地址范围和片选等信息。reg 0x10000000 0x1000表示起始地址0x10000000长度0x1000。但这里有个关键点这一整串数值到底分成几个 cell是由父节点的#address-cells和#size-cells决定的。父节点写#address-cells 1说明每个地址占 1 个 cell#size-cells 1说明每个长度占 1 个 cell。所以reg 0x10000000 0x1000前半段是地址后半段是长度。如果父节点写#address-cells 2那reg 0x0 0x10000000 0x0 0x1000就变成高 32 位地址 低 32 位地址 高 32 位长度 低 32 位长度。很多人在 DTS 上踩的第一个坑就是这个 cell 数对不上导致platform_get_resource拿到的地址完全是乱的甚至直接在ioremap时失败。2.3 中断、GPIO、时钟的引用方式设备树里描述硬件资源不光是地址。中断、GPIO、时钟这些资源一般通过属性引用其他节点。interrupt-parent gpio1表示这个设备的中断连接到gpio1这个节点上。interrupts 16 IRQ_TYPE_EDGE_FALLING里的两个数值具体含义由中断控制器决定。GPIO 控制器一般定义#interrupt-cells 2第一个 cell 是引脚号第二个 cell 是触发类型。所以这行意思就是 gpio1 的 16 号引脚下降沿触发中断。GPIO 描述类似led-gpios gpio1 15 GPIO_ACTIVE_HIGH表示使用 gpio1 的 15 号引脚高电平有效。这里要特别习惯一个规则在设备树里GPIO 属性通常以-gpios结尾比如led-gpios、enable-gpios、reset-gpios而不是led-gpio。这个后缀看着小但直接关系到后面驱动里devm_gpiod_get能不能找到属性。时钟和复位资源也是同样的套路clocks uart_clk 0、resets reset_controller 12通过 phandle 引用对应的 clock provider 或 reset provider。phandle 是设备树里节点之间的“指针”gpio1、uart_clk这些写法编译时会转换成实际的 phandle 数字。初学的时候不用太纠结这个数字只要记住“引用别人家的节点就用”。2.4 别小看status和chosenstatus okay表示节点启用如果写成disabled内核一般会把这个节点对应的设备直接忽略。很多人调试驱动时反复确认驱动代码没问题结果最后发现 DTS 里status还是 disabled。根节点下还可以有/chosen节点它不代表硬件而是 bootloader 和内核之间的“便条”。比如chosen { bootargs consolettyS0,115200; stdout-path serial0:115200n8; };里面可以放内核启动参数、控制台 stdout 路径。/aliases节点则是给设备起别名比如serial0 uart0方便stdout-path引用。设备树语法本身不大但真正要理解的是它描述资源的方式地址讲 cell资源讲 phandle使用状态讲 status。这些规则像棋盘一样驱动就是棋盘上的棋子。3. 平台驱动是怎么和设备树“对上眼”的3.1 device_node如何变成platform_device内核启动早期会做一次 unflatten把 DTB 转成一颗device_node树。但光有节点还不够驱动模型里需要的是设备对象。于是内核在初始化阶段会调用of_platform_default_populate_init()把设备树里合适的节点转换成platform_device。这里“合适”不是所有节点都转。挂在真实总线比如 I2C、SPI 下的节点通常由对应总线驱动去创建i2c_client或spi_device不会变成 platform_device。而直接描述在根节点或 platform 总线范围内的节点只要带有compatible属性大概率就会被转成platform_device。这也是为什么我们写一个普通的片上外设驱动通常都用platform_driver而不是自己手动device_create一个设备。platform_device和device_node是一对多的关系更准确地说一个platform_device内部会保留对device_node的引用pdev-dev.of_node就指向它。驱动里的struct device *dev pdev-dev通过dev-of_node就能读到对应的设备树节点。3.2 of_match_table匹配规则platform_driver里最关键的一行配置就是.of_match_table。它是一个of_device_id数组static const struct of_device_id mydevice_of_match[] { { .compatible example,mydevice }, { } }; MODULE_DEVICE_TABLE(of, mydevice_of_match);当内核创建好platform_device后总线的匹配逻辑会拿着设备节点的compatible属性去和驱动里的of_match_table逐个比较。字符串必须严格一致。比如设备树里写的是example,mydevice驱动里写example,mydevice-v2那是匹配不上的。实践中一个驱动通常会兼容多个版本static const struct of_device_id mydevice_of_match[] { { .compatible example,mydevice-v2 }, { .compatible example,mydevice-v1 }, { } };设备树节点里的compatible也经常写成多个字符串比如compatible example,mydevice-v2, example,mydevice意思是第一优先匹配 v2没有 v2 驱动时退回到基础版驱动。这种设计在芯片版本迭代时很常见新硬件尽量兼容旧驱动旧硬件也不会被新驱动误认。MODULE_DEVICE_TABLE(of, ...)这行值得单独说。它把of_device_id信息写进模块的二进制文件里系统里的 udev 通过读取设备节点导出的 modalias再配合模块文件里的 alias 表才能实现“插上设备自动加载模块”。只写of_match_table不写MODULE_DEVICE_TABLE直接insmod可能没问题但modprobe自动加载大概率失效。3.3 probe里取资源的核心API当设备和驱动匹配成功内核就会调用驱动的probe函数。在这个函数里我们的工作就是从platform_device里把资源取出来。常用 API 有这些platform_get_resource(pdev, IORESOURCE_MEM, 0)拿reg描述的内存资源。platform_get_irq(pdev, 0)拿interrupts描述的中断号。devm_platform_ioremap_resource(pdev, 0)同时完成取资源和 ioremap返回虚拟地址。devm_gpiod_get(dev, led, GPIOD_OUT_LOW)按属性名找 GPIO。devm_clk_get(dev, NULL)拿时钟引用。这里思路很关键驱动不应该直接写死物理地址和 GPIO 编号而是通过上述 API 从设备树里拿。拿不到了就去报错拿到什么用什么。这才能让同一份驱动在不同板卡上复用。所以 platform 驱动和设备树的关系可以总结成一句话设备树负责“说清硬件是什么”驱动负责“按约定去取”。4. 实操一个绑定DTS的LED/GPIO平台驱动4.1 DTS节点怎么加我用一个简化示例演示假设测试 SoC 上有一个外设既有一块寄存器空间又接了一个 LED 和一根中断脚。DTS 片段如下实际 BSP 里gpio1通常由厂商设备树提供我这里把结构写完整/dts-v1/; / { compatible example,myboard; #address-cells 1; #size-cells 1; mydevice10000000 { compatible example,mydevice; reg 0x10000000 0x1000; interrupt-parent gpio1; interrupts 16 IRQ_TYPE_EDGE_FALLING; led-gpios gpio1 15 GPIO_ACTIVE_HIGH; status okay; }; gpio1: gpio-controller10001000 { compatible example,gpio; reg 0x10001000 0x1000; gpio-controller; #gpio-cells 2; interrupt-controller; #interrupt-cells 2; }; };这里我用到了IRQ_TYPE_EDGE_FALLING和GPIO_ACTIVE_HIGH两个宏实际项目里 DTS 文件通常需要通过#include dt-bindings/interrupt-controller/irq.h和#include dt-bindings/gpio/gpio.h引入定义。不同 SoC 厂家的引脚宏定义可能不一样但引用的理念是一致的。节点里reg描述了外设寄存器interrupts描述了中断来源led-gpios描述了 LED 控制脚。一个硬件实体需要的核心资源在 DTS 里被完整描述出来了。4.2 driver代码怎么写对应的平台驱动示例#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_platform.h #include linux/io.h #include linux/interrupt.h #include linux/gpio/consumer.h #include linux/slab.h struct mydevice_data { void __iomem *base; struct gpio_desc *led; }; static irqreturn_t mydevice_isr(int irq, void *dev_id) { struct mydevice_data *data dev_id; iowrite32(ioread32(data-base) ^ 0x1,>obj-m mydevice.o然后执行make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules注意交叉编译前缀、内核源码路径和内核配置调用make时要保证内核头文件与运行的内核版本一致否则模块加载时会出现版本校验失败。把生成的mydevice.ko放到板子上后先确认设备树确实新加了节点再加载驱动insmod mydevice.ko dmesg | tail如果看到mydevice probed说明设备和驱动匹配成功。再看看 sysfsls -l /sys/bus/platform/devices/10000000.mydevice/driver如果链接指向mydevice说明驱动绑定正常。也可以直接查看设备树属性od -c /proc/device-tree/mydevice10000000/compatible会看到example,mydevice和结尾的\0。/proc/device-tree实际上把 DTB 解析后的节点直接导出成了一个只读文件系统查属性非常方便。5. 设备树与平台驱动调试的常见坑5.1 probe不执行的排查顺序驱动写好了也编译进内核了但probe就是不执行。这种问题我遇到太多次了排查顺序一般是这样。第一步看 DTB 是否真的更新到板子上了。很多时候你改了 DTS但 bootloader 加载的还是旧 DTB尤其是直接从网络启动或从 eMMC 固定分区读取的时候特别容易漏。先确认 boot 日志里有没有这句Kernel command line: ...再看/proc/device-tree里能不能找到新加的节点找不到就是 DTB 没刷进去。第二步看节点的status。如果是disabled内核默认不会为它创建设备。改成okay再试。第三步看compatible是否完全一致。设备树里写example,mydevice驱动of_match_table里写example,mydevice 带了个空格都不行。别笑我见过不少人把 vendor 前缀和型号名之间写得不一样。第四步查驱动是不是真的注册成功了。insmod之后执行grep mydevice /sys/bus/platform/drivers/mydevice/如果驱动注册失败dmesg 里有明确信息。最后看probe是否因为缺少依赖资源被 deferred。驱动里如果请求一个还没 ready 的时钟或 GPIO内核会返回-EPROBE_DEFER然后过一段时间再试。这时可以在 dmesg 里看到类似deferred probe的信息。5.2 reg/中断/GPIO资源解析失败这类问题最典型的报错是platform_get_irq failed、cant request region等。先看reg是否被正确解析。如果父节点的#address-cells或#size-cells写错了驱动拿到的 resource 会错得离谱。比如实际地址是0x10000000但解析成0x1000ioremap 出来的虚拟地址访问不动读写寄存器直接挂掉。中断资源解析失败时优先检查interrupt-parent是否存在、指向的中断控制器是否有#interrupt-cells、interrupts里写的 cell 数是否和控制器定义一致。很多 SoC 自带 GICGIC 的#interrupt-cells可能是 3那么interrupts 0 42 4才对只写42 4肯定不对。GPIO 属性解析失败常见原因就是属性名后缀。devm_gpiod_get(dev, led, ...)找的是led-gpios。如果设备树里写成led-gpio驱动会返回-ENOENT。还有一点GPIO 控制器是否有#gpio-cells以及#gpio-cells是 2 还是 1。有些芯片只有一个 GPIO bank#gpio-cells 1你在 DTS 里多写了一个 cell也会解析失败。5.3 引脚输出不对GPIO active-low与pinctrl有时候驱动加载正常寄存器配置也正常但 LED 就是点不亮或者按键电平反了。这里最容易忽略的有两件事。第一gpiod_set_value操作的是逻辑值会考虑设备树里的GPIO_ACTIVE_LOW。如果属性写的是GPIO_ACTIVE_LOW那么调用gpiod_set_value(1)时物理引脚输出的是低电平。这其实是 gpiod API 的贴心之处但也常常让初学的人摸不着头脑。用旧 APIgpio_set_value就不认这个 flag两个 API 混用更容易出问题。判断引脚物理电平最简单是拿示波器或者万用表量别靠猜。第二很多 SoC 的引脚默认就是普通 GPIO 功能但接到外设时还需要配置 pinctrl。如果设备节点没有pinctrl-0属性或者内核没启用对应的 pinctrl 驱动引脚可能还停留在复位默认状态GPIO 请求成功了输出却不对。遇到这种情况去核对 SoC 的 pinctrl 文档在 DTS 里给节点加上pinctrl-names default; pinctrl-0 mydevice_led_pins;5.4 dtb版本对不上的尴尬这个问题其实很常见但很多人第一次遇到时都会怀疑驱动。你在源码树里改的 DTS 属于源码最终 bootloader 加载的是 DTB。中间链条是DTS - DTC - DTB - bootloader 加载。任何一个环节缓存了旧文件都会导致内核看到的设备树和源码不一致。调试这类问题时先把 DTB 反编译出来看内容。反编译命令是dtc -I dtb -O dts -o dump.dts myboard.dtb然后直接搜索你加的节点名。如果反编译结果里没有说明 DTB 不是从你改过的 DTS 编译出来的。如果反编译结果里有但内核/proc/device-tree里没有那问题就在 bootloader 的加载流程比如加载了固定偏移分区里的另一个 DTB。这个排查思路能节约很多时间。6. 实战经验设备树时代的几点建议设备树把硬件描述从驱动代码里剥了出去这对驱动工程师的思维习惯是个不小的挑战。我自己的体会是驱动代码会越写越“薄”很多时候核心工作变成了写 DTS、调 DTS、查 DTS。有几个建议特别想分享。第一尽量用 resource-managed API。devm_platform_ioremap_resource、devm_gpiod_get、devm_clk_get这类 API 能自动管理资源释放出错时 probe 直接 return不用手工清理一堆临时变量。中断用devm_request_threaded_irq也一样设备解除绑定时不需要手动 free。这样代码不仅简洁还能避免内存泄漏和资源泄漏这类低级 bug。第二不要把一个设备的功能拆散到多个节点里。设备树是按“功能设备”建模的不是按“寄存器块”建模的。一个外设占一块寄存器、一根中断、一个 GPIO就应该是一个节点。驱动去节点里取资源才能保持一致。如果你强行按寄存器块拆成几个节点probe 时序、资源归属、电源时钟依赖都会变得非常难管。第三DTS 里的 compatible 字符串最好遵循厂商前缀加设备名的方式比如example,mydevice。这不仅是规范问题也直接影响驱动匹配和社区文档检索。随便起名会让后人根本无从判断驱动属于哪个 IP。第四新手调试时不要急着在内核里打印寄存器和中断状态。先确认设备树节点被正确解析再确认驱动和节点匹配成功最后才谈寄存器配置。很多所谓“驱动不工作”其实前面两步就没过。用/proc/device-tree和 sysfs 把这两步查清楚比反复加 printk 有效得多。设备树的坑归根结底都是“描述”和“解释”不一致的坑。写驱动时心里始终要有一根弦设备树不是给你随便写注释的文档它是一个会被内核逐字解析的数据结构。你少一个属性驱动就少一条路你多写一个 cell驱动就找错一个资源。保持 DTS 和驱动一一对应很多调试痛苦都能提前避免。