写给物联网人的Linux教程:从小白到精通——一文带你攻克设备驱动开发中的“设备树”难题
💡 阅读提示:本文专为物联网工程师打造,从零讲透设备树(Device Tree)的语法、原理、实战修改与调试。读完你将能独立为开发板添加外设、解决驱动加载问题。文末有完整DTS文件和避坑指南。
🚨 开篇:一个“设备树”引发的血案
刚接触Linux驱动开发时,我拿着一个I2C触摸屏,按照内核文档写好驱动,编译进内核,insmod也成功,但就是没反应。折腾了三天,最后一位前辈扫了一眼我的代码,问:“你在设备树里添加节点了吗?”
我一脸懵:设备树是什么树?
后来才知道,ARM Linux从2013年开始全面采用设备树(Device Tree)来描述硬件,取代了以前硬编码的board文件。如果不修改设备树,驱动永远不知道自己要控制的设备在哪里、用什么中断、接在哪个I2C总线上。
设备树就是硬件的“身份证”。它告诉内核:我有这些CPU、这些内存、这些外设,每个外设的中断引脚、时钟频率、寄存器地址分别是什么。
今天,我将从物联网工程师的视角,用最直白的方式带你攻克设备树。读完这篇,你将:
-
理解设备树的起源和设计哲学
-
掌握DTS、DTC、DTB的关系
-
学会编写和修改设备树节点(GPIO、I2C、SPI、中断)
-
实战:为你的开发板添加一个OLED屏幕驱动
-
熟练使用设备树调试工具(dtc、procfs、debugfs)
-
避开95%的新手会踩的坑
一、设备树的“前世今生”
1.1 为什么需要设备树?
在ARM Linux早期,每个板子的硬件信息都写在arch/arm/mach-xxx/board-xxx.c里,比如:
static struct platform_device my_device = {
.name = "my_driver",
.id = 0,
.resource = {
.start = 0x40000000,
.end = 0x400000FF,
.flags = IORESOURCE_MEM,
},
};
每增加一个板子,就要新增一个.c文件。内核里积累了成百上千个这样的文件,导致:
-
内核体积膨胀
-
一个内核无法同时支持多个板子(需要重新编译)
-
维护成本极高
2012年,Linus Torvalds发火后,ARM社区决定全面采用设备树——一种源自PowerPC的硬件描述机制。设备树将硬件信息从内核源码中剥离成独立的.dts文本文件,编译成.dtb二进制后由bootloader传递给内核。同一个内核镜像,通过更换不同的dtb,就能适配不同板卡。
1.2 设备树的文件家族
| 扩展名 | 全称 | 作用 |
|---|---|---|
.dts |
Device Tree Source | 设备树源文件,人类可编辑 |
.dtsi |
Device Tree Source Include | 包含文件,类似C语言的.h,用于复用公共定义(如SoC的通用外设) |
.dtb |
Device Tree Blob | 二进制文件,由DTC编译DTS生成,内核实际解析它 |
.dtbo |
Device Tree Blob Overlay | 动态覆盖片,用于热插拔设备(如树莓派扩展板) |
工具链:dtc(Device Tree Compiler)负责编译、反编译、验证。
# 编译 dts 到 dtb
dtc -I dts -O dtb -o myboard.dtb myboard.dts
# 反编译 dtb 到 dts(用于查看已有板子的配置)
dtc -I dtb -O dts -o extracted.dts myboard.dtb
二、设备树语法快速入门(物联网工程师视角)
设备树本质上是一棵树,根节点是/,下面有若干子节点,每个节点有属性。
2.1 最简单的设备树示例
/dts-v1/;
/ {
model = "My IoT Board";
compatible = "mycompany,myboard";
#address-cells = <1>;
#size-cells = <1>;
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>; // 起始地址0x80000000,大小256MB
};
chosen {
bootargs = "console=ttyS0,115200";
stdout-path = "serial0:115200n8";
};
leds {
compatible = "gpio-leds";
led0: led@0 {
label = "heartbeat";
gpios = <&gpio1 16 GPIO_ACTIVE_HIGH>;
default-state = "on";
};
};
};
2.2 常用属性详解
| 属性 | 含义 | 示例 |
|---|---|---|
compatible |
设备兼容性字符串,驱动会用它来匹配 | "my_driver", "generic_driver" |
reg |
寄存器地址和长度,格式是<地址 长度>,个数由#address-cells和#size-cells决定 |
<0x40000000 0x1000> |
interrupts |
中断号、触发方式 | <0 31 1>(SPI中断31,上升沿触发) |
interrupt-parent |
指定中断控制器 | <&gic> |
gpios |
GPIO引脚和有效电平 | <&gpio1 16 GPIO_ACTIVE_LOW> |
clocks |
使用的时钟 | <&clk IMX6QDL_CLK_UART_IPG> |
status |
设备状态,okay表示启用,disabled表示禁用 |
okay |
pinctrl-0, pinctrl-names |
引脚复用配置 | pinctrl-0 = <&uart1_pins> |
2.3 节点命名规范
text
节点名@地址
-
节点名:描述设备类型,如
i2c、spi、led -
地址:通常是寄存器的基地址
示例:
-
uart@40000000 -
i2c@30000000 -
led@0(无地址时可为0)
2.4 引用与标签
用&符号引用其他节点,用:定义标签。
&i2c1 { // 引用 SoC 定义好的 i2c1 节点
status = "okay";
clock-frequency = <400000>;
my_device: mydevice@20 { // 标签为 my_device,地址0x20
compatible = "my,i2c-sensor";
reg = <0x20>;
interrupt-parent = <&gpio2>;
interrupts = <3 IRQ_TYPE_EDGE_FALLING>;
};
};
2.5 包含文件
#include "stm32mp157.dtsi" // SoC 通用定义
#include "stm32mp15xxac-pinctrl.dtsi"
dtsi里定义了默认的外设节点,状态大多是disabled,需要在板级.dts中启用并配置引脚。
三、实战:为开发板添加一个I2C触摸屏
我们有一块搭载I2C触摸屏(FT5x06)的开发板,触摸屏挂在I2C2总线上,地址0x38,中断引脚连接到GPIO1_IO9。
3.1 步骤1:查看原设备树
先反编译或找到开发板原有的.dts,查看I2C2是否已启用。
dtc -I dtb -O dts -o myboard.dts myboard.dtb
或者直接在内核源码中找到对应dts。假设I2C2节点如下:
&i2c2 {
status = "disabled"; // 默认禁用
pinctrl-names = "default";
pinctrl-0 = <&i2c2_pins>;
clock-frequency = <100000>;
};
3.2 步骤2:在板级dts中启用I2C2并添加触摸屏节点
修改你板子的.dts文件,在根节点下增加:
#include <dt-bindings/interrupt-controller/irq.h>
#include <dt-bindings/gpio/gpio.h>
/ {
// ...其他内容
};
&i2c2 {
status = "okay"; // 启用
clock-frequency = <400000>; // 提高到400kHz(如果支持)
touchscreen@38 {
compatible = "edt,edt-ft5x06";
reg = <0x38>;
interrupt-parent = <&gpio1>;
interrupts = <9 IRQ_TYPE_EDGE_FALLING>;
reset-gpios = <&gpio1 10 GPIO_ACTIVE_LOW>;
wakeup-source;
};
};
3.3 步骤3:配置引脚复用(pinctrl)
如果I2C2和中断引脚还没配置复用,需要在pinctrl节点中增加。通常在&pinctrl或&iomuxc下:
&iomuxc {
pinctrl_i2c2: i2c2grp {
fsl,pins = <
MX6UL_PAD_UART5_TX_DATA__I2C2_SCL 0x4001b8b0
MX6UL_PAD_UART5_RX_DATA__I2C2_SDA 0x4001b8b0
>;
};
pinctrl_touch_irq: touch_irqgrp {
fsl,pins = <
MX6UL_PAD_GPIO1_IO09__GPIO1_IO09 0x1b0b0 // 中断引脚,输入
MX6UL_PAD_GPIO1_IO10__GPIO1_IO10 0x1b0b0 // 复位引脚,输出
>;
};
};
然后在&i2c2中引用:
&i2c2 {
pinctrl-0 = <&pinctrl_i2c2>, <&pinctrl_touch_irq>;
// ...
};
3.4 步骤4:编译设备树
在内核源码根目录下:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- dtbs
或单独编译你的dtb:
make ARCH=arm myboard.dtb
3.5 步骤5:替换dtb并重启
把新生成的.dtb复制到/boot或烧录到开发板,重启。
3.6 步骤6:验证
系统启动后,查看内核日志:
dmesg | grep ft5x
应该看到类似:
ft5x06 2-0038: ft5x06 touchscreen detected
input: ft5x06 as /devices/platform/soc/2000000.i2c/i2c-2/2-0038/input/input2
检查I2C设备:
cat /sys/bus/i2c/devices/2-0038/name
应该输出ft5x06。
检查中断是否注册:
cat /proc/interrupts | grep ft5x
四、调试设备树的必备工具
4.1 在系统运行中查看当前设备树
# 当前使用的完整设备树(二进制格式)
ls -l /sys/firmware/fdt
# 设备树节点呈现为目录结构
ls /sys/firmware/devicetree/base/
# 查看某个节点的 compatible 属性
cat /sys/firmware/devicetree/base/touchscreen@38/compatible
4.2 检查设备树是否被正确解析
# 查看某个设备是否成功创建平台设备
ls /sys/bus/platform/devices/
# 或 for i2c
ls /sys/bus/i2c/devices/
4.3 验证内存地址映射
cat /proc/iomem
4.4 反编译当前使用的dtb
# 将运行中的dtb导出
cp /sys/firmware/fdt /tmp/myboard.dtb
dtc -I dtb -O dts -o /tmp/current.dts /tmp/myboard.dtb
然后查看current.dts确认你的修改是否生效。
4.5 常见错误排查
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 驱动probe函数未被调用 | compatible不匹配或节点status不为okay | 检查compatible字符串,查看dmesg是否有“OF: ... not found” |
设备出现在/sys/bus/i2c/devices但驱动没加载 |
缺少MODULE_DEVICE_TABLE或内核没编译驱动 |
确认驱动中有of_match_table并已编译进内核 |
| 中断不触发 | 中断号或触发方式错误 | 查看/proc/interrupts确认中断已注册,用cat /sys/kernel/debug/gpio查看引脚电平 |
| 寄存器地址读写出错 | reg属性地址或长度错误 |
检查芯片手册,确认地址范围,查看/proc/iomem是否冲突 |
五、高级技巧:设备树Overlay动态覆盖
树莓派、BeagleBone等板卡支持设备树Overlay,可以在系统运行时动态加载/卸载设备节点,而无需重新编译整个dtb。
5.1 编写Overlay dts
/dts-v1/;
/plugin/;
&i2c2 {
#address-cells = <1>;
#size-cells = <0>;
my_temp@48 {
compatible = "ti,tmp102";
reg = <0x48>;
};
};
5.2 编译Overlay
dtc -@ -I dts -O dtb -o my_overlay.dtbo my_overlay.dts
5.3 加载Overlay
# 加载
echo my_overlay.dtbo > /sys/kernel/config/device-tree/overlays/ovl1/path
# 卸载
echo 0 > /sys/kernel/config/device-tree/overlays/ovl1/status
注意:需要内核开启CONFIG_OF_CONFIGFS和CONFIG_OF_OVERLAY。
六、物联网工程师的避坑指南
❌ 坑1:忘了启用节点
很多SoC的dtsi里默认status = "disabled",必须在板级dts中改为okay。
❌ 坑2:中断号和触发方式写错
中断号要查SoC手册,有些是硬件中断号(如GIC SPI),不是Linux中断号。触发方式常用:
-
IRQ_TYPE_EDGE_RISING -
IRQ_TYPE_EDGE_FALLING -
IRQ_TYPE_LEVEL_HIGH -
IRQ_TYPE_LEVEL_LOW
❌ 坑3:引脚复用冲突
两个设备用了同一个引脚(比如I2C和UART共用)。检查pinctrl部分,确保没有重复配置。
❌ 坑4:设备地址错误
I2C设备地址是7位地址(不包含读写位)。查看数据手册确认实际地址,例如0x38。
❌ 坑5:忘记添加时钟或时钟不匹配
某些外设需要时钟才能工作。如果驱动依赖时钟,必须在设备树中提供clocks属性,否则probe会失败。
❌ 坑6:内核配置未开启所需驱动
即使设备树正确,内核也必须编译进对应的驱动(或编译为模块)。检查.config中CONFIG_TOUCHSCREEN_FT5X06等。
七、写给物联网工程师的特别建议
-
善用厂商提供的dtsi:ST、NXP、全志等厂商会在内核源码中提供SoC的公共dtsi,包含了几乎所有外设的默认节点。你只需要
#include它,然后启用并配置引脚即可。 -
多做实验:可以在U-Boot阶段手动修改dtb(使用
fdt命令),验证修改效果再固化到内核。 -
阅读优秀的设备树:从树莓派、BeagleBone等开源硬件的设备树学起,它们规范清晰。
-
写驱动时记得添加OF匹配表:
static const struct of_device_id my_driver_of_match[] = {
{ .compatible = "mycompany,mydevice", },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_driver_of_match);
static struct platform_driver my_driver = {
.driver = {
.name = "my_driver",
.of_match_table = my_driver_of_match,
},
.probe = my_probe,
.remove = my_remove,
};
八、写在最后
设备树是ARM Linux物联网开发的必修课。它并不难,难的是第一次理解它的“思维模式”——把硬件描述从驱动代码中剥离出来。
当你学会修改设备树后,你就不再是“只会写驱动”的工程师,而是能从系统层面看硬件的架构师。
现在,拿起你的开发板,试着写一个自己的设备树节点,点亮一个LED或挂载一个传感器。
设备树,就是物联网工程师的“乐高说明书”。
更多推荐

所有评论(0)