嵌入式Linux嵌入式Linux驱动开发:板级DTS实操与完整实战演练------从修改设备树到点亮LED的完整闭环
仓库已经开源!所有教程,主线内核移植,跑新版本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 文件
确认板子型号之后,找到对应的 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:SOC 级或模块级的通用定义,可被多个.dts引用.dts:具体的板级定义,包含这块板子特有的硬件配置.dtb:编译后的二进制设备树,内核真正读取的文件
重要原则:永远不要直接修改 .dtsi 文件! 这些文件是公用的,修改会影响所有引用它的板子。正确做法是在你的 .dts 文件里通过引用标签来修改或追加内容。
备份原有文件
修改之前养成备份的习惯:
bash
# 备份原始文件
cp imx6ull-aes-led.dts imx6ull-aes-led.dts.bak
# 或者用 git
git stash save "修改前的备份"
git checkout -b experiment/device-tree-modification
步骤1:编写 DTS 设备树文件
确定寄存器地址
根据 IMX6ULL 芯片手册,控制 LED 需要操作以下寄存器:
| 寄存器 | 地址 | 用途 |
|---|---|---|
| CCM_CCGR1 | 0x020C406C | 时钟使能 |
| SW_MUX_GPIO1_IO03 | 0x020E0068 | GPIO 复用 |
| SW_PAD_GPIO1_IO03 | 0x020E02F4 | GPIO 电气属性 |
| GPIO1_DR | 0x0209C000 | GPIO 数据 |
| GPIO1_GDIR | 0x0209C004 | GPIO 方向 |
编写设备节点
打开设备树文件(在我们的项目中位于 driver/device_tree/alpha-board/device_tree_try_03/imx6ull-aes-led.dts),在根节点 / 下添加 LED 节点:
dts
/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>:子节点 reg 属性中,地址占 1 个单元格(32 位)#size-cells = <1>:子节点 reg 属性中,长度占 1 个单元格(32 位)compatible = "atkalpha-led":驱动匹配标识,平台设备驱动中会用它来自动匹配status = "okay":设备启用;如果设为"disabled",内核会跳过这个设备reg属性:寄存器地址列表,格式为"地址1 长度1 地址2 长度2...",5 个寄存器共 10 个 u32 值
新手易踩的坑 :reg 里的数字必须严格遵循
#address-cells和#size-cells定义的格式。我们定义了都是 1,所以格式就是地址、长度、地址、长度......以此类推。
节点位置选择
你可能会问:为什么把这个节点直接放在根节点下面?答案取决于设备连接方式:
- 设备挂在 SOC 内部总线上(比如直接操作 GPIO 寄存器)→ 放在根节点下
- 设备挂在外部总线上(I2C、SPI)→ 放在对应的总线节点下
如果需要修改现有节点而不是添加新节点,可以通过 &label 引用:
dts
&i2c1 {
clock-frequency = <100000>;
status = "okay"; /* 覆盖原来的 "disabled" */
mag3110@0e {
compatible = "fsl,mag3110";
reg = <0x0e>;
};
};
语法检查
编译之前先检查语法:
bash
dtc -I dts -O dtb -o /dev/null imx6ull-aes-led.dts
没有报错就说明语法基本正确。
步骤2:编译 DTB
内核只能读取二进制的 DTB 文件,不能直接读取 DTS 文本文件。
使用构建脚本
在 imx-forge 项目中最简单的方式:
bash
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/。
手动编译
bash
# 基本编译
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 格式,对比确认正确性:
bash
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 有差异,但节点结构和属性值应该一致。
步骤3:编写设备树驱动代码
设备树准备好了,现在编写驱动。核心逻辑:去设备树里把刚才填的值"抠"出来,然后操作寄存器控制 LED。
我们的驱动代码分为两个文件:led_hw.c 负责硬件操作,device_tree_try_03_driver_main.c 负责字符设备框架。这种分离让代码结构更清晰,也便于以后复用。
头文件与结构体
c
#include <linux/of.h>
#include <linux/of_address.h>
struct led_handle {
void __iomem* ccm_ccgr1;
void __iomem* sw_mux_gpio;
void __iomem* sw_pad_gpio;
void __iomem* gpio_dr;
void __iomem* gpio_gdir;
struct device_node* device_tree_node;
};
static struct led_handle led;
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 流向内核内存,再流向驱动变量:
c
int led_hw_init(void) {
u32 regdata[10];
int ret;
const char* str;
struct property* 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释放,否则会内存泄漏。
c
/* 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 匹配,所以失败也不中断。
c
/* 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**,函数会把字符串地址存到这个指针指向的位置,不需要调用者手动释放内存。
c
/* 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
拿到了物理地址,下一步是映射。of_iomap 可以直接从 reg 属性中取第 N 组地址进行映射:
c
/* 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 属性中的地址转换(某些架构上设备树地址可能不是直接的物理地址)。
配置寄存器
映射完成后,操作寄存器和硬编码版本完全一样:
c
/* 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");
return 0;
}
无论地址来自硬编码还是设备树,硬件寄存器的操作方式不变。这正是"配置与代码分离"的好处。
LED 控制与资源清理
c
void led_set_status(bool status) {
u32 val = readl(led.gpio_dr);
if (status) {
val &= ~(1 << 3); /* 低电平点亮 */
} else {
val |= (1 << 3); /* 高电平熄灭 */
}
writel(val, led.gpio_dr);
}
bool led_get_status(void) {
u32 val = readl(led.gpio_dr);
return (val & (1 << 3)) == 0;
}
void led_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;
}
}
资源清理的几个要点:
- 检查 NULL 再释放 --- 防止
led_hw_deinit被调用两次时的双重释放 - 释放后置 NULL --- 防止悬空指针,错误使用时至少触发空指针异常而非内存破坏
- 最后调用
of_node_put--- 释放设备树节点引用,这是设备树 API 的要求
字符设备框架代码(file_operations、cdev、class、device 等)和传统驱动完全一样,此处不再赘述,完整代码请参考项目中的 device_tree_try_03_driver_main.c。
步骤4:编译驱动模块
驱动代码写好了,编译生成 .ko 文件。
使用构建脚本
bash
cd /home/charliechen/imx-forge
./scripts/driver_helper/build_driver.sh device_tree_try_03 alpha-board
脚本会自动处理:设置交叉编译工具链 → 指定内核源码路径 → 调用 make 编译 → 产物放到输出目录。
手动编译
bash
cd driver/device_tree_try_03/alpha-board
make
Makefile 大致如下:
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 模块。
检查编译结果
bash
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 显示的内核版本和板子运行的内核不一致,模块加载会因版本不匹配而失败。
步骤5:部署到板子
编译好了 .ko 和 .dtb,部署到开发板上。
部署 DTB 文件(TFTP)
bash
# 使用部署脚本
./scripts/driver_helper/deploy_driver.sh device_tree_try_03 alpha-board --target=tftp
# 或手动拷贝
sudo cp 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 查看。
部署 KO 文件
bash
# 通过 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 后重启板子,验证设备树是否正确加载:
bash
# 查看节点是否存在
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 里的属性定义有问题。
步骤6:加载驱动测试
终于到了见证结果的时刻。
加载驱动
bash
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 文件中写的完全一致、寄存器映射成功、字符设备创建成功。
测试 LED 控制
bash
# 检查设备文件
ls -l /dev/AES_LED
# 点亮 LED(低电平有效)
echo 1 > /dev/AES_LED
# 熄灭 LED
echo 0 > /dev/AES_LED
# 读取 LED 状态
cat /dev/AES_LED
看板子上的 LED,亮了吗?如果亮了,恭喜------你完成了完整的设备树驱动开发!
卸载驱动
bash
rmmod device_tree_try_03_driver
dmesg | tail
# [ 234.567890] Deinit LED Hardware
调试技巧与常见问题
即使严格按教程操作,也可能会遇到各种问题。这里总结最常见的坑点。
dmesg 日志分析
dmesg 是你最好的调试朋友:
bash
dmesg | tail -20 # 最近的消息
dmesg | grep -i "led" # 过滤关键词
dmesg -w # 实时监控
常见错误排查
dtsled node can not found! --- 节点找不到
- 检查节点路径(注意大小写和前导
/) - 检查 DTB 是否正确部署
- 在
/proc/device-tree中查看节点是否存在
reg property read failed! --- reg 属性读取失败
- 检查 DTS 中 reg 属性是否存在
- 确认格式正确(地址和长度成对出现)
- 用
hexdump -C /proc/device-tree/imx_aes_led/reg查看实际值
ioremap failed! --- 内存映射失败
- reg 属性中的地址可能无效
- of_iomap 的索引可能超出 reg 属性中定义的组数
- 检查是否已有其他驱动占用了这些地址
编译通过但内核报 "Duplicate node" --- 节点重复
- 你在
.dts里定义了一个.dtsi里已存在的节点 - 改用
&label引用现有节点,或用/delete-node/删除
DTB 部署后不生效 --- 加载的不是你的 DTB
- U-Boot 中执行
printenv fdt_file确认加载的文件名 - 检查 TFTP 目录下是否有旧文件
- 手动在 U-Boot 里加载:
tftp 0x83000000 imx6ull-aes.dtb; bootm 0x80800000 - 0x83000000
DTC 编译报错 "FDT_ERR_BADSTRUCTURE" --- 编译失败但信息模糊
- 检查所有
#include指令,确保文件存在 - 用
-i选项指定 include 路径 - 单独编译被 include 的
.dtsi文件排查
系统排查清单
按以下顺序逐一排查:
- 设备树是否加载? ---
/proc/device-tree中有无对应节点 - 驱动是否加载? ---
lsmod中有无模块,dmesg有无报错 - 设备文件是否创建? ---
/dev目录下有无设备文件,权限是否正确 - 硬件是否正常? --- LED 连接的 GPIO 是否正确,用万用表测量电平
- 驱动逻辑是否正确? --- 添加更多调试打印,或用
strace跟踪用户态调用
总结
这一章我们把所有知识串起来,走完了从修改设备树到点亮 LED 的完整闭环。回顾一下:
- 环境准备:确认板子型号、找到 DTS 文件、备份
- 编写 DTS:在设备树中"描述"硬件,而不是在代码中"编码"硬件
- 编译 DTB:用构建脚本或手动 DTC 编译
- 编写驱动 :通过 OF API 从设备树获取硬件信息,用
of_iomap映射寄存器 - 编译部署 :交叉编译
.ko模块,通过 TFTP/NFS 部署到板子 - 测试验证:加载驱动、测试功能、分析日志
核心思想只有一个:把"硬件描述"和"驱动逻辑"彻底解耦 。驱动只负责"怎么操作一个 GPIO",设备树负责"这个 GPIO 在哪里"。以前移植驱动需要改代码重新编译 .ko,现在只需改 .dts 重新编译 DTB(几秒钟的事),驱动代码连动都不用动。
这就是 Linux 内核引入设备树模型的初衷,也是构建可移植嵌入式系统的基石。
当然,我们展示的还是最基础的使用方式。在实际工程中,你还会遇到中断、DMA、时钟、电源管理......这些都可以通过设备树描述,但万变不离其宗。在未来的学习中,你会接触到 Platform 设备驱动框架,届时你会发现今天折腾的 device_node 和 OF 函数,在 Platform 驱动里以更标准、更优雅的方式被封装起来。
现在,找一块开发板,把这一章的流程完整走一遍。只有在实践中,理论才能变成你自己的技能。
相关阅读
- 04. OF API 基础与验证------从 DTS 到代码的桥梁 - 相似度 100%
- 通用GUI编程技术------图形渲染实战(四十三)------D3D12设计哲学:显式控制与性能解锁 - 相似度 82%
