阻塞和非阻塞 IO 是 Linux 驱动开发里面很常见的两种设备访问模式,在编写驱动的时候一定要考虑到阻塞和非阻塞。本章我们就来学习一下阻塞和非阻塞 IO,以及如何在驱动程序中处理阻塞与非阻塞,如何在驱动程序使用等待队列和 poll 机制。
阻塞和非阻塞简介
这里的“IO”并不是我们学习 STM32 或者其他单片机的时候所说的“GPIO”(也就是引脚)。这里的 IO 指的是 Input/Output,也就是输入/输出,是应用程序对驱动设备的输入/输出操作。当应用程序对设备驱动进行操作的时候,如果不能获取到设备资源,那么阻塞式 IO 就会将应用程序对应的线程挂起,直到设备资源可以获取为止。对于非阻塞 IO,应用程序对应的线程不会挂起,它要么一直轮询等待,直到设备资源可以使用,要么就直接放弃。阻塞式 IO 如图 52.1.1.1所示:
图 52.1.1.1 中应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。非阻塞 IO 如图 52.1.2 所示:
从图 52.1.1.2 可以看出,应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。应用程序可以使用如下所示示例代码来实现阻塞访问:
从示例代码 52.1.1.1 可以看出,对于设备驱动文件的默认读取方式就是阻塞式的,所以我们前面所有的例程测试 APP 都是采用阻塞 IO。
如果应用程序要采用非阻塞的方式来访问驱动设备文件,可以使用如下所示代码:
第 4 行使用 open 函数打开“/dev/xxx_dev”设备文件的时候添加了参数“O_NONBLOCK”,表示以非阻塞方式打开设备,这样从设备中读取数据的时候就是非阻塞方式的了。
阻塞IO的驱动实现机制:等待队列
1、等待队列的队列头
阻塞访问最大的好处就是当设备文件不可操作的时候进程可以进入休眠态,这样可以将CPU 资源让出来。但是,当设备文件可以操作的时候就必须唤醒进程,一般在中断函数里面完成唤醒工作。Linux 内核提供了等待队列(wait queue)来实现阻塞进程的唤醒工作,如果我们要在驱动中使用等待队列,必须创建并初始化一个等待队列头,等待队列头使用结构体wait_queue_head_t 表示,wait_queue_head_t 结构体定义在文件 include/linux/wait.h 中,结构体内容如下所示:
定义好等待队列头以后需要初始化,使用 init_waitqueue_head 函数初始化等待队列头,函数原型如下:
void init_waitqueue_head(wait_queue_head_t *q)
参数 q 就是要初始化的等待队列头。
也可以使用宏 DECLARE_WAIT_QUEUE_HEAD 来一次性完成等待队列头的定义的初始化。
2、等待队列的队列项
等待队列头就是一个等待队列的头部,每个访问设备的进程都是一个队列项,当设备不可用的时候就要将这些进程对应的等待队列项添加到等待队列里面。结构体 wait_queue_t 表示等待队列项,结构体内容如下:
使用宏 DECLARE_WAITQUEUE 定义并初始化一个等待队列项,宏的内容如下:
DECLARE_WAITQUEUE(name, tsk)
name 就是等待队列项的名字,tsk 表示这个等待队列项属于哪个任务(进程),一般设置为current , 在 Linux 内 核 中 current 相 当 于 一 个 全 局 变 量 , 表 示 当 前 进 程 。 因 此 宏DECLARE_WAITQUEUE 就是给当前正在运行的进程创建并初始化了一个等待队列项。
3、将队列项添加/移除等待队列头
当设备不可访问的时候就需要将进程对应的等待队列项添加到前面创建的等待队列头中,只有添加到等待队列头中以后进程才能进入休眠态。当设备可以访问以后再将进程对应的等待队列项从等待队列头中移除即可,等待队列项添加 API 函数如下:
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
函数参数和返回值含义如下:
q:等待队列项要加入的等待队列头。
wait:要加入的等待队列项。
返回值:无。
等待队列项移除 API 函数如下:
void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
函数参数和返回值含义如下:
q:要删除的等待队列项所处的等待队列头。
wait:要删除的等待队列项。
返回值:无。
4、等待唤醒
当设备可以使用的时候就要唤醒进入休眠态的进程,唤醒可以使用如下两个函数:
void wake_up(wait_queue_head_t *q) void wake_up_interruptible(wait_queue_head_t *q)
参数 q 就是要唤醒的等待队列头,这两个函数会将这个等待队列头中的所有进程都唤醒。
wake_up 函数可以唤醒处于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 状态的进程,而 wake_up_interruptible 函数只能唤醒处于 TASK_INTERRUPTIBLE 状态的进程。
5、等待事件
除了主动唤醒以外,也可以设置等待队列等待某个事件,当这个事件满足以后就自动唤醒等待队列中的进程,和等待事件有关的 API 函数如表 52.1.2.1 所示:
非阻塞IO的驱动实现机制:poll 操作函数
如果用户应用程序以非阻塞的方式访问设备,可以通过 select、epoll 或 poll 函数来查询设备是否可以操作,如果可以操作的话就从设备读取或者向设备写入数据。当应用程序调用 select、epoll 或 poll 函数的时候设备驱动程序中的 poll 函数就会执行,因此需要在设备驱动程序中编写 poll 函数。
关于应用程序中使用的 select、poll 和 epoll 这三个函数,参考我的另一篇文章:
Linux-IO多路复用_linux io多路复用方式-CSDN博客
当应用程序调用 select 或 poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations 操作集中的 poll 函数就会执行。所以驱动程序的编写者需要提供对应的 poll 函数,poll 函数原型如下所示:
unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait)
函数参数和返回值含义如下:
filp:要打开的设备文件(文件描述符)。
wait:结构体 poll_table_struct 类型指针,由应用程序传递进来的。一般将此参数传递给poll_wait 函数。
返回值:向应用程序返回设备或者资源状态,可以返回的资源状态如下:
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN,普通数据可读
我们需要在驱动程序的 poll 函数中调用 poll_wait 函数,poll_wait 函数不会引起阻塞,只是将应用程序添加到 poll_table 中,poll_wait 函数原型如下:
void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
参数 wait_address 是要添加到 poll_table 中的等待队列头,参数 p 就是 poll_table,就是file_operations 中 poll 函数的 wait 参数。
使用示例如下所示:
阻塞 IO 实验
在上一章 Linux 中断实验中,我们直接在应用程序中通过 read 函数不断的读取按键状态,当按键有效的时候就打印出按键值。这种方法有个缺点,那就是 imx6uirqApp 这个测试应用程序拥有很高的 CPU 占用率,大家可以在开发板中加载上一章的驱动程序模块 imx6uirq.ko,然后以后台运行模式打开 imx6uirqApp 这个测试软件,命令如下:
./imx6uirqApp /dev/imx6uirq &
测试驱动是否正常工作,如果驱动工作正常的话输入“top”命令查看 imx6uirqApp 这个应用程序的 CPU 使用率,结果如图 52.2.1 所示:
从图 52.2.1 可以看出,imx6uirqApp 这个应用程序的 CPU 使用率竟然高达 99.6%,这仅仅是一个读取按键值的应用程序,这么高的 CPU 使用率显然是有问题的!原因就在于我们是直接在 while 循环中通过 read 函数读取按键值,因此 imx6uirqApp 这个软件会一直运行,一直读取按键值,CPU 使用率肯定就会很高。最好的方法就是在没有有效的按键事件发生的时候,imx6uirqApp 这个应用程序应该处于休眠状态,当有按键事件发生以后 imx6uirqApp 这个应用程序才运行,打印出按键值,这样就会降低 CPU 使用率,本小节我们就使用阻塞 IO 来实现此功能。
也就是说,虽然应用层读写时默认是阻塞的,但是也要驱动层的支持,否则无法阻塞和唤醒。
具体参考正点原子《第五十二章 Linux 阻塞和非阻塞 IO 实验》
使用等待队列实现阻塞访问重点注意两点:
①、将任务或者进程加入到等待队列头,
②、在合适的点唤醒等待队列,一般都是中断处理函数里面。
非阻塞 IO 实验
非阻塞方式需要驱动层poll函数的支持。
具体参考正点原子《第五十二章 Linux 阻塞和非阻塞 IO 实验》
本文不赘述。
对poll非阻塞IO的理解
先理解阻塞与非阻塞。阻塞就是由于工作不能立刻完成,进程放弃cpu,非阻塞就是任务不能立刻完成,函数直接返回或者一直轮询操作。起初想不明白如果对文件的可读进行poll的话,如果没有数据,进程就会睡眠从而引起切换,这不是典型的阻塞吗?
从几个方面来理解下:
1、从read的层面来看,阻塞指的是read时阻塞,不阻塞也是指read时不阻塞,虽然poll时在poll这里阻塞了,但是随后的read并不会阻塞;
2、poll可以执行非阻塞的操作,并不是说不能执行阻塞的操作,poll函数有个timeout,当timeout为0时,就是完全的非阻塞;当timeout为-1时,就是完全的阻塞;当timeout为任意正数的时候,就是有条件地阻塞;
这里有个问题,poll因为是非阻塞的,所以需要在应用层轮询是否有文件描述符准备好,当timeout为0时,是否cpu占用也会很高呢?
有个epoll的说法可以参考:Epoll 的time_out参数引发的cpu占用问题_epoll timeout-CSDN博客
不过,对于poll来说,貌似并不会,参考:poll函数是否消耗CPU - 你的KPI完成了吗 - 博客园
这篇文章里的意思是,如果timeout为0,当循环执行到poll的时候,还没有文件描述符准备好的话,就会引起任务调度?所以不会导致cpu都用在调用poll的while(1)循环里。
具体待实操确定。
3、poll的主要作用是多路IO监测,所以并不需要特别关注它的阻塞性;