鲁班猫RK3568芯片 LED 驱动的全流程开发与深度解析

64 阅读33分钟

引言

大家好,最近搞了一块 lubancat 2N的开发板,搭载 RK3568 芯片,想从经典的点亮 LED 入门 Linux 驱动,但是没想到在最开始的修改设备树时就一头撞到了南墙上,编译了不知道多少次,板子就是不认,一遍一遍的执行着ls /proc/device-tree,已经对它的输出产生绝望了,但好在最终是发现了问题所在。为了彻底弄懂这些底层原理,我把整个过程完整地走了一遍,并把我的理解和思考整理成了这篇笔记。

在这篇文章中,我将会从设备树入手,深度解释我对这些代码的理解,再到最后的编译部署以及运行,点亮那个 LED 灯。

但是实际上,点亮那个 LED 灯只是学习的附加产物,更重要的是能建立起驱动开发的整个框架,这对以后的学习是起到极大的作用的。

1. 硬件与开发环境

首先介绍一下我使用的板子和虚拟机的环境。

我使用的板子是 lubancat 2N ,芯片是 RK3568 ,这是我板子的图片:

1. 板子图片.jpg

我用的虚拟机是 ubuntu 24.04,但是由于野火官方的版本适配原因,这里需要使用 ubuntu 20.04 进行开发。因此,我使用 docker 容器创建了一个 20.04 的环境。

此外,我在 ubuntu 24.04 和 docker 的 ubuntu 20.04 已经板子上通过 NFS 创建了一个共享文件夹。使用 NFS 共享文件夹,可以在 PC (Docker) 中编译完成后,无需通过 scp 拷贝,板子就能立刻访问到最新的 .ko 和 .dtb 文件,这样能够提高效率。

图中的 nfs_share 文件夹就是我 ubuntu 24.04 中创建的共享文件夹:

2. NFS图片(1).png

这个文件夹在 docker 的 ubuntu20.04 环境和板子中也可以访问,如下:

3. NFS图片(2).png

4. NFS图片(3).png

板子中的 nfs 文件夹与其他两个环境中的 nfs_share 文件夹中的内容是共享的。

下面介绍一下 LED 的配置,这里我们通过板子上引出的 GPIO 引脚来外接一个 LED ,LED 的正极接在 GPIO0_B0 上,负极接在它下面的引脚也就是 GND 上,引脚图中的引脚排列顺序与板子上的引脚排列顺序是相同的,并且板子上对个别引脚标注了序号以便定位。

5. 引脚图.png

6. 接线图.jpg

到这里,硬件与开发环境就介绍完了,下一章将介绍设备树,以及如何在里面添加一个节点。

2. 在设备树中添加 LED 节点

2.1 寻找板子使用的设备树文件

在修改之前,我们首先要确定一件事,就是板子到底在用哪个设备树文件,如果改错了文件,后面的一切都是在做无用功。很多嵌入式系统,包括 lubancat,都使用软链接来统一引导入口,我们可以通过追踪这个软链接找到真实文件。

我们首先要进入到板子的 /boot 目录下,然后通过 ls 命令列出该目录下的所有文件,如下图:

7. 软连接.png

我最初的尝试中,就是因为修改错了设备树文件,导致后面的验证一直不成功。

这里涉及到一个经常容易被忽略的概念——软链接,可以把它想象成 windows 上的快捷方式,它本身不是一个真正的文件,而是指向另一个真实文件的路牌,这个路牌文件里,只记录了真实文件的路径地址,当尝试读取这个软链接时,系统会自动找到它指向的真实文件,并读取真实文件的内容。

上面图中列出的文件列表的倒数第三行,它的属性,也就是最前面的一列是 l ,可以理解为 link 的意思,这说明 rk-kernel.dtb 不是一个普通文件,而是一个链接,它的后面是一个箭头,用我们上面的比喻来说,它正是指向真实文件的路牌。而后面的dtb/rk3568-lubancat-2-v3.dtb这就是真实文件,但是这是一个相对路径,是相对于 /boot 目录来说的,所以它的完整路径是 /boot/dtb/rk3568-lubancat-2-v3.dtb

那么为什么要搞的这么麻烦呢?可能也有不少人和我一样最初有这个疑问。实际上,系统的引导程序(U-Boot)的配置通常是写死的,它可能被设定为刚开机就去加载/boot/rk-kernel.dtb,这里如果使用软链接,可以带来很大的灵活性。假设官方发布了一个新的内核版本,假设是 xxxx.v4.dtb 文件,升级时我们只需要把新文件放进 /boot/dtb/ 目录,然后重新创建一个软链接,让 /boot/rk-kernel.dtb 指向新的 .v4.dtb 文件就行了,整个过程完全不需要修改 U-Boot 的配置,如果新版本有问题,再把软链接指回 v3,就能轻松回退。

通过这个软链接陷阱我们可以知道:我们修改并编译生成的 .dtb 文件,最终必须覆盖掉软链接指向的那个真实文件,而不是软链接本身,也不是其他同名的文件

因此,正确的做法应该是:

第一步,修改并在 docker 上编译 rk3568-lubancat-2-v3.dts,生成 rk3568-lubancat-2-v3.dtb

第二步,将这个新的 rk3568-lubancat-2-v3.dtb 文件,拷贝并覆盖到板子上的 /boot/dtb/rk3568-lubancat-2-v3.dtb

第三步,重启板子,并验证这个设备是否已经存在。

在知道了我们需要修改的设备树文件是那个之后,我们就可以对这个文件进行修改了。

2.2 修改设备树文件

我们需要在 rk3568-lubancat-2-v3.dts 中添加 LED 节点,这是整个驱动开发的根,所有软件操作都源于此。在从野火官方那里下载的SDK源码中,我们将它编译好就会出现/kernel这个目录,rk3568-lubancat-2-v3.dts 这个文件的路径如下:

kernel/arch/arm64/boot/dts/rockchip/rk3568-lubancat-2-v3.dts

要添加的代码如下:

my_led_test {
    compatible = "lubancat,led-demo";
    status = "okay";
    led-gpios = <&gpio0 RK_PB0 GPIO_ACTIVE_HIGH>;
};

添加后的界面如下所示:

8. 设备树节点添加.png

最上面两行代码(modelcompatible)是文件中本来就有的,我们把要添加的节点写在它下面就行。

my_led_test { ... }是一个 节点设备树就是由无数个这样的节点组成的树状结构。它在设备树这张硬件地图上创建了一个新的区域,专门用来描述我们外接的这个 LED。my_led_test是节点名,主要给人看的,让我们在阅读设备树或者在系统的 /proc/device-tree/ 目录下查找时,能一眼认出它。这个名字本身对驱动匹配没有直接作用,名字可以自定义,但通常要有意义

compatible = "lubancat,led-demo"是一个属性 ,它的值是一个字符串,这是整个节点里最重要的属性。它是内核用来匹配驱动程序的唯一标识,可以理解为这个硬件的身份证号。内核启动时,会扫描设备树,发现有一个设备的型号是 "lubancat,led-demo",与此同时,当驱动模块被加载 (insmod) 时,它会告诉内核:“我是一个驱动,能处理型号为 "lubancat,led-demo" 的设备”。内核一听,这两个信息对上了,就会把这个设备节点的信息打包,然后去调用驱动里的 probe 函数,完成初始化。命名一般采用这种格式,"<vendor>,<model>",即 <厂商名>,<模块名>lubancat代表这是为鲁班猫平台定义的,led-demo代表这是一个 LED 驱动模块,这种格式是为了保证全局唯一性,避免这里定义的设备和其他成千上万的设备重名。

status = "okay"是一个标准的属性,用来表示该设备的状态。它决定内核是否理会这个节点"okay" 或 "ok" 表示这个设备是启用的,内核会为它寻找并加载驱动。 "disabled" 表示这个设备禁用了,内核就不会管他。比如一块板子可以选配 A 模块或 B 模块,就可以在设备树里同时写上 A 和 B 的节点,然后根据实际情况,把要用的那个设为 "okay",不用的设为 "disabled"。如果怀疑某个硬件驱动导致系统出问题,可以直接在设备树里把它 disabled 掉来快速定位问题,而不用去修改驱动代码

led-gpios = <&gpio0 RK_PB0 GPIO_ACTIVE_HIGH>是一个自定义的属性,专门用来描述 LED 所连接的 GPIO 引脚信息。led-gpios 这个名字是我们自己定的,但 Linux 内核的 GPIO 子系统驱动有个约定俗成的习惯,会自动识别以 -gpios 结尾的属性<> 括号里的内容是一组用空格隔开的数据,称为单元,每个单元代表一个信息片段。这里有三个单元:

第一个单元: &gpio0这是一个 phandle(pointer handle,指针句柄),可以理解为对另一个节点的引用快捷方式。它指向了设备树中另一个描述 GPIO 控制器组 0 的节点(这个节点通常在芯片原厂提供的 .dtsi 文件里已经定义好了),它告诉内核,我们接下来要描述的引脚,是属于 gpio0 这个控制器管理的。

第二个单元: RK_PB0是一个宏定义,代表引脚在 gpio0 控制器内的偏移量编号RK_PB0 就是 Rockchip 平台的 Port B 的第 0 个引脚,它最终会被编译器替换成一个具体的数字。

第三个单元: GPIO_ACTIVE_HIGH是一个标志位,用来描述引脚的电气特性HIGH 表示这个 LED 是高电平有效的,也就是给它高电平它就亮,如果电路改成了低电平点亮,只需要把这里改成 GPIO_ACTIVE_LOW,而驱动代码一行都不用改就能完美适配。这正是因为我们在编写驱动时,使用的是 Linux GPIO 子系统提供的统一接口( gpiod_set_value),它会自动读取设备树里的这个标志位来处理电平极性,实现真正的软硬件解耦。

2.3 编译并部署设备树

设备树修改完成后,我们还需要通过编译器把它变成内核能读懂的二进制文件(后缀为.dtb),并将其部署到开发板上。编译这个过程需要在 docker 中,也就是 ubuntu 20.04环境下进行。

我们首先进入 docker 中的内核源码目录下,这里,由于我们只修改了设备树,所以不需要编译整个内核,而只需要编译设备树就行。可以使用下面命令进行编译,其中 dtbs 告诉 make 只需要编译设备树文件:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs -j8

编译后结果如下:

9. 设备树编译.png

可以看到系统已经提示我们成功生成了rk3568-lubancat-2-v3.dtb文件,并且告诉了我们这个文件的路径。

得益于我们之前搭建的 NFS 共享环境 ,文件传输变的非常简单,我们不需要插拔 SD 卡,也不需要麻烦的 scp 命令。我们直接在docker中执行下面命令,将编译好的.dtb复制到共享文件夹中我们新创建的my_led文件:

cp arch/arm64/boot/dts/rockchip/rk3568-lubancat-2-v3.dtb /home/xlp/nfs_share/my_led

然后我们进入开发板上面的 NFS 挂载目录,把新文件覆盖到系统的启动目录中。这里有一个关键点,我们在前面提到过,系统启动是通过 /boot/rk-kernel.dtb 这个软链接来寻找真实文件的,通过 ls 命令 我们已经确认它指向的是 dtb/rk3568-lubancat-2-v3.dtb。所以,我们要覆盖的就是dtb/rk3568-lubancat-2-v3.dtb这个文件。

这里我们先备份原来的文件,这是一个好的习惯:

cp /boot/dtb/rk3568-lubancat-2-v3.dtb /boot/dtb/rk3568-lubancat-2-v3.dtb.bak

然后用新编译的文件覆盖原来的旧文件:

cp /mnt/nfs/my_led/rk3568-lubancat-2-v3.dtb /boot/dtb/rk3568-lubancat-2-v3.dtb

最后重启板子:

reboot

10. 覆盖旧的设备树文件.png

2.4 验证结果

重启完成后,我们需要确认内核是否真的加载了我们修改过的设备树。

Linux 内核启动时,会将解析好的设备树以文件系统的形式展示在 /proc/device-tree/ 目录下,如果我们的修改生效了,那里应该会有我们添加的新节点。

我们使用下面的命令来查看这个节点是否存在,my_led_test正是我们前面在设备树文件中添加的节点名称,方便我们识别用的:

ls /proc/device-tree/my_led_test

结果如下:

11. 验证编译结果.png

看到这个结果,说明内核已经成功识别到了我们的 LED 硬件信息。

接下来我们要做的就是编写驱动程序来控制它。

3. 底层原理与代码详解

3.1 注册与匹配

static const struct of_device_id led_of_match[] = {
        { .compatible = "lubancat,led-demo" },
        { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, led_of_match);
​
static struct platform_driver led_platform_driver = {
        .probe = led_probe,
        .remove = led_remove,
        .driver = {
                .name = "my-led-driver",
                .of_match_table = led_of_match,
        },
};
​
module_platform_driver(led_platform_driver);
​

先来看第一行的static const,定义一个静态常量,意味着这个数组只在当前 C 文件内可见(这是static的特性),并且它的内容在程序运行期间不能被修改(这是const的特性)。整体上来看,第一行定义了一个名为led_of_match的数组,并且数组的每个元素的类型都是struct of_device_id

再来看数组的第一个元素,{ .compatible = "lubancat,led-demo" },这里用指定初始化的方式,明确告诉编译器,这个元素的 compatible 成员的值是字符串 "lubancat,led-demo"

指定初始化允许在初始化数组或结构体 / 联合体时,明确指定哪个成员 / 元素要赋什么值,而不是依赖顺序。对于结构体,它用 .成员名 = 值的语法。对于数组,他用[索引] = 值的语法。使用这种初始化方法,我们就可以在初始化时不用严格遵循结构体成员或者数组元素的顺序,而按照我们自己希望的顺序来初始化,并且还可以只对我们希望的结构体成员或者数组元素进行初始化,其他结构体成员或者数组元素会被自动赋值为0。

再看数组的第二个元素,是一个空的结构体,所有成员都被默认初始化为 0NULL,里面的注释是为了提醒我们这是一个哨兵元素,它依靠全零值作为特殊标志,让内核处理到这个元素就停止。

当驱动被编译时,编译器会在 .ko 文件的只读数据段 (.rodata) 中,创建一块内存,存放这个数组的内容。他就像驱动随身携带的一个身份证,上面写着能与它匹配成功的关键信息。

再往下看,是MODULE_DEVICE_TABLE(of, led_of_match),这是一个信息声明宏,它本身不产生可执行代码,而是告诉内核的编译模块加载系统一些元信息。of表示这个表格是用于 OF (Open Firmware, 即设备树) 匹配的,现在多用 dt(Device Tree) ,这是历史遗留问题,led_of_match是表格的名字,也就是数组名。编译时这个宏会在模块中添加一个特殊的信息段,加载时,用户空间的工具可以读取这个信息,来自动生成模块的依赖关系和别名。这对于系统的热插拔和模块自动加载机制非常重要。

继续往下看,定义了一个静态struct platform_driver 类型的变量,并进行初始化,这同样会被存放在 .ko 文件的只读数据段。

.probe = led_probeprobe 成员(这是一个函数指针)指向我们自己编写的 led_probe 函数的地址。有了这行代码,内核就知道如果这个驱动匹配成功,就去调用 led_probe 这个函数

.remove = led_removeremove 成员(函数指针)指向 led_remove 函数的地址。有了这行代码,内核就知道如果这个驱动要被卸载,就去调用 led_remove 来善后

.driver = { ... }是内嵌的 struct device_driver 成员。.name = "my-led-driver"用来设置驱动的名字,内核知道了这个驱动的名字后,会在 /sys/bus/platform/drivers/ 下创建这个目录。.of_match_table = led_of_match将这个驱动的设备树匹配表指针,指向我们之前定义的 led_of_match 数组,内核通过这个指针,才能找到那张写着 "lubancat,led-demo" 信息的身份证。

最后一行,module_platform_driver(led_platform_driver)是驱动程序的启动按钮,这个宏会展开成标准的 module_init()module_exit() 函数,大家光看名字就知道这两个函数是干什么的。

insmod 时,module_init 被调用,它内部会执行 platform_driver_register(&led_platform_driver),此时,内核已经正式接收了led_platform_driver,并把它挂载到平台总线的驱动链表上。然后,立即触发一次总线匹配流程,开始在设备树里寻找它能处理的设备(最开始设备树my_led_test节点中的compatible ,因为找到了匹配的 my_led_test 节点,所以 led_probe 函数紧接着就被调用了。

rmmod 时:module_exit 被调用,它内部会执行 platform_driver_unregister(&led_platform_driver),内核从平台总线的驱动链表上摘下你的驱动。这个动作会触发与该驱动绑定的所有设备的解绑流程,从而调用 led_remove 函数。

3.2 自定义设备结构体

//驱动私有数据
struct my_led_dev {
    dev_t dev_num;
    struct cdev cdev;
    struct class *class;
    struct device *device;
    struct gpio_desc *led_gpio;
};
static struct my_led_dev my_led;

在面向过程的 C 语言中,我们通常使用结构体来模拟面向对象编程中的类。struct my_led_dev 就是我们为 LED 设备这个对象定义的一个,它包含了驱动运行所需的所有关键信息和资源句柄。

如果没有这个结构体,dev_num, cdev, class 等变量都将成为全局变量,这会带来几个严重问题:

首先是命名冲突,如果系统中有另一个驱动也定义了一个全局的 cdev 变量,编译链接时就会产生冲突。

其次是可扩展性差,如果驱动需要同时管理两个 LED 灯怎么办?定义 cdev1, cdev2, dev_num1, dev_num2 这种做法显然是不明智的,这会让代码变得无法维护。

最后是状态不清晰,零散的全局变量使得驱动的状态分散各处,难以管理和理解。

通过将所有与这个设备相关的资源都封装在 struct my_led_dev 这一个结构体里,我们解决了以上所有问题。如果需要支持多个设备,我们只需要定义一个该结构体的数组即可:struct my_led_dev my_leds[10]

在理解了这个结构体存在的意义之后,下面我们来关注一下这个结构体的成员。

dev_t dev_numdev_t是内核中专门用来表示设备号的数据类型,它本质上是一个 32 位的无符号整数。高 12 位代表主设备号,用于关联驱动程序。低 20 位代表次设备号,用于区分由同一个驱动管理的多个设备。在 probe 函数中,我们调用 alloc_chrdev_region(),内核会分配一个唯一的设备号,并把结果存放在这个 dev_num 成员里。之后,在 cdev_add()device_create() 中,我们都需要用到这个 dev_num,告诉内核我们正在操作的是哪个设备号。在 remove 函数中,我们也需要用它来调用 unregister_chrdev_region(),将设备号归还给系统。

struct cdev cdevstruct cdev字符设备在内核中的核心抽象,可以把他看作是连接 VFS驱动操作之间的桥梁。它的内部最重要的成员是 .ops,这是一个指向 file_operations 结构体的指针,在 probe 函数中,我们会通过 cdev_init(&my_led.cdev, &led_fops),将我们自己的led_fops 注册给这个 cdev 实例,然后通过 cdev_add(),将这个 cdevdev_num 一起提交给内核的 cdev_map 哈希表。总之,它是内核用来查找并调用 read/write 等函数的关键数据结构。

struct class *classstruct class *是一个指向设备类结构体的指针,设备类是 sysfs 文件系统中的一个概念,用于对具有相似功能的设备进行分组。在 probe 函数中,我们通过 class_create() 创建了一个新的设备类,并将返回的指针保存在这个 class 成员中,接着,在 device_create() 中,我们需要把这个 class 指针作为参数传进去,告诉内核新创建的设备属于哪个类别,在 remove 函数中,我们也需要用这个指针来调用 class_destroy(),销毁我们创建的类。

struct device *devicestruct device *是一个指向设备结构体的指针,struct device 是 Linux 设备模型中对所有设备(无论块设备、字符设备还是网络设备)的最底层抽象。在 probe 函数中,device_create() 会在内核中创建一个 device 实例,并在 /dev 目录下创建对应的文件节点,这个函数返回的指针就被我们保存在 device 成员中,在 remove 函数中,我们需要这个指针来调用 device_destroy(),以清理设备和 /dev 下的文件。

struct gpio_desc *led_gpiostruct gpio_desc * 是一个指向 GPIO 描述符 (GPIO Descriptor) 结构体的指针,这是现代 Linux 内核中对单个 GPIO 引脚的抽象,取代了过去直接使用整数编号的方式。在 probe 函数中,我们通过 devm_gpiod_get() 从设备树中获取并初始化了这个描述符,并将其保存在 led_gpio 成员中,在 write 函数中,我们就是通过这个 led_gpio 指针,来调用 gpiod_set_value(),从而实现对硬件引脚电平的控制。总而言之,它就是我们用来操作物理 GPIO 引脚的句柄。

static struct my_led_dev my_led,这一行是在全局数据区定义了一个 my_led_dev 类型的变量 my_led,我们将通过my_led这个变量来管理一个 LED 设备。

3.3 Probe 函数详解

static const struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .write = led_write,
};
​
static int led_probe(struct platform_device *pdev)
{
    int ret; // 用于接收函数返回值
    printk("LED Driver: Probe Enter!\n");
    
    //获取硬件
    my_led.led_gpio = devm_gpiod_get(&pdev->dev, "led", GPIOD_OUT_LOW);
    if (IS_ERR(my_led.led_gpio))
    {
         printk(KERN_ERR "LED Driver: Failed to get GPIO\n");
        return PTR_ERR(my_led.led_gpio);
    }
    
    //申请设备号
    ret = alloc_chrdev_region(&my_led.dev_num, 0, 1, DRIVER_NAME);
    if (ret < 0) 
    {
        printk(KERN_ERR "LED Driver: Failed to alloc chrdev_region\n");
        return ret; //此时只获取了devm管理的GPIO,不用手动清理,会自动回收
    }
    
    //注册cdev
    cdev_init(&my_led.cdev, &led_fops);
    ret = cdev_add(&my_led.cdev, my_led.dev_num, 1);
    if (ret < 0) 
    {
        printk(KERN_ERR "LED Driver: Failed to add cdev\n");
        goto err_unregister_chrdev; //跳转到错误处理标签
    }
    
    //创建节点
    my_led.class = class_create(THIS_MODULE, "my_led_class");
    if (IS_ERR(my_led.class)) 
    {
        ret = PTR_ERR(my_led.class);
        printk(KERN_ERR "LED Driver: Failed to create class\n");
        goto err_del_cdev; //跳转到错误处理标签
    }
    
    my_led.device = device_create(my_led.class, NULL, my_led.dev_num, NULL, DEV_NAME);
    if (IS_ERR(my_led.device)) 
    {
        ret = PTR_ERR(my_led.class);
        printk(KERN_ERR "LED Driver: Failed to create device\n");
        goto err_destroy_class; //跳转到错误处理标签
    }
​
    printk("My LED Driver Probe Success!\n");
    return 0;
    
    //倒序释放
err_destroy_class:
    class_destroy(my_led.class);
err_del_cdev:
    cdev_del(&my_led.cdev);
err_unregister_chrdev:
    unregister_chrdev_region(my_led.dev_num, 1);
    return ret; //返回错误码
}

这段代码最开始定义了一个静态常量结构体。struct file_operations是内核 VFS 层的一个核心数据结构,定义在 include/linux/fs.h 中,本质上是一个函数指针的集合,它充当了用户空间中的文件操作内核驱动程序中的具体实现之间的桥梁。无论是硬盘上的真实文件,还是我们创建的 /dev/my_led 这种设备文件,VFS 都用统一的 struct file 来表示,当用户对一个文件进行操作时,VFS 会在该文件的 file->f_op 指针中查找对应的操作函数并调用,f_op 指针就指向我们定义的 led_fops 这样的结构体。

.owner = THIS_MODULETHIS_MODULE是一个定义在 include/linux/module.h 中的宏,它在编译时会被替换成一个指向当前模块 struct module 实例的指针。当用户通过 open() 系统调用打开 /dev/my_led 时,VFS 会看到 .owner 指向了我们的驱动模块,然后内核会调用 try_module_get(fops->owner),将我们模块的引用计数加 1,当用户 close() 文件时,内核会调用 module_put(fops->owner),将引用计数减 1,当执行 rmmod led_driver 时,内核会检查模块的引用计数,如果计数不为 0(这意味着还有进程在使用它),卸载就会失败,并返回 -EBUSY 。因此,这行代码是为了防止模块正在提供服务时被强制卸载

.write = led_write,将 led_fops 结构体中的 write 成员(这是一个函数指针)初始化为我们自己编写的 led_write 函数的地址。用户执行 write 系统调用时,内核的 VFS 层最终会通过 file->f_op->write 这条路径,精确地调用到我们的 led_write 函数。由于这里使用的是指定初始化方法,没有定义的 .read, .ioctl 等其他成员会被置为 0,也就是 NULL。当用户尝试对 /dev/my_led 执行 read() 操作时,VFS 会发现 file->f_op->readNULL,于是会立即向用户返回 -EINVAL

再往下看,probe 函数是驱动的初始化入口。

第一行是int ret,定义一个整型变量 ret,用于接收各个内核函数的返回值,在内核编程中,绝大多数会失败的函数都会通过返回一个负数错误码来表示失败,只需要检查 ret 是不是小于 0,就可以判断操作是否成功。

然后向 GPIO 子系统申请对 led-gpios 属性所描述的 GPIO 的控制权,成功时devm_gpiod_get会返回一个有效的 struct gpio_desc 指针,并存入 my_led.led_gpioIS_ERR()这个宏专门用来检查一个指针是否是错误指针,PTR_ERR() 这个宏用来从错误指针中提取出真正的负数错误码。如果失败,我们打印一条错误日志,并立即返回错误码,此时我们什么资源都还没有申请,所以可以直接退出

再往下ret = alloc_chrdev_region(&my_led.dev_num, 0, 1, DRIVER_NAME)是动态申请一个设备号,成功时alloc_chrdev_region 返回 0,并将分配到的设备号写入 my_led.dev_num,如果失败,我们同样直接返回错误码。因为第一步申请的 GPIO 是 devm_ 管理的,内核会自动帮我们释放,所以这里我们还是不需要手动清理任何东西

在下面是注册并初始化 cdev ,将我们的操作函数集 led_fops 和设备号 dev_num 关联起来,并注册到内核,cdev_init()这个函数不会失败,如果 cdev_add 失败,这时我们已经申请了设备号 ,这个资源不是 devm_ 管理的,我们必须在退出前归还它goto err_unregister_chrdev 就是 Linux 内核中标准错误处理模式,通过 goto 语句跳转到函数末尾的 err_unregister_chrdev 标签处,进行相应的错误处理。

在然后是创建用户空间接口,通过创建设备类和设备节点,让用户可以访问。class_create()可能会因内存不足而失败,返回错误指针,如果 class_create 失败,我们已经成功添加了 cdev,所以需要跳转到 err_del_cdev进行错误处理。device_create()同理。

3.4 文件操作接口

static ssize_t led_write(struct file *filp, const char __user *buf,size_t count,loff_t *ppos)
{
    char kbuf;
    if(copy_from_user(&kbuf,buf,1))
    {
        return -EFAULT;
    }
    
    if(kbuf == '1')
    {
        gpiod_set_value(my_led.led_gpio, 1);
    }
    else if (kbuf == '0')
    {
        gpiod_set_value(my_led.led_gpio, 0);
    }
​
    return count;
}
​

先来看函数原型与参数,static限制了函数的作用域,write 系统调用成功时应返回成功写入的字节数,失败时应返回 -1,并设置 errno,在内核中,我们直接返回一个负的错误码,因此,这里的函数返回值类型为ssize_t

struct file *filp代表打开的文件实例,每次 open() 都会创建一个新的 struct file,如果多个进程同时打开你的设备,就会有多个 struct file 实例,但它们可能指向同一个设备。这个结构体里包含了文件的读写位置、标志位等信息。

const char __user *buf表明这是一个指向字符数据的指针,__user是一个内核宏,用于静态代码检查提醒开发者,本身对编译结果没有影响,但他时刻提醒着此指针来自用户空间,其指向的内存是不安全的,绝对不能直接解引用

size_t count表示用户期望写入的字节数

loff_t *ppos指向文件当前的读写偏移量。对于像 LED 这样没有位置概念的设备,通常可以忽略,但处理完后应该更新它,告诉 VFS 数据被消耗了多少。

我们再来看函数内部。

char kbuf在当前函数的内核栈上,分配了一个字节的内存空间。为了创建一个安全、可信的缓冲区,从用户空间拷贝过来的数据将首先存放在这里,之后我们对 kbuf 的所有操作都是在内核空间内进行的,是绝对安全的。

copy_from_user(&kbuf,buf,1)会调用内核核心函数,从用户空间的 buf 指针处,拷贝 1 个字节到内核空间的 &kbuf 地址,因为我们的只关心第一个字符是 '1' 还是 '0',即使 count 是 2(因为 echo 会附加换行符 \n),我们也只取我们关心的部分,忽略其他。copy_from_user 成功时返回 0,失败时返回未能成功拷贝的字节数(这是一个正数)。所以 if判断非零值即为失败。

再往下就是检查我们从用户空间安全拷贝过来的数据 kbuf,并根据其值执行不同的分支。gpiod_set_value(my_led.led_gpio, 1)是整个流程的终点,my_led.led_gpio是我们在 probe 函数中获取并保存的、代表 GPIO0_B0抽象句柄1这个数字是逻辑值,代表 active (有效)gpiod_set_value 会根据描述符 desc 中保存的有效电平标志(我们在设备树里设置的是 GPIO_ACTIVE_HIGH)来决定最终要写入寄存器的物理值。因为是 ACTIVE_HIGH,所以逻辑值 1 会被转化为物理上的高电平

最后return count,向 VFS 返回一个正数,表示我们成功处理了用户请求的所有 count 个字节。

3.5 Remove 函数与资源释放

static int led_remove(struct platform_device *pdev)
{
    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("My LED Driver Removed.\n");
    return 0;
}

这是在执行 rmmod 时会执行的函数,它会销毁前面申请的资源。remove 函数的执行顺序必须严格遵守 与 probe 函数相反的顺序。如果顺序搞反了,比如先归还了设备号,再去销毁使用该设备号的设备节点,就可能导致内核出现竞态条件或空指针引用。

device_destroy(my_led.class, my_led.dev_num)是对 device_create() 的逆操作。它向 udev 系统发送一个 remove 事件,udev 收到后,会删除 /dev/my_led 这个文件节点my_led.class告诉内核要去哪个类下面找设备,my_led.dev_num精确地告诉内核要销毁的是与哪个设备号关联的设备。这个函数会找到对应的 struct device 对象,减少其引用计数。当引用计数为零时,会调用该设备的 .release 函数来释放内存,同时,它还会清理掉在 /sys/class/my_led_class/ 目录下对应的 my_led 链接。

class_destroy(my_led.class)是对 class_create() 的逆操作,它销毁我们在 probe 中创建的设备类,my_led.class指定要销毁的类的句柄。这个函数会向 sysfs 文件系统注销这个类,导致 /sys/class/my_led_class 整个目录被删除,它还会检查这个类下面是否还有注册的设备,如果有,通常会打印警告,因为这违反了先销毁设备,再销毁类的原则。

cdev_del(&my_led.cdev)是对 cdev_add() 的逆操作,它将我们的字符设备核心从内核的管理中移除,&my_led.cdev就是要删除的 cdev 对象的指针。执行 cdev_del 之后,任何新的 open("/dev/my_led") 请求都会失败。但是,如果此时已经有进程打开了 /dev/my_led 并且正在使用,那个 struct filecdev 的连接依然有效,对应的 read/write 仍然可以被调用,只有当最后一个持有该文件的进程 close() 它之后,cdev 对象才会被真正释放。

unregister_chrdev_region(my_led.dev_num, 1)是对 alloc_chrdev_region() 的逆操作,将我们占用的设备号范围归还给系统,my_led.dev_num表示起始设备号,1 表示数量。它会清除内核 chrdev_major_map 位图中相应的标记位,使得这个设备号可以被其他驱动重新申请和使用。

probe 里的第一步 devm_gpiod_get 申请的 GPIO 由于devm_ 机制,在 remove 函数执行完毕后,驱动核心会自动遍历与该设备关联的一个资源链表 ,执行所有登记在册的清理任务,因此,GPIO 资源被自动、安全地释放了,无需我们手动干预。

3.6 完整驱动代码

我把完整的,我使用的驱动代码放在这里,方便大家测试。

#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/mod_devicetable.h>
#include <linux/gpio/consumer.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>#define DRIVER_NAME "my_led_driver"
#define DEV_NAME "my_led"//驱动私有数据
struct my_led_dev{
    dev_t dev_num;
    struct cdev cdev;
    struct class *class;
    struct device *device;
    struct gpio_desc *led_gpio;
};
​
//文件操作
static ssize_t led_write(struct file *filp, const char __user *buf,size_t count,loff_t *ppos)
{
    char kbuf;
    if(copy_from_user(&kbuf,buf,1))
    {
        return -EFAULT;
    }
    
    if(kbuf == '1')
    {
        gpiod_set_value(my_led.led_gpio, 1);
    }
    else if (kbuf == '0')
    {
        gpiod_set_value(my_led.led_gpio, 0);
    }
​
    return count;
}
​
static const struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .write = led_write,
};
​
static int led_probe(struct platform_device *pdev)
{
    int ret; 
    printk("LED Driver: Probe Enter!\n");
​
    my_led.led_gpio = devm_gpiod_get(&pdev->dev, "led", GPIOD_OUT_LOW);
    if (IS_ERR(my_led.led_gpio))
    {
         printk(KERN_ERR "LED Driver: Failed to get GPIO\n");
        return PTR_ERR(my_led.led_gpio);
    }
​
    ret = alloc_chrdev_region(&my_led.dev_num, 0, 1, DRIVER_NAME);
    if (ret < 0) 
    {
        printk(KERN_ERR "LED Driver: Failed to alloc chrdev_region\n");
        return ret; 
    }
​
    cdev_init(&my_led.cdev, &led_fops);
    ret = cdev_add(&my_led.cdev, my_led.dev_num, 1);
    if (ret < 0) 
    {
        printk(KERN_ERR "LED Driver: Failed to add cdev\n");
        goto err_unregister_chrdev;
    }
    
    my_led.class = class_create(THIS_MODULE, "my_led_class");
    if (IS_ERR(my_led.class)) 
    {
        ret = PTR_ERR(my_led.class);
        printk(KERN_ERR "LED Driver: Failed to create class\n");
        goto err_del_cdev;
    }
    
    my_led.device = device_create(my_led.class, NULL, my_led.dev_num, NULL, DEV_NAME);
    if (IS_ERR(my_led.device)) 
    {
        ret = PTR_ERR(my_led.class);
        printk(KERN_ERR "LED Driver: Failed to create device\n");
        goto err_destroy_class;
    }
​
    printk("My LED Driver Probe Success!\n");
    return 0;
​
err_destroy_class:
    class_destroy(my_led.class);
err_del_cdev:
    cdev_del(&my_led.cdev);
err_unregister_chrdev:
    unregister_chrdev_region(my_led.dev_num, 1);
    return ret; 
}
​
static int led_remove(struct platform_device *pdev)
{
    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("My LED Driver Removed.\n");
    return 0;
}
​
static const struct of_device_id led_of_match[] = {
        { .compatible = "lubancat,led-demo" },
        { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, led_of_match);
​
static struct platform_driver led_platform_driver = {
        .probe = led_probe,
        .remove = led_remove,
        .driver = {
                .name = "my-led-driver",
                .of_match_table = led_of_match,
        },
};
​
module_platform_driver(led_platform_driver);
​
MODULE_LICENSE("GPL");
MODULE_AUTHOR("XLP");

4. 驱动代码的编译、部署与验证

4.1 编写 Makefile

驱动程序的编译必须依赖 Linux 内核的构建系统,因此,这里需要编写一个专门的 Makefile ,并且要在 docker 环境下编译,如果本身使用的就是 ubuntu 20.04,那就不需要创建 docker 了。

我们在 led_driver.c 的同级目录下创建 Makefile 文件,Makefile 的代码如下:

#指定内核源码路径,注意是编译环境下的路径(这是我的内核源码路径,每个人的可能不一样,要根据实际情况修改)
KERNEL_DIR := /home/xlp/workspace/kernel
​
#指定要编译的目标文件(.o文件会自动链接为.ko)
obj-m := led_driver.o
​
#编译规则
all:
    #-C 跳转到内核源码目录,利用那里的Makefile来编译
    #M=$(PWD)告诉内核,驱动源码在当前目录
    #ARCH=arm64指定架构
    #CROSS_COMPILE指定交叉编译器
    make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
​
clean:
    make -C $(KERNEL_DIR) M=$(PWD) clean

4.2 编译驱动模块

在 Docker 环境(或已经配置好交叉编译链的环境)中,进入代码目录,执行下面命令:

    make

结果如下:

12. 驱动编译.png

原本这个目录下只有rk3568-lubancat-2-v3.dtb(这是我们前面拷贝过来的),led_driver.c(这是我们前面编写的),还有 Makefile (这是刚才编写的)文件。

在编译后会产生一些中间产物和我们最终需要的 led_driver.ko 文件,这个.ko文件就是我们要的驱动模块。

4.3 部署与加载

由于配置了NFS共享文件夹,我们不需要特别复杂的操作。

我们打开板子的终端,并进入存放驱动的目录:

13. 板子ko.png

可以看到,我们需要的文件已经在这里了。

此时,我们就可以加载驱动,insmod 命令会将我们的 .ko 模块动态加载到 Linux 内核中。此时,module_init 宏定义的入口函数会被执行,从而触发 platform_driver_register

    insmod led_driver.ko

然后查看内核日志,看看是否成功:

    dmesg | tail

14. 内核日志.png

我们一般只需要关注最后几行内容,可以看到已经打印出了我们在led_probe函数中编写的内容。这意味着初始化已经成功了。

4.4 点亮那颗 LED

现在,我们就可以点亮那个 led 灯了。

命令行执行下面命令:

    echo 1 > /dev/my_led

可以看见,灯已经亮了。

15. 灯亮.jpg

执行下面命令让灯熄灭:

    echo 0 > /dev/my_led

4.5 卸载驱动

测试完程序要卸载驱动时,可以执行下面命令:

    rmmod led_driver

然后查看内核日志:

    dmesg | tail

16. 卸载驱动.png

可以看到内核日志已经成功打印出驱动移除的消息。

4.6 我踩的一个小坑

在调试过程中,我还遇到一个诡异的现象,驱动加载了,命令也执行了,但灯就是不亮,日志也没反应,折磨了我挺长时间。

最后才发现,我在加载驱动之前,不小心先执行了 echo 1 > /dev/my_led。但此时 /dev/my_led 还没生成,Linux 系统会自动在 /dev 下创建一个同名的普通文本文件。当驱动加载后,真正的设备节点无法生成,导致我后续的 echo 命令其实一直是在往这个普通文本文件里写字,根本没有经过驱动。

要不是使用ls命令时无意间发现这个文件的属性不对劲(字符设备文件属性的第一列是c),这个问题还不知道会困扰我多久。


这篇文章到这里就结束了。