
干这行快十年了TCP和UDP这两个词几乎天天见面试要问三次握手四次挥手写上位机要调Modbus TCP做嵌入式要拿Zynq跑UDP测试连PLC的FX5U做主站也绕不开TCP Socket连接。很多刚接触网络编程的兄弟总把“TCP可靠、UDP不可靠”挂在嘴边可真让他们抓包看一次握手、分析一次重复ACK再聊一聊为什么UDP也会丢包立刻就卡壳了。这篇就把这两个协议从设计思路到常见坑完整过一遍重点放在实际排查和协议解析的落地上。适合刚入门的学生、写上位机和嵌入式软件的朋友、做运维调试的同行你会发现很多理论书上没写透的东西在真实报文里其实特别直观。1. 协议整体设计与思路拆解做网络调试这些年你先得想清楚一个问题TCP和UDP到底在解决什么不同的问题。我见过太多人拿着TCP的轮子去套UDP的诉求也见过有人因为UDP丢包骂链路结果发现是自己接收线程没处理好。只有把两个协议的设计意图吃透后面所有排查和选型才有依据。1.1 为什么TCP牢靠而UDP轻快两种极端的设计哲学TCP的核心设计目标只有一个让不可靠的IP网络“看起来”像一条可靠的信道。为了这个目标TCP在两端都维护连接状态包括发送序号、接收序号、确认号、滑动窗口、拥塞窗口、重传计时器。每个报文发出后发送方必须等接收方回ACK确认没收到就重传到达的报文要按序号排序。你可以把TCP理解成挂号信每一封都要签收寄丢了要补寄收件人还要按编号整理好再交给上层。代价是额外的链路开销和处理时延都藏在这里面。UDP走的是完全相反的路线我只看端口把数据报往网络里一扔就完事不确认、不重传、不排序甚至不理会对端是否真的活着这就是“尽力而为”。像你在教室往最后一排扔纸飞机扔出去就算完能不能到全看缘分。UDP头部固定就8个字节源端口、目的端口、长度、校验和一个连接状态都不维护处理一个UDP报文的开销比TCP小一个数量级。这两个极端设计导致了什么结果TCP的协议栈复杂状态机有十一种状态初次实现和排障的难度明显更高UDP的协议栈非常薄应用只需要一个socket发出去就完事。如果业务能容忍偶发丢包、对实时性要求高UDP往往更合适如果数据一个字节都不能少那就只能选TCP也别抱怨握手慢、队头阻塞。这个取舍没有对错只有适合不适合。1.2 UDP和TCP协议的区别什么时候用哪个我给自己整理过一个对照表每次选型都拿它过一遍维度TCPUDP连接管理需要三次握手建立连接、四次挥手释放无连接发数据前不建立会话可靠性确认、重传、排序端到端可靠尽力而为不确认不重传有序性保证字节流按序到达不保证顺序可能乱序头部开销20字节起步带选项更大固定8字节传输模式面向字节流无消息边界面向数据报一条消息一个报文典型场景HTTP/HTTPS、数据库、SSH、Modbus TCPDNS、音视频、游戏同步、设备探测选型逻辑其实很简单先问三个问题。第一业务能不能容忍丢包和乱序能优先UDP地图刷新、传感器广播、视频帧偶尔丢一帧可以靠后一帧补。第二实时性刚需是不是超过可靠性音频通话宁可少一帧也不能等重传TCP的重传会造成卡顿这类场景UDP加抖动缓冲更合适。第三中间网络环境你说了不算跨运营商、跨NAT的场景很多设备对UDP的穿透支持反而更好而TCP在大流量时会因为重传放大问题。搭内网设备通信的上位机我的经验是“默认TCP确需UDP再换”。因为内网丢包不多TCP让上层代码简洁太多不用自己处理序号、确认和重试。反过来做视频流、广播发现、高并发小报文采集就选UDP。另外这些年QUIC已经把TCP的很多优势搬到了UDP上说明可靠性和实时性未必非得对立但那是另一个话题了这里点到为止。1.3 从Modbus和645协议谈起应用层如何选择承载协议热词里混着一堆“modbus tcp”“fx5u modbus tcp主站”“java 645协议解析”每次看到我都想说先把层级分清。Modbus RTU跑在串口上一主多从靠地址轮询报文是地址功能码数据CRC这套电报格式Modbus TCP只是把同样的功能码和数据塞进TCP报文前面加了个MBAP报文头。CAN协议解析又是另外一个体系CAN2.0的报文ID、仲裁、DBC信号解析都在链路层和物理层跟TCP/UDP不在一个层面上。很多人拿起CAN的工具去抓以太网越抓越乱。做应用层协议解析的时候“解析”两个字到底在解析什么说白了就四件事找到报文边界读出字段值处理大小端校验正确性。例如Modbus TCP里MBAP头包含事务ID、协议ID、长度字段其中的“长度”决定了这条消息有多少字节645报文里甚至有帧起始符、地址域、数据标识、校验和任何一个字段错位都会导致整帧解析失败。UDP天然有消息边界一个数据报就是一条消息解析起来省心TCP是字节流你收到1024字节可能包含两条半报文必须靠长度字段或者固定分隔符自己切包。我选型时会重点参考这一点协议本身是否自带长度字段。所以别把TCP跟“可靠”盲目绑定也别把UDP当“不可靠”直接排除。应用层协议栈叠加在传输层之上选哪个传输层取决于消息模型而不是单纯的信道可靠性。理解了这层关系再看下面那些具体机制就容易多了。2. 核心细节解析与实操要点讲完设计哲学接着把TCP和UDP的细节掰开看。这部分是我面试新人必聊的也是实际抓包排障时最常碰到的点。2.1 TCP三次握手不是形式主义是双方状态同步三次握手的过程大家都听过客户端发SYN服务端回SYN-ACK客户端再回ACK。关键是为什么必须有第三次我打个比方甲给乙写信说“我能听到你”乙回信说“我也能听到你”如果甲不回复乙没法确认甲真的收到了自己这封回信。同理第二次握手之后服务端需要收到确认才能把连接从半连接转成正式连接第三次ACK就是客户端告诉服务端“我收到了你的序号我们可以开始传数据了”。三次握手真正完成的是双方序列号的同步这是可靠传输的地基。抓包时怎么看tcpdump或Wireshark里你会看到SYN、SYN-ACK、ACK三个报文依次出现每个包都带Seq和Ack号。第一次握手客户端说“我的初始序列号是x”服务端在第二次握手时回“我收到了x1我的初始序列号是y”客户端第三次握手时说“我收到了y1”。三个报文一发一收序列号才对齐。我在教学里经常遇到有人问为什么不是两次握手因为如果不加第三次服务端在收到一个迟到的旧SYN时就会误建一条无效连接白白占用资源。实际排障中比理论更常见的是三次握手不完整。客户端SYN发出后一直停在SYN_SENT常见原因是服务端端口没监听这时对端回的是RST或者客户端发出的SYN被防火墙丢了服务端根本没收到。还有一种很隐蔽的情况服务端程序卡在accept之前的某个地方导致内核全连接队列被占满客户端表现为“能连通但一直被断开”或者握手超时。查握手问题先把listen队列和accept循环调出来看再层层往外揪。第三次ACK能否携带数据可以但很少有人主动这么干。绝大多数实现里第三次ACK是空的只有序号没有载荷个别场景会开启TCP Fast Open在握手里带数据来减少往返时延属于进阶玩法。新手先记住标准流程就好。2.2 TCP四次挥手与连接释放陷阱挥手为什么是四次因为TCP是全双工的两边都要各自关闭自己的方向。客户端发FIN表示“我不再发数据了”服务端回ACK确认服务端发完自己的数据后再发FIN客户端回ACK。每个方向的关闭都是一对FIN和ACK所以看起来是四次。实际抓包里经常会看到三次——服务端把ACK和FIN合并在一个报文里发这是允许的优化别看到“少了一次”就以为理解错了。四次挥手后的坑主要在TIME_WAIT。主动关闭方在发送最后一个ACK后会进入TIME_WAIT等待2个MSL报文最大生存时间才完全关闭。这段时间里同一个四元组源IP、源端口、目的IP、目的端口不能被重新使用。热词里那条“java tcp客户端重连时报地址已在使用”十有八九就是这个原因——客户端快速断开重连而旧连接还在TIME_WAIT里占着同一组端口。解法是客户端用系统分配的随机端口而不是固定端口或者服务端在监听socket上设置SO_REUSEADDR下面第四章我会展开讲。和TIME_WAIT相对的是CLOSE_WAIT。如果服务端程序收到FIN后一直没有调用close连接就会挂在CLOSE_WAIT状态文件描述符一直被占着。线上排查看到几千个CLOSE_WAIT几乎可以断定是业务代码忘了关闭socket而不是网络问题。这个判断我做过太多次了先看状态分布再对应到具体的fd和代码行问题立刻就现形。2.3 看不见的可靠性序号、ACK与dup ack机制TCP可靠性的核心是序号和确认。发送方给每个字节编号接收方收到后回ACK告诉对方“你发到第N字节之前的我都收到了”。注意这是累积确认回一个ACK意味着之前全部确认完毕所以TCP不需要每个包都回一次经常是攒到一起确认。如果某个数据段丢了接收方会一直重发对前一个字节的确认这就是dup ack重复确认。发送方收到连续3个重复ACK就认为网络大概率丢了包不等重传计时器到期就立刻重发这叫快速重传。热词里的“tcp dup ack机制”指的就是这套流程。抓包时Wireshark会标记“TCP Dup ACK”和“TCP Retransmission”看到这些标记就说明链路里有丢包或者重排。不过这里有坑dup ack不一定就是丢包。网络抖动、负载均衡改变路径、多网卡出口都可能让同一个流的报文乱序接收方先收到后发的包、再收到先发的包同样会触发重复ACK。判定的关键看RTT和重传次数偶尔一个dup ack往往只是乱序连续3个以上且伴随重传才基本坐实丢包。另外要提醒一点现代内核往往开启了SACK选择性确认能精确告诉发送方丢了哪些区间排障时看到SACK选项千万不要觉得是异常这反而是优化过的表现。怎么查系统里的dup ackLinux上netstat -s会输出TCP重传相关的计数ss -s能看到连接状态分布要精确到某个连接还是得tcpdump抓一个完整流数一数重传次数和dup ack数量。我测试打流时习惯双端同时抓包左右对照哪一端先出现重传就能定位瓶颈在哪一侧。2.4 TCP端口号与协议栈基础端口号是传输层最基本的东西。TCP和UDP都用16位端口号范围0到65535分为知名端口0~1023、注册端口1024~49151、动态端口49152~65535。源端口是客户端随机选的目的端口是服务端固定的。一次连接靠四元组唯一区分源IP、源端口、目的IP、目的端口四个元素一起才能定位一条socket这也是为什么“地址已在使用”和“端口被占”是两码事。对于“tcp协议包如何修改”这种问题我想说可以改但别乱改。测试工具可以修改报文里的TTL、窗口大小、序号Wireshark的重传分析、tcprewrite这类工具都支持主要用于模拟弱网场景和测试协议栈行为改端口号和IP需要顺带更新校验和否则对端直接丢包。实际生产里更常见的是调TCP参数而不是改报文比如调整接收窗口rwnd、重传超时RTO这些是操作系统协议栈的配置范畴。协议栈分层也容易混淆。TCP/IP协议族从下往上是链路层、网络层、传输层、应用层CAN协议在链路层甚至更底层单独的645协议解析只做应用层。做协议解析时一定要知道自己站在哪一层你抓到的以太网报文里最外层是以太网头MAC然后是IP头再往里才是TCP/UDP头最后才是应用数据。好多人第一步就把偏移算错了后面全是白干。3. 实操过程与核心环节实现从理论到实操往往隔着一堆命令和代码。这一章我直接把实测过的步骤拿出来都是能重复跑通、在线下验证过的。3.1 用iperf3做UDP打流测带宽和丢包的正确姿势iperf3是我做网络评估最顺手的工具。TCP打流用来测极限带宽UDP打流用来测丢包和抖动。先说UDP服务端一条命令iperf3 -s -p 5201客户端iperf3 -c 192.168.1.10 -u -b 100M -l 1400 -t 30这里 -b 指定目标码率意思是尽力跑到100Mbps-l 是每个数据报的长度1400字节接近MTU最贴近实际大包场景-t 是持续30秒。跑完输出几行关键数据包括发送带宽、接收带宽、丢包率、抖动。丢包率是重点如果显示0.02%或者干脆是0%说明链路里UDP报文基本没丢如果显示5%以上赶紧查链路。实测下来有几个细节。UDP测试一定要显式指定 -b否则iperf3会尝试以尽可能大的速率发送直接把接收端打崩。还要在两端都跑一次测试方向不同结果可能天差地别下行好不代表上行好家庭宽带和办公网络的上下行不对称我碰到太多了。另外 -l 别乱调1400以上在多数链路里可能触发分片分片后只要一个分片丢了整个报文就废了UDP没有重传丢包率突然上升很可能就是分片引起的。怎么确认UDP打流结果可信我会在客户端和服务端同时开tcpdump抓包跑完对比两边收到的报文数。iperf3报的丢包是基于发送方计数和接收方反馈算出来的如果两边计数对不上说明链路真的有丢如果完全一样那问题就不是网络丢包而是接收端缓冲区太小导致应用层没来得及收这个坑我放第四章讲。3.2 UDP端口测试怎么确认对方端口通不通UDP没有握手测试“UDP端口是否开通”本质上是个伪命题。你发一个UDP报文到某个端口对方可能有几种反应返回RST说明端口关闭返回ICMP不可达说明路由可达但端口没监听什么都不回可能是通了也可能是报文被防火墙静默丢弃或半路丢了。最常用的工具是nc。监听端nc -u -l 12345发送端nc -u 192.168.1.10 12345然后两边随便打点字能收到就说明通了。但要注意 nc -uz 这种模式其实不太靠谱-z 模式只发探测不建会话对UDP来说跟普通发包没区别回不回都说明不了“端口开放”。我的经验是UDP探测必须要应用层配合让服务端启动监听程序在收到报文后打印日志或回一条确认这才是真正的“通”。另外UDP测试时如果发现“发得出去但收不到”先ping一下确认基本连通性再检查防火墙策略。很多系统对UDP的校验比TCP严格应用没监听端口又不开放ICMP的话你这边看到的症状就是静默超时没有任何提示。这时在服务端用tcpdump抓包能一锤定音服务端网卡上根本没收到问题在链路或防火墙收到了但应用没回问题在应用。3.3 TCP Server编程实践从asio库到C语言socket服务端编程是热词里的高频需求。先说最朴素的C语言版本不依赖任何框架流程就是 socket()、bind()、listen()、accept()、recv/send()。很多教材里只写一次accept于是新手实验就踩了“单连接”的坑服务端accept一个连接后必须再次accept才能接受第二个但多数教学代码把收发逻辑写在accept外面结果只能处理一个客户端。正确做法是accept之后开线程或丢进事件循环再回到accept等待新连接。接着是asio库做TCP server。如果你用C写网络服务asio的异步模型是主流选择。核心三件套io_context负责事件循环acceptor负责监听async_accept负责异步接受连接。核心流程是这样的boost::asio::io_context io; tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080)); acceptor.async_accept([](error_code ec, tcp::socket socket) { // 收到新连接后必须立刻再次async_accept // 否则只能处理一个连接 // 然后才是处理socket的收发 }); io.run();用asio最容易被忽略的点是“一次async_accept只能处理一个连接”必须在回调里立刻再次发起async_accept才能形成持续监听。另外回调里的socket要合理管理生命周期shared_ptr是最省心的选择裸指针加手动delete很容易出野指针崩溃。再往下是模型选型。阻塞式多线程适合连接数不多的上位机select/poll适合几十个连接的中等规模epoll是Linux下高并发的事实标准几万连接都靠它asio帮你把事件循环和回调封装好了不用自己写epoll但回调地狱也是成本。我用asio写过和PLC做Modbus TCP的网关几百个连接很稳但如果只做单连接点对点拿原生socket反而更简单。选型没有银弹看业务体量说话。3.4 跨语言/跨平台的TCP交互LabVIEW、C#、Modbus现场工程现场很少只用一个技术栈。LabVIEW上位机与NI实时机做TCP交互时大家问得最多的是“怎么知道收到了多少数据”。TCP是字节流LabVIEW的TCP Read控件在不知道对方发多长时可以先做一次小读解析报文头里的长度字段再按长度读剩余。NI实时机上可以开一个TCP Listen上位机作为Client去连信息量查询可以用TCP Properties节点里的Bytes to Read属性但这只能告诉你当前缓冲区里有多少数据不保证一次读完一帧。最稳的做法还是自己定义帧协议头部带长度字段。C#写Modbus TCP客户端时坑主要在字节序上。Modbus寄存器是16位大端但C#里常见的字节数组操作习惯是小端思维很容易把字的顺序弄反事务ID和协议ID也要按MBAP头正确填。我见过同事写完寄存器读写值一直不对最后发现就是高低字节反了。另外C#里TcpClient.GetStream()返回的网络流读写时要小心多次读半包的情况一定要按长度循环读够。三菱FX5U做Modbus TCP主站核心是Socket指令或内置的MELSEC通信协议库。PLC作为主站主动去连接上位机或触摸屏的端口需要在程序里维护连接状态打开、等待、关闭跟嵌入式TCP客户端没有本质区别。用GOT触摸屏做联调时会发现触摸屏和PLC各自占用了一个端口上位机再连就要注意端口不能冲突。Zynq的以太网UDP测试更偏嵌入式。先在petalinux里起好网络接口确认IP和MTUPC端用Python写一个UDP收发脚本往开发板发指定长度的包板子上写个监听线程回包或者打印。UDP测试我最常踩的坑是MTU板子默认MTU是1500你发个2000字节的UDP包要么被分片要么直接被丢调试时先在PC端用tcpdump看有没有分片心里就有数了。4. 常见问题与排查技巧实录协议栈用久了你终究会遇到那些“书上没写”的怪问题。这里把我踩过的和帮别人排过的典型问题记下来每条都按排查顺序给方案。4.1 “地址已在使用”重连报错的根因与解法热词里“java tcp客户端重连时报地址已在使用”我实在太熟了。场景一般是客户端程序每几秒连一次服务器连上、断掉、再连反复几次之后客户端抛BindException: Address already in use。第一次遇到的人往往会怀疑是服务器的问题其实根因在客户端这侧的TIME_WAIT状态前面2.2节解释过主动关闭方要等2MSL四元组才能释放。排查顺序是先看报错发生前客户端是否是主动断开的一方短连接模式下几乎必然是然后看TIME_WAIT数量Linux下用ss -s看到TIME-WAIT数目快速增长就能确认。解法有几个客户端不要固定本地端口让操作系统分配随机高端口四元组冲突概率极低或者给socket设置SO_REUSEADDR允许TIME_WAIT状态下的端口被重用。Java里可以在Socket.connect之前设置不过这个选项对服务端监听socket更有效客户端场景大多数时候换随机端口就解决了。有一种更隐蔽的情况报“地址已在使用”不是因为TIME_WAIT而是客户端进程绑定了同一个端口又没释放。用lsof -i :端口可以看到谁占了kill掉旧进程或等它自然释放。排查时我会先看进程列表再ss -tan看状态最后抓包确认三步走不绕弯。4.2 TCP建连失败排查从Nginx最大连接到单连接问题“Nginx作为反向代理TCP最大连接数”也是高频问题。影响TCP反向代理连接池上限的因素首先是进程文件描述符上限ulimit -n 默认可能只有1024Nginx的worker_connections参数默认也保守加上单个worker要同时处理大量长连接超过某个阈值就开始拒绝新连接。其次还有内核参数somaxconn也就是全连接队列长度默认值在不同内核版本里差别很大高并发场景必须调大否则客户端连接会卡在握手阶段。调优的组合拳ulimit -n 改成65535或更高nginx.conf里worker_connections调大把 /proc/sys/net/core/somaxconn 也调上去。改完用ss -s和lsof确认连接数涨上去了。我调过一台内网接入层真正的瓶颈在文件描述符上限而不是Nginx配置这个要一层层算清楚。再有就是“tcp单连接实验”的坑。教学实验里很多人写一个只accept一次的server然后客户端连成功一次第二次连不上就以为是Nginx或者操作系统有问题。真问题在程序逻辑accept必须在循环里或者交给异步框架不停接受。先确认是不是代码问题再查网络配置这个排查顺序能省很多时间。另外ESP01S这种WiFi模组往手机TCP连接发消息连不上基本是同一局域网下手机App没监听或者路由器开了AP隔离跟TCP协议本身没有关系。4.3 UDP丢包与乱序链路排查思路UDP丢包排查我建议按“网络丢包、协议栈丢包、应用丢包”三层来想。第一层是链路和中间设备。用iperf3 -u -b 打流丢包率持续大于0基本可以判断链路有瓶颈。第二层在接收端的协议栈Linux下UDP报文到达网卡后先进套接字缓冲区缓冲区满了就丢。用netstat -su看UdpRcvbufErrors如果非零且持续增长说明是缓冲区问题。解决办法是调大rmem_max和rmem_default同时应用程序要尽快把数据从缓冲区读走。第三层是应用处理速度跟不上即使协议栈没丢你的读线程慢了数据还是会被覆盖。区分第二三层的方法是看netstat -su的计数协议栈丢包会显示RcvbufErrors而应用丢包不会反映在那个计数里。UDP乱序又是另一种情况。因为UDP不走可靠传输多路径、多网卡、调度抖动都可能打乱顺序应用层必须自己处理。我的经验是报文里放递增序号接收端维护一个小窗口缓存乱序超过阈值再丢弃或重排。很多实时通信系统其实不重排而是靠最新帧覆盖旧帧这在音视频场景里反而是正确设计。千万别在UDP解析代码里默认“接收顺序一定等于发送顺序”这是新手最容易写错的假设。4.4 协议栈性能与观测工具汇总工具用好了排障效率完全不一样。我常用的组合是tcpdump抓原始流量Wireshark做深度分析netstat/ss看状态和计数iperf3做主动打流nc做连通性测试。一句话原则先抓到包再下结论别凭直觉猜。“tcp标定原理”这个词我理解多半来自汽车电子标定。ECU标定协议XCP/CCP通常跑在以太网上用UDP或TCP承载。标定强调的是实时读写内存变量很多工具选UDP是因为延迟低、开销小再配合自动重发机制来保证可靠性TCP则适合大批量下载标定文件。归根到底还是选型逻辑按业务对数据新鲜度和可靠性的要求选传输层。最后提醒一个容易乱的CAN协议解析和TCP/UDP解析不是一个体系。做CAN解析要处理报文ID、DLC、信号位、DBC文件属链路层范畴做TCP/UDP解析要处理IP头、端口、字节流分片属传输层以上。有人拿着Wireshark的CAN插件去解以太网报文自然对不上。先把帧格式和层级搞清楚解析工具才能选对。做了这么多年协议解析我最大的体会是抓包永远是第一现场。看技术文档看十页不如看一个真实报文直观遇到任何疑难杂症先抓包把链路层的帧、网络层的IP、传输层的TCP/UDP头逐字节对照一遍很多问题当场就清楚了。最后分享一个小技巧你可以在Wireshark里给常用端口设置自定义应用层协议映射比如把502端口直接显示成Modbus TCP把某个内部端口显示成645报文一打开抓包直接看到“这是哪类协议”排查效率能提升一大截。留下这个习惯后面不管是解析CAN还是调Modbus你都会感谢当初那个愿意抠字节的自己。