什么是select

select() 是一个 POSIX 系统调用,用于在多个文件描述符上进行 I/O 多路复用。它可以监控一组文件描述符,检测它们是否准备好进行读取、写入或发生异常,并允许设置超时,避免无限阻塞。

简而言之

select() 的主要功能有

  • 监控一组文件描述符(比如串口、网络套接字等)。

  • 检查哪些文件描述符已准备好进行操作(可读、可写或有异常)。

  • 支持设置超时时间,避免程序无限等待。

之前在linux网络编程中,TCP client中有见到使用select,因为没有对网络编程过于深入,但最近在修改串口读取代码中遇到了一些阻塞读取导致的程序无法正常退出问题,所以来学习一下select具体用法。

函数原型

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数说明

    • nfds:要监控的最大文件描述符值加 1

    • readfds:监控是否有数据可读的文件描述符集合(传入时指定,传出时表示哪些已就绪)。

    • writefds:监控是否可写的文件描述符集合(可以为 NULL)。

    • exceptfds:监控异常的文件描述符集合(可以为 NULL)。

    • timeout超时时间,指向 struct timeval 结构。如果为 NULL,则无限等待

  • 返回值

    • > 0就绪的文件描述符数量。

    • 0:超时,没有文件描述符就绪。

    • -1:出错,errno 会被设置。

  • fd_set 操作宏

    • FD_ZERO(fd_set *set):清空集合。

    • FD_SET(int fd, fd_set *set):将文件描述符 fd 添加到集合。

    • FD_CLR(int fd, fd_set *set):从集合中移除文件描述符 fd。

    • FD_ISSET(int fd, fd_set *set):检查 fd 是否在集合中(用于检查就绪状态),用于检查多个文件描述符中,具体哪个就绪。

  • struct timeval:

struct timeval { 
  time_t tv_sec; // 秒
  suseconds_t tv_usec; // 微秒 
};

具体用法

这里以串口阻塞超时读取为例

int main(){
    // 打开串口
    serial_fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
    if (serial_fd < 0) {
        perror("打开串口失败");
        return -1;
    }

/* ....... 省略了串口配置代码 ........... */

    // select 相关变量
    fd_set readfds;  // 文件描述符添加变量
    struct timeval timeout;  // 时间超时变量
    char buf[1024];  // 串口读取buffer
    int ret;  // select状态

    while (1) {
        // 初始化文件描述符集合
        FD_ZERO(&readfds);  // 1. 首先清空文件描述符集合
        FD_SET(serial_fd, &readfds);  // 2. 添加要监控的文件描述符,这里是串口的文件描述符

        // 3. 设置超时为 1 秒
        timeout.tv_sec = 1;
        timeout.tv_usec = 0;

        // 4. 调用 select 监控串口
        ret = select(serial_fd + 1, &readfds, NULL, NULL, &timeout);
        
        // 5. 检查状态
        if (ret > 0) { // 有数据可读
            if (FD_ISSET(serial_fd, &readfds)) {
                ret = read(serial_fd, buf, sizeof(buf) - 1);
                if (ret > 0) {
                    buf[ret] = '\0'; // 添加字符串终止符
                    printf("收到数据: %s", buf);
                } else {
                    perror("读取失败");
                }
            }
        } else if (ret == 0) { // 超时
            printf("等待数据超时\n");
        } else { // 出错
            perror("select 错误");
            break;
        }

        // 6. 循环
    }
    
    close(serial_fd);
    return 0;
}

注意1

select注重的是监控文件操作符的就绪情况

什么是就绪?

在这个阻塞串口的例子中,该串口配置为规范行输入,即只有接收到回车换行符后,read才能返回数据

正是这个使read返回的状态,称为文件操作符的就绪

而select就是把这种就绪情况捕捉。

注意2

在循环中每次都需要调用

FD_ZERO(&readfds)FD_SET(serial_fd, &readfds)

来初始化文件描述符集合,这是 select() 的工作方式决定的。

select() 会修改传入的文件描述符集合

  • 当你调用 select() 时,传入的 readfds、writefds 和 exceptfds 会被修改。

  • select() 返回后,这些集合只保留就绪的文件描述符,而未就绪的文件描述符会被清除

  • 如果不在循环中重新初始化 readfds,第二次调用 select() 时,集合可能已经不包含你想要监控的 serial_fd,导致无法正确检测。

FD_ZERO 和 FD_SET 的作用

  • FD_ZERO(&readfds):清空整个文件描述符集合,确保没有残留的旧数据。

  • FD_SET(serial_fd, &readfds):将特定的文件描述符(这里是 serial_fd)加入集合,表示你希望监控它。

  • 集合的状态会依赖上一次 select() 的结果,所以每次select前必须重复清空和设置

超时参数也会被修改

  • struct timeval timeout 在 select() 调用后会被修改为剩余时间,所以每次循环也需要重新设置 timeout.tv_sec 和 timeout.tv_usec。

山和山不相遇,人与人要相逢