接上文:Lwip之TCP协议实现(二)_龙赤子的博客-CSDN博客

第三部分:tcp的输出处理

Tcp的输出处理主要在文件tcp_out.c中实现。该文件中提供了与输出有关的函数接口。在这部分最主要的一个函数就是tcp_output,该函数用来处理与发送相关的许多东西。Tcp的RST报文和KEEPALIVE报文是单独构造发送的,因为它们的处理很简单,没有必要放到tcp_output中来使问题更复杂,并且它们需要的额外信息也很少,很容易构成一个独立的处理模块。对于tcp_rexmit和tcp_rexmit_rto主要用来进行超时重传。Tcp_enqueue用来将应用缓存的数据按照设置的最大段MSS进行分段并列入队列,但是数据不会立即发送。Tcp_write主要是用来给应用在发送数据时调用。

目录

一:发送的核心处理

1.1 判断是否处于tcp输入的状态机处理中

1.2 必要的变量设置

1.3 发送纯ack报文

1.4 发送数据段

1.5 数据段的实际发送

二:重传数据段的发送

2.1 tcp_rexmit_rto的处理

2.2 tcp_rexmit的处理

三:RST数据包的发送

四:KEEPALIVE探测报文的发送

五:待发送数据或者选项入队列

5.1 变量初始化与有效性检查

5.2 数据分段并排队

5.3 数据入队列

5.4 内存错误处理

六:上层调用

6.1 tcp_write接口

6.2 tcp_send_ctrl接口

一:发送的核心处理

Tcp_output完成发送的核心处理:找到我们需要发送的数据然后发送它

1.1 判断是否处于tcp输入的状态机处理中

如果我们当前正在进行tcp输入包的状态机处理中,此时如果tcp_output被调用的话,不会发送任何数据,相反,依赖与输入处理完后的代码来调用。也就是说,输入处理完后会调用该接口,此时会将之前待发送的数据一块发送出去的,因为它们都是属于同一个pcb,也就是同一个连接。

1.2 必要的变量设置

Wnd保存拥塞窗口和接收方通告窗口值之间的小值作为当前的发送窗口

Seg指向当前连接的未发送数据段队列的开始

Useg指向当前连接的已发送但未确认的数据段队列的尾部

1.3 发送纯ack报文

如果当前连接的TF_ACK_NOW标识被设置,并且没有数据可以发送(没有数据可以发送是指连接的未发送数据段队列为空或者当前的窗口值不允许发送数据),就直接构造一个空的ack段并且发送。

如果上述判断条件满足:

调用pbuf_alloc分配tcp报文缓存

清除连接的ack标识,包括TF_ACK_DELAY和TF_ACK_NOW

填充tcp包头,本地和远端端口号使用当前连接保存的值,发送序列号和接收确认号也使用当前连接保存的值,tcp标识设置TCP_ACK位,tcp头部中的窗口值使用连接中保存的变量rcv_wnd来填充,该值是我们本地的接收窗口大小。设置tcp头部中的其他值,并完成校验和的计算,最后调用底层的ip_output进行数据的发送。

调用pbuf_free释放之前分配的内存。

返回err_ok

这里返回是因为如果有数据的话,ack会附在数据上捎带发送

1.4 发送数据段

如果当前连接的未发送数据段队列不空,并且当前的发送窗口大小允许发送新的数据段,则进行数据段的发送。这个过程是循环进行的,直到队列为空或者窗口不允许发送数据为止。

当前连接的pcb数据结构的未发送数据段队列指针下移(因为在之前,我们已经用seg指向队列的第一个数据段了)。

如果当前的连接不在SYN_SENT状态,就设置seg指向的数据段的tcp头部的TCP_ACK标识,并清除连接的TF_ACK_DELAY和TF_ACK_NOW标识。

调用tcp_output_segment完成数据段的实际发送操作。

之后进行连接的发送序号更新:

{

连接的snd_nxt(下一个发送字节的序号)更新为当前发送段的起始序号和段长度之和。

如果连接的snd_max(当前连接所发送数据的最高序号)小于snd_nxt,则将snd_max更新为snd_nxt。(一般情况下,当重传发生时,snd_nxt会缩到窗口的左边,指向已发送但是未被确认的第一个数据段,但是snd_max不会变)

}

如果当前发送的数据段的数据长度大于零,就将它放到连接的未确认队列上,否则释放段,不入队列。(空段是不进入未确认队列的)。在将这些已发送但未确认的段放入位确认段队列时,有一些情况需要考虑:如果是在快速重传的情况下,数据段不应该放到队列尾部,而相反,要放到队列的头部。对这种情况的判断是通过对seg指向段的序号和useg指向段的序号的比较得出的。这就是为什么useg要指向未确认段的尾部。未确认数据段是按照段序号递增的顺序来排列的。另外,这种情况是可能出现的,因为所有需要发送的数据段都是放到unsent队列上来处理的,就是说如果出现重传的话,unacked队列上的段同样会被移到unsent队列上来发送。Unacked队列上的数据段只是在被收到的ack确认后移除,但是不会被tcp_output使用来发送其上的数据。

变量seg重新指向当前的未发送的数据段队列的队列头,为下一次处理做好准备。

当循环因某个条件不满足退出后,返回err_ok。

1.5 数据段的实际发送

数据段的实际发送通过一个新的函数来完成:tcp_output_segment。

此时,数据段的tcp头都已经设置完了,但是 确认序号(ackno)和窗口域(win)还需要重新设置。

确认序号使用当前连接的接收序号rcv_nxt来设置。

对于窗口,还需要考虑糊涂窗口症避免,但是lwip的处理是相当简单的。当当前连接的接收窗口大小小于设定的最大段值mss时,就直接将窗口值设定为零,以此来避免对端发送小于最大段大小的数据。否则,就直接用当前连接的接收窗口大小rcv_wnd来设置即可。

如果此时还没有设置连接的本地ip地址,就调用ip层的路由函数ip_route来确定发送接口。

因为一个新的数据段将要发送,所以将超时重传定时计数值清零,便于进行超时重传的估算。同时进行一下与rtt轮回时间评估相关的操作,计算出新的评估时间。

最后,对段中的数据指针进行校正,进行校验和的计算,并调用ip_output完成数据的实际发送。

二:重传数据段的发送

需要重传的数据段通过调用tcp_rexmit_rto和tcp_rexmit两个接口完成。这两个接口最终调用tcp_output完成数据段的发送。

2.1 tcp_rexmit_rto的处理

该函数将所有已发送但是未被确认的数据段重新装入未发送数据段队列进行重传。这在超时重传中调用。

如果当前连接的unacked队列为空,就是说没有需要重传的数据,则返回。

否则,将所有已发送但是未确认的数据段移动到未发送数据段队列的头部(这一步是通过指针移动来完成的:unacked的最后一个段的next指向unsent的头部,pcb重新指向unacked的头部,最后将unacked置空)

设置当前连接的下一个发送序号为当前调整过的unsent队列头一个段的tcp头部的发送序号。

增加重传次数++nrtx

将当前连接的rtt测量相关的变量清零,因为此时是超时重传,说明链路上很可能发生了拥塞,此时估算出来的rtt应该是不可行的。

调用tcp_output发送unsent队列上的数据。

2.2 tcp_rexmit的处理

不同于tcp_rexmit_rto,该函数只是将unacked队列头的第一个段移到unsent队列头发送。该函数在快速重传中调用。不是超时重传。

将unacked队列头的第一个数据段移到unsent队列头,调整pcb中这两个队列的头指针

调整当前连接的发送序号,同样使用unsent队列头第一个数据段tcp头部中的发送序号

后续的处理同tcp_rexmit_rto。

三:RST数据包的发送

协议中调用tcp_rst来发送RST报文。对于重传报文的发送,最终调用了tcp_output来完成,另外,在实现中对报文也只是简单的重新设置了发送序号,这样也就保留了许多tcp头部的设置。这是必须的,因为重传的报文就是之前已经发送过的报文,不需要进行额外的特别设置。(其实这里可以有更优的实现,就是对于这些报文,可以采用特殊的方法来计算校验和,从而提高速度。)对于RST报文,不需要上述繁琐的处理,相对比较独立,所以使用单独的接口来实现,简化了实现难度。

该报文头部的许多项都是通过参数来传递的,在函数中直接分配所需的内存,用参数设置本地和远端端口,发送和接收序号,以及本地和远端ip地址。额外的设置就是tcp包头中的TCP_RST标识,以及窗口值。这里,我们简单的将窗口值设置为TCP_WND。对于RST报文来讲,这个值是不重要的。

计算完校验和之后就调用ip_output发送数据包

最后释放之前分配的内存

四:KEEPALIVE探测报文的发送

对于KEEPALIVE报文的发送使用一个单独的接口来实现,理由同上。但是不同于RST报文,也许我们发送RST报文时,针对该报文的连接控制块pcb已经删除了,不过,对于KEEPALIVE报文,它的连接还是存在的,所以其tcp头部中的许多项来自其所在的连接控制块中的值。该函数的参数就是pcb。

同样,我们先分配所需的内存。

设置tcp头部中的各个域。发送序号使用snd_nxt-1,这里将其减1是因为这样发送过去的序号是对端已经收到过的序号,而不是期望的序号,这会促使对端立即发送一个ack报文来响应。Ackno使用连接上的下一个期望从对端接收的序号,窗口设置为连接的接收窗口。

后续的处理类似与RST报文的处理,计算校验和,调用ip_output发送数据包,最后释放之前分配的内存

五:待发送数据或者选项入队列

对于tcp而言,如果待发送的数据很大,则需要将数据分割成MSS大小的段来发送,这部分的功能通过tcp_enqueue函数实现。该函数将待要发送的数据在必要的情况下分段,并将它们排入其所属连接的unsent队列。另外,该函数要么将数据入队列,要么将tcp的标识入队列,而不会将它们同时入队列。也就是对该接口的调用,如果含有tcp标识,则不含数据,如果含有数据,则不含tcp标识。该函数的具体处理流程如下:

5.1 变量初始化与有效性检查

如果发送的数据太多,则失败。snd_buf保存了发送数据可用的缓冲空间,len为要发送数据的长度,如果len>snd_buf则表明发送缓冲装不下要发送的数据,此时返回ERR_MEM错误

内部变量left(表示剩余的要处理的数据)保存len的值(因为初始时剩余的数据就是要发送的数据),ptr指针指向参数中给的数据(其实就是数据的首地址)。当前连接的下一个要缓存的数据的序号保存在snd_lbb变量中,并用内部变量seqno来保存该值,而pcb的域snd_queuelen则以数据段为单位保存了发送方可用的缓冲空间,程序中为便于处理,同样将其赋值给内部变量queuelen

检查tcp发送窗口中的数据是否大于配置设定的值,如果是,则返回ERR_MEM。如果发送窗口中有太多数据的话,就等待tcp去处理这些数据,使更多的数据被对端确认来扩大窗口空间。发送窗口中的数据包括处在unsent队列上的数据和处在unacked队列上的数据,程序配置设定的值保存在变量TCP_SND_QUEUELEN中。这里条件满足的表达式为queuelen>TCP_SND_QUEUELEN。

5.2 数据分段并排队

到这里,说明数据是可以放到tcp的发送缓冲中的。在这一部分,首先将数据按照MSS大小进行分割,并将它们排队,用本地变量queue来指向这个队列。在下一部分,我们将把这个队列加到当前连接的unsent队列上。

首先初始化这部分用到的几个变量useg queue seg seglen,前三个为指向tcp_seg的指针,最后一个用于保存每一次处理的数据段的长度。

数据分段处理会循环进行,直到剩余数据left不再大于零。

Seglen保存当前要处理的数据段的长度。如果剩余数据left大于MSS的话,seglen就设置为MSS,否则就设置为left。可以看出,如果设置为left的话就是最后一次处理了。

分配一块MEMP_TCP_SEG类型的内存,它的next指针和pbuf的指针均初始化为空。

如果它是正在处理的第一个段,则使queue变量指向它,这样,最后完成分段的数据都就被保存在queue队列上了。否则的话,就将新的数据段放到上一个段的后面。这项工作是借助于useg变量来完成的(useg变量保存了队列尾部的数据段)。因为数据是由段的一个指针指向的,所以先通过段的排列管理数据,后续再将数据“拷贝”到段数据指针指向的内存中。这里的拷贝不一定完全是内存数据的移动,只是感觉上的拷贝。

下面处理数据和选项。如果copy参数设置了,内存将被分配,数据将被真正的拷贝的分配的pbuf中,否则,数据来自ROM或者其他静态存储,不需要拷贝。如果optdata不为空,我们就用选项代替数据。

{

如果optdata不为空,说明是选项,而不是数据。我们为选项分配pbuf内存,并使段的pbuf指针指向它,使段的dataptr指针指向pbuf中的数据。增加内部变量queuelen,说明又有一个段进入当前连接的发送缓存中。

否则,如果copy被设置,说明是需要拷贝的数据。同样,我们为数据分配pbuf内存,并使段的pbuf指针指向它,增加queuelen变量的值,并且如果arg参数不为空,说明数据实际存在,则将数据拷贝到pbuf的数据缓冲中。段的数据指针dataptr指向pbuf中的数据部分。

如果前面两个条件都不满足,则说明需要发送不进行拷贝的数据。数据有可能在非易失性存储器中。我们同样分配一个pbuf,不过不同于之前的分配,这次分配的pbuf是pbuf_rom类型的,就只是分配一个pbuf头部,不实际分配数据空间。分配完成后,将pbuf的数据指针和段的数据指针都指向内部变量ptr(该变量初始时指向了待发送数据)。增加queuelen值。因为之前我们仅仅为数据分配了pbuf结构体,并没有为数据分配tcpip头部,所以第二步要从pbuf_ram中为数据分配tcpip头部空间,并使段的pbuf指针指向该pbuf。这部操作成功后就调用pbuf操作接口pbuf_cat将头部和数据链到一块,形成一个数据段。

这里有一疑点,就是在这步操作后又将queuelen增加了,按照程序中的设计来看,这个变量好像是表示当前有多少个队列可能进入发送缓存,而pbuf_cat操作后实际上是将两部分的数据合成一个段了,而queuelen变量却增加了两次。

}

因为通过之前的操作,有更多的数据段将要入队列,所以需要检查一下队列的长度是否超过了配置的最大长度,同样使用条件表达式queuelen>TCP_SND_QUEUELEN。如果超过了配置的最大长度,则直接跳转到memerr处,进行错误处理。

程序走到这里,说明该段可以列入queue队列上。首先将当前段的长度保存到段数据结构的len域中。之后,建立tcp头。因为pbuf中留有tcp头部的空间,但是此时pbuf的数据指针是指向数据部分的,为了设置tcp头部的相关域,需要将pbuf中的数据指针调整到指向tcp数据部分,这一部分的工作是通过调用底层的pbuf操作接口pbuf_header来完成的。

为了便于完成数据的拷贝,段数据结构tcp_seg含有tcp头指针tcphdr。此时,将该指针指向pbuf中数据指针payload。头部的本地和远端端口号使用当前连接pcb中保存的值来填充,发送序号使用本地seqno变量设置,标识使用参数设置。期望接收的下一个字节的序号和窗口值在调用tcp_output发送数据段时才能确定,并将在那里设置。

之前,在进行数据分割的处理中,如果判断是选项后只是为选项分配了pbuf内存,并没有进行数据的拷贝(因为它跟实际数据的拷贝是互斥的),在这里,在此时,将检查optdata参数。如果不为空,则需要将选项数据拷贝到tcp的数据缓冲中,并需要根据选项的长度来修改tcp头部的长度域。目前,我们只是在发送带MSS选项时用到这部分逻辑。

到此,一个完整的段便构造好了。更新内部变量left(减去新形成段的长度)seqno(加上新形成段的长度)ptr(增加新形成段的长度,指向下一个段的第一个字节数据)。跳转到循环开始处,进行下一次分割段(如果需要的话)和入队列操作

5.3 数据入队列

此时,已退出循环,说明数据都已经被分段并且放到了queue队列上。我们需要将queue队列添加到当前连接的unsent队列尾部。

先将useg指针调整到unsent队列的尾部,如果unsent队列为空,则其也为空。

首先进行段合并检查(nagle算法)。条件:unsent段不空,并且有数据,TCP_SYN和TCP_FIN标识没有被设置,当前入队列数据的上述两个标识也没有被设置(说明不是建立连接和关闭连接过程),unsent队列最后一个段的数据长度和queue队列的第一个段的数据长度之和小于MSS。如果上述条件都满足,则进行段的合并。合并时首先将queue队列第一个段的pbuf的数据指针重新调整到数据部分(之前在填充tcp头部时是调整到tcp头部部分的),调用pbuf_cat将useg指向的段和该段链在一起(逻辑上合并,并非物理上移动)。Useg段的长度增加合并段的长度,useg的指向下一个段的指针指向queue队列的第二个段。最后,如果seg变量和queue变量指向同一个段,那么就使其指向空,这在后面设置TCP_PSH标识时会用到。释放queue指向段的头部,因为其已经被合并了。(这一部分的操作需要注意一些隐含的事实:如果可以进行合并,那么queue队列实际上就只有一个段,否则,段都是按照MSS大小分割的,就不可能满足合并的条件。所以上面提到的queue队列上的第二个段实际上应该是空的,而且seg变量和queue变量指向同一个段的可能行几乎就是必然的

如果上述条件有一个不满足,则直接将queue队列挂到unsent队列尾部。这样就完成了数据入队列操作。后续进行一些变量的调整:

如果输入的参数包含TCP_SYN或者TCP_FIN标识,因为这两个标识在tcp中是需要消耗一个序号的,所以需要将参数len的值加一,然后使用修正后的len值来更新连接的snd_lbb(下一个需要缓冲的数据的字节序号更新为加上len长度后的值)和snd_buf(可用的缓冲空间更新为减去len长度后的值),同时更新连接的snd_queuelen值(用queuelen来设置)。

如果数据段没有合并,那边seg变量将指向进入队列的最后一个段(否则,其已被设置为空)。如果这个段含有数据,我们则设置其tcp头部的TCP_PSH标识。

到此,正常情况的处理都已经结束,返回ERR_OK。

5.4 内存错误处理

该函数中有一个跳转标识memerr,在进行与内存相关的操作失败后,跳转到此进行处理。

跳转条件:新的段头部结构分配失败

                  数据pbuf内存分配失败

                  更多的队列入队列超过了最大配置长度

                  数据段中pbuf中tcp头部指针失败

处理:释放queue队列中的段,这些段只是按照MSS进行了分割,并没有真正放到连接的unsent队列上,也就是没有真正进入发送缓冲。

返回错误码ERR_MEM。

六:上层调用

上层通过调用tcp_write和tcp_send_ctrl两个接口来发送数据。

6.1 tcp_write接口

该接口在应用层发送数据时被调用。应用层将发送数据的消息和数据给tcpip_thread任务(tcp的核心任务),该任务在处理该消息时会调用tcp_write。

在该函数中,我们首先判断当前的连接是否处于可以发送数据的有效状态。这里的有效状态由ESTABLISHED CLOSE_WAIT SYN_SENT SYN_RCVD的逻辑或添加构成。

在满足上述状态的条件下,如果数据长度大于零,则调用tcp_enqueue将数据放到该连接的unsent队列上。否则,只返回err_ok。

如果上述条件不能满足,说明连接的当前状态是不能调用tcp_write来发送数据的。返回ERR_CONN错误。

需要注意的是,调用该接口并不会立即发送数据,因为调用tcp_enqueue本身就不会立即发送数据,而且,如果当前数据段很小的话,可能会将后续到达的小数据和当前的数据合并后才发送,这就是nagle算法。如果不想这样做,就是说每次调用这个接口后就想将数据立即发送了,则可以在该调用之后马上调用tcp_output。

6.2 tcp_send_ctrl接口

从代码中来看,这个接口只在tcp_close中调用,用来发送一个数据段,但该数据段只包含标识不含数据。

在该函数中,我们调用tcp_enqueue将这些tcp选项放入tcp的unsent队列中,等待某次tcp_output的调用来发送它。

其实,因为该函数只被tcp_close调用,所以这里的标识主要是TCP_FIN,用于关闭该连接。

(完) 

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐