4. 网络协议栈调试(二):socket编程常见问题定位、epoll与select调试技巧、网络超时与重传机制分析、MTU/MSS问题排查
各位好,咱们接着聊协议栈调试。上一章我们把底层驱动和协议栈内核态的东西理了一遍,这一章我打算把焦点往上提一层,落到大家每天都要打交道的 socket 编程上。
说实话,我见过太多应用层开发同事,一遇到网络卡顿或者连接断开,第一反应就是“底层有问题”。但实际排查下来,十次里有七八次是 socket 用错了、epoll 事件没处理好、或者 MTU 没对齐。嗯,今天咱们就把这些坑一个一个填上。
4.1 socket 编程常见问题定位
先说说 socket 编程里最容易翻车的地方。我个人习惯把问题分成三类:资源泄漏、阻塞与超时、地址与选项配置错误。
4.1.1 文件描述符泄漏
这是最隐蔽的。我记得有一次帮客户排查一个网关设备,运行三天后必然无法建立新连接。用 lsof -p [pid] 一看,socket 描述符数量已经逼近进程上限。代码里每次 accept 后没有做异常分支的 close,一旦客户端异常断开,服务端就漏一个 fd。
4.1.2 阻塞模式下的死锁
很多嵌入式工程师图省事,直接用默认的阻塞 socket。但如果在同一个线程里先 recv 再 send,而对方也在等你先发数据,两边就僵住了。我建议所有新写的代码都改用非阻塞 + poll/select/epoll,除非你能严格证明阻塞模式不会出问题。
4.1.3 SO_REUSEADDR 与端口占用
调试阶段经常需要频繁重启服务进程。如果不设置 SO_REUSEADDR,bind 会失败,报 EADDRINUSE。这个选项要在 bind 之前设置:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
注意,TCP 的 TIME_WAIT 状态会持续 2*MSL(通常 60 秒),不设这个选项你就得干等一分钟。
4.2 epoll 与 select 调试技巧
select 和 epoll 是 Linux 下最常用的 I/O 多路复用机制。我个人的经验是:连接数少于 100 且对实时性要求不高,用 select 够用;超过 100 或者需要处理大量短连接,必须上 epoll。
4.2.1 select 的三大陷阱
- fd_set 大小限制: 默认 FD_SETSIZE 是 1024,超出就溢出。我曾经在项目里遇到过,连接数一过 1024,select 就开始随机返回错误,排查了两天才找到原因。
- 每次调用都要重新设置 fd_set: select 会修改传入的集合,所以每次调用前必须重新把关心的 fd 加进去。很多人忘了这一步,导致事件丢失。
- 大 fd 下的性能退化: select 是线性扫描所有 fd,fd 数量越大,耗时越长。在 1000 个连接里扫描,每次调用都要几十微秒。
4.2.2 epoll 的边缘触发与水平触发
epoll 有两种触发模式:LT(水平触发)和 ET(边缘触发)。LT 是默认模式,行为类似 select,只要缓冲区还有数据,每次 epoll_wait 都会返回。ET 只在状态变化时通知一次,你必须一次性把数据读完。
调试 epoll 时,我常用的手段是 strace -e epoll_ctl,epoll_wait,可以清楚看到每次事件注册和返回的 fd 列表。配合 gdb 在 epoll_wait 返回后打印 events 数组,能快速定位事件丢失的原因。
4.3 网络超时与重传机制分析
网络超时是嵌入式设备上最头疼的问题之一。Wi-Fi 信号不稳定、网线接触不良、对端负载过高,都会导致 TCP 重传。你需要理解 Linux 内核是怎么处理重传的,才能准确判断问题出在哪一层。
4.3.1 RTO 与重传指数退避
TCP 的重传超时(RTO)不是固定的。内核会基于 RTT(往返时间)动态计算 RTO,初始值通常是 1 秒。如果重传后还没收到 ACK,RTO 会翻倍(指数退避),直到达到上限(默认 120 秒)。
你可以通过 ss -i 查看每个 socket 的 RTT 和 RTO:
ss -i | grep -E "rtt|rto"
输出里会显示类似 rtt:10.5/5.0 rto:200 的信息,前者是平均 RTT,后者是当前 RTO。
4.3.2 快速重传与重复 ACK
当接收端收到乱序包时,会重复发送上一个正确收到的包的 ACK。发送端收到 3 个重复 ACK 后,不等 RTO 超时,立即重传丢失的包。这就是快速重传机制。
4.3.3 应用层超时 vs 内核超时
很多工程师混淆了这两个概念。内核超时(RTO)是 TCP 协议栈自己控制的,你改不了。应用层超时是你自己在 socket 上设置的 SO_RCVTIMEO 或 SO_SNDTIMEO。我建议两者都设,但用途不同:
| 超时类型 | 设置方式 | 作用范围 |
|---|---|---|
| 内核 RTO | /proc/sys/net/ipv4/tcp_retries1/2 | TCP 重传次数上限 |
| 应用层接收超时 | setsockopt SO_RCVTIMEO | recv/recvfrom 阻塞等待时间 |
| 应用层发送超时 | setsockopt SO_SNDTIMEO | send/sendto 阻塞等待时间 |
4.4 MTU/MSS 问题排查
MTU(最大传输单元)和 MSS(最大分段大小)是网络性能的隐形杀手。我遇到过不止一次,设备在局域网内通信正常,一上公网就丢包严重,最后发现是 MTU 不匹配导致的 IP 分片问题。
4.4.1 MTU 与 IP 分片
以太网的 MTU 通常是 1500 字节。如果上层协议(比如 TCP)发送的数据包超过这个值,IP 层会进行分片。分片带来的问题是:只要有一个分片丢失,整个原始包都要重传。这对无线网络来说是灾难性的。
你可以用 ip link show 查看当前接口的 MTU:
ip link show eth0 | grep mtu
4.4.2 MSS 的协商与调整
MSS 是 TCP 层能接受的最大数据段大小,等于 MTU 减去 IP 头部(20 字节)和 TCP 头部(20 字节)。所以以太网环境下,MSS 通常是 1460 字节。
MSS 在 TCP 三次握手的 SYN 包中协商。如果两端 MTU 不一致,MSS 会取较小值。你可以用 tcpdump 抓 SYN 包,看 MSS 选项的值:
tcpdump -i eth0 'tcp[tcpflags] & tcp-syn != 0' -X
4.4.3 PMTUD 与 ICMP 黑洞
路径 MTU 发现(PMTUD)是 TCP 用来探测整条路径最小 MTU 的机制。发送端发送一个 DF(不分片)标志的包,如果中间路由器发现包太大,会返回 ICMP 的“需要分片但 DF 置位”消息。发送端据此减小 MSS。
手动设置 MSS 的方法:
iptables -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1400
这个命令强制所有经过的 TCP 连接的 MSS 不超过 1400 字节,可以有效避免 PMTUD 失败导致的问题。
4.5 本章知识结构
下面这张图把本章的核心知识点串了起来,方便你对照排查:
4.6 小结
这一章的内容偏实战,每个知识点背后都有我踩过的坑。socket 编程的 fd 泄漏、epoll 的 ET 模式误用、RTO 超时导致的连接假死、MTU 不匹配引发的分片风暴——这些在嵌入式 Linux 网关开发中几乎天天都能遇到。