Fork me on GitHub

从1写TCPIP协议栈7:TCPIP协议!!!!!

前言

  本次课程练习的重点来啦!!TCP(Transmission Control Protrol)协议,存在于传输层,面向字节流、带有确认与累计的滑动窗口协议。TCP可以是说IP子协议中最基础的、最完备的前辈协议,它首次实现了差错修正,弥补了IP协议与UDP协议的缺点。、

TCP协议简介(初步)

滑动窗口

  1、我们假设一条链路中有且仅有一个分组/一个数据包进行传输,那如何确定这个分组有没有到达呢?我们需要添加ACK进行接收成功的确认即可。

  2、那如果我们要保证两个分组之间有序传输呢?我们需要给ACK添加一个累计的属性(即ACK为确认成功的包序列号+1 = 下一次想要接收的包序列号)。

  3、在1、2的基础上我们已经可以实现一个有序的、可靠的数据包传输了。可是这样效率太低了,那我们如果一次性发很多包呢?这样就会产生很多问题?这些包的有序性在发送时如何保证?这些包发送的速率比接收的速率快怎莫办?其实这些我们都可以总结为一个问题点:“我们到底要在多长时间内注入多少包?”

  4、由此诞生了滑动窗口协议,即事先布置好发送的数个包装在一个窗口里上,每次发车都会把窗口上所有的数据包全部发送完然后慢慢等待每个包的ACK确认,如果某个包没有收到确认,发送方将进行重传。如果最先发送的包收到了ACK,这个窗口便有空间继续装下一个包,此为滑动。

  总之,分组滑动窗口解决了:

  • 数据不可靠的问题:通过维护发送方和接收方缓冲区,解决网络之间数据不可靠的问题,例如丢包、重复包、出错、乱序等。

  • 次序问题:通过滑动窗口协议,发送方和接收方可以按次序传输数据,从而保证数据次序正确。

  • 吞吐量问题:通过滑动窗口协议可以提高吞吐量。在传统的停止等待协议中,每发送一个包都需要等待对方的确认包,这样就会造成很大的延迟和低吞吐量。而在滑动窗口协议中,发送方可以连续发送多个数据包,并且不需要等待对方的确认,从而提高了数据传输的效率。

拥塞控制

  拥塞控制一般有速率的流量控制,比如根据环境的上线计算一个发送速率的上限值。另一种是基于窗口大小的流量控制,比如通过限定窗口的大小来限定一次性发送包的数量,窗口大则发的多,窗口小则发得少。会过来继续上面的内容,基本的滑动窗口协议已经帮助我们解决了数据的有序可靠传输,一定程度上也提高了传输效率。但是还有一个亟待解决?滑动协议的窗口具体应该多大?大了的话会不会导致延时?小的话比如1会不会导致失去作用?既然我都要,那就设计为动态的吧。具体动态如何变化我们后文继续说。

TCP头部

TCP头部仍然是跟在IP头部之后,TCP头部和IP头部一样一般都是20字节:

 

  • 源端口、目标端口:与IP协议中的IP源/目标IP地址进行绑定以对应唯一确定的应用进程,这一绑定关系称之为Sokcet。
  • 序列号:用来保证包次序的正确性,起始值0-2^32中间的随机值。
  • 确认号:期望接收的下一个序列号,只有在ACK位生效的前提下才有用。
  • 头部长度:TCP头部长度,以dword为单位。
  • CWR:拥塞窗口减少。
  • ECE:拥塞通知,发送方接受了一个拥塞通知。
  • URG:表示紧急指针字段有效,很少使用。紧急指针启用后,TCP协议栈会进入紧急模式,需要指明目前紧急模式的紧急点都指序列号字段+非紧急数据的第一个字节。这样才能够保证后续URG字段不置位的数据包不会导致前面紧急字段的包信息丢失(出现选择重传等)。
  • ACK:确认。
  • PSH:推送标志,接收方应该尽快给应用程序传输这个数据。
  • RST:重置链接,通常是发生错误的错误时候置位。
  • SYN:同步位,握手的期间置位。
  • FIN:结束数据传输标志,在分手的期间置位。
  • 窗口大小:控制滑动协议窗口的大小以实现拥塞控制。
  • 校验和:同UDP一样是伪intel校验和,因此每跳依次需要重新计算依次校验和。

TCP协议状态机

  图中的红色路径标注的是客户端的状态转换路径,也就是主动发起方。在完整的TCP协议开发时,我们需要给每一个线程(Socket)都要准备两种状态转换机制,也就是要求我们开发的协议栈既能作为服务器,也能作为客户端。因为是整车中ECU往往请求其他ECU的同时而被另外的ECU请求者,既是服务器又是客户端。而我们本次开发的工程代替的是服务器,因此实际上只需要考虑四个状态“Established”、“Closewait”、“Last ACK”、“Closed”之间的状态即可。这里需要强调一个参数就是2MSL(maximum segment lifetime),设置这个参数的主要原因是为了保证FIN重传,说大话就是要保证TCP链接的可靠性和完整性,还有另外一种作用就是为了确保此Sokcet不可被重复使用,否则将产生资源耗尽的情况。若是上层应用做了相关校验,也会抛出对应异常。

三握四分(链接

序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为上一次发送的序列号 + 1

确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1

  三次握手过程:

  第一次握手:客户端主动发起请求,发送同步请求报文A,报文A中SYN标志位置1,seq=客户端初始随机值(3432325086),ack确认号=0

  第二次握手:服务器发送同步应答报文B回应第一次同步请求报文A,报文B中SYN/ACK标志位置1,seq=服务器初始随机值(460807193),ack确认号=报文A的seq初始值+1(3432325086+1)

  第三次握手:客户端发送应答报文C回应服务器的报文B,报文C中ACK标志位置1,seq=客户端初始随机值+1(3432325086+1),ack确认号=报文B的seq初始值+1(460807193+1)

  三次握手的特殊情况:理论上三次握手中ack确认号总是等于前一报文的seq+1,但是会存在握手报文负载不为0的情况,此时ack=seq+len,根据本白观察,如果你合理的配置好option字段,这种情况仍然可以握手成功。比如:

	//tcphdr->hdr_flags.length = (opt_size + sizeof(_tcp_header)) / 4;
	tcphdr->hdr_flags.length = (sizeof(_tcp_header)) / 4;///将option字段划为负载数据


  四次分手过程:

  首先需要说明的是,常规的四次分手就是客户端一来一回,服务器一来一回。但是实际开发时也可以选择将服务器回复客户端的一回和服务器的一来合并到一起,这样的表现形式类似于三次分手。当然也可以服务器与客户端同时一来一回。继续三次分手过程,具体如下:

  第一次分手:客户端发送一个标志位FIN/ACK报文,此报文中seq与ack变化为当前的序列号L确认号K,以告诉接收者结束传输的位置。

  第二、三次分手:服务器回应一个FIN/ACK报文,此报文中seq为当前序列号(就是K-1),ack为L+1,以此来同时达到ACK-FIN与主动FIN的目的。

  第四次分手:客户端回应一个ACK报文,此报文中的seq为L+1ack为K+1

  四次分手中出现Retranssion的原因

  本白在进行开发调试时,发现分手会出现重传,现象为:

  首先分析就是服务器回应客户端的FIN报文没有得到应答,所以发生了重传,那为什莫会客户端应用没有回复呢?大概率就是服务器这条包回应的有问题。那就一个个字段去查:

  • 查ACK/FIN是否设置正确
  • 检查ack与seq是否符合规则
  • 检查Windows size变化是否正确
  • 检查回应的时间是否超时

  发现问题出在了seq序列上,这条报文的序列号应该为1932284099=1932283075+1024,这导致了超时重传。

  总结下重传的常见场景:

  • 超时重传:未在规定的时间内收到ACK确认将会超时重传
  • 快速重传:在规定的时间内没有收到数据包,但是收到了后续的数据包将会快速重传
  • 选择重传:(Selective Acknowledgment)当接收方收到乱序的数据包时,如果能够识别出中间缺少的数据包,就会利用SACK选项通知发送方需要重传的数据包序号范围
  • 捎带确认:(Delayed ACK)接收方因为等待一段时间才发送确认消息,在这段时间内可能会使得发送方误以为数据包丢失,从而触发重传

TCP状态机判断

代码解释如下:

void _tcp_input(_ip_addr* srcip, _eth_packet* packet)
{
	_tcp_header* tcphdr = (_tcp_header*)packet->dataptr;
	uint16_t pre_checksum = 0;
	_tcp_packet* tcppaket = NULL;

	//检查长度
	if (packet->size < sizeof(_tcp_header))
	{
		printf("_tcp_input:the packet size is error!");
		return;
	}

	pre_checksum = tcphdr->checksum;
	tcphdr->checksum = 0;

	//输入包:checksum检查
	if (pre_checksum == 0)
	{
		printf("_tcp_input:this checksum error!!!");
		return;
	}

	//输入包:Checlsum校验检查
	uint16_t checksum = checksum_peso(srcip, &_ip_cfg, TCP, (uint16_t*)tcphdr, packet->size);//就是剥去ip头部的长度,但是不是伪头部吗
	checksum = (checksum == 0) ? 0xFFFF : checksum;
	if (checksum != pre_checksum)
	{
		return;
	}

	//长度>uint16的都要进行大小端转化
	tcphdr->SrcPort = swap_order16(tcphdr->SrcPort);
	tcphdr->DesPort = swap_order16(tcphdr->DesPort);
	tcphdr->hdr_flags.all = swap_order16(tcphdr->hdr_flags.all);
	tcphdr->Seq = swap_order32(tcphdr->Seq);
	tcphdr->AckSeq = swap_order32(tcphdr->AckSeq);
	tcphdr->windowsSize = swap_order16(tcphdr->windowsSize);

	tcppaket = tcp_find(srcip, tcphdr->SrcPort, tcphdr->DesPort);
	if (tcppaket == (_tcp_packet*)NULL)
	{
		printf("_tcp_input:this is a null packet! Start sending reset packet! \n");
		tcp_reset(tcphdr->Seq + 1, tcphdr->DesPort, srcip, tcphdr->SrcPort);
		return;
	}

	tcppaket->remote_win = tcphdr->windowsSize;

	if (tcppaket->state == XTCP_STATE_LISTEN)
	{
		//检查收到的数据包是不是链接请求的数据包
		printf("tcp_process_accept: XTCP_STATE_LISTEN \n");
		tcp_process_accept(tcppaket, srcip, tcphdr);
		return;
	}

	//准备处理第三次握手
	if (tcphdr->Seq != tcppaket->ack)//根据find的结果检查是否匹配
	{
		printf("_tcp_input:tcphdr->Seq = %lld  tcppaket->ack = %lld  \n", tcphdr->Seq, tcppaket->ack);
		printf("_tcp_input:second handle  is error packet! Start sending reset packet! \n");
		tcp_reset(tcphdr->Seq + 1, tcphdr->DesPort, srcip, tcphdr->SrcPort);
		return;
	}

	//后续都是状态机的转化,不再进行发包工作
	_eth_packet_del_header(packet,tcphdr->hdr_flags.length*4);//把tco的包头移除,只剩数据场了
	switch (tcppaket->state)//目前的状态机状态
	{
	default:
		break;
	case XTCP_STATE_SYNC_RECVD:
		if (tcphdr->hdr_flags.flags & FLAG_TCP_ACK)
		{
			tcppaket->state = XTCP_STATE_ESTABLISHED;
			tcppaket->handle(tcppaket,XTCP_CONN_CONNECTED);
			//printf("TCP Syn receive ok! \n");

		}
		break;
	case XTCP_STATE_ESTABLISHED:
		printf("TCP connect ok! \n");
		if (tcphdr->hdr_flags.flags & (FLAG_TCP_ACK))
		{
			if (tcppaket->unacked_seq < tcphdr->AckSeq && tcppaket->next_seq >= tcphdr->AckSeq)
			{
				uint16_t curr = tcphdr->AckSeq - tcppaket->unacked_seq;
				tcp_buffer_add_acked_count(&tcppaket->tx_buf,curr);
				tcppaket->unacked_seq += curr;
			}
		}

		uint16_t readsize = tcp_recv(tcppaket,(uint8_t)tcphdr->hdr_flags.flags,packet->dataptr,packet->size);

		printf("readsize = %d \n",readsize);

		//fin报文
		if (tcphdr->hdr_flags.flags & (FLAG_TCP_FIN))
		{
			tcppaket->state = XTCP_STATE_LAST_ACK;
			tcppaket->ack++;
			//主动关闭
			tcp_send(tcppaket, FLAG_TCP_FIN | FLAG_TCP_ACK);
		}
		//如果是普通的ack报文,回复ack
		else if(tcp_buffer_wait_send_count(&tcppaket->tx_buf))
		{
			tcp_send(tcppaket,FLAG_TCP_ACK);
		}
		//收到了对方的数据
		else if (readsize)
		{
			tcp_send(tcppaket, FLAG_TCP_ACK);
			tcppaket->handle(tcppaket, XTCP_CONN_DATA_RECV);
		}
		break;
	case XTCP_STATE_FIN_WAIT_1:
		if ((tcphdr->hdr_flags.flags & (FLAG_TCP_FIN | FLAG_TCP_ACK)) == (FLAG_TCP_FIN | FLAG_TCP_ACK))
		{
			tcp_free(tcppaket);
		}
		else if ((tcphdr->hdr_flags.flags & (FLAG_TCP_ACK)) == (FLAG_TCP_ACK))
		{
			tcppaket->state = XTCP_STATE_FIN_WAIT_2;
		}
		break;
	case XTCP_STATE_FIN_WAIT_2:
		if (tcphdr->hdr_flags.flags & FLAG_TCP_FIN)
		{
			tcppaket->ack++;
			tcp_send(tcppaket, FLAG_TCP_ACK);
			tcp_free(tcppaket);
		}
		break;

	case XTCP_STATE_LAST_ACK:
		printf("TCP free ok! \n");
		if (tcphdr->hdr_flags.flags & (FLAG_TCP_ACK))
		{
			tcppaket->handle(tcppaket,XTCP_CONN_CLOSED);
			tcp_free(tcppaket);

		}
		break;
	}
}

注意,我们实际调试时因为本次练习的开发对象是服务器,因此作为客户端的状态XTCP_STATE_FIN_WAIT_1、XTCP_STATE_FIN_WAIT_2并没有跳入。但是完整开发时我们需要考虑ECU同时作为客户端和服务器的情况。

TCP可选字段

  TCP option字段是在TCP头部中添加功能和控制选项的一种手段,常见的选项有7种:

Option-EOL

  默认,放在TCP头末尾用于填充,用途是说明TCP首部已经没有更多的消息,应用数据在下一个32位字的开始处。

Option-NOP

  没啥含义,唯一的作用可能就是用来填充字节数至4的整数倍。

Option-MSS

  MSS(Max Segment Size) 最大报文长度选项,这也是本次开发中唯一涉及的部分,一般出现在握手的协商阶段用于告知服务器被允许传输的最大报文长度(不包含TCP与IP头),值得注意得是MSS并不是协商的结果,而是在协商阶段告知对方的一个默认值。TCP-IPv4默认值为1460字节(1460-20-20)。

Option-WSOPT

  WSOPT(Windows Scale Options)窗口缩放选项,作为一种请求用于将TCP头部中的Windows scale成倍放大的选项,WSOPT选项只能在SYN包中发送,选项字段中我们重点关注shift.cnti。cnti计数器的取值为0~14,常规的TCP窗口WS值在0~2^16之间,因此最大的WS值为2^16 * 2^14,对于centi的取值RFC1323也规定the maximum  window is guaranteed to be < 2*30 if S <= 14 (which allows windows of 2**30 = 1 Gbyte)。一般WSOPT是不会使用的,只有在用于大带宽、高延迟网络中提供海量数据服务时设置。

Option-SACK-Permitted

  SACK-Permitted在SYN包中携带,表明服务器/客户端自身是否允许对方在TCP Option字段携带选择确认选项。

Option-SACK

  SACK是在SACK-Permitted后才生效的选项字段。通常SACK-Permitted选项一般是在SYN包中发送,一旦收到对端SACK-Permitted选项后,SACK选项则可以在任意包中传输。一个ACK包中最多包含三个SACK空缺(3*8+2=26字节,TCP选项最多40字节,一般TSPOT与SACK联用会占用10字节)。对于SACK接收端来说将回重传SACK指示的空缺报文。

Option-TSPOT

  TSPOP(Time Stamp Option)时间戳选项主要是为了计算往返时间与超时重传。使用TSPOP选项时,发送方将一个32位的数值填充到时间戳数值字段(TSV);而接收方将收到的时间戳数值填充到第二个时间戳回显重试字段(TSER)。时间戳将用于计算往返时间设置超时重传。 在RFC1323中,TSOPT主要有两个用途一个是RTTM(Round-Trip Time Measurement)即根据ACK报文中的这个选项测量往返时延,另外一个用途是PAWS(protect against wrapped sequence numbers),即当时间戳小于上一个时间戳时,判定为重传包,而当时间戳间隔大于约束值时,判定重传包无效,这种做法称为防回绕序列号算法。另外还有一些其他的用途,如SYN-cookie、 Eifel Detection Algorithm 等等。

Option-UTO

  UTO(User Timeout Optiom)用户超时选项是一个比较新的选项,指明TCP发送者在确认对方未能成功收到数据之前愿意等待数据ACK的时间。一旦超时达到三次时将会通知上层,严重的还会关闭链接。因此超时值的设置必须注意。UTO选项一般出现在SYN报文、首个非SYN报文与Timeout值可能改变的报文,总之这个选项用的也不多。

Option-TCP-AO

  一种用于增强连接安全性的选项,可以理解为增强型的通信密匙,实际没有得到广泛使用,所以这部分我也没看rfc,嘿嘿。

大多数情况来说,MSS与WSOPT都是必选项。一般MSS如果出现在选项中,都是优先发送的。当然,如果选项字段的总长度不是4字节的整数倍,还会启动NOP选项填充字段,以确保整个TCP包长度。

posted @ 2023-04-17 16:40  张一默  阅读(130)  评论(0编辑  收藏  举报