最近在学习流量录制框架goreplay(GitHub - buger/goreplay: GoReplay is an open-source tool for capturing and replaying live HTTP traffic into a test environment in order to continuously test your system with real data. It can be used to increase confidence in code deployments, configuration changes and infrastructure changes.),发现需要了解TCP协议,不然读不懂相关的代码。因此开始了TCP 相关的学习,TCP/IP 协议本身是比较大的内容,这里核心关注报文结构和一次应用层请求引起的tcp交互。这里先思考一下2个问题:
- 首先为什么要有协议?
- 协议为什么要对报文格式有约定?
基本概念:
- 应用层(HTTP)的分组称为报文(message)
- 传输层(TCP)的分组称为报文段 (segment)
- 网络层的分组称为数据报 (datagram);
socket这个英⽂单词的原意是“插⼝”“插槽”, 在⽹络编程中,它的寓意是可以通过插⼝接⼊的 ⽅式,快速完成⽹络连接和数据收发。可以理解为操作系统提供的网络插槽。
套接字:UDP是二元祖(目的IP,目的端口);TCP是四元祖(源IP,源端口,目的IP,目的端口)
TCP/IP报文解析
一个HTTP包(应用层)通过TCP协议(传输层)封装,接着通过IP协议(网络层)承载,而IP报文又通过以太网(数据链路层)传输。所以从网卡抓取的包是一个完整的4层协议的包,是一层层套娃的结构。这里结合《趣谈网络协议》里的一张图理解下
也就是说一个完整的在网上跑的包都是有这4层的,可以有下层没上层(比如tcp的握手包没有http层的payload),绝对不可能有上层没下层。
接着我们就分析每一层的报文结构
Ethernet头
以太帧有好多种,我们最常用到的是Ethernet II
首先是目的MAC 6个字节,然后源MAC6个字节,接下来数据类型2个字节。
整体报文结构:
- 目标MAC地址(MAC destination address):目标主机的MAC物理地址;
- 源MAC地址(MAC source address):本机MAC地址;
- 802.1q(可选)
- 类型(Type):常见的类型有IPv4,ARP等
- 数据(Payload)
- FCS(Frame check sequence)
Type类型:
- IPv4: 0x0800
- ARP:0x0806
- PPPoE:0x8864
- 802.1Q tag: 0x8100
- IPV6: 0x86DD
- MPLS Label:0x8847
然后是数据长度,46-1500字节。对于不定长的数据包,帧最后还有4个字节的FCS(Frame check sequence)
下面是一个以太帧头示例,该报文类型为IPv4(0x8000)
因此没有特殊情况Ethernet 头首部占14(6+6+2)个字节
IP层
IP协议在网络层,IP协议主要提供了IP编址,让主机可以在不同网段之间通信,报头信息解析如下:
1、4位版本号(Version):一般是IPv4或IPv6;
2、4位头部长度(Header Length):IP报头长度;这里所指示的长度,是以4个字节为一个单位。例如,一个IP包头的长度最长为“1111”,即15*4=60个字节。IP包头最小长度为20字节。
3、8位服务类型(Type of Service)
4、16位总长度(Total Length):IP报文的总长度,包括报头和数据;
5、16位标识符(Identification),标识每个已切分的数据包;
6、3位标记(Flags):第一位保留,第二位为DF(为1不将数据分包),第三位为MF(为1表示后面还有数据包);
7、13位包偏移(Fragmenet Offset):表示IP包在原数据包中的位置;
8、8位TTL,数据包的存活时长;
9、协议(Protocol):IP报头后面的报文协议,常用协议号:
-
- 1 ICMP
- 2 IGMP
- 6 TCP
- 17 UDP
- 88 IGRP
- 89 OSPF
10、头部检查和(Header Checksum)
11、源IP地址(Source Address):本机IP地址;
12、目的IP地址(Destination):目的IP地址。
一般IPv4的首部是20个字节。
TCP报文结构:
当上层应用向TCP发送一个大数据文件时,文件数据会被切割成一段段的报文段存放至TCP缓存中,那么报文段的大小应该是多少呢?这就要提到一个值最大报文段长度(Maximum Segment Size, MSS),而这个 MSS 值最终会受到链路层传输的数据长度即 最大传输单元(Maximum Transmission Unit, MTU)的限制。在以太网中链路层协议都具有1500字节的MTU,而报文段长度一般是1460字节,还有40字节是TCP/IP的首部。
- 序号(Seq) 和 确认号(Ack) 是用于可靠传输,简单来说是一个顺序关系。
- 首部长度:TCP头的长度,即数据从何处开始。最大为15,(单位是32比特,即4个字节),与IP头中的长度定义相同。也就是对应的数值*4 才是实际字节。
- 保留(Reserved):4bit,这些位必须是0。为了将来定义新的用途所保留
- 标志(FLAG) 标记出包的一些作用
-
- URG - 紧急: 当 URG 设置为 1 时,紧急数据指针指向的数据会被优先传递, 无需经过 TCP 缓存;
- SYN - 同步: 表示开始会话请求;
- RST - 复位: 用来关闭异常连接,即不需要通过 4 次分手;
- PSH - 推送: 尽快将数据传递到上层,这种在交互式终端就会看到这个标记的包;
- ACK - 应答: 用于确认确认号的值时有效的;
- FIN - 结束: 结束连接,即发起 4 次分手;
- ECE - 显示拥塞回显: 这是和拥塞控制有关系,是通过路由设备(支持 ECN 功能)为报文段添加的一个 flag;
- CWR - 拥塞窗口减少: 发送端将通过降低发送窗口的大小来降低发送速率, 涉及拥塞控制。
- 接收窗口 用于作为流量控制,该字段用于指示接收方愿意接收的字节数量。
- 校验和(Checksum):16bit。发送端基于数据内容计算一个数值,接收端要与发送端数值结果完全一样,才能证明数据的有效性。接收端checksum校验失败的时候会直接丢掉这个数据包。CheckSum是根据伪头+TCP头+TCP数据三部分进行计算的。
- 紧急指针(Urgent Pointer):16位,在URG标志设置了时才有效。与序号字段的值相加后表示最后一个紧急数据的下一字节的序号,可以说这个字段是紧急指针相对当前序号的偏移。
- 选项(Option):长度不定,但长度必须以是32bits的整数倍。常见的选项包括MSS、SACK、Timestamp等等
抓包分析
上面是针对一个网卡抓取的tcp 8000端口一次完成的http请求(119.1 是client端,119.31是server端)。
- 44~46,3个包是3次握手
- 47是一个http GET 请求,然后48是server 端对请求的ACK,注意这还不是response,只是tcp 协议规定的每次请求都要有对应的ACK 包回复。
- 49是server端返回http response,50 是client 返回的ACK,同样也是tcp的规定。
- 53~55,这3个包是4次挥手,但是为啥只捕获3个包,这个是因为做了优化,第2次和第3次都是server端发送给client端的包,直接将他们俩合并成了1个包。 这个网上有不少资料,这里不赘述,可以参考:美团二面:TCP 四次挥手,可以变成三次吗?-51CTO.COM
查看具体报文:
TCP 粘包&拆包
首先TCP 是面向流的,对他来说不存在包的概念,包是应用层的视角人为设定的一个模型。就像家里的水孔头,假设发送端是水孔头,接收端是地面,一个个水滴是数据包。稍微松开一点点开关,可以看到一滴滴水比较清晰的落下来,就像网络不繁忙,包之间间隔时间比较长的时候。再加大一点开关,就能看到原来的水珠变成了一条水线,这个时候接收端是分不清楚到底发了多少个数据包。从报文上看,就是一个tcp头,后面带的payload(就是data)可以是多次发送合并的内容。
因此粘包和拆包是同一个事情的两面,因为存在粘包,必然会导致别的包被拆断,因此存在3种可能的情况:
- 正常一个个数据包输出
- 多个数据包”粘“在了一起。
- 一个数据包被”拆“开了,这种包叫半包。
代码重现:
server端:
func main() {
l, err := net.Listen("tcp", ":8888")
if err != nil {
panic(err)
}
fmt.Println("listen to 8888")
for {
conn, err := l.Accept()
if err != nil {
fmt.Println("conn err:", err)
} else {
go handleConn(conn)
}
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
defer fmt.Println("关闭")
fmt.Println("新连接:", conn.RemoteAddr())
result := bytes.NewBuffer(nil)
var buf [1024]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
continue
} else {
fmt.Println("read err:", err)
break
}
} else {
fmt.Println("----------recv:", result.String())
}
result.Reset()
}
}
client端:
func main() {
data := []byte("{@@@@@@@@@@@一个完整的包@@@@@@@@@@@@@}")
conn, err := net.DialTimeout("tcp", "localhost:8888", time.Second*30)
if err != nil {
fmt.Printf("connect failed, err : %v\n", err.Error())
return
}
for i := 0; i < 1000; i++ {
_, err = conn.Write(data)
if err != nil {
fmt.Printf("write failed , err : %v\n", err)
break
}
}
}
先启动server端,再运行client端,从server端的输出就可以看到粘包现象
可以看到有些包粘到一起了,有些包被截断了。
什么时候需要考虑粘包&拆包
如果是直接处理tcp 层的数据,就必须考虑这个问题。如果是处理应用层的数据,就不需要了。因为应用层已经做了这块的处理。
解决思路:
- 定长分隔,每个数据包固定长度,不足用特殊字符填充。简单粗暴,缺点也多。
- 使用特殊字符分隔,如果数据中有该字符,需要进行转义,不然会出现bug。
- 在数据包中添加长度字段,相当于payload 中使用自定义的报文格式。这个兼容性较好,但是处理逻辑很复杂,需要考虑很多情况。
最后:
知道这些有什么用
应用层开发其实不用知道这么多底层细节,基本都是操作系统处理好tcp 层拿到有效payload 交给应用层去处理,只要关心应用层接收的数据就可以了。但是如果你需要分析网络包,或者需要直接获取网卡上的数据进行处理(比如流量录制),就需要对协议和报文有一定的认识。
这里尝试回答下最开始的2个问题,仅代表个人理解:
为什么要有协议:
协议是一种规范,保证大家都能按照这套规矩来协作,降低交互的成本。比如tcp 是一个可靠协议,规定了3次握手和4次回收,每次收到请求后要发送ack包,因为他是根据ack包来保证可靠传输的(顺序传输、丢包重传)。
协议为什么要对报文格式有约定?
首先分层带来报文的套娃结构,引入了复杂性。而且不同报文的长度不同,部分协议头长度也是可变的,如何让通信双方能够顺利编解码需要一套统一的规范。所以协议头的规范就很重要,这样大家能够根据协议头顺利的拿到下一层的报文。