3. I2C通信基础
串口编程里,阻塞与非阻塞模式的选择,直接决定了你的应用是“死等”还是“高效运转”。
很多刚入行的朋友,上来就写 read(fd, buf, size),觉得简单直接。嗯,这里要注意,这种写法在数据量小、系统空闲时确实没问题。但一旦数据流不稳定,或者你需要同时处理多个串口,阻塞读就成了一个巨大的陷阱。
3.1 阻塞读的陷阱
阻塞读,说白了就是调用 read() 时,如果内核缓冲区里没有数据,线程就会挂起,一直等到数据到来才返回。
我遇到过这样一个项目:一个数据采集终端,需要同时从三个串口读取传感器数据。最初设计用了三个线程,每个线程都阻塞在 read() 上。结果呢?某个传感器偶尔会“罢工”几秒钟不发数据,对应的线程就卡死了,整个系统的实时性瞬间崩溃。
- 线程挂起: 没有数据时,线程被操作系统调度出去,无法做其他事情。
- 无法超时: 默认的阻塞读没有超时机制,如果对方设备异常断开,你的线程可能永远等下去。
- 多路处理困难: 一个线程只能处理一个文件描述符,要处理多个串口,就得开多个线程,资源开销大。
当然,阻塞模式也不是一无是处。它的编程模型简单,CPU占用率低(因为线程休眠了)。但如果你追求高并发、高响应,就必须跳出这个舒适区。
3.2 O_NONBLOCK 标志:打破阻塞的第一步
非阻塞模式,就是告诉内核:read() 调用立即返回,不管有没有数据。如果有数据,返回实际读取的字节数;如果没有数据,返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。
设置方法很简单,打开设备时加上 O_NONBLOCK 标志:
int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0) {
perror("open");
return -1;
}
也可以在打开后,用 fcntl() 动态设置:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
我个人习惯用第二种方式,因为有时候你拿到的文件描述符是从别处继承来的,没法在 open() 时控制。
非阻塞模式的好处是,你的程序可以“轮询”多个串口。但轮询也有代价——如果一直循环调用 read(),CPU 会被白白吃掉。这时候,就需要更高级的 I/O 多路复用机制了。
3.3 select/poll 多路复用:同时盯着多个串口
select 和 poll 是经典的 I/O 多路复用方案。它们允许一个线程同时监视多个文件描述符,当某个描述符“就绪”(比如有数据可读)时,再通知你去处理。
说白了,就是让内核帮你盯着,有动静了再叫你,省得你一遍遍去问“有数据了吗?”。
3.3.1 select 的使用
select 的接口比较老,但它几乎在所有 Unix-like 系统上都支持。它的主要限制是:监视的文件描述符数量受 FD_SETSIZE 限制(通常是 1024),而且每次调用都要把整个 fd_set 从用户态拷贝到内核态,效率不高。
fd_set read_fds;
struct timeval timeout;
int ret;
FD_ZERO(&read_fds);
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
timeout.tv_sec = 5; // 5秒超时
timeout.tv_usec = 0;
ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret < 0) {
perror("select");
} else if (ret == 0) {
printf("超时,没有数据到来\n");
} else {
if (FD_ISSET(fd1, &read_fds)) {
// 从 fd1 读取数据
}
if (FD_ISSET(fd2, &read_fds)) {
// 从 fd2 读取数据
}
}
我曾经在一个嵌入式网关项目里用过 select 来管理 4 个串口和 1 个网络 socket。当时觉得够用了,直到后来设备数量翻倍,select 的性能瓶颈就暴露出来了。
3.3.2 poll 的改进
poll 用 struct pollfd 数组代替了 select 的位图,理论上没有最大描述符数量的限制。它的接口更清晰一些:
struct pollfd fds[2];
int ret;
fds[0].fd = fd1;
fds[0].events = POLLIN;
fds[1].fd = fd2;
fds[1].events = POLLIN;
ret = poll(fds, 2, 5000); // 5秒超时
if (ret < 0) {
perror("poll");
} else if (ret == 0) {
printf("超时\n");
} else {
if (fds[0].revents & POLLIN) {
// 读取 fd1
}
if (fds[1].revents & POLLIN) {
// 读取 fd2
}
}
但 poll 和 select 有一个共同的痛点:每次调用都要把整个 fd 列表从用户态传到内核态,而且内核会遍历整个列表来检查哪些 fd 就绪。当监视的 fd 数量很大(比如几千个)时,这个遍历的开销就很可观了。
| 特性 | select | poll |
|---|---|---|
| 最大 fd 数量 | FD_SETSIZE(通常 1024) | 无硬限制(受内存限制) |
| 数据结构 | 位图(fd_set) | 数组(struct pollfd) |
| 超时精度 | 微秒 | 毫秒 |
| 可移植性 | 极好(POSIX 标准) | 好(POSIX 标准) |
| 性能(大量 fd) | 差(O(n) 遍历) | 差(O(n) 遍历) |
3.4 epoll 在串口中的应用:高性能的终极方案
epoll 是 Linux 下专为高性能 I/O 设计的方案。它解决了 select/poll 的两个核心问题:
- 避免重复拷贝: epoll 在内核中维护一个事件表,只需要在
epoll_ctl()时拷贝一次 fd 信息,后续epoll_wait()只返回就绪的事件。 - 避免全量遍历: epoll 通过回调机制,只把就绪的 fd 放到一个就绪队列中,
epoll_wait()直接取走,复杂度 O(1)。
对于串口应用,虽然我们通常不会同时管理几千个串口,但 epoll 的优势依然明显:
- 支持边缘触发(ET)和水平触发(LT)两种模式。
- 可以同时管理串口、网络 socket、定时器等多种文件描述符。
- 性能稳定,不会因为 fd 数量增加而明显下降。
下面是一个使用 epoll 管理两个串口的示例框架:
int epfd = epoll_create1(0);
if (epfd < 0) {
perror("epoll_create1");
return -1;
}
struct epoll_event ev, events[10];
ev.events = EPOLLIN; // 水平触发,默认模式
ev.data.fd = fd1;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);
ev.data.fd = fd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 10, 5000); // 5秒超时
if (nfds < 0) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// 从 events[i].data.fd 读取数据
char buf[256];
int n = read(events[i].data.fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
}
}
}
}
close(epfd);
- 水平触发(LT): 只要缓冲区还有数据,每次
epoll_wait()都会返回。编程简单,不容易漏数据。 - 边缘触发(ET): 只在状态变化时通知一次。你必须一次性把数据读完,否则会丢失。需要配合非阻塞 I/O 和循环读取。
我个人建议,串口应用先用水平触发,简单可靠。等你对 epoll 熟悉了,再考虑边缘触发来追求极致性能。
3.5 知识体系总览
下面这张图,把串口 I/O 模式的演进和选择逻辑梳理了一下:
3.6 实战建议与避坑指南
最后,分享几个我在实际项目中踩过的坑:
- 非阻塞读一定要检查返回值: 我曾经漏掉了
EAGAIN的处理,结果程序在数据没准备好时读到了垃圾数据,排查了好几天。 - select 的 fd 集合会变: 每次调用 select 前,必须重新设置 fd_set。我见过有人只设置一次,然后反复用,导致 select 行为异常。
- epoll 的 ET 模式要配合循环读: 如果用了边缘触发,一定要在
read()返回EAGAIN时才停止读取,否则会丢数据。 - 串口打开后别忘了设置串口参数: 波特率、数据位、停止位、校验位,一个都不能少。默认参数往往是 9600 8N1,不一定适合你的设备。
在一个工业控制项目中,我用 select 管理 8 个串口,但没注意到 select 的 fd 数量上限是 1024。当时觉得 8 个串口绰绰有余,结果因为系统里还有其他文件描述符(日志文件、socket 等),select 的 fd_set 溢出了,导致程序随机崩溃。后来改用 poll 才彻底解决。
所以,如果你不确定系统里有多少文件描述符,或者未来可能增加,直接用 poll 或 epoll 更稳妥。
好了,串口的阻塞与非阻塞模式就聊到这里。记住,没有银弹——阻塞模式简单但脆弱,非阻塞模式灵活但复杂,select/poll 适合中等规模,epoll 则是高性能场景的利器。根据你的实际需求,选择合适的方案,才是真正的调优之道。