ARM Linux 驱动开发篇:阻塞与非阻塞IO详解(含等待队列+poll机制)--- Ubuntu20.04

🎬 渡水无言个人主页渡水无言

专栏传送门 : 《linux专栏》《嵌入式linux驱动开发》《linux系统移植专栏》

专栏传送门 : 《freertos专栏》 《STM32 HAL库专栏》《linux裸机开发专栏

专栏传送门《产品测评专栏

⭐️流水不争先,争的是滔滔不绝

📚博主简介:第二十届中国研究生电子设计竞赛全国二等奖 |国家奖学金 | 省级三好学生

| 省级优秀毕业生获得者 | csdn新星杯TOP18 | 半导纵横专栏博主 | 211在读研究生

在这里主要分享自己学习的linux嵌入式领域知识;有分享错误或者不足的地方欢迎大佬指导,也欢迎各位大佬互相三连

目录

前言

[一、阻塞和非阻塞 IO 核心概念](#一、阻塞和非阻塞 IO 核心概念)

[二、阻塞 IO 实现核心:等待队列](#二、阻塞 IO 实现核心:等待队列)

[2.1 等待队列头(wait_queue_head_t)](#2.1 等待队列头(wait_queue_head_t))

2.2、等待队列项(wait_queue_t)

2.3、将队列项添加/移除等待队列头

2.4、等待唤醒

2.5、等待事件

[2.6、阻塞 IO 实现步骤(驱动侧)](#2.6、阻塞 IO 实现步骤(驱动侧))

[三、非阻塞 IO 的核心实现:轮询机制](#三、非阻塞 IO 的核心实现:轮询机制)

[3.1 三种轮询 API 对比](#3.1 三种轮询 API 对比)

[3.2、select 函数](#3.2、select 函数)

[3.3、poll 函数](#3.3、poll 函数)

[3.4、epoll 函数(高并发场景)](#3.4、epoll 函数(高并发场景))

[四、Linux 驱动下的 poll 操作函数](#四、Linux 驱动下的 poll 操作函数)

总结


前言

在 Linux 驱动开发中,设备数据的到来往往是随机且不可预知的,如果应用程序一直轮询查询设备状态,会造成极高的 CPU 占用,甚至影响系统运行。因此,合理处理数据的等待与读取方式至关重要,本期博客我们就来学习 Linux 下两种经典的设备访问模式 ------阻塞 IO 与非阻塞 IO,并通过等待队列和 poll 机制实现高效、低功耗的驱动设计。


一、阻塞和非阻塞 IO 核心概念

博客中的 IO(Input/Output) 并非单片机 GPIO 引脚 ,而是应用程序对驱动设备的输入 / 输出操作(如读取按键值、写入数据到外设)。在 Linux 驱动开发中,阻塞和非阻塞 IO 是设备访问的两种核心模式,直接决定驱动的 CPU 占用率与响应效率。

当 应用程序对设备驱动进行操作的时候,如果不能获取到设备资源,那么阻塞式 IO 就会将应用程

序对应的线程挂起,直到设备资源可以获取为止。下图就是阻塞式 IO典型用途:

应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。

等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。

应用程序可以使用如下所示示例代码来实现阻塞访问:

cpp 复制代码
int fd;
int data = 0;

fd = open("/dev/xxx_dev", O_RDWR);    /* 阻塞方式打开 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */

定义文件描述符 fd 和数据变量 data。
调用 open 以读写模式(O_RDWR)打开设备文件 /dev/xxx_dev,默认采用阻塞方式。
调用 read 从设备中读取 sizeof(data) 字节的数据到 data 变量中,并将返回值存入 ret。
对于设备驱动文件的默认读取方式就是阻塞式的。

对于非阻塞 IO,应用程序对应的线程不会挂起,它要么一直轮询等待,直到设备资源可以使用,要么就直接放弃,如下图所示:

应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。

应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。
如果应用程序要采用非阻塞的方式来访问驱动设备文件,可以使用如下所示代码:

cpp 复制代码
int fd;
int data = 0;

fd = open("/dev/xxx_dev", O_RDWR | O_NONBLOCK);    /* 非阻塞方式打开 */
ret = read(fd, &data, sizeof(data));               /* 读取数据 */

第 4 行使用 open 函数打开" /dev/xxx_dev "设备文件的时候添加了参数" O_NONBLOCK ",
表示以非阻塞方式打开设备,这样从设备中读取数据的时候就是非阻塞方式的了。

| 模式 | 触发方式 | 进程状态 | CPU 占用 | 适用场景 |
| 阻塞 IO | 资源就绪自动唤醒 | 休眠态 | 低 | 单设备、低并发数据采集 |

非阻塞 IO 主动轮询 / 事件监听 运行态 多设备、高并发、实时响应

二、阻塞 IO 实现核心:等待队列

阻塞访问最大的好处就是当设备文件不可操作的时候进程可以进入休眠态,这样可以将CPU 资源让出来。但是,当设备文件可以操作的时候就必须唤醒进程,一般在中断函数里面完成唤醒工作。
Linux 内核提供了等待队列 (wait queue) 来实现阻塞进程的唤醒工作。

2.1 等待队列头(wait_queue_head_t)

等待队列的 "容器",管理所有等待该事件的进程。定义于 include/linux/wait.h

cpp 复制代码
struct __wait_queue_head {
    spinlock_t lock;          // 自旋锁,保护队列操作的原子性
    struct list_head task_list; // 等待队列项链表,存储所有休眠进程
};
typedef struct __wait_queue_head wait_queue_head_t;

定义好等待队列头以后需要初始化

cpp 复制代码
动态初始化:void init_waitqueue_head(wait_queue_head_t *q)
静态初始化:DECLARE_WAIT_QUEUE_HEAD(name)(一次性定义并初始化)

2.2、等待队列项(wait_queue_t)

等待队列头就是一个等待队列的头部,每个访问设备的进程都是一个队列项,当设备不可用的时候就要将这些进程对应的等待队列项添加到等待队列里面。

定义于 include/linux/wait.h

cpp 复制代码
struct __wait_queue {
    unsigned int flags;       // 队列项标志
    void *private;            // 私有数据,通常指向当前进程
    wait_queue_func_t func;   // 唤醒函数
    struct list_head task_list; // 挂接到等待队列头的链表节点
};
typedef struct __wait_queue wait_queue_t;

使用宏 DECLARE_WAITQUEUE 定义并初始化一个等待队列项,宏的内容如下:

cpp 复制代码
DECLARE_WAITQUEUE(name, tsk)

name 就是等待队列项的名字。

tsk 表示这个等待队列项属于哪个任务(进程),一般设置为 current(表示当前进程)。

因 此 这个宏就是给当前正在运行的进程创建并初始化了一个等待队列项。

2.3、将队列项添加**/**移除等待队列头

当设备不可访问的时候就需要将进程对应的等待队列项添加到前面创建的等待队列头中, 只有添加到等待队列头中以后进程才能进入休眠态。当设备可以访问以后再将进程对应的等待队列项从等待队列头中移除即可。
等待队列项添加 API 函数如下:

cpp 复制代码
add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)

q:等待队列项要加入的等待队列头。

wait:要加入的等待队列项。

返回值:无。
等待队列项移除 API 函数如下:

cpp 复制代码
remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)

q:要删除的等待队列项所处的等待队列头。

wait:要删除的等待队列项。

返回值:无。

2.4、等待唤醒

当设备可以使用的时候就要唤醒进入休眠态的进程,唤醒可以使用如下两个函数:

cpp 复制代码
void wake_up(wait_queue_head_t *q)
void wake_up_interruptible(wait_queue_head_t *q)

参数 q 就是要唤醒的等待队列头,这两个函数会将这个等待队列头中的所有进程都唤醒。

API 函数 功能 适用场景
wake_up(wait_queue_head_t *q) 唤醒队列中所有可中断 / 不可中断进程 通用唤醒场景
wake_up_interruptible(wait_queue_head_t *q) 仅唤醒可被信号中断的进程 驱动中断 / 事件触发唤醒(推荐)

2.5、等待事件

除了主动唤醒以外,也可以设置等待队列等待某个事件,当这个事件满足以后就自动唤醒等待队列中的进程,和等待事件有关的 API 函数如下表所示:

函数原型 功能说明
wait_event(wq, condition) 等待以 wq 为等待队列头的等待队列被唤醒,前提是 condition 条件必须满足(为真),否则一直阻塞。此函数会将进程设置为 TASK_UNINTERRUPTIBLE 状态
wait_event_timeout(wq, condition, timeout) 功能和 wait_event 类似,但是此函数可以添加超时时间,以 jiffies 为单位。此函数有返回值:如果返回 0 表示超时时间到且 condition 为假;返回 1 表示 condition 为真,即条件满足
wait_event_interruptible(wq, condition) wait_event 函数类似,但是此函数将进程设置为 TASK_INTERRUPTIBLE,即可以被信号打断
wait_event_interruptible_timeout(wq, condition, timeout) wait_event_timeout 函数类似,此函数也将进程设置为 TASK_INTERRUPTIBLE,可以被信号打断

2.6、阻塞 IO 实现步骤(驱动侧)

以按键驱动为例,阻塞 IO 实现需完成 4 步:

设备结构体中添加等待队列头:用于管理按键数据就绪的休眠进程;

初始化等待队列头:在驱动初始化函数中调用 init_waitqueue_head;

读函数中实现阻塞逻辑:无数据时将进程加入等待队列并休眠,有数据时直接读取;

事件触发时唤醒进程:在按键中断 / 定时器处理函数中,判断数据有效后调用 wake_up_interruptible 唤醒。

三、非阻塞 IO 的核心实现:轮询机制

当应用程序以非阻塞方式O_NONBLOCK)访问设备时,无法通过阻塞休眠等待数据,需通过轮询(Polling) 主动查询设备状态。Linux 内核提供了三种经典轮询 API:selectpollepoll,应用程序通过这些 API 监听设备是否可读 / 可写,避免空轮询占用 CPU。

3.1 三种轮询 API 对比

API 最大监听 FD 数 效率 适用场景
select 1024(默认) 随 FD 数量增加效率下降 少量 FD 监听、简单场景
poll 无限制 select,但无 FD 数量限制 中等数量 FD 监听
epoll 无限制 高并发场景下效率极高 大规模并发服务器、网络编程

3.2、select 函数

select 是最基础的轮询 API,通过文件描述符集合(fd_set) 监听读、写、异常三类事件。

函数原型

cpp 复制代码
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

参数与返回值

nfds:监听的三类文件描述符集合中,最大文件描述符 + 1;

readfds/writefds/exceptfds:分别监听读、写、异常事件的文件描述符集合(fd_set 类型);

timeout:超时时间(struct timeval 类型,tv_sec 秒 + tv_usec 微秒),NULL 表示无限等待;

返回值:>0 表示就绪 FD 数量,0 表示超时,-1 表示错误。

比如我们现在要从一个设备文件中读取数据,那么就可以定义一个 fd_set 变量,这个变量要传递给参数 readfds。当我们定义好一个 fd_set 变量以后可以使用如下所示几个宏进行操作:

cpp 复制代码
void FD_ZERO(fd_set *set);       // 清空集合
void FD_SET(int fd, fd_set *set); // 添加 FD 到集合
void FD_CLR(int fd, fd_set *set); // 从集合移除 FD
int  FD_ISSET(int fd, fd_set *set); // 判断 FD 是否就绪

使用 select 函数对某个设备驱动文件进行读非阻塞访问的操作示例:

cpp 复制代码
void main(void)
{
    int ret, fd;
    fd_set readfds;
    struct timeval timeout;

    // 非阻塞方式打开设备
    fd = open("/dev/blockio", O_RDWR | O_NONBLOCK);

    FD_ZERO(&readfds);          // 清空读集合
    FD_SET(fd, &readfds);       // 将设备 FD 加入读集合

    // 设置超时时间:500ms
    timeout.tv_sec = 0;
    timeout.tv_usec = 500000;

    // 轮询监听读事件
    ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
    switch (ret) {
        case 0:  // 超时
            printf("timeout!\r\n");
            break;
        case -1: // 错误
            printf("error!\r\n");
            break;
        default: // 有数据可读
            if (FD_ISSET(fd, &readfds)) {
                int key;
                read(fd, &key, sizeof(key)); // 读取按键值
                printf("key: %d\r\n", key);
            }
            break;
    }
}

3.3、poll 函数

pollselect的改进版,无最大FD数量限制 ,通过struct pollfd数组监听事件,使用更灵活。

函数原型:

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函数参数和返回值含义如下:

fds**:**要监视的文件描述符集合以及要监视的事件,为一个数组,数组元素都是结构体 pollfd

类型的,pollfd 结构体如下所示:

cpp 复制代码
struct pollfd {
    int   fd;      // 要监听的文件描述符
    short events;  // 要监听的事件(如 POLLIN)
    short revents; // 内核返回的就绪事件(由内核填充)
};

可监视的事件类型如下所示:

cpp 复制代码
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN

使用 poll 函数对某个设备驱动文件进行读非阻塞访问的操作示例如下所示

cpp 复制代码
void main(void)
{
    int ret, fd;
    struct pollfd fds;

    // 非阻塞方式打开设备
    fd = open("/dev/blockio", O_RDWR | O_NONBLOCK);

    // 构造 pollfd 结构体
    fds.fd = fd;
    fds.events = POLLIN; // 监听读事件

    // 轮询监听,超时 500ms
    ret = poll(&fds, 1, 500);
    if (ret > 0) { // 数据有效
        if (fds.revents & POLLIN) {
            int key;
            read(fd, &key, sizeof(key));
            printf("key: %d\r\n", key);
        }
    } else if (ret == 0) { // 超时
        printf("timeout!\r\n");
    } else if (ret < 0) { // 错误
        printf("error!\r\n");
    }
}

3.4、epoll 函数(高并发场景)

epoll 是为大规模并发 设计的轮询 API,通过事件通知机制 避免遍历所有 FD,效率远高于 select/poll,常用于网络编程。应用程序需要先使用 epoll_create 函数创建一个 epoll 句柄,epoll_create 函数原型如下:

cpp 复制代码
int epoll_create(int size)

函数参数和返回值含义如下:
size**:**从 Linux2.6.8 开始此参数已经没有意义了,随便填写一个大于 0 的值就可以。

返回值:epoll 句柄,如果为-1 的话表示创建失败。

epoll 句柄创建成功以后。

使用 epoll_ctl 函数向其中添加要监视的文件描述符以及监视的事件,epoll_ctl 函数原型如下所示:

cpp 复制代码
int epoll_ctl(int     epfd, 
              int      op, 
              int      fd,
             struct epoll_event *event)

函数参数和返回值含义如下:

epfd:要操作的 epoll 句柄,也就是使用 epoll_create 函数创建的 epoll 句柄。

op:表示要对 epfd(epoll 句柄)进行的操作,可以设置为:

cpp 复制代码
EPOLL_CTL_ADD 向 epfd 添加文件参数 fd 表示的描述符。
EPOLL_CTL_MOD 修改参数 fd 的 event 事件。
EPOLL_CTL_DEL 从 epfd 中删除 fd 描述符。

fd:要监视的文件描述符。

event**:**要监视的事件类型。为 epoll_event 结构体类型指针。可选的事件如下所示:

cpp 复制代码
EPOLLIN 有数据可以读取。
EPOLLOUT 可以写数据。
EPOLLPRI 有紧急的数据需要读取。
EPOLLERR 指定的文件描述符发生错误。
EPOLLHUP 指定的文件描述符挂起。
EPOLLET 设置 epoll 为边沿触发,默认触发模式为水平触发。
EPOLLONESHOT 一次性的监视,当监视完成以后还需要再次监视某个 fd,那么就需要将
fd 重新添加到 epoll 里面。

注意:
上面这些事件可以进行"或"操作,也就是说可以设置监视多个事件。
返回值 : 0 ,成功; -1 ,失败,并且设置 errno 的值为相应的错误码。

一切都设置好以后应用程序就可以通过 epoll_wait 函数来等待事件的发生,类似 select 函数。epoll_wait 函数原型如下所示:

cpp 复制代码
int epoll_wait( int                    epfd, 
                struct epoll_event     *events,
                int                    maxevents, 
                int                    timeout)

函数参数和返回值含义如下:

epfd**:**要等待的 epoll。

events**:**指向 epoll_event 结构体的数组,当有事件发生的时候 Linux 内核会填写 events,调用者可以根据 events 判断发生了哪些事件。

maxevents:events 数组大小,必须大于 0。

timeout**:**超时时间,单位为 ms。

**返回值:**0,超时;-1,错误;其他值,准备就绪的文件描述符数量。

四、Linux驱动下的poll****操作函数

当应用程序调用 select/poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序

file_operations 操作集中的 poll 函数就会执行。所以驱动程序的编写者需要提供对应的 poll 函数。
poll 函数原型如下所示:

cpp 复制代码
unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait)

参数与返回值

filp:要打开的设备文件;

wait:poll 表格,需传递给 poll_wait 函数;

返回值:设备就绪事件掩码(如 POLLIN 表示可读)。可返回的资源如下:
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN ,普通数据可读

我们需要在驱动程序的 poll 函数中调用 poll_wait 函数,poll_wait 函数不会引起阻塞,只是将应用程序添加到 poll_table 中,poll_wait 函数原型如下:

cpp 复制代码
void poll_wait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p);

功能:将应用程序进程加入等待队列(不阻塞),仅完成监听注册;

注意:poll_wait 不会返回就绪事件,需手动检查设备状态并返回事件掩码。


总结

本文详细讲解了 Linux 驱动开发中阻塞与非阻塞 IO 的核心概念、实现机制及实际应用。

相关推荐
kyle~3 小时前
Linux---nmcli (NetworkManager服务的核心命令行工具)
linux·运维·php
不愿透露姓名的大鹏3 小时前
VMware vcenter报错no healthy upstream
linux·运维·服务器·vmware
胡楚昊3 小时前
Polar PWN (4)
linux·运维·算法
nangonghen3 小时前
centos 7.9安装hiclaw
linux·运维·centos
-凌凌漆-3 小时前
【C语言】大小端判断
linux·c语言·算法
Op_chaos3 小时前
Ubuntu 22.04 安装 Bazel,解决GPG密钥导入失败问题
linux·ubuntu
linux修理工3 小时前
armbian 安装openclaw
linux·运维·服务器
学电子她就能回来吗3 小时前
liunx嵌入式基础:socket通信
linux·运维·服务器·人工智能·单片机·嵌入式硬件·学习
风曦Kisaki3 小时前
# Linux进阶Day06:scp远程拷贝、源码编译安装、rsync同步、inotify+rsync实时同步
linux·运维·服务器