去年底帮一个现场处理故障:设备IP地址能ping通,但上位机软件怎么都连不上TCP端口。一开始我以为是防火墙或者端口没开,结果抓包出来发现三次握手是成功的,问题出在第一个数据包发出去之后一直没收到ACK,客户端在超时重传的循环里干等。从那次以后我意识到,不少人对TCP协议的理解停留在“三次握手、四次挥手”这八个字上,真到排查故障时,脑子里没有一张完整的协议地图。
这篇内容我想按自己的实战路线重新拆一遍TCP:它到底向应用层承诺了什么,握手和挥手每一步在干吗,序号、确认号、滑动窗口、重传、拥塞控制这些机制是怎么串起来的,最后再结合几个实际排过的坑,讲一讲怎么把协议知识变成排查手段。后面会提到的modbus tcp、mqtt、rtmp、onvif这些应用层协议,核心依赖都是TCP,但很多朋友一直把它们和TCP混在一起学,其实先搞懂TCP再看它们会轻松很多。适合刚接触网络的人建立坐标系,也适合天天被TCP故障折磨的开发和运维朋友对照着排查。
1. TCP协议到底向应用层承诺了什么
1.1 诺言列表:按序、不丢、不重、有连接
先说一个结论:TCP本质是在不可靠的IP网络上,为应用层提供一条可靠的字节流管道。IP层的职责是尽力把数据报“努力送到”目的地,但它不保证顺序、不保证不丢、也不保证不重复。TCP则通过序号Sequence Number、确认号Acknowledgment Number、重传、校验和等一系列机制,把“尽力而为”变成“基本可靠”。
理解这四个承诺很重要:
- 按序:IP网络里不同数据包可能走不同路径,先发的不一定先到。接收方根据包里的序号重新排序,再交给上层的是一段完整有序的字节流。
- 不丢:发送方发出数据后启动定时器,超时没收到确认就重传。这一条是后面所有排查工作的主线。
- 不重:同一个数据包即使因为重传被对方收到两份,接收方也只会向上层交付一次。
- 有连接:通信双方在交换数据之前,先通过握手建立一条逻辑连接,共同维护序号空间、窗口等状态。
但要注意,TCP的“可靠”不是绝对可靠。比如中间链路中断、对端进程崩溃、网线被拔掉,TCP无法神奇地把数据恢复出来。它能保证的是:在一个持续工作的连接上,应用层最终收到的字节流,和发送方写进去的字节流完全一致。很多现场问题看似TCP“坏了”,其实是对端已经不再收发数据,TCP的重传只是在做最后的挣扎。
1.2 同样是“协议”,这些名词的层次其实不一样
做嵌入式、PLC或上位机开发的朋友,经常把这些词混在一起讨论:“modbus tcp”“mqtt”“rtmp”“onvif”“secs/gem”,还有热搜里常见的“can协议”“spi协议”“iic协议”“mipi协议”。这里必须区分一点:can、spi、iic、mipi更多是物理总线或链路层协议,跑在和TCP完全不同的层级和介质上;而modbus tcp、mqtt、rtmp、onvif这类是应用层协议,它们绝大多数工作在TCP之上,先借助TCP把数据可靠送到对方,再在应用层定义数据格式和业务含义。
带着这个层次感去学TCP,很多困惑能瞬间解开。TCP不关心你传的是Modbus报文还是MQTT消息,它只负责把字节流从一端搬到另一端。所以排查这类应用协议故障时,标准套路是先把问题分层:ping通不代表TCP端口通,TCP端口通不代表应用协议正常,应用协议正常也不代表业务数据一定对。每一层有每一层的问题,混在一起查,最后往往是在瞎猜。
1.3 字节流没有边界,粘包拆包是绕不开的功课
这是TCP新手最容易踩的坑。TCP是字节流协议,不保存消息边界。你调用send发送三笔数据,对端可能一次收到,也可能分三次收到;你连发了两个modbus请求,接收方可能在一个TCP段里同时收到两个请求,必须自己切分。
Modbus TCP的做法是,在MBAP报文头里放一个“长度”字段,告诉接收方后面还有多少字节;MQTT在固定头中用“剩余长度”变量记录整个报文长度;rtmp、onvif也各有各的分帧方式。所以写TCP客户端时,第一步往往不是处理业务,而是设计一个可靠的拆包器:维护一个接收缓冲区,把每次收到的字节追加进去,然后按协议的边界规则解析出完整报文,再交给业务层。后面第6章我会给一个简化的示例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 握手不是打招呼:三次握手的逐字段拆解
2.1 TCP报文头里,握手时谁在起作用
先看TCP头部的核心字段。这个表不要求背,但排查时你得知道该去哪个字段里找答案:
| 字段 | 位宽 | 握手时的作用 |
|---|---|---|
| 源端口/目的端口 | 各16位 | 标识两端应用,是建立连接的基本路由信息 |
| 序号SEQ | 32位 | 通告自己的初始序号x或后续数据的字节位置 |
| 确认号ACK | 32位 | 指向“期望对方下一次发送的序号” |
| 标志位 | 多位 | SYN、ACK、FIN、RST、PSH等,控制连接状态 |
| 窗口大小Window | 16位 | 通告剩余接收缓冲区能力,配合窗口缩放选项使用 |
| 校验和 | 16位 | 覆盖TCP头和数据,防传输损坏 |
| 选项 | 可变长度 | MSS、窗口缩放、时间戳、SACK等,连接建时协商 |
推荐打开窗口缩放和TCP时间戳选项,否则在高带宽长链路下,16位的窗口字段不够用,吞吐直接卡死。Wireshark里看握手包时,我会重点看SYN包里的MSS字段。如果MSS异常小,比如不足536字节,很可能被中间网络设备改过,这会影响后续线上传输速度。
2.2 三步动作,一次说清
三次握手更准确地讲,是双方交换彼此的初始序号并确认对方“能收能发”。
第一步:客户端发送SYN包,seq=x。这是连接请求,也顺手通告了自己的初始序号x。
第二步:服务端收到后回复SYN+ACK,seq=y,ack=x+1。这个ack=x+1表示“我已经收到序号x,期待你下一个字节的序号是x+1”。
第三步:客户端再回一个ACK,seq=x+1,ack=y+1。服务端收到后状态变成ESTABLISHED,连接建立,双方进入数据传输阶段。
注意,SYN包本身要占一个序号,所以第二次握手的ack=x+1而不是x,第三次握手里客户端要发seq=x+1。服务端同样为自己的SYN占了一个序号y。因为第二个包同时背负“确认我的SYN”和“发出我的SYN”两个任务,所以它的数据负载是空的,只占一个序号。
状态迁移也需要记一下:
- 客户端:CLOSED -> SYN_SENT -> ESTABLISHED
- 服务端:LISTEN -> SYN_RCVD -> ESTABLISHED
如果SYN包发出去没人回,客户端会周期性地重传SYN。Linux下重传次数由net.ipv4.tcp_syn_retries控制,默认通常是6次,调整它会影响“连接失败”的等待时长。
2.3 为什么是三次,两次或四次行不行
核心原因是要双向确认序号。如果只握两次,服务端在发出SYN+ACK后就会认为连接已建立,然后干等数据;但这个包可能丢了,客户端根本不知道服务端同意连接,连接就耗在服务端那里,变成半死连接。
握手两次无法让服务端确认“客户端是否收到了我的SYN+ACK”,也就无法确认客户端是否已经准备好。三次握手正好能做到:第三次ACK到达时,服务端知道客户端已经收到自己的序号,同时客户端也知道服务端收到了自己的序号。四次理论可行,但第三次和第四次可以合并为一个确认包,所以三次是效率和可靠性的平衡点。
实际网络里,这一步非常容易出问题。如果服务端的半连接队列满了,恶意或突发的连接请求就会导致正常客户端“连接超时”或“连接被重置”。排查时用ss -lnt看Listen队列的溢出,用netstat -s看SYN丢包统计,都比直接改应用配置有用得多。
2.4 握手阶段最常见的坑
我只挑几个高频的:
- 中间防火墙静默丢弃SYN。客户端表现是卡住几秒然后connection timed out。抓包只能看到客户端不断重发SYN,对面没有任何回应。此时查网络节点而不是改应用。
- backlog大小不匹配。内核参数net.ipv4.tcp_max_syn_backlog和somaxconn,以及应用层listen的backlog参数,三者会互相影响。Nginx的listen 80 backlog=1024就属于联合调优。
- SYN重传间隔很短,也未必是故障。移动网络或高延迟链路会触发快速重传,先统计再判断。
3. 数据在管道里怎么保证不漏:序号、确认号与滑动窗口
3.1 序号和确认号的语义怎么读
数据传输阶段的核心,其实就一句话:发送方给每个字节编号,接收方告诉对方“我期待的下一个字节编号是多少”。TCP的确认是累计确认,接收方收到了seq=1001到2000的数据,但中间1500丢了,它不会确认1500之后的数据,而是继续回ack=1500,让发送方知道前面没收齐。
累计确认的好处是简单、节省带宽,缺点也明显:丢一个包可能导致后面一大段数据被整体重传。这个问题的缓解方案就是SACK选项,它允许接收方明确告诉发送方哪些块收到了、哪些块丢了。Linux默认开启SACK,但对端不一定支持,所以遇到高丢包环境,先确认双方有没有协商出SACK。
3.2 滑动窗口:发送不能超过对方的承受能力
TCP的流量控制并不难理解:接收方在ACK里带上“窗口大小”,发送方维持一个“已发送但未确认”的字节数,这个数不能超过接收窗口。窗口大小等于min(接收通告窗口, 拥塞窗口)。我习惯把它想成一个水池的出水口:下游水池快满了,闸门就自动关小;下游水池放空了,闸门再开大。
计算窗口时还有个关键点:如果接收方通告窗口是0,表示缓冲区已满,发送方必须停止发送。此时如果接收方一直不发新的窗口更新,双方就要靠“零窗口探测”包来维持联系。这类问题在低性能嵌入式设备上非常常见,表现是设备连接还在,但数据传输龟速,抓包一看全是窗口更新包。
3.3 超时重传、快速重传与SACK
- 超时重传(RTO):基于RTT采样动态计算超时时间,重传后如果继续超时会指数退避。RTO计算本身在Linux内核里会过滤抖动,但现场环境抖动剧烈时,仍可能出现“假超时”。
- 快速重传:接收方收到乱序数据时,会立即重复ACK最后连续收到的序号。发送方收到3个重复ACK,基本可以认定丢包,不等超时直接重传。这条机制让很多丢包场景下不需要等一个RTT就能恢复。
- SACK:前面说了,允许选择性确认,对高带宽高丢包环境非常有用。Linux下默认开启,但部分嵌入式协议栈不带SACK,双端协商时如果一方没启用,自动降级为累计确认。
Wireshark里遇到TCP Retransmission、TCP Dup ACK、TCP Out-of-Order这些标黄信息,分别代表不同情况。很多人看到黄色就慌,其实要判断的是:这些告警是常态还是突增,是持续还是偶发。公司内网如果一直有少量乱序,通常不是问题;但如果是丢包率突然上升,那就要看中间链路是不是跑满了或光模块有问题了。
Linux内核里TCP收包的数据流,大致是tcp_v4_rcv -> tcp_v4_do_rcv -> tcp_rcv_established这条线走下来。有兴趣走读协议栈源码的朋友,可以从这三层函数入手,再逐步往下看ACK处理和窗口更新逻辑。
4. 一条TCP连接能跑多快,由拥塞控制说了算
4.1 为什么不能“有多快发多快”
接收窗口解决了“接收方承受力”的问题,但没解决“网络承受力”的问题。如果几十个连接同时按接收窗口满速发送,中间路由器的缓存很快被打爆,产生大量丢包,最终所有人一起变慢。所以TCP还有一个“拥塞窗口”(cwnd),由发送方自己维护,用来探测网络当前能承受多快的发送速度。
发送方的实际发送速度,受限于min(接收通告窗口, 拥塞窗口)。当接收方通告很大时,决定上限的就是拥塞窗口。拥塞控制算法,就是一套动态调整cwnd的策略。
4.2 慢启动、拥塞避免:指数增长到线性增长的切换
新连接建立后,cwnd从很小的值开始,我自己的服务器上通常从10个MSS左右开始。每收到一个ACK,cwnd增加一个MSS,所以实际上呈指数增长:1、2、4、8……直到达到慢启动阈值ssthresh。
一旦超过ssthresh,进入拥塞避免阶段,cwnd不再是翻倍增长,而是每经过一个RTT大约增加一个MSS,变成线性增长。这个过程是为了在接近网络容量时不要莽撞地翻倍,避免直接把中间路由打爆。
发生超时丢包后,TCP会把ssthresh降到当前cwnd的一半,cwnd重置为初始值,重新慢启动。这就是最经典的TCP Reno思路。后来的NewReno改进了恢复阶段处理多个丢包的能力,而Linux默认的CUBIC使用三次函数曲线控制增长,在高带宽长延迟链路上表现更好。Google的BBR则走了另一条路:不再以丢包为拥塞信号,而是通过周期性测量瓶颈带宽和最小RTT来估算可用带宽。
| 算法 | 核心思路 | 适用场景 |
|---|---|---|
| Reno | 丢包即拥塞,窗口半减 | 传统有线网络 |
| NewReno | 改进恢复阶段的多次丢包处理 | 广泛部署的改进版 |
| CUBIC | 窗口增长用三次函数,RTT独立 | Linux默认,高带宽长时延 |
| BBR | 基于带宽和RTT建模 | 高丢包、高延迟链路效果明显 |
4.3 带宽延迟积:窗口和缓冲区该设多大
调优TCP时经常会看到“带宽延迟积”BDP,公式是带宽乘以往返时延。它表示在一条理想链路上,有多少数据正在“飞行”,还没确认。想让吞吐跑满,接收窗口至少要等于BDP。
举个例子:100Mbps链路,RTT为20ms,BDP=100×10^6×0.02/8=250KB。如果接收缓冲区只有64KB,那无论发送端多快,吞吐上限也只有64KB/0.02=3.2MB/s左右,远远跑不满带宽。所以遇到“带宽加了不少还是慢”的问题,先算算BDP,再看两端缓冲区够不够。
另外网上很多人会用TCP Optimizer之类的工具改注册表,核心就是调大收发缓冲区、启用窗口缩放、调整初始拥塞窗口。Linux下常用ip route change ... initcwnd 10这类命令调整初始拥塞窗口,但要注意,盲目调大cwnd在某些低速链路上反而会造成瞬时突发。先用实测数据说话,再动手。
5. 连接关闭的战场:四次挥手、TIME_WAIT与端口资源
5.1 四次挥手的每一步,分别是谁在等谁
主动关闭方发送FIN报文,seq=m,表示“我的数据发完了”。被动方收到后回ACK,ack=m+1,表示“知道了,但我可能还有数据要发”。被动方把剩余数据发完后,再发FIN,seq=n+1,表示“我也没数据了”。主动方收到后回ACK,ack=n+2,然后进入TIME_WAIT。
四个步骤不能合并的原因很简单:TCP是全双工的,一个方向的数据发完了,另一个方向可能还在传。所以两个方向的关闭动作要分开确认。很多新手以为“断开连接就是发一个FIN”,实际还有半关闭状态的存在:调用shutdown可以只关发送或只关接收,而close是两方向一起断。
5.2 TIME_WAIT为什么宁可多等60秒
主动关闭方发送最后一个ACK后,不会立刻释放连接,而是进入TIME_WAIT并等待2MSL。这个机制很多人讨厌,但它有两个作用:
- 保证最后一个ACK如果丢了,还有机会重发。如果不等,被动方迟迟收不到ACK,会一直重发FIN。
- 防止旧连接中迟到的数据包污染新的连接。等2MSL后,网络里跟这个连接相关的旧数据包基本都消亡了,才能安全地复用四元组。
两倍的MSL在Linux下默认约60秒。这段时间内,这条连接的端口对不会完全释放,所以高并发短连接服务经常会看到大量TIME_WAIT堆积。
5.3 端口不可用的经典现场:Docker映射和短连接杀手
很多朋友在容器环境遇到这个报错:
code复制error response from daemon: ports are not available: exposing port tcp 0.0.0.0:xxxx: bind: address already in use
表面上这是端口被占用,但实际排查时常常发现,不是业务进程还活着,而是大量短连接把socket留在了TIME_WAIT状态,导致端口无法立即复用。Docker在映射端口时,如果宿主机的对应端口处于TIME_WAIT且不允许复用,就会报这个错。
应对思路有这几个方向:
- 尽量用长连接,避免每发一笔业务就建一次连接。现场设备通信尤其如此,短连接在复杂网络里永远是最不稳定的设计。
- 客户端连接可以启用net.ipv4.tcp_tw_reuse,配合TCP时间戳使用。服务端listen socket通常不需要开这个,因为端口复用机制不同。
- 扩大临时端口范围net.ipv4.ip_local_port_range,从默认的32768起步改到10240以上,能延缓端口耗尽。
- 调整TIME_WAIT数量上限的tcp_max_tw_buckets,但这不是银弹,只是把问题延后或转化为其他现象。
另外要注意RST。收到RST时,连接立刻被双方丢弃,不需要走四次挥手。它通常发生在向一个不存在的端口发数据、防火墙主动切断、或者连接已超时被对端杀掉等场景。抓包看到TCP RST别觉得是错误,先看它出现在哪一步。
5.4 关闭连接时,应用层最常见的坑
一个是close和shutdown没分清。close会立即释放文件描述符,如果接收缓冲区还有数据,对端可能收到RST而不是FIN,导致对端读到的数据被截断。正确的优雅关闭应该是先shutdown(SHUT_WR)告诉对端“数据发完了”,再等对端关闭后close。
另一个是CLOSE_WAIT堆积。这是被动关闭方的常见问题,出现在上层应用收到对端FIN后,自己没有及时调用close。客户端不断重连,服务端CLOSE_WAIT越堆越多,最终进程的文件描述符耗尽。这类问题的根因通常在上层业务代码,而不是TCP参数。
6. 实战排查:三个高频TCP疑难杂症的定位套路
6.1 “IP能ping通,但modbus tcp扫描不通”怎么办
这是工业现场被问烂的一个问题。首先明确:ping通只代表ICMP通,不代表TCP端口通。排查层次依次是:
- 确认TCP端口层是否通:用telnet、nc或Test-NetConnection连一下目标IP的502端口。如果连不上,抓包看有没有SYN+ACK回应。
- SYN无响应:可能是防火墙拦截,或目标服务没监听502。在PC上用netstat -ano确认本地监听,在PLC/模块侧检查服务是否启用。
- 有SYN+ACK但ModScan还是不通:问题往往在应用层。检查Modbus TCP的MBAP头,重点是长度字段、单元标识符和功能码。ModScan默认的从站地址或功能码如果和目标设备配置不一致,表现就是“端口通但扫描无数据”。
- 超时时间太短:Modbus TCP默认响应时间通常在几十到几百毫秒。如果现场总线负载高,在Wireshark里能看到请求发出后响应间隔偏长,然后在扫描器侧已经超时。此时适当调大超时时间往往立刻见效。
抓包工具建议直接用Wireshark,过滤规则类似tcp.port == 502 or modbus。看到请求和响应对不上时,优先看MBAP里的事务标识符Transaction Identifier是否匹配。很多设备在并发请求时会乱配事务ID,上位机如果不校验,会把响应塞给错误的请求,表现得极其诡异。
6.2 “西门子PLC只有重启后才能连上一分钟”
这个现象很典型,现场排查时先别怀疑TCP协议,多数是上层通信链路或资源问题。常见原因有这么几类:
- 连接资源被占用:PLC的通信资源数量是有限的。调试软件、HMI、多个上位机同时占用连接,TCP连接数达到上限后新连接就会被拒绝。重启后资源清空,能连上一会儿,很快又被占满。
- 连接保持机制:S7通信中,有些通信方式需要周期性地发送保持连接报文。上位机如果只在启动时建立了一次连接,后续没有周期通信,PLC侧可能在几十秒后把空闲连接断开。重启后能连上一分钟,恰恰说明握手本身没有问题。
- 防火墙或NAT超时:如果跨网段通信,中间设备可能对空闲TCP流表有超时,通常就是几十秒到一分钟。表现在抓包上,是某一侧突然发出FIN或RST。
- 程序逻辑把连接关了:PLC程序或通信指令里配置了单次执行。上位机重连后完成读写,但PLC侧只允许一次通信就释放了连接。
排查时一上来就抓包过滤PLC的IP,看一分钟节点都发生了什么:是收到RST、FIN,还是根本没有包?如果连接直接消失且没有FIN/RST,优先查中间网络。如果收到了RST,多半是PLC侧主动杀连接,去查PLC里的连接配置和资源占用。
6.3 应用层通信封装:C#/Qt里怎么做才稳
很多项目里大家用C#或Qt写TCP通信,但写得不够“稳”通常是几个共性问题:没有处理粘包拆包、没有心跳保活、没有断线自动重连或者重连退避太激进。
C#里写个简单的长度前缀拆包器,思路很直观:
csharp复制// 假设协议约定:前4字节大端序表示报文长度
public byte[] TryReadPacket(byte[] buffer, ref int offset, bool bigEndian = true)
{
if (buffer.Length - offset < 4)
return null; // 头部都不完整
int len = bigEndian
? System.Net.IPAddress.NetworkToHostOrder(BitConverter.ToInt32(buffer, offset))
: BitConverter.ToInt32(buffer, offset);
if (len <= 0 || len > 8192)
throw new InvalidDataException("非法协议长度,检查粘包解析逻辑");
if (buffer.Length - offset - 4 < len)
return null; // 数据还没收完整,等下次读取
byte[] packet = new byte[len];
Array.Copy(buffer, offset + 4, packet, 0, len);
offset += 4 + len;
return packet;
}
每次收到Socket数据后,把数据追加进接收缓冲区,再循环调用TryReadPacket,直到返回null。注意这里的“大端序”判断很重要,Modbus TCP和大部分工业协议都用大端。
Qt里对应的是QTcpSocket的readyRead信号,处理思路一模一样,只是把Buffer换成QByteArray,读取时用QDataStream或者手动取前4字节。很多朋友问“qt5生成tcp”为什么收到数据不完整,八成就是忽略了TCP字节流没有消息边界这个前提。
另外一个必须做的是心跳KeepAlive。TCP本身有keepalive机制,但默认探测间隔通常很长,不适合设备场景。更稳妥的方案是应用层自己发心跳报文,比如每5秒发一个,连续2次无响应就判定连接异常,触发重连。重连时要有指数退避,避免高并发下所有客户端同一时间疯狂重建连接,把服务器打崩。
6.4 定位TCP问题最好用的工具链
- tcpdump:服务器排查首选,轻量无图形界面。
- Wireshark:本机或远程抓包后做深度分析,Expert Info会帮你分类重传、重复ACK、零窗口等事件。
- ss/netstat:看所有TCP连接的State、Send-Q、Recv-Q。Recv-Q长期不为0说明应用层没读数据,Send-Q长期不为0说明对端不消费或网络拥塞。
- nstat:快速看内核协议栈统计信息,比如TCPRetransSeg、TCPLostRetransmit、ListenOverflows等。
- 云上产品:如果现场不好抓包,可以用云厂商的流量镜像/拨测工具做远程链路分析。
我个人排查TCP问题时,会先看连接状态和队列,再决定是否抓包。比如Send-Q涨到几十MB却有连接,先怀疑对端读取慢;SYN_RECV堆积则看backlog;TIME_WAIT多则看业务建连频率。抓到包以后,优先看时间列,计算RTT和重传间隔,用相对时间而不是绝对时间,这样才能快速判断瓶颈在哪一跳。
最后再分享一个经验:TCP协议栈的很多“疑难杂症”,到最后都发现不是协议本身的问题,而是应用层写得太糙,或者中间网络的默认行为和我们预期不一致。抓包永远是最好的老师,别急着改参数,先看清楚数据在每一跳上到底发生了什么。
