用抓包的方式理解 TCP 协议¶
背景¶
程序员在求职的过程中,如果遇到侧重考察计算机网络基础的面试,几乎都会被问到是否理解 TCP 协议。工作快五年了,对这个问题我还是不太明白,这篇文章就尝试梳理一下这个问题。
首先不管是什么通信协议,其实都是在做一件事情,就是把信息从一端传输到另一端。比如我们用浏览器打开一篇博客,在这个场景中,浏览器是客户端,部署文章的服务器是服务端,我们要看到这篇文章,本质就是完成客户端和服务端之间基于网络的通信。
OSI 模型¶
要研究网络通信,首先得把经典的 OSI 七层模型搬出来。为了记忆这个模型我自创了一个顺口溜, 物联网传话试用 ,下面这个表格标明了它们的对应关系:
简写 |
对应的 OSI 层 |
---|---|
用 |
应用层 |
试 |
表示层 |
话 |
会话层 |
传 |
传输层 |
网 |
网络层 |
联 |
数据链路层 |
物 |
物理层 |
每一层都有一些工作在它们上面的通信协议,通信协议其实就是一种程序。当信息到达某一层时,对应的协议会按照其规则对信息进行处理,下面从低层到高层依次举例。
物理层 :WIFI 协议。我们用设备连接无线网时,操作系统会调用 WIFI 驱动程序,让无线网卡连接到 WIFI 信号。
数据链路层 :MAC 协议。MAC 协议规定每个网卡要有一个 MAC 地址。MAC 地址就是网卡的身份证号,表明了这张网卡是哪个厂子出的,对应的生产序列号是多少。理论上全世界网卡的 MAC 地址不会重复。
网络层 :IP 协议。一个联网的设备,在网络的世界中一定得有一个地址,这就是 IP 地址。有了 IP 地址,这个设备才能给另一个设备发送信息,以及接收另一个设备发过来的信息。
传输层 :TCP 协议,UDP 协议。有了 IP 地址,下面就可以开始通信了,但是因为通信的背景和需求各式各样,所以有很多不同特点的协议。
比如说打个视频电话,重要的是速度要快,实时性要保证,通话过程中缺个一两秒也可以接受,那么就可以用 UDP 协议。UDP 协议的特点就是速度快,但可能会因为网络信道不稳定而丢包。
再比如浏览网页,重要的是数据要完整,缺一点点东西可能全部的信息就乱了,响应慢一点也可以容忍,传输网页的 HTTP 报文就是 TCP 协议的。TCP 协议的特点是有一套可靠的建立连接和断开连接的机制,而且可以保证信息有序完整的传输,以及其他比较复杂的特性。即便网络信道不稳定,产生丢包,也有补救机制把缺失的数据重传补全。
会话层、表示层、应用层 这三层可以打包看成一层,在研究网络传输的这个过程里面不需要分这么细。比如上面提到的 HTTP 协议,就是浏览网页时用到的协议。
我们网购收到的快递都是用一个个袋子装起来,然后袋子上面有个标签,上面写着由哪个公司投递、发件地址和收件地址等信息,这就类似于一个报文。报文由报头和载荷组成。报头表明这是一个什么报文以及其他的一些描述性的参数信息,载荷里面装着要发送的内容。
用专业术语讲,二层的数据叫 Frame(帧),三层的数据叫 Packet,四层的数据叫 Segment,但其实都可以理解成报文,只是读取它们所使用的协议不一样。
客户端和服务端进行通信,其实就是客户端创建了一个 HTTP 的报文,然后根据 TCP 协议把他打包成 TCP 的 Segment,然后又根据 IP 协议打包成 IP 的 Packet,IP 报文再变成网络 Frame,网络 Frame 再变成二进制的比特流,经由交换机、路由器等网络设备层层转发,到达服务端。
对于 TCP 的 Segment,它的报头表明这个报文的类型是 TCP,载荷里面装着 HTTP 报文,只不过它的内容对于 TCP 协议来说不重要,它也无法理解。
刚才的比特流信息凭借 IP 地址被正确转发到了服务端所在网络的网关,再凭借 MAC 地址被交换机转发给服务端主机的网卡上。网卡收到了比特流,还原成网络 Frame,操作系统将其还原成 IP Packet,再根据 IP 协议从里面提取出 TCP Segment,TCP Segment 又被交给绑定了对应端口的应用上,这个应用程序再根据 TCP 协议从里面提取出 HTTP 报文,然后根据 HTTP 协议解析出内容,交由对应的处理函数做处理,进而构造响应报文。处理完成后,再经由相同的流程,层层打包后,把响应信息返回给客户端。
编写代码实现 TCP 通信¶
脑子里把这个过程加载完成后,就相当于有了一个知识的地图,那么现在再来讨论 TCP 协议,就能知道它在实际应用中是在什么样的场景下,完成了什么样的事情。
TCP 协议工作在传输层。根据控制变量原则,假设物理层、数据链路层、网络层都是没有问题的。会话层、表示层、应用层的信息我们尽量简化。在本地机器上用 Python 写一个 TCP 服务端和 TCP 客户端来实现一个 TCP 通信过程,然后用 Wireshark 抓包来实际看一下 TCP 协议的实际工作过程。
TCP 客户端的代码:
1# -*- coding: utf-8 -*-
2import socket
3# 创建一个 socket 应用,使用 IPV4 和 TCP 协议
4s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
5# 绑定 ip 地址为本地回环地址,5201 端口
6s.bind(("127.0.0.1", 5201))
7# 要连接的服务端 ip 为本地回环地址,端口为 5200
8s.connect(("127.0.0.1", 5200))
9# 当 TCP 连接成功建立后,打印服务端返回的信息
10print(s.recv(1024).decode("utf-8"))
11# 向服务端发送信息
12for data in [b"user1", b"user2", b"user3"]:
13 s.send(data)
14 # 打印服务端返回的信息
15 print(s.recv(1024).decode("utf-8"))
16# 想服务端发动断开连接的消息
17s.send(b"exit")
18# 客户端主动断开连接
19s.close()
TCP 服务端的代码:
1# -*- coding: utf-8 -*-
2import time
3import socket
4import threading
5# 创建一个 socket 应用,同样使用 IPV4 和 TCP 协议
6s = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
7# 绑定 ip 地址为本地回环地址,5200 端口
8s.bind(("127.0.0.1", 5200))
9# 开始监听请求,最多同时接受 5 个请求
10s.listen(5)
11print("Server is running ...")
12def tcplink(sock, addr):
13 print("Get a new connection, ip:%s, port:%s" % addr)
14 sock.send(b"Hello!")
15 while True:
16 # 解码接收到的信息
17 data = sock.recv(1024).decode("utf-8")
18 time.sleep(1)
19 # 如果接收到的信息是 exit,由服务端主动断开连接
20 if data == "exit":
21 print("Got exit message, close the connection.")
22 break
23 print("Got a data: %s" % data)
24 sock.send(("Hi, %s, I got you!" % data).encode("utf-8"))
25 sock.close()
26 print("Close a connection, ip:%s, port:%s" % addr)
27while True:
28 # 收到一条请求,交由一个线程去处理
29 sock, addr = s.accept()
30 t = threading.Thread(target=tcplink, args=(sock, addr))
31 t.start()
用 Wireshark 抓包¶
打开 Wireshark,准备抓包。
在上面的代码里,我们把客户端的端口设置成了 5201,服务端的端口是 5200。所以将筛选规则设置成这样:
1tcp port 5200 or tcp port 5201
捕获的网卡是本机的回环网卡,筛选规则是 TCP 协议,端口为 5200 和 5201,如下图所示:
点 Start,开始抓包。
先启动服务端:
1python3 tcp_server.py
再启动客户端:
1python3 tcp_client.py
服务端的运行日志是这样:
1Server is running ...17:21:36:552048
2Get a new connection, ip:127.0.0.1, port:5201, 17:21:39:253209
3Got a data: user1, 17:21:40:254525
4Got a data: user2, 17:21:41:255906
5Got a data: user3, 17:21:42:257482
6Got exit message, close the connection, 17:21:43:258953
7Close a connection, ip:127.0.0.1, port:5201, 17:21:43:259191
客户端的运行日志是这样:
1Hello! 17:21:39:253365
2Hi, user1, I got you! 17:21:40:254783
3Hi, user2, I got you! 17:21:41:256238
4Hi, user3, I got you! 17:21:42:257720
抓包得到的信息是这样:
报文文件下载,可以直接用 Wireshark 打开:
报文颜色解释¶
这里面总共有 20 条报文,他们的颜色不一样。不同的颜色代表了不同的报文类型。打开 Wireshark 的 Coloring Rules 可以看到这些每种颜色代表的含义:
红框圈出来的颜色就是我们的报文里涉及到的两种。其中淡紫色表示是 TCP 传输过程的报文,深灰色的表示 TCP 建立连接和关闭连接的报文。SYN 是 Synchronous 的缩写,意思是同步,表示建立连接;FIN 是 Finish 的缩写,意思是完成,表示关闭连接。
tcp.flags & 0x02 || tcp.flags.fin == 1
这一段看起来有点高深。
tcp.flags
是TCP报文报头的一个标志字段。TCP 报文本质上就是一行二进制数据,二进制数据就是一串 0 和 1 组成的数字串。一个 0 或 1 是 1 个比特,8 个比特是一个字节。这行二进制数据的每个位置上的数值表示固定的含义,通过查看这些值是多少就可以判断当前这个 TCP 连接的状态以及进行流量控制。下面这张图就是 TCP 报头的内容,仔细看里面有一段绿色的字写着 TCP Flags,共有 8 个标志字段,其中 S 就是 SYN ,F 就是 FIN,图的下方有标注。
0x02
是个十六进制数,把它转成二进制是 00000010。
&
意思是按位与运算。遇 0 为 0,遇 1 不变。这里就是把 tcp.flags 和 00000010 做按位与运算。如果运算结果不为 0,是真值,那就表示包含 SYN 标志,是建立连接的报文。
tcp.flags.fin
是一个布尔字段,表示 FIN 标志。如果这个值是 1,就表示包含 FIN 标志,是关闭连接的报文。
||
是个逻辑运算符,意思是有一个条件为真,整体就为真。
这一长串整体的意思就是,检查这个报文是不是包含 SYN 或者 FIN 标志,如果包含任意一个,就把它标记为深灰色。
报文内容解释¶
三次握手建立连接¶
第一次握手¶
11 0.000000000 127.0.0.1 127.0.0.1 TCP 74 5201 → 5200 [SYN] Seq=0 Win=65495 Len=0 MSS=65495 SACK_PERM TSval=1156316798 TSecr=0 WS=128
报文中每一个值的含义如下表所示:
列号 |
内容 |
解释 |
---|---|---|
1 |
1 |
Wireshark 捕获到的报文的帧编号 |
2 |
0.000000000 |
捕获到这个报文时的时间戳,是个相对时间,因为这个是第一个报文,所以从 0 开始 |
3 |
127.0.0.1 |
源 IP 地址 |
4 |
127.0.0.1 |
目标 IP 地址 |
5 |
TCP |
报文的协议 |
6 |
74 |
帧的帧长度(以字节为单位),包括以太网头、IP 头、TCP 头和数据 |
7 |
5201 → 5200 |
从 5201 端口发到 5200 端口,表示是客户端发给服务端 |
8 |
[SYN] |
标志位上 SYN 的值为 1,所以这个报文是个 SYN 报文,用于建立 TCP 连接 |
9 |
Seq=0 |
初始序号为 0 |
10 |
Win=65495 |
接收窗口大小为 65495 字节 |
11 |
len=0 |
载荷的长度是 0 字节,因为这是个 SYN 报文,不包含数据 |
12 |
MSS=65495 |
最大分段尺寸为 65495 字节 |
13 |
SACK_PERM=1 |
表示支持 SACK 选项 |
14 |
TSval=1156316798 |
时间戳值为 1156316798 |
15 |
TSecr=0 |
时间戳密钥值为 0 |
16 |
WS=128 |
窗口缩放因子为 128 |
在 Wireshark 中用鼠标选中这条报文,在报文窗口下方的窗口会提取出详细的内容。选中 Transmission Control Protocol
,再点开下面的 Flags
选项,就可以看到每个 TCP Flags 的赋值情况。
结合上面的 TCP 报头格式可以明白,标识 TCP Flags 的方式就是把对应的位置上的数字置为 1
。
在这条报文中,SYN 的位置上为 1,其他位置上都是 Not set
,因此这条报文是个 SYN 报文。
所谓 握手 ,其实就是发送一次报文。
双方每次收到报文都要做一件事情:
接收对方的 Seq 码,回复自己的 Ack 码。 A 把自己的 Seq 码发送给 B,B 收到后把这个 Seq 码 加 1 后作为 Ack 码 ,再返回给 A,这就表示 B 确实已收到 A 的包。 B 回复的这个 Ack 码,也就是 A 下一个包中的 Seq 码。Ack 码的意思是,你的下一个包传来的时候,要从这个码开始。
在建立连接和断开连接的过程中,因为没有载荷数据,所以 Ack 都是加 1。等到建立连接后,在传输数据的过程中,Ack 就不是加 1 了,而是加载荷数据的长度。 接收端在收到多个数据包后,会对所有的数据包按照 Seq 按顺序进行重构, 这个过程中就必然会发现有缺失包,他就可以再次请求重发这个缺失包。 也就是说,根据这一来一回的 Seq 和 Ack 的偏移量,就可以保证数据传输的有序、完整。
在这条报文中,Seq=0,这是一个相对值。报文信息里面有一条 Sequence Number (raw): 1784164156
,这个值是实际的 Seq 值,他是客户端这边随机生成的。
后面客户端发送的所有报文中的 Seq 值都要在这个初始值的基础上作累加,因此这个值的相对值就是 0。
客户端发送完这条报文后,自己会进入一个状态,可以用 netstat 工具查看:
netstat -tnp | grep -E '127.0.0.1:(5200|5201)'
# -t 仅显示 TCP 连接
# -n 以数字的形式
# -p 显示进程号
# grep -E 按正则筛选
# 127.0.0.1:(5200|5201) 筛选 5200 和 5201 这两个端口
查看结果:
tcp 0 0 127.0.0.1:5201 127.0.0.1:5200 ESTABLISHED 56988/python3
tcp 0 0 127.0.0.1:5200 127.0.0.1:5201 ESTABLISHED 56985/python3
这已经是三次握手结束,已经建立连接的状态了。SYN-SENT 和 SYN-RECEIVED 这两个状态持续的时间非常短,一般看不到,最常见的就是 LISTEN 、 ESTABLISHED 和 TIME-WAIT 这几个状态。
这张图就是三次握手的过程和状态:
这条报文的作用是: 客户端生成自己的初始 Seq 码,将 Seq 标志位置为 1,向服务端发送 SYN 报文,从 CLOSED 状态进入 SYN-SENT 状态 。
第二次握手¶
12 0.000019926 127.0.0.1 127.0.0.1 TCP 74 5200 → 5201 [SYN, ACK] Seq=0 Ack=1 Win=65483 Len=0 MSS=65495 SACK_PERM TSval=1156316798 TSecr=1156316798 WS=128
这条报文的详细信息如下:
服务端收到上一条报文后,拿到了 Seq 值 1784164156
,将其加 1,作为自己的 Ack 值 1784164157
。
自己再随机生成一个初始 Seq 值 2543691202
,其相对值同样也是 0。
然后将这两个信息构造成一条 SYN-ACK 报文,返回给客户端。
之所以叫做 SYN-ACK 报文,就是因为它做了两件事情,一个是向客户端同步自己的 Seq 值,这是 SYN 报文,因此它的 SYN 标志位置为 1。 另一个是确认客户端的 Seq 值,这是 ACK 报文,因此它的 ACK 标志位置为 1。 这条报文是把这两个动作合到一起了。
这条报文的作用是: 服务端收到了客户端发送的的 SYN 报文,将 Seq 码加 1 作为 Ack 码,生成自己的初始 Seq 码,将 Seq 和 Ack 两个标志位置为 1,给服务端发送一个 SYN-ACK 报文,从 LISTEN 状态进入 SYN-RECEIVED 状态 。
第三次握手¶
13 0.000033010 127.0.0.1 127.0.0.1 TCP 66 5201 → 5200 [ACK] Seq=1 Ack=1 Win=65536 Len=0 TSval=1156316798 TSecr=1156316798
这条报文的详细信息如下:
客户端收到这条报文后,拿到 Seq 值为 2543691202
,将其加 1,作为自己的 Ack 值 2543691203
。
将第二条报文中的 Ack 值作为 Seq 值 1784164157
。
将 ACK 标志位置为 1,构造 ACK 报文,发送给服务端。
这条报文的作用是: 客户端收到了服务端返回的 SYN-ACK 报文,将 Seq 码加 1 作为 Ack 码,将 Ack 标志位置为 1,构造 Ack 报文发送给服务端,由 SYN-SENT 状态变为 ESTABLISHED 状态。服务端收到这条 ACK 报文后,状态由 SYN-RECEIVED 变为 ESTABLISHED 状态,连接建立 。
四次挥手断开连接¶
这张图就是四次挥手的过程和状态:
第一次挥手¶
117 3.005417521 127.0.0.1 127.0.0.1 TCP 66 5201 → 5200 [FIN, ACK] Seq=22 Ack=50 Win=65536 Len=0 TSval=1136610107 TSecr=1136610107
这条报文的详细信息如下:
现在是由客户端主动发起关闭动作。
客户端把 FIN 标志位置为 1,向服务端发动一个 FIN 包。这里的 ACK 也置为 1 了,这个 ACK 是为了回应上一步的数据传输,并不是关闭连接的逻辑,可以忽略。
这条报文的作用是: 客户端将 FIN 标志位置为 1,构造 FIN 报文发给服务端,由 ESTABLISHED 状态变为 FIN-WAIT-1 状态 。
第二次挥手¶
118 3.046771271 127.0.0.1 127.0.0.1 TCP 66 5200 → 5201 [ACK] Seq=50 Ack=23 Win=65536 Len=0 TSval=1136610149 TSecr=1136610107
这条报文的详细信息如下:
这条报文的作用是: 服务端收到 FIN 报文后,告诉应用程序即将关闭,由 ESTABLISHED 状态变为 CLOSE-WAIT 状态,向客户端发送 ACK 报文。客户端收到 ACK 报文后,由 FIN-WAIT-1 状态变为 FIN-WAIT-2 状态,继续等待下一个 FIN 报文 。
第三次挥手¶
119 4.006796503 127.0.0.1 127.0.0.1 TCP 66 5200 → 5201 [FIN, ACK] Seq=50 Ack=23 Win=65536 Len=0 TSval=1136611109 TSecr=1136610107
这条报文的详细信息如下:
这条报文的作用是: 应用程序准备好关闭,服务端将 FIN 标志位置为 1,向客户端发送 FIN 报文,由 CLOSE-WAIT 状态变为 LAST-ACK 状态 。
第四次挥手¶
120 4.006834955 127.0.0.1 127.0.0.1 TCP 66 5201 → 5200 [ACK] Seq=23 Ack=51 Win=65536 Len=0 TSval=1136611109 TSecr=1136611109
这条报文的详细信息如下:
这条报文的作用是: 客户端收到 FIN 报文,将 ACK 标志位置为 1,向服务端发送 ACK 报文,由 FIN-WAIT-2 状态变为 TIME-WAIT 状态,等待 2 倍 MSL 的时间后,变为 CLOSED 状态断开连接。服务端收到 ACK 报文后,由 LAST-ACK 变为 CLOSED 状态,断开连接 。
备注
MSL 全称是 Maximum Segment Life Time ,意思是 TCP 报文段在网络中能够存活的最长时间。
如果在 tcp_client.py 刚执行完毕后又执行一遍,会发现报这个错误:
Traceback (most recent call last):
File "tcp_client.py", line 9, in <module>
s.bind(("127.0.0.1", 5201))
OSError: [Errno 98] Address already in use
这就是因为客户端现在处在 TIME-WAIT 状态,此时这个 TCP 连接还未完全释放,因此这个端口还不能用。大约 2 分钟之后,就可以用了。
用 netstat 也可以查看到:
$ netstat -tnp | grep -E '127.0.0.1:(5200|5201)'
tcp 0 0 127.0.0.1:5201 127.0.0.1:5200 TIME_WAIT -
小结¶
本文我按照自己的理解梳理了一下 TCP 协议的实际应用场景以及三次握手和四次挥手的过程,对于建立连接后的数据传输过程没有写,这块涉及的内容也不少,应该单独写一篇总结一下。TCP 作为一个基础的数据传输协议,内容有点复杂,除了基本的流程,还有性能优化的内容也需要了解。 《TCP/IP 详解 卷1:协议》 和 RFC793 这样的原始资料也应该看一看,后面再慢慢学习吧。