我的《Linux驱动开发》专栏基本已经把字符设备相关的内容讲的差不多了,下面是时候上点硬件,来点小小的视觉冲击了。本文虽然只是控制一个小小的 LED,但是代码是完整的,包含了字符设备,设备树节点添加,平台总线,Linux 的总线-设备-驱动模型,以及现代 gpio 子系统,这是驱动开发的一整个框架,今天的 LED 你学会了,明天随便拿个传感器来代码框架不变,实际要改的只是驱动程序的具体逻辑,今天的平台总线学会了,再换个 I2C 总线,SPI 总线,来来回回还是那些个操作,只是 API 换了而已,进行过实际操作的读者应该是深有体会的。
0. 前言
如果你和我一样,学习过单片机,然后是从学习 open,read,write 的字符设备驱动开始接触到 Linux 内核,那么你对下面的场景应该不陌生:
- 为了点亮一个 LED,我们需要翻阅芯片手册,找到控制 GPIO 的寄存器物理地址,然后把它 硬编码 到驱动程序中,最后使用
ioremap重映射之后通过操作寄存器点灯。
这种开发方式简单直接,对于入门学习来说非常有效,代码也很简便,就是查手册有点烦。但是对于我手中的 鲁班猫 2 开发板,其搭载着 RK3568 这颗复杂的 SoC ,如果还采用这种方式就会有一个巨大的问题:
这颗芯片有数百个引脚,功能复用极其复杂。如果每个驱动都像上面那样 硬编码物理地址,那么 Linux 内核如何维护这样一个混乱的代码库?如果我的 LED 换了一个引脚,难道唯一的办法就是修改驱动源码中的物理地址,然后重新编译内核模块吗?
显然这是不可行的。Linux 内核为我们提供了一套软硬件解耦,可移植的解决方案。
本文将完整记录我如何在鲁班猫 2 上,从零开始,利用设备树 、平台总线 以及现代化的 GPIO子系统 ,实现一个功能完整的 LED 驱动。我们不会再触碰任何一个物理地址,而是学习如何 用设备树来描述硬件 ,让 平台总线自动为我们的硬件匹配驱动 ,通过 gpiod接口 操作硬件引脚。
本文最终达成的效果:
- 编写出完成的驱动程序,实现 LED 亮,灭,状态转换的逻辑。
- 在用户层编写测试程序,通过系统调用打开并操作字符设备节点文件,从而控制 LED 的状态。
1. 核心概念解析
1.1 设备树
第一次接触到 .dts 文件时,我被它那既像 C 语言又像 JSON 的语法搞得有些迷茫,但是后来理解了它的核心目的之后,一切都变得豁然开朗起来:设备树就是一份用 文本描述的硬件信息档案,它可以告诉内核硬件的信息。
设备树将硬件描述从 C 代码中抽取出来,形成独立的 .dts 文件,这样驱动代码本身就变得非常纯粹,只关心逻辑相关的内容,而不需要关心除此之外的部分。
在编写设备树节点之前,我们还有两件事情需要做,第一是在你使用的板子上找一个你喜欢的引脚,把 LED 接上去,如下是我使用的鲁班猫 2 的引脚分布图:

图中我用蓝色的笔圈出来两个引脚,我把 LED 正极接到 GPIO0_B0 上,把负极接到 GND 上。
接好之后的情况如下:

第二件事是看一下你的板子在启动阶段加载的是哪个设备树文件,进入到板子的 /boot 目录下,如下图:

在 /boot 目录下,你可以找到一个以 .dtb 为后缀的软链接,它指向的就是板子启动时会加载的设备树文件,我们要做的是:等会添加好设备树节点,把它编译好生成 .dtb 文件,然后用这个新的文件覆盖掉板子上旧的文件,再重启板子,到时候我们可以验证一下成功了没。
现在我们已经知道了要修改的设备树文件是 rk3568-lubancat-2-v3.dts,可以在下载并编译好的的 SDK 中找到,位置为:
bash
/kernel/arch/arm64/boot/dts/rockchip/rk3568-lubancat-2-v3.dts
然后在该文件的根节点 / 下添加下面节点:
ini
myled_device{
compatible = "lubancat,myled";
status = "okay";
led-gpios = <&gpio0 RK_PB0 GPIO_ACTIVE_HIGH>;
};
myled_device是节点名称,他声明了一个名为myled_device的设备。compatible是用来与驱动程序匹配的,后面我们编写的驱动程序也会有一个这个字段,并且内容完全相同,保证设备和驱动能够匹配上。led-gpios描述了设备占用的gpio资源,可以与上面的引脚图对比一下,这正是描述了我们接了LED的那个引脚,RK_PB0是头文件中定义好的宏,直接用就可以。GPIO_ACTIVE_HIGH是引脚的电气特性,表示高电平有效,如果我的LED是低电平点亮的,我只需要把这里改成GPIO_ACTIVE_LOW,驱动代码完全不需要改动,这就是设备树的强大之处。
添加好节点之后,我们在 SDK 的 kernel 目录下进行编译,相关命令和编译结果如下:

到这儿,设备树就编译成功了。
下面,我们把它拷贝到板子上,在覆盖掉原先的 dtb 文件:

这样,他就会覆盖掉原文件了。然后我们重启板子,查看我们的设备树是否加载成功了,使用下面命令:
bash
ls -lh /proc/device-tree/myled_device

myled_device 是我们在设备树文件中添加的节点名称,可以看到,已经被成功加载了。
1.2 平台总线
平台总线是 Linux 内核中一种虚拟的、不存在的总线。它的唯一使命,就是 匹配设备和驱动。
1.2.1 核心角色
在平台总线中,有两个核心的角色:
platform_device: 当内核启动并解析设备树时,它一旦发现我们写的myled_device节点,就会在内存中创建一个struct platform_device结构体来代表它。这个结构体里打包了 从设备树节点中读取到的所有信息 ,其中最重要的就是那个compatible = "lubancat,myled"属性。然后,内核将这个platform_device注册到平台总线上,静待匹配。platform_driver: 这就是我们用 C 语言编写的驱动程序。我们需要定义一个struct platform_driver结构体,并填充其中的关键信息,然后通过module_platform_driver宏将它注册到内核。
1.2.2 compatible介绍
平台总线是如何判断两者是否匹配呢?答案就在我们驱动代码里的一个关键数据结构:of_device_id。
下面是我们驱动程序的一部分:
c
static const struct of_device_id myled_of_match[] = {
{ .compatible = "lubancat,myled", }, //看这里
{}
};
MODULE_DEVICE_TABLE(of, myled_of_match); //告知内核
//定义平台驱动结构体
static struct platform_driver myled_driver = {
.probe = myled_probe,
.remove = myled_remove,
.driver = {
.name = "myled_driver",
.of_match_table = myled_of_match, //看这里
},
};
整个流程如下:
- 当我
insmod myled_drv.ko时,myled_driver就被注册到了平台总线上。 - 总线发现一个新的
driver来了,就查看它的.of_match_table,知道了它想找compatible为"lubancat,myled"的设备。 - 总线随即在已经注册的
device列表中进行查找,发现之前由设备树生成的myled_device的compatible属性正好也是"lubancat,myled"。 - 匹配成功。
1.2.3 probe函数
一旦匹配成功,平台总线会立刻调用我们驱动程序里指定的 .probe 函数,也就是 myled_probe。
probe 函数是整个驱动逻辑的真正入口。,内核会把匹配到的 platform_device 作为参数传递给它。这意味着,在 probe 函数里,我们终于可以将硬件描述和软件逻辑结合起来了。所有初始化的工作,比如申请 GPIO 资源、创建 /dev/myled 设备节点等等,都在这里完成。
与之对应,当我们 rmmod 卸载驱动时,.remove 函数会被调用,用于释放在 probe 中申请的资源资源。
1.3 gpio子系统
我们的驱动程序拿到了代表硬件的 platform_device 结构体,下一步就是去真正地获取并操作硬件资源------那个在设备树里定义的 GPIO0_B0 引脚。
在过去,我们可能会使用一套叫做 gpio_request 和 gpio_set_value 的老旧接口。但现在,内核开发者强烈推荐使用一套更现代化、更安全、更抽象的接口,这就是 GPIO Descriptor Subsystem ,简称为 gpiod。
1.3.1 为什么不用老的接口
老接口通过一个 全局 的 GPIO 编号来操作引脚。这样,任何驱动都可以尝试申请任意一个编号的 GPIO,容易产生冲突。此外,看到代码里的 gpio_set_value(122, 1),你完全不知道这个 122 号引脚是干嘛的,高电平是亮还是灭,这样对于代码的维护也不利。
而新的接口不使用全局编号,而是通过 描述符 来操作引脚,获取 GPIO 的过程,就像通过 open 获取文件描述符的过程一样。
驱动只能向内核申请属于自己的 GPIO,而这些信息在设备树里已经 绑定 好了,无法越权访问。
并且它能自动处理高低电平有效的问题。在设备树里定义了 GPIO_ACTIVE_HIGH,那么调用 gpiod_set_value(desc, 1) 就代表点亮。如果设备树改成 GPIO_ACTIVE_LOW,同样的代码 gpiod_set_value(desc, 1) 就会自动输出低电平。实现代码逻辑与物理电平彻底解耦。
1.3.2 驱动程序中的实现
下面看看我的 myled_probe 函数中是如何获取 GPIO 资源的:
c
static int myled_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
//其他代码
//从设备树获取 GPIO,GPIOD_OUT_LOW 表示获取一个输出引脚,并设置初始值为低电平
my_led.led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW);
if (IS_ERR(my_led.led_gpio))
{
printk(KERN_INFO "myled: Get gpio failed!\n");
return PTR_ERR(my_led.led_gpio);
}
//申请字符设备号、创建设备节点等
return 0;
}
核心代码就是 devm_gpiod_get(dev, "led", GPIOD_OUT_LOW) 。
dev是指向当前设备的指针,明确了谁在申请资源。"led"是暗号,内核会自动去我们这个设备对应的设备树节点里,查找名为"led-gpios"的属性。如果设备树中属性名是"enable-gpios",那这里的字符串就得是"enable"。GPIOD_OUT_LOW: 这是申请的标志,OUT表示我需要一个输出引脚,LOW表示我希望它的 初始状态 是低电平。
1.3.3 操作led的逻辑
一旦 devm_gpiod_get 成功返回,my_led.led_gpio 里就保存了这个描述符。之后,在 myled_write 函数里,操作LED就变得极其简单和直观,部分代码如下:
c
static ssize_t myled_write(struct file *file, const char __user *buf, ...)
{
/* ... */
if (kbuf[0] == '1')
{
//亮
gpiod_set_value(my_led.led_gpio, 1);
}
else if (kbuf[0] == '0')
{
//灭
gpiod_set_value(my_led.led_gpio, 0);
}
/* ... */
}
代码里完全没有高低电平的概念,语义非常清晰。
2. 字符设备的基础支撑
虽然我们花了大量篇幅来学习设备树、平台总线和 gpiod,但驱动的最终目的是为用户空间程序服务的。而这个服务的窗口,依然是我们所熟悉的 字符设备。
但是不同的是,字符设备的注册和初始化不在以前的 module_init 中了,而是在设备与驱动匹配成功后立即执行的 probe 函数里面。
同样,在 remove 函数中,我们需要按照相反的顺序,依次执行 device_destroy、class_destroy、cdev_del和unregister_chrdev_region,完成资源的彻底清理。
这样看起来,平台驱动模型并没有颠覆字符设备框架 ,而是将它作为自己的一部分包容了进来。probe负责初始化,remove负责收拾烂摊子,分工明确,结构清晰。
3. 完整代码
3.1 驱动程序代码
c
#include <linux/module.h>
#include <linux/gpio/consumer.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#define DEVICE_NAME "myled"
#define CLASS_NAME "myled_class"
//自定义结构体
struct myled_dev{
dev_t dev_num;
struct cdev cdev;
struct class* class;
struct device* device;
struct gpio_desc* led_gpio;
};
static struct myled_dev my_led;
static int myled_open(struct inode* inode, struct file* file)
{
printk(KERN_INFO "myled: Open success!\n");
return 0;
}
static int myled_release(struct inode* inode, struct file* file)
{
printk(KERN_INFO "myled: Release success!\n");
return 0;
}
//读取LED当前状态
static ssize_t myled_read(struct file* file, char __user* buf, size_t count, loff_t* offset)
{
char kbuf[2];
int state;
if(*offset > 0) return 0;//也就是说偏移量只能是0
state = gpiod_get_value(my_led.led_gpio);
kbuf[0] = state ? '1' : '0';
kbuf[1] = '\0';
if(copy_to_user(buf, kbuf, 1))
{
return -EFAULT;
}
*offset += 1;
return 1;
}
//控制LED状态
static ssize_t myled_write(struct file* file, const char __user* buf, size_t count, loff_t* offset)
{
char kbuf[2];
if(count > sizeof(kbuf)-1)
{
count = sizeof(kbuf)-1;
}
if(copy_from_user(kbuf, buf, count))
{
return -EFAULT;
}
if(kbuf[0] == '1')
{
gpiod_set_value(my_led.led_gpio, 1);
}
else if(kbuf[0] == '0')
{
gpiod_set_value(my_led.led_gpio, 0);
}
else if(kbuf[0] == 'x')
{
int state = gpiod_get_value(my_led.led_gpio);
gpiod_set_value(my_led.led_gpio, !state);
}
return count;
}
static struct file_operations myled_fops = {
.owner = THIS_MODULE,
.open = myled_open,
.release = myled_release,
.read = myled_read,
.write = myled_write,
.llseek = default_llseek,
};
//设备树匹配成功后执行probe函数
static int myled_probe(struct platform_device* pdev)
{
int ret;
struct device* dev = &pdev->dev;
printk(KERN_INFO "myled: device tree matched!\n");
//这里第二个参数传"led",内核会自动去设备树找"led-gpios"属性
my_led.led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW);
if(IS_ERR(my_led.led_gpio))
{
ret = PTR_ERR(my_led.led_gpio);
printk(KERN_INFO "myled: Get gpio failed!\n");
return ret;
}
ret = alloc_chrdev_region(&my_led.dev_num, 0, 1, DEVICE_NAME);
if(ret < 0)
{
printk(KERN_INFO "myled: Failed to alloc!\n");
return ret;
}
cdev_init(&my_led.cdev, &myled_fops);
my_led.cdev.owner = THIS_MODULE;
ret = cdev_add(&my_led.cdev, my_led.dev_num, 1);
if(ret < 0)
{
printk(KERN_INFO "myled: cdev add failed!\n");
goto err_add;
}
my_led.class = class_create(THIS_MODULE, CLASS_NAME);
if(IS_ERR(my_led.class))
{
ret = PTR_ERR(my_led.class);
printk(KERN_INFO "myled: Class create failed!\n");
goto err_class;
}
my_led.device = device_create(my_led.class, NULL, my_led.dev_num, NULL, DEVICE_NAME);
if(IS_ERR(my_led.device))
{
ret = PTR_ERR(my_led.device);
printk(KERN_INFO "myled: Device create failed!\n");
goto err_device;
}
printk(KERN_INFO "myled: Init success!\n");
return 0;
err_device:
class_destroy(my_led.class);
err_class:
cdev_del(&my_led.cdev);
err_add:
unregister_chrdev_region(my_led.dev_num, 1);
return ret;
}
//设备卸载时执行
static int myled_remove(struct platform_device *pdev)
{
gpiod_set_value(my_led.led_gpio, 0);//退出之前先关灯
device_destroy(my_led.class, my_led.dev_num);
class_destroy(my_led.class);
cdev_del(&my_led.cdev);
unregister_chrdev_region(my_led.dev_num, 1);
printk(KERN_INFO "myled: Resource freed!\n");
return 0;
}
static const struct of_device_id myled_of_match[] = {
{.compatible = "lubancat,myled",},
{}
};
MODULE_DEVICE_TABLE(of,myled_of_match);
static struct platform_driver myled_driver = {
.probe = myled_probe,
.remove = myled_remove,
.driver = {
.name = "myled_driver",//驱动名称,在/sys/bus/platform/drivers下显示
.of_match_table = myled_of_match,
},
};
module_platform_driver(myled_driver);
MODULE_LICENSE("GPL");
3.2 测试程序代码
c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define DEVICE "/dev/myled"
int main()
{
int fd;
char buf[10];
int choice;
fd = open(DEVICE, O_RDWR);
if(fd < 0)
{
perror("open");
return -1;
}
while(1)
{
printf("\nSelect operation:\n");
printf(" 1 - Turn LED ON\n");
printf(" 2 - Turn LED OFF\n");
printf(" 3 - Inverse LED\n");
printf(" 4 - Read LED status\n");
printf(" 0 - Exit\n");
printf("Choice: ");
scanf("%d", &choice);
switch (choice)
{
case 1:
write(fd, "1", 1);
printf("LED ON\n");
break;
case 2:
write(fd, "0", 1);
printf("LED OFF\n");
break;
case 3:
write(fd, "x", 1);
printf("LED inverse\n");
break;
case 4:
lseek(fd, 0, SEEK_SET);
read(fd, buf, 1);
printf("LED status: %s\n", buf[0] == '1' ? "ON" : "OFF");
break;
case 0:
write(fd, "0", 1);//关闭程序之前先关灯
close(fd);
printf("END!\n");
return 0;
default:
printf("Invalid choice\n");
}
}
return 0;
}
4. 实战演示
加载模块之后,内核日志如下,表面已经完成了字符设备注册,获取 gpio 等操作:

这时,我们可以运行应用程序了:

然后,我们可以输入对应的数字,LED 就会变成相应的状态,我们一一测试:

经过测试,LED 状态和预期完全一样。

最后输入 0 时程序会直接退出,LED也会熄灭:

最后卸载模块,整个流程的内核日志如下:

完成资源释放。
现在你已经成功通关了现代LED驱动这个副本!你完全有能力为一些简单的传感器编写驱动程序。
是不是感觉收获满满,但又有点意犹未尽?如果可以的话:
- 随手点个 【收藏】 ,下次忘记代码框架的时候,还能瞬间找到。
- 再随手点个 【关注】 , 后续还有中断处理、I2C 通信等内容。
来吧,我们一起加油,向内核深处进发。