用抓包的方式理解 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,如下图所示:

Wireshark 抓包筛选规则

Wireshark 抓包筛选规则

点 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 打开:

learn_tcp.pcapng

报文颜色解释

这里面总共有 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,图的下方有标注。

TCP 报文头

TCP 报文头格式(图片来源:https://nmap.org/book/images/hdr/MJB-TCP-Header-800x564.png

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 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 值,他是客户端这边随机生成的。

TCP 报文中的实际 Sequence Number

后面客户端发送的所有报文中的 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

这条报文的详细信息如下:

SYN-ACK 报文详细信息

服务端收到上一条报文后,拿到了 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 状态,断开连接

Note

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 这样的原始资料也应该看一看,后面再慢慢学习吧。

参考资料

  1. TCP 的那些事儿(上)-酷壳

  2. TCP编程-廖雪峰的官方网站

  3. Web 协议详解与抓包实战-极客时间

  4. 一条视频讲清楚TCP协议与UDP协议-什么是三次握手与四次挥手?-Youtube