这节从tcp_receive
函数入手,逐步深入了解控制块各个字段的意义以及整个TCP层的运行机制,足足600行。源码注释的该函数功能为:检查收到的数据段是不是对已发数据段的确认,如果是,则释放相应发送缓冲中的数据;接下来,如果该数据段中有数据,应将数据挂接到控制块的接收队列上(pcb->ooseq)
。如果数据段同时也是对正在进行RTT
估计的数据段的确认,则RTT
计算也在这个函数中进行
要讲清楚tcp_receive
还得说清楚tcp_enqueue
。源码注释对该函数的功能描述很简单:将数据包或者连接的控制握手包放到tcp控制块的发送队列上。这个函数的原型为:
err_t
tcp_enqueue( struct tcp_pcb *pcb, void *arg, u16_t len,u8_t flags,
u8_t apiflags,u8_t *optdata, u8_t optlen )
其中有几个重要的输入参数:
pcb
是相应连接的TCP控制块;
arg
是要发送的数据的指针;
len
是要发送的数据的长度,以字节为单位;
flags
是TCP数据段头部中的标识字段,主要用于连接建立或断开的握手;
apiflags
表示要对该数据段做的操作,包括是否拷贝数据、是否设置PUSH标志;
optdata
表示TCP头部中的选项字段的值;
optlen
表示选项字段的长度。
tcp_enqueue
首先确认要发送的数据长度len
是否小于当前连接能用的数据发送缓冲区大小,即pcb->snd_buf
,若缓冲区不够,则不会对该数据进行任何处理(其实这个缓冲区并不存在,只是用snd_buf
标识出连接还能缓存的数据量)。
接着,将要发送的数据段的序号字段设置为pcb->snd_lbb
,然后判断pcb->snd_queuelen
值是否超过了所允许挂接的数据包的上限值TCP_SND_QUEUELEN
,如果超过了该上限值,则函数也不会对这个要发送的数据段进行处理。
接下来tcp_enqueue
函数会将数据组装成为tcp_seg
类型的数据段,根据数据长度的大小不同,可能需要几个tcp_seg
类型结构才能描述完所有的数据,每个数据段中的TCP头部部分字段值要在这里都要被设置,包括数据序号、标志字段。
最后,所有创建好的tcp_seg
类型结构都是连接在queue队列上的,queue
是函数的一个临时变量。
接下来,函数tcp_enqueue
需要将queue队列上的数据段挂接到TCP控制块的unsent
队列上,这里又有好几种情况,即unsent
队列是否为空的情况,若为空,则直接挂接,若不为空,则需要将queue
挂接在unsent
队列的最后一个tcp_seg
之后,如果挂接点处相邻两个tcp_seg
所包含的数据大小小于最长发送段大小pcb->mss
,且相邻的两个段都不是FIN
包或SYN
包,则需要将两个段合并为一个段。
最后,函数需要调整TCP控制块中的相关字段的值,这点也是我最关心的地方,
if ((flags & TCP_SYN) || (flags & TCP_FIN)) {
//发送SYN或FIN包被认为数据长度为1
++len;
}
if (flags & TCP_FIN) {
// 若为FIN包,则设置flags字段为相应值
pcb->flags |= TF_FIN;
}
pcb->snd_lbb += len; // 下一个要被缓冲数据的序号,注意与snd_nxt不同
pcb->snd_buf -= len; // 减小空闲的发送缓冲数,注意这个缓冲区并不是真正存在的
pcb->snd_queuelen = queuelen; // 未发送队列中的pbuf个数
因为在看滑动窗口时怎样实现的时候,这些字段是非常关键的。
讲了tcp_enqueue
函数,又不得不讲讲tcp_output
函数。tcp_output
函数有个唯一的参数,即某个链接的TCP控制块指针pcb
,函数把该控制块的unsent
队列上数据段发送出去或直接发送一个ACK
数据段。如果调用该函数时,控制块的flags
字段设置了TF_ACK_NOW
标志,则函数必须马上发出去一个带有ACK
标志。因此,如果此时unsent
队列中无数据发送或者发送窗口此时不允许发送数据,则函数需要发出去一个不含任何数据的ACK
数据报。当没有TF_ACK_NOW
置位,或者TF_ACK_NOW
置位但该ACK
能和数据段一起发送出去时,则此时函数会取下unsent
队列上的数据段发送出去(这里先暂时不考虑nagle
算法)。发送一个具体的数据段是通过调用函数tcp_output_segment
实现的,这个函数主要是填充待发送数据段的TCP头部中的确认序号为pcb->rcv_nxt
,通告窗口大小为pcb->rcv_ann_wnd
,校验和字段,最后tcp_output_segment
将数据包递交给IP层发送。当然,tcp_output_segment
还有许多其他操作,这里我们先不关心。
好了,还是回到tcp_output
这条正道上来,数据段被发送出去后,这个函数还需要设置控制块相关字段的值。这里我最关心的还是与滑动窗密切相关的字段,
pcb->snd_nxt = ntohl(seg->tcphdr->seqno) + TCP_TCPLEN(seg); // 下一个要发送的字节序号
if (TCP_SEQ_LT(pcb->snd_max, pcb->snd_nxt)) {
pcb->snd_max = pcb->snd_nxt; // 最大发送序号
}
接下来,函数将发送出去的这个段挂接在控制块unacked
链表上,以便后续的重发等操作。到这里,unsent
队列上的第一个数据段就处理完了,tcp_output
函数还会依次按照上述方法处理unsent
队列上剩下的各个数据段,直到数据被全部发送出去或者发送窗口被填满。
现在可以来看看tcp_receive
这个庞然大物了。这个函数简单的来说就是操作TCP控制块中的unsent
、unacked
、ooseq
字段,这三个字段用于连接TCP的各种数据段。unsent
用于连接还未被发送出去的数据段、unacked
用于连接已经发送出去但是还未被确认的数据段、ooseq
用于连接接收到的无序的数据段。这个三个字段都是tcp_seg
类型的指针,结构体tcp_seg
用于描述一个TCP数据段,源代码如下:
struct tcp_seg {
struct tcp_seg *next; // 用来建立链表的指针
struct pbuf *p; // 数据段pbuf指针
void *dataptr; // 指向TCP段的数据区
u16_t len; // TCP段的数据长度
struct tcp_hdr *tcphdr; // 指向TCP头部
};
掌握这个结构体很重要,这是理解tcp_receive
函数的关键。从下面的图中可以看出,tcp_seg
结构时怎样描述一个TCP数据段的。能够进行数据段收发的TCP控制块都被连接在链表tcp_active_pcbs
上,每个控制块的三个指针unsent
、unacked
、ooseq
连接了该连接相关的数据。unsent
、unacked
链表与ooseq
链表上的tcp_seg
结构描述数据段的方式不尽相同,从图上可知,unsent
、unacked
链表的tcp_seg
结构dataptr
和tcphdr
字段都指向pbufs
的数据起始位置,即TCP头部位置;而ooseq
链表上的tcp_seg
结构dataptr
指向了TCP数据段的开始位置,tcphdr
字段指向了TCP头部。且对于链表ooseq
上的数据包pbuf
,其payload指针也是指向TCP数据段的开始位置,而不是指向pbuf
的数据开始位置。这是因为链表ooseq
上的TCP数据段都是从IP层递交上来的,TCP层已经调用tcp_input
函数将数据包的payload
指针指向了TCP数据段的开始位置。