day64 设备树与GPIO子系统驱动开发实践
本日学习内容聚焦于Linux内核驱动开发的核心范式:设备树(Device Tree)与GPIO子系统。通过从硬编码资源到动态获取、从手动寄存器操作到标准API调用的演进,我们掌握了如何编写可移植、易维护的现代驱动程序。
一、设备树(Device Tree)基础概念
设备树是Linux内核为了实现"驱动方法统一,资源描述分离"而引入的关键机制。它将硬件平台相关的资源配置信息从内核代码中剥离,独立为外部文件,从而实现了"一次编译,多平台适配"。
1. 传统方式 vs 设备树方式
传统方式 | 设备树方式 |
---|---|
驱动与资源耦合 | 驱动与资源分离 |
内核臃肿,平台依赖强 | 内核精简,平台灵活 |
修改需重编译内核 | 仅需更新 .dtb |
维护成本高 | 可复用、易扩展 |
💡 核心思想:"让驱动专注逻辑,让设备树专注描述"。
2. 设备树文件类型
文件类型 | 后缀 | 说明 |
---|---|---|
源文件 | .dts |
设备树源码,人类可读,包含硬件描述。 |
头文件 | .dtsi |
可复用的公共部分(如处理器通用模块),类似C语言头文件。 |
二进制文件 | .dtb |
编译后的二进制文件,内核启动时加载。 |
📌 类比 :
内核 = 手机系统;
.dtb
= 说明书(只含当前设备信息);旧方式 = 系统内置所有品牌/型号说明书 → 膨胀且冗余。
3. DTS 与 DTSI 的分工
文件类型 | 功能定位 | 示例 |
---|---|---|
.dts |
描述具体开发板的硬件资源(如LED、按键、SPI设备)。 | imx6ul-14x14-evk.dts |
.dtsi |
描述处理器平台的通用硬件模块(如CPU、I²C、UART控制器)。 | imx6ull.dtsi |
✅ 设计原则:
- 开发板
.dts
包含平台.dtsi
→ 复用通用模块。- 不同开发板共用同一
.dtsi
→ 减少重复代码。
4. 设备树结构与语法
一个设备树节点的基本结构如下:
dts
节点名字 {
属性 = 值;
子节点名 {
属性 = 值;
};
};
关键特性
- 节点嵌套:支持层级结构,描述设备层次关系。
- 节点合并:同名节点属性自动合并。
- 属性覆盖:后定义属性覆盖先定义属性。
- 节点引用 :通过
&label
引用其他节点,避免重复描述。
属性值类型
- 字符串 :
"value"
(双引号包裹) - 数字 :
<0x123>
或<10>
- 数组 :
<0x1 0x2 0x3>
- 布尔值 :空属性(如
status = "okay";
)
二、设备树驱动开发流程
1. 设备树层:定义硬件资源
在你的开发板 .dts
文件中(如 arch/arm/boot/dts/pt.dts
),添加一个新节点来描述你的设备。
示例1:使用 reg
属性描述寄存器资源 (led_dts.c)
dts
myled {
#address-cells = <1>; // 定义地址字段长度
#size-cells = <1>; // 定义大小字段长度
compatible = "myled"; // 驱动匹配标识
reg = <0x20e0068 4 // GPIO1_SW_MUX_CTL 寄存器地址和大小
0x20e02f4 4 // GPIO1_SW_PAD_CTL 寄存器地址和大小
0x209c004 4 // GPIO1_DIR 寄存器地址和大小
0x209c000 4>; // GPIO1_DATA 寄存器地址和大小
status = "okay"; // 启用该设备节点
};
此节点描述了LED控制所需的四个寄存器的物理地址和映射大小。
示例2:使用 gpios
属性描述GPIO引脚 (led1_subgpio.c, key1_dts.c)
dts
// LED设备节点
myled_subgpio {
#address-cells = <1>;
#size-cells = <1>;
compatible = "myled_subgpio";
pinctrl-0 = <&pinctrl_myled>; // 引用引脚配置节点
gpio-led = <&gpio1 3 1>; // 指定GPIO控制器、引脚号、电平极性
status = "okay";
};
// 按键设备节点
mykey {
#address-cells = <1>;
#size-cells = <1>;
compatible = "pt,mykey";
pinctrl-0 = <&pinctrl_mykey>; // 引用引脚配置节点
gpio-key = <&gpio1 18 1>; // 指定GPIO控制器、引脚号、电平极性
status = "okay";
};
示例3:定义引脚配置 (pinctrl)
在 &iomuxc_snvs
或 &iomuxc
节点下,定义具体的引脚功能和电气属性。
dts
pinctrl_myled: myledgrp {
fsl,pins = <MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10B0>; // 将GPIO1_IO03配置为GPIO功能,电气属性为0x10B0
};
pinctrl_mykey: mykeygrp {
fsl,pins = <MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x10B0>; // 将GPIO1_IO18配置为GPIO功能,电气属性为0x10B0
};
2. 编译设备树生成 .dtb
在内核源码根目录下执行以下命令:
方法一:编译所有设备树
bash
make dtbs
方法二:只编译指定设备树
bash
make pt.dtb
🔍 注意:
- 编译前确保你的
.dts
已在arch/arm/boot/dts/Makefile
中被包含。- 在
Makefile
中找到对应平台的行(如dtb-$(CONFIG_SOC_IMX6ULL)
),添加你的设备树文件名。- 编译成功后,生成的
.dtb
文件位于arch/arm/boot/dts/
目录下。
bash
# 示例:编辑 Makefile
vim arch/arm/boot/dts/Makefile
# 在对应行添加
dtb-$(CONFIG_SOC_IMX6ULL) += \
... \
pt.dtb
3. 驱动层:从设备树读取资源并初始化硬件
步骤1:引入设备树相关头文件
c
#include <linux/of.h>
#include <linux/of_address.h>
#include <linux/of_device.h>
#include <linux/of_gpio.h> // 如果使用GPIO子系统
步骤2:在驱动probe函数中获取设备树资源
方式A:读取寄存器地址 (reg
属性) - led_dts.c
c
static int __init led_init(void)
{
struct device_node * node;
const char * str_compatible = NULL;
unsigned int reg_array[8] = {0}; // 用于存储reg属性的值
int ret;
// 1. 注册misc设备
ret = misc_register(&miscdev);
if(ret < 0)
goto err_misc_register;
// 2. 根据路径查找设备树节点
node = of_find_node_by_path("/myled");
if(NULL == node)
{
ret = PTR_ERR(node); // 获取错误码
goto err_dts;
}
printk("find node\n"); // 输出日志
// 3. 读取compatible属性
ret = of_property_read_string(node, "compatible", &str_compatible);
if(ret < 0)
goto err_dts;
printk("compatible = %s\n", str_compatible);
// 4. 读取reg属性,将其解析为u32数组
ret = of_property_read_u32_array(node, "reg", reg_array, 8); // 8个元素,4对地址+大小
if(ret < 0)
goto err_dts;
// 5. 打印读取到的寄存器地址和大小
printk("0x%x %x \n", reg_array[0], reg_array[1]);
printk("0x%x %x \n", reg_array[2], reg_array[3]);
printk("0x%x %x \n", reg_array[4], reg_array[5]);
printk("0x%x %x \n", reg_array[6], reg_array[7]);
// 6. 使用ioremap将物理地址映射到虚拟地址空间
led_iomuxc = ioremap(reg_array[0], reg_array[1]); // 映射第一个寄存器
led_pad_ctl = ioremap(reg_array[2], reg_array[3]); // 映射第二个寄存器
led_gdir = ioremap(reg_array[4], reg_array[5]); // 映射第三个寄存器
led_dr = ioremap(reg_array[6], reg_array[7]); // 映射第四个寄存器
printk("################## led_misc_init!\n");
return 0;
err_dts:
err_misc_register:
misc_deregister(&miscdev);
printk("############### led_misc_register failed\n");
return ret;
}
讲解:
- 此代码首先注册了一个misc设备,然后通过
of_find_node_by_path
查找/myled
节点。 - 接着读取
compatible
和reg
属性,并打印出来进行验证。 - 最后,使用
ioremap
函数将设备树中提供的物理寄存器地址映射到内核的虚拟地址空间,以便后续驱动可以通过指针直接访问这些寄存器。
方式B:读取GPIO引脚 (gpios
属性) - led1_subgpio.c, key1_dts.c
c
static int __init led_init(void)
{
struct device_node * node;
const char * str_compatible = NULL;
int ret;
// 1. 注册misc设备
ret = misc_register(&miscdev);
if(ret < 0)
goto err_misc_register;
// 2. 根据路径查找设备树节点
node = of_find_node_by_path("/myled_subgpio");
if(NULL == node)
{
ret = PTR_ERR(node);
goto err_dts;
}
printk("find node\n");
// 3. 读取compatible属性
ret = of_property_read_string(node, "compatible", &str_compatible);
if(ret < 0)
goto err_dts;
printk("compatible = %s\n", str_compatible);
// 4. 从设备树中获取GPIO编号
gpio_led = of_get_named_gpio(node, "gpio-led", 0); // 从"gpio-led"属性获取第一个GPIO
if(gpio_led < 0)
{
ret = gpio_led; // 返回错误码
goto err_dts;
}
// 5. 请求并配置GPIO为输出模式
gpio_request(gpio_led, "gpioled"); // 请求GPIO,提供标签
gpio_direction_output(gpio_led, 1); // 设置为输出,并初始电平为高
printk("################## led_misc_init!\n");
return 0;
err_dts:
err_misc_register:
misc_deregister(&miscdev);
return ret;
}
讲解:
- 此代码同样先注册misc设备并查找节点。
- 核心在于
of_get_named_gpio
函数,它根据节点和属性名(如"gpio-led"
)以及索引(0)来获取对应的GPIO编号。 - 之后调用
gpio_request
和gpio_direction_output
来申请并初始化这个GPIO,使其可以作为输出引脚使用。
方式C:结合Platform总线模型 - led1_dts_platfrom.c
c
static int probe(struct platform_device * pdev)
{
struct device_node * node;
int ret;
// 1. 注册misc设备
ret = misc_register(&misc);
if(ret < 0)
goto err_misc_register;
// 2. 查找设备树节点
node = of_find_node_by_path("/myled_subgpio");
if(NULL == node)
{
ret = PTR_ERR(node);
goto err_dts;
}
// 3. 获取GPIO编号
gpio_led = of_get_named_gpio(node, "gpio-led", 0);
if(gpio_led < 0)
{
ret = gpio_led;
goto err_dts;
}
// 4. 请求并配置GPIO
gpio_request(gpio_led, "gpioled");
gpio_direction_output(gpio_led, 1);
printk("led1 probe ############################ ***\n");
return 0;
err_dts:
err_misc_register:
misc_deregister(&misc);
return ret;
}
// 定义platform驱动结构体
static const struct of_device_id led_table[] = {
{.compatible = "myled_subgpio"}, // 必须与设备树中的compatible一致
{}
};
static struct platform_driver drv = {
.probe = probe,
.remove = remove,
.driver = {
.name = DEV_NAME,
.of_match_table = led_table // 指定匹配表
}
};
// 驱动初始化函数
static int __init led1_driver_init(void)
{
int ret = platform_driver_register(&drv); // 注册platform驱动
if(ret < 0)
goto err_driver_register;
printk("led1 platform_driver_register ...\n");
return 0;
err_driver_register:
platform_driver_unregister(&drv);
return ret;
}
讲解:
- 这种方式更符合现代Linux驱动的规范。驱动不再直接初始化,而是由platform总线框架在设备匹配成功后调用
probe
函数。 of_match_table
是关键,它定义了驱动能匹配哪些设备树节点。内核会自动将设备树节点与驱动的匹配表进行比较,如果compatible
字段匹配,则调用probe
函数。- 这样做的好处是解耦了设备发现和驱动初始化的过程,使得驱动代码更加模块化和标准化。
4. 应用层:控制硬件
示例:LED应用 (led1_app.c)
c
int main(int argc, const char *argv[])
{
int fd = open("/dev/led", O_RDWR); // 打开LED设备文件
if(fd < 0)
{
perror("open led ");
return -1;
}
// 控制LED亮灭
write(fd, "ledon", 5); // 发送"ledon"命令
sleep(1); // 等待1秒
write(fd, "ledoff", 6); // 发送"ledoff"命令
close(fd);
return 0;
}
示例:按键应用 (key1_app.c)
c
int main(int argc, const char *argv[])
{
int fd_key = open("/dev/key", O_RDWR); // 打开按键设备文件
if(fd_key < 0)
{
perror("open key1 ");
return -1;
}
int fd_led = open("/dev/led", O_RDWR); // 打开LED设备文件
if(fd_led < 0)
{
perror("open led1 ");
return -1;
}
unsigned char status = 0;
while(1)
{
read(fd_key, &status, 1); // 读取按键状态
printf("status = %d\n", status);
if(status == 0) // 按键按下
write(fd_led, "ledon", 5); // 点亮LED
else if(status == 1) // 按键松开
write(fd_led, "ledoff", 6); // 熄灭LED
}
close(fd_key);
close(fd_led);
return 0;
}
三、常见问题与调试技巧
Q1:of_find_node_by_path
返回 NULL?
- 原因 :设备树节点路径错误或未编译进
.dtb
。 - 解决 :
- 检查
.dts
中节点名是否拼写正确。 - 确保
.dts
已加入Makefile
并重新编译dtbs
。 - 在内核启动日志中搜索
myled
,确认节点是否存在。
- 检查
Q2:of_property_read_u32_array
返回错误?
- 原因:属性名错误、数据类型不匹配、数组长度不足。
- 解决 :
- 检查
.dts
中reg
属性是否为<addr size>
对形式。 - 确保
count
参数与实际数据量匹配(如8个u32对应4对地址+大小)。 - 使用
of_property_count_u32_elems(np, "reg")
先获取元素数量。
- 检查
Q3:of_get_named_gpio
返回无效值?
- 原因 :设备树中
gpios
属性未正确定义或拼写错误。 - 解决 :
- 检查
gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>;
格式是否正确。 - 确认
&gpio1
是否在设备树中存在(通常在.dtsi
中定义)。 - 使用
dmesg
查看内核启动日志,确认设备树节点是否被解析。
- 检查
Q4:驱动加载失败?
- 调试建议 :
- 在驱动中增加
pr_info
输出关键步骤。 - 查看
dmesg
或串口输出,定位错误位置。 - 确保
.dtb
已正确烧录到开发板并被内核加载。
- 在驱动中增加
四、总结与最佳实践
1. 主流驱动开发模式对比
开发模式 | 特点 | 适用场景 |
---|---|---|
传统platform驱动 | 手动匹配设备名,硬编码资源 | 老版本内核,无设备树 |
设备树 + platform驱动 | 通过 compatible 匹配,资源动态获取 |
主流开发模式,推荐使用 |
GPIO子系统 | 标准API操作引脚,无需关心寄存器 | 所有GPIO设备,简化开发 |
✅ 最佳实践:
- 设备树层 :描述硬件资源,定义
compatible
和gpios
。- 驱动层 :使用
platform_driver
+of_match_table
匹配设备,调用gpio_*
或gpiod_*
API 操作引脚。- 调试 :通过
dmesg
和gpioinfo
验证驱动加载和引脚状态。
2. 学习路径
"先模仿 → 再理解 → 后创新"
- 第一遍:复制现有代码,确保能运行。
- 第二遍:删除注释,尝试自己写出关键部分。
- 第三遍:修改功能(如增加多个按键、支持中断)。
- 第四遍:独立完成一个完整项目(如"按键控制LED+蜂鸣器")。
3. 分层设计思想
层级 | 职责 |
---|---|
硬件层 | 提供物理按键和LED |
驱动层 | 提供标准接口(如 read , write ),屏蔽硬件细节 |
应用层 | 实现业务逻辑(如按键控制灯) |
✅ 优势:
- 驱动可复用:同一驱动可用于不同应用。
- 应用灵活:可根据需求修改逻辑,无需改动驱动。
掌握设备树与GPIO子系统,是Linux驱动开发的核心技能,标志着你已迈入"标准驱动开发"的大门。