TCP协议实验全记录:从抓包验证三次握手到粘包与重传排查 1. 为什么把实验看得比背协议重要环境准备与观测手段先说个现象。我面试过不少自称熟悉TCP的候选人三次握手画图没问题但一追问“TIME_WAIT为什么存在”“服务端大量TIME_WAIT怎么处理”“半开连接怎么发现”基本就卡住了。原因很简单大多数人没亲手做过TCP相关的实验没见过真实报文所有结论都停留在“书上这么写”。这篇文章是我近期做的一轮TCP实验的完整记录从抓包环境搭建、三次握手和四次挥手的报文拆解到粘包复现、重传和半开连接排查再到用C语言和ASIO自己写服务端做验证。整个流程跑下来很多之前模模糊糊的概念都落地了。适合正在学TCP/IP的初学者参考也适合被线上连接异常坑过的开发同学对照排查。1.1 先想清楚实验要回答什么问题做实验最忌讳的是“为了抓包而抓包”。我开始之前先列了一份问题清单每个问题都对应一个具体的观察目标三次握手里SYN、SYNACK、ACK各自携带哪些关键字段序号是怎么同步的。四次挥手的每个阶段两端分别处于什么状态TIME_WAIT出现在哪一端。连续多次调用send()TCP真的会把数据粘成一包发给对端吗。连接断开时RST和FIN有什么区别什么场景会触发RST。丢包发生后TCP从多少毫秒开始重传快速重传的条件是什么。客户端断电不通知服务端服务端要多久才能感知连接失效。这条清单贯穿了后面所有实验。抓完每一轮包我都要求自己能回答三个问题看到了什么、为什么是这样、改哪个参数会改变行为。否则实验就只是形式上的“看包”和背概念没什么区别。1.2 抓包工具的准备与过滤语法抓包工具我准备了Wireshark加tcpdump两个。日常交互式分析用Wireshark跑自动化实验、批量收集pcap文件用tcpdump另外配上nc、ss、lsof、iperf这几个命令行工具基本覆盖全部场景。Wireshark在Windows上安装时要注意勾选Npcap组件否则抓不到回环接口的流量。第一次打开界面会看到一堆网卡列表实验场景优先选Loopback: lo如果只是想看某个端口的数据可以选Any。过滤语法是这套实验里最常用的技能区分两类过滤器捕获过滤器在抓包前生效只抓满足条件的数据包。比如tcp port 9090表示只抓源端口或目的端口为9090的TCP报文。显示过滤器抓完包后用来筛选展示语法更丰富。比如tcp.flags.syn 1只看SYN报文tcp.analysis.retransmission直接标出所有重传包。命令行场景我用tcpdump下面这条命令会把整个握手和挥手过程完整存下来sudo tcpdump -i lo -nn -vv tcp port 9090 -w tcp_exp.pcap抓完包后用Wireshark打开tcp_exp.pcap按tcp.stream排序就能按连接维度把每个会话的报文串起来看。Follow TCP Stream这个功能也值得多说一句它会把一次连接里所有payload按顺序拼成一整段原始数据非常直观地展示了TCP的“字节流”模型——后面讲粘包时会用到这里。1.3 顺带把TCP在协议栈里的位置理清实验里会反复涉及“传输层”“应用层”这些词先放一张我常用的对应表层级典型协议/设备职责应用层HTTP、FTP、DNS、Modbus TCP定义业务语义与报文格式传输层TCP、UDP端到端传输TCP负责可靠性和流控网络层IP、ICMP、IGMP寻址和路由决定数据包怎么走链路层以太网、Wi-Fi、MAC地址物理网络内的帧传输TCP处在传输层负责的是“两个进程之间”的可靠字节流而不是“两台主机之间”的通信——后者是IP层的活。这个区分很关键否则你会把网卡断线、路由不通这类网络层问题误当成TCP问题排查。2. 三次握手、四次挥手的抓包过程让每个状态都有报文证据这一轮实验我用一个最简单的方案复现终端A运行一个小服务端监听9090端口终端B用nc去连接然后直接关闭。Wireshark全程记录接下来所有结论都来自真实报文。2.1 三次握手SYN、SYNACK、ACK到底交换了什么先启动服务端再从客户端发起连接。过滤tcp.port 9090能看到三条报文客户端发SYN设置SYN1携带一个初始序号ISN。服务端回SYNACKSYN1, ACK1携带自己的ISN同时把客户端的ISN加1作为确认号。客户端回ACKACK1确认号是服务端ISN加1。如果只看这三条报文的表象很多人会以为三次握手是为了让双方确认“你在线、我也在线”。但这不是完整的故事。扩展开看握手真正要解决的是序号同步问题TCP的可靠传输依赖序号把乱序、重复的报文纠正到正确顺序所以双方必须互相知道对方的起始序号。为什么至少要三次而不是两次这个经典问题值得在报文里验证。想象双方各自生成ISN客户端告诉服务端“我的序号从5000开始”服务端也告诉客户端“我的序号从8000开始”这两条信息必须都到达对端并且被确认。如果只有两次握手服务端发出SYNACK后无法确认客户端已经收到了自己的ISN后续客户端发来的第一个带数据载荷的报文如果序号不对服务端就会把它当成乱序包处理。另外注意ISN不是从0开始的Linux下它会随时间随机递增。这是为了防止一个旧连接的延迟报文被新连接当成有效数据接收。抓包时选中SYN包在Wireshark的TCP头部能看到Sequence Number两次实验之间观察这个值的变化就能体会到随机初始序号的实际含义。还有个常见困惑为什么握手阶段服务端会通告自己的窗口大小和MSS。窗口大小在每次ACK时都会同步更新是TCP流量控制的基础而MSS告诉对端“我这边单个报文最大能接受多少数据”避免IP层分片。这些字段我建议在Wireshark的Options折叠项里逐个展开看一遍比背一百遍报文格式都管用。2.2 四次挥手FIN、ACK、FIN、ACK 与 TIME_WAIT客户端发起断开时抓到了四条报文客户端FIN服务端ACK服务端FIN客户端ACK。这里有个很容易误解的点——为什么服务端不直接把ACK和FIN合并成一条报文发出去。原因是TCP允许半关闭。客户端发FIN只表示“我的数据发完了”但服务端可能还有数据要发给客户端所以服务端先回ACK表示“我收到了你的FIN”等自己这侧的数据也发完再单独发FIN。如果服务端恰好没有待发数据理论上也能合并但协议栈通常不会这么做而是按标准流程分两条走。挥手阶段的四个状态值得逐个对照客户端发出FIN后进入FIN_WAIT_1收到ACK后进入FIN_WAIT_2。服务端收到FIN进入CLOSE_WAIT此时如果服务端应用层忘了调用close()连接就卡在CLOSE_WAIT。线上排查时用ss -tn看到一堆CLOSE_WAIT基本可以断定是业务代码没释放连接。服务端发FIN后进入LAST_ACK等客户端的最终ACK。客户端收到服务端的FIN并回ACK后进入TIME_WAIT。TIME_WAIT是很多人忽略的重点。客户端在回完最后一个ACK后并不会立刻关闭而是停留2MSL时长。为什么两个理由一是确保对端收到了自己最后这个ACK如果服务端没收到会重发FIN此时客户端还在TIME_WAIT可以重发ACK二是让旧连接上延迟到达的报文在网络中自然消亡避免它们被复用在相同四元组的新连接上造成干扰。Linux下TIME_WAIT的实际时长不是严格的两倍MSL而是由内核参数tcp_fin_timeout控制默认60秒。大量短连接场景下TIME_WAIT会占用本地端口导致新建连接时报Cannot assign requested address。服务端重启时如果端口还处于TIME_WAITbind会报Address already in use解决办法是设置SO_REUSEADDR。这些都是在实验中真实踩过的坑报文里都能对应到状态。3. 粘包与拆包实验问题根源在“字节流”而非TCP本身网上搜TCP相关的热词“C tcp粘包处理”“tcp粘包”出现频率非常高。我特意用实验验证了一下粘包到底是怎么发生的以及为什么UDP没有这个问题。3.1 先亲手复现一次粘包实验设计很简单客户端在一个循环里连续调用10次send()每次发送8字节数据中间不sleep。服务端每次调read()只读一次记录读到的字节数。跑完发现服务端第一次read就可能读出80字节——10次send的数据被合并在了一个TCP段里。看Wireshark的Follow TCP Stream会更直观客户端应用层视角是10段独立数据但作为接收方收到的是一整串80字节的连续数据。这正是TCP的“流”本质发送方的send()只是把数据放进了内核发送缓冲区TCP按照MSS、窗口和Nagle算法决定什么时候组装成报文发出去。多次写入的数据可能被合并在一个报文段里一次写入的大数据也可能被拆成多个报文段。Nagle算法在这里起了主要作用当连接中还有未确认的小报文时后续的小数据会被积压到缓冲区等收到ACK再一起发出去。这个算法本意是减少网络上小报文的数量提升带宽利用率但对交互型应用很不友好尤其是和延迟确认机制配合时可能造成40毫秒左右的额外延迟。实验中让客户端发完小数据后立刻等待回包能清楚看到这40毫秒的停顿。那为什么UDP没有粘包问题因为UDP是报文模型每次send()对应一个独立数据报应用层用recvfrom()读回来时边界就是send()时那么清晰。TCP没有这个边界所有数据被揉进同一根字节流里应用层必须自己划分边界。这也是“TCP和UDP的区别”里最容易被忽视的一条不是TCP没有粘包而是TCP的模型决定了你必须处理粘包和半包。3.2 应用层的拆包方案与代码实现拆包的本质是给字节流“画边界”。我在实验里对比了三种常见方案方案优点缺点固定长度实现最简单接收方读满固定长度即为一帧小消息浪费带宽大消息无法适配分隔符灵活适合文本协议内容里不能出现分隔符需要转义长度前缀通用性强二进制协议首选需要先读长度再读数据多一次判断长度前缀是工业协议最常用的方案典型做法是4字节大端长度字段加数据体。发送端的核心逻辑只有两行uint32_t len htonl((uint32_t)payload_len); write(fd, len, 4); write(fd, payload, payload_len);接收端要小心“先收长度再收数据”会出现read()只读到一半的情况必须循环读取直到收满目标字节int read_full(int fd, void *buf, size_t n) { size_t got 0; char *p (char *)buf; while (got n) { ssize_t r read(fd, p got, n - got); if (r 0) return -1; got r; } return 0; }实际实验里我先用普通read()收数据第一次就遇到只收了2字节的情况这才理解了为什么所有成熟的网络库都会封装类似read_full这样的循环读取。C里用ASIO时对应的就是asio::async_read和asio::read它会保证读满你期望的字节数才回调本质上是把这套逻辑封装好了。顺带说一句HTTP和TCP的关系。HTTP是跑在TCP之上的应用层协议它的报文边界靠Content-Length字段和chunked编码来划分本质上也是“字节流上做拆包”的思路。抓包时你会看到HTTP请求被拆成好几个TCP段也可能一个TCP段里塞了好几个HTTP请求这正好印证了应用层协议和应用层协议栈的关系。4. 重传、RST与半开连接异常场景的复现与排查正常工作流程大家都知道但线上问题大多出在异常场景。这一轮我专门制造了些异常把TCP的几个保护机制逐个激活观察。4.1 RST拒绝连接与异常关闭的标记RST报文只有两种情况一端认为连接出错主动宣告“立即终止”。我实验了三种触发方式第一连接一个没有被监听的端口。用nc连接127.0.0.1的一个空端口客户端会立刻收到RST。抓包能看到客户端SYN发出后服务端返回的是RST而不是SYNACK。这种情况的排查思路是SYN发出后如果收到RST优先检查端口是否真的在监听、防火墙有没有放行。第二服务端主动关闭时携带SO_LINGER且linger时间为0。正常情况下close()会先发FIN走四次挥手但设置了LINGER0后close()会直接丢弃发送缓冲区数据并发送RST连接被立即重置。这个技巧在某些特殊场景有用但它是异常的会导致对端收到RST而不是正常的EOF。第三一端已经关闭连接后另一端还在往这个连接上写数据。内核发现发送缓冲区里的数据无法交给对端也会发RST。RST的特殊之处在于它不经过TIME_WAIT直接终结连接。排查线上问题时如果在Wireshark里看到大量RST要重点怀疑应用层做了非法操作比如重复close、往已关闭的连接写数据等。4.2 用netem模拟丢包观察超时重传和快速重传要主动制造丢包Linux的netem模块很好用。拿回环接口做实验加上5%的随机丢包率sudo tc qdisc add dev lo root netem loss 5%注意这只是实验环境操作千万别在生产网卡的物理接口上执行。跑完实验记得删掉规则sudo tc qdisc del dev lo root丢包后Wireshark会把重传包标成TCP Retransmission而重复确认包标成TCP Dup ACK。实验里我观察到两类重传超时重传。发送方发出一个包后在RTO超时时间内没等到ACK就重新发送。RTO不是固定值TCP会根据历史RTT动态计算初值通常从1秒开始逐步逼近真实往返时间。抓包看到重传间隔是200毫秒还是1秒能反过来推断这条链路的RTT大概是多少。快速重传。接收方收到乱序包时会立即重复发送指向缺失数据的ACK。发送方连续收到3次重复ACK就知道“数据确实丢了”不等超时直接重传。这个机制是“用重复ACK代替超时”的典型设计。丢包实验还让我理解了拥塞控制的一个细节超时重传后TCP会明显放慢发送速度拥塞窗口会被砍半甚至重置到1。抓包时看发送方在重传后的一串报文时间间隔能直观看到这种“减速”行为这是TCP的快速恢复机制在起作用。4.3 半开连接为什么服务端迟迟感知不到对端掉线半开连接实验是这样设计的客户端建立连接后直接拔掉网络模拟断电场景而不是正常关闭服务端继续运行。等几分钟后查看服务端的状态连接还停留在ESTABLISHED。原因不难理解TCP认为连接存在靠的是双向通信。客户端突然消失没有发FIN也没有RST服务端无从得知对端已经不在了除非它主动发数据并等待确认超时或者靠保活机制探测。TCP自带的Keepalive参数在Linux下的默认值是7200秒也就是两个小时后才开始发送探测包每75秒发一次连续9次无响应才判定连接失效。这个时间窗口对大多数在线服务来说太慢了。我在实验里通过setsockopt把保活时间调短int keepalive 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, keepalive, sizeof(keepalive));调整内核参数tcp_keepalive_time可以让保活更快生效但真实业务里更靠谱的做法是应用层心跳——服务端定期主动给客户端发特定心跳报文连续几次没有应答就主动关闭连接并清理资源。很多RPC框架和游戏服务器都是这么设计的。这个实验最大的价值在于让我养成了排查连接异常时先看ss -tn的习惯如果服务端堆积了大量疑似失效的ESTABLISHED连接先确认是不是半开连接再决定是调保活参数还是升级应用层心跳。5. 实验代码怎么写从C语言demo到ASIO服务端热词里“C语言写一个tcp通信demo”“使用asio库如何做tcp server”都是很实际的诉求。我这一轮也把代码完整写了一遍从最朴素的阻塞模型到稍接近生产形态的异步模型逐步过渡。5.1 一个干净的C语言echo示例最基础的服务端流程只有五个系统调用socket、bind、listen、accept、read/write。完整的回显服务端代码如下#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9090); bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)); listen(listen_fd, 16); while (1) { int conn_fd accept(listen_fd, NULL, NULL); char buf[1024]; int n read(conn_fd, buf, sizeof(buf)); write(conn_fd, buf, n); close(conn_fd); } return 0; }客户端更简单核心是connect、write、read三步#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main(void) { int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family AF_INET; inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); addr.sin_port htons(9090); connect(fd, (struct sockaddr *)addr, sizeof(addr)); write(fd, hello tcp, 9); char buf[1024]; int n read(fd, buf, sizeof(buf)); write(STDOUT_FILENO, buf, n); close(fd); return 0; }这段代码作为“跑通流程”的实验工具足够了但离真实服务差距很大——它是单线程串行处理一次只能处理一个连接一个客户端阻塞在read上其他客户端全部排队。做实验时我用它配合nc和Wireshark验证握手挥手和粘包都很方便。但如果想体验真实服务端至少要引入select、poll或者epoll或者直接换用现成的网络库。5.2 用ASIO库搭一个非阻塞TCP服务端C场景我更推荐ASIO。它现在是C标准库的一部分别名的std::execution里有它的影子实际要用一般用独立发行版或boost.asio跨平台异步模型比裸socket好用太多。一个最小的异步TCP服务端骨架如下#include asio.hpp #include iostream using asio::ip::tcp; class session : public std::enable_shared_from_thissession { public: session(tcp::socket sock) : socket_(std::move(sock)) {} void start() { socket_.async_read_some( asio::buffer(data_, 1024), [this](std::error_code ec, std::size_t len) { if (!ec) { async_write(socket_, asio::buffer(data_, len), [this](std::error_code, std::size_t) {}); } }); } private: tcp::socket socket_; char data_[1024]; }; int main() { asio::io_context io; tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 9090)); std::functionvoid() do_accept; do_accept [] { acceptor.async_accept( [](std::error_code ec, tcp::socket sock) { if (!ec) { std::make_sharedsession(std::move(sock))-start(); } do_accept(); }); }; do_accept(); io.run(); return 0; }注意这里的async_read_some和前面C语言的read()一样不保证一次读满你期望的完整请求。如果你想按长度前缀拆包应该改用asio::async_read并传入asio::transfer_exactly或者配合dynamic_buffer先读固定长度的头再根据头里的长度字段读完整body。这一点和底层read_full循环是同一套逻辑只是换成了异步回调的形式。用ASIO写实验服务端的体验是代码结构更接近线上项目的组织方式session对象天然对应一个连接资源管理通过shared_ptr解决不必像裸socket那样手动维护连接生命周期。5.3 调试辅助工具清单除了自己写代码实验过程中还有一批现成工具帮了大忙工具用途常用例子nc快速搭客户端/服务端nc -l 9090、nc 127.0.0.1 9090ss查看连接状态ss -tn、ss -tnlplsof查端口占用进程lsof -i :9090iperf测试TCP吞吐iperf -s、iperf -c 127.0.0.1tcp调试助手类GUI可视化收发数据适合快速验证协议帧格式这里面ss是最常被低估的它能直接显示连接处于LISTEN、ESTABLISHED、TIME_WAIT还是CLOSE_WAIT排查连接异常时第一手情报就靠它。iperf则是压测TCP的好帮手能快速验证带宽和丢包率实验里配合netem使用可以量化丢包对吞吐的影响。6. 做完这轮实验的几个实用心得这轮TCP相关的实验做完有几个心得值得单独记下来。第一个是关于TIME_WAIT的。之前看文章说TIME_WAIT太多会导致端口耗尽我理解得并不深。直到我自己做一个高并发的短连接压测抓包看到客户端端口号被TIME_WAIT占满、新建连接直接报Cannot assign requested address才真正明白为什么业界都在说“连接池化”“长连接优先”。对高频短连接场景要么让服务端主动断开以减少客户端TIME_WAIT堆积要么开启net.ipv4.tcp_tw_reuse并配合时间戳选项但后者有副作用生产环境要谨慎评估。第二个是TCP_NODELAY。如果业务是对交互时延敏感的小消息场景比如游戏、即时通信记得在建立连接后设置这个选项关闭Nagle算法。我实验里用两次连续send()加一次阻塞read()测出来开启Nagle和不开启对端收到数据的时延差距可以到40毫秒左右。代价是网络上可能多出一些小报文但交互体验的收益通常远大于这点带宽损失。第三个是协议设计。写通信程序之前一定要先把帧格式定清楚用固定长度、分隔符还是长度前缀写进协议文档再动代码。我看到太多项目是上线之后才开始处理粘包半包问题要么在业务代码里拼凑读缓冲要么临时改协议头改得一地鸡毛。当初在协议里多花半小时后面能省一个月的排查时间。最后再分享一个扩展方向这轮实验用的是Linux上的标准socket接口但TCP的语义在所有平台上是一致的。如果你做嵌入式开发遇到lwIP、RT-Thread、W5500、ESP32这些场景只要理解了字节流模型和握手挥手机制换的只是API外壳核心的调试思路完全可以复用比如Modbus TCP这类工业协议本质上也是标准TCP之上套了一层应用层帧格式拿Wireshark抓包一样能分析。把这轮实验跑通之后再去看那些嵌入式TCP协议栈的源码明显轻松得多。