【Linux驱动实战】:字符设备驱动之内核态与用户态数据交互

这是我《Linux 驱动实战》专栏字符设备驱动相关的第二篇文章,上一篇我们一起写了一个只有躯壳的字符设备,/dev/my_cdev_device 设备节点只能看不能摸。今天我们要给这个躯壳补上灵魂,让用户程序能通过文件操作来实现用户态与内核态的数据交互。相比上一篇文章,本篇文章的代码量也增加了一倍,考虑到直接放一大坨代码上来大家可能会看的头晕,我决定采用分块讲解的形式,但最后会附上完整可运行的代码。我们话不多说,直接进入正题。


1. 本文目标

每次看《Linux 设备驱动程序》这种经典著作,看到各种复杂的底层架构,总是会忍不住怀疑人生,我就想从简单的驱动开始慢慢学,有必要搞的这么复杂吗?

后来我明白了,学习驱动最好的方法就是先跑通一个极简、完整的程序,俗称 麻雀虽小,五脏俱全

之前也看过不少的教程,但是上来就是点灯,要问点灯这个项目基础不基础?得分情况,对于单片机来说,点灯还是可以拿来作为入门项目练手的;但是对于 Linux 驱动开发来说,你才刚接触这个方向,就让你学习写一个字符设备驱动来控制 LED ,这显然不合理,也不符合事物的发展规律。因为这时候你可能根本都不了解字符设备驱动的框架,不知道字符设备是个什么东西,甚至连字符设备这个名词都不知道什么含义。

经过这段时间的学习,我感受到,我们不能一上来就搞点灯,我们需要循序渐进。正如我现在采用的办法,我们得先知道字符设备是什么吧,这正是我上一篇文章开头讲过的;我们还得知道字符设备的基础框架吧,知道要创建一个字符设备我们都需要干什么;我们还得知道当字符设备初始化到每一个阶段,你的文件系统的某个位置发生了什么变化吧,这也是上一篇文章我用测试程序验证过的。

上篇文章我们只学到怎样为用户层提供一个操作字符设备的接口,却没有实现操作本身,今天我们来实现这个操作本身。

最终我们会达到这样的效果:

  • 先用命令简单测试,通过 echo 命令向 /dev/my_cdev_device 写入内容,然后通过 cat 读出来。
  • 然后写一个用户层的程序,实现打开文件,写入数据,读出数据的操作。

这样,我们就彻底打通了 用户空间应用程序到虚拟文件系统,再到咱们自己写的底层驱动 这条路,以后的各种真实传感器驱动,不过是在这个框架上添砖加瓦罢了。


2. 核心逻辑拆解

本章我们将完整的代码分成几个大块进行学习,包括函数的一些逻辑,以及新接触到的一些 API。

2.1 定义结构体

如下面代码块,我们将需要用到的信息打包进一个代码块:

c 复制代码
#define BUFFER_SIZE 1024
​
struct my_cdev{
    dev_t dev_num;//设备号
    struct cdev cdev;//字符设备核心结构体
    struct class *class;//设备类
    struct device *device;//设备节点
​
    char *buffer;//用来存储数据
    size_t data_size;//实际存的数据量
};

这个结构体相比我们之前用的新加入了三个成员。我下面一一介绍一下:

  • struct cdev cdev:定义一个 struct cdev 类型的成员 cdev ,这是字符设备核心结构体。后面我们会用 cdev_init 将它与文件操作结构体关联起来,然后再用 cdev_add 将它添加到内核的全局哈希表中。
  • char *buffer: 这是用来存储数据的,当用户程序向设备节点文件写入内容时,把内容就放在这,当用户程序读取设备节点文件的内容时,我们把这里面的内容传给用户程序。
  • size_t data_size:表示 buffer 中当前存储的字节数,我们只需要在用户程序向设备节点文件写入内容时更新它即可。

剩下的三个成员都是我们之前讲过的,都是注册一个字符设备并在/dev目录下自动创建节点文件必备的东西。

2.2 驱动的灵魂

要让设备真正活起来,响应用户层的 catecho 或者 C 语言里的 openreadwrite,我们必须搞懂一套核心机制:file_operations 以及它绑定的函数指针

2.2.1 open 与 release

先上代码:

c 复制代码
//定义全局结构体
struct my_cdev my_cdev;
​
static int my_cdev_open(struct inode* inode,struct file* file)
{
    //把结构体指针存入file->private_data,方便别的函数使用
    file->private_data = &my_cdev;
    printk(KERN_INFO "open success!\n");
    return 0;
}
​
static int my_cdev_release(struct inode* inode, struct file* file)
{
    printk(KERN_INFO "release success!\n");
    return 0;
}

我们最终达成的目的是当我们在用户程序中使用 open 打开 /dev/my_cdev_device 时,函数 my_cdev_open 会被执行;当我们使用 close 关闭 /dev/my_cdev_device 时,函数 my_cdev_release 会被执行。

但是现在还不行,我们现在还只是实现了这两个函数,后面还需要将这两个函数的填充到 file_operations 结构体中去,再与 cdev 结构体绑定,最后注册到内核中。

我们看看这两个函数。

乍一看这两个函数其实很简单,没什么技术含量,不就是用 printk 打印提示信息吗?但是请千万别小看 my_cdev_open 函数里那句不起眼的 file->private_data = &my_cdev ,这可以算是字符设备驱动框架的一部分。

要知道在 Linux 中,同一个驱动可以同时支持多个同类设备,而 struct file 又代表一个 打开的文件实例 ,因此当我们在 open 时将 &my_cdev 装入 file 结构体,又在 readwrite 中把它取出来操作,那么以后对这个字符设备的操作就不会影响到别的同类设备了。

现在的话理解起来可能有点难度,但是随着时间推移,一定能理解的,而且正如我所说,这是字符设备驱动框架的一部分,可以先形成肌肉记忆,以后再慢慢理解。

2.2.2 write

代码如下:

c 复制代码
static ssize_t my_cdev_write(struct file* file, const char __user* buf, size_t count, loff_t* offset)
{
    //把上面存进去的东西取出来
    struct my_cdev* dev = file->private_data;
    size_t len;//实际要写的字节数
    //打印当前偏移量和计划写入的字节数
    printk(KERN_INFO "my_cdev: [write]  offset=%lld  count=%zu\n", *offset, count);
​
    //偏移量超过缓冲区大小
    if(*offset >= BUFFER_SIZE)
    {
        return 0;
    }
​
    //计算实际要写的字节数,min里面的参数数据类型要严格相同
    len = min(count, (size_t)(BUFFER_SIZE - *offset));
​
    //这里下面详细讲
    if(copy_from_user((dev->buffer)+(*offset), buf, len))
    {
        return -EFAULT;
    }
    *offset += len;//更新偏移量
    
    //写完了要更新缓冲区数据的字节数
    if(*offset >= dev->data_size)
    {
        dev->data_size = *offset;//更新数据大小
    }
    return len;//规矩,write要返回写入的字节数。
}
​

下面我们有重点的了解一下这个函数:

  • 首先,我们一定要区分开我们 计划写入的字节数实际能写入的字节数 ,这个逻辑占据了 write 函数代码量的一半。这个函数是内核调用的,参数也是内核传进来的。 第一个参数无需多言,第二个参数是用户的缓冲区地址,由于用户态与内核态是 隔离 的,我们不能直接操作它,这也是后面使用copy_from_user 的原因第三个参数 count 代表计划写入的字节数,第四个参数代表偏移量也就是说我们计划 从文件的哪个位置开始写
  • 当偏移量超过缓冲区大小BUFFER_SIZE,也就是 1024 时,这说明光标已经指向缓冲区末尾甚至是缓冲区外面了,我们不能再写了,这时直接返回 0,表示写入 0 个字节。
  • 如果偏移量没有超过缓冲区大小,还需要进一步判断缓冲区能不能容纳下我们计划写入的字节数。我们使用 min 找出 count缓冲区剩余容量 中较小的那一个。
  • 之后,使用 copy_from_user 将用户态的数据拷贝到内核缓冲区中,也就是 buffer 里面,这里不用着急,我们在初始化函数中会为 buffer 分配内存。
  • copy_from_user 的第一个参数是内核空间的目标地址,我们要把数据拷贝到这里;第二个参数是用户空间的地址,也就是数据的来源;第三个是要拷贝的字节数。
  • 在拷贝完成之后,我们要更新偏移量,将光标移到我们刚写入的数据后面。最后还要更新缓冲区数据的字节数。

我觉得还需要再深入了解一下偏移量 offset重点来了 :当我们使用 open 打开一个文件时,内核会创建一个 struct file 结构体用来记录这个打开的文件实例,这个文件可以被同一个或者不同的进程同时打开,并且每个打开的实例都会有一个 struct file ,而偏移量 offset 这个struct file 结构体中的成员之一,用来记录这个打开的文件实例的光标偏移量。

当我们使用 open 打开文件时,光标默认在文件开头,也就是说偏移量为 0,我们在用户态每写入一些数据,偏移量都要增加相应的字节数,这也就是为什么 copy_from_user 的参数要传 (dev->buffer)+(*offset) ,如果不这样做,每次向文件写入数据时都会间过前面的内容覆盖掉。

2.2.3 read

代码如下:

c 复制代码
static ssize_t my_cdev_read(struct file* file, char __user* buf, size_t count, loff_t* offset)
{
    struct my_cdev* dev = file->private_data;
    size_t len;//实际能读的字节数
​
    //打印偏移量,计划读的字节数和当前缓冲区内有的字节数
    printk(KERN_INFO "my_cdev: [read]  offset=%lld  count=%zu  data_size=%zu\n", *offset, count, dev->data_size);
​
    //如果读到文件末尾,返回0,表示文件结束
    if(*offset >= dev->data_size)
    {
        return 0;
    }
​
    //计算实际可读的字节数
    len = min(count,(size_t)((dev->data_size)-(*offset)));
​
    if(copy_to_user(buf, (dev->buffer) + (*offset), len))
    {
        return -EFAULT;
    }
    *offset += len;//更新偏移量
    return len;//规矩,read返回读到的字节数
}

下面讲一下这个函数:

  • 我们依旧把重点放到偏移量上面。我们都知道 read 在读到文件末尾时要返回 0,我们要在驱动程序中实现这个特性,当偏移量大于或等于内核缓冲区内容的字节数时,说明读到末尾了,read 直接返回 0。
  • 如果没读到末尾,就计算实际可读的字节数,找出 count缓冲区中光标后面内容字节数 二者较小的那一个。
  • 然后使用 copy_to_user,将内容拷贝到用户空间。它的第一个参数是用户空间的目标地址,第二个参数是内核空间的数据源地址,第三个参数是要拷贝的字节数。
  • 读完之后更新偏移量,这是标准流程。

这里我们一定要区分开 copy_from_usercopy_to_user ,数据流向的示意图如下:

可以结合 tofrom 来理解,还是挺形象的。

2.2.4 file_operations

如下:

c 复制代码
static struct file_operations my_cdev_fops = {
    .owner = THIS_MODULE,
    .open = my_cdev_open,
    .release = my_cdev_release,
    .read = my_cdev_read,
    .write = my_cdev_write,
    .llseek = default_llseek,//使用系统默认的偏移量操作
};

到这里,我们可以梳理一下整个流程了:用户空间使用 open 打开一个字符设备时,虚拟文件系统通过 open 传入的路径查找该字符设备对应的 struct inode 节点,然后遍历内核哈希表cdev_map,根据inode结构体中的dev_t设备编号找到struct cdev对象,再创建struct file,系统采用一个数组来管理一个进程中多个被打开的设备,每个文件描述符作为数组的下标标识一个字符设备对象,然后将struct file结构体中的file_operations成员指向struct inode结构体中的file_operations成员,最终执行回调函数,file->fops->open

当我们对这个打开的文件实例执行readwrite操作时,调用的就是各自对应的驱动函数。

2.3 init和exit函数

关于init函数和exit函数,上篇文章我们已经讲的很透彻了,大部分操作都是固定框架,只是有个别地方需要注意下,这里篇幅已经挺长了,我挑重点讲一下。

首先,在 init 函数中要为我们的buffer分配内存并初始化缓冲区字节数为 0:

c 复制代码
    my_cdev.buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if(!my_cdev.buffer)
    {
        printk(KERN_INFO "kmalloc failed!\n");
        return -ENOMEM;
    }
    memset(my_cdev.buffer, 0, BUFFER_SIZE);
    my_cdev.data_size = 0;

其次,我们上篇文章没讲过的还有cdev_initcdev_add,但本文前面我也讲了他们的作用了,cdev_init用于将cdev结构体与file_operations结构体绑定起来,而cdev_add用于将该字符设备添加到内核的全局哈希表中,二者缺一不可。下面看看代码实现:

c 复制代码
    //cdev初始化
    cdev_init(&(my_cdev.cdev), &my_cdev_fops);
    my_cdev.cdev.owner = THIS_MODULE;
​
    //cdev添加到内核
    ret = cdev_add(&(my_cdev.cdev), my_cdev.dev_num, 1);
    if(ret < 0)
    {
        printk(KERN_INFO "cdev add failed!\n");
        goto err_cdev_add;
    }

这里比较简单,就不讲参数了。


3. 完整代码与编译

3.1 完整驱动代码

代码如下:

c 复制代码
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/device.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/string.h>
​
#define BUFFER_SIZE 1024
​
struct my_cdev{
    dev_t dev_num;
    struct cdev cdev;
    struct class *class;
    struct device *device;
​
    char *buffer;
    size_t data_size;
};
​
struct my_cdev my_cdev;
​
static int my_cdev_open(struct inode* inode,struct file* file)
{
    //把结构体指针存入file->private_data,方便别的函数使用
    file->private_data = &my_cdev;
    printk(KERN_INFO "open success!\n");
    return 0;
}
​
static int my_cdev_release(struct inode* inode, struct file* file)
{
    printk(KERN_INFO "release success!\n");
    return 0;
}
​
static ssize_t my_cdev_read(struct file* file, char __user* buf, size_t count, loff_t* offset)
{
    struct my_cdev* dev = file->private_data;
    size_t len;//实际能读的字节数
​
    printk(KERN_INFO "my_cdev: [read]  offset=%lld  count=%zu  data_size=%zu\n", *offset, count, dev->data_size);
​
    //如果读到文件末尾,返回0,表示文件结束
    if(*offset >= dev->data_size)
    {
        return 0;
    }
​
    len = min(count,(size_t)((dev->data_size)-(*offset)));
​
    if(copy_to_user(buf, (dev->buffer) + (*offset), len))
    {
        return -EFAULT;
    }
    *offset += len;//更新偏移量
    return len;
}
​
static ssize_t my_cdev_write(struct file* file, const char __user* buf, size_t count, loff_t* offset)
{
    struct my_cdev* dev = file->private_data;
    size_t len;
    printk(KERN_INFO "my_cdev: [write]  offset=%lld  count=%zu\n", *offset, count);
​
​
    //偏移量超过缓冲区大小
    if(*offset >= BUFFER_SIZE)
    {
        return 0;
    }
​
    len = min(count, (size_t)(BUFFER_SIZE - *offset));
​
    if(copy_from_user((dev->buffer)+(*offset), buf, len))
    {
        return -EFAULT;
    }
    *offset += len;
    if(*offset >= dev->data_size)
    {
        dev->data_size = *offset;//更新数据大小
    }
    return len;
}
​
static struct file_operations my_cdev_fops = {
    .owner = THIS_MODULE,
    .open = my_cdev_open,
    .release = my_cdev_release,
    .read = my_cdev_read,
    .write = my_cdev_write,
    .llseek = default_llseek,
};
​
static int __init my_cdev_init(void)
{
    int ret;
​
    //分配缓冲区
    my_cdev.buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if(!my_cdev.buffer)
    {
        printk(KERN_INFO "kmalloc failed!\n");
        return -ENOMEM;
    }
    memset(my_cdev.buffer, 0, BUFFER_SIZE);
    my_cdev.data_size = 0;
​
    ret = alloc_chrdev_region(&(my_cdev.dev_num), 0, 1, "my_cdev");
    if(ret < 0)
    {
        printk(KERN_INFO "alloc failed!\n");
        goto err_alloc;
    }
    //打印主次设备号
    printk(KERN_INFO "Major:%d \t Minor:%d\n",MAJOR(my_cdev.dev_num),MINOR(my_cdev.dev_num));
​
    //cdev初始化
    cdev_init(&(my_cdev.cdev), &my_cdev_fops);
    my_cdev.cdev.owner = THIS_MODULE;
​
    //cdev添加到内核
    ret = cdev_add(&(my_cdev.cdev), my_cdev.dev_num, 1);
    if(ret < 0)
    {
        printk(KERN_INFO "cdev add failed!\n");
        goto err_cdev_add;
    }
​
    my_cdev.class = class_create(THIS_MODULE, "my_cdev_class");
    if(IS_ERR(my_cdev.class))
    {
        ret = PTR_ERR(my_cdev.class);
        printk(KERN_INFO "class create failed!\n");
        goto err_class;
    }
​
    my_cdev.device = device_create(my_cdev.class, NULL, my_cdev.dev_num, NULL, "my_cdev_device");
    if(IS_ERR(my_cdev.device))
    {
        ret = PTR_ERR(my_cdev.device);
        printk(KERN_INFO "device create failed!\n");
        goto err_device;
    }
​
    printk(KERN_INFO "init success!\n");
​
    return 0;
​
err_device:
    class_destroy(my_cdev.class);
err_class:
    cdev_del(&my_cdev.cdev);
err_cdev_add:
    unregister_chrdev_region(my_cdev.dev_num,1);
err_alloc:
    kfree(my_cdev.buffer);
​
    return ret;
}
​
static void __exit my_cdev_exit(void)
{
    device_destroy(my_cdev.class, my_cdev.dev_num);
    class_destroy(my_cdev.class);
    cdev_del(&my_cdev.cdev);
    unregister_chrdev_region(my_cdev.dev_num,1);
    kfree(my_cdev.buffer);
    printk(KERN_INFO "resource freed!\n");
}
​
module_init(my_cdev_init);
module_exit(my_cdev_exit);
​
MODULE_LICENSE("GPL");

3.2 Makefile

Makefile 如下:

c 复制代码
KERNEL_DIR := /home/xlp/workspace/kernel
​
obj-m := cdev.o
​
all:
    make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
​
clean:
    make -C $(KERNEL_DIR) M=$(PWD) clean

3.3 编译

编译后的结果如下:

看到这个界面就说明编译成功了。

然后我们把需要的 cdev.ko 拷贝到板子上就可以运行了。


4. 运行

4.1 加载模块

加载模块需要root权限,我们先用下面命令切换到root

bash 复制代码
sudo su

然后加载模块:

bash 复制代码
insmod cdev.ko

执行状况与内核日志如下:

日志中打印了 "init success",这说明我们的init函数已经成功执行了。

4.2 写入数据并读取

然后我们尝试向/dev/my_cdev_device写入数据并读取:

成功读出来了。

我们再看内核日志,驱动程序中writeread函数中的printk的内容也成功打印了:

从这张内核日志的截图中我们还能得到重量级结论:

  • 第一,echocat的底层其实也是一系列系统调用,先open文件,然后read或者write,最后close文件。

  • 第二,相信大家已经发现了,日志中echo引起的write调用只有一次,而cat引起的read调用却有两次,这是为什么呢?实际上,当cat执行时,因为当前它的目标是一个字符设备,而不是普通的文件,cat并不知道它要读取的内容有多少,但是有一点,它要全部读完,因此,它准备了一个超级大的缓冲区,日志中可以看到这个缓冲区大小为 131072 字节,也就是128KB ,但实际上只读了10个字节,因为我们的内核缓冲区中只有 10 个字节。cat拿到这 10 个字节后还没有停手,它要验证是否已经读到文件的末尾了,于是它发起了第二次 read,但是此时offset已经为 10 了,就进入了我们代码中的这个逻辑:

    c 复制代码
    if(*offset >= dev->data_size) 
    {
        return 0; //触发EOF
    }

    cat收到返回的 0 之后,它明白已经读到文件末尾了,于是调用close关闭文件。

试想一下: 如果在第二次 read 的时候,我们没有返回 0,而是随便返回了一个大于 0 的数,或者忘了判断 offsetcat 就会一直以为还有数据,不断发起第三次、第四次、第一万次 read,你的屏幕就会瞬间卡死。这就是驱动开发中 状态管理 最生动的体现。

4.3 卸载模块

最后我们使用rmmod卸载模块:

内核日志显示,我们的exit函数已经成功调用了,资源释放完成。

4.4 小补充

如果想使用echo追加写入,那么需要在驱动程序的write函数中加一段代码:

c 复制代码
    //如果发现是以追加模式打开的,我们就强行把光标移到数据的末尾
    if (file->f_flags & O_APPEND) {
        *offset = dev->data_size; 
    }

5. 编写应用程序测试

这一章,我们要用 C 语言写一个用户态的应用程序来调用我们的底层驱动。

代码如下:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
​
#define DEV_NAME "/dev/my_cdev_device"
​
int main()
{
    int fd;
    char write_buf[] = "hello xlp";
    char read_buf[100] = {0};
    int ret;
​
    fd = open(DEV_NAME,O_RDWR);//可读可写
    if(fd < 0)
    {
        printf("open failed!\n");
        return -1;
    }
​
    //写入内容
    ret = write(fd,write_buf,strlen(write_buf));
    if(ret < 0)
    {
        printf("write failed!\n");
        close(fd);
        return -1;
    }
    printf("写入 %d 个字节:%s\n",ret,write_buf);
​
    //读取
    lseek(fd,0,SEEK_SET);//需要将光标移动到文件开头
    ret = read(fd,read_buf,sizeof(read_buf));
    if(ret < 0)
    {
        printf("read failed!\n");
        close(fd);
        return -1;
    }
    printf("读出 %d 个字节:%s\n",ret,read_buf);
    close(fd);
​
    //再打开关闭测试一下
    fd = open(DEV_NAME,O_RDWR);
    if(fd<0)
    {
        return -1;
    }
    close(fd);
​
    return 0;
}

这个程序直接在板子上编译即可。

我们先加载模块,然后运行这个应用程序,再查看内核日志:

可以看到,我们的应用程序完美的调用的我们的驱动程序,内核日志打印的信息和我们应用程序的逻辑完全一样。


6. 总结

这篇文章写到现在也就快结束了,我想谈谈我的看法。

以前,当我在终端敲下 cat /dev/xxx 时,我只是单纯的觉得这是一个系统命令。

但现在,我的脑海里会立刻浮现出一幅生动的画面:shell 帮我调用了 open,内核通过文件路径查找inode进而找到cdev ,同时也就找到了cdev里面的file_operations ,找到了对应的驱动程序,接着 cat 带着一个 128KB 的缓冲区发起 read ,直到驱动给出一个 return 0EOF 标志,它才 close 文件,最后结束。

这种 看透事物底层运转逻辑 的感觉,我不知道大家什么啥想法,反正我很享受。

最后,如果这篇文章帮助到你了,可以看一下我的专栏《Linux 驱动开发》,感兴趣的朋友可以订阅一下,如果可以的话也请给我点个关注,点赞收藏一下文章,可以以后隔一段时间回顾一下,温故而知新嘛!谢谢大家了~


本文结束

相关推荐
久绊A1 小时前
服务器新硬盘初始化与挂载
linux·挂载
IMPYLH2 小时前
Linux 的 chroot 命令
linux·运维·服务器
克莱因3582 小时前
Linux Cent OS7 at定时任务
linux·运维·服务器
RisunJan2 小时前
Linux命令-make(GNU的工程化编译工具)
linux·运维·gnu
闲猫2 小时前
Linux 历史命令(history)
linux·运维·chrome
Mr_Carl2 小时前
我用 Trae 花了一周,从零打造了一个 AI 面试官🚀
面试·trae·vibecoding
茶杯梦轩2 小时前
面试常问:DNS,CDN,Cookie,Session和Token详解及实战避坑指南
后端·网络协议·面试
张元清2 小时前
React Hooks 性能优化:如何避免不必要的重新渲染
前端·javascript·面试
程序员小董3 小时前
从 RocksDB 定时器出发:手写一个通用的 Linux 高精度定时器
linux·服务器