最近在读《Unix网络编程》,感觉以前看的太粗糙,很多细节都没有深究,这次重读重新整理下笔记,以期对网络编程的一些细枝末节能有一个较好的梳理,这是第一篇,实例分析TCP协议通信流程。
1 协议分层
一次网络请求是要经过很多层的,如底层的物理层,再上面的链路层、网络层、传输层以及应用层。当然我们一般工作是针对应用层,但是也需要对传输层有很深刻的了解,传输层个人感觉也是最复杂的。下图是TCP/IP协议分层图,注意,虽然ARP和RARP协议都划分在链路层,实际上IP、ARP和RARP数据报都需要以太网驱动程序来封装成帧;同样的,ICMP和IGMP协议虽然划分在网络层,实际它们都需要IP协议来封装成数据报。
图1中没有画出的物理层,指的是电信号的传递方式,比如现在以太网通用的网线(双绞线)、早期以太网采用的的同轴电缆(现在主要用于有线电视)、光纤等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。
链路层有以太网、令牌环网等标准,链路层负责网卡设备的驱动、帧同步、冲突检测、数据差错校验等工作,现在我们平时接触到的基本是以太网。交换机是工作在链路层的网络设备,可以在不同的链路层网络之间转发数据帧(比如十兆以太网和百兆以太网之间、以太网和令牌环网之间),由于不同链路层的帧格式可能不同,交换机要将进来的数据包拆掉链路层首部重新封装之后再转发。
网络层则主要是经常提及的IP协议,IP协议不保证数据传输的可靠性,数据包在传输过程中可能丢失,可靠性可以在上层协议或应用程序中提供支持。路由器是工作在第三层的网络设备,同时兼有交换机的功能,可以在不同的链路层接口之间转发数据包,因此路由器需要将进来的数据包拆掉网络层和链路层两层首部并重新封装。
传输层则是TCP和UDP协议,TCP协议保证数据收发的可靠性,丢失的数据包自动重发,上层应用程序收到的总是可靠的数据流。UDP协议不面向连接,也不保证可靠性。
应用层则是我们自己的应用程序。而在应用程序里面发送的数据,在网络上则不仅仅是那些数据本身,还有各个协议头部,数据包的封装过程如图二所示。各层协议头部和内容在接下来会通过一个例子来分析。
2 基于TCP协议的编程
2.1 以太网帧和ARP协议
从图2可以看到,数据最终都是封装成以太网帧在网络中传输。以太网帧的格式如图3所示:
在TCP编程中,两端通信之前,需要先通过ARP协议来确定指定IP的机器的物理地址,也就是它的网卡的硬件地址(MAC),MAC地址长度为48位,在网卡出厂的时候固化在网卡里面的,可以通过命令 ifconfig
来查看网卡地址。ARP协议的数据报格式如下:
比如当我们运行命令 ping 192.168.1.100
,那么需要先arp协议获取192.168.1.100
这个ip对应的MAC地址,下面是一个ARP协议的请求和响应包。通常操作系统会有ARP缓存,所以一次请求后,只要缓存没有过期,下次就可以从缓存中取而不需要发送ARP请求来获取目的IP地址的MAC地址了。
请求包
响应包
对照前面贴出来的以太网帧格式,很容易分析这两个数据包。如以太网帧的头部包含的14个字节,分别是目的地址,源地址以及协议类型。最初因为不知道目的ip地址的mac地址,所以目的地址填的是ff:ff:ff:ff:ff:ff
进行广播,协议类型是0x0806。而ARP协议的内容可以参照上图,分布是硬件类型(以太网,标志1),协议类型为IPV4(0x0800),硬件地址长度(6个字节),协议地址长度(IPV4,4个字节),操作码(ARP请求类型为1,ARP响应类型为2),发送方的MAC地址(本机mac地址),发送方ip地址(请求包里是192.168.1.106,响应包是192.168.1.100),目标MAC地址(请求时不知道目标MAC地址,所以填全0,响应包会填写目标MAC地址),目标IP地址(请求包是192.168.1.100,响应包是192.168.1.106)。
2.2 Socket编程
接下来要开始socket编程了,先写一个简单的客户端-服务端。
#服务端:server.py
import socket
def start_server(ip, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((ip, port))
sock.listen(1)
while True:
conn, cliaddr = sock.accept()
print 'server connect from: ', cliaddr
while True:
data = conn.recv(1024)
if not data:
print 'client closed:', cliaddr
break
conn.send(data.upper())
conn.close()
except Exception, ex:
print 'exception occured:', ex
finally:
sock.close()
if __name__ == "__main__":
start_server('127.0.0.1', 7777)
#客户端:client.py
from socket import *
import sys
def start_client(ip, port):
try:
sock = socket(AF_INET, SOCK_STREAM, 0)
sock.connect((ip, port))
print 'connected'
while True:
data = sys.stdin.readline().strip()
print 'input data:', data
if not data: break
sock.send(data)
result = sock.recv(1024)
if not result:
print 'other side has closed'
else:
print 'response from server:%s' % result
sock.close()
except Exception, ex:
print ex
if __name__ == "__main__":
start_client('127.0.0.1', 7777)
TCP的通信流程如下图所示,可以看到前面建立连接有三次握手,后面关闭连接有四次握手。注意图中是C语言的函数,对应到python的里面发送用的是send函数,读取用的是recv函数。要注意的是,一个TCP连接的套接字对是一个四元组,包括源IP,源端口,目的IP,目的端口。客户端和服务端的状态并不是同步的,如果客户端的ACK发送失败,可能客户端的连接是ESTABLISHED,而服务端对应连接还是SYN_RCVD状态。
3 TCP通信流程实例分析
接下来要做的就是通过wireshark来观察这个流程。先在一个终端运行 python server.py
,然后在第二个终端运行python client.py
,这个时候我们看到第二个终端输出connected
,表示连接上了。wireshark输出如下:
从图中可以看到三次握手的过程,这里我们拿出第一个SYN包来分析下数据包的格式,以太网帧的格式上面我们已经提过了,前面12个字节是目的地址和源地址,因为是本地地址,所以这都是00,然后两个字节帧类型是0800,即IP协议。后面就是IP协议栈和TCP协议栈的内容。
先看IP协议栈的格式如图7所示,数据包如图8所示,第一个字节0x45中,前4位为版本IPV4,接着4位5为首部长度,代表的是4*5=20个字节,这是指的整个IP数据包的长度。第二个字节0x00为服务类型TOS,有3个位用来指定IP数据报的优先级,现在几乎不用。然后的16位0x003c为IP包的总长度60(首部20字节+数据40字节),可以看到,从IP数据包的第一个字节45开始到最后一共是60个字节。紧接着的16位0xe963为标识,如果IP包大小超过了MTU,则需要进行拆分,这个标识字段就是用于标识哪些包分拆前是同一组的。接着的16位0x4000,前3位为标志位,其中最高位保留为0,第二位为DF(don't fragment)位为1,也就是不分片,第三位为MF(more fragments,更多分片)位为0,因为我们这里没有分片; 接着的13位是片的偏移,这里没有分片,所以为0。接下来的8位0x40为TTL,值为64(TTL在traceroute时就很有用,TTL是这样用的:源主机为数据包设定一个生存时间,比如64,每过一个路由器就把该值减1,如果减到0就表示路由已经太长了仍然找不到目的主机的网络,就丢弃该包,因此这个生存时间的单位不是秒,而是跳(hop)),再8位是协议字段,指示上层协议是TCP,UDP还是ICMP,IGMP等。我们这里是TCP,所以值为0x06。然后16位0x5356是首部校验和,只校验IP首部,数据的校验由更高层协议负责。然后的32位7f000001是源IP地址127.0.0.1,而接着的32位是目的IP地址127.0.0.1,选项为空,然后接下来的是TCP协议栈内容。
3.1 三次握手数据包解析
TCP段格式和数据段实例如图9,10所示。最开始16位0xdbb8为源端口56280,然后的16位0x1e61为目的端口7777。接着是32位序号0x2d4a6c26即759852070,注意我们看到wireshark中为了显示友好,显示的值为0,那是相对序号(可以在右键的Protocol Preference中取消相对序号选项就可以看到绝对序号了)。接着是32位的确认序号0x00000000。接着的16位中的前4位是首部长度0xa,也就是4*10=40
个字节。我们可以看到这里的TCP段正好是40个字节,也就是说没有数据部分,只有首部。接着的6位是保留位,这16位的最后6位是6个标志位0x002,分布是URG,ACK,PSH,RST,SYN,FIN,其中URG先不管,ACK是确认标志,PSH是尽快推送数据到接收进程标志,RST是复位连接标志,SYN是同步序号标志,FIN是完成数据发送标志。我们这里看到只有SYN标志置位为1,表示是同步序号。而后16位0xaaaa,表示窗口大小为43690。再接着就是16位校验和0xfe30,然后是16为紧急指针为0x0000,接着是选项字段。
TCP选项字段格式分为3部分,kind为选项的类型,length为该选项的总长度(这个总长度包括了kind和length这两个字节在内),info为选项的值,下表是常见的选项值和含义,更多选项值参见参考资料2。
kind (1字节) | length (1字节) | info (n字节) |含义
--------------------|------------------|-----------------------|
0 | 空 | 空 |表示选项表结束
1 | 空| 空| 空操作nop,一般用于填充tcp选项的总长度为4的倍数
2 | 4 | MSS| 最大段长度
3 | 3 |window scale| 滑动窗口扩大因子
4 | 2 | SACK | 选择性确认
8 | 10 | timestamp |时间戳值4字节+时间戳回显应答4字节
可以看到选项字段先是 0x02 04 ff d7表示是MSS,长度为4字节,值为65495。MSS通常等于MTU-20-20,而MTU一般设置为1500,所以一般MSS为1460,当然,考虑到TCP的选项值可能会占据最多20个字节,所以MSS也可能是1460-12-8=1440。而通常的MTU为1500,lo特殊,为65536,所以这里的MSS不是1440,而是一个比较大的值0xffd7=65495。MTU是服务器可配置的,在TCP通信过程中客户端和服务器端会协商最小的MSS作为最终值。TCP有了MSS限制,就可以保证在IP层不用分片了。再接着是0x0402是选择性确认选项,类别为4,总长度为2字节,这里表示没有info。接着的0x08 0a ff ff 85 45 00 00 00 00是时间戳,类别是8,总长度为10,内容为时间戳0xffff8545=4294935877,时间戳回显值为0x0000000=0。然后是1字节的0x01,类别为1,表示空操作,用于填充的。接着是0x03 03 07。类别为3,长度为3,值为7,表示窗口扩大因子为7。
那么第二阶段SYN+ACK和第三阶段的ACK的数据包类似,分别如下所示,SYN+ACK中的标记是SYN和ACK置位,然后时间戳回显为当前的时间;第三阶段ACK数据包是ACK标记置位和时间戳回显。
3.2 发送数据的数据包解析
下面看看发送数据的包,我们在client.py的终端输入 haha,可以看到捕获到4个数据包,第一个包是客户端发往服务端的,PSH和ACK标志置位,同时数据为haha;第二个包是服务端发往客户端的,ACK标志置位;第三个包还是服务端发往客户端的,PSH和ACK标志置位,这是服务端发送的内容为HAHA;第四个包是客户端发往服务端的,ACK标志置位,确认收到了数据。需要注意的是,客户端和服务端各自维护了一个序号,这是因为TCP是全双工通信。如果某种面向连接的协议是半双工的,通讯过程只能采用一问一答的方式,收和发两个方向不能同时传输,在同一时间只允许一个方向的数据传输,则只需要一套序号就够了,不需要通讯双方各自维护一套序号。服务端发往客户端的ACK是5,这是因为收到的数据长度为4,所以新的请求序号为5(注意都是相对序号),同理看到后面客户端发送数据的序号是5了,序号跟数据长度是相关的。
3.3 关闭连接四次握手数据包解析
关闭TCP连接时,会有四次握手,主动关闭的一方会处于TIME_WAIT状态一段时间再彻底关闭。TIME_WAIT的时间是系统配置参数tcp_fin_timeout设定的,Linux里面一般是60秒,当然我们也可以调短它,关于为什么要有TIME_WAIT的状态,主要原因有两点:其一是为了可靠的实现TCP全双工连接的终止,其二是为了允许老的重复分节在网络中消逝。第一点,假设最后客户端发送的ACK服务端没有收到,则服务端会重发FIN,这个时候处于TIME_WAIT状态的客户端连接还可以重发ACK。否则,如果连接已经关闭,则客户端会发送RST的一个分节,这样服务端会解析为一个错误,这个不是我们想要的。第二点,如果没有TIME_WAIT,那么可能在连接关闭后,新建立一个连接,IP地址和端口跟之前的一样,TCP必须防止老的重复分组或者延迟的分组在该连接后终止后再现,不然无法区分分组来自老的连接,还是新的连接的,如下图所示。当然这个情况很难出现,因为新的连接和老的连接必须是两边的IP地址和端口都一样,而且老的分组的ISN编号也要有效。通常,新的连接会采用一个随机的端口号,很难这么凑巧跟之前一样,而要之前老的分组的序列号ISN也几乎不可能有效。因此,TCP禁止处于TIME_WAIT状态的端口发起新的连接,在TIME_WAIT时间过后,建立新的连接,基本可以保证该连接以前的老的化身的重复分组已经消逝。
在第二个终端,CTRL+C关闭client.py,可以看到wireshark捕获到的数据包如下,这里看到服务端的FIN和ACK合并在了一个数据包里。而且可以看到客户端连接处于TIME_WAIT状态,过一段时间后才关闭。
4 总结
这是TCP/IP协议的第一篇,总结下各个协议的格式和数据包的分析,第二篇会重点分析下诸如SO_REUSEADDR, backlog
等参数的意义,特别是backlog参数,是查了好多资料才明白一二。
5 参考资料
- 《Linux C 一站式编程》网络编程部分,大部分协议图也来自这里。
- TCP头部选项
- time-wait-and-its-design-implications-for-protocols-and-scalable-servers
- 《Unix网络编程》部分章节