从零点亮 RK3568 的 LED:设备树,平台总线,现代gpio子系统全解析(附完整代码)

我的《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 通信等内容。

来吧,我们一起加油,向内核深处进发。

相关推荐
卷福同学2 小时前
第一次当面试官有感
后端·面试
怕浪猫2 小时前
顶级模型一句话,AI 写出了能玩的 QQ飞车
前端·面试·github
呃呃呃呃ex2 小时前
9. TypeScript:从类型系统到工程实践
前端·面试
律宏阔2 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔2 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴2 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
Blanche15002 小时前
Webpack 构建流程解析
javascript·面试
我叫黑大帅2 小时前
为了知道计算机为什么选择了二进制,我做了这些……
面试·程序员·计算机组成原理
怕浪猫2 小时前
DeepSeek Harness 系列图解
面试·前端框架·node.js
double_face2 小时前
AgentScope 2.0 源码解析系列(Java 版)|第 3 篇:State 模块,Agent 靠什么记住这轮对话
后端·面试