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;
}
相关推荐
求索小沈1 小时前
linux 录屏软件安装--obs
linux·运维·服务器
承渊政道2 小时前
Linux系统学习【深入剖析Git的原理和使用(上)】
linux·服务器·git·学习
开开心心就好2 小时前
高效U盘容量检测工具,一键辨真假,防假货
linux·运维·服务器·线性代数·安全·抽象代数·1024程序员节
蓝天居士2 小时前
VMware Workstation挂载共享文件夹(3)
linux·ubuntu
czxyvX2 小时前
001-Linux基本指令(一)
linux
IT布道2 小时前
基于Rocky Linux制作Apache HTTPD 2.4.66 的RPM安装包
linux·运维·apache
RisunJan2 小时前
Linux命令-lsusb(列出系统中所有USB总线以及连接到它们的设备信息)
linux·运维·服务器
wsad05322 小时前
CentOS 7 Minimal 配置静态 IP 完整指南(VMware NAT 模式)
linux·tcp/ip·centos
三万棵雪松3 小时前
【Linux Shell 编程基础学习与实践作业】
linux·运维·网络·学习·嵌入式linux