【Linux驱动实战】:字符设备之ioctl与mutex全解析

0. 前言

上一篇我们讲了字符设备用户态与内核态的数据交互,这篇文章我们继续深入学习。

在学习 Linux 驱动开发过程中,大家对于 openreadwrite 这些基础操作肯定是手拿把掐,但是一遇到 ioctl 和并发控制,面对一堆宏定义和死锁问题,往往会学的一头雾水。

今天,我们会继续延续使用上一篇内核态与用户态数据交互的驱动代码,用 200 多行的完整实战代码,把 ioctl 的原理及使用方法和 mutex 互斥锁的避坑指南讲得明明白白。

如果你看过我《Linux驱动开发》专栏中的上一篇文章,并认真看过代码,手敲过一遍,那么在看完本篇文章之后,你只需略做修改,即可跑通,当然,看完本篇文章你会具有修改代码的能力的。

不管你是初学者还是想复习巩固有一定基础的高手,我相信看完之后会有新的感悟的,建议收藏一下,以后复习用。

注:文末附完整可运行代码。


1. ioctl与mutex的必要性

可能还有不少读者听过这两个概念,但不知道他们是用来干什么的,我们第一章先来了解一下,如果没有它们会产生什么严重的后果,用了它们,事情又是怎样变的不一样了。

1.1 深入解析ioctl

在 Linux 中,一切皆文件。我们一般情况下都是通过 openreadwriteclose 四个标准的系统调用来与文件或设备进行交互。

但是,并不是所有的设备交互方式都能被简单地抽象为读和写

比如:如果你想 修改串口波特率控制 LED 亮灭 ,或者我们本文的 清空缓冲区操作 ,这些操作既不是读数据,也不是写数据,而是控制指令

如果为每一种设备的每一种控制功能都设计一个专门的系统调用,内核的接口将会爆炸式增长并且极难维护。因此, ioctl 这个系统调用出现了,它专门用于处理所有无法被标准的读写操作覆盖的设备特定操作

1.1.1 用户态中的ioctl

在编写应用程序时,ioctl 的接口定义在 <sys/ioctl.h> 中,函数原型如下:

c 复制代码
#include <sys/ioctl.h>
​
int ioctl(int fd, unsigned long request, ...);
  • 第一个参数是文件描述符 fd,这不用多说。
  • 第二个参数 request 是一个命令码,从他的数据类型就能看出来它是一个 无符号长整形 的数,用来告诉内核驱动程序我们要执行什么操作,request 通常由设备驱动以 的形式提供。
  • 后面是可变参数,名为 arg,这个参数传什么取决于 request ,可以什么都不传,可以传一个整数,也可以传一个指针。
  • 执行成功返回 0,失败返回 -1。

1.1.2 命令码详解

request 表面上是一个无符号长整数,但在 Linux 中,它并不是随便定义的。为了防止不同设备的命令码发生冲突,Linux 规定了命令码的 32 位要 按特定规则划分,携带4个信息,详细内容请看下图:

  • 方向:也就是数据传输方向,表明数据是传给内核,还是从内核读取,又或者是无数据传输。
  • 数据大小:传递的结构体或者变量大小,以字节为单位。
  • 幻数:通常是一个 ASCII 字符(如我们将要使用的 'k'),用来唯一标识某个特定的驱动程序,防止你的命令发给了错误的设备。
  • 序号:当前驱动程序内部操作的序号。

上面这四个信息,都不用我们自己填写,因为内核提供了一套 ,用于标准化地生成命令码,像这样的宏一共有四个:

  • _IO(magic, seq):无数据传输。
  • _IOR(magic, seq, type):从设备读取数据到用户空间,后面的 R 代表读。
  • _IOW(magic, seq, type):向设备写入数据,W 代表写。
  • _IOWR(magic, seq, type):既向设备写入数据,又从设备读取数据,WR 代表读写双向。

其中 magic 是幻数,seq 是序号,type 是数据类型。

1.1.3 驱动程序中的实现

老话说得好:光说不练假把式。了解到这个地步,我们就该看看驱动程序中具体怎么操作了。

驱动代码中,我们定义了下面命令:

c 复制代码
#define MYDEV_MAGIC 'k'  //幻数,随便选个字符都行,这里选的'k'
#define MYDEV_IOC_RESET  _IO(MYDEV_MAGIC,0)           //清空缓冲区操作
#define MYDEV_IOC_GET_SIZE  _IOR(MYDEV_MAGIC, 1, int) //获取数据大小操作
  • 第一个宏:我们先定义好 幻数,后面传进去,标准步骤,没什么好讲的。
  • 第二个宏:定义 清空缓冲区 操作,这个操作不需要进行数据传输,因此使用 _IO(magic, seq) 这个宏,后面函数中会具体实现清空缓冲区的操作。
  • 第三个宏:定义 获取数据大小 的操作,这个操作我们需要从内核中读取内容,因此使用 _IOR(magic, seq, type) 这个宏。

这里,我们只用了两个宏,其他两个宏相信大家也能类比推理出它们的用法。

此外,在 unlocked_ioctl 回调函数中,可以使用 _IOC_TYPE(cmd)_IOC_NR(cmd) 提取出 幻数序列号,进行合法性校验,具体操作如下:

c 复制代码
//检查命令是否属于我们的设备
if(_IOC_TYPE(cmd) != MYDEV_MAGIC)
{
    return -ENOTTY;
}
​
//检查序列号是否有效
if(_IOC_NR(cmd) > 1)
{
    return -ENOTTY;
}

cmd 是内核根据用户程序的操作传入 unlocked_ioctl 回调函数的,这里有两个合法性检查,分别是检查设备类型和检查序列号:

  • _IOC_TYPE(cmd) 会提取出该命令对应的幻数,将这个幻数与我们定义的幻数比较,如果不相同说明发生了错误。
  • _IOC_NR(cmd)会提取出该命令对应的序列号。前面我忘了讲了,我们在宏定义时,为了保证 每个操作对应的序列号不同,因此,我们将序列号从 0 开始,每个命令递增 1。由于我们只有两个操作,所以序列号最大就到 1,如果提取出的序列号大于 1,那就说明发生了错误。

关于 ioctl,还有最后一点要讲。

当应用层发起调用 ioctl(fd, MYDEV_IOC_GET_SIZE, &size) 时,&size 的指针会被转化为 unsigned long arg 传递到内核态我们定义的 my_cdev_ioctl 函数中,我们要特别注意:内核绝对不能直接解引用用户态空间的指针,必须使用 copy_to_usercopy_from_user 进行安全拷贝:

c 复制代码
//将内核中的size变量,拷贝到用户空间arg指向的内存中
if(copy_to_user((int __user*)arg, &size, sizeof(int))) 
{
    return -EFAULT;
}

1.2 并发控制与mutex的细节

1.2.1 并发导致的后果

在 Linux 系统中,设备文件 /dev/my_cdev_device 可以被多个进程同时 open 并操作。假设 没有 互斥锁:

  • 进程 A 调用 read,读取了一半数据,此时 CPU 调度切换。
  • 进程 B 恰好调用了 ioctl 发送了 MYDEV_IOC_RESET 指令,执行了 memset(dev->buffer, 0, BUFFER_SIZE),并将 data_size0
  • 进程 A 恢复执行,继续从原来的 offset 往后读,此时不仅读到了全是 0 的脏数据,甚至可能因为 data_size 突变而引发内存越界访问。

1.2.2 mutex的生命周期

值得一提的是,在老版本的 Linux 内核中,这个回调函数就叫做 ioctl,并且它受到 大内核锁 BKL 的保护,如下图,Linux 内核版本 v2.6.0 的 file_operations

但现代内核为了提高并发性能去掉了 BKL,回调函数改名为 unlocked_ioctl这意味着,我们必须自己负责并发安全的加锁逻辑。 再看我现在使用的 v5.10.198 版本:

为了解决并发问题,我们在 struct my_cdev 中引入了 struct mutex lock

需要进行以下操作:

  • my_cdev_init 中必须使用 mutex_init(&my_cdev.lock) 初始化。
  • my_cdev_exit 和错误处理的 goto 分支中,使用 mutex_destroy(&my_cdev.lock) 释放资源。

1.2.3 高频踩坑点

互斥锁最容易出错的地方不在于加锁,而在于 解锁的时机 。一旦在某个分支忘记解锁,后续所有的读写进程都会被永远挂起,造成 死锁

请看 my_cdev_read 中的部分内容:

c 复制代码
mutex_lock(&dev->lock); //进入临界区,加锁
​
//读到文件末尾,return之前,必须unlock
if(*offset >= dev->data_size) {
    mutex_unlock(&dev->lock); 
    return 0; 
}
​
len = min(count, (size_t)((dev->data_size)-(*offset)));
​
//拷贝给用户态发生缺页或内存错误,return之前必须unlock
if(copy_to_user(buf, (dev->buffer) + (*offset), len)) {
    mutex_unlock(&dev->lock);
    return -EFAULT;
}
​
*offset += len;
dev->read_count++;
​
//正常退出之前unlock
mutex_unlock(&dev->lock);

总结成一句话:在编写内核驱动时,凡是处于 mutex_lock 之后的 return 语句,都必须仔细查看是否有对应的 mutex_unlock


2. unlocked_ioctl回调函数实现

我们还需要在 unlocked_ioctl 回调函数中实现前面两个宏的具体操作,并绑定到 file_operations 结构体上:

c 复制代码
static long my_cdev_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    struct my_cdev *dev = file->private_data;
    int size;
​
    //检查命令是否属于我们的设备
    if(_IOC_TYPE(cmd) != MYDEV_MAGIC)
    {
        return -ENOTTY;
    }
​
    //检查命令号是否有效
    if(_IOC_NR(cmd) > 1)
    {
        return -ENOTTY;
    }
​
    switch(cmd)
    {
        case MYDEV_IOC_RESET://清空缓冲区
            {
                mutex_lock(&dev->lock);
                memset(dev->buffer, 0, BUFFER_SIZE);
                dev->data_size = 0;
                mutex_unlock(&dev->lock);
                printk(KERN_INFO "buffer reset");
                break;
            }
​
        case MYDEV_IOC_GET_SIZE://获取数据大小
            {
                mutex_lock(&dev->lock);
                size = dev->data_size;
                mutex_unlock(&dev->lock);
                
                //把数据大小拷贝给用户程序
                if(copy_to_user((int __user*)arg, &size, sizeof(int)))
                {
                    return -EFAULT;
                }
                break;
            }
​
        default:
            return -ENOTTY;
    }
    return 0;
}
​
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,
    .unlocked_ioctl = my_cdev_ioctl, //别忘了加进来
};
​

检查操作我们上面已经讲过了,两个宏的具体实现我们这里使用了 switch-case 完成,核心就是通过判断 cmd 的类型而执行不同的操作,case 中的处理逻辑其实很简单,最重要的是心中一定要有这个框架,这样才能独立完成代码的编写。

3. 应用程序逻辑分析

为了验证驱动逻辑,我编写了一个严格对应的用户态 C 程序。执行流程如下:

  1. 调用 write 写入 22 字节字符串 "This project is ioctl!"
  2. 调用 ioctl 配合 MYDEV_IOC_GET_SIZE 命令,成功获取到 size = 22
  3. 通过 lseek 将偏移量归零,读出刚刚写入的字符串。
  4. 调用 ioctl 发送 MYDEV_IOC_RESET,内核层在 mutex 锁的保护下将 buffer 清零。
  5. 再次获取大小,发现 size 已经归零,再次 read,返回值为 0。

应用程序的代码基本都是一些简单的文件操作逻辑,没什么可讲的,完整代码和驱动程序代码一起放到文末了。


4. 运行程序

交叉编译好驱动程序,应用程序可以直接在板子上编译,如下:

我们得到了等会要加载的 ioctl.ko 模块和存放着应用程序的文件夹 test_app

我们可以另开一个终端使用下面命令实时查看内核日志状况:

c 复制代码
sudo dmesg -w

然后用下面命令加载模块:

c 复制代码
sudo insmod ioctl.ko

内核日志如下:

然后我们就可以运行应用程序了,看看和我们预测的结果是否相同:

相应的内核日志如下:

可以看到,和我们预测的结果完全相同。

至此,我们的正文就告一段落。


博主有话说: 感谢阅读!希望这篇文章能帮你在学习 Linux 驱动开发时扫清关于 ioctl 和并发控制的障碍。

如果这篇文章对你有所帮助,请不要吝啬你的👍点赞 和⭐收藏。

关注我,后续持续硬核输出更多 Linux 内核与驱动底层原理解析!有问题欢迎在评论区随时探讨交流~

5. 附录

5.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>   //kmalloc
#include <linux/uaccess.h>  //copy_from_user    copy_to_user
#include <linux/string.h>  //memcpy
#include <linux/mutex.h> //互斥锁
​
#define BUFFER_SIZE 1024
​
//定义ioctl命令,要和用户程序保持一致
#define MYDEV_MAGIC 'k'
#define MYDEV_IOC_RESET  _IO(MYDEV_MAGIC,0)  //清空缓冲区
#define MYDEV_IOC_GET_SIZE  _IOR(MYDEV_MAGIC, 1, int) //获取数据大小
​
struct my_cdev{
    dev_t dev_num;
    struct cdev cdev;
    struct class *class;
    struct device *device;
​
    char *buffer;
    size_t data_size;
​
    struct mutex lock; //互斥锁
​
    //统计信息
    unsigned long read_count;
    unsigned long write_count;
};
​
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);
​
    mutex_lock(&dev->lock); //加锁
​
    //如果读到文件末尾,返回0,表示文件结束
    if(*offset >= dev->data_size)
    {
        mutex_unlock(&dev->lock); //退出之前要释放互斥锁
        return 0;
    }
​
    len = min(count,(size_t)((dev->data_size)-(*offset)));
​
    if(copy_to_user(buf, (dev->buffer) + (*offset), len))
    {
        mutex_unlock(&dev->lock);
        return -EFAULT;
    }
    *offset += len;//更新偏移量
    dev->read_count++; //读计数加一
​
    mutex_unlock(&dev->lock);
​
    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);
​
    mutex_lock(&dev->lock);
​
    //偏移量超过缓冲区大小
    if(*offset >= BUFFER_SIZE)
    {
        mutex_unlock(&dev->lock);
        return 0;
    }
​
    len = min(count, (size_t)(BUFFER_SIZE - *offset));
​
    if(copy_from_user((dev->buffer)+(*offset), buf, len))
    {
        mutex_unlock(&dev->lock);
        return -EFAULT;
    }
    *offset += len;
    if(*offset >= dev->data_size)
    {
        dev->data_size = *offset;//更新数据大小
    }
​
    dev->write_count++;//写计数加一
    
    mutex_unlock(&dev->lock);
​
    return len;
}
​
//ioctl函数
static long my_cdev_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    struct my_cdev *dev = file->private_data;
    int size;
​
    //检查命令是否属于我们的设备
    if(_IOC_TYPE(cmd) != MYDEV_MAGIC)
    {
        return -ENOTTY;
    }
​
    //检查命令号是否有效
    if(_IOC_NR(cmd) > 1)
    {
        return -ENOTTY;
    }
​
    switch(cmd)
    {
        case MYDEV_IOC_RESET://清空缓冲区
            {
                mutex_lock(&dev->lock);
                memset(dev->buffer, 0, BUFFER_SIZE);
                dev->data_size = 0;
                mutex_unlock(&dev->lock);
                printk(KERN_INFO "buffer reset");
                break;
            }
​
        case MYDEV_IOC_GET_SIZE://获取数据大小
            {
                mutex_lock(&dev->lock);
                size = dev->data_size;
                mutex_unlock(&dev->lock);
                
                //把数据大小拷贝给用户程序
                if(copy_to_user((int __user*)arg, &size, sizeof(int)))
                {
                    return -EFAULT;
                }
                break;
            }
​
        default:
            return -ENOTTY;
    }
​
    return 0;
}
​
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,
    .unlocked_ioctl = my_cdev_ioctl, //别忘了加进来
};
​
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;
    my_cdev.read_count = 0;
    my_cdev.write_count = 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));
​
    //初始化互斥锁
    mutex_init(&my_cdev.lock);
​
    //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:
    mutex_destroy(&my_cdev.lock);//销毁锁
    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);
    mutex_destroy(&my_cdev.lock);//销毁锁
    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");
​

5.2 应用程序代码

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
​
#define DEV_NAME "/dev/my_cdev_device"
​
//定义ioctl命令,与驱动程序中一致
#define MYDEV_MAGIC 'k'
#define MYDEV_IOC_RESET _IO(MYDEV_MAGIC, 0)
#define MYDEV_IOC_GET_SIZE _IOR(MYDEV_MAGIC, 1, int)
​
int main()
{
    int fd;
    int size;
    char buf[128];
​
    fd = open(DEV_NAME,O_RDWR);//可读可写
    if(fd < 0)
    {
        printf("open failed!\n");
        return -1;
    }
​
    //写入内容
    strcpy(buf,"This project is ioctl!");
    int ret = write(fd,buf,strlen(buf));
    if(ret < 0)
    {
        printf("write failed!\n");
        close(fd);
        return -1;
    }
    printf("写入: %s\n",buf);
​
    //获取数据大小
    ioctl(fd, MYDEV_IOC_GET_SIZE, &size);
    printf("当前数据大小:%d字节\n",size);
​
    //读取数据
    lseek(fd, 0, SEEK_SET);
    memset(buf, 0, sizeof(buf));
    read(fd, buf, sizeof(buf));
    printf("当前内容:%s\n",buf);
​
    //清空缓冲区
    ioctl(fd,MYDEV_IOC_RESET);
    printf("buffet reset\n");
​
    //再次获取数据大小
    ioctl(fd, MYDEV_IOC_GET_SIZE, &size);
    printf("reset 后数据大小:%d\n",size);
​
    //尝试读取,这次应该读不到内容
    lseek(fd, 0, SEEK_SET);
    memset(buf, 0, sizeof(buf));
    int n = read(fd, buf, sizeof(buf));
    printf("reset之后:读取字节数%d \t 读取的内容:%s\n", n, buf);
​
    close(fd);
​
    return 0;
}
​
相关推荐
守望时空332 小时前
使用NetworkManager替换当前网络管理器
linux·运维
进击的cc2 小时前
拒绝背诵!一文带你打穿 Android ANR 发生的底层全链路
android·面试
爱网安的monkey brother2 小时前
Linux自用文档
linux
进击的cc2 小时前
App 启动优化全家桶:别再只盯着 Application 了,热启动优化你真的做对了吗?
android·面试
xlq223222 小时前
30.进程池IPC
linux·运维·服务器
nuomigege2 小时前
beagleboneblack刷入官方IOT镜像后无法运行nodered问题的处理
linux·运维·服务器
huaxiu53 小时前
ubuntu下应用打不开
linux·运维·ubuntu
m0_683124793 小时前
Ubuntu服务设置开机自启
linux·运维·ubuntu
BestOrNothing_20153 小时前
(1)双系统中Ubuntu22.04启动盘制作与启动盘恢复全过程
linux·ubuntu·双系统·启动盘制作·启动盘恢复