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

我的《Linux驱动开发》专栏基本已经把字符设备相关的内容讲的差不多了,下面是时候上点硬件,来点小小的视觉冲击了。本文虽然只是控制一个小小的 LED,但是代码是完整的,包含了字符设备,设备树节点添加,平台总线,Linux 的总线-设备-驱动模型,以及现代 gpio 子系统,这是驱动开发的一整个框架,今天的 LED 你学会了,明天随便拿个传感器来代码框架不变,实际要改的只是驱动程序的具体逻辑,今天的平台总线学会了,再换个 I2C 总线,SPI 总线,来来回回还是那些个操作,只是 API 换了而已,进行过实际操作的读者应该是深有体会的。


0. 前言

如果你和我一样,学习过单片机,然后是从学习 openreadwrite 的字符设备驱动开始接触到 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_devicecompatible 属性正好也是 "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_requestgpio_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_destroyclass_destroycdev_delunregister_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 小时前
Linux线程基本概念
linux
Fairy要carry2 小时前
面试08-“生产者-消费者” 模型实现并发 Agent
python·面试
姓王名礼3 小时前
一份 Windows/macOS/Linux 完整安装 + 运行 + 对接 WebUI 的步骤
linux·windows·macos
零雲3 小时前
java面试:Spring事务失效的场景有哪些?
java·数据库·面试
发现一只大呆瓜3 小时前
React-深度拆解 React路由:从实战进阶到底层原理
前端·react.js·面试
发现一只大呆瓜3 小时前
React-手把手带你实现 Keep-Alive 效果
前端·react.js·面试
idolao4 小时前
CentOS 7 安装 nginx-1.3.15.tar.gz 详细步骤(从源码编译到启动配置)
linux·运维·数据库
yaoxin5211234 小时前
358. Java IO API - 使用 relativize() 创建路径之间的相对关系
java·linux·python
武藤一雄4 小时前
C#常见面试题100问 (第一弹)
windows·microsoft·面试·c#·.net·.netcore