
1. 从阻塞到并发为什么文件IO最终都会走到I/O多路复用这一步刚接触嵌入式Linux文件IO编程的人几乎都会经历同一条路径先学会open、read、write、close这几个最基本的系统调用然后写一个读写文件或者串口的小程序跑通了觉得不过如此。接着开始碰网络编程写一个TCP服务端发现accept之后如果只用一个线程处理一个连接第二个客户端连上来就没人理了。于是要么上多线程要么上多进程问题看似解决了但资源开销和上下文切换的代价在嵌入式设备上很快就暴露出来——内存紧张、CPU主频不高、线程栈本身就要吃掉不少RAM。这时候I/O多路复用就登场了。它要解决的核心问题非常明确单个线程如何同时监控多个文件描述符并在其中任意一个或几个变为“就绪”状态时得到通知从而只处理真正有数据的那些描述符。注意这里的关键词是“就绪”不是“读取”。多路复用本身不搬运数据它只负责告诉你“现在可以读了”或者“现在可以写了”真正的read/write还是你自己来。在嵌入式场景下这个机制的价值被进一步放大。嵌入式设备往往需要同时处理串口、网络、GPIO中断上报、传感器数据采集等多个IO源如果每个IO源开一个线程线程间的同步和资源竞争会让代码迅速变得难以维护。而select、poll、epoll这三套接口恰好提供了一种“事件驱动”的编程范式让一个主循环就能把多个IO源管得井井有条。这篇文章我会把这三套接口从原理到实操完整拆一遍重点讲清楚它们各自的数据结构、内核行为、性能差异以及在嵌入式Linux下怎么选、怎么写、怎么避坑。如果你正在做嵌入式网关、数据采集器、串口服务器这类需要多路IO并发的项目这篇内容应该能帮你少走不少弯路。2. 三套接口的底层逻辑与选型考量2.1 select最古老也最通用的方案select的接口长这样int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);它的核心是fd_set这个位图结构。在大多数实现里fd_set是一个固定大小的数组每一位对应一个文件描述符。比如在32位系统上fd_set通常是1024位也就是最多能监控1024个描述符这个限制由FD_SETSIZE宏定义。使用select的典型流程是先把关心的描述符用FD_SET加进集合调用select阻塞等待返回后遍历集合用FD_ISSET判断哪个就绪。这里有个非常容易踩的坑select返回后传入的fd_set会被内核修改只保留就绪的描述符。也就是说如果你要在循环里反复调用select每次调用前都必须重新用FD_SET把集合构建一遍。我见过不少初学者写的代码第一次循环正常第二次就再也检测不到事件了原因就是忘了重新填充集合。另一个坑是nfds参数。它不是你监控的描述符个数而是最大描述符值加一。比如你监控的描述符是3、5、8那nfds应该传9。这个参数如果传错内核可能扫描不到你关心的某些描述符。select的优点是跨平台性极好几乎所有的Unix-like系统和Windows都支持代码可移植性强。缺点是三个描述符数量受限、每次调用都要把整个集合从用户态拷贝到内核态、返回后需要线性遍历所有描述符来判断就绪状态。在描述符数量少、调用频率不高的场景下这些缺点可以忽略但在嵌入式设备上如果监控几十个描述符且事件频繁select的O(n)遍历开销就会变得明显。2.2 poll去掉数量限制的改进版poll的接口是这样的int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; short events; short revents; };相比selectpoll用了一个pollfd数组来代替位图每个元素包含描述符、关心的事件和返回的事件。这样就没有了1024的数量限制理论上能监控多少描述符只取决于内存。而且events和revents是分开的每次调用不需要重新构建整个集合内核只修改revents字段events保持不变这一点比select友好很多。poll支持的事件类型也更丰富除了POLLIN、POLLOUT还有POLLPRI紧急数据、POLLERR、POLLHUP、POLLNVAL等。其中POLLERR、POLLHUP、POLLNVAL是内核自动设置的不需要你在events里指定但返回时一定要检查否则可能漏掉错误状态导致死循环。poll的缺点和select类似每次调用仍然需要把整个pollfd数组从用户态拷贝到内核态返回后仍然需要遍历数组判断revents。所以它的时间复杂度还是O(n)只是常数比select小一些且没有数量上限。在嵌入式Linux下如果描述符数量在几十到一两百这个量级poll是一个非常稳妥的选择代码比select清晰兼容性也好。2.3 epoll为高并发而生的现代方案epoll是Linux特有的机制接口由三个函数组成int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll的设计思路和select/poll完全不同。它把“注册关心的描述符”和“等待事件”这两件事拆开了。epoll_ctl负责增删改监控的描述符内核会在内部维护一棵红黑树来管理这些描述符epoll_wait只负责等待和返回就绪的事件不需要每次都传入全部描述符。这个设计带来的好处是每次epoll_wait不需要把全部描述符从用户态拷贝到内核态因为描述符已经在epoll_ctl阶段注册到内核了。返回时内核只把就绪的事件填充到events数组里用户程序直接遍历这个数组即可数组长度就是就绪事件的数量不需要遍历全部监控的描述符。所以epoll的时间复杂度是O(1)准确说是O(就绪数量)在描述符数量大、活跃连接比例低的场景下优势极其明显。epoll支持两种触发模式水平触发LT和边缘触发ET。水平触发是默认模式只要描述符还有数据可读每次epoll_wait都会返回它边缘触发则只在状态发生变化时通知一次如果你没把数据读完后续不会再通知。ET模式配合非阻塞IO可以减少epoll_wait的调用次数提高效率但编程难度更高稍不注意就会丢数据或卡死。嵌入式场景下我一般建议先用LT模式把逻辑跑通确认稳定后再考虑是否切换到ET。2.4 三套接口的对比与选型建议特性selectpollepoll描述符数量限制有通常1024无无每次调用是否拷贝全部描述符是是否返回后遍历方式遍历全部遍历全部只遍历就绪时间复杂度O(n)O(n)O(就绪数)触发模式LTLTLT/ET跨平台性好较好Linux特有嵌入式适用场景描述符少、需移植中等数量、代码清晰大量描述符、高并发选型上我的经验是如果项目只在Linux上跑描述符数量可能超过几十个直接上epoll如果需要兼顾可移植性或者描述符就几个poll比select更值得用因为代码更干净select现在基本只在维护老代码或者需要跨平台到非Linux系统时才会选。3. 核心细节解析与实操要点3.1 文件描述符就绪到底意味着什么很多人对“就绪”的理解是模糊的。对于读操作描述符就绪意味着一次read调用不会被阻塞但不代表read一定能读到你要的那么多字节。比如一个TCP连接内核接收缓冲区里有10个字节你read的时候传了100字节的缓冲区read会立刻返回10而不是等满100。对于写操作描述符就绪意味着内核发送缓冲区有空间可写但能写多少也不确定。这个细节直接影响到编程模型。如果你用阻塞IO配合多路复用那么在一次事件通知后你只能保证“读一次或写一次不会阻塞”不能保证“读完所有数据”。所以正确的做法通常是把描述符设置为非阻塞然后在事件通知后循环读写直到返回EAGAIN或EWOULDBLOCK。这一点在ET模式下是必须的在LT模式下虽然不强制但为了效率也推荐这么做。设置非阻塞的方法是在open时加O_NONBLOCK标志或者用fcntl动态设置int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);嵌入式Linux下串口、socket、管道都支持非阻塞模式但要注意某些字符设备驱动可能对非阻塞支持不完善需要实测确认。3.2 epoll的事件结构体与数据传递epoll_event结构体里有一个data联合体通常用来存放文件描述符或者指针struct epoll_event { uint32_t events; epoll_data_t data; }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;这个data字段是epoll非常实用的一个设计。你可以把描述符存进去epoll_wait返回后直接从events[i].data.fd拿到描述符不需要额外维护映射关系。如果描述符和某个上下文结构体关联也可以把结构体指针存进data.ptr这样事件到来时直接就能拿到完整上下文省去了一次查找。我在做串口服务器时就是把每个串口的配置结构体指针存进data.ptr事件一到直接操作对应结构体代码非常简洁。3.3 超时参数的设置技巧三套接口都有超时参数select用struct timevalpoll和epoll_wait用毫秒整数。超时设置有几个实践要点传NULL或-1表示永久阻塞适合纯事件驱动的场景但嵌入式设备上如果没有任何事件主循环就完全停住了可能影响看门狗喂狗或其他周期性任务。传0表示立即返回适合轮询模式但会占满CPU一般不推荐。传一个合理的小值比如100毫秒或500毫秒既能及时响应事件又能让主循环有机会处理其他周期性任务。这是嵌入式场景下我最常用的方式。需要注意的是select的timeout在返回后会被修改为剩余时间如果要在循环里复用同一个timeval结构每次都得重新赋值。poll和epoll_wait的timeout是值传递不会被修改用起来更省心。3.4 信号中断与EINTR处理在嵌入式Linux下如果程序注册了信号处理函数select、poll、epoll_wait可能会被信号中断返回-1并设置errno为EINTR。这不是错误只是系统调用被打断了。正确的处理方式是判断errno EINTR后重新调用而不是当成致命错误退出。int ret; do { ret epoll_wait(epfd, events, MAX_EVENTS, timeout); } while (ret 0 errno EINTR);这个细节在嵌入式设备上尤其重要因为很多设备会用SIGALRM做定时器或者用SIGTERM做优雅退出信号中断是常态。我见过有人的程序跑一段时间就莫名退出排查半天发现就是没处理EINTR。4. 实操过程与核心环节实现4.1 用poll实现一个多路串口采集程序假设我们要做一个嵌入式数据采集器同时监控4个串口把收到的数据转发到网络。用poll实现的主循环大概是这样#include poll.h #include fcntl.h #include unistd.h #include stdio.h #include string.h #include errno.h #define MAX_PORTS 4 #define BUF_SIZE 256 int main(void) { int fds[MAX_PORTS]; struct pollfd pfd[MAX_PORTS]; char buf[BUF_SIZE]; int i, ret; /* 打开4个串口设置为非阻塞 */ for (i 0; i MAX_PORTS; i) { char path[32]; snprintf(path, sizeof(path), /dev/ttyS%d, i); fds[i] open(path, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fds[i] 0) { perror(open serial); return -1; } pfd[i].fd fds[i]; pfd[i].events POLLIN; pfd[i].revents 0; } while (1) { ret poll(pfd, MAX_PORTS, 500); if (ret 0) { if (errno EINTR) continue; perror(poll); break; } if (ret 0) continue; /* 超时处理其他周期任务 */ for (i 0; i MAX_PORTS; i) { if (pfd[i].revents POLLIN) { ssize_t n; while ((n read(pfd[i].fd, buf, sizeof(buf))) 0) { /* 处理数据比如转发到网络 */ process_data(i, buf, n); } if (n 0 errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read); } } if (pfd[i].revents (POLLERR | POLLHUP | POLLNVAL)) { /* 处理异常比如重新打开串口 */ handle_error(i); } } } for (i 0; i MAX_PORTS; i) close(fds[i]); return 0; }这段代码有几个关键点值得展开。第一串口打开时加了O_NONBLOCK这样read在没数据时返回EAGAIN而不是阻塞。第二poll的超时设了500毫秒这样即使没有串口数据主循环也能定期醒来处理其他任务。第三read用了while循环一次事件通知后把缓冲区里的数据全部读干净避免数据积压。第四检查了POLLERR、POLLHUP、POLLNVAL这些错误状态如果不处理可能导致描述符一直返回就绪但读不出数据形成忙循环。4.2 用epoll重构为高并发网络服务如果采集器还要同时接受多个客户端连接描述符数量可能上升到几十上百个这时候epoll更合适。下面是一个简化的epoll服务端框架#include sys/epoll.h #include sys/socket.h #include netinet/in.h #include fcntl.h #include unistd.h #include stdio.h #include string.h #include errno.h #define MAX_EVENTS 64 #define BUF_SIZE 1024 static int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int listen_fd, epfd, nfds, i; struct epoll_event ev, events[MAX_EVENTS]; struct sockaddr_in addr; listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8080); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return -1; } if (listen(listen_fd, 16) 0) { perror(listen); return -1; } set_nonblock(listen_fd); epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return -1; } ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl add listen); return -1; } while (1) { nfds epoll_wait(epfd, events, MAX_EVENTS, 500); if (nfds 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (i 0; i nfds; i) { int fd events[i].data.fd; if (fd listen_fd) { /* 接受新连接 */ while (1) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblock(conn_fd); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev) 0) { perror(epoll_ctl add conn); close(conn_fd); } } } else { /* 处理客户端数据 */ char buf[BUF_SIZE]; ssize_t n; while ((n read(fd, buf, sizeof(buf))) 0) { process_client_data(fd, buf, n); } if (n 0) { /* 对端关闭 */ epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else if (n 0 errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } close(listen_fd); close(epfd); return 0; }这段代码用了EPOLLET边缘触发模式所以accept和read都必须循环到EAGAIN为止否则会丢事件。listen_fd本身也设了非阻塞这样accept在连接队列空的时候返回EAGAIN循环才能正常退出。如果listen_fd是阻塞的accept在队列空时会阻塞住整个事件循环就卡死了这是ET模式下非常经典的坑。4.3 参数计算与缓冲区大小选择缓冲区大小不是随便定的。以串口为例假设波特率是1152008N1格式那么每秒最多传输115200/10 11520字节。如果主循环500毫秒跑一次两次之间最多积累约5760字节。所以串口读缓冲区至少要能容纳这个量级否则一次读不完数据会留在内核缓冲区里虽然不会丢但会增加事件通知次数。实际项目中我一般给串口读缓冲区设1024到4096字节配合循环读取基本不会出问题。网络缓冲区则要考虑MTU。以太网MTU通常是1500字节TCP会把数据切成MSS大小的段所以读缓冲区设2048或4096比较合适能一次读完一个完整的数据段。如果设得太小比如64字节一个1500字节的包要读二十几次系统调用开销会明显上升。epoll_wait的maxevents参数也要注意。它表示一次最多返回多少个就绪事件。如果设得太小比如1那么即使有10个描述符就绪一次也只返回1个需要多次调用才能处理完效率低。一般设成64或128比较合理既能一次处理一批事件又不会占用太多栈空间。events数组是在栈上分配的maxevents乘以sizeof(struct epoll_event)就是栈占用64个事件大约占1KB左右嵌入式设备完全能承受。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因排查方法解决方案select第二次循环检测不到事件fd_set被内核修改后未重新填充检查循环内是否重新FD_SET每次调用select前重建集合poll一直返回就绪但read无数据描述符有错误状态未处理检查revents是否含POLLERR/POLLHUP处理错误状态并关闭或重开描述符epoll ET模式丢数据read未循环到EAGAIN检查read是否只调用一次循环read直到EAGAINepoll_wait返回EINTR导致退出未处理信号中断检查errno是否为EINTR循环重试而非退出CPU占用100%超时设为0或描述符一直就绪用top观察检查超时参数设合理超时处理错误状态accept阻塞导致事件循环卡死listen_fd未设非阻塞检查listen_fd的O_NONBLOCK设置非阻塞并循环accept描述符泄漏关闭连接时未epoll_ctl DEL用lsof查看描述符数量关闭前先DEL再close5.2 一个真实的排查案例之前有个项目设备跑几个小时后就出现CPU占用飙升但功能还正常。用top看进程CPU占用从平时的5%涨到90%多。加了日志后发现epoll_wait返回的事件里有一个描述符反复返回EPOLLIN但read返回0。read返回0在TCP里表示对端关闭了连接但代码里只处理了read返回-1的情况返回0时没有关闭描述符导致这个描述符一直留在epoll集合里每次epoll_wait都返回它形成忙循环。修复方法很简单在read返回0时执行epoll_ctl(EPOLL_CTL_DEL)并close。这个案例说明多路复用编程里描述符的生命周期管理必须和事件处理严格对应任何一条路径漏掉清理都可能造成资源泄漏或忙循环。5.3 独家避坑技巧第一个技巧用epoll的data.ptr存上下文指针而不是data.fd。当描述符关闭后如果事件数组里还有它的事件用data.fd去操作可能操作到已经被复用的新描述符造成难以排查的bug。用指针的话可以在关闭描述符时把上下文标记为无效事件处理时先检查有效性安全性更高。第二个技巧给每个描述符维护一个状态机。多路复用只告诉你“可读”或“可写”但业务逻辑往往需要知道“当前处于哪个阶段”。比如一个协议解析可能需要先读头部再读body用一个状态机记录每个连接的解析进度比用一堆全局变量清晰得多。第三个技巧在嵌入式设备上把epoll_wait的超时和看门狗喂狗结合起来。超时设500毫秒每次超时返回时喂一次狗这样既保证了事件响应又保证了看门狗不会误复位。如果超时设成-1永久阻塞一旦没有事件看门狗就可能超时复位设备反复重启。第四个技巧测试时用strace观察系统调用。strace -e traceepoll_wait,epoll_ctl,read,write ./your_program能清楚看到每次epoll_wait返回了哪些事件、read读了多少字节。排查丢数据、忙循环这类问题时strace比加日志更直接。5.4 水平触发与边缘触发的选择经验LT和ET的选择没有绝对答案但有几个判断依据。如果代码逻辑简单对性能要求不高用LT不容易出错。如果描述符数量大、事件频繁且你能保证每次事件都把数据读干净用ET能减少epoll_wait的调用次数降低CPU占用。我的一般做法是先用LT把功能跑通压测时如果发现epoll_wait调用过于频繁成为瓶颈再切换到ET并仔细检查所有读写路径是否都循环到了EAGAIN。需要注意的是ET模式下accept也必须循环。因为多个连接可能在同一时刻到达epoll_wait只通知一次如果accept只调用一次剩下的连接就会一直留在队列里直到下一个新连接到来才会被处理造成连接延迟。这个坑我在早期项目里踩过现象是客户端连接偶尔要等好几秒才建立排查了很久才定位到。6. 从文件IO到事件驱动的思维转变写完这几套接口的代码你会发现一个有意思的事情select、poll、epoll本质上都是对“文件描述符”这个抽象的统一操作。在Linux里串口是文件、socket是文件、管道是文件、甚至某些设备也是文件所以同一套多路复用机制能同时管理它们。这种“一切皆文件”的设计让嵌入式开发者可以用统一的思维模型处理各种IO源不用为每种设备学一套不同的并发模型。从阻塞式编程转到事件驱动编程最大的思维转变是你不再主动去“读”而是等系统告诉你“可以读了”。这个转变刚开始会有点别扭但一旦适应代码结构会变得非常清晰——一个主循环一个事件分发每个描述符的处理逻辑独立成函数扩展新IO源只需要加一个描述符和对应的处理函数不用改主循环结构。我在实际项目中的体会是嵌入式Linux下的IO并发epoll配合非阻塞IO基本能覆盖90%以上的场景。剩下的10%里有些是描述符极少、用poll更简单的有些是需要跨平台、只能用select的。把这三套接口都掌握遇到不同需求时就能快速做出选择而不是硬套一种方案。最后分享一个小技巧如果你不确定该用LT还是ET可以先写一个测试程序用strace统计两种模式下epoll_wait的调用次数和read的调用次数。在同样的数据量下ET模式的epoll_wait调用次数通常明显少于LT但read次数可能更多因为要循环读到EAGAIN。根据你的实际数据特征选择总系统调用次数更少的那种模式通常就是更优解。