☰
TCP协议实验全记录:从抓包验证三次握手到粘包与重传排查
2026/9/28 5:19:05 网站建设 项目流程

1. 为什么把实验看得比背协议重要:环境准备与观测手段

先说个现象。我面试过不少自称熟悉TCP的候选人,三次握手画图没问题,但一追问“TIME_WAIT为什么存在”“服务端大量TIME_WAIT怎么处理”“半开连接怎么发现”,基本就卡住了。原因很简单:大多数人没亲手做过TCP相关的实验,没见过真实报文,所有结论都停留在“书上这么写”。

这篇文章是我近期做的一轮TCP实验的完整记录,从抓包环境搭建、三次握手和四次挥手的报文拆解,到粘包复现、重传和半开连接排查,再到用C语言和ASIO自己写服务端做验证。整个流程跑下来,很多之前模模糊糊的概念都落地了。适合正在学TCP/IP的初学者参考,也适合被线上连接异常坑过的开发同学对照排查。

1.1 先想清楚实验要回答什么问题

做实验最忌讳的是“为了抓包而抓包”。我开始之前先列了一份问题清单,每个问题都对应一个具体的观察目标:

  • 三次握手里SYN、SYN+ACK、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、SYN+ACK、ACK到底交换了什么

先启动服务端,再从客户端发起连接。过滤tcp.port == 9090,能看到三条报文:

  1. 客户端发SYN,设置SYN=1,携带一个初始序号ISN。
  2. 服务端回SYN+ACK,SYN=1, ACK=1,携带自己的ISN,同时把客户端的ISN加1作为确认号。
  3. 客户端回ACK,ACK=1,确认号是服务端ISN加1。

如果只看这三条报文的表象,很多人会以为三次握手是为了让双方确认“你在线、我也在线”。但这不是完整的故事。扩展开看,握手真正要解决的是序号同步问题:TCP的可靠传输依赖序号把乱序、重复的报文纠正到正确顺序,所以双方必须互相知道对方的起始序号。

为什么至少要三次而不是两次,这个经典问题值得在报文里验证。想象双方各自生成ISN:客户端告诉服务端“我的序号从5000开始”,服务端也告诉客户端“我的序号从8000开始”,这两条信息必须都到达对端并且被确认。如果只有两次握手,服务端发出SYN+ACK后无法确认客户端已经收到了自己的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_WAIT,bind会报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而不是SYN+ACK。这种情况的排查思路是:SYN发出后如果收到RST,优先检查端口是否真的在监听、防火墙有没有放行。

第二,服务端主动关闭时携带SO_LINGER且linger时间为0。正常情况下close()会先发FIN走四次挥手,但设置了LINGER=0后,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_this<session> { 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::function<void()> do_accept; do_accept = [&] { acceptor.async_accept( [&](std::error_code ec, tcp::socket sock) { if (!ec) { std::make_shared<session>(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 9090
ss查看连接状态ss -tn、ss -tnlp
lsof查端口占用进程lsof -i :9090
iperf测试TCP吞吐iperf -s、iperf -c 127.0.0.1
tcp调试助手类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协议栈的源码,明显轻松得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询