Linux 应用层开发入门(二十二)| poll_select方式读取输入数据

在前两篇文章中,我们已经学习了:如何通过ioctl查询输入设备信息;如何使用阻塞 / 非阻塞方式读取输入事件;但是在真实项目中,几乎不会直接使用while(1)+read轮询。原因很简单:

当系统中有多个文件描述符需要监听时,简单的阻塞或非阻塞 read 已经不够用了。

1 为什么需要IO多路复用?

假设有如下需求:

  • 同时监听键盘输入
  • 同时监听触摸屏
  • 同时监听串口
  • 同时监听网络socket

这些都属于"文件描述符",在Linux中一切皆文件,输入设备、串口、网络连接本质上都是可读写的文件对象。问题在于:如何在一个线程中同时感知它们的状态变化?

① 如果使用阻塞read

cs 复制代码
read(fd1);
read(fd2);
read(fd3);

程序执行到read(fd1)时,如果当前没有键盘输入数据,进程就会进入睡眠状态。此时即便串口或网络socket已经有数据到达,程序也完全不知道,因为它被阻塞在第一个read上。换句话说:

阻塞read只能等待一个文件描述符,无法同时感知多个数据源。

如果想解决这个问题,可能会想到创建多个线程,每个线程分别阻塞在不同的read上。虽然可以实现功能,但会带来新的问题:

  • 线程切换开销
  • 资源消耗增加
  • 程序结构复杂
  • 线程同步问题

对于嵌入式系统或资源受限设备,这种方式并不优雅。

② 如果使用非阻塞read

cs 复制代码
while(1)
{
    read(fd1);
    read(fd2);
    read(fd3);
}

非阻塞模式下,如果没有数据,read会立即返回 -1,因此程序不会被挂起。看起来好像可以同时"检查"多个设备。但这种方式存在严重问题:

  1. CPU 空转:如果当前没有任何设备产生数据,程序会不停循环调用read,形成高频率的系统调用,占用大量CPU时间。系统在做无意义的查询工作,效率极低。
  2. 响应延迟不可控:如果在循环中加入usleep()降低CPU占用,那么又会增加响应延迟,导致事件处理不及时。
  3. 代码不优雅、不可扩展:当监听的文件描述符数量从3个变成30个、300个时,轮询方式会让代码变得臃肿,并且性能迅速下降。

本质上,阻塞read是"只能等一个",非阻塞read是"自己不停问",而我们真正想要的是:

在没有数据时让进程休眠,有数据时立即被唤醒,而且可以同时监听多个文件描述符。

这正是IO多路复用机制解决的问题。

Linux 提供select、poll、epoll等接口,其核心思想是:

  • 由内核统一监视多个文件描述符
  • 当任意一个文件准备好时
  • 内核唤醒进程
  • 应用层再去精确读取数据

这种机制兼具:

  • 阻塞方式的低CPU占用
  • 非阻塞方式的多路监听能力

因此,IO多路复用 是介于"阻塞等待"和"主动轮询"之间的一种高效折中方案,也是现代Linux网络编程和设备监听的基础。

2 什么是IO多路复用?

在实际开发中,程序往往需要同时监听多个文件描述符 ,例如键盘输入、触摸屏设备、串口数据以及网络socket等。如果对每个文件描述符依次调用阻塞式read(),程序只能阻塞在其中一个设备上,无法做到并发监听;如果采用非阻塞方式轮询多个read(),则会导致CPU空转、效率低下,代码结构也不够优雅。

为了解决这个问题,Linux 提出了 IO 多路复用(I/O Multiplexing)机制。其核心思想是:

使用一个线程同时监听多个文件描述符,当其中任意一个文件描述符就绪(可读、可写或发生异常)时,由内核统一通知应用程序,然后再进行实际的读写操作。这样既避免了阻塞等待单一设备的问题,也避免了无意义的轮询,提高了程序效率。

| 接口 | 特点 |
| select | 最早的,多平台支持 |
| poll | 改进版select |

epoll 高性能服务器使用

在Linux中,常见的IO多路复用机制有三种:selectpollepoll。其中,select是最早出现、兼容性最好的方式;poll是对select的改进版本;而epoll则主要用于高并发服务器场景。本节将重点介绍selectpoll的基本原理与使用方法。

3 poll工作原理

poll是Linux提供的一种IO多路复用机制,用于同时监听多个文件描述符的状态变化。

函数原型:

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

参数说明:

  • fds:需要监听的文件描述符数组
  • nfds:数组中元素的个数
  • timeout:超时时间(单位:毫秒)
    • -1:一直等待
    • 0:立即返回(非阻塞)
    • >0:等待指定毫秒数

返回值:

  • >0:有文件描述符准备好
  • =0:超时
  • <0:发生错误

3.1 poll的核心结构

cs 复制代码
struct pollfd {
    int fd;         // 监听的文件描述符
    short events;   // 关心的事件
    short revents;  // 实际发生的事件(由内核填写)
};

使用时,应用层通过events告诉内核自己关心什么事件,内核在返回时把实际发生的事件写入revents

例如监听输入设备时:

cs 复制代码
fds[0].events = POLLIN;

表示:监听该文件描述符是否有数据可读

常见事件类型包括:

  • POLLIN: 有数据可读
  • POLLOUT:可以写数据
  • POLLERR:发生错误
  • POLLHUP:设备断开

poll()返回后,如果:

cs 复制代码
fds[0].revents & POLLIN

成立,就说明该设备已经准备好,可以安全调用read()读取数据。

3.2 poll读取流程

poll的工作过程可以理解为:

bash 复制代码
应用层调用 poll()
        ↓
内核遍历检查所有 fd 的状态
        ↓
如果没有任何 fd 就绪 → 进程进入睡眠
如果有 fd 就绪      → 立即返回
        ↓
应用层再调用 read() 读取数据

这是一种:"先通知,再读写"的事件驱动模型。 与非阻塞轮询相比,poll不会让CPU空转;与阻塞read()相比,它又能同时监听多个文件描述符。因此在需要监听多个输入设备(如键盘、触摸屏、串口、socket)时,poll是一种++结构清晰、效率较高++的解决方案。

4 select工作原理

select是Linux系统中经典的I/O多路复用函数,它的底层工作逻辑可以概括为:

  1. 程序向内核传递需要监听的fd集合(读/写/异常)、最大fd值和超时时间;
  2. 内核阻塞等待,直到:
    • 至少一个监听的fd触发了对应事件(可读/可写/异常);
    • 超时时间到达(即使没有事件触发);
    • 被信号中断。
  3. 内核修改fd集合,只保留触发事件的fd,然后返回给用户态;
  4. 程序遍历修改后的fd集合,判断哪些fd触发了事件,再做对应处理。

函数原型:

cs 复制代码
int select(int maxfd, fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);
参数 含义
maxfd 监听的最大文件描述符值+1(内核会遍历0~maxfd-1的所有fd,需准确设置以减少遍历开销)
readfds 待监听可读事件的fd集合(NULL表示不监听) 可读场景:fd有数据可读、连接建立成功(如 socket accept)、对方关闭连接
writefds 待监听可写事件的fd集合(NULL 表示不监听) 可写场景:fd缓冲区有空闲空间可写数据
exceptfds 待监听异常事件的fd集合(NULL 表示不监听) 异常场景:fd发生带外数据(OOB)等
timeout 超时时间(struct timeval类型),控制select阻塞行为: -NULL:永久阻塞,直到有事件触发; -时间值为0:非阻塞,立即返回; - 时间值 > 0:阻塞指定时长,超时后返回 0

返回值说明:

  • 成功:返回触发事件的fd总数(读 + 写 + 异常);
  • 0:超时时间到达,无任何fd触发事件;
  • -1:出错(如被信号中断、参数错误),同时设置errno

4.1 fd_set集合操作宏

fd_set是系统定义的fd集合类型(本质是位图/整数数组),无法直接操作,必须通过以下宏管理:

作用
FD_ZERO(fd_set *set) 清空fd集合(初始化,必须先调用,否则集合内数据随机)
FD_SET(int fd, fd_set *set) 将指定fd加入集合(标记需要监听的fd)
FD_CLR(int fd, fd_set *set) 将指定fd从集合中移除
FD_ISSET(int fd, fd_set *set) 检查fd是否在集合中(select 返回后,判断该fd是否触发事件),返回非0 表示触发

关键注意点: select返回后,readfds/writefds/exceptfds会被内核修改------只保留触发事件的fd ,因此每次调用select前,都需要重新初始化并设置fd集合(否则会丢失需要监听的fd)

4.2 select 的局限性

  • fd 数量限制fd_set的大小由系统宏FD_SETSIZE定义(通常是1024),无法监听超过1024个 fd;
  • 效率低:每次调用select,内核需要遍历0~maxfd-1的所有fd,fd越多遍历越慢;
  • 重复拷贝:每次调用select,都需要把fd集合从用户态拷贝到内核态,开销大;
  • 需要重新设置集合:select返回后集合被修改,每次调用前必须重新初始化,代码冗余。

5 poll与select的区别

对比项 select poll
文件数量限制 1024 无固定限制
参数形式 位图 数组
效率 较低 更好
可移植性

在嵌入式开发中:

推荐使用 poll

6 为什么要配合O_NONBLOCK?

在多路复用模式下:

cs 复制代码
fd = open(dev, O_RDWR | O_NONBLOCK);

原因:

  • poll/select只负责"通知"
  • 真正读取数据还是read
  • 如果read阻塞会影响逻辑

因此通常:

多路复用 + 非阻塞模式

7 完整流程

复制代码
输入设备产生事件
        ↓
内核缓存事件
        ↓
poll/select 被唤醒
        ↓
应用层 read 读取所有数据

8 poll方式读取输入数据完整代码

cs 复制代码
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>

/*
 * 使用方式:
 *      ./03_input_read_poll /dev/input/event0
 *
 * 功能:
 *      使用 poll 方式读取输入设备数据
 */

int main(int argc, char **argv)
{
    int fd;
    int err;
    int len;
    int ret;
    struct input_id id;
    unsigned int evbit[2];
    struct input_event event;

    struct pollfd fds[1];     // 监听一个文件
    nfds_t nfds = 1;          // 文件数量

    if(argc != 2)
    {
        printf("Usage: %s <dev>\n", argv[0]);
        return -1;
    }

    // 非阻塞打开
    fd = open(argv[1], O_RDWR | O_NONBLOCK);
    if(fd < 0)
    {
        printf("open error\n");
        return -1;
    }

    // 查询设备ID
    err = ioctl(fd, EVIOCGID, &id);
    if(err == 0)
    {
        printf("bustype = 0x%x\n", id.bustype);
        printf("vendor  = 0x%x\n", id.vendor);
        printf("product = 0x%x\n", id.product);
        printf("version = 0x%x\n", id.version);
    }

    while(1)
    {
        // 设置监听对象
        fds[0].fd = fd;
        fds[0].events = POLLIN;   // 监听可读
        fds[0].revents = 0;

        // 等待事件,超时50秒
        ret = poll(fds, nfds, 50000);

        if(ret > 0)
        {
            if(fds[0].revents & POLLIN)
            {
                // 读取所有事件
                while(read(fd, &event, sizeof(event)) == sizeof(event))
                {
                    printf("type=0x%x code=0x%x value=0x%x\n",
                           event.type, event.code, event.value);
                }
            }
        }
        else if(ret == 0)
        {
            printf("time out\n");
        }
        else
        {
            printf("poll error\n");
        }
    }

    return 0;
}

9 select方式读取输入数据完整代码

cs 复制代码
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>

/*
 * 使用方式:
 *      ./04_input_read_select /dev/input/event0
 *
 * 功能:
 *      使用 select 方式读取输入设备数据
 */

int main(int argc, char **argv)
{
    int fd;
    int retval;
    struct input_event event;
    fd_set rfds;
    struct timeval tv;

    if(argc != 2)
    {
        printf("Usage: %s <dev>\n", argv[0]);
        return -1;
    }

    // 非阻塞打开
    fd = open(argv[1], O_RDWR | O_NONBLOCK);
    if(fd < 0)
    {
        printf("open error\n");
        return -1;
    }

    while(1)
    {
        FD_ZERO(&rfds);      // 清空集合
        FD_SET(fd, &rfds);   // 添加监听fd

        tv.tv_sec = 5;       // 5秒超时
        tv.tv_usec = 0;

        // 调用select
        retval = select(fd+1, &rfds, NULL, NULL, &tv);

        if(retval > 0)
        {
            while(read(fd, &event, sizeof(event)) == sizeof(event))
            {
                printf("type=0x%x code=0x%x value=0x%x\n",
                       event.type, event.code, event.value);
            }
        }
        else if(retval == 0)
        {
            printf("time out\n");
        }
        else
        {
            printf("select error\n");
        }
    }

    return 0;
}
相关推荐
AlfredZhao14 小时前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户97183563346620 小时前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪1 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5202 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩2 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言
古城小栈2 天前
Unix 与 Linux 异同小叙
linux·服务器·unix
凡人叶枫2 天前
Effective C++ 条款42:了解 typename 的双重意义
java·linux·服务器·c++
2601_961875242 天前
决战申论100题2026|最新|范文
linux·容器·centos·debian·ssh·fabric·vagrant