Linux 驱动中的 IO 模型

Last edited
Last updated July 11, 2023
Pages
Tags

1. 为什么要在驱动中学习 IO 模型

因为用户空间对硬件进程操作需要基于对硬件对应的设备文件进行读写,对于设备文件的读写情景也各有不同,所以我们需要学习多种不同的 IO 模型。

2. IO 模型有几种

总共四种:非阻塞 IO,阻塞 IO,IO 多路复用,信号驱动 IO
驱动对 IO 模型的支持,简单来说就是需要实现 VFS 对象 file_operations 结构体中对该种 IO 模型相关的方法。

3. 非阻塞 IO

3.1 概述

当我们基于文件描述符进行 read 读取数据时,不关注数据是否准备好, read 函数立即返回

3.2 实现

O_NONBLOCK 以不可阻断的方式打开文件,也就是无论是否有数据读取或等待,都会立即返回进程之中:
/*********** 应用程序 **********/ // 以非阻塞形式打开文件 int fd = open("/dev/mychrdev0", O_RDWR|O_NONBLOCK); // 以非阻塞形式读取设备文件对应的设备的数据 read(fd, buf, sizeof(buf)); /*********** 驱动 ************/ ssize_t mycdev_read(struct file *file, char *ubuf, size_t size, loff_t *lof) { // 条件满足说明打开文件时添加 O_NONBLOCK 这个标志 // 以非阻塞读 if (file->f_flag&O_NONBLOCK) { // 1.直接读取硬件寄存器中的数据 // 2.通过 copy_to_user 将读取到的硬件数据拷贝用户空间 } }

4. 阻塞 IO

4.1 概述

当以阻塞的形式进行硬件数据读取时,如果硬件的数据没有准备好,此时进程阻塞在读取数据的位置,一直到硬件数据准备好了,准备好后成功读取到硬件数据,此时函数返回,程序继续向下执行。如果硬件数据没有准备好,此时进程处于阻塞状态。阻塞状态下的进程处于休眠态,对应进程的休眠态有两种形式:
  • D uninterruptible sleep (usually io)
  • S interruptible sleep (waiting for an event to complete) 能够被信号中断

4.2 实现

/*********** 应用程序 **********/ // 以阻塞形式打开文件 int fd = open("/dev/mychrdev0", O_RDWR); // 以阻塞形式读取设备文件对应的设备的数据 read(fd, buf, sizeof(buf)); /*********** 驱动 ************/ ssize_t mycdev_read(struct file *file, char *ubuf, size_t size, loff_t *lof) { // 条件满足说明打开文件时添加 O_NONBLOCK 这个标志 // 以非阻塞读 if (file->f_flag&O_NONBLOCK) { // 1.直接读取硬件寄存器中的数据 // 2.通过 copy_to_user 将读取到的硬件数据拷贝用户空间 } else { // 以阻塞形式打开文件 // 1. 判断硬件数据是否准备好 // 2. 如果硬件数据没有准备,将进程切换到休眠状态 // 3. 数据准备好了,进程被唤醒 // 4. 读取硬件的数据 // 5. 将硬件的数据通过 copy_to_user 拷贝到用户空间 } }

4.3 在驱动中实现的框图(等待队列)

notion image
会反复扫描链表,直到没有更多进程唤醒,或者唤醒的占用进程数目达到了 nr_exclusive 。(最常使用的 __wake_up_common 函数将 nr_exclusive 设置为1,确保只唤醒一个独占访问的进程)如果进程在等待数据传输的结束,那么唤醒等待队列中所有的进程是有用的。这是因为几个进程的数据可以同时读取,而互不干扰。

4.4 阻塞 IO 相关 API

// 1. 定义灯等待列头 wait_queue_head_t wq_head; // 2. 初始化等待队列 void init_waitqueueu_head(struct wait_queue_head *wq_head); // 参数 wq_head 等待队列头节点指针 // 返回值:无 // 3. 判断 condition 的值,如果为真,则不做任何操作, // 如果为假,将当前进程切换为不可中断休眠状态 wait_event(wq_head, condition) // 参数 : wq_head 等待队列头 // condition 判断数据是否就绪的标志变量 // 4. 判断 condition 的值,如果为真,则不做任何操作, // 如果为假,将当前进程切换为可中断休眠状态 wait_event_interruptible(wq_head, condition) // 参数:wq_head 等待队列头 // condition 判断数据是否就绪的标志变量 // 5. 将等待队列中的不可中断的休眠态进程唤醒, // 当 condition 为假时调用这个函数将进程唤醒, // 被唤醒的进程会立即再次切换为休眠状态 void wake_up(struct wait_queue_head *wq_head) // 参数:wq_head 等待队列头 // 6. 将等待队列中的可中断的休眠态进程唤醒, // 当 condition 为假时调用这个函数将进程唤醒, // 被唤醒的进程会立即再次切换为休眠状态 void wake_up_interruptible(struct wait_queue_head *wq_head) // 参数:wq_head 等待队列头

4.5 模拟阻塞 IO 实例

由于现在没有学习内核中断,先模拟一下硬件数据准备好的过程:进程1以阻塞的形式进行数据读取,进程2调用 write 函数向内核拷贝数据,模拟硬件数据就绪3
notion image
notion image

5. IO 多路复用(select)

5.1 概述

当我们想要在进程中同时监听来自多个硬件的数据时,就需要用到 IO 多路复用。IO 多路复用有三种实现方式:select/poll/epoll
IO 多路复用的基本思想是:在应用程序中将想要监听的事件文件描述符添加到集合中,轮询监听集合中的文件描述符的事件,有一个或者多个事件发生时,进程针对发生的事件进行处理;当集合中的文件描述符对应的事件没有一个发生时,将当前进程切换为休眠状态,直到有事件发生,休眠的进程被唤醒。
select 的局限:
  1. 需要将文件描述符表从用户态拷贝到内核态
  1. 最多监听 1024 个文件描述符事件
select 的优点:
  1. 不同类型的事件(读与写)分开存放,不需要手动判断
poll 的局限:
  1. 需要将文件描述符表从用户态拷贝到内核态
poll 的优点:
  1. 没有最大文件描述符表的限制

5.2 实现过程

/************ 应用程序 *************/ // 1. 打开文件描述符 int fd1, fd2, ret; fd1 = open("/dev/myled0", O_RDWR); // 省略错误检查 fd2 = open("/dev/mouse0", O_RDWR); // 2. 创建事件集合 fd_set readfs; FD_ZERO(&readfs); // 清空集合 // 3. 将要监听的文件描述符添加到集合 FD_SET(fd1, &readfs); FD_SET(fd2, &readfs); ret = select(fd2 + 1, &readfs, NULL, NULL, NULL); if (ret < 0) { printf("select 调用失败\n"); return ret; } if (FD_ISSET(fd1, &readfs)) { memset(buf, 0, sizeof(buf)); read(fd1, buf, sizeof(buf)); printf("自定义事件发生 buf:%s\n", buf); } if (FD_ISSET(fd2, &readfs)) { memset(buf, 0, sizeof(buf)); read(fd2, buf, sizeof(buf)); printf("鼠标事件发生 buf:%s\n", buf); } /*************** VFS ********************/ sys_select() { /** 1. 在内核空间中申请一片内存, 将用户空间的文件描述符表拷贝到当前申请的空间中 2. 针对文件描述符表中的每一个文件描述符 按照 fd->fd_arr[df]->struct_file->f_op->poll 路线调用每个文件描述符对应的驱动中的 poll 方法 3. 有一个变量 mask 用来接收 poll 方法的返回值, 判断每个 poll 方法的返回值,如果每个 poll 方法 的返回值都为 0,表示没有任何事件发生,此时在这里 将进程切换为休眠状态;mask 返回值不为 0 表示有 件事发生,此时将发生事件的文件描述符拷贝到用户空间 的事件集合表中 4. 当进程进入休眠后,收到一个或者多个事件发生的唤醒操作后, 会重新变量文件描述符表,调用 poll 方法,找到发生事件的 文件描述符,将发生事件的文件描述符拷贝到用户空间 */ } /************* 驱动程序 ******************/ __poll_t mycdev_poll(struct file *file, struct poll_table_struct *wait) { // 这个函数赋值给 file_operations.poll // 向上提交等待队列头 void poll_wait(struct file * filp, wait_queue_head_t *wait_address, poll_table *p); // 功能:向上层提交等待队列头 // 参数: // filp:file 结构体纸质,填写 poll 第一个参数 // wait_address:等待队列头节点地址 // p: 驱动层连接上一层的通道 // 根据事件是否发生返回一个合适的返回值 if (condition) { // 当前驱动对应的事件发生 return POLLIN; // 读事件 } else { return 0; // 没有事件发生 } }
大体而言,想要设备文件支持 select ,需要实现其 file_operations 结构体中的 poll 方法,这个方法在底层是通过轮询去实现对设备的检查(VFS中)。

5.3 IO 多路复用实例

同样需要设置一个程序 proc2 去模拟设备数据的准备就绪。
notion image

6. IO 多路复用(epoll)

6.1 概述

被成为当前时代中最好用的 IO 多路复用方式
核心操作:一棵树、一张表以及三个接口
优势:
  1. 直接将文件描述符信息添加到内核
  1. 没有最大文件描述符的限制
  1. 当事件发生,epoll 会通知用户分别是哪几个事件发生

6.2 原理

epoll采用了基于事件驱动的方式,通过内核中的事件表(event table)回调机制来提供高性能的I/O多路复用。它使用了红黑树(Red-Black Tree)哈希表(Hash Table)等数据结构来管理被监视的文件描述符,并能够高效地处理大量并发连接。epoll使用epoll_create创建一个epoll实例,并通过epoll_ctl注册和取消事件。然后,通过调用epoll_wait等待事件就绪,并获取就绪事件的详细信息。
epoll的事件表(event table)通常是通过红黑树(Red-Black Tree)实现的,用于存储和管理被监视的文件描述符及其关联的事件信息。
红黑树是一种自平衡的二叉搜索树,具有以下特性:
  1. 每个节点要么是红色,要么是黑色。
  1. 根节点是黑色的。
  1. 叶子节点(NIL节点或空节点)是黑色的。
  1. 如果一个节点是红色的,则它的子节点必须是黑色的。
  1. 从任一节点到其每个叶子节点的简单路径上,黑色节点的数量是相同的。
epoll中,红黑树通常被用作事件表,用于存储已注册的文件描述符和关注的事件。每个节点表示一个文件描述符及其关联的事件信息。通过红黑树的特性,内核可以高效地进行插入、删除和查找操作。
除了红黑树,epoll的实现还可能涉及其他数据结构,如哈希表(Hash Table),用于快速查找文件描述符的节点。这些数据结构的组合使用使得epoll在处理大量文件描述符时具有较高的性能。
 

6.3 API 接口

#include <sys/epoll.h> int epoll_create(int size);
功能:创建一个 epoll 句柄(创建红黑树的根节点),epoll 把要检测的事件文件描述符挂载到红黑树上
参数:size 没有意义,但是必须 >0
返回值:成功返回根节点对应的文件描述符,失败返回 -1
 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:实现对于 epoll 的控制
参数:
  • epfdepoll_craete 创建的句柄
  • op:控制方式——
    • EPOLL_CTL_ADD:添加要监测的事件文件描述符
    • EPOLL_CTL_MOD:修改epoll检测的事件类型
    • EPOLL_CTL_DEL:将文件描述符从epoll删除
  • fd:要操作的文件描述符
  • event:事件结构体:
typedef union epoll_data { void *ptr; int fd;//使用这个 uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; //EPOLLIN(读) EPOLLOUT(写) epoll_data_t data; /* User data variable */ };
返回值:成功返回 0,失败返回 -1
 
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:阻塞等待准备好的文件描述符
参数:
  • epfdepoll 句柄
  • events:存放就绪事件描述符的结构体数组首地址
  • maxevents:监听的事件最大个数
  • timeout:超时检测
    • > 0:毫秒级检测
    • == 0:立即返回
    • == -1:不关心是否超时
返回值:
  • > 0:准备好的文件描述符的个数
  • == 0:超时
  • < 0:失败

6.4 实例

同 5.3 节的目录,添加了 driver/day06/io_multiplexing/proc1_epoll.c 文件,现象亦同 5.3 节。

7. 信号驱动 IO

7.1 概述

进程注册一个 sigio 信号的信号处理函数,在信号处理函数中完成对于数据的读写,在正常情况下做自己的事情,当数据就绪后内核发送一个信号 sigio 信号给当前的进程,进程完成数据的读写,关于主进程的执行以及信号的发送是一个异步的过程,所以这种方式被成为异步 IO。

7.2 API接口

/*******************应用程序******************/ //1.注册sigio的信号处理函数 //2.回调驱动中的fasync操作方法 int flags=fcntl(fd,F_GETFL); fcntl(fd,F_SETFL,flags|FASYNC);//FASYNC标识被添加后可以调用驱动中的fasync操作方法 //3.设置当前设备文件对应的驱动发送SIGIO信号只发送给当前进程 fcntl(fd,F_SETOWN,getpid()); /*******************驱动程序******************/ int (*fasync) (int, struct file *file, int on){ //1.完成发送信号之前的准备工作 int fasync_helper(int fd, struct file * filp, int on, struct fasync_struct **fapp) } 当硬件数据准备就绪,发送信号: void kill_fasync(struct fasync_struct **fp,SIGIO, POLL_IN) //POLL_IN:表示进行读事件标识

7.3 实例代码