lwip是瑞士计算机科学院的一个开源的TCP/IP协议栈实现.
LwIP是Light Weight (轻型)IP协议,有无操作系统的支持都可以运行。LwIP实现的重点是在保持TCP协议主要功能的基础上减少对RAM 的占用,一般它只需要几百字节的RAM和40K左右的ROM就可以运行,这使LwIP协议栈适合在低端的嵌入式系统中使用。
本文主要讨论TCP的核心协议(滑动窗口、拥塞控制、慢启动、快速重传、快速恢复、Nagle 算法、捎带ACK等)在lwip中的实现。
lwip中负责TCP会话管理的核心数据结构是tcp_pcb
·········10········20········30········40········50········60········70········80········90········100·······110·······120·······130·······140·······150structtcp_pcb {
/**1、ip头信息*/
structip_addr local_ip;
structip_addr remote_ip;
u16_t so_options;
u8_t tos;
u8_t ttl;
u16_t remote_port;
u8_t flags;
#define TF_ACK_DELAY ((u8_t)0x01U) /* 延迟确认*/
#define TF_ACK_NOW ((u8_t)0x02U) /* 立即确认*/
#define TF_INFR ((u8_t)0x04U) /* 快速恢复过程中*/
#define TF_TIMESTAMP ((u8_t)0x08U) /* 时间戳选项开启*/
#define TF_RXCLOSED ((u8_t)0x10U) /* rx closed by tcp_shutdown */
#define TF_FIN ((u8_t)0x20U) /* Connection was closed locally (FIN segment enqueued). */
#define TF_NODELAY ((u8_t)0x40U) /* 关闭Nagle算法*/
#define TF_NAGLEMEMERR ((u8_t)0x80U) /* nagle enabled, memerr, try to output to prevent delayed ACK to happen */
/**2、滑动窗口协议相关变量*/
/**接收窗口相关变量*/
u32_t rcv_nxt; /*接收端希望下一个到达的序列号*/
u16_t rcv_wnd; /*当前接收窗口的可用部分,当数据到达时变小,当上层应用程序把数据取走之后变大*/
u16_t rcv_ann_wnd; /*向数据发送方声明的接收窗口大小*/
u32_t rcv_ann_right_edge; /*向发送方声明接收窗口时,计算得到的接收窗口的最右端*/
/**发送窗口相关变量*/
u32_t snd_nxt; /*下一次将要发送的序列号*/
u16_t snd_wnd; /*发送窗口大小*/
u32_t snd_wl1, snd_wl2; /*上次更新发送窗口时(收到一个数据包)的序列号和确认序列号*/
u16_t snd_buf; /*按照发送窗口计算得到的可以发送的数据量*/
/**3、计时器 */
u32_t tmr; /*该连接上次有数据包到达的时间*/
u8_t polltmr, pollinterval; /*poll计时器*/
s16_t rtime;/*重传计时器*/
u16_t mss; /*最大分段大小,lwip中实际发送的数据包,都是以mss大小进行切分的*/
/**4、RTT (round trip time)相关变量*/
u32_t rttest; /*用于计算RTT的数据分片的发送时间(500ms为单位的整数)*/
u32_t rtseq; /*用于计算RTT的数据分片的序列号*/
/*
RTT计算方法:
Err = M-A //A是RTT平均值,M是实际测量值,Err是误差
A←A + gErr //用误差更新平均值
D←D + h( | Err |-D) //D是均值偏差,用误差更新均值偏差
RTO = A + 4D //计算得到RTO(重传超时时间)
g=0.125 ; h=0.25
*/
s16_t sa, sv; /*用于计算RTO:sa = 8*A;sv = 4*D */
s16_t rto; /*RTO*/
u8_t nrtx; /*数据包重传计数*/
/**5、快速重传和快速恢复*/
u32_t lastack; /*目前最大确认序列号*/
u8_t dupacks; /*重复ACK计数*/
/**6、拥塞控制相关变量*/
u16_t cwnd; /*拥塞避免窗口*/
u16_t ssthresh; /*慢启动门限*/
/**7、时间戳选项相关变量*/
u32_t ts_lastacksent;
u32_t ts_recent;
/**8、保活定时器相关变量*/
u32_t keep_idle; /*间隔多长时间开始发送 keep alive消息*/
u32_t keep_intvl; /*TCP选项keepa live中的值,发送keepalive时间间隔*/
u32_t keep_cnt; /*TCP选项keep live 最多发送多少次,最后这个连接就被rst了*/
u8_t keep_cnt_sent;/*已经发送的保活消息次数*/
/*9、坚持定时器*/
u32_t persist_cnt; /*坚持定时器退避时间*/
u8_t persist_backoff; /*坚持计时器指数退避参数*/
/**10实际数据*/
u32_t snd_lbb;
u16_t acked; /*上一次ack所确认的数据量*/
u16_t snd_queuelen; /* 可以发送的数据大小*/
structtcp_seg *unsent;/*还没有发送的数据分片列表*/
structtcp_seg *unacked;/*还没有确认的数据分片列表*/
structpbuf *refused_data;/*已经收到,但是还没有被应用程序领走的数据*/
};
1、滑动窗口
1.1发送窗口的使用:
网络数据到达时:
更新 pcb->snd_wnd
网络数据发送时:
//发送数据大小不能超过发送窗口和拥塞窗口的最小值。
wnd =MIN( pcb->snd_wnd, pcb->cwnd);
if( len(待发送数据包) > wnd) {
暂不发送;
}
1.2接收窗口的使用:
网络数据到达时:
pcb->rcv_wnd -= len;
应用层读取数据时:
pcb->rcv_wnd += len;
当 rcv_wnd被调整时:
u32_t new_right_edge = pcb->rcv_nxt + pcb->rcv_wnd;
if(new_right_edge>=pcb->rcv_ann_right_edge + MIN((TCP_WND/2), pcb->mss))
{//数据被应用层程序取走,调大接收窗口大小,接收窗口右边沿向右移动。
pcb->rcv_ann_wnd = pcb->rcv_wnd;
} else {//设置接收窗口大小为 0
if (pcb->rcv_nxt >= pcb->rcv_ann_right_edge) {
pcb->rcv_ann_wnd = 0;
} else {//调小接收窗口大小,接收窗口右边沿不动。
pcb->rcv_ann_wnd = pcb->rcv_ann_right_edge - pcb->rcv_nxt;
}
2、RTT估计开启和关闭
2.1 RTT估计的开启
RTT估计的时机:说在内核没有进行 rtt估计时,且不处于超时重传状态(避免重传多义性)
LWIP中的实现:
wnd =MIN( pcb->snd_wnd, pcb->cwnd);
if((seg->tcphdr->seqno) - pcb->lastack + seg->len <= wnd)){//要发送的数据位于发送窗口
和拥塞窗口之中
……
if( pcb->rttest == 0) {
开启 RTT估计。
}
}
2.2 RTT 估计的关闭:
LWIP 的实现:
将 pcb->rttest置为 0即关闭 rtt估计,共有三处会关闭 rtt估计:
tcp_receive ,完成了 rtt估计的运算,正常关闭,等待下次重新计算 rto 。
tcp_rexmit ,快速重传,关闭 rtt估计。
tcp_rexmit_rto ,超时重传,关闭 rtt估计。
2.3RTT 估计算法:
RTT 计算方法 :
Err = M-A//A 是 RTT 平均值, M 是实际测量值, Err 是误差
A ← A + gErr// 用误差更新平均值
D ← D + h( | Err |-D)//D 是均值偏差,用误差更新均值偏差
RTO = A + 4D// 计算得到 RTO( 重传超时时间 )
g=0.125 ; h=0.25
LWIP 的实现:
1、发送数据时:
if((seg->tcphdr->seqno) - pcb->lastack + seg->len <= wnd)){
……
if (pcb->rttest == 0) {// 需要重新进行 rtt 估计
pcb->rttest = tcp_ticks ; // 记录当前时间戳(以 500ms 为单位)
pcb->rtseq = ntohl(seg->tcphdr->seqno) ; // 记录当前数据分片的序列号
}
}
2、接收数据时:
if (flags & TCP_ACK) {// 数据包含 ACK 数据确认
if (pcb->rttest && TCP_SEQ_LT(pcb->rtseq, ackno)) {// 正在进行 RTO 估算,
并且用于估算的数据的 ACK 回来了
m = (s16_t)(tcp_ticks - pcb->rttest);
// 均值偏差估算算法。
m = m - (pcb->sa >> 3);
pcb->sa += m;
if (m < 0) {
m = -m;
}
m = m - (pcb->sv >> 2);
pcb->sv += m;
pcb->rto = (pcb->sa >> 3) + pcb->sv;
pcb->rttest = 0;//rto 估算完成,准备下一次重新估算。
}
}
3 、超时重传时:
pcb->rto = ((pcb->sa >> 3) + pcb->sv) << tcp_backoff[pcb->nrtx];//TCP 指数退避重新计算重传时间
3、超时重传、慢启动和拥塞避免
拥塞窗口 (congestion window , cwnd) ,是指发送方在接收到对方的 ACK 确认前向允许网
络发送的数据量,数据发送后,拥塞窗口缩小;接收到对方的 ACK 后,拥塞窗口相 应增加,拥塞窗口越大,可发送的数据量越大。拥塞窗口初始值的 RFC2581 中被规定为不超过发送方 MSS 的两倍,而且不能超过两个TCP 包,在 RFC3390 中更新了初始窗口大小的设置方法。
慢启动阈值 (slow start threshold, ssthresh) ,用来判断是否要使用慢启动或拥塞避免算法来
控制流量的一个参数,也是随通信过程不断变化的。
当 cwnd < ssthresh 时,拥塞窗口值已经比较小了,表示未经确认的数据量增大,需要启动慢启动算法;当cwnd > ssthresh 时,可发送数据量大,需要启动拥塞避免算法。
拥塞窗口 cwnd 是根据发送的数据量自动减小的,但扩大就需要根据对方的接收情况进行扩大,慢启动和拥塞避免算法都是描述如何扩大该值的。
在启动慢启动算法时, TCP 发送方接收到对方的 ACK 后拥塞窗口最多每次增加一个发送方 MSS 字节的数值,当拥塞窗口超过 sshresh 后或观察到拥塞才停止算法。
启动拥塞避免算法时,拥塞窗口在一个连接往返时间 RTT 内增加一个最大 TCP 包长度的量,一般实现时用以下公式计算:
cwnd += max(SMSS*SMSS/cwnd, 1)
LWIP 实现:
1、数据到达时:
if ( TCP_SEQ_BETWEEN(ackno, pcb->lastack+1, pcb->snd_nxt){// 收到了一个正常的 ACK
if(pcb->unacked == NULL){// 未确认队列为空
pcb->rtime = -1;// 关闭重传定时器
}else {
pcb->rtime = 0;// 重启重传定时器
pcb->nrtx = 0;// 重传次数清零
}
if (pcb->state >= ESTABLISHED) {// 会话建立的状态以后的状态,
if (pcb->cwnd < pcb->ssthresh) {// 慢启动算法
if ((u16_t)(pcb->cwnd + pcb->mss) > pcb->cwnd) {
pcb->cwnd += pcb->mss;
}
} else {// 拥塞避免算法
u16_t new_cwnd = (pcb->cwnd + pcb->mss * pcb->mss / pcb->cwnd);
if (new_cwnd > pcb->cwnd) {
pcb->cwnd = new_cwnd;
}
}
}
}
2 、定时器的实现: LWIP 中实现了两个定时器处理函数: tcp_fasttmr ()和 tcp_slowtmr ()。tcp_fasttmr 函数是每 250ms 调用一次; tcp_slowtmr 函数每 500ms 调用一次。超时重传功能是在 tcp_slowtmr中实现的。
if ( pcb->persist_backoff <= 0 ) {// 坚持定时器还没有到时
if(pcb->rtime >= 0)// 重传定时器开启了
++pcb->rtime;// 重传定时器 ++
}
if (pcb->unacked != NULL && pcb->rtime >= pcb->rto){// 重传定时器到时
if (pcb->state != SYN_SENT) {// 重新设定超时重传时间
pcb->rto = ((pcb->sa >> 3) + pcb->sv) << tcp_backoff[pcb->nrtx];
}
pcb->rtime = 0;// 重传计时器清零
eff_wnd = LWIP_MIN(pcb->cwnd, pcb->snd_wnd);
// 重新计算慢启动门限
pcb->ssthresh = eff_wnd >> 1;
(pcb->ssthresh < (pcb->mss << 1)) {
pcb->ssthresh = (pcb->mss << 1);
}
pcb->cwnd = pcb->mss;
// 重传,把 unasked 队列整体移动到 unsent 队列前端; ++pcb->nrtx ;
tcp_rexmit_rto(pcb);
}
}
4、快速重传和快速恢复
TCP 接收方收到错序的 TCP 包时要发送复制的 ACK 包回应,提示发送方可能出现网络丢包;发送方收到连续 3 个重复的 ACK 包后启动快速重传算法,根据确认号快速重传那个可能丢失的包而不必等重传定时器超时后再重传,普通的重传是要等到重传定时器超时还没收到 ACK 才进行的。这个算法是 TCP 发送方应该 (SHOULD)实现的,不是必须。 TCP 发送方进行了快速重传后进入快速恢复阶段,直到没再接收重复的 ACK 包。
快速重传和快速恢复具体过程为: 1.当收到第 3 个重复的 ACK 包时, ssthreh 值按 ssthresh = max (FlightSize / 2, 2*SMSS) 重新设置;
2.重传丢失的包后,将拥塞窗口 cwnd 设置为 sshresh+3*SMSS ,人工扩大了拥塞窗口;
3.对于每个接收到的重复的 ACK 包, cwnd 相应增加 MSS ,扩大拥塞窗口;
4.如果新的拥塞窗口 cwnd 值和接收方的通告窗口值允许的话,可以继续发新包;
5.当收到下一个 ACK 确认了新数据时,将 cwnd 大小调整为 sshresh ,减少窗口;对接收方来说,接收到重发的 TCP 包后就要发此 ACK 确认当前接收的数据。
接收数据时:
if (TCP_SEQ_LEQ(ackno, pcb->lastack)) {// 收到一个之前确认过的 ACK
pcb->acked = 0;// 最近一次 ACK 确认的数据量为 0
if (tcplen == 0) {// 数据包长度为 0
if (pcb->snd_wl2 + pcb->snd_wnd == right_wnd_edge){// 通告接收窗口不变
if (pcb->rtime >= 0) {// 超时重传计时器正在运行
if (pcb->lastack == ackno) {// 确认序列号和上次的相同
found_dupack = 1;
if (pcb->dupacks + 1 > pcb->dupacks)
++pcb->dupacks;
if (pcb->dupacks > 3) {// 人工调整拥塞窗口
if ((u16_t)(pcb->cwnd + pcb->mss) > pcb->cwnd) {
pcb->cwnd += pcb->mss;
}
} else if (pcb->dupacks == 3) {// 快速重传
tcp_rexmit_fast(pcb);
/*
tcp_rexmit_fast 做如下工作:
wnd = MIN ( pcb->snd_wnd , pcb-> cwnd );
if (pcb->ssthresh < 2*pcb->mss) {
pcb->ssthresh = 2*pcb->mss;
}
pcb->cwnd = pcb->ssthresh + 3 * pcb->mss;
pcb->flags |= TF_INFR;// 表示正在进行快速重传
*/
}
}
}
}
}
if (!found_dupack) {//dupack 重传计数清零
pcb->dupacks = 0;
}
} else if (TCP_SEQ_BETWEEN(ackno, pcb->lastack+1, pcb->snd_nxt)){/* 正常 ACK*/
if (pcb->flags & TF_INFR) {// 该会话正处于快速重传状态
pcb->flags &= ~TF_INFR;// 恢复状态为正常状态
pcb->cwnd = pcb->ssthresh;// 调整拥塞窗口
}
……
}
5、Nagle 算法
Nagle 的文档定义了一种他称之为小封包问题的解决方法。当某个应用程序每次只产生 一字节的数据,就会导致网络由于这样的小封包而过载(该情况通 常被称为 “ 发送端 SB 窗口并发症 ” ),从而产生该问题。一个源自键盘的单一字符- 1 字节的数据-可能导致一个 41 字节的封包被传送,该封包包含了 1 字节的 有用数据和 40 字节的头部数据。
Nagle 算法他的主要职责是数据的累积,实际上有两个门槛:一个就是缓 冲区中的字节数达到了一定量,另一个就是等待了一定的时间(一般的 Nagle 算法都是等待 200ms );这两个门槛的任何一个达到都必须发送数据了。 Nagle 算法兼顾了实时性和发送效率。
和 Nagle 算法相关的有两个 TCP 选线: TCP_NODELAY 和 TCP_CORK 。
TCP_NODELAY 和 TCP_CORK 基本上控制了包的“去 Nagle 化”。
TCP_NODELAY 禁用 Nagle 算法:有数据时立即发送。
TCP_CORK 实际上也禁用了 Nagle 算法,,这种数据传输方式有益于大量数据的通信性能,典型的应用就是文件服务器。 TCP_CORK 就相当于一个“塞子”,塞子塞上之后所有网络数据都被塞住,塞子拔掉之后,所有数据一起发送出来。
示例代码如下:
intfd, on = 1;
…
setsockopt (fd, SOL_TCP, TCP_CORK, &on, sizeof (on)); /* cork */
write (fd, …);
fprintf (fd, …);
sendfile (fd, …);
write (fd, …);
sendfile (fd, …);
…
on = 0;
setsockopt (fd, SOL_TCP, TCP_CORK, &on, sizeof (on)); /*拔去塞子 */
LWIP 的实现, LWIP 对 Nagle 算法的实现是比较“简陋”的,并且只支持 TCP_NODELAY 选项,并不支持 TCP_CORK 选项。
#define tcp_do_output_nagle(tpcb) ((((tpcb)->unacked == NULL) || /
((tpcb)->flags & (TF_NODELAY | TF_INFR)) || /
(((tpcb)->unsent != NULL) && (((tpcb)->unsent->next != NULL) || /
((tpcb)->unsent->len >= (tpcb)->mss))) /
) ? 1 : 0)
当 tcp_do_output_nagle 为 0 时执行 Nagle 算法,其条件是: unacked 队列不能为空、用户没有设置NoDelay 选项、会话不能处于快速重传状态、 unsent 队列为空或者只有一个消息、 unsent 队列的第一个对象的数据长度要小于 mss 。
6、立即 / 延迟 / 捎带 ACK
6.1 立即 ACK :
1 、状态为 SYN_SENT 时,发送立即 ACK
2 、接收到 FIN 数据包后,发送立即 ACK
3 、收到的整个数据包都在 pcb->rcv_nxt ,立即发送一个 dupack
4 、收到 tcp 长度为 0 的数据包,并且序列号落在接收窗口中,发送立即 ACK
LWIP 实现:
#define tcp_ack_now(pcb) /
do {/
(pcb)->flags |= TF_ACK_NOW;/// 设置为立即发送标记
} while (0)
6.2 延迟 ACK :稍等看是否有数据可以捎带
LWIP 实现:
1 、有数据到达时:
#define tcp_ack(pcb) /
do {/
if((pcb)->flags & TF_ACK_DELAY) {/
(pcb)->flags &= ~TF_ACK_DELAY;/
(pcb)->flags |= TF_ACK_NOW;/
}/
else { /
(pcb)->flags |= TF_ACK_DELAY;/
} /
} while (0)
2、在定时器处理函数 tcp_fasttmr() (每 250ms 调用一次)中
if (pcb && (pcb->flags & TF_ACK_DELAY)) {// 发现 TF_ACK_DELAY 已经置位了
tcp_ack_now(pcb);// 发送延迟 ACK
tcp_output(pcb);
pcb->flags &= ~(TF_ACK_DELAY | TF_ACK_NOW);
}
6.3 捎带 ACK :
在 tcp_ack 被调用后, tcp_fasttmr 被触发前, tcp_output 函数被调用,那么:
if (pcb->state != SYN_SENT) {
TCPH_SET_FLAG(seg->tcphdr, TCP_ACK); // 发送捎带 ACK
pcb->flags &= ~(TF_ACK_DELAY | TF_ACK_NOW);//ACK 标记位清零。
}