仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里,或者一起来尝试跑7.0的Linux!欢迎各位大佬观摩!喜欢的话点个⭐!

仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/
跟着教程一路走过来,你现在应该对设备树有了相当全面的了解:知道它是什么、语法怎么写、OF API 怎么用。上一章我们也完成了从硬编码驱动到设备树驱动的改造。但说实话,这些知识如果不动手,永远只是"纸上谈兵"。
很多朋友在这个阶段会遇到一个尴尬的问题:教程里的示例都看懂了,但面对自己手里的开发板,却不知道从哪里下手。厂商给的设备树文件一大堆,动辄几千行,看着就头皮发麻。这一章,我们要把之前学的所有知识串起来,从空白开始走完整个流程:确认板级 DTS 位置、编写 DTS 文件、编译 DTB、编写驱动代码、编译驱动模块、部署到板子、加载驱动测试。这六步走完,你就真正掌握了设备树驱动开发的完整闭环。
我们的实验目标是点亮 LED。这个实验足够简单,不会让你在硬件调试上浪费太多时间,同时又涵盖了设备树驱动的所有核心要素——描述硬件,而不是编码硬件,这就是现代驱动开发的分水岭。
在动手之前,最重要的一步是搞清楚"我们要改什么"。
首先确认你手里的开发板型号。最可靠的方法是看串口启动信息,你会看到类似这样的输出:
Model: Freescale i.MX6 UltraLite 14x14 EVK Board
或者:
Machine: ALIENTEK ATK-IMX6ULL
确认板子型号之后,找到对应的 DTS 文件。在我们的 imx-forge 项目中,设备树文件存放在 driver/device_tree/alpha-board/ 目录下。如果使用 NXP 官方 BSP,通常在内核源码的 arch/arm/boot/dts/ 目录下。
典型的设备树目录结构:
arch/arm/boot/dts/
├── imx6ull.dtsi # SOC 级通用定义(类似 C 语言头文件)
├── imx6ull-14x14-evk.dts # 官方 EVK 板级文件
├── imx6ull-14x14-evk.dtb # 编译后的二进制文件
└── ...
三种文件的区别:
.dtsi.dts 引用.dts.dtb重要原则:永远不要直接修改 .dtsi 文件! 这些文件是公用的,修改会影响所有引用它的板子。正确做法是在你的 .dts 文件里通过引用标签来修改或追加内容。
修改之前养成备份的习惯:
# 备份原始文件
cp imx6ull-aes-led.dts imx6ull-aes-led.dts.bak
# 或者用 git
git stash save "修改前的备份"
git checkout -b experiment/device-tree-modification
根据 IMX6ULL 芯片手册,控制 LED 需要操作以下寄存器:
打开设备树文件(在我们的项目中位于 driver/device_tree/alpha-board/device_tree_try_03/imx6ull-aes-led.dts),在根节点 / 下添加 LED 节点:
/dts-v1/;
#include "imx6ull.dtsi"
#include "imx6ull-aes.dtsi"
/ {
model = "Awesome Embedded Studio IMX6ULL Example Driver";
compatible = "fsl,imx6ull-14x14-evk", "fsl,imx6ull";
imx_aes_led {
#address-cells = <1>;
#size-cells = <1>;
compatible = "atkalpha-led";
status = "okay";
reg = < 0X020C406C 0X04 /* CCM_CCGR1_BASE */
0X020E0068 0X04 /* SW_MUX_GPIO1_IO03_BASE */
0X020E02F4 0X04 /* SW_PAD_GPIO1_IO03_BASE */
0X0209C000 0X04 /* GPIO1_DR_BASE */
0X0209C004 0X04 >; /* GPIO1_GDIR_BASE */
};
};
逐行解析关键部分:
imx_aes_led/imx_aes_led,驱动代码通过这个路径查找节点#address-cells = <1>#size-cells = <1>compatible = "atkalpha-led"status = "okay""disabled",内核会跳过这个设备reg 属性新手易踩的坑:reg 里的数字必须严格遵循
#address-cells和#size-cells定义的格式。我们定义了都是 1,所以格式就是地址、长度、地址、长度……以此类推。
你可能会问:为什么把这个节点直接放在根节点下面?答案取决于设备连接方式:
如果需要修改现有节点而不是添加新节点,可以通过 &label 引用:
&i2c1 {
clock-frequency = <100000>;
status = "okay"; /* 覆盖原来的 "disabled" */
mag3110@0e {
compatible = "fsl,mag3110";
reg = <0x0e>;
};
};
编译之前先检查语法:
dtc -I dts -O dtb -o /dev/null imx6ull-aes-led.dts
没有报错就说明语法基本正确。
内核只能读取二进制的 DTB 文件,不能直接读取 DTS 文本文件。
在 imx-forge 项目中最简单的方式:
cd /home/charliechen/imx-forge
./scripts/driver_helper/build_driver.sh device_tree_try_03 alpha-board
脚本会自动完成:查找源码和设备树文件 → 编译驱动生成 .ko → 编译设备树生成 .dtb → 产物放到 out/driver_artifacts/device_tree_try_03/alpha-board/。
# 基本编译
dtc -I dts -O dtb -o imx6ull-aes-led.dtb imx6ull-aes-led.dts
# 带 include 路径(DTS 引用了其他文件时)
dtc -I dts -O dtb -i driver/device_tree/alpha-board/ \
-o imx6ull-aes-led.dtb \
driver/device_tree/alpha-board/device_tree_try_03/imx6ull-aes-led.dts
把编译出来的 DTB 反编译回 DTS 格式,对比确认正确性:
dtc -I dtb -O dts -o test_from_dtb.dts imx6ull-aes-led.dtb
grep -A 10 "imx_aes_led" test_from_dtb.dts
反编译的 DTS 在格式上可能和原始 DTS 有差异,但节点结构和属性值应该一致。
设备树准备好了,现在编写驱动。核心逻辑:去设备树里把刚才填的值"抠"出来,然后操作寄存器控制 LED。
我们的驱动代码分为两个文件:led_hw.c 负责硬件操作,device_tree_try_03_driver_main.c 负责字符设备框架。这种分离让代码结构更清晰,也便于以后复用。
#include<linux/of.h>
#include<linux/of_address.h>
structled_handle {
void __iomem* ccm_ccgr1;
void __iomem* sw_mux_gpio;
void __iomem* sw_pad_gpio;
void __iomem* gpio_dr;
void __iomem* gpio_gdir;
structdevice_node* device_tree_node;
};
staticstructled_handleled;
linux/of.h 提供设备树基础 API(of_find_node_by_path、of_property_read_string 等),linux/of_address.h 提供地址映射 API(of_iomap)。结构体最后一个成员 device_tree_node 保存设备树节点指针,后续所有操作都靠它。
真正的重头戏在 led_hw_init 里——数据从 DTS 流向内核内存,再流向驱动变量:
intled_hw_init(void) {
u32 regdata[10];
int ret;
constchar* str;
structproperty* proper;
u32 val;
/* 1. 获取设备节点 */
led.device_tree_node = of_find_node_by_path("/imx_aes_led");
if (led.device_tree_node == NULL) {
pr_err("dtsled node can not found!\n");
return -EINVAL;
}
pr_info("dtsled node has been found!\n");
第一步:找人。of_find_node_by_path 就像在电话簿里按名字找人。路径必须和 DTS 里写的一样(包括根节点 /)。如果返回 NULL,说明设备树里没这个节点或者路径写错了。
注意:
of_find_node_by_path成功时会增加节点引用计数,用完后必须调用of_node_put释放,否则会内存泄漏。
/* 2. 获取 compatible 属性(验证性读取) */
proper = of_find_property(led.device_tree_node, "compatible", NULL);
if (proper == NULL) {
pr_err("compatible property find failed\n");
} else {
pr_info("compatible = %s\n", (char*)proper->value);
}
第二步:查户口。of_find_property 找具体的属性,proper->value 指向属性值的原始数据。对于字符串属性,可以直接转成 char* 打印。这里主要是验证性读取——我们通过路径查找节点而非通过 compatible 匹配,所以失败也不中断。
/* 3. 获取 status 属性 */
ret = of_property_read_string(led.device_tree_node, "status", &str);
if (ret < 0) {
pr_err("status read failed!\n");
} else {
pr_info("status = %s\n", str);
}
第三步:看状态。of_property_read_string 专读字符串属性。标准值是 "okay"(可用)和 "disabled"(禁用)。第三个参数是 const char**,函数会把字符串地址存到这个指针指向的位置,不需要调用者手动释放内存。
/* 4. 获取 reg 属性(关键) */
ret = of_property_read_u32_array(led.device_tree_node, "reg", regdata, 10);
if (ret < 0) {
pr_err("reg property read failed!\n");
of_node_put(led.device_tree_node);
return -EINVAL;
}
pr_info("reg data:\n");
for (int i = 0; i < 10; i++) {
pr_cont("%#X ", regdata[i]);
}
pr_cont("\n");
第四步:拿地址。 DTS 里 5 组寄存器,每组"地址+长度"两个值,共 10 个 u32。执行完这一句,regdata 数组里就存满了我们在 DTS 里写的那串十六进制数字。
注意:这里读取失败时我们手动调用了
of_node_put释放节点引用,因为直接 return 了,不会走到后面的统一清理逻辑。这个细节很容易遗忘,但忘记释放会导致内存泄漏。
拿到了物理地址,下一步是映射。of_iomap 可以直接从 reg 属性中取第 N 组地址进行映射:
/* 5. 使用 of_iomap 进行寄存器地址映射 */
led.ccm_ccgr1 = of_iomap(led.device_tree_node, 0);
led.sw_mux_gpio = of_iomap(led.device_tree_node, 1);
led.sw_pad_gpio = of_iomap(led.device_tree_node, 2);
led.gpio_dr = of_iomap(led.device_tree_node, 3);
led.gpio_gdir = of_iomap(led.device_tree_node, 4);
if (!led.ccm_ccgr1 || !led.sw_mux_gpio || !led.sw_pad_gpio ||
!led.gpio_dr || !led.gpio_gdir) {
pr_err("ioremap failed!\n");
of_node_put(led.device_tree_node);
return -ENOMEM;
}
of_iomap(node, index) 的设计非常巧妙:驱动代码不需要知道具体的地址值,只需要知道"我需要第几个寄存器"。索引 0 对应 reg 属性第一组 0X020C406C 0X04,索引 1 对应第二组,以此类推。具体的地址是什么,那是设备树的事情。 换一块板子,只需要改设备树文件,驱动代码完全不用动。
of_iomap 内部会调用 ioremap,失败时返回 NULL。它还会自动处理 reg 属性中的地址转换(某些架构上设备树地址可能不是直接的物理地址)。
映射完成后,操作寄存器和硬编码版本完全一样:
/* 6. 使能 GPIO1 时钟 */
val = readl(led.ccm_ccgr1);
val &= ~(3 << 26);
val |= (3 << 26);
writel(val, led.ccm_ccgr1);
/* 7. 设置 GPIO1_IO03 复用为 GPIO */
writel(5, led.sw_mux_gpio);
/* 8. 设置 GPIO1_IO03 电气属性 */
writel(0x10B0, led.sw_pad_gpio);
/* 9. 设置 GPIO1_IO03 为输出 */
val = readl(led.gpio_gdir);
val |= (1 << 3);
writel(val, led.gpio_gdir);
/* 10. 默认关闭 LED(高电平) */
val = readl(led.gpio_dr);
val |= (1 << 3);
writel(val, led.gpio_dr);
pr_info("LED Init OK!\n");
return0;
}
无论地址来自硬编码还是设备树,硬件寄存器的操作方式不变。这正是"配置与代码分离"的好处。
voidled_set_status(bool status) {
u32 val = readl(led.gpio_dr);
if (status) {
val &= ~(1 << 3); /* 低电平点亮 */
} else {
val |= (1 << 3); /* 高电平熄灭 */
}
writel(val, led.gpio_dr);
}
boolled_get_status(void) {
u32 val = readl(led.gpio_dr);
return (val & (1 << 3)) == 0;
}
voidled_hw_deinit(void) {
if (led.ccm_ccgr1) { iounmap(led.ccm_ccgr1); led.ccm_ccgr1 = NULL; }
if (led.sw_mux_gpio) { iounmap(led.sw_mux_gpio); led.sw_mux_gpio = NULL; }
if (led.sw_pad_gpio) { iounmap(led.sw_pad_gpio); led.sw_pad_gpio = NULL; }
if (led.gpio_dr) { iounmap(led.gpio_dr); led.gpio_dr = NULL; }
if (led.gpio_gdir) { iounmap(led.gpio_gdir); led.gpio_gdir = NULL; }
if (led.device_tree_node) {
of_node_put(led.device_tree_node);
led.device_tree_node = NULL;
}
}
资源清理的几个要点:
led_hw_deinit 被调用两次时的双重释放of_node_put字符设备框架代码(file_operations、cdev、class、device 等)和传统驱动完全一样,此处不再赘述,完整代码请参考项目中的 device_tree_try_03_driver_main.c。
驱动代码写好了,编译生成 .ko 文件。
cd /home/charliechen/imx-forge
./scripts/driver_helper/build_driver.sh device_tree_try_03 alpha-board
脚本会自动处理:设置交叉编译工具链 → 指定内核源码路径 → 调用 make 编译 → 产物放到输出目录。
cd driver/device_tree_try_03/alpha-board
make
Makefile 大致如下:
obj-m := device_tree_try_03_driver.o
device_tree_try_03_driver-y := device_tree_try_03_driver_main.o led_hw.o
ARCH := arm
CROSS_COMPILE := arm-none-linux-gnueabihf-
KDIR := $(PROJECT_ROOT)/third_party/linux-${KERNEL_TYPE}
modules:
$(MAKE) -C $(KDIR) M=$(CURDIR) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules
注意驱动由两个 .o 文件组成,链接成一个 .ko 模块。
ls -lh out/driver_artifacts/device_tree_try_03/alpha-board/
# 预期产物:
# device_tree_try_03_driver.ko (约14K)
# imx6ull-aes-led.dtb (约35K)
modinfo device_tree_try_03_driver.ko
# 查看 vermagic 确认内核版本匹配
如果 vermagic 显示的内核版本和板子运行的内核不一致,模块加载会因版本不匹配而失败。
编译好了 .ko 和 .dtb,部署到开发板上。
# 使用部署脚本
./scripts/driver_helper/deploy_driver.sh device_tree_try_03 alpha-board --target=tftp
# 或手动拷贝
sudocp out/driver_artifacts/device_tree_try_03/alpha-board/imx6ull-aes-led.dtb \
/srv/tftp/imx6ull-aes.dtb
注意目标文件名是 imx6ull-aes.dtb,不是 imx6ull-aes-led.dtb。U-Boot 启动时加载的 DTB 文件名由环境变量 fdt_file 决定:
=> printenv fdt_file
fdt_file=imx6ull-aes.dtb
如果不确定自己的板子用哪个文件名,在 U-Boot 命令行输入 printenv 查看。
# 通过 NFS
./scripts/driver_helper/deploy_driver.sh device_tree_try_03 alpha-board --target=nfs
# 或通过 scp
scp device_tree_try_03_driver.ko root@192.168.1.100:/lib/modules/
部署 DTB 后重启板子,验证设备树是否正确加载:
# 查看节点是否存在
ls /proc/device-tree/imx_aes_led/
# 查看属性
cat /proc/device-tree/imx_aes_led/compatible
# 输出:atkalpha-led
cat /proc/device-tree/imx_aes_led/status
# 输出:okay
hexdump -C /proc/device-tree/imx_aes_led/reg
# 输出寄存器地址列表
如果节点不存在,说明 DTB 没有正确加载或者节点路径写错了。如果属性值不对,说明 DTS 里的属性定义有问题。
终于到了见证结果的时刻。
insmod device_tree_try_03_driver.ko
# 查看内核日志
dmesg | tail -30
预期日志:
[ 12.345678] dtsled node has been found!
[ 12.345679] compatible = atkalpha-led
[ 12.345680] status = okay
[ 12.345681] reg data:
[ 12.345682] 0X20C406C 0X4 0X20E0068 0X4 0X20E02F4 0X4 0X209C000 0X4 0X209C004 0X4
[ 12.345683] LED Init OK!
[ 12.345684] LED handle get the device number: major: 245, minor: 0
这串日志意味着:内核找到了 imx_aes_led 节点、成功读取了属性、reg data 和 DTS 文件中写的完全一致、寄存器映射成功、字符设备创建成功。
# 检查设备文件
ls -l /dev/AES_LED
# 点亮 LED(低电平有效)
echo 1 > /dev/AES_LED
# 熄灭 LED
echo 0 > /dev/AES_LED
# 读取 LED 状态
cat /dev/AES_LED
看板子上的 LED,亮了吗?如果亮了,恭喜——你完成了完整的设备树驱动开发!
rmmod device_tree_try_03_driver
dmesg | tail
# [ 234.567890] Deinit LED Hardware
即使严格按教程操作,也可能会遇到各种问题。这里总结最常见的坑点。
dmesg 是你最好的调试朋友:
dmesg | tail -20 # 最近的消息
dmesg | grep -i "led"# 过滤关键词
dmesg -w # 实时监控
dtsled node can not found! — 节点找不到
/)/proc/device-tree 中查看节点是否存在reg property read failed! — reg 属性读取失败
hexdump -C /proc/device-tree/imx_aes_led/reg 查看实际值ioremap failed! — 内存映射失败
编译通过但内核报 "Duplicate node" — 节点重复
.dts 里定义了一个 .dtsi 里已存在的节点&label 引用现有节点,或用 /delete-node/ 删除DTB 部署后不生效 — 加载的不是你的 DTB
printenv fdt_file 确认加载的文件名tftp 0x83000000 imx6ull-aes.dtb; bootm 0x80800000 - 0x83000000DTC 编译报错 "FDT_ERR_BADSTRUCTURE" — 编译失败但信息模糊
#include 指令,确保文件存在-i 选项指定 include 路径.dtsi 文件排查按以下顺序逐一排查:
/proc/device-tree 中有无对应节点lsmod 中有无模块,dmesg 有无报错/dev 目录下有无设备文件,权限是否正确strace 跟踪用户态调用这一章我们把所有知识串起来,走完了从修改设备树到点亮 LED 的完整闭环。回顾一下:
of_iomap 映射寄存器.ko 模块,通过 TFTP/NFS 部署到板子核心思想只有一个:把"硬件描述"和"驱动逻辑"彻底解耦。驱动只负责"怎么操作一个 GPIO",设备树负责"这个 GPIO 在哪里"。以前移植驱动需要改代码重新编译 .ko,现在只需改 .dts 重新编译 DTB(几秒钟的事),驱动代码连动都不用动。
这就是 Linux 内核引入设备树模型的初衷,也是构建可移植嵌入式系统的基石。
当然,我们展示的还是最基础的使用方式。在实际工程中,你还会遇到中断、DMA、时钟、电源管理……这些都可以通过设备树描述,但万变不离其宗。在未来的学习中,你会接触到 Platform 设备驱动框架,届时你会发现今天折腾的 device_node 和 OF 函数,在 Platform 驱动里以更标准、更优雅的方式被封装起来。
现在,找一块开发板,把这一章的流程完整走一遍。只有在实践中,理论才能变成你自己的技能。