Linux Device Drivers-第六章 高级字符驱动操作

开始编写全特性字符设备驱动。然后学习如何与用户空间同步的方法,然后学习如何使进程休眠以及唤醒他们,如何实现非阻塞的I/O,

6.1 ioctl 接口

内核通过file_operations结构中的unlocked_ioctl处理驱动设备来自用户空间 名为ioctl的系统调用请求(俗称I/O通道管理)。通过ioctl,用户可以对设备进行特定配置,如修改波特率、获取设备信息等。ioctl命令编号需在系统范围内保持唯一,以防止错误的设备命令匹配。
ioctl用户态系统调用接口如下:

c 复制代码
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long cmd, ...);

仔细观察,ioctl系统调用的第三个参数并不代表可以有更多数量的参数 ,该参数与第二个参数有关系,其类型会随着第二个参数变化,可能有也可能没有。这个"..."仅用于阻止在编译时的类型检查。

ioctl的驱动方法与用户空间不同,通过查看内核代码*include/linux/fs.h*文件得到(注意书本的定义有点老了,我看的6.6.36版本内核),下面是最新的内核代码:

c 复制代码
long (*unlocked_ioctl) (struct file *, unsigned int cmd, unsigned long arg);//这个才是ioctl的接口
long (*compat_ioctl) (struct file *, unsigned int cmd, unsigned long arg);//为了兼容性保留的老的接口
  1. struct file *filp :仅用于内核,表示一个打开的文件。忘了的话可以去第三章 字符设备驱动程序回忆一下file是啥东西。

    • 作用 :指向内核中代表 "打开的设备文件" 的结构体,相当于用户态 fd(文件描述符)在内核的 "实体分身"。

    • 包含的关键信息

      • filp->f_op:指向驱动的操作函数集(比如 read/write 都在这里)。
      • filp->private_data:驱动可以自由使用的 "私有数据指针",通常在 open 时绑定设备结构体(比如 struct my_device *dev = filp->private_data),方便在 ioctl 中直接操作设备。
      • filp->f_mode:文件的打开模式(读 / 写),可用于检查权限(比如拒绝在只读模式下执行写操作的 cmd)。
    • 通俗理解:相当于用户态传过来的 "设备访问凭证",驱动通过它知道 "是谁在操作设备" 以及 "设备当前的状态"。

  2. unsigned int cmd:"操作命令码"

    • 作用 :用户态传递的 "控制指令",是 ioctl 的 "核心指令",告诉驱动要执行什么操作(比如 "开灯""设置波特率""读取温度")。
    • 特殊之处:不是随便定义的整数,而是按内核规范 "编码" 的 32 位整数,包含三部分信息(通过 _IO/_IOR/_IOW/IOWR宏生成):
      • 类型(type) :8 位,通常是一个字符(比如 'L' 代表 LED 设备),用于区分不同设备的命令。
      • 序号(nr) :8 位,同一设备内的命令编号(比如 0x01 表示 "开灯",0x02 表示 "关灯")。
      • 方向(dir):2 位,指示数据传输方向(无数据 / 从用户到内核 / 从内核到用户 / 双向)。
      • 大小(size):14 位,传递数据的大小(用于检查用户态参数是否合法)。
    • 通俗理解:相当于用户给设备发的 "遥控器按键",每个按键对应一个具体操作,驱动根据按键执行相应逻辑。
  3. unsigned long arg:"命令的附加参数"

    • 作用 :传递与 cmd 相关的参数(可能是整数、指针、结构体等),是命令的 "补充信息"。
    • 常见用法:
      • 如果 cmd 是 "设置波特率",arg 可能是具体的波特率值(比如 9600)。
      • 如果 cmd 是 "读取传感器数据",arg 可能是用户态缓冲区的指针(驱动需要把数据拷贝到这里)。
      • 如果 cmd 是 "配置设备参数",arg 可能是用户态一个结构体的指针(包含多个配置项)。
    • 注意事项 :由于是用户态地址,驱动必须用 copy_from_user/copy_to_user 等函数安全访问(避免直接解引用,防止用户态传非法地址)。
    • 通俗理解 :相当于 "遥控器按键的附加参数",比如按 "调音量" 键时,arg 就是具体要调到的音量值。

上面这两个接口用哪个呢?

答案:用unlocked_ioctl.因为我在编程的时候发现,在我的板子上使用第二个进不去,所以在网上找啊找,发现他俩有区别,看这个博客ioctl,unlocked_ioctl,compat_ioctl之间的区别,总结一下就是unlocked_ioctl是给32位机器上用,而compat_ioctl是给64位机器用,其实compat_ioctl底层还是调用unlocked_ioctl。

6.1.1 选择 ioctl 命令

新手使用cmd的话,可能会从0或者1开始逐渐累加,但是存在一个问题,如果一个驱动程序被错误的用户使用,由于cmd命名一样那就有可能会出故障,所以cmd的命名需要遵守一定规则。ioctl的命令cmd规范在被创立之初仅有两个位段,但是随着发展逐渐完善。但是老的形式的规范在内核中依然存在。下面介绍新的cmd规则:

ioctl cmd编号需在系统范围内保持唯一,以防止错误的设备命令匹配。ioctl cmd 为"32位 "无符号整型,包括nr、type、sizedirection四个字段:

高位 低位
2bit(不同架构可能微调) 14bit 8bit 8bit
direction 方向 size 数据大小 type 幻数 nr 命令序号

direction : 方向,如_IOC_NONE:无数据传输(仅控制)。_IOC_READ:数据从内核→用户空间(读)。_IOC_WRITE:数据从用户空间→内核(写)
size: 系统调用ioctl传递的用户数据大小。
type: "魔数"(也称"幻数"), 8 位的标识(通常用一个字符,如 'k''m'),这个值用以与其它设备驱动程序区别开。
nr: 真正进入switch后区分不同命令的可自定义的序号。

内核有一个文档给出一些推荐的或者已经被使用的幻数。在内核文件中定义如下:./Documentation/userspace-api/ioctl/ioctl-number.rst

你知道了上面的规定,但是用起来好像很麻烦,一个case要定义这个一大堆烦死了,莫慌,内核的include/uapi/asm-generic/ioctl.h文件给了很多类似_IO/_IOR/_IOW/IOWR的宏,帮助你创建符合上述规定的cmd,很方便,比如:

c 复制代码
// include/uapi/asm-generic/ioctl.h
/*
 * Used to create numbers.
 *
 * NOTE: _IOW means userland is writing and kernel is reading. 
 *		 _IOR means userland is reading and kernel is writing.
 */
#define _IO(type,nr)            _IOC(_IOC_NONE,(type),(nr),0) //不需要数据交互
#define _IOR(type,nr,size)      _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size))) 
//size 是用户空间与内核之间传输的数据结构的类型(如 struct my_data)。
//_IOC_TYPECHECK(size) 是一个辅助宏,计算该类型的大小(等价于 sizeof(size)),用于内核后续校验用户空间传递的缓冲区是否足够大。
//例如,若 size 是 struct msg,则 _IOC_TYPECHECK(struct msg) 会返回 sizeof(struct msg)(假设为 32 字节)。
//_IOR 最终调用 _IOC 宏,将上述 4 个参数(方向、type、nr、size)按固定位域打包成一个 32 位整数。
#define _IOW(type,nr,size)      _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size))) //常用
#define _IOWR(type,nr,size)     _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOR_BAD(type,nr,size)  _IOC(_IOC_READ,(type),(nr),sizeof(size))
#define _IOW_BAD(type,nr,size)  _IOC(_IOC_WRITE,(type),(nr),sizeof(size))
#define _IOWR_BAD(type,nr,size) _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))

/* used to decode ioctl numbers.. */
#define _IOC_DIR(nr)            (((nr) >> _IOC_DIRSHIFT) & _IOC_DIRMASK) //提取数据传输方向(无数据、读、写、读写)
#define _IOC_TYPE(nr)           (((nr) >> _IOC_TYPESHIFT) & _IOC_TYPEMASK) // switch前检查幻数是否是当前设备(幻数)
#define _IOC_NR(nr)             (((nr) >> _IOC_NRSHIFT) & _IOC_NRMASK)  //从 ioctl 命令编号nr中提取命令序号
#define _IOC_SIZE(nr)           (((nr) >> _IOC_SIZESHIFT) & _IOC_SIZEMASK)//从 ioctl 命令编号nr中提取数据结构大小

ioctl 使用小例子(不全):

c 复制代码
/*include_cmd.h 头文件,用户态APP*/
#define LED_IOC_MAGIC 0x13 		 //定义幻数,用于与其他字符设备区分
#define LED_MAX_NR    3          //定义命令的最大序数
#define LED_GPRS_MAGIC _IO(LED_IOC_MAGIC,0x00)  //0x00   用"宏+幻数"来自动生成ioctl命令码
#define LED_WIFI_MAGIC _IO(LED_IOC_MAGIC,0x01)  //0x00	
#define LED_BT_MAGIC   _IO(LED_IOC_MAGIC,0x02)  //0x00

/*test.c 用户态APP */
#include <sys/ioctl.h>
#include "include_cmd.h"
fd = open();
ioctl(fd,LED_GPRS_MAGIC ,0);
ioctl(fd,LED_GPRS_MAGIC ,1);
ioctl(fd,LED_WIFI_MAGIC ,0);
ioctl(fd,LED_WIFI_MAGIC ,1);

/*test_ioctl.c 驱动 */
#include "include_cmd.h"
int test_ioctl (struct inode *node, struct file *filp, unsigned int cmd, unsigned long arg)
{
    if(_IOC_TYPE(cmd) != LED_IOC_MAGIC ) return -EINVAL;   //提取出幻数做检验,确认当前设备可用
    if(_IOC_NR(cmd) > LED_MAX_NR ) return -EINVAL;         //提取命令序数,有没有超过最大范围

    switch(cmd){
	case LED_GPRS_MAGIC:
		if(arg==0){
		//..........
		}else if(arg ==1){
		//..........
		}
		break;
    case LED_WIFI_MAGIC:
		//..........
    break;
    }
}

6.1.2. 返回值

返回 -ENIVAL来响应一个无效的 ioctl 命令.

6.1.4. 使用 ioctl 参数

这节说明在进入ioctl的switch前的必要检查,先检查cmd是不是自己的:

c 复制代码
if (_IOC_TYPE(cmd) != SCULL_IOC_MAGIC) //幻数是不是本字符设备的
        return -ENOTTY;
if (_IOC_NR(cmd) > SCULL_IOC_MAXNR) //命令中的nr有没有超范围
        return -ENOTTY;

然后如果有读写操作,就涉及到这个附加参数的使用了,如果是整数还好说,如果是指针就要特殊关照一下。使用access_ok对要传输的地址进行校验,验证指针是否安全,否则对未验证用户空间指针的访问可能导致内核oops

c 复制代码
int access_ok(int type, const void *addr, unsigned long size); 
  • int type:为VERIFY_WRITEVERIFY_READ来指示对用户空间内存区域进行写入还是读取操作。

  • const void *addr:是用户空间地址,

  • unsigned long size第三个参数为字节数。

  • 返回值:很特殊需要注意,该函数返回 1 表示可成功访问,返回 0 表示失败,且失败后通常驱动程序需要返回 -EFAULT 给调用者。

注意下面的代码的有趣之处了吗? _IOC_READ "读"为什么与 VERIFY_WRITE "写"匹配了呢? 请思考一下!

c 复制代码
if (_IOC_DIR(cmd) & _IOC_READ)
        err = !access_ok(VERIFY_WRITE, (void __user *)arg, _IOC_SIZE(cmd));
else if (_IOC_DIR(cmd) & _IOC_WRITE)
        err = !access_ok(VERIFY_READ, (void __user *)arg, _IOC_SIZE(cmd));
if (err)
        return -EFAULT;

因为 _IOC_READ 是针对用户空间而言的,要将一些数据从内核空间搬到用户空间,而 access_ok 函数是面向内核的,表示内核可以在这块用户空间内存进行写入操作。

之后就可以调用copy_from_user,copy_to_user来进行拷贝了,我们也可以用一个对传输少量字节优化过的函数:put_userget_userput_user与get_user函数介绍

6.1.5 兼容性和受限操作

对于驱动中有些值,不同的用户对应不同的权限,有些用户只能查看,特权用户才可以修改,通过如下函数在内核ioctl中进行特权检查:

c 复制代码
int capable(int capability); 

6.1.6. ioctl 命令的实现

然后,参照文档中的指示,给自己的scull增加ioctl。步骤如下:

  1. 在scull_ioctl.h头文件定义使用的魔数,cmd命令有哪些,最大的cmd个数。
  2. 然后在scull.c中增加ioctl方法,注意要进入switch前做好对cmd有效性和地址范围的检查。
  3. 最后在用户空间代码中调用ioctl来使用,查看不同命令的效果。

上面的待归档完善。

6.2 阻塞型I/O接口

这节主要将解什么是阻塞,为什么阻塞,谁阻塞,阻塞的一堆分类。

6.2.1 睡眠的简单介绍

休眠意味着进程被设置为某种不会被调度器调度到CPU上的状态。

规则一:不要在原子上下文进入休眠。(不能在拥有自旋锁,seqlock,或RCU锁时休眠)

规则二:当休眠的进程被唤醒时,该进程不能自行知道休眠了多久。

规则三:只有当我们知道我们会有其他进程能唤醒我们时,才能进入休眠,否则不能休眠。

6.2.2 简单休眠 - 等待队列

等待队列 的本质是"多线程阻塞等待与按需唤醒的高效协同",我有一篇【内核等待队列以及用户态的类似机制】的博客,详细介绍了内核态等待队列用户态等待队列的用法,值得一读。

定义在<linux/wait.h>中的类型为wait_queue_head的结构体。可以使用如下方法定义并初始化一个等待队列头

c 复制代码
DECLEAR_WAIT_QUEUE_HEAD(name);

或者使用动态方法定义并初始化

c 复制代码
wait_queue_head_t my_queue;
int_waitqueue_head(&my_queue);

进程进入休眠的方法:

c 复制代码
wait_event(queue, condition) //不可中断睡眠
wait_enent_interruptible(queue, condition) //**可中断睡眠**,返回 0 表示正常唤醒,-ERESTARTSYS 表示被信号打断

其他线程需要执行如下相关函数唤醒正在休眠的进程:

c 复制代码
void wake_up(wait_queue_head_t *queue); //跟wait_event搭配,完成创建并初始化等待队列项
void wake_up_interruptible(wait_queue_head_t *queue);//跟wait_enent_interruptible搭配

wake_up 唤醒在给定等待队列上的所有进程。
wake_up_interruptible 只会唤醒那些执行wait_event_interruptible可中断休眠的进程。

还有一个函数值得一提 wake_up_process,用于唤醒单个内核线程。与上面的唤醒一整个队列的方法区别开来。

6.2.3 阻塞和非阻塞操作

总结一下讲解了三件事:什么是阻塞,什么是非阻塞,何时用,怎么用。

  • 阻塞:当I/O操作无法立即完成时进入睡眠,等条件满足后被唤醒继续执行。

  • 非阻塞 :进程在操作无法立即满足时立即返回错误(-EAGAIN),不进入睡眠,适用于轮询场景。【在read/write中检查O_NONBLOCK 标志,时刻检查errno防止错判返回值】

  • 给谁用 :给read, write, open 用。

  • 什么时候用:何时用 阻塞 or 非阻塞 ?

    • 需要阻塞操作的例子:适用于同步等待、CPU 敏感的场景,通过休眠机制实现高效资源利用。
    c 复制代码
    例:读取串口数据时,若无数据则阻塞等待,数据到达后立即处理(避免轮询消耗 CPU)。
    例:向打印机缓冲区写入数据时,若无空间则阻塞等待,空间释放后继续写入。
    • 需要非阻塞操作的例子:适用于异步轮询、不可休眠的场景,通过轮询或事件驱动实现并发处理。
    c 复制代码
    例:网络套接字的非阻塞读 / 写(如recv/send配合EAGAIN错误实现多路复用)。
    例:嵌入式系统中,传感器数据采集需在等待数据时处理其他外设事件。

这一章节文字太多,看晕了,我简单总结一下涉及到的知识然后直接看代码如何在驱动中实现阻塞与非阻塞操作:

  • O_NONBLOCK :存储在**struct file *filp**结构体的filp->f_flags里(还记得这个结构体吗?回忆一下,仅用于内核,表示一个打开的文件。忘了的话可以去第三章 字符设备驱动程序的"2.2 file结构"小节回忆一下file是啥结构)。
  • 等待队列(inq/outq):用于进程休眠唤醒的机制。
  • 信号量 :涉及到的API包括sema_init, up, down等,避免临界区资源竞争。
  • 信号处理:进程休眠时被信号唤醒的处理。

下面直接看例子,简单介绍一下这个例子的来历:

  • 例子来自 scullpipe 驱动,是 scull 的一个特殊形式。这个设备驱动使用一个设备结构, 它包含 2 个等待队列和一个缓冲. 缓冲大小是以常用的方法可配置的,设备的结构如下:

    复制代码
    struct scull_pipe {
        wait_queue_head_t inq, outq;  // 读和写的等待队列头:inq(读进程等数据),outq(写进程等空间)
        char *buffer, *end;           // 缓冲区的头和尾(整个缓冲区范围)
        int buffersize;               // 缓冲区大小
        char *rp, *wp;                // 读指针(rp)和写指针(wp)
        int nreaders, nwriters;       // 读写进程数量
        struct semaphore sem;         // 信号量(锁):防止多进程抢缓冲区
        struct cdev cdev;  	/* Char device structure */
    };

设备驱动中实现了个scull_p_read 函数。该函数实现了从缓冲区读数据,没数据就阻塞等,有数据就返回,支持非阻塞。其实6.2.4节就是这个例子的实现。这段在我的博客"内核等待队列以及用户态的类似机制*"中有详细的介绍,非常值得一看。

6.2.4 一个阻塞 I/O 的例子

这段在我的博客"内核等待队列以及用户态的类似机制*"中有详细的介绍,非常值得一看。在这只列出代码:

c 复制代码
struct scull_pipe
{
        wait_queue_head_t inq, outq; /* read and write queues */
        char *buffer, *end; /* begin of buf, end of buf */
        int buffersize; /* used in pointer arithmetic */
        char *rp, *wp; /* where to read, where to write */
        int nreaders, nwriters; /* number of openings for r/w */
        struct fasync_struct *async_queue; /* asynchronous readers */
        struct semaphore sem;  /* mutual exclusion semaphore */
        struct cdev cdev;  /* Char device structure */
};
......

static ssize_t scull_p_read (struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
        struct scull_pipe *dev = filp->private_data;
        
        /* 因为要操作共享资源"读写指针",用信号量避免竞态。*/
        if (down_interruptible(&dev->sem)) 
                return -ERESTARTSYS;

        while (dev->rp == dev->wp) //没东西可读
        { /* nothing to read */
                up(&dev->sem); /* release the lock */
                if (filp->f_flags & O_NONBLOCK) //如果非阻塞直接返回
                        return -EAGAIN;
                PDEBUG("\"%s\" reading: going to sleep\n", current->comm);
                //如果阻塞该线程就睡了,睡觉时会被写接口的wait_up唤醒,
                //被唤醒后先检查期望的条件(dev->rp != dev->wp)是否满足
                //,不满足又会睡去,满足了才会继续往下执行
                if (wait_event_interruptible(dev->inq, (dev->rp != dev->wp))) 
                        return -ERESTARTSYS; 
                //条件满足了,需要先获取锁,避免操作读写指针出现竞态。
                if (down_interruptible(&dev->sem))
                        return -ERESTARTSYS;
        }
        /* ok, data is there, return something */
        if (dev->wp > dev->rp)
                count = min(count, (size_t)(dev->wp - dev->rp));
        else /* the write pointer has wrapped, return data up to dev->end */
                count = min(count, (size_t)(dev->end - dev->rp));
        
        if (copy_to_user(buf, dev->rp, count)) {
                up (&dev->sem); //拷贝失败,返回没成功拷贝的字节数
                return -EFAULT;
        }
        
        // copy_to_user 拷贝成功,读指针后移count字节
        dev->rp += count;
        
        if (dev->rp == dev->end)
                dev->rp = dev->buffer; /* wrapped */
        
        /* 因为要操作共享资源"读写指针",用信号量避免竞态。与最开始的down匹配 */
        up (&dev->sem);

        /* 唤醒 "写等待队列" 的休眠 */
        wake_up_interruptible(&dev->outq);
        PDEBUG("\"%s\" did read %li bytes\n",current->comm, (long)count);
        return count;
}

6.2.5 高级睡眠

在本节,我们要了解一个进程睡眠时到底发生了什么,敬请期待。

6.2.5.1. 一个进程如何睡眠

概述了一下内核等待队列,想要详细了解还得看我上面的博客。通过手动或者调用不推荐的方式修改不推荐的current中存储的当前进程状态,介绍了进程状态分类。set_current_state可以直接操作进程状态。可以这样current->state = TASK_INTERRUPTIBLE; 设置当前进程状态。调用schedule引用调度器让当前进程让出CPU,导致当前进程进入休眠。

6.2.5.2. 手动睡眠

在 Linux 内核的之前的版本,正式睡眠要求程序员使用上面的方法实现,繁琐且易出错。而现在有了更简单 的方式:使用prepare_to_waitfinish_wait替代了6.2.2小节讲的wake_upwait_event,实现了scull_p_write 函数,正常情况你不会看到同一个驱动中的读和写函数混用不同的睡眠方法,在这仅用于展示两种处理睡眠的方式。

第一步:静态的方法创建并初始化一个等待队列项(在我的博客【内核等待队列以及用户态的类似机制】有动态创建等待队列项的方法)

c 复制代码
DEFINE_WAIT(my_wait);

第二步:添加等待队列项到等待头队列。

c 复制代码
void prepare_to_wait(wait_queue_head_t *queue, wait_queue_t *wait, int state); 

第一个参数指向等待队列头,第二个参数指向要入队的等待队列项,第三个参数为进程的新状态一般为TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE

在调用prepare_to_wait后,就可以调用schedule来引用调度器让出CPU,当前进程休眠。

第三步:一旦 schedule 返回, 就需要调用如下特殊函数,将进程重新设置为运行态,并从指定等待队列删除等待描述符项。

c 复制代码
void finish_wait(wait_queue_head_t *queue, wait_queue_t *wait); 

下面我们看个例子来讲上面的内容串起来:

c 复制代码
/* 计算缓冲区逻辑上剩余的空间(不保证物理上连续) */
static int spacefree(struct scull_pipe *dev)
{
	if (dev->rp == dev->wp)
        return dev->buffersize - 1;
    return ((dev->rp + dev->buffersize - dev->wp) % dev->buffersize) - 1;
}

static ssize_t scull_p_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
    // 1. 从文件描述符关联的私有数据中拿到设备结构体(驱动常用技巧)
	struct scull_pipe *dev = filp->private_data;
    int result;
    // 2. 加信号量锁,避免多进程同事写该字符设备时,导致指针混乱。
    if (down_interruptible(&dev->sem)) //可被信号中断(比如用户按 Ctrl+C),中断后返回非0
        return -ERESTARTSYS;
    /* 确保在缓冲中有空间给新的数据, 睡眠直到有空间可用 */
    result = scull_getwritespace(dev, filp);/* scull_getwritespace called up(&dev->sem) */
    if (result)
        return result;
    /* ok, 缓冲区一定有空间,取[用户想写的count]与[缓冲区剩余空间]的最小值 */
    count = min(count, (size_t)spacefree(dev));
    /* 分两种场景,计算"单次写用连续物理空间"与"剩余逻辑空间"的较小值 */
    if (dev->wp >= dev->rp)
        count = min(count, (size_t)(dev->end - dev->wp));/* 缓冲区非绕回,缓冲区是连续线性的,只有最后剩下那块空间单次可用 */
    else
        count = min(count, (size_t)(dev->rp - dev->wp - 1));/* 缓冲区绕回,缓冲区被分成「wp到末尾」和「开头到rp-1」两段,但剩余可用空间连续啊哈哈 */
    PDEBUG("Going to accept %li bytes to %p from %p\n", (long)count, dev->wp, buf);
    
    /* 拷贝用户数据到内核缓冲区(搞半天才到最关键的拷贝操作) */
    if (copy_from_user(dev->wp, buf, count)) {
        up(&dev->sem)
        return -EFAULT;
    }
    dev->wp += count;
    if (dev->wp == dev->end)
        dev->wp = dev->buffer; /* wrapped */
    // 释放信号量:写操作结束,允许其他进程访问缓冲区
    up(&dev->sem);
    /* 唤醒 "读等待队列" */
    wake_up_interruptible(&dev->inq); 
    /* 异步通知:如果有进程通过 SIGIO 异步等待数据,发送信号 */
    if (dev->async_queue)
        kill_fasync(&dev->async_queue, SIGIO, POLL_IN);
    PDEBUG("\"%s\" did write %li bytes\n",current->comm, (long)count);
    return count;
}

!NOTE

要看明白上面代码中的深意,首先要看明白如下内容关于环形缓冲区的内容:

  • rp(Read Pointer):读指针,指向缓冲区中「下一个要读取的数据」的位置。
  • wp(Write Pointer):写指针,指向缓冲区中「下一个要写入数据」的位置。
  • 缓冲区剩余逻辑空间:这部分剩余空间,在物理上不连续,但是在逻辑上连续。
  • 单次写操作可使用的空间:这段空间物理上必须连续(memcpy或copy_from_user只能操作连续内存),这意味着可能小于缓冲区剩余逻辑空间。

为区分 "缓冲区 " 和 "缓冲区" ,环形缓冲区会特意预留一个字节的空闲空间(关键设计,后面所有操作都围绕这个规则):

  • 空状态:rp == wp读写指针重合,没有数据。
  • 满状态:(wp + 1) % buffersize = rp写指针再走一步就追上读指针,剩余一字节空闲。

spacefree函数中, 仅仅是为了计算逻辑可用空间,物理上未必连续:

c 复制代码
static int spacefree(struct stull_pipe *dev) 
{
    if (dev->rp == dev->wp)
        // 情况1:缓冲区空(rp == wp),剩余空间 = 总大小 - 1(留1字节区分空/满)
        return dev->buffersize - 1;
    // 情况2:缓冲区非空,计算剩余空间
    return ((dev->rp + dev->buffersize - dev->wp) % dev->buffersize) - 1;
}

其中(rp + buffersize - wp) % buffersize是为了处理缓冲区绕回的场景:

  • buffersize :为了防止rp小于wp时,计算rpwp的相对距离避免出现负数。

  • buffersize取余 :在rp大于wp时,即绕回情景时,计算rpwp的相对距离。

  • 减1 :始终预留1字节的空闲空间,用来区分缓冲区 和缓冲区

举例(假设 buffersize=10):

  1. 空状态:rp = 3, wp = 3,返回10 - 1 = 9;(能写 9 字节)
  2. 非绕回(rp > wp):rp = 8, wp = 3,返回(8 + 10 - 3)%10 - 1 = 4;(能写 4 字节,写后wp=7,刚好满了)
  3. 绕回(rp < wp):rp = 3, wp = 8,返回(3 + 10 - 8)%10 - 1 = 4;(能写 4 字节,写后wp=2,刚好满了)

scull_getwritespace函数是实现睡眠的函数,直到缓冲中有空间可用。一旦有空间,scull_p_write就可以拷贝用户数据到哪里。函数实现:

c 复制代码
/* 解决缓冲区满时,如何安全等待可用空间的问题。避免写进程在缓冲区满时 "忙等"(浪费 CPU) */
static int scull_getwritespace(struct scull_pipe *dev, struct file *filp)
{
	while (0 == spacefree(dev)) {
		DEFINE_WAIT(cur_wait)
		/* 睡眠前释放信号量,(核心:防止死锁) */
		up(&dev->sem);
         /* 检查文件是否为 NON-BLOCK 非阻塞模式 */
		if (filp->f_flags & O_NONBLOCK)
 			return -EAGAIN; //非阻塞模式直接返回资源暂不可用
        
		PDEBUG("\"%s\" writing: going to sleep\n",current->comm);
		prepare_to_wait(&dev->outq, &cur_wait, TASK_INTERRUPTIBLE); 
		
		if (0 == spacefree(dev)) //(防"唤醒丢失",关键!否则可能永久休眠)
			schedule(); //调度器切到其他进程,当前进程暂停执行。
		/* 进程被唤醒后,从等待队列中清理等待项*/
		finshed_wait(&dev->outq, &cur_wait); 
		
        /* 进程休眠时,若收到信号 Ctrl+C,则下面两行会返回 */
		if (signal_pending(current))
            return -ERESTARTSYS;//内核收到这个错误码后,会重新触发write系统调用, 保证信号中断后操作的连续性。
		/* 获得信号量 */
		if (down_interruptible(&dev->sem))
            return -ERESTARTSYS;
	}
	return 0; // 没问题
}

上面函数就是使用 prepare_to_waitfinshed_waitschedule 实现进程阻塞休眠的关键步骤。关键的几点:

  • 在休眠前必须释放信号量,防止死锁
  • 检查文件是否是阻塞模式,不是直接返回
  • schedule前要再检查空间可用否,防止唤醒丢失。
  • 信号中断处理一下

6.2.5.3. 互斥等待

当某个进程在等待队列上调用wake_up时,所有等待在该队列上的进程都被置为可运行状态。多数情况下如此。

还有时候,我们预先知道只有某个被唤醒的进程可用获得期望的资源,其他被唤醒的进程都要获得处理器,面对资源,狼多肉少,而后多数再次进入休眠。如果等待队列进程数量庞大,那这种行为会严重影响系统性能。

为应对这种"惊群效应",内核开发者增加了"互斥等待"选项,有两点不同:

  • 当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
  • wake_up在一个等待队列上调用,他唤醒第一个带有WQ_FLAG_EXCLUSEVE标志的进程后停止。

最后的结果是内次wake_up调用后,所有的非互斥等待者和第一个互斥等待者被唤醒。将互斥等待者加入等待队列的方法如下,使用时增加WQ_FLAG_EXCLUSEVE 标志位呦:

复制代码
void prepare_to_wait_exclusive(wait_queue_head_t *queue, wait_queue_t *wait, int state);

与上面的函数类似的函数如wake_up_nr,能够唤醒指定个数的互斥等待进程,注意当传递为0时是唤醒所有互斥等待进程。

6.2.6. 测试 scull_pipe 驱动

需要在我前面的博客"Linux Device Drivers-第三章 字符设备驱动程序"的结尾的代码基础上做一下修改。

6.3 poll 和 select 等

poll/select/epoll是干啥的?

非阻塞I/O应用尝试用poll, select, epoll系统调用。他们的本质有相同的功能:他们可以同时盯着多个设备,做到谁有消息就处理谁。【select/poll/epoll对比分析】这篇文章对三个系统调用涉及到哪些方法,怎么使用,有什么特点做了细致的探讨。

举例子:如果你要写一个聊天软件,需要同时监控 "键盘输入"和"网络消息",如果等键盘输入就收不到消息,如果等消息就没法输入键盘。这时候就需要 poll/select/epoll 帮忙:程序只用把 "键盘输入" 和 "网络消息" 的 "设备描述符" 给到 poll/select/epoll,他们呢帮忙盯着这俩设备,等没消息的时候你CPU可以干别的,等有消息了,他会通知你哪个设备有消息了。也可以弄成等待某个设备有消息再唤醒程序。

!NOTE

那为啥有三个(poll/select/epoll)?

不同厂家在历史上同一时期开发出了类似的工具:

  • select 是 BSD 系统先搞的,poll 是 System V 系统同期搞的,功能差不多;
  • epoll 是后来 Linux 2.5.45 版本引入的,专门解决 "盯几千个设备时效率低" 的问题(前两个盯多了会慢)。

驱动如何支持这三个调用?

驱动程序想要支持这三个系统调用,那驱动程序内就得自己实现**poll方法**。程序用 poll/select/epoll 是为了 "同时盯多个设备,谁有动静就处理谁"。但这些系统调用自己不知道设备的状态(比如 "有没有数据能读""能不能写"),必须得问设备的驱动 ------驱动的poll方法 就是专门回答这个问题的接口

include/linux/fs.h中的struct file_operations {中找到poll方法的原型:

复制代码
__poll_t (*poll) (struct file *, poll_table *);

poll方法的两个核心任务?

  1. 告诉系统,"等设备有动静叫我"(poll_wait

    当设备没动静时(比如没数据可读),可能阻塞等待,这时poll方法可能通过poll_wait把设备的等待队列 注册到系统的poll_table里。注意,使用poll_wait需要包含linux/poll.h(该文件会包含eventpoll.h这个头文件,所以可以使用其中定义的掩码位)

  2. 告诉系统 "现在能对设备做什么操作"(返回位掩码)

    poll方法还需要返回一个"位掩码"(一堆二进制bit,每一位bit代表一种状态),用于告诉系统当前状态,当前设备能不能读,额能不能写,有没有错误等。

    比如常见的标志(定义在用户态可用头文件 ./include/uapi/linux/eventpoll.h 里),在 Linux 系统终端里输入 man 2 poll,会详细说明每个掩码的含义(poll/select/epoll 通用)。

    c 复制代码
    // ./include/uapi/linux/eventpoll.h 
    /* Epoll event masks */
    #define EPOLLIN         (__force __poll_t)0x00000001//设备现在有数据,能读,不用等;
    #define EPOLLPRI        (__force __poll_t)0x00000002    
    #define EPOLLOUT        (__force __poll_t)0x00000004//设备现在能写数据,不用等;
    #define EPOLLERR        (__force __poll_t)0x00000008//设备出问题了,读写都会报错;
    #define EPOLLHUP        (__force __poll_t)0x00000010//设备断开了(比如串口拔了),读的时候会读到文件尾。
    #define EPOLLNVAL       (__force __poll_t)0x00000020//文件描述符无效(比如传了个已关的 fd)	程序传错 fd 时触发
    #define EPOLLRDNORM     (__force __poll_t)0x00000040//有普通数据(NORM 联想 "Normal)能读,(和 EPOLLIN 功能重叠),配合 EPOLLIN 一起返回,兼容性更好
    #define EPOLLRDBAND     (__force __poll_t)0x00000080
    #define EPOLLWRNORM     (__force __poll_t)0x00000100//能写普通数据(和 EPOLLOUT 功能重叠)	配合 EPOLLOUT 一起返回,兼容性更好
    #define EPOLLWRBAND     (__force __poll_t)0x00000200
    #define EPOLLMSG        (__force __poll_t)0x00000400
    #define EPOLLRDHUP      (__force __poll_t)0x00002000

    实际开发时,驱动常常组合返回,比如:

    • 设备 "可读":返回 EPOLLIN | EPOLLRDNORM(普通数据能读);
    • 设备 "可写":返回 EPOLLOUT | EPOLLWRNORM(普通数据能写);
    • 设备 "又能读又能写":返回 EPOLLIN | EPOLLRDNORM | EPOLLOUT | EPOLLWRNORM
    • 设备 "出错 + 断开":返回 EPOLLERR | EPOLLHUP

!NOTE

注意,虽然内核态的掩码位都是EPOLLINEPOLLOUT这种带E开头的,而用户态中 man 手册查到的是不带E开头的,EPOLLINPOLLIN对应的都是同一个数值,只是内核和用户态起的不同的宏名而已,不要困惑。

poll描述起来很费劲,但是使用简单多了,看看scullpipepoll实现:

c 复制代码
static unsigned int scull_p_poll(struct file *filp, poll_table *wait)
{
	struct scull_pipe *dev = filp->private->data;
	unsigned int mask = 0;
    
    /* buffer 为环形缓冲区,如果wp在rp后表明有数据可读,如果wp==rp则为空 */
    down(dev->sem);
    poll_wait(filp->inq, wait); // 向poll_table中增加了下面两个等待队列
    poll_wait(filp->outq, wait); 
    if (dev->rp != dev->wp)
        mask = EPOLLIN | EPOLLRDNORM; //可读
    if (spacefree(dev)) //检查有空间可写入
        mask = EPOLLOUT | EPOLLWRNORM; //可写
    up(dev->sem);
    return mask; //返回位掩码
}

这段代码简单的增加两个scullpipe等待队列到poll_table中,然后根据数据的可读或可写状态设置相应的位掩码。但是这段程序没有实现到文件尾的返回状态。

明确一下Linux中文件尾的本质:

对普通文件(.txt)来说,文件尾EOF(end of file)是读到文件最后一个字节后再读的状态,但对设备文件来说(串口 , FIFO, scullpipe),EOF的含义更特殊,他代表:"当前没有数据可读,且未来也不会有数据可读(如写端也全部关闭,或设备断开连接)。"

一个FIFO管道一般支持文件尾部,FIFO进程间通信管道的特点是有"读端和写端"的,当所有写端全部关闭,即使当前FIFO中有数据,等当前的数据被读完后,驱动会返回 0 (EOF),同时 poll方法会返回POLLUP掩码。

6.3.1. poll 与 read 和 write 的协作逻辑

pollselect的调用目的是确定接下来的I/O操作是否会阻塞。从这个角度看,他们是readwrite的补充。

pollselect更重要的目的是可以使应用程序同时等待多个数据流,即使scull的例子里没有用到这一点。

为了应用程序能正常工作,三个调用的正确实现很重要,下面不惜费口舌总结一下啊:

6.3.1.1. 从设备读数据

  • 如果输入缓冲区有数据,即使就绪的数据比程序读请求所需的要少,也应当立马返回。read甚至可以一直返回比所请求数目少的数据。
  • 如果输入缓冲区没数据,默认read必须阻塞等待,直到有一字节数据被写入。若O_NONBLOCK标志被设置,read应立即返回。如果没数据返回值为-EAGAIN,这种情况下poll必须报告设备不可读,直到至少有一个字节到达。
  • 如果已经到达文件尾,read应立刻返回0,无论O_NONBLOCK是否设置,poll应该报告POLLUP

6.3.1.2. 向设备写数据

  • 如果输出缓冲区有空间,write应无延迟立刻返回,可以比请求的少,但至少一字节,这种情况poll报告设备可写。

  • 如果输出缓冲区已满,则write默认阻塞直到有空间被释放,如果O_NONBLOCK标志被设置,write应立刻返回 -EAGAIN,此时poll报告文件不可写,另一方面,如果设备不能再接受任何数据,则write返回-ENOSPC(No space left on device),不管O_NONBLOCK标志是否设置。

  • 无论是否设置 O_NONBLOCK(非阻塞模式),write 函数在返回前都不能主动 "等待数据传送完成"

    !IMPORTANT

    为何write不能主动等待数据发送完成?如何理解?与poll什么关系?

    答:write只是保证数据被放进缓冲区,不等待硬件真正发送完成就返回。因为应用程序调用poll发现可写是期待调用write后能立刻返回,如果write被调用后因为等待发送完成而被阻塞住了,则破坏了pollwrite的合作逻辑。

6.3.1.3. 刷新待处理输出

上面提到,write不能保证数据已经被硬件输出完毕,而fsync可以弥补这一空袭,通过同名系统调用来调用,该方法的原型是:

复制代码
int (*fsync) (struct file *, loff_t, loff_t, int datasync);

6.3.2. 底层的数据结构

pollselect系统调用的实现相当简单,但是epoll略复杂,但基于相同的原理。这部分我找到一篇深入分析的博客:源码解读select/poll内核机制。以及博客:源码解读epoll内核机制。简单总结一下:问了防止这篇文章被下架,我照着敲一遍。

既然学了poll和select, 写个代码,使用poll实现监控多个文件。

复制代码

6.4. 异步通知

解决什么问题?

Linux 设备驱动开发中关于 异步通知(Asynchronous Notification) 的机制,主要用于解决某些应用场景下 poll/select 不够高效的问题

1. 背景问题

  • 阻塞 I/O :进程调用 read() 时,如果没有数据,会阻塞等待。
  • 非阻塞 I/O :进程调用 read() 时,如果没有数据,立即返回 -EAGAIN,进程需要不断轮询。
  • poll/select:进程可以等待多个文件描述符的状态变化,但仍然需要调用这些函数,存在一定的延迟和 CPU 消耗。

问题场景

如果一个进程在执行一个长时间的计算任务(低优先级),但又希望在数据到达时立即处理输入,使用 pollselect 仍然需要周期性检查,浪费 CPU 时间。

2. 异步通知的解决方案

异步通知允许设备在数据准备好时 主动通知应用程序,而不是应用程序去轮询设备。

实现方式:

  • 当设备有新数据时,内核会向应用程序发送一个 信号(SIGIO)
  • 应用程序注册一个信号处理函数,当信号到达时立即执行对应逻辑。

这样,应用程序可以专心做其他事情,而不必频繁调用 pollselect

3. 用户程序需要做的两步

为了让内核知道通知谁 ,以及启用异步通知。用户程序必须:

(1)设置文件的拥有者

c 复制代码
fcntl(fd, F_SETOWN, getpid());
  • F_SETOWN告诉内核,当设备有事件时,应该通知哪个进程(或进程组)。

  • 内核会把这个pid存在filp->f_owner中。

(2)启用FASYNC标志

c 复制代码
oflags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, oflags | FASYNC)
  • FASYNC标志表示该文件描述符支持异步通知。
  • 当设备状态化时,驱动会调用kill_fasync()向拥有者进程发送SIGIO信号。

4. 驱动层的支持

  • 驱动必须实现 fasync 文件操作(file_operations 结构中的 fasync 回调)。
  • 当应用程序设置 FASYNC 时,内核会调用驱动的 fasync() 方法,驱动需要维护一个 fasync_struct 链表,用于后续通知。

当设备有新数据时,驱动调用:

复制代码
kill_fasync(&async_queue, SIGIO, POLL_IN);

这会向注册的进程发送 SIGIO

5. 异步通知的局限性

  • 不是所有设备都支持 ,通常用于 sockettty
  • 如果一个进程对多个文件启用了异步通知,收到 SIGIO无法知道哪个文件有数据 ,仍然需要 poll/select 来确认。

6. 示例代码解释

c 复制代码
signal(SIGIO, &input_handler); /* 注册信号处理函数 */
fcntl(STDIN_FILENO, F_SETOWN, getpid()); /* 设置当前进程为拥有者 */
oflags = fcntl(STDIN_FILENO, F_GETFL);
fcntl(STDIN_FILENO, F_SETFL, oflags | FASYNC); /* 启用异步通知 */
  • stdin 有输入时,内核会发送 SIGIO,调用 input_handler()

总结

异步通知的核心思想是:
让设备主动通知应用程序,而不是应用程序去轮询设备状态。

这在需要快速响应输入、但又不想浪费 CPU 的场景非常有用。

6.5. 定位设备(llseek)

本章要讨论 llseek 方法,对于某些设备来讲,该方法有用且易于实现。

llseek方法实现了lseekllseek系统调用。前面提到了,如果设备操作未定义llseek方法,内核默认通过修改filp->f_ops而执行定位。filp->f_ops是文件的当前读取/写入位置。请注意,未来使lseek系统调用能正确工作,readwrite方法必须通过更新他们收到的偏移量参数来配合。

如果定位操作对应于设备的一个物理操作,可能就需要提供自己的 llseek 方法,在scull的驱动程序中可以看到一个简单的例子:

c 复制代码
loff_t scull_llseek(struct file *filp, loff_t off, int whence)
{
    struct scull_dev *dev = filp->private_data;
    loff_t newpos;
    
    switch(whence) {
        case 0:/*SEEK_SET */
            newpos = off;
            break;
        case 1: /*SEEK_CUR*/
            newpos = filp->f_pos + off;
            break;
        case 2: /*SEEK_END*/
            newpos = dev->size + off;
            break;
        default:
            return -EINVAL;
    }
    if (newpos < 0) return -EINVAL;
    filp->f_pos = newpos;
    return newpos;
}

学习一下 llseek 如何使用的啦,还没到自己实现 llseek 的时候。

相关推荐
轻赚时代2 小时前
音视频 + 图像处理一站式工具箱:AI 辅助高效处理教程
人工智能·经验分享·笔记·创业创新·课程设计
青瓦梦滋2 小时前
Linux线程的同步与互斥
linux·c++
Elivs.Xiang2 小时前
centos9中安装Jenkins
linux·运维·centos·jenkins
橙子也要努力变强2 小时前
信号捕捉的底层机制-内核态和用户态初识
linux·服务器·c++
somi72 小时前
ARM-10-SQLite3 库移植笔记
jvm·笔记·sqlite
j_xxx404_2 小时前
Linux C 语言编译链接全解析:静态库与动态库从原理到实战
linux·运维·服务器·c语言·编辑器
噜噜噜阿鲁~2 小时前
python学习笔记 | 6.3、函数-函数的参数
笔记·python·学习
橙子也要努力变强2 小时前
信号捕捉底层机制-进程与OS
linux·服务器·c++
青瓦梦滋2 小时前
Linux线程
linux·运维·c++