文章目录
初步实现简单的TCP用户态协议栈
TCP的状态迁移
下图是TCP的状态迁移图。显然,由于TCP是一种有状态协议,所以实现起来要比之前实现的那些协议复杂很多,其中包括三次握手、四次挥手、连接重置等等。同时还有错误重传、滑动窗口等机制以及各种定时器。此外,还需要为TCP实现慢启动、拥塞控制、快速重传等等算法。平时直接通过系统调用使用内核协议栈很轻松,没想到自己要实现时如此复杂。
由于要实现的东西实在太多,而我们的主要目的是理解TCP的工作流程,不沉迷于过分的细节,因此我们不妨先实现最基础的部分,就是三次握手、数据的收发以及四次挥手,并且是从服务端的角度来实现(即总是等待被动打开)。由于暂时没有实现用户接口,所以我们的服务器将对所有的端口进行监听和响应。简而言之,最终的效果就是对端向任意端口建立连接后发什么数据,咱们就回复什么数据。
TCP首部处理
在之前这篇笔记中,我们已经实现了IP协议的解析,也定义了TCP首部的结构体。此处我们简单分析一下每次回复数据的代码中需要针对首部中的几个字段做何处理。
- 源端口号和目的端口号:直接填入即可
- 32位序号seq:TCP报文用32位序号来为每一个数据字节编号,其中SYN、FIN、RST标志也要消耗一个序号。首部中的这个字段在本质上也是指示了当前数据包中第一个数据字节的序号。每次发送数据后,会接收到对端的ACK确认该数据包已正常接收,此时再将seq加上这个已确认的数据包的字节数,也就是指明下一个数据包中起始字节数据的序号。
(原则上起始的序号应是随机初始化的一个值,这里我们简单起见以0作为起始值) - 32位确认序号ack:确认序号的作用就是告知对端该序号之前的数据都已经正常接收到了,你可以从ack这个序号开始继续给我发数据。所以,我们的首部中ack字段=对端首部的seq+本次接收到的数据长度(+1,如果有SYN、FIN、RST的话)。由于目前我们没有考虑报文丢失以及超时重传的情况,因此可以简单地使首部中seq字段=对端首部的ack。
(实际上TCP会有延时ACK的机制,在连续收到多个数据包后只响应一次ACK,但这需要借助定时器来实现) - 4位首部长度:这个字段的单位是32bits,其值是指首部总共的长度占多少个32bits,所以实际的首部长度应是该字段值乘上4。(编码时要特别注意,否则将导致首部长度解析错误)
- 标志位:我们目前只关注SYN、ACK以及FIN。发送数据时可以加PSH,异常时才使用RST。CWR和ECE用于实现显式拥塞通知(ECN),我们也不关心。
- 窗口大小:简单理解就是通过该字段告知对端自己目前还可继续接收的字节数,用于实现流量控制。接收端应根据自身接收缓冲区的大小来设置初始的窗口大小,当缓冲区中还有数据未被用户取走时,窗口原则上将不断减小。此处我们还没有实现滑动窗口,所以暂且将该字段设为一固定值。
- 校验和:TCP的校验和处理方式与UDP一致,在校验的算法上与IP首部的校验计算也是一致的。但要注意的是,TCP和UDP的校验数据不仅只包含其报文本身,还需要在前面临时加上一个伪首部,这个伪首部中包含了IP首部的一些信息;同时,由于总的数据bit数不一定能够被16 bits整除,此时还要在数据末尾填充0进行补齐。需要参与校验计算的所有数据总体如下图:
伪首部的主要意义在于方便对端传输层确认自己的IP层没有抽风把地址错误的报文传给自己。 - 紧急指针:不关注。
- 选项:一般情况下,TCP首部长度是20字节(4位首部长度=5),有时会带有选项字段,那么4位首部长度就不是5了。实际的头部总长度减去20字节就是选项字段的长度,这些选项一般用于三次握手过程中通知对端自己的MSS等参数。我们也可以暂时不处理。
TCP控制块(TCB)
我们已知TCP是有状态的协议,当一个客户端通过SYN
报文发起连接后,在该连接断开之前,协议栈需要一直保存这个连接的状态信息。并且,同时有多个连接存在时,需要能够区分不同的TCP数据报属于哪一个连接。因此,我们需要通过TCP控制块(TCB)来记录每一个连接的状态,并且将其挂在一个容器中,每次接收到报文后从容器中取出对应的控制块来处理。那么如何准确的标识每一个连接对应的控制块呢?显然是通过所谓的“五元组”,即对端IP、本地IP、对端端口号、本地端口号以及协议类型,而这些都包含在IP首部和TCP首部中,我们只需在三次握手的过程中记录这些基本信息即可。
除此之外,TCB中还可以定义一些其他信息来辅助完成TCP数据包的收发过程,包括当前连接的状态(即状态变迁图中的各个状态)、收发缓冲区的指针、序号和确认序号以及窗口大小等等。如果需要实现用户接口,则TCB中还需要包含socket的数据。
那么如何组织这些TCB呢?
协议栈中会有两个队列:半连接队列和全连接队列。其中半连接队列中存放的就是接收到SYN
报文并回复SYN
+ACK
后,尚未完成握手过程的TCB,这些连接处于SYN_RCVD
状态;半连接队列中的连接完成三次握手过程后(即接收到最后一个ACK
后)就会被加入到全连接队列,此时这些连接就处于ESTABLISHED
状态。基于此,我们也使用链表实现两个队列存放这些TCB即可。
定义描述TCB的结构体如下:
struct tcb // tcp 控制块
{
struct tcb* next; // 链表节点
_u32 remote_ip; // 远端ip
_u32 local_ip; // 本地ip
_u16 remote_port; // 远端端口号
_u16 local_port; // 本地端口号
int status; // 连接状态
_u8* recv_buf;
_u8* send_buf;
_u32 seq_num; // 本次发送时的 seq,等于上次发送的 seq + 本次发送的 data length (SYN、RST、FIN 占 1)(没有数据则等于本次接受到的 ack)
_u32 ack_num; // 本次发送时的 ack,等于本次接收到的 seq + 本次接收的 data length (SYN、RST、FIN 占 1)
_u32 ack_recv_next; // 下一次接收时应该收到的正确 ack,等于本次发送时的 seq + 本次发送的 data length (SYN、RST、FIN 占 1)
_u16 win_size;
_u16 ip_id; // ip数据报id
};
关于tcb队列的操作其实就是链表的操作,此处就不详细说明了
对于所有TCP的状态的定义如下:
enum _tcp_status
{
TCP_STATUS_CLOSED,
TCP_STATUS_LISTEN,
TCP_STATUS_SYN_REVD,
TCP_STATUS_SYN_SENT,
TCP_STATUS_ESTABLISHED,
TCP_STATUS_FIN_WAIT_1,
TCP_STATUS_FIN_WAIT_2,
TCP_STATUS_CLOSING,
TCP_STATUS_TIME_WAIT,
TCP_STATUS_CLOSE_WAIT,
TCP_STATUS_LAST_ACK,
};
几个辅助函数
- 从首部中提取出TCP数据长度
static _u16 tcp_get_payload_len(struct tcp_packet* tcp)
{
_u16 tcp_len = ntohs(tcp->ip.total_len) - tcp->ip.header_len*4; // ip数据报总长度减去ip首部长度得到TCP报文长度
return tcp_len - tcp->tcp.header_len*4; // 减掉TCP首部长度就是紧跟其后的实际数据长度
}
- 发生握手时新建一个tcb
static struct tcb* tcp_new_tcb(struct tcp_packet* packet)
{
struct