您的位置 首页 golang

基于netmap的用户态协议栈(二)

初步实现简单的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_ close D,
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 tcb* new_conn_tcb = (struct tcb*)malloc(sizeof(struct tcb));
    if(!new_conn_tcb)
    {
        log("malloc new_conn_tcb failed.\n");
        return NULL;
    }
     memset (new_conn_tcb, 0, sizeof(struct tcb));
    new_conn_tcb->remote_ip = packet->ip.src_ip;
    new_conn_tcb->local_ip = packet->ip.dst_ip;
    new_conn_tcb->remote_port = packet->tcp.src_port;
    new_conn_tcb->local_port = packet->tcp.dst_port;
    new_conn_tcb->win_size = TCP_MAX_WIN_SIZE;
    return new_conn_tcb;
}  

在半连接队列和全连接队列中查看这个连接的tcb是否存在。其中如果这个tcb位于半连接队列则应将其取出,完成三次握手后,稍后加入到全连接队列中。

 /* 如果 tcb 是在半连接队列中,则会将其从队列中取出;
   如果 tcb 是在全连接队列中,则只会返回其地址作为引用 */struct tcb* search_tcb(_u32 remote_ip, _u32 local_ip, _u16 remote_port, _u16 local_port) 
{
    // 先到半连接队列中找,找不到则到全连接队列中找
    struct tcb* tcb = find_tcb_in_rcvd_queue(remote_ip, local_ip, remote_port, local_port);
    if(tcb != NULL)
    {
        take_tcb_from_rcvd_queue(tcb);
    }
    else
    {
        tcb = find_tcb_in_estb_queue(remote_ip, local_ip, remote_port, local_port);
    }
    return tcb;
}  

TCP数据包校验和计算

 /* TCP 和 UDP 数据报的校验和计算,注意与IP头部校验和不同,需要增加伪首部 */_u16 tcp_udp_calculate_checksum(_u16 *buf, _u16 len, _u32 saddr, _u32 daddr, _u8 proto)
{
_u32 sum;
_u16 *w;
int nleft;

sum = 0;
nleft = len;
w = buf;

while (nleft > 1)
{
sum += *w++;
nleft -= 2;
}

// add  padding  for odd length   // 补齐16 bits
if (nleft)
sum += *w & ntohs(0xFF00);

// add pseudo header // 增加伪首部
sum += (saddr & 0x0000FFFF) + (saddr >> 16);
sum += (daddr & 0x0000FFFF) + (daddr >> 16);
sum += htons(len);
sum += htons(proto);
sum = (sum >> 16) + (sum & 0xFFFF);
sum += (sum >> 16);
sum = ~sum;
return (_u16)sum;
}  

【文章福利】需要C/C++ Linux 服务器架构师学习资料加群812855908(资料包括C/C++,Linux,golang技术,内核, Nginx ,ZeroMQ,MySQL, Redis ,fastdfs, MongoDB ZK ,流媒体, CDN ,P2P,K8S, Docker ,TCP/IP,协程,DPDK, ffmpeg ,大厂面试题 等)

TCP数据报的接收和发送

1 实现三次握手

 int tcp_process(struct nm_desc *nmr, _u8* stream)
{
    struct tcp_packet* tcp = (struct tcp_packet*)stream;
    /* 1 - 有 SYN 标志,则表明是一个新的连接(简单处理) */    if(tcp->tcp.syn)
    {
        // 1.1 新建一个 tcb 
        struct tcb* tcb= tcp_new_tcb(tcp);
        if(tcb == NULL)
            return -1;
        // 1.2 回复 SYN ACK 并挂到半连接队列
        tcp_handle_to_rcvd(nmr, tcp, tcb);
        add_tcb_to_rcvd_queue(tcb);
        return 0;// 直接返回
    }
    /* 2 - 非新连接,先到半连接队列中找,后到全连接队列中找 */    struct tcb* tcb = search_tcb(tcp->ip.src_ip, tcp->ip.dst_ip, tcp->tcp.src_port, tcp->tcp.dst_port);
    if(tcb == NULL)
    {   // 没有则出错
        log("search_tcb error, tcb not  exists ...\n");
        return -1;
    }
    /* 3 - 按状态机处理 */    switch (tcb->status)
    {
        case TCP_STATUS_SYN_REVD:// 当前连接状态是 SYN_REVD,需要进入 ESTABLISHED
            tcp_handle_to_estb(nmr, tcp, tcb);
            break;
......
}  

2 接收数据并回复相同的数据

 int tcp_process(struct nm_desc *nmr, _u8* stream)
{
......
/* 3 - 按状态机处理 */    switch (tcb->status)
    {
    ......
        case TCP_STATUS_ESTABLISHED:// 当前连接状态是 ESTABLISHED,处理 TCP 数据
            tcp_handle_estb_recv(nmr, tcp, tcb);
            break;
        ......
}  

函数tcp_handle_estb_recv()负责在ESTABLISHED状态下处理TCP数据报的接收和数据的提取。

由于我们要实现的是回复客户端相同的数据,因此暂且在tcp_handle_estb_recv()中调用tcp_handle_estb_send()进行TCP数据报发送。此时,tcp_handle_estb_send()要负责完成整个协议栈首部的封装,从TCP首部–>IP首部–> 以太网 首部。

 /* TCP发送数据 */int tcp_handle_estb_send(struct nm_desc *nmr, struct tcb* tcb, struct tcp_flags* flags, struct eth_header* eth, 
                                _u8* sendbuf, _u16 tcp_datalen)
{
    struct tcp_packet* tcp = (struct tcp_packet*)malloc(sizeof(struct tcp_packet) + tcp_datalen);
    if(tcp == NULL)
    {
        log("[%d]malloc tcp failed.\n", __LINE__);
        return -1;  // 暂时先直接返回
    }
    memset(tcp, 0, sizeof(struct tcp_packet));
    // TCP头部与数据
    tcp->tcp.src_port = tcb->local_port;
    tcp->tcp.dst_port = tcb->remote_port;
    tcp->tcp.header_len = sizeof(struct tcp_header)/4;
    tcp->tcp.ack = flags->ack;
    tcp->tcp.fin = flags->fin;
    tcp->tcp.rst = flags->rst;
    tcp->tcp.syn = flags->syn;
    tcp->tcp.psh = flags->psh;
    tcp->tcp.seq_num = htonl(tcb->seq_num);
    tcp->tcp.ack_num = htonl(tcb->ack_num);
    tcp->tcp.win_size = htons(tcb->win_size);
    if(tcp_datalen > 0 && sendbuf != NULL)
         memcpy (tcp->payload, sendbuf, tcp_datalen); // 数据拷贝过来
    tcp->tcp.check = tcp_udp_calculate_checksum((_u16*)&tcp->tcp, sizeof(struct tcp_header) + tcp_datalen, 
                                                            tcb->remote_ip, tcb->local_ip, IPPROTO_TCP);    // 校验和计算,包括数据
    
    // IP 头部
    _u16 tcp_send_len = sizeof(struct tcp_header) + tcp_datalen; // ip报文长度包括 TCP头部和 数据长度
    ip_enpack_header(&tcp->ip, tcb->ip_id, IPPROTO_TCP, tcp_send_len, tcb->local_ip, tcb->remote_ip);
    
    // ETH 头部
    memcpy(tcp->eth.src_mac, eth->src_mac, ETH_LEN);
    memcpy(tcp->eth.dst_mac, eth->dst_mac, ETH_LEN);
    tcp->eth.proto = eth->proto;
    nm_inject(nmr, tcp, sizeof(struct tcp_packet) + tcp_datalen);// netmap 发送数据
    free(tcp);
}  

(实际上这么写并不好,应该与接收数据报时自底向上分解首部的过程一样,自顶向下交给各协议层进行首部封装,而不是在一个函数中处理所有的首部)

3 实现“四次”挥手

为什么一般认为关闭连接时需要“四次”挥手呢?因为对于客户端和服务端来说,发送FIN的过程是各自独立的,客户端发起一个FIN后,服务端响应一个ACK后,并不一定立即也发送一个FIN;如果没有立即发送FIN,则连接会进入CLOSE_WAIT状态。从服务端应用层的角度来说,可能还有数据留在接收缓冲区中需要继续读出,或者在关闭连接之前需要做一些业务逻辑的处理。待这些都处理完毕后再调用close(),此时才会发出FIN并使连接进入到LAST_ACK状态。最后,在收到客户端发送的最后一个ACK后,连接的生命周期结束,进入CLOSED状态,此时TCB会被释放。

由于此时我们的协议栈还没有实现用户接口,所以还不存在主动调用close()来发出FIN的能力。为此,我们姑且在接收到对端发来的FIN报文后,立即也响应一个ACK+FIN,也就是说将”四次”挥手变成了“三次”挥手来处理。至少目前这样处理我们可以确保连接正常关闭,代码工作流程如下图。

代码中有两处需做处理,一是前面提及的tcp_handle_estb_recv()函数,需要判断是否接收到了FIN标志,并使连接状态变为LAST_ACK;二是增加对状态为LAST_ACK的连接进行处理的函数tcp_handle_last_ack(),该函数需确保收到了最后一个ACK,一个简单的处理方法就是判断接收到的确认序号是否是最后一次发送的序号+最后一次发送的数据长度+1(由FIN占用)。

 int tcp_process(struct nm_desc *nmr, _u8* stream)
{
......
/* 3 - 按状态机处理 */    switch (tcb->status)
    {
    ......
        case TCP_STATUS_LAST_ACK:// 当前连接状态是 LAST_ACK
            // 接收到最后一个 ack ,将连接的 tcb 删除
            tcp_handle_last_ack(tcp, tcb);
            break;
}  

 static int tcp_handle_last_ack(struct tcp_packet* tcp, struct tcb* tcb)
{
    if(ntohl(tcp->tcp.ack_num) == tcb->ack_recv_next)// 确保收到的是最后一个 ACK
    {
        take_tcb_from_estb_queue(tcb);// tcb 出队
        free(tcb);// 释放资源
        return 0;
    }
    return -1;
}  

文章来源:智云一二三科技

文章标题:基于netmap的用户态协议栈(二)

文章地址:https://www.zhihuclub.com/101793.shtml

关于作者: 智云科技

热门文章

网站地图