TCP/IP 报文&协议学习

最近在学习流量录制框架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个问题:

  1. 首先为什么要有协议?
  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种可能的情况:

  1. 正常一个个数据包输出
  2. 多个数据包”粘“在了一起。
  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 层的数据,就必须考虑这个问题。如果是处理应用层的数据,就不需要了。因为应用层已经做了这块的处理。

解决思路:

  1. 定长分隔,每个数据包固定长度,不足用特殊字符填充。简单粗暴,缺点也多。
  2. 使用特殊字符分隔,如果数据中有该字符,需要进行转义,不然会出现bug。
  3. 在数据包中添加长度字段,相当于payload 中使用自定义的报文格式。这个兼容性较好,但是处理逻辑很复杂,需要考虑很多情况。

最后:

 知道这些有什么用

应用层开发其实不用知道这么多底层细节,基本都是操作系统处理好tcp 层拿到有效payload 交给应用层去处理,只要关心应用层接收的数据就可以了。但是如果你需要分析网络包,或者需要直接获取网卡上的数据进行处理(比如流量录制),就需要对协议和报文有一定的认识。

这里尝试回答下最开始的2个问题,仅代表个人理解:

为什么要有协议:

协议是一种规范,保证大家都能按照这套规矩来协作,降低交互的成本。比如tcp 是一个可靠协议,规定了3次握手和4次回收,每次收到请求后要发送ack包,因为他是根据ack包来保证可靠传输的(顺序传输、丢包重传)。

协议为什么要对报文格式有约定?

首先分层带来报文的套娃结构,引入了复杂性。而且不同报文的长度不同,部分协议头长度也是可变的,如何让通信双方能够顺利编解码需要一套统一的规范。所以协议头的规范就很重要,这样大家能够根据协议头顺利的拿到下一层的报文。

  • 2
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值