E-mail:for_rest@foxmail.com

网卡的功能  时间:2021-03-01  阅读:()
老衲五木出品LwIP协议栈源码详解——TCP/IP协议的实现Createdby.
.
老衲五木at.
.
UESTCContactme.
.
for_rest@foxmail.
com540535649@qq.
comE-mail:for_rest@foxmail.
com老衲五木出品前言最近一个项目用到LwIP,恰好看到网上讨论的人比较多,所以有了写这篇学习笔记的冲动,一是为了打发点发呆的时间,二是为了吹过的那些NB.
往往决定做一件事是简单的,而坚持做完这件事却是漫长曲折的,但终究还是写完了,时间开销大概为四个月,内存开销无法估计.
.
这篇文章覆盖了LwIP协议大部分的内容,但是并不全面.
它主要讲解了LwIP协议最重要也是最常被用到的部分,包括内存管理,底层网络接口管理,ARP层,IP层,TCP层,API层等,这些部分是LwIP的典型应用中经常涉及到的.
而LwIP协议的其他部分,包括UDP,DHCP,DNS,IGMP,SNMP,PPP等不具有使用共性的部分,这篇文档暂时未涉及.
原来文章是发在空间中的,每节每节依次更新,后来又改发为博客,再后来就干脆懒得发了.
现在终于搞定,于是将所有文章汇总.
绞尽脑汁的想写一段空前绝后,人见人爱的序言,但越写越觉得像是猫儿抓的一样.
就这样,PS:由于本人文笔有限,情商又低,下里巴人一枚,所以文中的很多语句可能让您很纠结,您可以通过邮箱与我联系.
共同探讨才是进步的关键.
最后,欢迎读者以任何方式使用与转载,但请保留作者相关信息,酱紫!
码字.
.
.
世界上最痛苦的事情莫过于此.
.
.
——老衲五木E-mail:for_rest@foxmail.
com老衲五木出品目录1移植综述-42动态内存管理-63数据包pbuf94pbuf释放-135网络接口结构-166以太网数据接收-207ARP表238ARP表查询269ARP层流程2810IP层输入3111IP分片重装1-3412IP分片重装2-3713ICMP处理4014TCP建立与断开4315TCP状态转换4616TCP控制块4917TCP建立流程5318TCP状态机5619TCP输入输出函数1-6020TCP输入输出函数2-6321TCP滑动窗口6622TCP超时与重传6923TCP慢启动与拥塞避免7324TCP快速恢复重传和Nagle算法7625TCP坚持与保活定时器8026TCP定时器8427TCP终结与小结8828API实现及相关数据结构-9129API消息机制-9430API函数及编程实例-97E-mail:for_rest@foxmail.
com老衲五木出品1移植综述移植综述移植综述移植综述如果你认为所谓的毅力是每分每秒的"艰苦忍耐"式的奋斗,那这是一种很不足的心理状态.
毅力是一种习惯,毅力是一种状态,毅力是一种生活.
看了这么久的代码觉得是不是该写点东西了,不然怎么对得起某人口中所说的科研人员这个光荣称号.
初见这如山如海的代码,着实看出了一身冷汗.
现在想想其实也不是那么难,那么多革命先辈经过N长时间才搞出来的东东怎么可能让你个毛小子几周之内搞懂.
我见到的只是冰川的一小角,万里长征的一小步,九头牛身上的一小毛…再用某人的话说,写吧,昏写,瞎写,胡写,乱写,写写就懂了.
我想我很适合当一个歌颂者,青春在风中飘着.
你知道,就算大雨让这座城市颠倒,我会给你怀抱;受不了,看见你背影来到,写下我度秒如年难捱的离骚;就算整个世界被寂寞绑票,我也不会奔跑;逃不了,最后谁也都苍老,写下我,时间和琴声交错的城堡.
我正在听的歌.
扯远了…正题,嵌入式产品连入Internet网,这个MS是个愈演愈烈的趋势.
想想,你可以足不出户对你的产品进行配置,并获取你关心的数据信息,多好.
这也许也是物联网世界最基本的雏形.
当然,你的产品要有如此功能,那可不容易,至少它得有个目前很Fashion的TCP/IP协议栈.
LWIP是一套用于嵌入式系统的开放源代码TCP/IP协议栈.
在你的嵌入式处理器不是很NB,内部Flash和Ram不是很强大的情况下,用它还是很合适滴.
LWIP的设计者为像我这样的懒惰者提供了详细的移植说明文档,当然这还不够,他们还尽可能的包揽了大部分工作,懒人们只要做很少的工作就功德圆满了.
纵观整个移植过程,使用者需要完成以下几个方面的东西:首先是LWIP协议内部使用的数据类型的定义,如u8_t,s8_t,u16_t,u32_t等等等等.
由于所移植平台处理器的不同和使用的编译器的不同,这些数据类型必须重新定义.
想想,一个int型数据在64位处理器上其长度为8个字节,在32位处理器上为4个字节,而在16位处理器上就只有两个字节了.
因此这部分需要使用者根据处理器位数和和使用的编译器的特点来编写.
所以在ARM7处理器上使用的typedefunsignedintu32_t移植语句用在64位处理器的移植过程中那肯定是行不通的了.
其次是实现与信号量和邮箱操作相关的函数,比如建立、删除、等待、释放等.
如果在裸机上直接跑LWIP,这点实现起来比较麻烦,使用者必须自己去建立一套信号量和邮箱相关的机制.
一般情况下,在使用LWIP的嵌入式系统中都会有操作系统的支持,而在操作系统中信号量和邮箱往往是最基本的进程通信机制了.
UC/OSII应该算是最简单的嵌入式操作系统了吧,它也无例外的能够提供信号量和邮箱机制,只要我们将UC/OSII中的相关函数做相应的封装,就可满足LWIP的需求.
LWIP使用邮箱和信号量来实现上层应用与协议栈间、下层硬件驱动与协议栈间的信息交互.
LWIP协议模拟了TCP/IP协议的分层思想,表面上看LWIP也是有分层思想的,但从实现上看,LWIP只在一个进程内实现了各个层次的所有工作.
具体如下:LWIP完成相关初始化后,会阻塞在一个邮箱上,等待接收数据进行处理.
这个邮箱内的数据可能来自底层硬件驱动接收到的数据包,也可能来自应用程序.
当在该邮箱内取得数据后,LWIP会对数据进行解析,然后再依次调用协议栈内部上层相关处理函数处理数据.
处理结束后,LWIP继续阻塞在邮箱上等待下一批数据.
当然LWIP还有一大串的内存管理机制用以避免在各层间交互数据时大量的时间和内存开销,这将在后续讲解中慢慢道来.
当然,但这样的设计使得代码理解难度加大,这一点让人头大.
信号量也E-mail:for_rest@foxmail.
com老衲五木出品可以用在应用程序与协议栈的互相通信中.
比如,应用程序要发送数据了,它先把数据发到LWIP阻塞的邮箱上,然后它挂起在一个信号量上;LWIP从邮箱上取得数据处理后,释放一个信号量,告诉应用程序,你要发的数据我已经搞定了;此后,应用程序得到信号量继续运行,而LWIP继续阻塞在邮箱上等待下一批处理数据.
其其次,就是与等待超时相关的函数.
上面说到LWIP协议栈会阻塞在邮箱上等待接收数据的到来.
这种等待在外部看起来是一直进行的,但其实不然.
一般在初始化LWIP进程的时候,都会同时的初始化一些超时事件,即当某些事件等待超时后,它们会自动调用一些超时处理函数做相关处理,以满足TCP/IP协议栈的需求.
这样看来,当LWIP协议栈阻塞等待邮箱之前,它会精明的计算到底应该等待多久,如果LWIP进程中没有初始化任何超时事件,那好,这种情况最简单了,永远的挂起进程就可以了,这时的等待就可以看做是天长地久的….
有点暧昧了.
如果LWIP进程中有初始化的超时事件,这时就不能一直等了,因为这样超时事件没有任何被执行的机会.
LWIP是这样做的,等待邮箱的时间设置为第一个超时事件的时间长度,如果时间到了,还没等到数据,那好,直接跳出邮箱等待转而执行超时事件,当执行完成超时事件后,再按照上述的方法继续阻塞邮箱.
可以看出,对一个LWIP进程,需要用一个链表来管理这些超时事件.
这个链表的大部分工作已经被LWIP的设计者完成了,使用者只需要实现的仅有一个函数:该函数能够返回当前进程个超时事件链表的首地址.
LWIP内部协议要利用该首地址来查找完成相关超时事件.
其其其次,如果LWIP是建立在多线程操作系统之上的话,则要实现创建一个新线程的函数.
不支持多线程的操作系统,汗…表示还没听过.
不过UC/OSII显然是支持多线程的,地球人都知道.
这样一个典型的LWIP应用系统包括这样的三个进程:首先启动的是上层应用程序进程,然后是LWIP协议栈进程,最后是底层硬件数据包接收发送进程.
通常LWIP协议栈进程是在应用程序中调用LWIP协议栈初始化函数来创建的.
注意LWIP协议栈进程一般具有最高的优先级,以便实时正确的对数据进行响应.
其其其其次,其他一些细节之处.
比如临界区保护函数,用于LWIP协议栈处理某些临界区时使用,一般通过进临界区关中断、出临界区开中断的方式来实现;又如结构体定义时用到的结构体封装宏,LWIP的实现基于这样一种机制,即上层协议已经明确知道了下层所传上来的数据的结构特点,上层直接使用相关取地址计算得到想要的数据,而避免了数据递交时的复制与缓冲,所以定义结构体封装宏,禁止编译器的地址自动对齐是必须的;还有诸如调试输出、测量记录方面的宏不做讲解.
最后,也是比较重要的地方.
底层网络驱动函数的实现.
这取决于你嵌入式硬件系统所使用的网络接口芯片,也就是网卡芯片,常见的有RTL8201BL、ENC28J60等等.
不同的接口芯片厂商都会提供丰富的驱动函数.
我们只要将这些发送接收接口函数做相应的封装,将接收到得数据包封装为LWIP协议栈熟悉的数据结构、将发送的数据包分解为芯片熟悉的数据结构就基本搞定了.
最起码的,发送一个数据包函数和接收一个数据包函数需要被实现.
那就这样了吧,虽然写得草草,但终于在撤退之前搞定.
好的开始是成功的一半,那这暂且先算四分之一吧.
不晓得一个月、两个月或者更多时间能写完否.
预知后事如何,请见下回分解.
E-mail:for_rest@foxmail.
com老衲五木出品2动态内存管理动态内存管理动态内存管理动态内存管理最近电力局很不给力啊,隔三差五的停电,害得我们老是痛苦的双扣斗地主,不带这样的啊!
今天还写吗写,必须的.
昨天把LWIP的移植工作框架说了一下,网上也有一大筐的关于移植细节的文档.
有兴趣的童鞋不妨去找找.
这里,我很想探究LWIP内部协议实现的细节,以及所有盘根错节的问题的来龙去脉.
以后的讨论研究将按照LWIP英文说明文档《DesignandImplementationoftheLWIP:TCP/IPStack》的结构组织展开.
这里讨论LWIP的动态内存管理机制.
总的来说,LWIP的动态内存管理机制可以有三种:C运行时库自带的内存分配策略、动态内存堆(HEAP)分配策略和动态内存池(POOL)分配策略.
动态内存堆分配策略和C运行时库自带的内存分配策略具有很大的相似性,这是LWIP模拟运行时库分配策略实现的.
这两种策略使用者只能从中选择一种,这通过头文件lwippools.
h中的宏定义MEM_LIBC_MALLOC来实现的,当它被定义为1时则使用标准C运行时库自带的内存分配策略,而为0时则使用LWIP自身的动态内存堆分配策略.
一般情况下,我们选择使用LWIP自身的动态内存堆分配策略,这里不对C运行时库自带的内存分配策略进行讨论.
同时,动态内存堆分配策略可以有两种实现方式,纠结….
第一种就是如前所述的通过开辟一个内存堆,然后通过模拟C运行时库的内存分配策略来实现.
第二种就是通过动态内存池的方式来实现,也即动态内存堆分配函数通过简单调用动态内存池(POOL)分配函数来完成其功能(太敷衍了事了),在这种情况下,用户需要在头文件lwippools.
h中定义宏MEM_USE_POOLS和MEM_USE_CUSTOM_POOLS为1,同时还要开辟一些额外的缓冲池区,如下:LWIP_MALLOC_MEMPOOL_STARTLWIP_MALLOC_MEMPOOL(20,256)LWIP_MALLOC_MEMPOOL(10,512)LWIP_MALLOC_MEMPOOL(5,1512)LWIP_MALLOC_MEMPOOL_END这几句摘自LWIP源码注释部分,表示为动态内存堆相关功能函数分配20个256字节长度的内存块,10个512字节的内存块,5个1512字节的内存块.
内存池管理会根据以上的宏自动在内存中静态定义一个大片内存用于内存池.
在内存分配申请的时候,自动根据所请求的大小,选择最适合他长度的池里面去申请,如果启用宏MEM_USE_POOLS_TRY_BIGGER_POOL,那么,如果上述的最适合长度的池中没有空间可以用了,分配器将从更大长度的池中去申请,不过这样会浪费更多的内存.
晕乎乎….
.
就这样了,这种方式一般不会被用到.
哎,就最后这句话给力.
下面讨论动态内存堆分配策略的第一种实现方式,这也是一般情况下被使用的方式.
这部分讨论主要参照网上Oldtom'sBlog,TA写得很好(但是也有一点小小的错误),所以一不小心被我借用了.
动态内存堆分配策略原理就是在一个事先定义好大小的内存块中进行管理,其内存分配的策略是采用最快合适(FirstFit)方式,只要找到一个比所请求的内存大的空闲块,就从中切割出合适的块,并把剩余的部分返回到动态内存堆中.
分配的内存块有个最小大小的限E-mail:for_rest@foxmail.
com老衲五木出品制,要求请求的分配大小不能小于MIN_SIZE,否则请求会被分配到MIN_SIZE大小的内存空间.
一般MIN_SIZE为12字节,在这12个字节中前几个字节会存放内存分配器管理用的私有数据,该数据区不能被用户程序修改,否则导致致命问题.
内存释放的过程是相反的过程,但分配器会查看该节点前后相邻的内存块是否空闲,如果空闲则合并成一个大的内存空闲块.
采用这种分配策略,其优点就是内存浪费小,比较简单,适合用于小内存的管理,其缺点就是如果频繁的动态分配和释放,可能会造成严重的内存碎片,如果在碎片情况严重的话,可能会导致内存分配不成功.
对于动态内存的使用,比较推荐的方法就是分配->释放->分配->释放,这种使用方法能够减少内存碎片.
下面具体来看看LWIP是怎么来实现这些函数的.
mem_init()内存堆的初始化函数,主要是告知内存堆的起止地址,以及初始化空闲列表,由lwip初始化时自己调用,该接口为内部私有接口,不对用户层开放.
mem_malloc()申请分配内存.
将总共需要的字节数作为参数传递给该函数,返回值是指向最新分配的内存的指针,而如果内存没有分配好,则返回值是NULL,分配的空间大小会收到内存对齐的影响,可能会比申请的略大.
返回的内存是"没有"初始化的.
这块内存可能包含任何随机的垃圾,你可以马上用有效数据或者至少是用零来初始化这块内存.
内存的分配和释放,不能在中断函数里面进行.
内存堆是全局变量,因此内存的申请、释放操作做了线程安全保护,如果有多个线程在同时进行内存申请和释放,那么可能会因为信号量的等待而导致申请耗时较长.
mem_calloc()是对mem_malloc()函数的简单包装,他有两个参数,分别为元素的数目和每个元素的大小,这两个参数的乘积就是要分配的内存空间的大小,与mem_malloc()不同的是它会把动态分配的内存清零.
有经验的程序员更喜欢使用mem_calloc(),因为这样的话新分配内存的内容就不会有什么问题,调用mem_calloc()肯定会清0,并且可以避免调用memset().
休息…………动态内存池(POOL)分配策略可以说是一个比较笨的分配策略了,但其分配策略实现简单,内存的分配、释放效率高,可以有效防止内存碎片的产生.
不过,他的缺点是会浪费部分内存.
为什么叫POOL这点很有趣,POOL有很多种,而这点依赖于用户配置LWIP的方式.
例如用户在头文件opt.
h文件中定义LWIP_UDP为1,则在编译的时候与UDP类型内存池就会被建立;定义LWIP_TCP为1,则在编译的时候与TCP类型内存池就会被建立.
另外,还有很多其他类型的内存池,如专门存放网络包数据信息的PBUF_POOL、还有上面讲解动态内存堆分配策略时提到的CUSTOM_POOLS等等等等.
某种类型的POOL其单个大小是固定的,而分配该类POOL的个数是可以用户配置的,用户应该根据协议栈实际使用状况进行配置.
把协议栈中所有的POOL挨个放到一起,并把它们放在一片连续的内存区域,这呈现给用户的就是一个大的缓冲池.
所以,所谓的缓冲池的内部组织应该是这样的:开始处放了A类型的POOL池a个,紧接着放上B类型的POOL池b个,再接着放上C类型的POOL池c个….
直至最后N类型的POOL池n个.
这一点很像UC/OSII中进程控制块和事件控制块,先开辟一堆各种类型的放那,你要用直接来取就是了.
注意,这里的分配必须是以单个缓冲池为基本单位的,在这样的情况下,可能导致内存浪费的情况.
这是很明显的啊,不解释.
下面我来看看在LWIP实现中是怎么开辟出上面所论述的大大的缓冲池的('的'这个字,今天让我们一群人笑了很久).
基本上绝大部分人看到这部分代码都会被打得晕头转向,完全不晓得作者是在干啥,但是仔细理解后,你不得不佩服作者超凡脱俗的代码写能力,差一点用了沉鱼落雁这个词,罪过.
上代码:E-mail:for_rest@foxmail.
com老衲五木出品staticu8_tmemp_memorystaticu8_tmemp_memorystaticu8_tmemp_memorystaticu8_tmemp_memory[[[[MEM_ALIGNMENTMEM_ALIGNMENTMEM_ALIGNMENTMEM_ALIGNMENT----1111#defineLWIP_MEMPOOL(name,num,size,desc)+#defineLWIP_MEMPOOL(name,num,size,desc)+#defineLWIP_MEMPOOL(name,num,size,desc)+#defineLWIP_MEMPOOL(name,num,size,desc)+((num)*(MEMP_SIZE+MEMP_ALIGN_SIZE(size)))((num)*(MEMP_SIZE+MEMP_ALIGN_SIZE(size)))((num)*(MEMP_SIZE+MEMP_ALIGN_SIZE(size)))((num)*(MEMP_SIZE+MEMP_ALIGN_SIZE(size)))#include"lwip/memp_std.
h"#include"lwip/memp_std.
h"#include"lwip/memp_std.
h"#include"lwip/memp_std.
h"];上面的代码定义了缓冲池所使用的内存缓冲区,很多人肯定会怀疑这到底是不是一个数组的定义.
定义一个数组,里面居然还有define和include关键字.
解决问题的关键就在于头文件memp_std.
h,它里面的东西可以被简化为诸多条LWIP_MEMPOOL(name,num,size,desc).
又由于用了define关键字将LWIP_MEMPOOL(name,num,size,desc)定义为+((num)*(MEMP_SIZE+MEMP_ALIGN_SIZE(size))),所以,memp_std.
h被编译后就为一条一条的+(),+(),+(),+()….
所以最终的数组memp_memory等价定义为:staticu8_tmemp_memory[MEM_ALIGNMENT–1+()+()….
];如果此刻你还没懂,只能说明我的表述能力有问题了.
当然还有个小小的遗留问题,为什么数组要比实际需要的大MEM_ALIGNMENT–1作者考虑的是编译器的字对齐问题,到此打住,这个问题不能深究啊,以后慢慢讲.
复制上面的数组建立的方法,协议栈还建立了一些与缓冲池管理的全局变量:memp_num:这个静态数组用于保存各种类型缓冲池的成员数目memp_sizes:这个静态数组用于保存各种类型缓冲池的结构大小memp_tab:这个指针数组用于指向各种类型缓冲池当前空闲节点接下来就是理所当然的实现函数了:memp_init():内存池的初始化,主要是为每种内存池建立链表memp_tab,其链表是逆序的,此外,如果有统计功能使能的话,也把记录了各种内存池的数目.
memp_malloc():如果相应的memp_tab链表还有空闲的节点,则从中切出一个节点返回,否则返回空.
memp_free():把释放的节点添加到相应的链表memp_tab头上.
从上面的三个函数可以看出,动态内存池分配过程时相当的简洁直观啊.
HC:百度说是胡扯的意思.
哈哈……E-mail:for_rest@foxmail.
com老衲五木出品3数据包数据包数据包数据包pbuf高的地方,总是很冷.
孤独,可以让人疯狂.
没人能懂你!
昨天讲过了LWIP的内存分配机制.
再来总之一下,LWIP中常用到的内存分配策略有两种,一种是内存堆分配,一种是内存池分配.
前者可以说能随心所欲的分配我们需要的合理大小的内存块(又是'的'),缺点是当经过多次的分配释放后,内存堆中间会出现很多碎片,使得需要分配较大内存块时分配失败;后者分配速度快,就是简单的链表操作,因为各种类型的POOL是我们事先建立好的,但是采用POOL会有些情况下会浪费掉一定的内存空间.
在LWIP中,将这两种分配策略混合使用,达到了很好的内存使用效率.
下面我们将来看看LWIP中是怎样合理利用这两种分配策略的.
这就顺利的过渡到了这节要讨论的话题:LWIP的数据包缓冲的实现.
在协议栈中移动的数据包,最无疑的是整个内存管理中最重要的部分了.
数据包的种类和大小也可以说是五花八门,数数,首先从网卡上来的原始数据包,它可以是长达上千个字节的TCP数据包,也可以是仅有几个字节的ICMP数据包;再从要发送的数据包看,上层应用可能将自己要发送的千奇百怪形态各异的数据包递交给LWIP协议栈发送,这些数据可能存在于应用程序管理的内存空间内,也可能存在于某个ROM上.
注意,这里有个核心的东西是当数据在各层之间传递时,LWIP极力禁止数据的拷贝工作,因为这样会耗费大量的时间和内存.
综上,LWIP必须有个高效的数据包管理核心,它即能海纳百川似的兼容各种类型的数据,又能避免在各层之间的复制数据的巨大开销.
数据包管理机构采用数据结构pbuf来描述数据包,其源码如下,structpbuf{structpbuf*next;void*payload;u16_ttot_len;u16_tlen;u8_ttype;u8_tflags;u16_tref;};这个看似简单的数据结构,却够我讲一大歇的了!
next字段指针指向下一个pbuf结构,因为实际发送或接收的数据包可能很大,而每个pbuf能够管理的数据可能很少,所以,往往需要多个pbuf结构才能完全描述一个数据包.
所以,所有的描述同一个数据包的pbuf结构E-mail:for_rest@foxmail.
com老衲五木出品需要链在一个链表上,这一点用next实现.
payload是数据指针,指向该pbuf管理的数据的起始地址,这里,数据的起始地址可以是紧跟在pbuf结构之后的RAM,也可能是在ROM上的某个地址,而决定这点的是当前pbuf是什么类型的,即type字段的值,这在下面将继续讨论.
len字段表示当前pbuf中的有效数据长度,而tot_len表示当前pbuf和其后所有pbuf的有效数据的长度.
显然,tot_len字段是len字段与pbuf链中随后一个pbuf的tot_len字段的和;pbuf链中第一个pbuf的tot_len字段表示整个数据包的长度,而最后一个pbuf的tot_len字段必和len字段相等.
type字段表示pbuf的类型,主要有四种类型,这点基本上涉及到pbuf管理中最难的部分,将在下节仔细讨论.
文档上说flags字段也表示pbuf的类型,不懂,type字段不是说明了pbuf的类型吗不过在源代码里,初始化一个pbuf的时候,是将该字段的值设为0,而在其他地方也没有用到该字段,所以,这里直接忽略掉.
最后ref字段表示该pbuf被引用的次数.
这里又是一个纠结的地方啊.
初始化一个pbuf的时候,ref字段值被设置为1,当有其他pbuf的next指针指向该pbuf时,该pbuf的ref字段值加一.
所以,要删除一个pbuf时,ref的值必须为1才能删除成功,否则删除失败.
pbuf的类型,令人很晕的东西.
pbuf有四类:PBUF_RAM、PBUF_ROM、PBUF_REF和PBUF_POOL.
下面,一个一个的来看看各种类型的特点.
PBUF_RAM类型的pbuf主要通过内存堆分配得到的.
这种类型的pbuf在协议栈中是用得最多的.
协议栈要发送的数据和应用程序要传递的数据一般都采用这个形式.
申请PBUF_RAM类型时,协议栈会在内存堆中分配相应的大小,注意,这里的大小包括如前所述的pbuf结构头大小和相应数据缓冲区,他们是在一片连续的内存区的.
下面来看看源代码是怎样申请PBUF_RAM型的.
其中p是pbuf型指针.
p=(structpbuf*)mem_malloc(LWIP_MEM_ALIGN_SIZE(SIZEOF_STRUCT_PBUF+offset)+LWIP_MEM_ALIGN_SIZE(length));可以看出,系统是调用内存堆分配函数mem_malloc进行内存分配的.
分配空间的大小包括:pbuf结构头大小SIZEOF_STRUCT_PBUF,需要的数据存储空间大小length,还有一个offset.
关于这个offset,也有一大堆可以讨论的东西,不过先到此打住.
总之,分配成功的PBUF_RAM类型的pbuf如下图:E-mail:for_rest@foxmail.
com老衲五木出品从图中可看出pbuf头和相应数据在同一片连续的内存区种,注意payload并没有指向pbuf头结束即ref字段之后,而是隔了一定的区域.
这段区域就是上面的offset的大小,这段区域用来存储数据的包头,如TCP包头,IP包头等.
当然,offset也可以是0,具体值是多少,那就要看你是怎么个申请法了.
如果还要深究,你肯定会更晕了.
PBUF_POOL类型和PBUF_RAM类型的pbuf有很大的相似之处,但它主要通过内存池分配得到的.
这种类型的pbuf可以在极短的时间内得到分配.
在接受数据包时,LWIP一般采用这种方式包装数据.
申请PBUF_POOL类型时,协议栈会在内存池中分配适当的内存池个数以满足需要的申请大小.
下面来看看源代码是怎样申请PBUF_POOL型的.
其中p是pbuf型指针.
p=memp_malloc(MEMP_PBUF_POOL);可以看出,系统是调用内存池分配函数memp_malloc进行内存分配的.
分配成功的PBUF_POOL类型的pbuf如下图:图中是分配指定大小的数据缓冲的结果,系统调用会分配多个固定大小的PBUF_POOL类型pbuf,并把这些pbufs链成一个链表,以满足用户的分配空间请求.
PBUF_ROM和PBUF_REF类型的pbuf基本相同,它们的申请都是在内存堆中分配一个相应的pbuf结构头,而不申请数据区的空间.
这就是它们与PBUF_RAM和PBUF_POOL的最大区别.
PBUF_ROM和PBUF_REF类型的区别在于前者指向ROM空间内的某段数据,而后者指向RAM空间内的某段数据.
下面来看看源代码是怎样申请PBUF_ROM和PBUF_REF类型的.
其中p是pbuf型指针.
p=memp_malloc(MEMP_PBUF);可以看出,系统是调用内存池分配函数memp_malloc进行内存分配的.
而此刻请求的内存池类型为MEMP_PBUF,而不是MEMP_PBUF_POOL,晕啊…这个太让人郁闷了.
MEMP_PBUF类型的内存池大小恰好为一个pbuf头的大小,因为这种池是LWIP专为PBUF_ROM和PBUF_REF类型的pbuf量身制作的.
LWIP还是真的很周到啊,它会为不同的数据结构量身定做不同类型的池.
正确分配的PBUF_ROM或PBUF_REF类型的pbuf,其结构如下图:E-mail:for_rest@foxmail.
com老衲五木出品注:以上所有图片都来自文档《DesignandImplementationoftheLWIP:TCP/IPStack》,这些图都有个共同的错误,即len和tot_len字段位置搞反了,窃喜.
最后说明,对于一个数据包,它可能使用上述的任意的pbuf类型,很可能的情况是,一大串不同类型的pbufs连在一起,用以保存一个数据包的数据.
广告……下节看点,关于pbuf的内存释放问题.
E-mail:for_rest@foxmail.
com老衲五木出品4pbuf释放释放释放释放牢骚发完,GoOn.
昨天说到了数据缓冲pbuf的内存申请,今天继续来探究一下它的内存释放过程.
由于pbuf的申请主要是通过内存堆分配和内存池分配来实现,所以,pbuf的释放也必须按照这两种情况分别讨论.
别慌,在展开讨论之前,还得说说某个pbuf能被释放的前提.
在LWIP中这点很容易判断,因为前节说到pbuf的ref字段表示该pbuf被引用的次数,当pbuf被创建时,该字段的初始值为1,由此可判断,当pbuf的ref字段为1时,该pbuf才可以被删除,所以位于pbufs链表中间的pbuf结构是不会被删除成功的,因为他们的ref值至少是2.
由此总之一下,能被删除的pbuf必然是某个pbufs链的首节点.
当然pbuf的删除工作远不如此的简单,其中另一个需要特别注意的地方是,想想,很可能的情况是某个pbufs链的首节点删除成功后,该pbufs链的第二个节点就自然的成为该pbufs链的首节点,此时,该节点的ref值可能变为1(该节点没有被引用了),这种情况下,该节点也会被删除,因为LWIP认为它和第一个节点一起存储同一数据包.
当第二个节点也被删除后,LWIP又会去看看第三个节点是否满足删除条件…就这样一直删一下去.
当然,如果首节点删除后,第二个节点的ref值大于1,表示该节点还在其他地方被引用,不能再被删除,删除工作至此结束.
这段话写的很口水,不如我们举个例子来看看这个删除过程.
假如现在我们的pbufs链表有A,B,C三个pbuf结构连接起来,结构为A--->B--->C,利用pbuf_free(A)函数来删除pbuf结构,下面用ABC的几组不同ref值来看看删除结果:(1)1->2->3函数执行后变为.
.
.
1->3,节点BC仍在;(2)3->3->3函数执行后变为2->3->3,节点ABC仍在;(3)1->1->2函数执行后变为.
.
.
.
.
.
1,节点C仍在;(4)2->1->1函数执行后变为1->1->1,节点ABC仍在;(5)1->1->1函数执行后变为.
.
.
.
.
.
.
,节点全部被删除.
如果您能说醍醐灌顶,那将是我最大的动力.
当可以删除某个pbuf结构时,LWIP首先检查这个pbuf是属于前节讲到的四个类型中的哪种,根据类型的不同,调用不同的内存释放函数进行删除.
PBUF_POOL类型和PBUF_ROM类型、PBUF_REF类型需要通过memp_free()函数删除,PBUF_RAM类型需要通过mem_free()函数删除,原因不解释.
PBUF_RAM类型来自于内存堆,所以需通过mem_free()函数将pbuf释放回内存堆.
这里,先得来看看内存堆的组织结构,见下图,在内存堆内部,内存堆管理模块通过在每一个内存分配块的顶部放置一个比较小的结构体来保存内存分配纪录(注意这个小小的结构体是内存管理模块自动附加上去的,独立于用户的申请大小).
这个结构体拥有三个成员变量,两个指针和一个标志,如图.
next与prev分别指向内存的下一个和上一个分配块,used标志表示该内存块是否已被分配.
图中需要注意的两个地方,first,图中每个内存块的大小是不同且可能随时变化的.
Second,当系统初始化的时候,整个内存堆就是一个内存块,下图中是经过多次分配释放后内存堆呈现出来的结果.
内存堆管理模块根据所申请分配的大小来搜索所有未被使用的内存分配块,检索到的最先满足条件的内存块将分配给申请者,注意这里并不包括前面说到的那个小小结构体,所以用户得到的是used后的那个地址.
当分配成功后,内存管理模块会马上在已经分配走了的数据区后面再插一个小小的结构体,并用next和prev指针将这个结构体串起来,以便于E-mail:for_rest@foxmail.
com老衲五木出品下次分配.
经过几次的申请与释放,我们就看到了图中的内存堆组织模型.
当内存释放的时候,为了防止内存碎片的产生,上一个与下一个分配块的使用标志会被检查,如果他们中的任何一个还未被使用,这个内存块将被合并到一个更大的未使用内存块中.
内存堆管理模块是这样做的,它根据用户提供的释放地址退后几个字节去寻找这个小小的结构体,利用这个结构体来实现内存堆得合并等操作.
已经分配的内存块被回收后,使用标志used清零.
当然,如果上一个与下一个分配块都已被使用,这时的释放就是最简单的情况,但这也是产生内存堆碎片问题的根源.
哎,要了老命了.
接着来讲其他三种结构通过memp_free()函数将pbuf释放回内存池的情况.
前面的内容已经讲过了内存池POOL的结构,PBUF_POOL型pbuf主要使用的是MEMP_PBUF_POOL类型的POOL,PBUF_ROM和PBUF_REF型pbuf主要使用的是MEMP_PBUF型的POOL.
这句话太绕了,你应该多读两遍.
POOL结构的起始处有个next指针,用于指向同类型的下一个POOL,用于将同类型的POOL连接成一个单向链表,这里应该有必要仔细看看POOL池是怎样初始化的,代码很简单:memp=LWIP_MEM_ALIGN(memp_memory);for(i=0;inext=memp_tab[i];memp_tab[i]=memp;memp=(structmemp*)((u8_t*)memp+MEMP_SIZE+memp_sizes[i]);//取得下}//一个POOL的地址}E-mail:for_rest@foxmail.
com老衲五木出品上面代码中有几个重要的全局变量,memp_memory是缓冲池的起始地址,前面已有所讨论;MEMP_MAX是POOL类型数;memp_tab用于指向某类POOL空闲链表的起始节点;memp_num表示各种类型POOL的个数;memp_sizes表示各种类型单个POOL的大小,对于MEMP_PBUF_POOL和MEMP_PBUF型的POOL,其大小是pbuf头和pbuf可装载数据大小的总和.
在这样的基础之上,POOL池的释放就简单了,首先根据POOL的类型找到相应空闲链表头memp_tab,将该POOL插在链表头上,并把memp_tab指向链表头,简单快捷.
至于POOL池的的申请那自然而然的也就是对memp_tab头的操作了,这个相信你懂.
好了,到这里,LWIP的内存相关机制就基本介绍完毕.
当然,它不可能这样简单,在以后使用到的地方,我会再加说明.
整理整理,收工.
周末来啦….
冬眠一天是必不可少的….
哈哈E-mail:for_rest@foxmail.
com老衲五木出品5网络接口结构网络接口结构网络接口结构网络接口结构我只是不想,将这份心动付诸言语.
前面还有一句:信任他人,并不意味着软弱.
我只是假装对万物一无所知,好借此获得你所有的温柔.
谢谢你所做的一切,现在一切又将重新开始.
我只有将这份无法忘怀的思念送给你.
人们总说"黑夜会过去",但那只是善意的谎言.
我想就算一个人,应该也能生存下去,因为你的笑容已经永远铭刻在我心中,还有那应该已经被我舍弃的信任别人的心.
以上内容系剽窃于某某美女的歌词.
今天我们来讨论LWIP是怎样来处理与底层硬件,即网卡芯片间的关系的.
为什么要首先讨论这个问题呢与许多其他的TCP/IP实现一样,LWIP也是以分层的协议为参照来设计实现TCP/IP的.
LWIP从逻辑上看分为四层:链路层、网络层、传输层和应用层.
注意,虽然LWIP也采用了分层机制,但它没有在各层之间进行严格的划分,各层协议之间可以进行或多或少的交叉存取,即上层可以意识到下层协议所使用的缓存处理机制.
因此各层可以更有效地重用缓冲区.
而且,应用进程和协议栈代码可以使用相同的内存,应用可以直接读写内部缓存,因此节省了执行拷贝的开销.
我们将从LWIP的最底层链路层起步,开始整个LWIP内部协议之旅.
在LWIP中,是通过一个叫做netif的网络结构体来描述一个硬件网络接口的.
这个接口结构比较简单,下面我们从源代码结构来分析分析这个结构:structnetif{structnetif*next;//指向下一个netif结构的指针structip_addrip_addr;//IP地址相关配置structip_addrnetmask;structip_addrgw;err_t(*input)(structpbuf*p,structnetif*inp);//调用这个函数可以从网卡上取得一个//数据包err_t(*output)(structnetif*netif,structpbuf*p,//IP层调用这个函数可以向网卡发送structip_addr*ipaddr);//一个数据包err_t(*linkoutput)(structnetif*netif,structpbuf*p);//ARP模块调用这个函数向网//卡发送一个数据包void*state;//用户可以独立发挥该指针,用于指向用户关心的网卡信息u8_thwaddr_len;//硬件地址长度,对于以太网就是MAC地址长度,为6各字节u8_thwaddr[NETIF_MAX_HWADDR_LEN];//MAC地址u16_tmtu;//一次可以传送的最大字节数,对于以太网一般设为1500u8_tflags;//网卡状态信息标志位charname[2];//网络接口使用的设备驱动类型的种类u8_tnum;//用来标示使用同种驱动类型的不同网络接口};next字段是指向下一个netif结构的指针.
我们的一个产品可能会有多个网卡芯片,LWIPE-mail:for_rest@foxmail.
com老衲五木出品会把所有网卡芯片的结构体链成一个链表进行管理,有一个netif_list的全局变量指向该链表的头部.
next字段就是用于链表用.
ip_addr、netmask、gw三个字段用于发送和处理数据包用,分别表示IP地址、子网掩码和网关地址.
前两个字段在数据包发送时有重要作用,第三个字段似乎没什么用.
IP地址和网卡设备必须一一对应.
如果你连什么叫IP地址、子网掩码和它们的作用都不晓得,那你有必要去看看TCP/IP协议详解卷1第三章.
input字段指向一个函数,这个函数将网卡设备接收到的数据包提交给IP层,使用时将input指针指向该函数即可,后面将详细讨论这个问题.
该函数的两个参数是pbuf类型和netif类型的,返回参数是err_t类型.
其中pbuf代表接收到的数据包.
output字段向一个函数,这个函数和具体网络接口设备驱动密切相关,它用于IP层将一个数据包发送到网络接口上.
用户需要根据实际网卡编写该函数,并将output字段指向该函数.
该函数的三个参数是pbuf类型、netif类型和ip_addr类型,返回参数是err_t类型.
其中pbuf代表要发送的数据包.
ipaddr代表网卡需要将该数据包发送到的地址,该地址应该是接收实际的链路层帧的主机的IP地址,而不一定为数据包最终需要到达的IP地址.
例如,当要发送IP信息包到一个并不在本地网络里的主机上时,链路层帧会被发送到网络里的一个路由器上.
在这种情况下,给output函数的IP地址将是这个路由器的地址.
linkoutput字段和上面的output基本上是起相同的作用,但是这个函数是在ARP模块中被调用的,这里不赘述了.
注意这个函数只有两个参数.
实际上output字段函数的实现最终还是调用linkoutput字段函数将数据包发送出去的.
state字段可以指向用户关心的关于设备的一些信息,用户可以自由发挥,也可以不用.
hwaddr_len和hwaddr[]表示MAC地址长度和MAC地址,一般MAC地址长度为6.
mtu字段表示该网络一次可以传送的最大字节数,对于以太网一般设为1500,不多说.
flags字段是网卡状态信息标志位,是很重要的控制字段,它包括网卡功能使能、广播使能、ARP使能等等重要控制位.
name[]字段用于保存每一个网络网络接口的名字.
用两个字符的名字来标识网络接口使用的设备驱动的种类,名字由设备驱动来设置并且应该反映通过网络接口表示的硬件的种类.
比如蓝牙设备(bluetooth)的网络接口名字可以是bt,而IEEE802.
11bWLAN设备的名字就可以是wl,当然设置什么名字用户是可以自由发挥的,这并不影响用户对网络接口的使用.
当然,如果两个网络接口具有相同的网络名字,我们就用num字段来区分相同类别的不同网络接口.
到这里,你可能一头雾水,太抽象的东西太容易让人纠结.
好吧,我们举个例子来看看一个以太网网卡接口结构是这样被初始化,还有数据包是如何接收和发送的.
先来看初始化过程,源码:staticstructnetifenc28j60;(1)structip_addripaddr,netmask,gw;(2)IP4_ADDR(&gw,192,168,0,1);(3)IP4_ADDR(&ipaddr,192,168,0,60);(4)IP4_ADDR(&netmask,255,255,255,0);(5)netif_init();(6)netif_add(&enc28j60,&ipaddr,&netmask,&gw,NULL,ethernetif_init,tcpip_input);(7)netif_set_default(&enc28j60);(8)netif_set_up(&enc28j60);(9)E-mail:for_rest@foxmail.
com老衲五木出品上面的(1)声明了一个netif结构的变量enc28j60,由于在我的板子上使用的是网卡芯片enc28j60,所以我选择使用了这个名字.
(2)声明了三个分别用于暂存IP地址、子网掩码和网关地址的变量,它们是32位长度的.
(3)~(5)分别是对上述三个地址值的初始化,该过程简单.
(6)很简单,它只需初始化上面所述的全局变量netif_list即可:netif_list=NULL.
(7)调用netif_add函数初始化变量enc28j60,其中比较重要的两个参数是ethernetif_init和tcpip_input,前者是用户自己定义的底层接口初始化函数,tcpip_input函数是向IP层递交数据包的函数,从前面的讲述中可以很明显的看出,该值会被传递给enc28j60的input字段.
再来看看源码:structnetif*netif_add(structnetif*netif,structip_addr*ipaddr,structip_addr*netmask,structip_addr*gw,void*state,err_t(*init)(structnetif*netif),err_t(*input)(structpbuf*p,structnetif*netif)){staticu8_tnetifnum=0;netif->ip_addr.
addr=0;//复位变量enc28j60中各字段的值netif->netmask.
addr=0;netif->gw.
addr=0;netif->flags=0;//该网卡不允许任何功能使能netif->state=state;//指向用户关心的信息,这里为NULLnetif->num=netifnum++;//设置num字段,netif->input=input;//如前所诉,input函数被赋值netif_set_addr(netif,ipaddr,netmask,gw);//设置变量enc28j60的三个地址if(init(netif)!
=ERR_OK){//用户自己的底层接口初始化函数returnNULL;}netif->next=netif_list;//将初始化后的节点插入链表netif_listnetif_list=netif;//netif_list指向链表头returnnetif;}上面的初始化函数调用了用户自己定义的底层接口初始化函数,这里为ethernetif_init,再来看看它的源代码:err_tethernetif_init(structnetif*netif){netif->name[0]=IFNAME0;//初始化变量enc28j60的name字段netif->name[1]=IFNAME1;//IFNAME在文件外定义的,这里不必关心它的具体值E-mail:for_rest@foxmail.
com老衲五木出品netif->output=etharp_output;//IP层发送数据包函数netif->linkoutput=low_level_output;////ARP模块发送数据包函数low_level_init(netif);//底层硬件初始化函数returnERR_OK;}天,还有函数调用!
low_level_init函数就是与我们使用的硬件密切相关的函数了.
啊啊啊啊啊啊啊,没写完,明天再来吧!
!
E-mail:for_rest@foxmail.
com老衲五木出品6以太网数据接收以太网数据接收以太网数据接收以太网数据接收少壮不努力,长大写程序.
悲剧!
昨天说到low_level_init函数是与我们使用的与硬件密切相关初始化函数,看看:staticvoidlow_level_init(structnetif*netif){netif->hwaddr_len=ETHARP_HWADDR_LEN;//设置变量enc28j60的hwaddr_len字段netif->hwaddr[0]='F';//初始化变量enc28j60的MAC地址netif->hwaddr[1]='O';//设什么地址用户自由发挥吧,但是不要与其他netif->hwaddr[2]='R';//网络设备的MAC地址重复.
netif->hwaddr[3]='E';netif->hwaddr[4]='S';netif->hwaddr[5]='T';netif->mtu=1500;//最大允许传输单元//允许该网卡广播和ARP功能,并且该网卡允许有硬件链路连接netif->flags=NETIF_FLAG_BROADCAST|\NETIF_FLAG_ETHARP|NETIF_FLAG_LINK_UP;enc28j60_init(netif->hwaddr);//与底层驱动硬件驱动程序密切相关的硬件初始化函数}至此,终于变量enc28j60被初始化好了,而且它描述的网卡芯片enc28j60也被初始化好了,而且变量enc28j60也被链入链表netif_list.
接着上上上上面的语句(8)调用netif_set_default函数初始化缺省网络接口.
协议栈除了有个netif_list全局变量指向netif网络接口结构的链表,还有个全局变量netif_default全局变量指向缺省的网络接口结构.
当IP层有数据发送时,它首先会以netif_list为索引选择满足某个条件的网络接口发送数据包,但是,当找不到这样的接口时,协议栈就会调用缺省的网络接口直接发送数据包,所以(8)中的意思是把变量enc28j60描述的网络接口设置为缺省的网络接口.
(9)调用函数netif_set_up使能网络接口,这通过一个简单语句来实现:netif->flags|=NETIF_FLAG_UP;至此,网卡初始化完成,能正常接收和发送数据包了.
下面我们来讨论讨论关于网卡数据包的接收和发送.
LWIP中实现了接收一个数据包和发送一个数据包函数的框架,这两个函数分别是low_level_input和low_level_output,用户需要使用实际网卡驱动程序完成这两个函数.
在第一篇中讲过,一个典型的LWIP应用系统包括这样的三个进程:首先是上层应用程序进程,然后是LWIP协议栈进程,最后是底层硬件数据包接收进程.
这里我们就来讲讲第三个进程,看看数据包是怎样被接收并往上层传递的.
但在这之前,有必要说说以太网网卡所收到的数据包的格式.
如下图,E-mail:for_rest@foxmail.
com老衲五木出品LWIP使用了一个eth_hdr的数据结构来描述以太网数据包包头的14个字节.
如下,PACK_STRUCT_BEGINstructeth_hdr{PACK_STRUCT_FIELD(structeth_addrdest);//目标MAC地址PACK_STRUCT_FIELD(structeth_addrsrc);//源MAC地址PACK_STRUCT_FIELD(u16_ttype);//类型}PACK_STRUCT_STRUCT;PACK_STRUCT_END其中PACK_STRUCT_xxx都是与编译器字对齐相关的宏定义,这里不作详细介绍了.
上面的dest、src和type三个字段分别和上图中的目的MAC地址、源MAC地址和类型域字段对应.
在上面讨论的基础上,我们来看看这个数据包接收进程,源代码如下:voidethernetif_input(void*arg)//创建该进程时,要将某个网络接口结构的netif结构指{//针作为参数传入structeth_hdr*ethhdr;structpbuf*p;structnetif*netif=(structnetif*)arg;while(1){p=low_level_input(netif);//接收一个数据包if(p==NULL)//如果数据包为空,continue;//则循环结束,启动下次接收过程ethhdr=p->payload;//取得数据包内数据switch(htons(ethhdr->type))//判断数据包类型{//只对IP数据包和ARP数据包进行处理caseETHTYPE_IP://IP数据包caseETHTYPE_ARP://ARP数据包if(netif->input(p,netif)!
=ERR_OK)//将数据包发送到上层应用函数{pbuf_free(p);p=NULL;}break;default:pbuf_free(p);E-mail:for_rest@foxmail.
com老衲五木出品p=NULL;break;}//switch}//while}//main函数要创建上面的这个进程,需要把个网络接口结构的netif结构指针作为参数传入,在UC/OSII中要用到下面的语句实现,OSTaskCreate(ethernetif_input,(void*)&enc28j60,&T_ETHERNETIF_INPUT_STK[T_ETHERNETIF_INPUT_STKSIZE-1]ETH_IF_TASK_PRIO);在数据包接收进程中,有三个需要注意的地方.
一是数据包接收的方法是查询方式,即处理器不断向网卡芯片中读取数据,如果读不到数据,则控制器会重新启动一个读取时序;如果能够成功读取到数据,则将数据通过网卡注册的input函数交往上层进行处理.
使用查询方式实现的数据包接收进程其优先级必须低于系统中其他进程的优先级,否则它会阻塞比它优先级低的进程的运行.
上面的程序有个可以改进的地方,即在读取到的数据包为空时,接收进程调用系统函数将自己延时一段时间再启动下一个读取过程,这样可以使其不能阻止优先级更低的进程的运行,缺点是数据包的接收得不到及时的响应.
其实数据包的接收可以采用中断的方式来实现,这种方式是一种比较好的方式.
一般的网卡芯片都有中断功能,即当网卡接收到一个数据包后,它可以产生中断信号告诉控制器自己接收到一个数据包.
控制器此时启动一个读取数据包时序,就能有效的读取到非空数据包.
所以可以这样来实现一个接收数据包进程:在无数据包收到时,数据包接收进程阻塞在一个信号量下,当有数据包到来时,网卡芯片产生一个中断信号,处理器进入中断处理,并释放一个信号量.
中断退出后,数据包接收进程得到信号量,并从网卡芯片中读取数据包,并将数据包递交给上层进行处理.
第二个需要注意的地方是htons(ethhdr->type)函数的使用,htons函数的功能是将一个半字长的数据从网络字节顺序转换到我们的处理器支持的字节顺序.
解释一下,在计算机体系结构和计算机通信领域中,对于半字、字等的存储机制有可能不同.
目前通常采用的存储机制主要有两种:big-endian和little-endian,即大端和小端.
对于大端模式,某个半字或字数据的高位字节被在内存的低地址端,低位字节排放在内存的高地址端.
对于小端模式,则恰好相反.
由于我们使用的ARM处理器使用的是小端模式,而接收到的网络字节数据用的是大端模式,所以这里调用函数htons实现大端与小端的转换,实际就是将两个字节交换顺序即可.
这样调用htons(ethhdr->type)后,ethhdr->type的值就为0x0800或0x0806等.
最后需要注意的地方,netif->input在结构enc28j60初始化时已经被设置为指向tcpip_input函数,所以实际上上面是调用tcpip_input函数往上层递交数据包.
tcpip_input属于IP层函数,从这里我们可以看出LWIP的一个很大的特点,即各层之间没有明显的界限划分.
像前面所讲的那样,LWIP协议栈进程完成初始化相关工作后,会阻塞在一个邮箱上等待数据包的输入,这就对了,tcpip_input函数就是向这个邮箱发送一条消息,且该消息中包含了收到的数据包存储的地址.
LWIP协议栈进程从邮箱中取到该地址后就可以对数据包进行处理了.
至此,数据包的接收可算大功告成,关于数据包的发送,这点很简单,因为它不必像数据包接收那样要使用一个专门的进程来实现,而是这样的:当上层有数据包要发送时,直接调用netif->linkoutput发送数据包就可以了.
netif->linkoutput在结构enc28j60初始化时已经被设置为指向low_level_output函数,该函数和底层硬件驱动密切相关,用于实现发送一个数据包的功能.
用户应该结合具体网卡驱动实现该函数.
E-mail:for_rest@foxmail.
com老衲五木出品7ARP表表表表哈哈哈….
.
新的一年祝愿哥们和姐们一切都好.
去年(嗯,确实是这个词)讲过了种种的种种,包括LWIP的移植要点、内存管理、数据包管理、网络接口管理等等.
新的一年继续给力.
ARP,全称AddressResolutionProtocol,译作地址解析协议,是位于TCP/IP协议栈底层的协议.
任何网络的通信都是基于底层硬件链路的,底层的数据链路有着自己的一套寻址机制,在以太网中,往往是通过一个48位的MAC地址来标示不同的网络通信设备的.
而TCP/IP协议的上层是使用IP地址作为各个主机间通信寻址机制的.
当源主机上层要向目标主机发送数据时,它只知道目标主机的IP地址,此时,源主机需要将该IP地址转换为目的主机对应的MAC地址,这样才能在数据链路上选择正确的通道将数据传送出去,这就是ARP的作用.
哎,复杂了!
协议里面的一段描述可能更明了:在ARP背后有一个基本概念,那就是每个网络接口有一个硬件地址(一个48bit的值,标识不同的以太网或令牌环网络接口),在硬件层次上进行的数据帧交换必须有正确的硬件接口地址.
但是,TCP/IP有自己的地址:32bit的IP地址.
知道主机的IP地址并不能让内核发送一帧数据给主机.
内核(如以太网驱动程序)必须知道目的端的硬件地址才能发送数据.
ARP的功能是在32bit的IP地址和采用不同网络技术的硬件地址之间提供动态映射.
ARP协议的基本功能就是通过目标设备的IP地址,查询目标设备的MAC地址,以保证通信的进行.
ARP协议实现的核心是ARP缓存表,ARP的实质就是对缓存表的建立、更新、查询等操作.
ARP缓存表是由一个个的缓存表项(entry)组成的,LWIP中描述缓存表项的数据结构叫etharp_entry,上源代码:structetharp_entry{#ifARP_QUEUEINGstructetharp_q_entry*q;//数据包缓冲队列指针#endifstructip_addripaddr;//目标IP地址structeth_addrethaddr;//MAC地址enumetharp_statestate;//描述该entry的状态u8_tctime;//描述该entry的时间信息structnetif*netif;//相应网络接口信息};ARP_QUEUEING是编译选项,表示是否允许缓存表项有数据包缓冲队列,在opt.
h里面设置.
为什么要用数据包缓冲队列指针,随后慢慢道来.
ipaddr和ethaddr字段就是分别用于存储IP地址和MAC地址的,它们是ARP缓存表项的核心部分了.
state是个枚举类型,它表示该缓存表项的状态,一个表项有三个可能的状态,我们用枚举型etharp_state进行描述.
enumetharp_state{ETHARP_STATE_EMPTY=0,ETHARP_STATE_PENDING,ETHARP_STATE_STABLE};LWIP内核通过数组的方式来创建ARP缓存表,如下,staticstructetharp_entryarp_table[ARP_TABLE_SIZE];E-mail:for_rest@foxmail.
com老衲五木出品初始化缓存表中的各个缓存表项都处于初始状态,没有记录任何信息,此时每个表项都处于ETHARP_STATE_EMPTY状态,ETHARP_STATE_PENDING状态表示该表项处于不稳定状态,很可能的情况是,该表项只记录到了IP地址,但是还为记录到对应该IP地址的MAC地址,此时就该表项就处于ETHARP_STATE_PENDING状态.
在该状态下,LWIP内核会发出一个广播ARP请求到数据链路上,以让对应IP地址的主机回应其MAC地址,当源主机接收到MAC地址时,它就更新对应的ARP表项.
当ARP表项得到更新后,它就完全记录了一对IP地址和MAC地址,此时该表项就处于ETHARP_STATE_STABLE状态.
注意当某表项处在PENDING状态时,要发往该表项中IP地址处的数据包会被连接在表项对应的数据包缓冲队列上,当等到该表项稳定后,这些数据包才会被发送出去.
这就是为什么每个表项需要有数据包缓冲队列指针了.
ctime字段记录表项处于某个状态的时间,当某表项的ctime值大于规定的表项最大生存值时,该表项会被内核删除.
在第一讲中,我们就说到了关于LWIP的超时事件,要使用ARP功能,就必须设置一个ARP超时事件,该超时事件的基本功能就是对每个表项的ctime字段值加1,然后删除那些生存时间大于最大生存值的表项.
好了,下面讲讲能够正确建立ARP缓存的基础:ARP数据包.
要在源主机上建立关于目标主机的IP地址与MAC地址对应表项,则源主机和目的主机间的基本信息交互是必须的,简单的说就是,源主机如何告诉目的主机:我需要你的MAC地址;而目的主机如何回复:这就是我的MAC地址.
ARP数据包,这就派上用场了.
ARP数据包可以分为ARP请求数据包和ARP应答数据包,ARP数据包到达底层链路时会被加上以太网数据包头发送出去,最终呈现在链路上的数据报头格式如下图,以太网包头中的前两个字段是以太网的目的MAC地址和源MAC地址,在前面一章已经有讲解.
目的地址为全1的特殊地址是广播地址.
在ARP表项建立前,源主机只知道目的主机的IP地址,并不知道其MAC地址,所以在数据链路上,源主机只有通过广播的方式将ARP请求数据包发送出去.
电缆上的所有以太网接口都要接收广播的数据包,并检测数据包是否是发给自己的,这点通过对照目的IP地址来实现,如果是发给自己的,目的主机需要回复一个ARP应答数据包给源主机,以告诉源主机自己的MAC地址.
两个字节长的以太网帧类型表示后面数据的类型.
对于ARP请求或应答数据包来说,该字段的值为0x0806,对于IP数据包来说,该字段的值为0x0800.
以太网数据报头说完,来说ARP数据报头.
硬件类型字段表示硬件地址的类型,它的值为1即表示以太网MAC地址,长度为6个字节.
协议类型字段表示要映射的协议地址类型.
它的值为0x0800即表示要映射为IP地址.
它的值与包含IP数据报的以太网数据帧头中的类型字段的值相同.
接下来的两个1字节的字段,硬件地址长度和协议地址长度分别指出硬件地址和协议地址的长度,以字节为单位.
对于以太网上ARP请求或应答来说,它们的值分别为6和4.
操作字段op指出四种操作类型,它们是ARP请求(值为1)、ARP应答(值为2)、RARP请求(值为3)和RARP应答(值为4),这里我们只关心前两个类型.
这个字段E-mail:for_rest@foxmail.
com老衲五木出品必需的,因为ARP请求和ARP应答的帧类型字段值是相同的.
接下来的四个字段是发送端的以太网MAC地址、发送端的IP地址、目的端的以太网MAC地址和目的端的IP地址.
注意,这里有一些重复信息:在以太网的数据帧报头中和ARP请求数据帧中都有发送端的以太网MAC地址.
对于一个ARP请求来说,除目的端MAC地址外的所有其他的字段都有填充值.
当目的主机收到一份给自己的ARP请求报文后,它就把自己的硬件地址填进去,然后将该请求数据包的源主机信息和目的主机信息交换位置,并把操作字段op置为2,最后把该新构建的数据包发送回去,这就是ARP响应.
最后,用源码来看看LWIP是如何描述上面的这个数据报头的:structetharp_hdr{PACK_STRUCT_FIELD(structeth_hdrethhdr);//14字节的以太网数据报头PACK_STRUCT_FIELD(u16_thwtype);//2字节的硬件类型PACK_STRUCT_FIELD(u16_tproto);//2字节的协议类型PACK_STRUCT_FIELD(u16_t_hwlen_protolen);//两个1字节的长度字段PACK_STRUCT_FIELD(u16_topcode);//2字节的操作字段opPACK_STRUCT_FIELD(structeth_addrshwaddr);//6字节源MAC地址PACK_STRUCT_FIELD(structip_addr2sipaddr);//4字节源IP地址PACK_STRUCT_FIELD(structeth_addrdhwaddr);//6字节目的MAC地址PACK_STRUCT_FIELD(structip_addr2dipaddr);//4字节目的IP地址}PACK_STRUCT_STRUCT;不唐僧了,和前面的各个描述完全相符.
PACK_STRUCT_FIELD()是防止编译器字对齐的宏定义,讲过了的.
E-mail:for_rest@foxmail.
com老衲五木出品8ARP表表表表查询查询查询查询ARP攻击,是针对以太网地址解析协议(ARP)的一种攻击技术.
在局域网中,ARP病毒收到广播的ARP请求包,能够解析出其它节点的(IP,MAC)地址,然后病毒伪装为目的主机,告诉源主机一个假MAC地址,这样就使得源主机发送给目的主机的所有数据包都被病毒软件截取,而源主机和目的主机却浑然不知.
ARP攻击通过伪造IP地址和MAC地址实现ARP欺骗,能够在网络中产生大量的ARP通信量使网络阻塞,攻击者只要持续不断的发出伪造的ARP响应包就能更改目标主机ARP缓存中的IP-MAC条目.
ARP协议在设计时未考虑网络安全方面的特性,这就注定了其很容易遭受ARP攻击.
黑客只要在局域网内阅读送上门来的广播ARP请求数据包,就能偷听到网内所有的(IP,MAC)地址.
而源节点收到ARP响应时,它也不会质疑,这样黑客很容易冒充他人.
这一节主要针对ARP讲解ARP表的创建,更新,查询等操作.
这里我们先从几个简单的函数入手讲解ARP各个子模块功能,然后再将各个模块与上层协议结合起来,宏观的讲解ARP模块.
第一个需要迫不及待要说的函数是find_entry,该函数最重要的输入是一个IP地址,返回值是该IP地址对应的ARP缓存表项索引.
函数声明原型如下,statics8_tfind_entry(structip_addr*ipaddr,u8_tflags)这里,很有必要翻译一下源代码中注释的内容:该函数主要功能是寻找一个匹配的ARP表项或者创建一个新的ARP表项,并返回该表项的索引号.
如果参数ipaddr为给定的非空的内容,则函数需要返回一个处于pending或stable的索引表项,如果没有匹配的表项,则该函数需要返回一个empty的表项,但该表项的IP字段的值要被设置为ipaddr的值,这种情况下,find_entry函数返回后,调用者需要将表项从状态empty改为pending.
还有一种情况,如果参数ipaddr为空值,同样返回一个状态为empty的表项.
返回状态为empty的表项,首先从状态标示为empty的空闲ARP表项中选取,如果这样的表项都用完了,同时参数flags的值被设置为ETHARP_TRY_HARD,则find_entry就回收最老的ARP表项,将该表项设置为empty状态返回.
这个函数比较大,有将近200行代码,这里就不贴了,直接讲讲它的工作流程.
这部分的讨论还是参考了网上某位大侠的博客,名字记不得了,对不起啊啊啊啊啊!
网络,有时确实是个好东西,越发的明白.
好了,看看find_entry的工作流程.
首先,lwip有一个比较巧妙的地方,它并不是冲上去就是就把arp缓存中所有的表项搜索一遍,而是做了一个假设,假设这次的表项索引还是上一次的(在很多情况下就是这样的).
所以,LWIP中有个全局的变量etharp_cached_entry,它始终保存着上次用到的索引号,如果这个索引恰好就是我们要找的内容,且索引的表项已经处于stable状态,那就直接返回这个索引号就完成了,we'rereallyfast!
如果情况不够理想,就必须去检索整个ARP表了,检索的过程是从ARP表的第一个表项开始,依次往后检索直至最后一个表项,过程较复杂.
对于每个表项首先判断它是否为empty状态,find_entry只关心第一个状态为empty的表项索引值,对该索引值以后的empty表项不感兴趣,忽略.
如果一个表项不是empty状态,则判断它是不是pending状态.
对于pending状态的表项,需要做以下的事情,先看看它里面存的IP地址和我们的ipaddr是否匹配,如果匹配,好返回该索引值,记住还要更新etharp_cached_entry为该索引值,如果不匹配,则判断该索引的数据包指针是否为空,find_entry试图记录生存时间最长的pending状E-mail:for_rest@foxmail.
com老衲五木出品态有数据缓冲或无数据缓冲的表项索引.
如果一个表项也不是pending状态,则判断它是不是stable状态.
对于stable状态的表项,与pending状态的表项处理过程相似,find_entry试图记录生存时间最长的stable表项的索引.
很晕吧,我也很晕,看了下面这段可能你会好点!
如果到这里都还没有找到匹配的表项,那就很杯具了,我们需要为find_entry调用者返回一个empty的表项索引.
经过上面一段后,find_entry已经知道了第一个empty状态表项的索引、生存时间最老的pending状态且有数据缓冲表项的索引、生存时间最老的pending状态且无数据缓冲表项的索引、生存时间最老的stable状态表项的索引,我们暂且先将这四个值假设为a、b、c、d.
如果参数flags的值被设置为ETHARP_TRY_HARD,那么find_entry会按照a-->d-->c-->b的顺序选择一个合适的索引返回,为什么是这样的顺序很明显,不解释.
find_entry首先判断a是否在ARP表项范围内,如果是,则选择a,如果不是,则判断b是否在ARP表项范围内,依此类推.
当选中一个索引后,随即就会将该索引对应的表项设置为empty状态,并且将该表项的IP地址设置为ipaddr的值,ctime值设置为0,最后返回索引.
至此,find_entry大功告成!
接下来,我很感兴趣的一个函数是etharp_query,该函数的功能是向给定的IP地址发送一个数据包或者发送一个ARP请求,当然情况远不如此简单.
还是很有必要翻译一下源代码中函数功能注释的内容:如果给定的IP地址不在ARP表中,则一个新的ARP表项会被创建,此时该表项处于pending状态,同时一个关于该IP地址的ARP请求包会被广播出去,再同时要发送的数据包会被挂接在该表项的数据缓冲指针上;如果IP地址在ARP表中有相应的表项存在,但该表项处于pending状态,则操作与前者相同,即发送一个ARP请求和挂接数据包;如果IP地址在ARP表中有相应的表项存在,且表项处于stable状态,此时再来判断给定的数据包是否为空,不为空则直接将该数据包发送出去,为空则向该IP地址发送一个ARP请求.
etharp_query函数原型如下所示,源代码在150行左右,这里主要讲解其流程:err_tetharp_query(structnetif*netif,structip_addr*ipaddr,structpbuf*q)(1)首先判断给定的ipaddr是否合法,对于空IP地址、广播IP地址、多播IP地址不予处理.
(2)将ipaddr作为参数调用函数find_entry,函数返回一个ARP表项索引,该表项可能是原来已经有的,此时该表项应该是pending或stable状态;该表项也可能是新申请得到的,此时该表项应该是empty状态.
(3)根据返回的表项索引找到该ARP表项,判断该表项是否为empty状态,如果是,说明该表项是新申请的,则将该表项状态设置为pending状态.
(4)判断要发送的数据包是否为空,或者判断ARP表项是否为pending状态,这两个条件只要有一个成立,就发送一个ARP请求出去,发送ARP请求的函数是etharp_request.
(5)如果待发送的数据包不为空,此刻就根据ARP表项的状态作不同的处理:若ARP表项处于stable状态,则直接调用函数etharp_send_ip发送数据包;若ARP表项处于pending状态,则需要将该数据包挂接到表项的待发送数据链表上,由于pending状态的表项必然在第(4)步中发出了一个ARP请求,当内核接收到ARP回应时,会将表项设置为stable状态,并将其链表上的数据全部发送出去,当然这项工作具体是怎样完成的那是后话了.
将数据包挂接在表项的发送链表上,这又是一个较复杂的过程:最重要的一点是判断该数据包pbuf的类型,对于PBUF_REF、PBUF_POOL、PBUF_RAM型的数据包不能直接挂在发送链表上,因为这些数据包在被挂接后并不会被立刻发送出去,这可能导致数据包在等待发送的过程中内部数据被改动.
对于以上这些类型的待发送数据包,需要将数据拷贝至新的pbuf中,然后将新的pbuf挂接至发送链表.
至此,etharp_query函数功德圆满!
时间总是在不经意间走完.
.
.
我却没写完.
E-mail:for_rest@foxmail.
com老衲五木出品9ARP层流程层流程层流程层流程前面一节重点说了ARP缓存表以及如何对其进行相关操作,关于ARP,一共想说三个函数,前面已经讲过了两个.
最后要讲的一个函数是update_arp_entry,该函数用于更新ARP缓存表中的表项或者在缓存表中插入一个新的表项.
该函数会在收到一个IP数据包或ARP数据包后被调用.
该函数原型如下,staticerr_tupdate_arp_entry(structnetif*netif,structip_addr*ipaddr,structeth_addr*ethaddr,u8_tflags)其中重要的两个参数ipaddr和ethaddr分别对应的ip地址和mac地址,函数利用这两个地址去更新或插入ARP表项.
由于这个函数代码量较小,这里就列出源码来讲解,注意这个源码是经过我处理的,已经去掉了编译选项、源码注释、调试输出信息等非重点部分.
staticerr_tupdate_arp_entry(structnetif*netif,structip_addr*ipaddr,structeth_addr*ethaddr,u8_tflags){s8_ti;//两个变量,不解释u8_tk;i=find_entry(ipaddr,flags);//查找或新建一个ARP表项,返回其索引值if(i0){k--;arp_table[i].
ethaddr.
addr[k]=ethaddr->addr[k];}arp_table[i].
ctime=0;//生存时间值置0#ifARP_QUEUEING//该ARP表项上有未发送的队列,则把这些队列发送出去while(arp_table[i].
q!
=NULL){//只要缓冲链表中海有数据则循环structpbuf*p;structetharp_q_entry*q=arp_table[i].
q;//记录下缓冲链表表头arp_table[i].
q=q->next;//缓冲链表表头指向下一个节点p=q->p;//取得记录下的缓冲链表表头指向的数据包memp_free(MEMP_ARP_QUEUE,q);//释放记录下的缓冲链表表头etharp_send_ip(netif,p,(structeth_addr*)(netif->hwaddr),ethaddr);//发送数据包pbuf_free(p);//释放数据包缓存空间}#endifreturnERR_OK;}从源程序中可以看出,update_arp_entry的流程如下:先通过调用find_entry找到对应ipaddr对应的表项,并设置相应的arp表项的成员(主要是state,netif,ethaddr,cttime),E-mail:for_rest@foxmail.
com老衲五木出品最后如果定义了ARP_QUEUEING,并且这个arp表项上有未发送的数据包的话,则把这些数据全部发送出去.
虽然比较啰嗦,但是还是我们还是根据不同的ipaddr经过find_entry执行后,来看看update_arp_entry运行的几种不同情况.
首先可以肯定的是,update_arp_entry的两个参数ipaddr和ethaddr必是互相匹配的,因为它们是从源主机发来的IP包或ARP包中解析出来的,代表了源主机的MAC地址和IP地址.
find_entry利用ipaddr作为参数执行后,返回一个ARP表项索引.
如果该表项处于empty状态,那么该表项现在一定是新创建的,此时设置该表项为stable状态并设置该表项其他字段值后即结束.
如果该表项是处于pending状态,由于此时已经有了和ipaddr匹配的MAC地址返回,所以该表项也被设置为stable状态并同时设置该表项其他字段值.
如果该表项是处于stable状态,其实此时只需要将ctime的值复位即可,但是LWIP为了节省代码量,它还是选择像上面的情况一样做相同的处理,这样虽然有些步骤是多余的,但并不影响函数功能.
最后都会检查该表项是否还有数据需要发送,如果有,则将所有数据包发送出去.
现在是时候从宏观上来看看到底ARP是怎么一个工作流程,以及它在整个LWIP协议栈当中发挥的重要作用.
关于这点,不得不借鉴网上某位大侠的了,不过对TA的图做了一些小小的修改(脸红,用画图工具改的,Visio表示不会用).
该图简洁明了的解释了基本所有LWIP的数据包接收与发送的全过程.
我们可以看到几个熟悉的身影:etharp_query、etharp_request、update_arp_entry.
在前面已经讲过了的!
ARP从功能上来说可以简单的分成两个部分:当有数据包输入时,更新arp表,如果是ip包则递交给ip层,如果是arp包,则针对不同的arp包类型做相应的响应;当向目的ip发送一个数据包的时候,需要通过arp实现ip到MAC地址的映射,必要时,需要发送广播数据包获得目标机器的MAC地址.
LWIP利用netif.
input指向的函数接收以太网数据包,通常这个函数是ethernet_input.
注意,这里并不是说ethernet_input直接与底层硬件交互接收数据包,而是更底层的函数接收到数据包后将数据包递交给ethernet_input,ethernet_input再对其进行处理.
以太网的帧类型可以是:IP,ARP,甚至可以是pppoe,wlan等.
这里主要分析IP和E-mail:for_rest@foxmail.
com老衲五木出品ARP两种类型的数据包.
ethernet_input根据以太网首部的类型字段判断收到的数据包的类型,如果是IP包,则将该包递交给etharp_ip_input,如果是ARP包,则将该包递交给etharp_arp_input.
对于ip类型的数据包,etharp_ip_input首先检查是否开启了ETHARP_TRUST_IP_MAC这个选项,如果开启了就是要用这个帧中的信息和update_arp_entry函数来更新arp表(利用帧首部的源mac地址和帧数据中ip报文中的源ip地址),然后丢弃以太网帧首部,将IP报文通过ip_input函数递交给ip层.
对于arp类型的数据包,etharp_arp_input函数首先利用数据包头信息更新arp表的内容,然后再判断该ARP数据包的类型,如果是ARP请求包,则首先判断这个包是不是给自己的,如果是给自己的,则在原有包的基础上重组一个ARP应答包发送出去(注意此处并没有重新分配一个pbuf,而是借用了原来的缓冲结构).
如果不是给自己的,则直接忽略.
如果是ARP应答包,主要的工作就是更新arp表,但是这一步已经在arp包刚进来的时候就处理了,所以这里不需要再重复做,这样ARP包的处理也完毕.
LWIP利用netif.
output指向的函数发送IP数据包,通常这个函数是etharp_output.
注意,这里并不是说etharp_output直接与底层硬件交互发送数据包,而是将数据包做相应的处理,主要是将IP数据包打包成以太网帧数据,最终递交给netif.
linkoutput函数来发送的.
etharp_output函数接收IP层要发送的数据包,并将数据包发送出去.
由于是发送ip数据包,所以函数一开始需要增加缓冲区大小,大小为以太网的数据首部的大小.
然后检查ip地址,可以分为广播包,多播包,单播包(单播包又分为是局域网内部还是局域网外面).
广播包:判断目的IP地址是不是为全1,或者是全0(老版本中使用的),如果是广播包则目的IP的MAC地址不需要查询arp表,直接将MAC地址设置为全1发送即可,即MAC六个字节值为0xff,0xff,0xff,0xff,0xff,0xff.
多播包:判断目的ip地址是不是d类地址,即0xexxxxxxx,如果是多播的话,mac地址也是确定的,即将MAC地址01-00-5e-00-00-00的低23位设置为IP地址的低23位.
对于以上的两种数据包,etharp_output直接调用函数etharp_send_ip将数据包发送出去.
单播包:要比较目的IP和本地IP地址,看是否是局域网内的,不是局域网内的,则将目的IP地址设置为默认网关的地址,然后再统一调用etharp_query函数将数据包发送出去,注意这些数据包在这种情况下可能被连接在相关ARP表项的发送链表上,等待发送.
E-mail:for_rest@foxmail.
com老衲五木出品10IP层层层层输入输入输入输入对于IP层主要讨论信息包的接收、分片数据包重装、信息包的发送和转发三个内容.
IP数据报头结构如下所示,其中,选项字段是可以没有的,所以通常的IP数据报头长度为20个字节.
第一个字段是4bit的版本号,对于IPv4,该值为4;对于IPv6,该值为6.
接下来的4bit字段用于记录首部长度,以字为单位.
所以对于不含任何选项字段的IP报头,则该长度值为5,由于该字段最大值为15,所以其能描述的最大IP报头长度为15*4=60字节.
再下来是一个8bit的服务类型字段,该字段主要用于描述该IP数据包急需的服务类型,如最小延时、最大吞吐量、最高可靠性、最小费用等.
这个字段在LWIP中没啥用处.
16位的总长度字段描述了整个IP数据报,包括IP数据报头的总字节数.
理论上说,IP数据包总长度最大可达65535字节,但在实际应用中,底层链路可不允许这么大的数据包出现在链路上,因为这会大大增加数据出错的可能性,所以在链路层往往会对大的IP数据包进行分片,当然这些都是后话.
接下来的16位标识字段用于标识IP层发送出去的每一份IP数据报,每发送一份报文,则该值加1.
然后的3位标志和13位片偏移字段用于在IP数据包分片时使用,这里先不讨论.
LWIP的较高版本才支持IP分片功能.
TTL字段描述该IP数据包最多能被转发的次数,每经过一次转发,该值会减1,当该值为0时,一个ICMP报文会被返回至源主机.
8位协议字段用来描述该IP数据包是来自于上层的哪个协议,该值为1表示为ICMP协议,该值为2表示IGMP协议,该值为6表示TCP协议,该值为17表UDP协议.
16位首部校验和只针对IP首部做校验,它并不关心其内部数据在传输过程中出错与否,E-mail:for_rest@foxmail.
com老衲五木出品对于数据的校验是上层协议负责的,如ICMP、IGMP、TCP、UDP协议都会计算它们头部以及整个数据区的长度.
这里再COPY一段这个校验和是怎样生成以及在接收端是如何实验校验的.
在发送端为了计算一份数据报的IP检验和,首先把检验和字段置为0.
然后,对首部中每个16bit进行二进制反码求和(整个首部看成是由一串16bit的字组成),结果存在检验和字段中.
当接收端收到一份IP数据报后,同样对首部中每个16bit进行二进制反码的求和.
由于接收方在计算过程中包含了发送方保存在首部中的检验和字段,因此,如果首部在传输过程中没有发生任何差错,那么接收方计算的结果应该为全1.
如果结果不是全1(即检验和错误),那么IP就丢弃收到的数据报.
但是不生成差错报文,由上层去发现丢失的数据报并进行重传.
接下来是两个32位的IP地址,不啰嗦了.
最后一个字段是任选字段,不同的协议会选择性的使用该字段,这里也不讨论.
现在来看看LWIP中是怎么样来描述这个IP数据报头的,使用的结构体叫ip_hdr:structip_hdr{PACK_STRUCT_FIELD(u16_t_v_hl_tos);//前三个字段:版本号、首部长度、服务类型PACK_STRUCT_FIELD(u16_t_len);//总长度PACK_STRUCT_FIELD(u16_t_id);//标识字段PACK_STRUCT_FIELD(u16_t_offset);//3位标志和13位片偏移字段#defineIP_RF0x8000//#defineIP_DF0x4000//不分组标识位掩码#defineIP_MF0x2000//后续有分组到来标识位掩码#defineIP_OFFMASK0x1fff//获取13位片偏移字段的掩码PACK_STRUCT_FIELD(u16_t_ttl_proto);//TTL字段和协议字段PACK_STRUCT_FIELD(u16_t_chksum);//首部校验和字段PACK_STRUCT_FIELD(structip_addrsrc);//源IP地址PACK_STRUCT_FIELD(structip_addrdest);//目的IP地址}PACK_STRUCT_STRUCT;注意结构体声明的时候定义了几个宏定义:IP_RF、IP_DF、IP_MF、IP_OFFMASK,它们是在求与分组相关两个字段时要用到的掩码,也可以在结构体的外面进行定义,无影响.
前面讲过,从以太网底层进来的数据包经过ethernet_input函数分发给IP模块或者ARP模块,分发给IP模块是通过调用ip_input函数完成的,当然在递交前,ethernet_input需要将数据包去掉以太网头.
现在来看看数据包传递给ip_input后,该函数进行了哪些方面的工作.
这里我们先不涉及其内部关于DHCP协议的相关处理.
第一件事是检查IP头部的版本号,如果该值不为4,则立即丢弃该数据包.
更高版本的LWIP协议栈可以支持IPv6,但这里我们只讨论IPv4.
接下来函数检查IP数据报头是否只保存于一个pbuf中,如果不是,也直接丢弃该IP包,这是因为LWIP不允许IP数据包头被分装在不同的pbuf里面.
同时,函数检查IP报头中的总长度字段是否大于递交上来的数据包总长度,如果是,则说明存在传输错误,直接丢弃数据包.
然后是对IP数据报头做校验,该工作是函数inet_chksum完成的,如果校验不通过则直接丢弃数据包.
inet_chksum函数在后续有需要时会详细讲解.
接着,需要在这里对数据包进行截断操作,按照IP包头记录的总长度字段截取数据包,因为经过ethernet_input传递上来的数据包只被去除了以太网数据包头部,而对于可能存在的以太网填充字段和一定存在的以太网校验字段(最后一字节)没做处理,我们在这里对它们进行截断,得到完整无冗余的IP数据包.
E-mail:for_rest@foxmail.
com老衲五木出品然后,函数检测IP数据包中的目的IP地址是否与本机的相符,本机的IP地址是保存在netif结构体变量中的,一个系统可能有着多个网卡设备,这就意味着它有多个netif结构体变量分别用于描述这些网卡设备,也意味着本机有着多个IP地址,这些netif结构体是被连接在netif_list链表上的.
ip_input函数会遍历netif_list链表上的netif结构以找到匹配的IP地址,并记录该netif结构体变量,也即记录该网卡.
从这点看来,在ARP部分内容中,对于某个接收到的ARP请求包,也应该按照这种方式进行遍历后再给出ARP回应更好,而源代码并没有这样做,当然,这只是个人意见.
当遍历完成后,如果依旧没有得到与匹配的netif结构体变量,这说明该数据包不是给本机的,此时需要对数据包进行转发或者丢弃工作,这是通过宏定义IP_FORWARD来完成的,这里注意不要对广播数据包进行转发.
再接下来,根据目标IP地址判断数据包是否为广播或多播IP数据包,LWIP不对这些类型的数据包进行响应.
再接下来的工作可以说是ip_input函数中最复杂最难理解的部分,这就是IP分片数据包的重装,ip_input函数通过数据包的3位标志和13位片偏移字段判断发给自己的该IP包是不是分片包,如果是,则需要将该分片包暂存,等到接收完所有分片包后,统一将整个数据包递交给上层应用程序.
这是万言难尽的过程,先在这里打住,我们在以后的内容里面细细讨论.
如果是分片包,且不是最后一片,则函数到这里就返回了.
终于,能到达这一步的数据包必然是未分片的或经过分片完整重装后的数据包.
此时,ip_input函数根据IP数据包头内部的协议字段判断该数据包应该被递交给哪个上层协议,并调用相应的函数递交数据包.
是UDP协议,则调用udp_input函数;是TCP协议,则调用tcp_input函数;是ICMP协议,则调用icmp_input函数;是IGMP协议,则调用igmp_input函数;如果都不是,则调用函数icmp_dest_unreach返回一个协议不可达ICMP数据包给源主机,同时删除数据包.
写完收工!
E-mail:for_rest@foxmail.
com老衲五木出品11IP分片重装分片重装分片重装分片重装1较低版本的LWIP协议并不支数据包持数据包的分片与重装功能,较高版本的LWIP协议,关于分片数据包的重装,采用了与标准协议中差别较大的方式来实现.
它采用了一种更为简单的数据结构来组装分片包,但是这样导致的结果是组装过程源代码晦涩难懂,代码执行效率低.
个人认为LWIP的这种实现机制不是太好,有时间的话想自己将整个数据包分片重装机制全部重新实现,指定更好.
需要注意的是,在一般的嵌入式产品中,数据量是比较小的,基本不会出现数据分片的情况,而且嵌入式产品也不会在网络中充当路由器,实现数据包的重组、转发等功能.
因此,较低版本的LWIP依然能在大多数应用中发挥其功能.
首先,我们来看看标准协议中例举的BSD是怎么来实现数据包的重组的,再讲LWIP的重装机制与该机制对比讲解.
标准中主要采用了两个数据结构来实现数据包的重装,ipq和ipasfrag.
其中ipq是为某个将要重组的数据包建立的节点信息数据结构,包括目标IP地址,数据包编号等信息,该结构还有指针ipf_next、ipf_prev使得该数据包的各个分片数据组成一个双向链表,图中ipasfrag用于包装各个分片信息包.
同时,将所有ipq结构体够成一个双向链表,便于某个ipq结构的查找、插入和删除操作.
整个ipq构成的链表有一个固定的链表头,该表头不存E-mail:for_rest@foxmail.
com老衲五木出品储任何数据包的数据,只做标识用.
在LWIP中,结构体ip_reassdata与上面的ipq起着类似的作用,但结构成员大不相同,ip_reassdata被用来构成的是单向链表,以实现对某个ip_reassdata结构的查找删除等操作.
所有ip_reassdata结构构成的单向链表有一个头节点指针,即reassdatagrams,用于指向链表头.
同时LWIP中没有类似ipasfrag的结构对收到的各个分片数据包进行封装,而是直接将分片数据包挂接在对应的ip_reassdata结构之后.
至于怎样挂接,那是后话.
现在来看看传说中的ip_reassdata结构,源代码:structip_reassdata{structip_reassdata*next;//用于构建单向链表的指针structpbuf*p;//该数据报的数据链表structip_hdriphdr;//该数据报的IP报头u16_tdatagram_len;//已经收到的数据报长度u8_tflags;//是否收到最后一个分片包u8_ttimer;//设置超时间隔};ip_reassdata主要用于描述一份正在被组装的数据报,完成数据包组装的函数叫做ip_reass,该函数以IP分片数据包为输入参数,输出组装好的数据包指针或空指针,这里我们来看看它的操作流程.
首先,判断该IP数据包的头部大小,目前LWIP不支持有IP选项的IP数据包,所以IP数据包头部大小只能为20个字节,如果大于该值,ip_reass直接丢弃该数据包后返回.
然后,由于LWIP对所有ip_reassdata结构连接的pbufs个数总和有个上限限定,即IP_REASS_MAX_PBUFS,所以ip_reass检查当前数据包分片占用的pbufs个数,假设如果这么多个pbufs被连接在某个ip_reassdata后,所使用了的pbufs个数是否大于了我们刚才提的上限值,如果是大于,LWIP是不会允许这样的情况下将pbufs被连接入ip_reassdata的.
此时用户可以有两种选择,一是直接删除数据包后返回,二是删除ip_reassdata链表中生存时间最长的ip_reassdata结构体及其相关pbufs,直到有了足够的pbufs个数能使用.
这种选择是通过宏定义IP_REASS_FREE_OLDEST来实现的.
哎,这段说得太凌乱了!
为什么要定义一个最大允许pbufs使用个数呢,不懂!
到这步就可以进行分片数据报的插入操作了,首先要做的就是在链表reassdatagrams中找到对应的ip_reassdata结构体,此时又有两种情况:没有找到匹配的结构体,即该分片是第一个到来的分片,此时需要创建一个新的ip_reassdata结构体并插入链表reassdatagrams中;找到匹配的结构体,即在该分片到来之前已经有分片到来,此时要判断该分片是不是整个数据包的第一个分片,如果是,则用对应ip_reassdata结构体的iphdr字段记录该IP数据包头部.
到这里,必然找到了分片数据包对应的ip_reassdata了,接着我们就判断该分片是不是整个数据包的最后一个分片包,因为最后一个分片数据包的IP_MF位为0.
如果是最后一个分片包,则设置对应ip_reassdata的flag标志和datagram_len值,表示收到最后一个分片和设置整个数据包的长度值.
PS:第一个到来的分片包不一定是第一个分片包,最后一个到来的数据包也不一定是最后一个分片包.
最后调用函数ip_reass_chain_frag_into_datagram_and_validate对分片数据包进行插入操作,这是我们后续会重点将的一个函数,先在这里打住.
上面这个函数还会检测某个数据包是否被组装完毕,如果某个数据包被组装完毕,那ip_reass还要做以下工作:根据ip_reassdata结构找到第一个分片数据包,将ip_reassdata结构中的IP报头字段iphdr拷贝至第一个分片数据包头部(第一个分片数据包头部头部数据已经被覆盖,在讨论函数E-mail:for_rest@foxmail.
com老衲五木出品ip_reass_chain_frag_into_datagram_and_validate时我们会讲到),并重新计算校验和.
同时将第一个分片以后的各个分片数据包去掉头部信息,这个整个数据包就恢复出来了,然后将ip_reassdata结构从链表中删除,将重组后的数据包指针作为返回参数返回.
E-mail:for_rest@foxmail.
com老衲五木出品12IP分片重装分片重装分片重装分片重装2上一节还遗留有一个问题:IP分片包是怎么被插入相应的组装链表的.
这里用一个直观的图形来解释这一过程.
nextpiphdrlenflagstimernextpiphdrlenflagstimerNULLreassdatagramsip_reassdataip_reassdata.
.
.
.
payload.
.
.
.
next_pbufstartendPbufIPIP8IP.
.
.
.
payload.
.
.
.
next_pbufstartendPbufIPIP8IPNULL.
.
.
.
payload.
.
.
.
next_pbufstartendPbufIPIP8IPNULL图中展示了有两个数据包正在被组装的过程,两个ip_reassdata结构分别用于两个数据包的组装过程.
第一个数据包已经组装好了两个分片数据,第二个组装好了一个分片数据.
LWIP并不像标准协议中描述的那样,另外分配一个数据结构来描述各个分片数据,而是直接利用各个分片数据,将数据中IP头部的前八个字节拿来存储组装过程中的相关信息,因此每个进来的IP分片数据包头都被改变了,也因此要使用ip_reassdata结构中的iphdr字段来暂时保存IP数据包的完整头部.
这八个字节被变成了什么值呢,这就是结构体ip_reass_helper的内容了.
这个结构体就是组装过程中最为重要的结构体了.
看看,这恐怕是最简单的一个结构体了.
structip_reass_helper{structpbuf*next_pbuf;u16_tstart;u16_tend;};next_pbuf字段在组装过程中用来连接各个分片数据包;start字段用来记录该分片数据包数据在整个IP包中的起始位置;同理,end字段表示在IP包中的结束位置.
函数ip_reass_chain_frag_into_datagram_and_validate(PS:神,这个函数名太长了)用来向一个ip_reassdata结构中插入一个IP分片包pbuf,插入后检查该IP包是否被组装完毕,未组装完毕则返回0,否则返回非0值.
很明显,这个函数的输入参数有两个,ip_reassdataE-mail:for_rest@foxmail.
com老衲五木出品结构和分片包pbuf.
下面仔细看看这个函数名最长的函数到底干了些什么工作.
首先它会从该IP分片包的头部中提取信息,包括分片数据的起始偏移地址offset和分片数据的长度len,之后它将该IP分片包的头部得前八个字节强制转换为ip_reass_helper结构,并将start字段的值置为offset;end字段的值置为offset+len;next_pbuf字段的值置为NULL.
到这里,进来的这个分片包已经被改变面貌了,它的IP头部已经被毁坏,下面就会将这个改头换面的分片包进行插入操作了.
插入操作主要是利用比较各个ip_reass_helper的start和end字段的值以确定这个分片包被插在链表中的哪个位置,插入位置的寻找是整个组装过程中最难理解的部分了,但从原理上看是很简单的一个过程,这里不再对查找插入位置及插入操作的源码做解析.
到这步,分片的数据包已经被插入到相应的ip_reassdata结构后的链表中了,此时这个函数名最长的函数要检查是否这个ip_reassdata对应的数据包已经组装完毕.
这里要分两步来看,第一是判断该数据包的最后一个分片包是否已经到来,在上面一节中已经讲过,当收到的IP分片包为某个IP包的最后一个分片时,函数ip_reass会把对应ip_reassdata结构的flags字段置1,所以,如果检测到某个ip_reassdata结构的flags字段仍为0,说明最后一个分片还未被收到,IP数据包组装肯定还未完成,此时,函数名最长的函数直接返回0即可.
第二步,如果发现flags字段置1,说明最后一个分片包已经收到,但是整个IP是否被组装完毕还是未知,因为在网络上,分片包不是每次都能按次序到达,因此,收到的最后一个分片数据包不一定是最后一个分片包.
此时需要遍历ip_reassdata结构后面的各个分片包链表,以检测是否还有分片包未被接收到.
到这步,ip_reass_chain_frag_into_datagram_and_validate函数就完成工作了,它将分片的数据插入了某个ip_reassdata结构的数据分片链表,并检测该IP数据包是否被组装完毕,组装完毕则返回一个非0值,否则返回0值.
这里,我们有回到了ip_reass函数,ip_reass通过函数调用将某个分片数据包插入相应的个ip_reassdata结构后,通过函数的返回值来决定要做的下一步操作.
如果数据包组装未完成,ip_reass函数需要向调用它的函数返回一个空指针,如果数据包组装完成,它就要向调用它的函数返回这个组装好的数据包.
从上面的图中可以看出,被组装好的数据包是全部分片被挂接在一个ip_reassdata结构上的,ip_reass函数需要从这个ip_reassdata结构上取下相应的各个分片数据,并删除各个分片中的不必要信息,然后将整个数据包返回给调用者.
为了完成这个任务,ip_reass函数进行了下面的工作.
先将ip_reassdata结构中的iphdr字段各个值做相应的修正,如修正报文总长度、校验和,然后将iphdr字段全部拷贝到第一个分片包的IP头部字段中,这样整个IP数据包的头部就出现在第一个分片信息包中了,接下来从第二个分片包开始,将它们的IP头部信息删除,这样,一个完整的IP数据包就重新组装好了.
最后,调用ip_reass_dequeue_datagram函数删除数据包组装过程中使用的ip_reassdata结构体,即从上图所述的reassdatagrams链表删除对应reassdata的节点.
好了,功德圆满!
这个圈子绕得有点大,本来是在讲ip_input函数的,结果就讲到了ip_reass函数,后来又讲到了ip_reass_chain_frag_into_datagram_and_validate函数.
没办法,谁让后面两个函数是为ip_input函数服务的呢!
ip_input函数使用ip_reass组装好的数据包递交到上层TCP或UDP等应用中去,在前面已经讲过了.
现在我们还是要回到原路上,走上ip_input这条道路.
ip_input的基本流程已经在前面讲得很清楚了,还有一个函数为它服务,即ip_forward函数,它主要是完成数据包的转发工作,当设备接收到的数据包不是给自己的时候,它就可以选择将该数据包转发出去,本来这里没有必要讲ip_forward函数的,因为在一般的应用中,这项功能会被禁止,设备收到不是给自己的数据包时,将在ip_input函数处理的初期被丢弃.
但到目前,我们还未涉及到任何E-mail:for_rest@foxmail.
com老衲五木出品关于IP数据包发送的内容,考虑了很久,还是觉得应该把ip_forward函数讲解一下,因为数据包的转发与数据包的发送是完全一样的原理,使用了完全相同的接口函数,因此讲解了ip_forward函数就等于讲解了IP层数据包发送的所有工作细节.
在TCP层或UDP层必然涉及到数据包的发送工作,在这里就利用ip_forward函数将IP层数据包发送的整个过程讲解清楚,这样逻辑清楚,利于理解!
当收到一个IP数据包后,LWIP会遍历所有网络接口的IP地址,判断这个数据包是不是给自己的,如果不是,就要调用收到该数据包的那个网络接口将数据包转发出去.
但是不慌,转发前还要检测这个包是不是一个广播包,如果是,直接丢弃,不做处理.
现在来看看数据包转发函数ip_forward做了哪些工作呢,这个函数的输入参数有三个:要转发的数据包指针,要转发的数据包的IP报头指针,收到该数据包的的网络接口数据结构netif指针.
首先,调用ip_route函数找到转发该数据包应该使用的网络接口,ip_route函数以数据包IP报头中的目标地址为参数,查找应该使用的相关结构.
如果找不到满足要求的接口,则选择缺省网络接口.
ip_route函数现在这里打住,在讲完ip_forward函数之后,再对它进行详细的讲解.
ip_forward检查ip_route函数找到的网络接口是否为有效,所谓有效,即不能为空,也不能为接收到该IP包的那个接口.
当判定网络接口为无效时,数据包不会被转发.
当可以用某个网络接口转发数据包时,ip_forward先将该IP报头中TTL字段值减1,若TTL变为0,则需要向源主机发送一份超时ICMP信息,表示当前数据包的生存周期到了,这个数据包在这里被丢弃,不会被转发出去.
至于怎样发送这个超时的ICMP信息包,这就涉及到IP层数据包的发送函数ip_output了,我们将在后面慢慢道来.
接下来函数重新计算头部校验和,因为头部TTL字段的值已经被修改,最后调用netif结构注册的output函数,该函数将数据包组装成以太网数据帧并发送出去.
前面说过了,这个函数就是etharp_output.
到这里ip_forward函数的工作就完成了,还剩下两个问题,ip_route函数和怎样发送一个超时的ICMP信息包出去.
这里讲解第一个问题,第二个问题放在ICMP部分.
ip_route函数以目标IP地址为输入参数,然后在网络接口结构链表netif_list上找寻与该IP地址在同一子网上的网络接口,若找到则返回满足要求的网络接口,若找不到则返回缺省网络接口.
如此的简单,不多说.
E-mail:for_rest@foxmail.
com老衲五木出品13ICMP处理处理处理处理目前,IP层的东西基本讲解完,数据包的发送或分片发送没有具体涉及到.
数据包的发送,与上层协议密切相关,即传输层,后面的内容就是讨论传输层的东西了.
这里先讲解传输层协议中比较简单的ICMP协议.
ICMP(InternetControlMessageProtocol)是Internet控制报文协议,用于在IP主机、路由器之间传递控制消息.
控制消息是指网络通不通、主机是否可达、路由是否可用等网络本身的消息.
这些控制消息虽然并不传输用户数据,但是对于用户数据的传递起着重要的作用.
在以前讲解IP层ip_input函数时,已经三次涉及到了ICMP的东西,第一次在数据包转发过程中,需要将数据包的TTL值减1,若此时TTL值变为0则用icmp_time_exceeded函数向源主机返回一份超时ICMP信息;还有两次是ip_input函数通过IP报文头部的协议字段值判断该数据包是交给哪个上层协议的,若是ICMP协议,则调用icmp_input函数;若没有一个协议能接受这个数据包,则调用icmp_dest_unreach函数向源主机返回一个协议不可达ICMP差错控制包.
这里先讲解icmp_time_exceeded和icmp_dest_unreach函数是怎样发送ICMP信息包的.
先来看看ICMP报文的格式,所有ICMP报文的前四个字节都是一样的,分别为1字节的类型字段,1字节的代码字段和2字节的校验和字段.
校验和字段的计算覆盖整个ICMP报文.
类型字段和代码字段唯一确定了该ICMP报文属于那种类型:如回显、超时、时间戮请求等等,尽管各种类型的ICMP报文结构通常是不相同的,但它们开始的四个字节是相同的.
ICMP报文从大的方面来说可以分为ICMP查询报文和ICMP差错报文.
ICMP查询报文包括ICMP回显应答、回显请求、时间戮请求、地址掩码请求等等类型,LWIP只实现了ICMP回显应答;ICMP差错报文有目的不可达、超时、重定向等等类型,LWIP只实现了目的不可达、超时两项ICMP处理功能.
这里先讲解目的不可达和超时两种类型的ICMP处理.
目的不可达和超时两种ICMP报文均属于ICMP差错报文,协议中规定,ICMP差错报文应始终包含产生ICMP差错报文的IP数据报的IP首部和数据前8个字节.
所以,目的不可达和超时这两种ICMP报文均有下面的报文结构,这个结构与前面所述完全相符,不解释了.
先讲与icmp_dest_unreach函数所描述的目的不可达差错报文.
该报文的类型字段值应为3,代码字段值应为(0~15),目前LWIP只支持(0~5),分别表示网络不可达、主机不可达、协议不可达、端口不可达、需进行分片但设置了不分片位、源站选择失败.
很明显,在ip_input函数找不到将该数据包交到哪个上层协议时,应该产生一个协议不可达的差错报E-mail:for_rest@foxmail.
com老衲五木出品文,即代码字段值为2.
函数原型如下,输入参数为接收到的目标不可达的IP数据包和应该用于填充ICMP头部代码字段的值.
voidicmp_dest_unreach(structpbuf*p,enumicmp_dur_typet)现在来看看这个函数做了哪些工作,首先为要发送数去的ICMP数据包申请一个pbuf缓冲区,这个缓冲区的长度为上图所述结构的长度与一个IP数据报头大小之和,之所以要多申请IP数据报头大小的空间是为了当该ICMP数据包被递交给IP层发送时,IP层不需要再去申请一个数据报头来封装该ICMP数据包,而是直接在已经申请好的报头中填入IP头部数据,注意这里申请好的pbuf的payload指针是指向ICMP数据报头处的.
接下来,函数填写ICMP数据包的相关字段,将类型段填充为3,代码段为输入参数t,同时将不可达的IP数据包的IP报头和数据前8个字节拷贝到ICMP数据包相应字段,最后计算校验和字段的值,然后调用ip_output函数将组装好的ICMP包发送出去.
ip_output函数通过调用ip_output_if函数完成数据包的发送,它在调用ip_output_if函数前,要先根据发送数据包的目的IP地址找到相应的发送网络接口结构,并将该结构作为调用ip_output_if函数的参数.
ip_output_if函数主要是填充IP报头各个字段的值,然后调用netif->output函数将封装好的IP数据包发送出去.
icmp_time_exceeded函数用于产生一个超时类型的ICMP报文.
该类型的报文与上面所述的报文有完全相同的报文结构,但类型字段值应为11,代码字段应为0或1,分别代表传输期间生存时间超时和数据组装期间生存时间超时.
对于后者,在讨论数据包重组时,我们知道每个ip_reassdata结构体中都有个时间字段,当指定的时间到达而数据包还未被组装完毕,则内核会将该ip_reassdata结构相关的所有分片数据全部删除,并向源主机发送一个代码字段为1的超时ICMP报文.
从代码流程和内容来看,icmp_time_exceeded函数和icmp_dest_unreach函数完全一样,只是在填充ICMP报文的类型和代码字段使用了不同的值,这里不再赘述.
接下来该讨论ICMP查询报文了,这部分在整个产品的设计调试过程中显示出非常重要的作用.
即Ping命令,它与ICMP回显应答、请求报文密切相关.
这小段来自于协议:"Ping"这个名字源于声纳定位操作.
目的是为了测试另一台主机是否可达.
该程序发送一份ICMP回显请求报文给主机,并等待返回ICMP回显应答.
一般来说,如果不能Ping到某台主机,那么就不能Telnet或者FTP到那台主机.
反过来,如果不能Telnet到某台主机,那么通常可以用Ping程序来确定问题出在哪里.
Ping程序还能测出到这台主机的往返时间,以表明该主机离我们有"多远".
同时,Ping程序也能在数据包的路由过程中记录下路由路径.
ICMP回显请求和回显应答报文格式如下图,回显应答的类型值为0,回显请求类型值为8,二者代码字段值均为0,Unix系统在实现ping程序时是把ICMP报文中的标识符字段置成发送进程的ID号.
这样即使在同一台主机上同时运行了多个ping程序实例,ping程序也可以识别出返回的信息.
序列号从0开始,每发送一次新的回显请求就加1.
E-mail:for_rest@foxmail.
com老衲五木出品icmp_input函数处理接收到的ICMP数据包,并根据包类型做相应的处理.
目前LWIP只能处理ICMP回显请求包,对其他类型的ICMP包不做响应,这在嵌入式产品中是足够用了的.
对于ICMP回显请求,icmp_input需要生成一个回显应答报文返回给源主机.
来看看icmp_input函数做了哪些工作.
首先将传进来的数据包pbuf的payload指针调整为指向ICMP头部,并判断ICMP头部长度是否小于4个字节,若是,则说明是个错误的ICMP数据包,该包被丢弃.
对于正确的ICMP包,函数根据其头部类型字段的值判断该做什么样的处理.
当前版本只实现了对ICMP回显请求的相关响应操作.
若当前的数据包为ICMP回显请求,则函数继续判断该数据包是否为广播或者多播包,对这两种数据包不做处理;接下来判断该数据包大小是否小于ICMP回显请求头部长度(如上图所示),是则丢弃数据包;接下来函数为这个数据包申请IP报头和以太网帧头内存空间,成功后将该ICMP包类型字段变为0,从新计算校验和,并将IP报头的源IP地址和目的IP地址交换位置,最后将整个数据包利用ip_output_if函数将数据包发送出去.
ICMP回显应答将回显请求中的数据原样返回给源主机,源主机在收到回显应答后,通过处理回显应答中的数据可以得到相关信息,如计算往返时间等.
ICMP就是这么多了.
E-mail:for_rest@foxmail.
com老衲五木出品14TCP建立与断开建立与断开建立与断开建立与断开TCP部分是整个LWIP最庞大也是难理解的部分,其代码将近占了整个协议栈代码量的一半.
看到如此大的一个工程真的不知道从哪里下口才能将它讲清楚.
郁闷,看到啥就写啥吧先,等写完了再来慢慢整理.
但我想参考的基本主线还是标准协议的TCP部分.
TCP叫传输控制协议,它为上层提供一种面向连接的、可靠的字节流服务,(PS:这一段都剽窃自协议).
TCP通过下面的一系列机制来提供可靠性:应用数据被分割成TCP认为最适合发送的数据块;当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段,如果不能及时收到一个确认,将重发这个报文段;当TCP收到发自TCP连接另一端的数据,它将发送一个确认,这个确认不是立即发送,通常将推迟几分之一秒;TCP将保持它首部和数据的检验和,如果收到段的检验和有差错,TCP将丢弃这个报文段并且不发送确认收,以使发送端超时并重发;IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序,如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层;IP数据报会发生重复,TCP的接收端必须丢弃重复的数据;TCP还能提供流量控制.
下图是TCP首部结构,若不计任选字段,其大小为20字节,与IP报首部大小相同.
源端口号和目的端口号,用于标识发送端和接收端的应用进程.
这两个值加上IP首部中的源IP地址和目的IP地址就能唯一确定一个TCP连接.
一个IP地址和一个端口号也称为一个插口(socket).
32位序号字段用来标识从TCP发送端到TCP接收端的数据字节流,用它来标识这个报文段中的第一个数据字节的序号.
当建立一个新的连接时,SYN标志置1,序号字段包含由这个发送主机选择的该连接上的初始序号ISN(InitialSequenceNumber).
该主机要发送数据的第一个字节序号为ISN+1.
E-mail:for_rest@foxmail.
com老衲五木出品32位确认序号只有ACK标志为1时才有效,它包含发送确认的一端所期望收到的下一个序号.
因此,确认序号应当是上次已成功收到数据字节序号加1.
当一个TCP连接被正确建立后,ACK字段总是被设置为1的.
4位首部长度给出首部中32bit字的数目.
需要这个值是因为任选字段的长度是可变的.
由于这个字段有4bit,因此TCP最多有60字节的首部.
然而,若没有任选字段,正常的长度是20字节.
在TCP首部中有6个标志比特.
它们中的多个可同时被设置为1.
在这里简单介绍它们的用法,在以后用到时会详加讲解:URG紧急指针(urgentpointer)有效标识;ACK确认序号有效标识;PSH接收方应该尽快将这个报文段交给应用层;RST重建连接;SYN同步序号,用来发起一个连接;FIN发端完成发送任务.
16位窗口大小字段通过声明自身的窗口大小来实现流量控制,窗口大小表示还能接收的字节数.
16位检验和覆盖了整个的TCP报文段:TCP首部和TCP数据.
这是一个强制性的字段,一定是由发送端计算和存储,并由收收端进行验证.
TCP检验和使用的TCP头部并不包括实际头部中的所有字段,而是一个伪首部,具体关于伪首部的结构可参看UDP部分,或参看协议.
16位紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号,只有当URG标志置1时紧急指针才有效.
TCP的紧急方式是发送端向另一端发送紧急数据的一种方式.
最常见的可选字段是最长报文大小,又称为MSS(MaximumSegmentSize).
每个连接方通常都在通信的第一个报文段(为建立连接而置SYN标志的那个段)中指明这个选项.
它指明本端所能接收的最大长度的报文段.
TCP报文段中的数据部分有时是为空的.
例如在一个连接建立和一个连接终止时,双方交换的报文段仅有TCP首部;又如一方没有数据要发送,则它需使用没有任何数据的首部来确认收到的数据;再在处理超时的许多情况中,也会发送不带任何数据的报文段.
上面的都是协议规定的内容,没有任何自由发挥的空间,下面来看看LWIP是如何描述这样一个TCP报头的,数据结构tcp_hdr如下,与上图描述的完全相符,不解释了.
PACK_STRUCT_BEGINstructtcp_hdr{PACK_STRUCT_FIELD(u16_tsrc);//源端口PACK_STRUCT_FIELD(u16_tdest);//目的端口PACK_STRUCT_FIELD(u32_tseqno);//序号PACK_STRUCT_FIELD(u32_tackno);//确认序号PACK_STRUCT_FIELD(u16_t_hdrlen_rsvd_flags);//首部长度+保留位+标志位PACK_STRUCT_FIELD(u16_twnd);//窗口大小PACK_STRUCT_FIELD(u16_tchksum);//校验和PACK_STRUCT_FIELD(u16_turgp);//紧急指针}PACK_STRUCT_STRUCT;PACK_STRUCT_END哎,又一次茫然了,不晓得该写什么!
TCP这块真是太难啃了.
先抛开LWIP的内容,仔细讲解详解卷中的内容.
先讲讲一个TCP连接的建立过程.
TCP建立连接需要有三个报文段的交互过程,所以又称三次握手过程.
首先,请求端(通常称为客户)发送一个SYN标志置1的TCP数据报,数据包中指明自己的端口号及将连接的服务器的端口号,同时通告自己的初始序号ISN.
E-mail:for_rest@foxmail.
com老衲五木出品当服务器接收到该数据包并解析后,也发回一个SYN报文段作为应答.
该回应报文包含服务器自身选定的初始序号ISN,同时,将ACK置1,将确认序号设置为请求端的ISN加1以对客户的SYN报文段进行确认.
这里的ISN也表示了服务器希望接收到的下一个字节的序号.
由此可见,一个SYN将占用了一个序号.
最后,当请求端接收到服务器的SYN应答包后,会再次产生一个握手包,这个包中,ACK标志置位,确认序号设置为服务器发送的ISN加1,以此来实现对服务器的SYN报文段的确认.
通常存在这样的一种情况,两端同时发起连接,即同时发送第一个SYN数据包,这时,这两端都处于主动打开状态,在后续的讨论中我们将涉及这个内容.
TCP连接的断开需要四次握手过程.
一个TCP连接是全双工(即数据在两个方向上能同时传递),因此每个方向必须单独地进行关闭.
当发送数据的一方完成它的数据发送任务后它就可以发送一个FIN标志置1的握手包来终止这个方向连接.
当另一端收到这个FIN包时,它必须通知应用层另一端已经终止了那个方向的数据传送.
发送FIN通常是应用层进行关闭的结果,收到一个FIN意味着在这一方向上已经没有数据流动.
一个TCP连接在收到一个FIN后仍能发送数据,此时的连接处于半关闭状态.
通常首先进行关闭的一方(即发送第一个FIN)将执行主动关闭,而另一方(收到这个FIN)执行被动关闭.
通常一方完成主动关闭而另一方完成被动关闭,但也存在双方都为主动关闭的情况,这将在后续讨论.
断开一个连接的四次握手过程如下图所示,首先客户端应用程序主动执行关闭操作时,客户端会向服务器发送一个FIN握手包,用来关闭从客户到服务器的数据传送.
当服务器收到这个FIN,它发回一个ACK,确认序号为收到的序号加1.
和SYN一样,一个FIN将占用一个序号.
同时服务器还向其上层应用程序通告接收到结束动作,接着这个服务器程序就会关闭它的连接,导致它的TCP端发送一个FIN握手包,客户必须发回一个确认,并将确认序号设置为收到序号加1.
在这个图中,发送FIN将导致应用程序关闭它们的连接,这些FIN的ACK是由TCP软件自动产生的.
连接断开的主动发起方通常是客户端,但如果是服务器首先发起FIN包,上图的握手过程也是完全成立的.
E-mail:for_rest@foxmail.
com老衲五木出品15TCP状态转换状态转换状态转换状态转换在理解了TCP连接建立于断开的过程后,再来看TCP的状态转换图就相对容易了.
(PS:其实还是很有难度!
!
)图中有两个典型的状态转换路径,第一个是客户端申请建立连接与断开连接的过程,如图中黑色粗线所示:与前面描述的一致,在客户端通过发送一个SYN包,主动向服务器申请一个连接,数据包发出后客户端进入SYN_SENT状态等待服务器的ACK和SYN包返回,当收到这个返回包后,客户端对服务器的SYN进行确认,然后自身进入ESTABLISHED状态,与前面描述的三次握手过程完全一致.
当客户端申请断开连接时,它要发送FIN包E-mail:for_rest@foxmail.
com老衲五木出品给服务器申请断开连接,当FIN包发送后,客户端进入FIN_WAIT_1状态等待服务器返回确认包,当收到这个确认包后,表明客户端到服务器方向的连接断开成功,此时客户端进入FIN_WAIT_2状态等待服务器到客户端方向的连接断开,此时当客户端收到服务器的FIN包时,即向服务器返回一个ACK包,表明服务器到客户端方向的连接断开成功,此后客户端进入TIME_WAIT状态,在该状态下等待2MSL后,客户端进入初始的CLOSED状态.
在连接处于2MSL等待时,任何迟到的数据报文段将被丢弃.
此过程与断开连接的四次握手过程完全相符.
另一个典型的状态转换路径是描述服务器的,如图中虚线所示.
服务器建立连接一般属于被动过程,它首先打开某个端口,进入LISTEN状态以侦听客户端的连接请求.
当服务器收到客户端的SYN连接请求,则进入SYN_REV状态,并向客户端返回一个ACK及自身的SYN包,此后,服务器等待客户端返回一个确认包,收到该ACK包后,服务器进入ESTABLISHED状态,并可以和服务器进行稳定的数据交换过程.
可见,连接建立的过程和前面描述的三次握手过程完全一致.
当服务器收到客户端发送的一个断开数据包FIN时,则进入CLOSE_WAIT状态,并向上层应用程序通告这个消息,同时向客户端返回一个ACK包,此时客户端到服务器方向的连接断开成功;此后,当服务器上层应用处理完毕相关信息后会向客户端发送一个FIN包,并进入LASK_ACK状态,等待客户端返回一个ACK包,当收到返回的ACK包后,此时服务器到客户端方向的连接断开成功,服务器端至此进入初始的CLOSED状态.
此过程与断开连接的四次握手过程完全相符.
上面这个转换图中还有两处较特殊的转换路线,它们分别用于处理TCP两端同时发起或断开连接的情况.
两台主机同时执行打开操作的握手包交互如下图所示,两台主机在同时发送自身的SYN请求包后各自进入SYN_SENT状态,等待对方的ACK包返回,但一定时间后,每个主机都收到对方的SYN包而不是ACK包,此时两端都判定已经遇到了同时打开的状况发生,两端都进入SYN_RCVD状态,并对对方的SYN进行确认并再次发送自己的SYN包,当接收到对方的ACK+SYN后,两边都进入ESTABLISHED状态.
从这一点看,状态转换图中的从SYN_RCVD到ESTABLISHED转换的条件应该有两个,而图中只标出了一个.
一个同时打开的连接需要交换4个报文段,比正常的三次握手多一个.
要注意的是在这样的连接过程中,没有将任何一端称为客户或服务器,因为每一端既是客户又是服务器.
两台主机同时发起主动关闭操作的握手包交互如下,当两个主机的用户程序同时执行E-mail:for_rest@foxmail.
com老衲五木出品关闭操作时,两主机都向对方发送一个FIN包,并进入FIN_WAIT_1状态等待对方的ACK返回,但一段时间后,双方各自都收到对方的FIN包,而不是ACK包,此时两主机都判定遇到了双方同时主动关闭的状况,此时,两个主机就没有必要进入FIN_WAIT_2状态等待对方的FIN包了,因为这个包刚刚已经收到,而是直接进入CLOSING状态,并向对方发送一个FIN包的确认,等到双方都收到对方的ACK包后,两边都各自进入TIME_WAIT状态.
再来详细讲解下TIME_WAIT状态,协议中是这样描述的:当TCP执行一个主动关闭,并发出最后一个ACK后,该连接必须在TIME_WAIT状态停留的时间为2倍的MSL.
这样可让TCP保证在最后的这个ACK丢失的情况下重新发送ACK(另一端超时并重发最后的FIN).
处于TIME_WAIT等待状态的TCP端口此刻还不能被其他新连接所使用.
虽然上面的状态转换图上指出,从一个连接从LISTEN状态转换到SYN_SENT状态是允许的,但是大多数的协议实现中均没有实现该转换,即执行被动打开的连接一般不要主动发起连接.
平静时间:如果主机在TCP状态转换过程中突然崩溃,在TCP重启后的一个MSL内,TCP不能发送任何数据报文段,这段时间称为平静时间.
设置平静时间是为了防止旧连接的延迟的数据报分组对新连接造成影响.
E-mail:for_rest@foxmail.
com老衲五木出品16TCP控制块控制块控制块控制块这一节正式踏入LWIP协议TCP部分的大门.
先来看看它是怎样来描述一个TCP连接的.
这个结构灰常的复杂,这里的简单描述,也并不全面,并不能清晰说明各个字段的作用,在后续的TCP相关内容中,会对每个用到的字段详加讲解.
结构体tcp_pcb的源代码如下:structtcp_pcb{IP_PCB;//这是一个宏,描述了连接的IP相关信息,包括双方IP地址,TTL等信息structtcp_pcb*next;//用于连接各个TCP控制块的链表指针enumtcp_statestate;//TCP连接的状态,即为状态图中描述的那些状态u8_tprio;//该控制块的优先级void*callback_arg;//u16_tlocal_port;//本地端口u16_tremote_port;//远程端口u8_tflags;//附加状态信息,如连接是快速恢复、一个被延迟的ACK是否被发送等#defineTF_ACK_DELAY(u8_t)0x01U/*DelayedACK.
*///这些宏定义是为flags字段#defineTF_ACK_NOW(u8_t)0x02U/*ImmediateACK.
*///定义的掩码#defineTF_INFR(u8_t)0x04U/*Infastrecovery.
*/#defineTF_RESET(u8_t)0x08U/*Connectionwasreset.
*/#defineTF_CLOSED(u8_t)0x10U/*Connectionwassucessfullyclosed.
*/#defineTF_GOT_FIN(u8_t)0x20U/*Connectionwasclosedbytheremoteend.
*/#defineTF_NODELAY(u8_t)0x40U/*DisableNaglealgorithm*///接收相关字段u32_trcv_nxt;//期望接收的下一个字节,即它向发送端ACK的序号u16_trcv_wnd;//接收窗口u16_trcv_ann_wnd;//通告窗口大小,较低版本中无该字段u32_ttmr;//该字段记录该PCB被创建的时刻u8_tpolltmr,pollinterval;//三个定时器,后续讲解u16_trtime;//重传定时,该值随时间增加,当大于rto的值时则重传发生u16_tmss;//最大数据段大小//RTT估计相关的参数u32_trttest;//估计得到的500ms滴答数u32_trtseq;//用于测试RTT的包的序号s16_tsa,sv;//RTT估计出的平均值及其时间差u16_trto;//重发超时时间,利用前面的几个值计算出来u8_tnrtx;//重发的次数,该字段在数据包多次超时时被使用到,与设置rto的值相关E-mail:for_rest@foxmail.
com老衲五木出品//快速重传/恢复相关的参数u32_tlastack;//最大的确认序号,该字段不解u8_tdupacks;//上面这个序号被重传的次数//阻塞控制相关参数u16_tcwnd;//连接的当前阻塞窗口u16_tssthresh;//慢速启动阈值//发送相关字段u32_tsnd_nxt,//下一个将要发送的字节序号snd_max,//最高的发送字节序号snd_wnd,//发送窗口snd_wl1,snd_wl2,//上次窗口更新时的数据序号和确认序号snd_lbb;//发送队列中最后一个字节的序号u16_tacked;//u16_tsnd_buf;//可用的发送缓冲字节数u8_tsnd_queuelen;//可用的发送包数structtcp_seg*unsent;//未发送的数据段队列structtcp_seg*unacked;//发送了未收到确认的数据队列structtcp_seg*ooseq;//接收到序列以外的数据包队列#ifLWIP_CALLBACK_API//回调函数,部分函数在较低版本没定义err_t(*sent)(void*arg,structtcp_pcb*pcb,u16_tspace);err_t(*recv)(void*arg,structtcp_pcb*pcb,structpbuf*p,err_terr);//数据包接收回调函数err_t(*connected)(void*arg,structtcp_pcb*pcb,err_terr);err_t(*accept)(void*arg,structtcp_pcb*newpcb,err_terr);err_t(*poll)(void*arg,structtcp_pcb*pcb);void(*errf)(void*arg,err_terr);#endif/*LWIP_CALLBACK_API*///剩下的所有字段在较低版本中均未定义,用到时再讲解u32_tkeep_idle;#ifLWIP_TCP_KEEPALIVEu32_tkeep_intvl;//保活定时器,用于检测空闲连接的另一端是否崩溃u32_tkeep_cnt;#endif/*LWIP_TCP_KEEPALIVE*/u32_tpersist_cnt;//这两个字段可以使窗口大小信息保持不断流动u8_tpersist_backoff;u8_tkeep_cnt_sent;};先说说和接收数据相关的字段rcv_nxt,rcv_wnd,rcv_ann_wnd和数据发送的相关字段snd_nxt,snd_max,snd_wnd,acked.
这些字段都和TCP中有名的滑动窗口协议有密切关E-mail:for_rest@foxmail.
com老衲五木出品系.
如下图所示,连接的双方都维持一个窗口用于数据的发送.
滑动窗口把整个序列分成三部分:左边的是发送了并且被确认的分组,窗口右边是还没发送的分组,窗口内部是待确认的分组,窗口内部又分成已经发送待确认的,和未发送但将立即发送.
TCP是通过正面确认和重传技术来保证可靠性的,滑动窗口可以使发送方在收到前一个分组的确认信息前发送下一个分组,这样提高了网络的带宽利用率.
除了发送窗口外,TCP连接的双方还各自维护了一个接收窗口,如下图,接收方的接收窗口和发送方的发送窗口对比起来看看数据包的交互过程.
在接收方,rev_wnd表示了自己接收窗口的大小,它可以在给发送方的ACK包中通告自己的窗口大小值,发送方接收到该值后,就以此设子自己的发送窗口大小值snd_wnd.
发送方的发送窗口内包含的数据发送序列是与ACK序号密切相关的,即它将ACK序号以后的snd_wnd个字节序号包括在窗口内.
发送方的acked字段就表示已经接收到的最高的ACK序号,snd_nxt表示发送方即将发送数据的序号,acked与snd_nxt之间的数据表示已经被发送但还未接收到ACK,发送方也必须将他们包括在滑动窗内,以方便超时重发,snd_nxt到发送窗口末端表示还未发送的数据.
snd_max表示不解,MARKKKKK一下,后面再来看看.
在接收方,接收处于滑动窗内编号的数据,当某个序号以前的所有序号都已经接收到后,则接收方可以ACK该序号,并将接收窗口向后滑动.
发送方也接收到该ACK后,也将自己的发送窗口向后滑动.
在接收方,re_nxt表示希望接收到的下个字节序号,rev_ann_wnd表示对方通告的窗口大小,这里也表示不解.
凌乱,凌乱.
.
.
与发送相关的还有cwnd字段,这就涉及到慢启动的概念了.
当发送方接收到接收方的窗口通告后,并不会一下子把窗口内允许的数据全部发送出去,因为这样做的话可能由于中间路由器转发拥塞等原因,造成网络吞吐量不稳定,带宽利用率低等不良现象.
发送端的做法是用cwnd字段保存一个拥塞窗口,发送方取拥塞窗口与通告窗口中的最小值作为发送上E-mail:for_rest@foxmail.
com老衲五木出品限,拥塞窗口初始值一般取1,并在每次收到接收方的一个ACK后加上一个值.
关于慢启动还会在后续内容中讲解.
E-mail:for_rest@foxmail.
com老衲五木出品17TCP建立流程建立流程建立流程建立流程前面说了一大堆虚无缥缈的东西,而且大部分都借鉴于标准协议里面的内容,让人有点晕!
这一节我们就看看如何在我们的LWIP上实现一个http服务器的过程,结合连接建立过程来理解TCP状态转换图和TCP控制块中各个字段的意义.
这里先讲解一些与TCP相关的最基础的函数,至于是怎样将这些函数合理高效的组织起来以方便实际应用,这里先不涉及.
第一个函数是tcp_new函数,该函数简单的调用tcp_alloc函数为一个连接分配一个TCP控制块tcp_pcb.
tcp_alloc函数首先为新的tcp_pcb分配内存空间,若内存空间不够,则函数会释放处于TIME-WAIT状态的TCP或者优先级更低的PCB(在PCB控制块的prio字段)以为新的PCB分配空间.
当内存空间成功分配后,函数会初始化新的tcp_pcb的内容,源码如下:if(pcb!
=NULL){memset(pcb,0,sizeof(structtcp_pcb));//清0所有字段的值pcb->prio=TCP_PRIO_NORMAL;//设置PCB的优先级为64,优先级在1~127之间pcb->snd_buf=TCP_SND_BUF;//TCP发送数据缓冲区剩余大小pcb->snd_queuelen=0;//发送缓冲中的数据包pbuf个数pcb->rcv_wnd=TCP_WND;//接收窗口大小pcb->rcv_ann_wnd=TCP_WND;//通告窗口大小pcb->tos=0;//IP报头部TOS字段pcb->ttl=TCP_TTL;//IP报头部TTL字段pcb->mss=(TCP_MSS>536)536:TCP_MSS;//设置最大段大小,不能超过536字节pcb->rto=3000/TCP_SLOW_INTERVAL;//初始超时时间值,为6spcb->sa=0;//估计出的RTT平均值pcb->sv=3000/TCP_SLOW_INTERVAL;//估计出的RTT方差pcb->rtime=-1;//重传定时器,当该值大于rto时则重传发生pcb->cwnd=1;//阻塞窗口iss=tcp_next_iss();//iss为一个临时变量,保存该连接的初始数据序列号pcb->snd_wl2=iss;//上一个窗口更新时收到的ACK号pcb->snd_nxt=iss;//下一个将要发送的数据编号pcb->snd_max=iss;//发送了的最大数据编号pcb->lastack=iss;//上一个ACK编号pcb->snd_lbb=iss;//下一个将要缓冲的数据编号pcb->tmr=tcp_ticks;//tcp_ticks是一个全局变量,记录了当前协议时钟滴答pcb->polltmr=0;//未解#ifLWIP_CALLBACK_APIpcb->recv=tcp_recv_null;//注册默认的接收回调函数#endif/*LWIP_CALLBACK_API*/pcb->keep_idle=TCP_KEEPIDLE_DEFAULT;#ifLWIP_TCP_KEEPALIVE//保活定时器相关设置.
.
未解E-mail:for_rest@foxmail.
com老衲五木出品pcb->keep_intvl=TCP_KEEPINTVL_DEFAULT;pcb->keep_cnt=TCP_KEEPCNT_DEFAULT;#endifpcb->keep_cnt_sent=0;}上面有很多晕的地方,这些将在后续一一讲解.
PCB中的还有一些函数字段如发送、接收函数等是在具体应用中初始化的.
当一个新建的PCB被初始化好后,tcp_bind函数将会被调用,用来将IP地址及端口号与该TCP控制块绑定.
该函数的输入参数很明显有三个,即TCP控制块、IP地址和端口号.
tcp_bind函数的工作也很简单,就是将两个参数的值赋值给TCP控制块中local_ip和local_port的字段.
但这里有个前提,就是这个对没有被使用.
所以,函数需要先遍历各个pcb链表,以保证这个对没有被其他PCB使用,这里的pcb链表有好几种:处于侦听状态的链表tcp_listen_pcbs、处于稳定状态的链表tcp_active_pcbs、已经绑定完毕的PCB链表tcp_bound_pcbs、处于TIME-WAIT状态的PCB链表tcp_tw_pcbs.
如果遍历完这些链表后,都没有找到相应的对,则说明该对可用,则可进行上面说的赋值操作,最后,函数将这个PCB加入绑定完毕的PCB链表tcp_bound_pcbs.
上面一共说了四种PCB链表,现在看看它们各自用来链接了处于哪种状态的PCB控制块.
tcp_bound_pcbs链表用来连接新创建的控制块,可以认为新建的控制块处于closed状态.
tcp_listen_pcbs链表用来连接处于LISTEN状态的控制块,tcp_tw_pcbs链表用来连接处于TIME_WAIT状态的控制块,tcp_active_pcbs链表用来连接处于TCP状态转换图中其他所有状态的控制块.
从状态转换图可以知,服务器端需进入LISTEN状态等待客户端的连接.
因此,服务器端此时需要调用函数tcp_listen使相应TCP控制块进入LISTEN状态.
可以直接的想象,要把一个控制块置为LISTEN状态很简单,先将其从tcp_bound_pcbs链表上取下来,将其state字段置为LISTEN,最后再将该PCB挂接到链表tcp_listen_pcbs上.
但事实上,LWIP的实现有一定的区别,它引入了一个叫tcp_pcb_listen的结构,该结构与tcp_pcb结构相近,但是去掉了其中在LISTEN阶段用不到的传输控制字段,这样tcp_pcb_listen的结构更小,更可以节省内存空间.
所以,其实tcp_listen是这样做的,先申请一个tcp_pcb_listen的结构,然后将tcp_pcb参数中的有用字段拷贝进来,然后将这个tcp_pcb_listen的结构挂接到链表tcp_listen_pcbs上.
到这里服务器就等待客户端发送来的SYN数据包进行连接了,要等待外面的数据包,这就和以前讨论过的ip_input函数相关了,ip_input函数会判断IP包头部的协议字段,并把TCP数数据包通过tcp_input函数传递到TCP层.
SYN数据包当然是TCP层数据包,当然也要经过tcp_input函数进行处理并递交上层,现在就来看看tcp_input函数.
tcp_input函数开始会对IP层递交进来的数据包进行一些基础操作,如移动数据包的payload指针、丢弃广播或多播数据包、数据和校验、提取TCP头部各个字段的值等等.
接下来,函数根据接收到的TCP包的对遍历tcp_active_pcbs链表,寻找匹配的PCB控制块,若找到,则调用tcp_process函数对该数据包进行处理.
若找不到,则再分别到tcp_tw_pcbs链表和tcp_listen_pcbs中寻找,找到则调用各自的数据包处理函数tcp_timewait_input和tcp_listen_input对数据包进行处理,若到这里都还未找到匹配的TCP控制块,则tcp_input函数会调用函数tcp_rst向源主机发送一个TCP复位数据包.
这里我们的TCP控制块处于LISTEN状态,连接在tcp_listen_pcbs上,正在等待一个SYN数据包.
因此,当等到该数据包后,函数tcp_listen_input应该被调用.
从状态转换图E-mail:for_rest@foxmail.
com老衲五木出品上可以看出,处于LISTEN状态的TCP控制块只能响应SYN握手包,所以,tcp_listen_input函数对非SYN握手包返回一个TCP复位数据包,若一个数据包不是SYN包,则其TCP包头中的ACK字段通常会被置1,所以tcp_listen_input函数是通过检验该位来实现的.
接下来,函数通过验证SYN位来确认该包是否为SYN握手包.
若是,则需要新建一个tcp_pcb结构,因为处于tcp_listen_pcbs上的控制块结构是tcp_pcb_listen结构的,而其他链表上的控制块结构是tcp_pcb结构的,所以这里新建一个tcp_pcb结构,并将相应tcp_pcb_listen结构拷贝至其中,同时在tcp_active_pcbs链表中添加这个新的tcp_pcb结构.
这样新的TCP控制块就处在tcp_active_pcbs中了,注意此时的这个tcp_pcb结构的state字段应该设置为SYN_RCVD,表示进入了收到SYN状态.
注意tcp_listen_pcbs链表中的这个tcp_pcb_listen结构还一直存在,它并不会被删除,以等待其他客户端的连接,服务器正是需要这样的功能.
到这里,函数tcp_listen_input还没完.
它应该从收到的SYN数据报中提取TCP头部中选项字段的值,并设置自己的TCP控制块.
这里要被调到用的函数叫tcp_parseopt,它目前仅能够做的是提取选项中的MSS(最长报文大小)字段,在LWIP以后的更高版本中,该函数将被扩充,以支持更多的TCP选项.
此后,函数还可以调用tcp_eff_send_mss来设置控制块中mss字段的值,该函数可直译为"有效发送最长报文大小",所谓有效,就是指收到SYN数据包中的MSS值不能大于我的硬件支持的最大发送报文长度,即硬件的MTU.
因此当收到的MSS值更大时,设置控制块中mss字段值会被设置为MTU,而不是MSS.
最后,函数需要向源端返回一个带SYN和ACK标志的握手数据包,并可以向源端通告自己的MSS大小.
发送数据包是通过tcp_enqueue和tcp_output函数共同完成的.
关于数据包的发送,将在以后介绍.
最最后,来看看函数tcp_listen_input内部的关键源代码部分,这几行代码涉及到TCP控制块内部各个字段值的设置,其中很重要的就是滑动窗口相关的字段.
ip_addr_set(&(npcb->local_ip),&(iphdr->dest));//复制本地IP地址npcb->local_port=pcb->local_port;//复制本地端口ip_addr_set(&(npcb->remote_ip),&(iphdr->src));//复制源IP地址npcb->remote_port=tcphdr->src;//复制源端口npcb->state=SYN_RCVD;//设置TCP状态npcb->rcv_nxt=seqno+1;//期望接收到的下一个字节序号npcb->snd_wnd=tcphdr->wnd;//设置发送窗口大小npcb->ssthresh=npcb->snd_wnd;//快速启动阈值设为和发送窗口大小相同npcb->snd_wl1=seqno-1;//该字段npcb->callback_arg=pcb->callback_arg;//该字段#ifLWIP_CALLBACK_APInpcb->accept=pcb->accept;//接收回调函数#endif/*LWIP_CALLBACK_API*/其中npcb表示新建的tcp_pcb结构,还有很多不懂的地方,为啥仅仅拷贝保留了这几个字段,其他字段直接被忽略E-mail:for_rest@foxmail.
com老衲五木出品18TCP状态机状态机状态机状态机服务器端接收到SYN握手包,向客户端返回带SYN和ACK的握手包,并将相应TCP控制块置为SYN_RCVD状态,并挂在tcp_active_pcbs链表上.
以后,继续等待客户端发送过来的握手包,这次,服务器期望的是接收一个ACK包以完成建立连接要求的三次握手操作.
还是和前几次一样,数据包进来通过ip_input传递给tcp_input,后者在三个链表中查找一个匹配的连接控制块.
这次进来的是客户端发送的ACK握手包,服务器端相应的tcp控制块一定是在tcp_active_pcbs链表上.
接下来,以查找到的tcp_pcb结构为参数,调用TCP状态机函数tcp_process处理输入数据段.
在讲解tcp_process函数之前,先来看看这个状态机函数要用到的一些重要的全局变量,这些变量是在tcp_input函数中,通过接收数据段的各个字段的值进行设置的.
全局变量tcphdr指向收到的数据段的TCP头部;全局变量seqno记录了TCP头部中的数据序号字段;全局变量ackno存储确认序号字段;全局变量flags表示TCP首部中的各个标志字段;全局变量tcplen表示数据报中数据的长度,对于SYN包和FIN包,该长度为1;全局变量inseg用于描述收到的数据内容,它是tcp_seg类型的,tcp_seg这个结构体后面再讲;全局变量recv_flags用来标识tcp_process函数对数据段的处理结果,初始化为0.
tcp_process函数首先判断该数据段是不是一个复位数据段,若是则进行相应的处理,这里先跳过这小部分.
直接到达tcp_process函数状态机部分,它就是对TCP状态转换图的简单代码诠释.
必须要贴一大段代码上来了,这比任何语言更能说清楚问题,注意下面的代码已经被我去掉了相关注释和编译输出部分.
switch(pcb->state){caseSYN_SENT://客户端将SYN发送到服务器等待握手包返回if((flags&TCP_ACK)&&(flags&TCP_SYN)//实际收到ACK和SYN包&&ackno==ntohl(pcb->unacked->tcphdr->seqno)+1){pcb->snd_buf++;pcb->rcv_nxt=seqno+1;pcb->lastack=ackno;pcb->snd_wnd=tcphdr->wnd;pcb->snd_wl1=seqno-1;/*initialisetoseqno-1toforcewindowupdate*/pcb->state=ESTABLISHED;tcp_parseopt(pcb);//处理选项字段#ifTCP_CALCULATE_EFF_SEND_MSS//根据需要设置有效发送mss字段pcb->mss=tcp_eff_send_mss(pcb->mss,&(pcb->remote_ip));#endifpcb->ssthresh=pcb->mss*10;//由于重新设置了mss字段值,所以要重设ssthresh值pcb->cwnd=((pcb->cwnd==1)(pcb->mss*2):pcb->mss);//设置阻塞窗口--pcb->snd_queuelen;//要发送的数据段个数减1rseg=pcb->unacked;//取下要被确认的字段E-mail:for_rest@foxmail.
com老衲五木出品pcb->unacked=rseg->next;if(pcb->unacked==NULL)//没有字段需要被确认,则停止定时器pcb->rtime=-1;else{//否则重新开启定时器pcb->rtime=0;pcb->nrtx=0;}tcp_seg_free(rseg);//释放已经被确认了的段TCP_EVENT_CONNECTED(pcb,ERR_OK,err);tcp_ack_now(pcb);//返回ACK握手包}elseif(flags&TCP_ACK){//仅仅只有ACK而无SYN标志tcp_rst(ackno,seqno+tcplen,&(iphdr->dest),&(iphdr->src),tcphdr->dest,tcphdr->src);//不支持半打开状态,所以返回一个复位包}break;caseSYN_RCVD://服务器端发送出SYN+ACK后便处于该状态if(flags&TCP_ACK&&//在SYN_RCVD状态接收到ACK返回包!
(flags&TCP_RST)){//判断ACK序号是否合法if(TCP_SEQ_BETWEEN(ackno,pcb->lastack+1,pcb->snd_nxt)){u16_told_cwnd;pcb->state=ESTABLISHED;//进入ESTABLISHED状态old_cwnd=pcb->cwnd;//保存旧的阻塞窗口accepted_inseq=tcp_receive(pcb);//若包含数据则接收数据段pcb->cwnd=((old_cwnd==1)(pcb->mss*2):pcb->mss);//重新设置阻塞窗口if((flags&TCP_FIN)&&accepted_inseq){//如果ACK包同时含有FIN位且tcp_ack_now(pcb);//已经接收完了最后的数据,则响应FINpcb->state=CLOSE_WAIT;//进入CLOSE_WAIT状态}}else{//不合法的ACK序号则返回一个复位包tcp_rst(ackno,seqno+tcplen,&(iphdr->dest),&(iphdr->src),tcphdr->dest,tcphdr->src);}}break;caseCLOSE_WAIT://服务器,TCP处于半打开状态,在该方向上不会再接收到数据包//服务器在此状态下会一直等待上层应用执行关闭命令tcp_close,并将状态变为LAST_ACKcaseESTABLISHED://稳定状态,客户端会一直保持稳定状态直到上层应用调用tcp_close函数关闭连接,将状态变为FIN_WAIT_1accepted_inseq=tcp_receive(pcb);//直接接收数据E-mail:for_rest@foxmail.
com老衲五木出品if((flags&TCP_FIN)&&accepted_inseq){//同时数据包有FIN标志,且接收到了最后tcp_ack_now(pcb);的数据,则响应FINpcb->state=CLOSE_WAIT;//进入CLOSE_WAIT状态}break;caseFIN_WAIT_1://客户端独有的状态tcp_receive(pcb);//还可以接收来自服务器的数据if(flags&TCP_FIN){//如果收到FIN包if(flags&TCP_ACK&&ackno==pcb->snd_nxt){//且还有ACK,则进入TIME_WAITtcp_ack_now(pcb);//发ACKtcp_pcb_purge(pcb);//清除该连接中的所有现存数据TCP_RMV(&tcp_active_pcbs,pcb);//从tcp_active_pcbs链表中删除pcb->state=TIME_WAIT;//置为TIME_WAIT状态TCP_REG(&tcp_tw_pcbs,pcb);//加入tcp_tw_pcbs链表}else{//无ACK,则表示两端同时关闭的情况发生tcp_ack_now(pcb);//发送ACKpcb->state=CLOSING;//进入CLOSING状态}}elseif(flags&TCP_ACK&&ackno==pcb->snd_nxt){//不是FIN包,而是有效ACKpcb->state=FIN_WAIT_2;//则进入FIN_WAIT_2状态}break;caseFIN_WAIT_2://客户端在该状态等待服务器返回的FINtcp_receive(pcb);//还可以接收来自服务器的数据if(flags&TCP_FIN){//如果收到FIN包tcp_ack_now(pcb);//发ACKtcp_pcb_purge(pcb);//清除该连接中的所有现存数据TCP_RMV(&tcp_active_pcbs,pcb);//从tcp_active_pcbs链表中删除pcb->state=TIME_WAIT;//置为TIME_WAIT状态TCP_REG(&tcp_tw_pcbs,pcb);//加入tcp_tw_pcbs链表}break;caseCLOSING://进入了同时关闭的状态,这种情况极少出现tcp_receive(pcb);//还可以接收对方的数据if(flags&TCP_ACK&&ackno==pcb->snd_nxt){//若是有效ACKtcp_ack_now(pcb);//与上面类似的操作,不解释了tcp_pcb_purge(pcb);TCP_RMV(&tcp_active_pcbs,pcb);pcb->state=TIME_WAIT;TCP_REG(&tcp_tw_pcbs,pcb);}break;caseLAST_ACK://服务器端(被动关闭端)能出现该状态tcp_receive(pcb);//还可以接收对方的数据E-mail:for_rest@foxmail.
com老衲五木出品if(flags&TCP_ACK&&ackno==pcb->snd_nxt){//接收到有效ACKrecv_flags=TF_CLOSED;//全局变量recv_flags用于标识该数据段进行了哪些处理}//以便tcp_input的后续处理,此时并没有把pcb->state的状态设置为CLOSED状态break;default:break;}这就是TCP状态机的大致流程图,如果对照着TCP状态转换图来看,你会觉得它是如此的简单.
不过还有很多搞不清楚的地方,比如控制块中各个字段的值特别是阻塞窗口和发送接收窗口等的值,它们代表了什么意义,为什么要如此设置,等等.
注意TCP状态转换图中的双方同时打开的情况,即从LISTEN状态到SYN_SENT状态,SYN_SENT状态到SYN_RCVD状态,没有被实现.
许多其他TCP/IP实现,如BSD中也是这样的,没有实现这部分功能.
因为在实际应用中这种情况几乎不可见,所以实现并没有严格按照协议来实行.
E-mail:for_rest@foxmail.
com老衲五木出品19TCP输入输出函数输入输出函数输入输出函数输入输出函数1这节从tcp_receive函数入手,逐步深入了解控制块各个字段的意义以及整个TCP层的运行机制,足足600行,神想吐血.
源码注释的该函数功能为:检查收到的数据段是不是对已发数据段的确认,如果是,则释放相应发送缓冲中的数据;接下来,如果该数据段中有数据,应将数据挂接到控制块的接收队列上(pcb->ooseq).
如果数据段同时也是对正在进行RTT估计的数据段的确认,则RTT计算也在这个函数中进行.
我晕,陷入了恶性循环.
越看越难,越看越说不清,TCP的东西太多了.
要讲清楚tcp_receive还得说清楚tcp_enqueue,不管了,先硬着头皮写下去!
源码注释对该函数的功能描述很简单:将数据包或者连接的控制握手包放到tcp控制块的发送队列上.
这个函数的原型为err_ttcp_enqueue(structtcp_pcb*pcb,void*arg,u16_tlen,u8_tflags,u8_tapiflags,u8_t*optdata,u8_toptlen)其中有几个重要的输入参数: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个数E-mail:for_rest@foxmail.
com老衲五木出品因为在看滑动窗口时怎样实现的时候,这些字段是非常关键的.
凌乱凌乱,讲了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数据段,源代码如下:structtcp_seg{structtcp_seg*next;//用来建立链表的指针structpbuf*p;//数据段pbuf指针void*dataptr;//指向TCP段的数据区u16_tlen;//TCP段的数据长度structtcp_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数据段的开始位置.
E-mail:for_rest@foxmail.
com老衲五木出品E-mail:for_rest@foxmail.
com老衲五木出品20TCP输入输出函数输入输出函数输入输出函数输入输出函数2继续说tcp_receive函数.
前面说过,当tcp_input函数接收到来自IP层的数据包后,会将相关TCP头部字段保存在一些全局变量中,全局变量seqno记录了TCP头部中的数据序号字段;全局变量ackno存储确认序号字段;全局变量flags表示TCP首部中的各个标志字段;全局变量tcplen表示数据报中数据的长度,对于SYN或FIN置为的数据包,该长度要加1;全局变量inseg用于描述收到的数据内容,它是tcp_seg类型的.
在TCP状态机的实现函数tcp_process中,会在不同地方来调用函数tcp_receive来处理输入的TCP数据段.
tcp_receive首先判断是否可以根据接收到的数据段来跟新本地的发送窗口大小,因为数据段中有对方的窗口大小通告.
与窗口跟新的TCP控制块中的字段是:snd_wnd、snd_wl1、snd_wl2,分别表示当前窗口大小、上一次窗口更新时接收到的数据序号(seqno)、上一次窗口更新时接收到的确认序号(ackno).
有三种情况可以导致本地发送窗口跟新:收到数据段中的seqno大于snd_wl1(连接建立握手过程中使用这种方式)、新seqno等于snd_wl1且新确认号ackno大于snd_wl2、新确认号ackno等于snd_wl2且数据段中有比snd_wnd更大的窗口通告.
为什么是这三种情况呢,表示不懂,还需努力.
当满足上面三种情况中任一种时,则需要进行窗口的更新,即重新设置snd_wnd、snd_wl1、snd_wl2三个值.
接下来,tcp_receive函数根据接收数据段的确认序号ackno值进行相关操作.
该值是对方返回的数据确认信息.
TCP控制块中的lastack字段记录了连接收到的上一个确认序号的值,函数比较lastack与ackno值判断该ACK是否为重复的ACK,如果是,则说明网络发生了异常,此时应进行拥塞避免算法或慢启动算法,这里先不涉及这两个算法.
正常情况下ackno应该是处在lastack+1与snd_max之间的,在这种情况下,函数首先设置TCP控制块相关字段的值,其中最重要的是snd_buf、lastack字段,当然还有包括与超时重传、慢启动等相关的字段,这里先不讨论.
函数接下来根据这个ackno来处理控制块的unacked队列,因为unacked队列连接的是发送后而未被确认的数据段,因此,当收到ackno后,应该遍历unacked队列,将数据段中所有数据编号都小于等于ackno的数据段移除.
当所有满足要求的数据段移除成功后,函数应检查unacked队列是否为空,若是,则停止重传定时器,否则复位重传定时器.
接下来函数还要根据这个ackno去处理控制块的unsent队列,Thismayseemstrange(源码注释中这样说)!
因为对于需要重传的TCP段,LWIP是直接将他们挂在unsent队列上的,所以收到确认后,可能是对已经发生了超时的数据段的确认,所以函数会遍历unsent队列,将数据段中所有数据编号都小于等于ackno的数据段移除,当然ackno的合法性在这种情况下也是一个考虑因素,就是ackno不能大于snd_max的值,即已经发送了的最大数据序号.
接下来,如果ackno确认了正在进行RTT估计的数据段,则RTT的估计在此处进行.
控制块的rtseq字段记录了正在进行RTT估计的数据段的数据序号.
关于RTT,放在后面讨论.
再接下来,函数要对收到的数据段中的数据进行处理了,前面说过了,seqno记录了接收到的数据的起始序号,tcplen表示数据的长度,对于SYN包和FIN包,该长度要加1,这两个全局变量在这部分中起着很重要的作用.
接收窗口与接收数据密切相关,控制块中的三个重要字段rcv_nxt、rcv_wnd与rcv_ann_wnd,分别表示期望接收的下一字节编号、接收窗口大小、向对方通告的接收窗口大小.
对数据的处理主要做了以下三件事:如果收到的数据段编号包含rcv_nxt,则说明这个数据段是顺序到达的段,这个段可被直接递交给上层;E-mail:for_rest@foxmail.
com老衲五木出品否则这个数据段需要按照其数据编号被连接在ooseq链表上,在链表上插入数据段的过程中,可能出现数据重叠的现象,此时需要将重复的数据移除;最后,会检查ooseq链表上的第一个数据是否是顺序到达的段,若是,则将该数据段提交给上层.
首先,若数据起始编号seqno并不恰恰是rcv_nxt,而是比rcv_nxt大,同时数据段中又有数据的编号是rcv_nxt,此时需要将数据段截断,使其起始编号恰好是rcv_nxt.
若seqno小于rcv_nxt,且数据段中所有数据的编号均小于rcv_nxt,说明这是一个重复的数据段,对这类重复数据段只是响应一个ACK包给源端,并不做其他处理.
接下来,判断数据段是否在接收窗口内(seqno的值可能已在上一步中被调整了),接收窗口的起始字节序号分别是rcv_nxt与rcv_nxt+rcv_ann_wnd–1.
若在窗口内,则可以根据数据段在窗口内的位置对数据进行进一步的处理.
如果数据段处在窗口的起始位置,即seqno等于rcv_nxt,则说明该数据段是连续到来的数据段,可以直接把它递交给上层.
但这里LWIP不是直接将这段数据交给上层的,而是将数据段挂接在ooseq链表的第一个位置,由于ooseq链表上的其他已挂接的数据段可能因为这个数据段的到来而变为有序(可以这样认为,若比某个数据段起始编号小的数据都到了,则该数据段就是有序的了,可以递交给上层),这样,协议就可以将尽可能多的数据递交给上层.
数据段被插入到ooseq链表的第一个位置后,可能遇到链表上第一个和第二个数据段数据重叠的情况,此时需要将第一个数据的尾部截断,以避免数据的重复.
全局变量recv_data指针用来指向可以上上层递交的数据包,所以,到这里tcp_receive函数将recv_data指向第一个数据段中的数据,并设置好rcv_nxt、rcv_wnd、rcv_ann_wnd的值,接下来,遍历ooseq链表后续的数据段,将所有有序的数据都挂接到recv_data指针上,挂接完成后,向源端返回一个确认包.
实际源码实现和上面的过程完全相同,但是实现上和上述有一点小差别,从上面的描述可以看出,被插入到第一个位置的数据段会马上被取下来挂到recv_data指针上,所以这个插入操作是没有必要实现的,事实也如此.
如果数据段不是处在窗口的起始位置,则向源端返回一个立即确认数据段,并将该数据段挂接到ooseq链表上,挂接数据段主要根据数据的编号在链表中选择一个合适的位置插入,同时如果插入操作可能出现数据重叠的现象,则需要将数据进行截断操作.
想起了伪代码这个词,就用伪代码来描述一下这个过程:ZSL=1;For(从ooseq取下第ZSL个数据段;该数据段不为空;ZSL++){if该数据段起始编号==要插入的数据段起始编号if要插入的数据段更长用要插入的数据段代替第ZSL个数据段;删除第ZSL个数据段;if插入后的数据段与后一个数据段有数据重合将插入数据段的尾部截断;end跳出循环;else跳出循环;endelseifZSL==1//即是第一个数据段if该数据段起始编号>要插入的数据段起始编号将新数据段插入到第一个位置;若与后一个数据段有数据重合,则截断数据段尾部;跳出循环;E-mail:for_rest@foxmail.
com老衲五木出品endelse//不是第一个数据段if要插入的数据段起始编号在第ZSL-1个和第ZSL个数据段起始编号之间将数据段插入到第ZSL位置上;若与后一个数据段有数据重合,则截断数据段尾部;若与前一个数据段有数据重合,则截断前一个数据段尾部;跳出循环;endendif第ZSL个数据段是链表上最后一个数据段将新数据段插在链表尾上;若与前一个数据段有数据重合,则截断前一个数据段尾部;跳出循环;endend}回过神来,如果数据段落在窗口之外,则直接向源端返回一个立即确认数据段.
到这里函数tcp_receive就完成了,如果它将新的数据挂接到了数据指针recv_data上,则该函数返回一个1,否则返回0.
E-mail:for_rest@foxmail.
com老衲五木出品21TCP滑动窗口滑动窗口滑动窗口滑动窗口前面讨论了TCP层所有基础性的东西,这里开始讲支撑TCP功能的各种TCP机制,包括滑动窗口机制、TCP定时器、RTT估计与超时重传机制、慢启动与拥塞避免算法、快速恢复与重传、Nagle算法等.
先说滑动窗口是怎样实现的,前面在TCP控制块中,我们已经涉及到了与发送和接收窗口相关的字段,这里再来看看.
发送窗口相关字段有:snd_wl1、snd_wl2、snd_nxt、snd_max、lastack、snd_lbb、snd_wnd.
That'stoomany!
!
snd_wl1和snd_wl2字段与窗口的更新密切相关,它们分别表示上一次窗口更新时所收到的数据段中的seqno和ackno.
当接收到新的数据段时,是否需要提取数据段中的窗口通告并进行窗口更新,就要看snd_wl1和snd_wl2字段的值与数据段中的seqno和ackno间的大小关系了,如果满足窗口更新条件,则发送窗口被更新;字段snd_wnd保存了窗口大小.
lastack字段表示最后一个有效确认的ackno;snd_nxt表示下一个将要发送的数据起始编号;snd_max表示表示发送了的最大数据序号+1(这貌似和snd_nxt的值一样,但为什么要用snd_max字段,表示不解),snd_lbb表示在下一个将被缓存且在发送窗口内的数据编号.
后面三个字段太纠结了,看看下面的图理解可能好理解一点.
snd_max和snd_nxt的值相同,从lastack到snd_nxt之间的数据是已经发送出去但未被确认的,它们被挂接在控制块的unacked链表上,从snd_nxt到snd_lbb间的数据是已经被应用程序递交给协议栈,但并未被协议栈发送出去的数据,这些数据挂接在控制块的unsent链表上,从snd_lbb到窗口末端表示可用的发送缓冲,但应用层还未递交数据,下一次应用层递交的数据将从snd_lbb开始编号.
接收窗口有三个字段rcv_nxt、rcv_wnd、rcv_ann_wnd,其中rcv_nxt表示下一个将要接收的数据序号,rcv_wnd表示接收窗口的大小,rcv_ann_wnd表示向发送方通告的窗口大小.
接收窗口相对于发送窗口来说更简单,我们也在上图中加以了描述.
那么TCP连接两端的滑动窗口是如何实现的呢现在以客户机与服务器之间连接建立过程来看看滑动窗是如何工作的.
(1)服务器端:首先调用函数tcp_alloc分配一个新的TCP控制块,在函数tcp_alloc中,控制块的rcv_wnd和rcv_ann_wnd被初始化为默认大小TCP_WND,snd_wl2、snd_nxt、snd_max、lastack、snd_lbb五个字段被初始化为ISS(ISS可以看成一个系统全局变量,其值随时间变化),这里假设这五个字段都被初始化为ZSL1,即表示上次窗口更新时收到的确E-mail:for_rest@foxmail.
com老衲五木出品认号为ZSL1,最后一个有效确认的数据编号是ZSL1,下一个将要发送的数据编号为ZSL1,下一个将被缓存的数据编号是ZSL1;然后调用函数tcp_bind进行端口的绑定,这一步不涉及窗口相关字段的操作;接下来,服务器调用函数tcp_listen_with_backlog进入监听状态,此时会新建一个tcp_pcb_listen结构来保存TCP控制块的相关字段,并把tcp_pcb_listen结构放入链表tcp_listen_pcbs,此时这个TCP控制块就进入了LISTEN状态等待客户端的连接.
(2)客户端:前几步与服务器相同,首先调用函数tcp_alloc分配一个新的TCP控制块,将控制块的rcv_wnd和rcv_ann_wnd被初始化为默认大小TCP_WND,snd_wl2、snd_nxt、snd_max、lastack、snd_lbb五个字段被初始化为自身的ISS(初始序列号),这里假设这五个字段都被初始化为ZSL2,即表示上次窗口更新时收到的确认号为ZSL2,最后一个有效确认的数据编号是ZSL2,下一个将要发送的数据编号为ZSL2,下一个将被缓存的数据编号是ZSL2;然后调用函数tcp_bind进行端口的绑定,这一步不涉及窗口相关字段的操作;接下来,客户端调用函数tcp_connect向服务器端发起连接,在tcp_connect函数中,rcv_nxt字段置为0,snd_nxt为ZSL2,lastack为ZSL2-1,snd_lbb为ZSL2-1,rcv_wnd和rcv_ann_wnd被置为默认大小TCP_WND,接收窗口snd_wnd被置为默认大小TCP_WND.
接下来,tcp_enqueue函数被调用来发送一个SYN数据包,这就是前面讲过的内容了,在函数tcp_enqueue中,数据包被组装后挂在unsent队列上,数据包的起始编号被设置为snd_lbb的值ZSL2-1(*),然后调整发送窗口的相关字段,如下面的代码,if((flags&TCP_SYN)||(flags&TCP_FIN)){//发送SYN或FIN包被认为数据长度为1++len;}pcb->snd_lbb+=len;//下一个要被缓冲数据的序号,注意与snd_nxt不同所以,tcp_enqueue函数过后,snd_lbb值变为ZSL2,其他字段值不变.
tcp_connect函数接下来还调用tcp_output将数据包发送出去,后者发送一个具体的数据段是通过调用函数tcp_output_segment实现的,这个函数主要是填充待发送数据段的TCP头部中的确认序号为rcv_nxt的值0(*),通告窗口大小为rcv_ann_wnd的值TCP_WND(*).
最后,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;//最大发送序号}即snd_nxt和snd_max的值都变为ZSL2.
综上,客户端发送一个SYN包后进入SYN_SENT状态,此时控制块中窗口相关的各个字段rcv_nxt为0,snd_nxt为ZSL2,lastack为ZSL2-1,snd_lbb为ZSL2,rcv_wnd和rcv_ann_wnd为默认大小TCP_WND,发送窗口snd_wnd为默认大小TCP_WND;发出去的SYN包中的seqno为ZSL2-1,ackno为0,通告窗口为TCP_WND.
(3)服务器端:处于LISTEN状态的控制块接收到SYN包后,调用函数tcp_listen_input处理数据包,由于该数据包是SYN包,所以需要重新建立一个控制块,并将tcp_pcb_listen结构中的内容拷贝出来,并把控制块加入tcp_active_pcbs链表中.
在这里,控制块的窗口相关字段被填充:rcv_nxt=seqno+1=ZSL2-1+1=ZSL2,snd_wl1=seqno–1=ZSL2-2(设为减2是为了保证下次接到数据段后能进行窗口更新).
rcv_wnd和rcv_ann_wnd为默认大小TCP_WND,snd_wl2、snd_nxt、snd_max、lastack、snd_lbb都维持不变,为ZSL1.
接下来服务器调用函数tcp_enqueue组装数据包挂在unsent队列上,数据包的起始编号被设置为snd_lbb的值ZSL1(*),然后调整发送窗口的相关字段,如下面的代码,if((flags&TCP_SYN)||(flags&TCP_FIN)){//发送SYN或FIN包被认为数据长度为1E-mail:for_rest@foxmail.
com老衲五木出品++len;}pcb->snd_lbb+=len;//下一个要被缓冲数据的序号,注意与snd_nxt不同所以,tcp_enqueue函数过后,snd_lbb值变为ZSL1+1,其他字段值不变.
接下来调用tcp_output将数据包发送出去,与客户端类似,填充待发送数据段的TCP头部中的确认序号为rcv_nxt的值ZSL2(*),通告窗口大小为rcv_ann_wnd的值TCP_WND(*).
最后,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;//最大发送序号}即snd_nxt和snd_max的值都变为ZSL1+1.
综上,服务器发送一个SYN+ACK后进入SYN_RCVD状态,此时控制块中窗口相关的各个字段rcv_nxt为ZSL2,snd_nxt为ZSL1+1,lastack为ZSL1,snd_lbb为ZSL1+1,rcv_wnd和rcv_ann_wnd为默认大小TCP_WND,发送窗口snd_wnd为默认大小TCP_WND;发出去的SYN+ACK包中的seqno为ZSL1,ackno为ZSL2,通告窗口为TCP_WND.
(4)客户端:处于SYN_SENT状态的客户端调用函数tcp_process处理服务器返回的SYN+ACK包:rcv_nxt=seqno+1=ZSL1+1;lastack=ackno=ZSL2;snd_wl1=seqno–1=ZSL1-1(这样设置snd_wl1使得下次接到数据段后能进行窗口更新).
到这里,客户端调用函数tcp_ack_now向服务器端返回一个立即确认数据包,tcp_ack_now也是通过调用函数tcp_output将数据包发送出去,这里注意,我们并没有调用函数tcp_enqueue组装一个数据包,而是直接在tcp_output组装发送的,这里发送出去的数据包seqno=(pcb->snd_nxt)=ZSL2,ackno=(pcb->rcv_nxt)=ZSL1+1,注意立即确认数据包不包含任何数据,也不占任何数据长度,所以snd_nxt字段值是保持不变的.
发送完ACK包后,客户端进入ESTABLISHED状态.
(5)服务器端:处在SYN_RCVD状态的服务器端接收到客户端返回的确认后,调用函数tcp_process处理这个数据包.
这个过程很简单,没有涉及窗口相关字段的操作,只是将控制块置为ESTABLISHED状态.
到这里,一条连接就建立起来了,来看看两端的窗口情况.
服务器端:rcv_nxt为ZSL2,snd_nxt为ZSL1+1,lastack为ZSL1,snd_lbb为ZSL1+1,窗口大小默认;客户端:rcv_nxt为ZSL1+1,snd_nxt为ZSL2,lastack为ZSL2,snd_lbb为ZSL2,窗口大小默认.
比较上面的值,可以看出,两端的滑动窗口都正确的建立起来了.
从整个过程来看,一片混乱,毫无规律.
当两端的连接进入ESTABLISHED后,窗口的调整就相当规律了,函数tcp_receive和rcv_nxt密切相关,因为收到数据段会利用该函数进行处理;tcp_enqueue函数和snd_lbb字段密切相关,用以缓存数据包;tcp_output函数和snd_nxt密切相关,该函数用于向网络中发送数据包;tcp_output_segment函数主要填充发送包的ackno字段.
E-mail:for_rest@foxmail.
com老衲五木出品22TCP超时与重传超时与重传超时与重传超时与重传在TCP两端交互过程中,数据和确认都有可能丢失.
TCP通过在发送时设置一个定时器来解决这种问题.
如果当定时器溢出时还没有收到确认,它就重传该数据.
对任何TCP协议实现而言,怎样决定超时间隔和如何确定重传的频率是提高TCP性能的关键.
这节讲解TCP的超时重传机制,TCP控制块tcp_pcb内部的相关字段为rtime、rttest、rtseq、sa、sv、rto、nrtx,太多了,先不要晕!
与超时时间间隔密切相关的是往返时间(RTT)的估计.
RTT是某个字节的数据被发出到该字节确认返回的时间间隔.
由于路由器和网络流量均会变化,因此RTT可能经常会发生变化,TCP应该跟踪这些变化并相应地改变其超时时间.
在某段时间内发送方可能会连续发送多个数据包,但发送方只能选择一个发送包启动定时器,估计其RTT值,另外,一个报文段被重发和该报文的确认到来之前不应该更新估计器.
协议中利用一些优化算法平滑RTT的值,并根据RTT值设置RTO的值,即下一个数据包的重传超时时间.
先来看看超时重传机制是怎样实现的,再来重点介绍与RTT估计密切相关的部分.
前面讲过tcp_output从unsent队列上取下第一个数据段,并调用函数tcp_output_segment将数据段发送出去,发送完毕后,tcp_output将该数据段挂接到unacked队列上,至于挂在unacked队列上的什么位置,那是后话.
tcp_output_segment负责将数据段发送出去,发送出去后它要做的工作如下面的代码所示:if(pcb->rtime==-1)pcb->rtime=0;if(pcb->rttest==0){pcb->rttest=tcp_ticks;pcb->rtseq=ntohl(seg->tcphdr->seqno);}rtime用于重传定时器的计数,当其值为-1时表示计数器未被使能;当值为非0时表示计数器使能,在这种情况下,rtime的值每500ms被内核加1,当rtime超过rto的值时,在unacked队列上的所有数据段将被重传.
rto已经提及过多次,就是我们为数据包所设置的超时重传时间.
接下来的rttest字段与RTT估计密切相关,当rttest值为0时表示RTT估计未启动,否则若要启动RTT估计,则应在发送数据包出去后,将rttest的值设置为tcp_ticks(全局变量,系统当前滴答数),并用rtseq字段记录要进行RTT估计的数据段的起始数据编号.
当接收到对方返回的ACK编号后,就可以根据rttest与rtseq的值计算RTT了,字段sa、sv与rto值的计算密切相关,放在后续讨论.
数据段发送就是这么多了,主要是针对发送出去的数据段启动重传定时器.
当然如过数据段发送出去的时候,重传定时器是启动的,即rtime不等于-1,此刻不对重传定时器做任何操作.
同理,如果rttest不等于0,则说明RTT正在进行,此时不会对RTT的各个字段做任何操作.
TCP慢定时器每500ms产生一次中断处理,在中断处理中,若TCP控制块的重传计数器被启动(即rtime不为0),则rtime值被加1.
同时,当rtime值大于rto时,调用重传函数对未被确认的数据进行重传,代码如下(仅列出了与重传相关的部分):if(pcb->unacked!
=NULL&&pcb->rtime>=pcb->rto){……E-mail:for_rest@foxmail.
com老衲五木出品pcb->rtime=0;//复位计数器tcp_rexmit_rto(pcb);//函数调用进行重传…….
}tcp_rexmit_rto函数实现重传的机制很简单,它将unacked链表上的所有数据段插入到unsent队列的前端,并将控制块重传次数字段nrtx加1,最后调用tcp_output重发数据包.
这里你可能会发现一个问题,假设我们刚刚从unsent队列上取下一个数据段发送出去,并将该数据段挂接在unacked链表上等待确认,接着前面某个数据段处设置的重传定时器超时,这样整个unacked链表上又被放到了unsent队列上进行重传.
不可避免,我们刚刚发送出去的那个数据段又回到了unsent队列上,这岂不是悲剧,它又得重发一遍尽管这种情况是可能发生的,但是LWIP通过窗口的控制以及收到确认号后遍历unsent队列(下面讲解)这两种方式使得这种可能性降到了最小.
这里注意,在较老版本的LWIP协议栈中,每个数据段结构tcp_seg中都对应有一个rtime字段,用于记录某个数据段的超时情况,这样可以避免重传时将整个unacked链表上放回到unsent队列上.
而新版本的中,整个TCP控制块公用了一个rtime字段.
在数据接收上,tcp_receive函数提取收到的数据段中的ackno,并用该ackno来处理unacked队列,即当该ackno确认了某个数据段中的所有数据,则将该数据段从unacked队列中移除,并释放数据段占用的空间.
同时,函数要检查unacked队列,如果unacked队列中没有被需要确认的数据段了,此时需要停止重传定时器,否则要复位重传定时器.
很简单,用下面的代码:if(pcb->unacked==NULL)pcb->rtime=-1;elsepcb->rtime=0;接下来,tcp_receive函数还要根据收到的确认号遍历unsent队列,以处理那些正被等待重传的数据段.
unsent队列上那些是被重传的数据段很明显就是数据段内数据编号小于控制块snd_max字段值的那些数据段.
能被确认号确认的数据段会从unsent链表中移除,同时数据段占用的空间被释放.
关于数据段的超时设置与重传就是这么多了,下面到了很重要的内容,即RTT的估计.
这里的代码有点难度啊!
先来看看《TCP/IP详解1》里面是怎样描述RTT的.
TCP超时与重传中最重要的部分就是对一个给定连接的往返时间(RTT)的测量.
由于路由器和网络流量均会变化,因此我们认为这个时间可能经常会发生变化,TCP应该跟踪这些变化并相应地改变其超时时间.
TCP必须测量在发送一个带有特别序号的字节和接收到包含该字节的确认之间的RTT,同时应注意,发出去的数据段与返回的确认之间并没有一一对应的关系.
在往返时间变化起伏很大时,基于均值和方差来计算RTO能提供更好的响应,下面这个算法是Jacobson提出的,目前广泛应用在了TCP协议的实现中,当然LWIP也不例外.
E-mail:for_rest@foxmail.
com老衲五木出品其中M表示某次测量的RTT的值,A表示测得的RTT的平均值,A值的更新如第二式所示,D值为RTT的估计的方差,其更新如第三式所示.
二式和三式中g和h都为常数,一般g取1/8,h取1/4.
这样取值是为了便于计算,从后面可以看出,通过简单的移位操作就可以完成上述计算了.
RTO的计算第四式所示,初始时,RTO取值为6,即3s,A值为0,D值为6.
现在我们将上面的四个表达式做简单的变化,就得到了LWIP中计算RTT的表达式:Err=M-AA=A+Err/8=A+(M-A)/8——>8A=8A+M-AD=D+(|Err|-D)/4=D+(|M-A|-D)/4——>4D=4D+(|M-A|-D)RTO=A+4D令sa=8A,sv=4D,这就是TCP控制块中的两个字段.
带入上面变换后的表达式,得到:sa=sa+M-sa>>3sv=sv+(|M-sa>>3|-sv>>2)RTO=sa>>3+sv这样我们就得到了最关心的RTO值,还有一个疑问:M值怎么得到M表示某次测量的RTT的值,在LWIP中它就是系统当前tcp_ticks值减去数据包被发送出去时的tcp_ticks值.
tcp_ticks也是在内核的500ms周期性中断处理中被加1.
如果到这里你都还很清醒,那说明对RTT的理解就没什么问题了.
这就来看看源代码是怎样进行RTT估算的,这也是在函数tcp_receive中进行的.
if(pcb->rttest&&TCP_SEQ_LT(pcb->rtseq,ackno)){//有RTT正在进行且该数据段被确认m=(s16_t)(tcp_ticks-pcb->rttest);//计算M值m=m-(pcb->sa>>3);//M-sa>>3pcb->sa+=m;//更新saif(m>3|}m=m-(pcb->sv>>2);//(|M-sa>>3|-sv>>2)pcb->sv+=m;//更新svpcb->rto=(pcb->sa>>3)+pcb->sv;//计算rtopcb->rttest=0;//停止RTT估计}这段代码基本是前面讲的公式的翻译了,不解释!
还有这样一个问题,当以某个RTO为超时值发送数据包后,在RTO时间后未收到对该数据段的确认,则该数据包被重发,若重发后仍收不到关于该数据包的确认,这种情况下,协议栈该怎么办呢,是每次都按照原来的RTO重发数据包吗答案是否定的,因为当多次重传都失败时,很可能是网络不通或者网络阻塞,如果这时再有大量的重发包被投入到网络,这势必使问题越来越严重,可能数据包永远的被阻塞在网络中,而无法到达目的端.
与标准里面描述的一样,LWIP是这样做的:如果重发的数据包超时,则接下来的重发包必须按照2的指数避让,即将RTO值设置为前一次的2倍,当重发超过一定次数后,不再对数据包进行重发.
这是在500ms定时处理函数tcp_slowtmr中完成的,看看源代码.
if(pcb->rtime>=0)//若重传定时器是开启的++pcb->rtime;//则增加定时器的值if(pcb->unacked!
=NULL&&pcb->rtime>=pcb->rto){//如果定时器超时,且有数据未确认if(pcb->state!
=SYN_SENT){//对处于SYN_SENT状态的数据不做避让处理E-mail:for_rest@foxmail.
com老衲五木出品pcb->rto=((pcb->sa>>3)+pcb->sv)nrtx];//rto值退让}pcb->rtime=0;//复位定时器……tcp_rexmit_rto(pcb);//重传数据}上面这小段代码有三个地方想说一下:一是对处于SYN_SENT状态的控制块不进行超时时间的避让,可能是由于考虑到SYN_SENT状态一般发送出去的是SYN握手包,每次按照固定的RTO值进行重发,为什么要这样呢不解,貌似标准里面也是进行避让了的啊!
第二点是避让使用一个数组tcp_backoff通过移位的方式实现,tcp_backoff定义如下:constu8_ttcp_backoff[13]={1,2,3,4,5,6,7,7,7,7,7,7,7};在这里面,当重传次数多于6次时,RTO值将不再进行避让.
最后一点是函数tcp_rexmit_rto,该函数真正完成数据包的重传工作:voidtcp_rexmit_rto(structtcp_pcb*pcb){structtcp_seg*seg;if(pcb->unacked==NULL){return;}for(seg=pcb->unacked;seg->next!
=NULL;seg=seg->next);//将unacked队列全部放到seg->next=pcb->unsent;//将unsent队列前端pcb->unsent=pcb->unacked;pcb->unacked=NULL;pcb->snd_nxt=ntohl(pcb->unsent->tcphdr->seqno);//下一个要发送的数据编号指向队列//unsent首部的数据段++pcb->nrtx;//重传次数加1pcb->rttest=0;//重发数据包期间不进行RTT估计tcp_output(pcb);//发送一个数据包}从这个代码和上面的避让算法里你可以很清楚的看到字段nrtx的作用了,它是多次重传时设置rto值的重要变量.
另外注意,在重传期间不应该进行RTT估计,因为这种情况下的估计值往往是不准确的.
这就是传说中的Karn算法,Karn算法认为由于某报文即将重传,则对该报文的计时也就失去了意义.
即使收到了ACK,也无法区分它是对第一次报文,还是对第二次报文的确认.
因此,TCP只对未重传报文计时.
还有一个要注意,经过上面的代码,snd_nxt的值很可能就会小于snd_max的值咯,相信你隐约中感觉到snd_max的作用了吧,哈哈,收工!
E-mail:for_rest@foxmail.
com老衲五木出品23TCP慢启动与拥塞避免慢启动与拥塞避免慢启动与拥塞避免慢启动与拥塞避免这节讨论慢启动算法与拥塞避免算法.
版权声明:下面这几段的内容大部分来自于《TCP/IP详解,卷1:协议》,若有雷同,纯非巧合!
假如按照前面所有的讨论,发送方一开始便可以向网络发送多个报文段,直至达到接收方通告的窗口大小为止.
当发送方和接收方处于同一个局域网时,这种方式是可以的.
但是如果在发送方和接收方之间存在多个路由器和速率较慢的链路时,就有可能出现一些问题.
中间路由器缓存分组,可能消耗巨大的时间和存储器空间开销,这样TCP连接的吞吐量会严重降低.
要解决这种问题,TCP需要使用慢启动(slowstart)算法.
该算法通过观察新分组进入网络的速率应该与另一端返回确认的速率相同而进行工作.
慢启动为发送方的TCP增加了另一个窗口:拥塞窗口(congestionwindow),记为cwnd.
当与另一个网络的主机建立TCP连接时,拥塞窗口被初始化为1个报文段(即另一端通告的报文段大小).
每收到一个ACK,拥塞窗口就增加一个报文段.
发送方取拥塞窗口与通告窗口中的最小值作为发送上限.
拥塞窗口是发送方使用的流量控制,而通告窗口则是接收方使用的流量控制.
发送方开始时发送一个报文段,然后等待ACK.
当收到该ACK时,拥塞窗口从1增加为2,即可以发送两个报文段.
当收到这两个报文段的ACK时,拥塞窗口就增加为4.
这是一种指数增加的关系.
说到慢启动算法,就不得不说拥塞避免算法,在实际中这两个算法通常是在一起实现.
通常慢启动算法是发送方控制数据流的方法,但有时发送方的数据流会达到中间路由器的极限,此时分组将被丢弃.
拥塞避免算法是一种处理丢失分组的方法.
该算法假定由分组受到损坏引起的丢失是非常少的(远小于1%),因此在发送方看来,分组丢失就意味着在源主机和目的主机之间的某处网络上发生了拥塞.
发送方可以通过两种方式来判断分组丢失:发生数据包发送超时和接收到重复的确认.
当拥塞发生时,我们希望降低分组进入网络的传输速率,发送方通过慢启动与拥塞避免算法主动调节分组进入网络的速率,同时发送方也通过接收方通告的窗口大小来被动调节分组发送速率.
拥塞避免算法和慢启动算法需要对每个连接维持两个变量:一个拥塞窗口cwnd和一个慢启动门限ssthresh.
这样算法一般按照如下过程进行:1)对一个给定的连接,初始化cwnd为1个报文段,ssthresh为65535个字节.
2)TCP输出例程的输出不能超过cwnd和接收方通告窗口的大小.
拥塞避免是发送方使用的流量控制,而通告窗口则是接收方进行的流量控制.
前者是发送方感受到的网络拥塞的估计,而后者则与接收方在该连接上的可用缓存大小有关.
3)当拥塞发生时(超时或收到重复确认),ssthresh被设置为当前窗口大小的一半(cwnd和接收方通告窗口大小的最小值,但最少为2个报文段).
此外,如果是超时引起了拥塞,则cwnd被设置为1个报文段(这就是慢启动).
4)当新的数据被对方确认时,就增加cwnd,但增加的方法依赖于我们是否正在进行慢启动或拥塞避免.
如果cwnd小于或等于ssthresh,则正在进行慢启动,否则正在进行拥塞避免.
慢启动一直持续到我们回到当拥塞发生时所处位置的一半时候才停止(因为我们记录了在步骤2中给我们制造麻烦的窗口大小的一半),然后转为执行拥塞避免.
慢启动算法初始设置cwnd为1个报文段,此后每收到一个确认就加1,这会使窗口按E-mail:for_rest@foxmail.
com老衲五木出品指数方式增长:发送1个报文段,然后是2个,接着是4个…….
拥塞避免算法要求每次收到一个确认时将cwnd增加1/cwnd.
与慢启动的指数增加比起来,这是一种加性增长(additiveincrease).
我们希望在一个往返时间内最多为cwnd增加1个报文段(不管在这个RTT中收到了多少个ACK),然而慢启动将根据这个往返时间中所收到的确认的个数增加cwnd.
说了半天用一句话来概括慢启动算法和拥塞避免算法:每个TCP控制块有两个字段cwnd和ssthresh,当数据发送时,发送方只能取cwnd和接收方通告窗口大小中的较小者作为发送上限.
当有确认返回时,若此时cwnd值小于等于ssthresh,则做慢启动算法,即每收到一个确认,cwnd都加1;若此时cwnd值大于ssthresh,则做拥塞避免算法,即每收到一个确认,cwnd都加1/cwnd,这保证了在一个RTT估计内,cwnd增加值不超过1.
当cwnd增加到某个值时拥塞发生,则按照3)所示来更新cwnd和ssthresh的值.
晕,MS不是一句话,是一段话!
这里我们按照上面算法所述的步骤来看看LWIP具体是怎样来描述整个过程的.
1)给定一个连接,初始化其cwnd与ssthresh.
cwnd都被初始化为1,而ssthresh字段在客户端与服务器端略有不同,在客户端执行主动连接,调用函数tcp_connect,在其中完成相关字段值的设置:pcb->cwnd=1;pcb->ssthresh=pcb->mss*10;pcb->state=SYN_SENT;在发送出SYN包并收到服务器的返回SYN+ACK后,客户机在函数tcp_process中对相关字段进行进一步的设置:pcb->ssthresh=pcb->mss*10;pcb->cwnd=((pcb->cwnd==1)(pcb->mss*2):pcb->mss);而在服务器端,进行被动连接,在函数tcp_listen_input中设置相关字段的值:npcb->snd_wnd=tcphdr->wnd;npcb->ssthresh=npcb->snd_wnd;可以看到在服务器端,ssthresh被设置为了对方通告的接收窗口大小.
2)TCP输出例程的输出不能超过cwnd和接收方通告窗口的大小.
在tcp_output函数中是用下面的代码来发送数据段的:wnd=LWIP_MIN(pcb->snd_wnd,pcb->cwnd);//取有效发送窗口大小值seg=pcb->unsent;//取得第一个发送数据段……while(seg!
=NULL&&ntohl(seg->tcphdr->seqno)-pcb->lastack+seg->lenunsent=seg->next;tcp_output_segment(seg,pcb);//则发送数据段……seg=pcb->unsent;//取得下一个发送数据段}3)发生拥塞时,需要更新cwnd和ssthresh的值.
若拥塞是由超时引起的,则相应的更新代码在tcp_slowtmr中被实现,如下,这段熟悉的代码我们在超时重传中也见到过:if(pcb->rtime>=0)++pcb->rtime;if(pcb->unacked!
=NULL&&pcb->rtime>=pcb->rto){//超时发生……//与超时重传相关的部分E-mail:for_rest@foxmail.
com老衲五木出品eff_wnd=LWIP_MIN(pcb->cwnd,pcb->snd_wnd);//取得超时的有效发送大小pcb->ssthresh=eff_wnd>>1;//ssthresh设置为有效大小的一半if(pcb->ssthreshmss){//修正ssthresh至少为2个报文段pcb->ssthresh=pcb->mss*2;}pcb->cwnd=pcb->mss;//设置cwnd大小为1个报文段大小}若拥塞是由重复确认引起的,则相应的更新代码在tcp_receive函数中实现,这段代码涉及到快速恢复与重传部分的知识,我们重点放在后面讲解.
if(pcb->lastack==ackno){…………if(pcb->cwnd>pcb->snd_wnd)//ssthresh设置为有效发送窗口的一半pcb->ssthresh=pcb->snd_wnd/2;elsepcb->ssthresh=pcb->cwnd/2;if(pcb->ssthreshmss){//修正ssthresh至少为2个报文段pcb->ssthresh=2*pcb->mss;}pcb->cwnd=pcb->ssthresh+3*pcb->mss;//这里不解……}4)当新的数据被对方确认时,更新cwnd和ssthresh的值.
if(TCP_SEQ_BETWEEN(ackno,pcb->lastack+1,pcb->snd_max)){……if(pcb->state>=ESTABLISHED){if(pcb->cwndssthresh){//小于的时候执行慢启动,标准里是小于等于哦if((u16_t)(pcb->cwnd+pcb->mss)>pcb->cwnd){//能否增加pcb->cwnd+=pcb->mss;//增加一个段大小}}else{//以下执行拥塞避免u16_tnew_cwnd=(pcb->cwnd+pcb->mss*pcb->mss/pcb->cwnd);if(new_cwnd>pcb->cwnd){//能否增加pcb->cwnd=new_cwnd;}}}……}这段代码基本就是上面所说的第四步的直接翻译了.
需要注意的是new_cwnd为什么要用上面的等式计算出来呢那是因为cwnd的大小是按照mss的大小为单位增加减少的,其他不解释.
E-mail:for_rest@foxmail.
com老衲五木出品24TCP快速恢复重传和快速恢复重传和快速恢复重传和快速恢复重传和Nagle算法算法算法算法前面介绍过,在收到一个失序的报文段时,该报文段会被挂接到ooseg队列上,同时向发送端返回一个ACK(期待的下一个字节),很明显,这个ACK一定是个重复的ACK,且这个重复的ACK被发送出去的时候不会有任何延迟.
接收端利用该重复的ACK,目的在于让对方知道收到一个失序的报文段,并告诉对方自己希望收到的序号.
但是在发送方看来,它不可能知道一个重复的ACK是由一个丢失的报文段引起的,还是由于仅仅出现了几个报文段的重新排序引起.
因此我们需要等待少量重复的ACK到来.
假如这只是一些报文段的重新排序,则在重新排序的报文段被处理并产生一个新的ACK之前,只可能产生1~2个重复的ACK.
如果一连串收到3个或3个以上的重复ACK,就非常可能是一个报文段丢失了.
于是我们就重传丢失的数据报文段,而无需等待超时定时器溢出.
这就是快速重传算法.
在上节讲超时重传时说到,当超时发生后,ssthresh会被设置为有效发送窗口的一半,而cwnd被设置为一个报文段大小,即执行的是慢启动算法.
而在这里,当执行完快速重传后,接下来执行的不是慢启动算法而是拥塞避免算法,这就是所谓的快速恢复算法了.
在快速重传后没有执行慢启动的原因在于,由于收到重复的ACK不仅仅告诉我们一个分组丢失了.
而且由于接收方只有在收到另一个报文段,并将该报文段挂接到ooseg队列后,才会产生重复的ACK,这就说明,在收发两端之间仍然有流动的数据,而我们不想执行慢启动来突然减少数据流.
卷一中描述的该算法步骤如下:1)当收到第3个重复的ACK时,将ssthresh设置为当前拥塞窗口cwnd的一半.
重传丢失的报文段.
设置cwnd为ssthresh加上3倍的报文段大小.
2)每次收到另一个重复的ACK时,cwnd增加1个报文段大小并发送1个分组(如果新的cwnd允许发送).
3)当下一个确认新数据的ACK到达时,设置cwnd为ssthresh(在第1步中设置的值).
这个ACK应该是在进行重传后的一个往返时间内对步骤1中重传的确认.
另外,这个ACK也应该是对丢失的分组和收到的第1个重复的ACK之间的所有中间报文段的确认.
LWIP也是在函数tcp_receive中实现快速恢复与重传的,如下所示,整个过程与上面算法所述基本相同.
if(pcb->lastack==ackno){//如果该ACK是个重复的ACKpcb->acked=0;//则被该ACK确认的数据个数为0if(pcb->snd_wl1+pcb->snd_wnd==right_wnd_edge){//如果未进行窗口更新++pcb->dupacks;//收到重复确认的次数加1if(pcb->dupacks>=3&&pcb->unacked!
=NULL){//如1)所述,三个以上充复ACKif(!
(pcb->flags&TF_INFR)){//此时快速重传未开启,即dupacks为3次tcp_rexmit(pcb);//调用函数重传丢失的报文段if(pcb->cwnd>pcb->snd_wnd)//ssthresh设置为有效发送窗口的一半pcb->ssthresh=pcb->snd_wnd/2;elsepcb->ssthresh=pcb->cwnd/2;if(pcb->ssthreshmss){//修正ssthresh值,最小为2个报文段pcb->ssthresh=2*pcb->mss;}E-mail:for_rest@foxmail.
com老衲五木出品pcb->cwnd=pcb->ssthresh+3*pcb->mss;//cwnd为ssthresh+3*报文段大小pcb->flags|=TF_INFR;//设置快速重传标志}else//快速重传已经开始,即dupacks大于3次{if((u16_t)(pcb->cwnd+pcb->mss)>pcb->cwnd)//快速重传已经开始,如2)pcb->cwnd+=pcb->mss;//每收到一个重复ACK,cwnd增加1个报文段大}//这与2)中描述的有区别,这里收到重复ACK后没有发送1个分组}//ifdupacks大于3}//if如果未进行窗口更新}//if如果是重复的ACKelseif(TCP_SEQ_BETWEEN(ackno,pcb->lastack+1,pcb->snd_max)){//确认了新的数据if(pcb->flags&TF_INFR){//正处于快速重传状态,pcb->flags&=~TF_INFR;//清除快速重传标志pcb->cwnd=pcb->ssthresh;//如3)所示,设置cwnd的值}…….
pcb->dupacks=0;//清除重复确认标志pcb->lastack=ackno;//记录ackno…….
}上面这段代码有两个地方需要说明一下:一是调用函数tcp_rexmit重传丢失的报文段,这个函数和上一节讲到的函数tcp_rexmit_rto相类似,都是重传数据包.
这个函数功能是将unacked队列上的第一个数据段放到unsent队列首部,并调用函数tcp_output输出数据包.
第二个需要注意的地方是:在收到三个以上的重复ACK后,代码只是将cwnd的值增加一个报文段大小,而没像向上面2)中所述的那样发送一个数据包.
快速重传与恢复就这么多了,下面是Nagle算法部分.
基于窗口的流量控制方案,会导致一种被称为"糊涂窗口综合症SWS(SillyWindowSyndrome)"的状况.
当TCP接收方通告了一个小窗口并且TCP发送方立即发送数据填充该窗口时,SWS就会发生,当一个小的报文段被确认,窗口再一次以较小单元被打开而发送方将再一次发送一个小的报文段填充这个窗口.
这样就会造成TCP数据流包含一些非常小的报文段情况的发生,而不是满长度的报文段.
糊涂窗口综合症是一种能够导致网络性能严重下降的TCP现象,因为小单元的数据段中IP头部和TCP头部这些字段占了大部分空间,而真正的TCP数据却很少.
该现象可能由TCP连接两端中的任何一端引起.
这是由于接收方可以通告一个小的窗口(而不是一直等到有大的窗口时才通告),而发送方也可以发送少量的数据(而不是等待其他的数据以便发送一个大的报文段).
为了避免SWS的发生,在发送方和接收方必须设法消除这种情况.
接收方不必通告小窗口更新,并且发送方在只有小窗口提供时不必发送小的报文段.
可以在任何一端采取措施避免出现糊涂窗口综合症的现象.
接收方解决SWS的方法是接收方不通告小窗口.
通常的算法是接收方不通告一个比当前窗口大的窗口(可以为0),除非窗口可以增加一个报文段大小(也就是将要接收的MSS)或者可以增加接收方缓存空间的一半,不论实际有多少.
发送方避免出现糊涂窗口综合症的措施是只有以下条件之一满足时才发送数据:E-mail:for_rest@foxmail.
com老衲五木出品(a)可以发送一个满长度的报文段;(b)可以发送至少是接收方通告窗口大小一半的报文段;(c)可以发送任何数据并且不希望接收ACK(也就是说,我们没有还未被确认的数据)或者该连接上不能使用Nagle算法.
条件(b)主要对付那些总是通告小窗口(也许比1个报文段还小)的主机,它要求发送方始终监视另一方通告的最大窗口大小,这是一种发送方猜测对方接收缓存大小的企图.
虽然在连接建立时接收缓存的大小可能会减小,但在实际中这种情况很少见.
条件(c)使我们在有尚未被确认的数据(正在等待被确认)以及在不能使用Nagle算法的情况下,避免发送小的报文段.
如果应用进程在进行小数据的写操作(例如比该报文段还小),条件(c)可以避免出现糊涂窗口综合症.
这三个条件也可以让我们回答这样一个问题:在有尚未被确认数据的情况下,如果Nagle算法阻止我们发送小的报文段,那么多小才算是小呢从条件(a)中可以看出所谓"小"就是指字节数小于报文段的大小MSS.
在LwIP中,SWS在发送端就被自然的避免了,因为TCP报文段在建立和排队时不知道通告的接收器窗口.
在大数据量发送中,输出队列将包括最大尺寸的报文段.
这意味着,如果TCP接收方通告了一个小窗口,发送方将不会发送队列中的第一个报文段,因为它比通告的窗口要大.
相反,它会一直等待直至窗口有足够大的空间容下它.
当作为TCP接收方时,LwIP将不会通告小于连接允许的最大报文段尺寸的接收器窗口(这点未懂).
来看看LWIP在数据段发送的时候是如何让来避免糊涂窗口的,下面的代码经过一定的删减,只保留了与算法相关的部分.
seg=pcb->unsent;//取得第一个数据段while(seg!
=NULL&&ntohl(seg->tcphdr->seqno)-pcb->lastack+seg->lenflags&(TF_NAGLEMEMERR|TF_FIN))==0)){//有内存错误标志和break;//FIN包标志时,nagle算法失效}……pcb->unsent=seg->next;//记录下一个数据段tcp_output_segment(seg,pcb);//发送数据段……seg=pcb->unsent;//取得下一个数据段}这短短的几句就实现了nagle算法有太多需要解释!
第一个是while的循环条件里面,它要求将要发送的数据段内所有数据序号都必须在有效发送窗口内.
这样的话,如果对方通告了一个小窗口,且发送的数据段很大的话则数据段不会被发送出去,这可以看作是上述条件(a)和条件(b)的变形.
对于其他情况,则可以利用Nagle算法来判断是否输出数据包.
同时如果flags的TF_NAGLEMEMERR和TF_FIN标志置位时,Nagle算法失效,即数据包不会被Nagle算法阻止.
TF_NAGLEMEMERR可以理解为表示内存错误,它是在tcp_enqueue函数组装数据包时出现发送缓存空间不足时被置位的,当这种情况发生时,已经组装好的数据包需要被尽快发送出去,所以Nagle算法在该标志置位时失效;TF_FIN标志置位时说明上层应用发出了关闭连接命令,所以此时也应尽快将该连接上的数据段发送出去,Nagle算法在这种情况下也失效.
Nagle算法是通过宏tcp_do_output_nagle来实现的,如下:#definetcp_do_output_nagle(tpcb)((((tpcb)->unacked==NULL)||\E-mail:for_rest@foxmail.
com老衲五木出品((tpcb)->flags&TF_NODELAY)||\(((tpcb)->unsent!
=NULL)&&((tpcb)->unsent->next!
=\NULL)))1:0)不要被如此多的括号吓着,我们只关心上式等于0,即Nagle算法阻止数据包发送时的情况.
具体为unacked队列不为空,且Nagle算法已使能(TF_NODELAY未置位),且unsent发送队列上有少于两个的待发送数据段时,tcp_do_output_nagle取值为0,tcp_output跳出while循环,不进行任何数据段的发送.
上面这段也可以按照(c)的说法来描述,当没有还未被确认的数据(unacked队列为空),或者Nagle算法未使能,或者unsent队列上有两个或两个以上的数据段时,数据段可以被发送出去.
这里比(c)中多了一个unsent队列上数据包个数的限制,此时我们可以把这个多出的限制看作是对条件(a)和条件(b)的另一种解释.
很多情况下需要禁止Nagle算法,这是通过设置控制块flags字段中的TF_NODELAY标志来实现的.
前面说过,接收方解决SWS的方法是不通告小窗口.
但是若使用LwIP作为接收端,它貌似还未实现此功能,即它可能向发送端通告小窗口信息.
难道我还没看懂.
.
.
E-mail:for_rest@foxmail.
com老衲五木出品25TCP坚持与保活定时器坚持与保活定时器坚持与保活定时器坚持与保活定时器这节讲解TCP的坚持定时器和保活定时器,先看坚持定时器.
TCP的接收方通过通告窗口大小来告诉发送方自己可以接收的数据字节数,接收方采用这种方式来进行流量控制.
假如接收方通告的窗口大小为0会发生什么情况呢这将有效地阻止发送方传送数据,直到通告窗口变为非0为止.
发送方接到0窗口通告时,则会停止数据段的发送,直到接收方通过非0的窗口.
很重要的一点,TCP必须能够处理含新非0窗口通告的数据包丢失的情况,通常这个非0窗口通告是在一个不含任何数据的ACK包中发送的.
ACK的传输并不可靠,也就是说,TCP不对ACK报文段进行确认(很明显,也就不会存在该ACK报文段的重发),TCP只确认那些包含有数据的ACK报文段.
如果一个确认丢失了,则双方就有可能因为等待对方而使连接终止:接收方等待接收数据(因为它已经向发送方通告了一个非0的窗口),而发送方在等待允许它继续发送数据的非0窗口更新.
为防止这种死锁情况的发生,发送方使用一个坚持定时器(persisttimer)来周期性地向接收方查询,以便发现窗口是否已增大.
这些从发送方发出的报文段称为窗口探查(windowprobe).
控制块中有两个字段与坚持定时器有关:persist_cnt和persist_backoff.
persist_cnt用于坚持定时器计数,当计数值超过某个值时,则发出窗口探查数据包.
persist_backoff表示坚持定时器是否被启动(是否>0)以及已经发出去了几个探查数据包(persist_backoff为大于0的整数时).
若坚持定时器已经被启动,则在内核500ms中断处理函数tcp_slowtmr会进行如下处理:if(pcb->persist_backoff>0){//如果坚持定时器已经开启pcb->persist_cnt++;//增加计数值if(pcb->persist_cnt>=tcp_persist_backoff[pcb->persist_backoff-1]){//计数值超过//某个计数值上限时则进行窗口探查pcb->persist_cnt=0;//复位计数值if(pcb->persist_backoffpersist_backoff++;}tcp_zero_window_probe(pcb);//发送一个窗口探查包}}有两点需要提及的.
一是数组tcp_persist_backoff,它保存了一系列的坚持定时器的计数值上限,persist_backoff是该数组的索引.
即发送第一个探测包的时间为3次500ms(1.
5s)中断后,发送第二个探测包的时间为6次(3s)后,当第六次及其以上发送探测包时,时间间隔都变为120次中断(60s).
constu8_ttcp_persist_backoff[7]={3,6,12,24,48,96,120};再来看看函数tcp_zero_window_probe是如何进行窗口探查的.
tcp_zero_window_probe函数很简单,组装一个含一字节数据的TCP报文段发送出去,这个字节的数据是从unacked或从unsent队列上取得的,且窗口探查包的数据序号字段被填成这个字节的数据序号.
所以当这两个队列都为空时,则表示没有任何数据需要处理,当然窗口探查也没有必要进行;当E-mail:for_rest@foxmail.
com老衲五木出品这个字节的数据是从unacked队列中得到时,由于该队列是已经被发送过的,对应窗口探查到达接收端时,会被看做是重复报文而不进行相关数据处理,只向发送方是返回一个ACK包;当这个字节的数据是从unsent队列中得到时,则这个字节数据到达接收端时会被挂接在ooseq队列或直接递交给上层应用,当发送方窗口允许时,unsent队列中的第一个数据段的第一个字节被发送后在接收方看来是重复的,接收方能够检测出这个重复的字节,并直接删除该字节数据.
从整个过程可以看出,窗口探查包里面的1字节数据并不影响整个数据传输过程.
什么时候启动一个窗口探查呢这是在函数tcp_output最后完成的.
当发送完能够发送的数据段后,unsent队列还不为空,且此时窗口探查未启动,且当前窗口太小以至不能发送下一个数据段,此时要启动窗口探查.
if(seg!
=NULL&&pcb->persist_backoff==0&&ntohl(seg->tcphdr->seqno)-pcb->lastack+seg->len>pcb->snd_wnd){pcb->persist_cnt=0;//复位计数值pcb->persist_backoff=1;//开始窗口探查}什么时候停止一个窗口探查呢从前面已经知道,在函数tcp_receive刚开始的部分,就会根据接收数据包的情况更新发送窗口,也即是在这里若检测到一个非0窗口,则停止窗口探查,如下所示.
if(TCP_SEQ_LT(pcb->snd_wl1,seqno)||(pcb->snd_wl1==seqno&&TCP_SEQ_LT(pcb->snd_wl2,ackno))||(pcb->snd_wl2==ackno&&tcphdr->wnd>pcb->snd_wnd)){//若满足窗口跟新条件pcb->snd_wnd=tcphdr->wnd;//窗口更新pcb->snd_wl1=seqno;pcb->snd_wl2=ackno;if(pcb->snd_wnd>0&&pcb->persist_backoff>0){检测到非0窗口且探查开启pcb->persist_backoff=0;//停止窗口探查}}再来看保活定时器.
如果一个已经处于稳定状态的TCP连接双方都没有向对方发送数据,则在两个TCP模块之间不交换任何信息.
然而很多时候,连接的双方都希望知道对方的是否处于非活动状态.
常见的状况是一个服务器希望知道客户主机是否崩溃并关机或者崩溃又重新启动,许多TCP/IP实现中提供的保活定时器可以提供这种检测功能.
保活功能主要是为服务器应用程序提供的.
服务器应用程序希望知道客户主机是否崩溃,从而可以合理分配客户使用资源.
如果一个给定的连接在两个小时之内没有任何动作,则服务器就向客户发送一个探查报文段.
客户主机必处于以下4个状态之一:1)客户主机依然正常运行,并从服务器可达.
客户的TCP响应正常,而服务器也知道对方是正常工作的.
服务器在两小时以后将保活定时器复位,并发送探查报文.
如果在两个小时定时器到时间之前有应用程序的通信量通过此连接,则定时器在交换数据后的未来2小时再复位,发送探查报文.
2)客户主机已经崩溃,并且关闭或者正在重新启动,在这些情况下,客户的TCP都不会有任何响应.
服务器将不能够收到对探查报文的响应,并在等待75秒后超时,以后服务器还会发送9个这样的探查报文,每个间隔75秒.
如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接.
3)客户主机崩溃并已经重新启动.
这时服务器将收到一个对其保活探查的响应,但是这个E-mail:for_rest@foxmail.
com老衲五木出品响应是一个复位,使得服务器终止这个连接.
4)客户主机正常运行,但是从服务器不可达.
这与状态2相同,因为TCP不能够区分状态4与状态2之间的区别,它所能发现的就是没有收到探查的响应.
在第1种情况下,服务器的应用程序没有感觉到保活探查的发生.
TCP层负责一切,这个过程对应用程序都是不可见的.
当第2、3或4种情况发生时,服务器应用程序将收到来自它的TCP层的差错报告(通常服务器应用程序向网络发出了读操作请求,然后等待来自客户的数据.
如果保活功能返回一个差错,则该差错将作为读操作的返回值返回给应用程序).
在第2种情况下,差错是诸如"连接超时"之类的信息,而在第3种情况则为"连接被对方复位".
第4种情况看起来像是连接超时,也可根据是否收到与连接有关的ICMP差错报文来判断是否是目的不可达引起的.
至此又会涉及TCP控制块中四个字段:keep_idle、keep_intvl、keep_cnt和keep_cnt_sent.
其中keep_intvl和keep_cnt与编译选项LWIP_TCP_KEEPALIVE相关,当该编译选项为1时,keep_intvl和keep_cnt字段分别用于保存用户自定义的保活时间选项值,这点在后面介绍.
实际应用中使用系统默认的保活时间选项值即可,所以我们将LWIP_TCP_KEEPALIVE设置为0,则keep_intvl和keep_cnt字段不会被编译,自然也不在我们的讨论范围之内了.
keep_idle字段记录了在多久后进行保活探测,一般为2小时,keep_cnt_sent字段表示已经发送的保活数据包的个数.
除了这两个字段外,还有几个与默认保活时间选项值宏定义:#defineTCP_KEEPIDLE_DEFAULT7200000UL//保活时间毫秒数(2小时)#defineTCP_KEEPINTVL_DEFAULT75000UL//连续保活包的时间间隔毫秒数(75s)#defineTCP_KEEPCNT_DEFAULT9U//保活包被重复发送的次数#defineTCP_MAXIDLETCP_KEEPCNT_DEFAULT*TCP_KEEPINTVL_DEFAULT//执行保活探测需要消耗的时间用户可以通过宏LWIP_TCP_KEEPALIVE允许keep_intvl和keep_cnt字段,它们分别用于记录用户自定义的保活包时间间隔与保活包个数.
当不使用自定义值时,就用上面的两个DEFAULT值作为保活选项值.
在这里,我们使用系统默认的保活选项值来分析保活的整个过程,此时字段keep_idle被设置为TCP_KEEPIDLE_DEFAULT的值.
保活处理也是在内核500ms中断处理函数tcp_slowtmr中进行的.
TCP控制块中还有一个字段要重新提及一下,即tmr记录了该TCP连接上最近一个数据段到来时的系统时间tcp_ticks值.
if((pcb->so_options&SOF_KEEPALIVE)&&//如果开启了保活功能,稳定数据交互状态((pcb->state==ESTABLISHED)||(pcb->state==CLOSE_WAIT))){if((u32_t)(tcp_ticks-pcb->tmr)>//2小时+9*75秒后断开连接(pcb->keep_idle+TCP_MAXIDLE)/TCP_SLOW_INTERVAL){tcp_abort(pcb);//断开连接}elseif((u32_t)(tcp_ticks-pcb->tmr)>//在2小时+9*75秒内则发送保活包(pcb->keep_idle+pcb->keep_cnt_sent*TCP_KEEPINTVL_DEFAULT)/TCP_SLOW_INTERVAL){tcp_keepalive(pcb);//发送保活包pcb->keep_cnt_sent++;//保活包次数加1}}E-mail:for_rest@foxmail.
com老衲五木出品tcp_abort函数用于释放一个连接,主要工作包括将控制块从相应的TCP链表中删除,若该连接上还有数据则释放数据所占用的内存空间,最后向对方发送一个RST数据包.
tcp_keepalive函数用于发送一个保活包,保活包只是一个TCP首部,并不包含任何数据,所以不会对对方的数据接收造成影响.
关于保活选项的两个小时的空闲时间是可以改变.
用户只要自己定义keep_idle的值就可以了,但是系统一般不建议修改这些值.
Don'tchangethisunlessyouknowwhatyou'redoing!
哈哈.
.
.
E-mail:for_rest@foxmail.
com老衲五木出品26TCP定时器定时器定时器定时器这节讨论TCP的定时处理函数.
在前面的讨论中,我们看到了与TCP的各种定时器,包括重传定时器、持续定时器和保活定时器,此外TCP中还有几个定时器我们还未涉及.
这里总的来看看TCP中的各个定时器.
TCP为每条连接总共建立了七个定时器,依次为:1)"连接建立(connectionestablishment)"定时器在发送SYN报文段建立一条新连接时启动.
如果在75秒内没有收到响应,连接建立将中止.
2)"重传(retransmission)"定时器在TCP发送某个数据段时设定.
如果该定时器超时而对端的确认还未到达,TCP将重传该数据段.
重传定时器的值(即TCP等待对端确认的时间)是动态计算的,与RTT的估计值密切相关,且还取决于该报文段已被重传的次数.
3)"延迟ACK(delayedACK)"定时器在TCP收到必须被确认但无需马上发出确认的数据时设定.
如果在200ms内,有数据要在该连接上发送,延迟的ACK响应就可随着数据一起发送回对端,称为捎带确认.
如果200ms后,该确认未能被捎带出去,则定时器超时,此时需要发送一个立即确认.
4)"持续(persist)"定时器在连接对端通告接收窗口为0,阻止TCP继续发送数据时设定.
由于连接对端发送的窗口通告不可靠(只有数据才会被确认,ACK不会被确认),允许TCP继续发送数据的后续窗口更新有可能丢失.
因此,如果TCP有数据要发送,但对端通告接收窗口为0,则持续定时器启动,超时后向对端发送1字节的数据,判定对端接收窗口是否已打开.
5)"保活(keepalive)"定时器在TCP控制块的so_options字段设置了SOF_KEEPALIVE选项时生效.
如果连接的连续空闲时间超过2小时,则保活定时器超时,此时应向对端发送连接探测报文段,强迫对端响应.
如果收到了期待的响应,TCP可确定对端主机工作正常,在该连接再次空闲超过2小时之前,TCP不会再进行保活测试.
如果收到的是RST复位响应,TCP可确定对端主机已重启.
如果连续若干次保活测试都未收到响应,TCP就假定对端主机已崩溃,但它无法区分是主机故障还是连接故障.
6)FIN_WAIT_2定时器,当某个连接从FIN_WAIT_1状态变迁到FIN_WAIT_2状态并且不能再接收任何新数据时,FIN_WAIT_2定时器启动,设为10分钟.
定时器超时后,重新设为75秒,第二次超时后连接被关闭.
加入这个定时器的目的是为了避免如果对端一直不发送FIN,某个连接会永远滞留在FIN_WAIT_2状态(假设TCP不选用半打开功能).
7)TIME_WAIT定时器,一般也称为2MSL定时器.
2MSL指两倍的MSL,即最大报文段生存时间.
当连接转移到TIME_WAIT状态,即连接主动关闭时,定时器启动.
状态转换图那一节中已经详细说明了需要2MSL等待状态的原因.
连接进入TIME_WAIT状态时,定时器设定为1分钟,超时后,TCP控制块被删除,端口号可重新使用.
前面的7个定时器中,重传定时器使用rtime字段计数,持续定时器使用persist_cnt字段计数,其他五个定时器除延迟ACK定时器外都使用rtime字段计数,从上面的描述中可以看出,这四个定时器是TCP处于四种不同的状态时使用的,因此四个定时器完全独立的使用rtime字段而不会互相影响.
延迟ACK定时器使用系统250ms周期性定时来完成的.
LWIP中包括两个定时器函数:一个函数每250ms调用一次(快速定时器);另一个函数每500ms调用一次(慢速定时器).
延迟ACK定时器与其他6个定时器有所不同:如果某个连接上设定了延迟ACK定时器,那么下一次250ms定时器超时后,延迟的ACK必须被发送(实际的ACK延迟时间在0~250ms之间).
其他的6个定时器每500ms增加1,当计数值E-mail:for_rest@foxmail.
com老衲五木出品超过某些阈值时,则相应的动作被触发.
先看简单的快速定时器处理函数:voidtcp_fasttmr(void){structtcp_pcb*pcb;for(pcb=tcp_active_pcbs;pcb!
=NULL;pcb=pcb->next){//遍历整个active链表if(pcb->refused_data!
=NULL){//如果某个控制块还没有数据未接收err_terr;TCP_EVENT_RECV(pcb,pcb->refused_data,ERR_OK,err);//调用上层函数接收数据if(err==ERR_OK){pcb->refused_data=NULL;//成功接收则复位指针}}if(pcb->flags&TF_ACK_DELAY){//若控制块开启了延迟ACK定时器tcp_ack_now(pcb);//发送一个立即确认pcb->flags&=~(TF_ACK_DELAY|TF_ACK_NOW);//清除标志位}}//if}//for从上面可以看出,快速定时器处理函数主要做了两方面的工作,一是向上层递交上层一直未接收的数据,二是发送该连接上的立即确认数据段.
与快速定时器相比,慢速定时器处理函数就显得相当的庞大了,这里我们只列出和各个定时器相关的部分,而对其他部分采用伪代码的方式加以描述,当然,这里所谓的其他部分就是我们在前面已经讲解过的部分了.
voidtcp_slowtmr(void){++tcp_ticks;pcb=tcp_active_pcbs;while(pcb!
=NULL){pcb_remove=0;该控制块的零窗口探查处理;该控制块的超时重传处理;if(pcb->state==FIN_WAIT_2){//FIN_WAIT_2定时器超时if((u32_t)(tcp_ticks-pcb->tmr)>TCP_FIN_WAIT_TIMEOUT/TCP_SLOW_INTERVAL){++pcb_remove;}}该控制块的超时保活处理;#ifTCP_QUEUE_OOSEQif(pcb->ooseq!
=NULL&&//丢弃在ooseq队列中长时间未被处理的数据(u32_t)tcp_ticks-pcb->tmr>=pcb->rto*TCP_OOSEQ_TIMEOUT){tcp_segs_free(pcb->ooseq);pcb->ooseq=NULL;}E-mail:for_rest@foxmail.
com老衲五木出品#endif/*TCP_QUEUE_OOSEQ*/if(pcb->state==SYN_RCVD){//SYN_RCVD状态超时if((u32_t)(tcp_ticks-pcb->tmr)>TCP_SYN_RCVD_TIMEOUT/TCP_SLOW_INTERVAL){++pcb_remove;}}if(pcb->state==LAST_ACK){//LAST_ACK定时器超时if((u32_t)(tcp_ticks-pcb->tmr)>2*TCP_MSL/TCP_SLOW_INTERVAL){++pcb_remove;}}若有超时则删除控制块;无超时则周期性的外发数据包(poll);取得active链表上的下一个控制块;}//whilepcb=tcp_tw_pcbs;//处理TIME-WAIT链表while(pcb!
=NULL){pcb_remove=0;//TIME-WAIT定时器超时if((u32_t)(tcp_ticks-pcb->tmr)>2*TCP_MSL/TCP_SLOW_INTERVAL){++pcb_remove;}若超时则删除控制块;取得TIME-WAIT链表上的下一个控制块;}//while}//函数尾可以看出各个定时器的实现都是利用全局变量tcp_ticks与tmr字段的差值来实现的.
当TCP进入某个状态时,就会将相应tmr字段设置为当前的全局时钟tcp_ticks的值,所以上面的差值可以有效表示出TCP处于某个状态的时间.
各个定时器超时后的处理也很相似,即将变量pcb_remove加1,pcb_remove变量是超时处理中最核心的变量了,当针对某个PCB控制块作完超时判断之后,函数通过判断pcb_remove的值来处理TCP控制块,当pcb_remove值大于1时,则表示该控制块上有超时事件发生,该控制块或被删除或被挂起.
注意伪代码中的重传定时器超时并不会影响pcb_remove的值.
如果细心,你还可以看到,上面的代码多了两个超时事件,即SYN_RCVD状态超时和ooseq队列数据超时,当然,这两个超时事件并不影响协议栈功能的实现.
最后来看看系统为每个超时时间设置的超时时间,从上面的代码中可以看出,它们是在各个宏定义里面实现的.
#defineTCP_TMR_INTERVAL250//250ms#defineTCP_FAST_INTERVALTCP_TMR_INTERVAL//快速定时器#defineTCP_SLOW_INTERVAL(2*TCP_TMR_INTERVAL)//慢速定时器#defineTCP_FIN_WAIT_TIMEOUT20000//FIN_WAIT_2状态超时时间#defineTCP_SYN_RCVD_TIMEOUT20000//SYN_RCVD状态超时时间E-mail:for_rest@foxmail.
com老衲五木出品#defineTCP_OOSEQ_TIMEOUT6U//ooseq队列中数据等待的rto周期数#defineTCP_MSL60000U//MSL到这里,我们的PCB控制块中的各个字段基本都已涉及到了,除了polltmr和pollinterval.
这两个字段用于周期性调用函数tcp_output,用以发送控制块上残留的未发送数据段.
E-mail:for_rest@foxmail.
com老衲五木出品27TCP终结与小结终结与小结终结与小结终结与小结TCP还有最后一点东西需要扫尾.
现在我们要跳出tcp_process函数,继续回到tcp_input中,前面说过,tcp_input接收IP层递交上来的数据包,并根据数据包查找相应TCP控制块,并根据相关控制块所处的状态调用函数tcp_timewait_input、tcp_listen_input或tcp_process进行处理.
如果是调用的前两个函数,则tcp_input在这两个函数返回后就结束了,但若调用的是tcp_process函数,则函数返回后,tcp_input还要进行许多相应的处理.
要继续往下讲就得看看一个很重要的全局变量recv_flags,前面说TCP全局变量的时候也简单说到过.
这个变量与控制块中的flags字段相似,都是用来描述当前TCP控制块的所处状态的.
flags字段可以设置的各个标志位及其意义如下宏定义所示:#defineTF_ACK_DELAY(u8_t)0x01U//延迟回复ACK包#defineTF_ACK_NOW(u8_t)0x02U//立即发送ACK包#defineTF_INFR(u8_t)0x04U//处于快速重传状态#defineTF_FIN(u8_t)0x20U//本地上层应用关闭连接#defineTF_NODELAY(u8_t)0x40U//禁止Nagle算法禁止#defineTF_NAGLEMEMERR(u8_t)0x80U//发送缓存空间不足上面的各个字段基本都已经涉及过了,再来看看全局变量recv_flags可以设置的各个标志位及其意义,如下宏定义所示:#defineTF_RESET(u8_t)0x08U//接收到RESET包#defineTF_CLOSED(u8_t)0x10U//在LAST_ACK状态收到ACK包,连接成功关闭#defineTF_GOT_FIN(u8_t)0x20U//接收到FIN包为什么要用两个字段来描述相应TCP控制块的状态呢,不是很明了个人理解有两个原因:一是由于控制块中的flags字段本身是8位的,若以每位描述一种状态,则不足以描述上面的9种状态;二是上面的9种描述状态很明显可以分为两类,第一类的6种与TCP的数据包处理密切相关,第二类的3种与TCP的状态转换密切相关.
在tcp_input每次调用tcp_process之前,recv_flags都会被初始化为0,在tcp_process的处理中,相关控制块在完成状态转换后,该全局变量与状态转换相关的位则会被置位,在函数返回到tcp_input后,tcp_input还会根据相应设置好的recv_flags值对控制块做后续处理.
if(recv_flags&TF_RESET){//TF_RESET标志表示接收到了对端的RESET包TCP_EVENT_ERR(pcb->errf,pcb->callback_arg,ERR_RST);//若注册了回调函数//则调用该函数通知上层tcp_pcb_remove(&tcp_active_pcbs,pcb);//将控制块从链表中删除memp_free(MEMP_TCP_PCB,pcb);//释放控制块内存空间}elseif(recv_flags&TF_CLOSED){//TF_CLOSED表示服务器成功关闭连接tcp_pcb_remove(&tcp_active_pcbs,pcb);//将控制块从链表中删除memp_free(MEMP_TCP_PCB,pcb);//释放控制块内存空间}else{err=ERR_OK;if(pcb->acked>0){//如果收到的数据包确认了unacked队列中的数据TCP_EVENT_SENT(pcb,pcb->acked,err);//则可调用自定义的函数发送数据包E-mail:for_rest@foxmail.
com老衲五木出品}if(recv_data!
=NULL){//若成功的接收了数据包中的数据if(flags&TCP_PSH){//全局变量flags保存的是TCP头部中的标志字段recv_data->flags|=PBUF_FLAG_PUSH;//将数据包pbuf字段}//设置PUSH标志TCP_EVENT_RECV(pcb,recv_data,ERR_OK,err);//调用自定义的函数接收数据if(err!
=ERR_OK){//若上层接收数据失败pcb->refused_data=recv_data;//用控制块refused_data字段暂存数据}}//if(recv_data!
=NULL)if(recv_flags&TF_GOT_FIN){//如果接收到FIN包TCP_EVENT_RECV(pcb,NULL,ERR_OK,err);//调用自定义的函数接收数据}if(err==ERR_OK){//若处理正常则tcp_output(pcb);//试图往外发数据包}}//else有两个地方需要说一下,首先是函数tcp_pcb_remove,源码如下所示,代码里面比较重要的两个函数是TCP_RMV和tcp_pcb_purge,这里就不再仔细说明了.
voidtcp_pcb_remove(structtcp_pcb**pcblist,structtcp_pcb*pcb){TCP_RMV(pcblist,pcb);//从某链表上移除PCB控制块tcp_pcb_purge(pcb);//清空控制块的数据缓冲队列,释放内存空间if(pcb->state!
=TIME_WAIT&&//如果该控制块上有被延迟的ACK,则立即发送pcb->state!
=LISTEN&&pcb->flags&TF_ACK_DELAY){pcb->flags|=TF_ACK_NOW;tcp_output(pcb);}pcb->state=CLOSED;//置状态}还有个需要注意的地方是回调函数的调用:如上面TCP_EVENT_XXX所示.
在实际应用程序中,我们可以通过回调函数的方式与LWIP内核交互,在初始化一个PCB控制块的时候,可以设定控制块中相应函数指针字段的初始值,包括sent、recv、connected、accept、poll、errf等.
在内核处理中,会在TCP_EVENT_XXX处调用我们预先注册的函数,从而完成应用程序与协议栈之间的交互.
关于应用程序与协议栈间接口的问题,又是一个庞大的工程,也是我们以后会继续讨论的重点.
到这里,TCP部分就基本讲完了,当然TCP层中还有一些东西没有讲到,如tcp_write等函数.
TCP层学习的关键是了解整个TCP层运行的机制,在这个基础上去阅读源代码,应该不会存在什么的问题的.
从《随笔14》到《随笔27》,可以看出TCP的篇幅实在是太多了,实际源代码也是如此,从代码量上看,TCP部分占了整个协议栈代码量的一半左右.
注意,一般讲TCP协议时都会谈到UDP,但在这里我还不想涉及UDP协议,原因是在一E-mail:for_rest@foxmail.
com老衲五木出品般嵌入式产品中,都需要提供有效可靠的网络服务,而UDP的本质特点让其无法满足这一要求.
所以,如果真是要将LWIP用于我们的产品中,则使用的基本是TCP协议,在后续的讲解中,我们会看到应用程序怎样利用LWIP建立一个Web服务器,这使得我们可以远程的通过http访问我们的设备了.
接下来要说的就是协议栈与应用程序间的接口问题了.
E-mail:for_rest@foxmail.
com老衲五木出品28API实现及相关数据结构实现及相关数据结构实现及相关数据结构实现及相关数据结构LwIP的内部机制讲解完了,接下来是应用程序与LwIP之间的结构问题,即应用程序如何使用协议栈内部提供的各种服务.
应用程序可以通过两种方式与协议栈交互:一是直接调用协议栈各个模块函数,二是利用LwIP提供的API函数.
直接调用协议栈内部各模块函数,即要创建一个TCP控制块时则调用函数tcp_new,进行TCP连接时则调用tcp_connect,要接收数据则要向控制块中注册相关的回调函数,等等.
通过这种方式的交互最大的缺点就是代码编写难度大,且代码常常是晦涩难懂,代码的调试也会异常艰辛.
使用得更多的也是我更关注的,就是第二种方式:LwIP的API函数.
用户程序通过简单的API函数调用就可以享用协议栈提供的服务了,这使得应用程序的编写更加容易.
LwIP的API相比BSD实现的API有个明显的特点,即数据在协议栈与应用程序中交互时不用拷贝,这点可以为我们节省大量的时间和内存开销,也说明LwIP更适用于嵌入式这种系统资源受限的系统.
但有个关键是,我们必须有个好的内存管理机制,因为协议栈和应用程序都会对同一片数据进行操作.
LwIP的API的实现主要有两部分组成:一部分驻留在用户进程中,一部分驻留在TCP/IP协议栈进程中.
这两个部分间通过操作系统模拟层提供的进程通信机制(IPC)进行通信,从而完成用户进程与协议栈间的通信,IPC包括共享内存、消息传递和信号量.
通常尽可能多的工作在用户进程内的API部分实现,例如运算量及时间开销大的工作;TCP/IP协议栈进程中的API部分只完成少量的工作,主要是完成进程间的通讯工作.
两部分API之间通过共享内存传递数据,对于共享内存区的描述是采用和pbuf类似的结构来实现.
综上,可以用简单的一段话来描述这种API实现的机制:API函数库中处理网络连接的函数驻留在TCP/IP进程中.
位于应用程序进程中的API函数使用邮箱这种通讯协议向驻留在TCP/IP进程中的API函数传递消息.
这个消息包括需要协议栈执行的操作类型及相关参数.
驻留在TCP/IP进程中的API函数执行这个操作并通过消息传递向应用程序返回操作结果.
很早以前描述过结构pbuf,它是协议栈内部用来描述数据包的一种方式.
这里介绍数据结构netbuf,它是API用来描述数据的一种方式.
netbuf是基于pbuf来实现的,其结构如下所示,很明显,它就是包含了几个典型结构的指针.
structnetbuf{structpbuf*p,*ptr;structip_addr*addr;u16_tport;};注意,这里的netbuf只是相当于一个数据头,而真正保存数据的还是字段p指向的pbuf链表.
字段ptr也是指向该netbuf的pbuf链表,但与p的区别在于:p一直指向pbuf链表中的第一个pbuf结构,而ptr则不然,它可能指向链表中的其他位置,源文档里面把它描述为fragmentpionter,与该指针调整密切相关的函数是netbuf_next和netbuf_first.
还有两个字段addr和port分别表示发出netbuf数据端的IP地址和端口号,这两个字段实际用处似乎不大,API定义了宏netbuf_fromaddr和netbuf_fromport分别用于返回某个netbuf结构中这两个字段的值.
与netbuf相关的处理函数很多,在源文档15.
2节中对每个函数也有了详细的说明,这E-mail:for_rest@foxmail.
com老衲五木出品里说说比较重要的几个.
netbuf_new用于分配一个新的netbuf结构,注意这里只是一个头部结构,而真正需要的存储数据区域是在函数netbuf_alloc中分配的,同理函数netbuf_delete用于删除一个netbuf结构,同时函数pbuf_free会被调用,用以删除数据区域的空间.
以上这几个函数使用的简单例子如下:structnetbuf*buf;//申明指针buf=netbuf_new();//申请新的netbufnetbuf_alloc(buf,200);//为netbuf分配200字节的空间.
.
.
.
//使用buf做相关事情netbuf_delete(buf);//删除buf讲过了API的内存管理,再来讲具体的API函数.
与前面的对应,API函数由两部分组成,分别在文件api_lib.
c和api_msg.
c中.
前者包括了用户程序直接调用的API接口函数,后者包括了与协议栈进程通信的API函数,用户程序不可直接调用.
这两部分API函数之间通过邮箱传递的消息进行通信.
这里又要涉及到两个重要的数据结构:API应用接口部分提供给上层应用以描述一个网络连接的数据结构netconn,以及描述两部分API函数间传递的消息结构的api_msg.
应用程序要使用API函数建立一个连接连接,首先应该建立一个netconn的结构来描述这个连接的各种属性.
netconn结构如下,其中去掉了不必要的编译选项和注释.
structnetconn{enumnetconn_typetype;//连接的类型,包括TCP,UDP等enumnetconn_statestate;//连接的状态union{//共用体,内核用来描述某个连接structip_pcb*ip;structtcp_pcb*tcp;structudp_pcb*udp;structraw_pcb*raw;}pcb;err_terr;//该连接最近一次发生错误的编码sys_sem_top_completed;//用于两部分API间同步的信号量sys_mbox_trecvmbox;//接收数据的邮箱sys_mbox_tacceptmbox;//服务器用来接受外部连接的邮箱intsocket;//该字段只在socket实现中使用u16_trecv_avail;//structapi_msg_msg*write_msg;//对数据不能正常处理时,保存信息intwrite_offset;//同上,表示已经处理数据的多少netconn_callbackcallback;//回调函数,在发生与该netconn相关的事件时可以调用};write_msg是api_msg_msg类型的指针,该结构后续讲到;netconn_callback是API定义的一种函数指针类型,其定义为:typedefvoid(*netconn_callback)(structnetconn*,enumnetconn_evt,u16_tlen);关键字typedef的功能是定义新的类型.
这里它用于定义一种netconn_callback的类型,这种类型为指向某种函数的指针,且这种函数有三个类型的输入参数,返回类型为void.
在这定义以后就可以像使用int,char一样使用netconn_callback,也即它也表示一种数据类型了.
所以上面的callback字段表示一个函数指针,当把某个函数名赋给该字段后,该字段就记录了这个函数的起始地址.
E-mail:for_rest@foxmail.
com老衲五木出品netconn的其他字段后面用到时再详解.
接下来看看两部分API函数间传递的消息结构的api_msg:structapi_msg{void(*function)(structapi_msg_msg*msg);//函数指针structapi_msg_msgmsg;//函数执行时需要的参数}字段function是一个函数指针,通常当消息被构造时,该字段被填充为api_msg.
c中的某个与协议栈接口的API函数,然后该消息被投递到协议栈进程中进行处理,协议栈进程解析出并执行该函数,这样应用程序与协议栈之间的通信就完成了.
字段msg中包含了这个函数执行时需要的所有参数,api_msg_msg是个枚举类型,所以对于不同的function,msg有着不同的结构.
api_msg_msg结构如下所示:structapi_msg_msg{structnetconn*conn;//与消息相关的某个连接union{structnetbuf*b;//函数do_send的参数struct{//函数do_newconn的参数u8_tproto;}n;struct{//函数do_bind和do_connect的参数structip_addr*ipaddr;u16_tport;}bc;struct{//函数do_getaddr的参数structip_addr*ipaddr;u16_t*port;u8_tlocal;}ad;struct{//函数do_write的参数constvoid*dataptr;intlen;u8_tapiflags;}w;struct{//函数do_recv的参数u16_tlen;}r;}msg;};这个结构体只包含了两个字段:描述连接信息的conn和枚举类型msg.
在api_msg_msg中保存conn字段是必须的,因为conn结构中包含了与该连接相关的信箱和信号量等信息,协议栈进程要用这些信息来完成与应用进程间的同步与通信.
枚举类型msg的各个成员与调用它的函数密切相关.
因此在构建一个消息时,API应首先填充消息的function字段,并针对特定的function种类往api_msg_msg的枚举字段写入参数,函数function被协议栈解析执行时,直接按照自己已定的格式取参数.
这意味着,对于构造消息的API函数和解析消息的function函数,它们对参数个数、结构的认识是预先约定的,不能有其他变数.
这一点E-mail:for_rest@foxmail.
com老衲五木出品体现出LwIP呆板僵硬的一面.
29API消息机制消息机制消息机制消息机制现在有必要来看看前面一直提到的内核协议栈进程是什么样子的.
这个函数叫tcpip_thread,其源码如下,其中去掉了不相关的编译选项和非重点讨论部分.
staticvoidtcpip_thread(void*arg){structtcpip_msg*msg;sys_timeout(IP_TMR_INTERVAL,ip_reass_timer,NULL);//创建IP分片重装超时事件sys_timeout(ARP_TMR_INTERVAL,arp_timer,NULL);//创建ARP超时事件while(1){//进程循环sys_mbox_fetch(mbox,(void*)&msg);//阻塞在邮箱上接收要处理的消息switch(msg->type){//判断消息类型caseTCPIP_MSG_API://若是API消息,调用消息内部的function函数msg->msg.
apimsg->function(&(msg->msg.
apimsg->msg));break;caseTCPIP_MSG_INPKT://若是接收到IP层递交的数据包if(msg->msg.
inp.
netif->flags&NETIF_FLAG_ETHARP)//支持ARPethernet_input(msg->msg.
inp.
p,msg->msg.
inp.
netif);//先进行ARP处//理,再判断是否递交IP层处理else//否则直接递交给IP层ip_input(msg->msg.
inp.
p,msg->msg.
inp.
netif);memp_free(MEMP_TCPIP_MSG_INPKT,msg);break;.
.
.
.
.
.
default:break;}//switch}//while}//邮箱mbox是协议栈初始化时建立的用于tcpip_thread接收消息的邮箱,该函数能够识别的消息类型是tcpip_msg结构的,所以不管是API部分还是IP数据包输入部分,都必须将自己的信息封装成tcpip_msg结构.
structtcpip_msg{enumtcpip_msg_typetype;//枚举结构,消息类型sys_sem_t*sem;//信号量指针,该字段似乎没怎么被用到union{//共用体,不同消息类型使用不同的结构structapi_msg*apimsg;//API消息指针structnetifapi_msg*netifapimsg;//不讨论struct{//接收到IP层数据包相关指针structpbuf*p;structnetif*netif;}inp;E-mail:for_rest@foxmail.
com老衲五木出品struct{//不讨论void(*f)(void*ctx);void*ctx;}cb;struct{//不讨论u32_tmsecs;sys_timeout_handlerh;void*arg;}tmo;}msg;//枚举类型的名字};这个结构和上节讨论的api_msg_msg有着很相似,api_msg_msg里面也包含了一个叫做的msg的结构体.
枚举型tcpip_msg_type的内部成员就是在函数tcpip_thread中看到的TCPIP_MSG_API和TCPIP_MSG_INPKT等,这里只讨论这两种类型.
API函数内部调用来向内核进程发送消息的函数叫tcpip_apimsg,该函数填充一个TCPIP_MSG_API类型的tcpip_msg结构,并把该结构投递到协议栈阻塞的邮箱mbox:err_ttcpip_apimsg(structapi_msg*apimsg){structtcpip_msgmsg;//定义一个消息变量if(mbox!
=SYS_MBOX_NULL){msg.
type=TCPIP_MSG_API;//消息类型msg.
msg.
apimsg=apimsg;//使用tcpip_msg中的msg.
apimsg字段记录相关信息sys_mbox_post(mbox,&msg);//投递消息sys_arch_sem_wait(apimsg->msg.
conn->op_completed,0);//阻塞,等待内核处理完毕returnERR_OK;}returnERR_VAL;}底层向内核进程递交接收到的IP数据包是通过调用网络接口结构netif中指针input指向的函数来实现的(参见前面netif描述的部分).
通常这个函数是tcpip_input.
err_ttcpip_input(structpbuf*p,structnetif*inp){structtcpip_msg*msg;//定义了一个消息指针if(mbox!
=SYS_MBOX_NULL){msg=memp_malloc(MEMP_TCPIP_MSG_INPKT);//为新的消息申请空间if(msg==NULL){returnERR_MEM;}msg->type=TCPIP_MSG_INPKT;//消息类型msg->msg.
inp.
p=p;//使用tcpip_msg中的msg.
inp字段记录相关信息msg->msg.
inp.
netif=inp;if(sys_mbox_trypost(mbox,msg)!
=ERR_OK){//投递一次消息memp_free(MEMP_TCPIP_MSG_INPKT,msg);//投递不成功则删除消息returnERR_MEM;E-mail:for_rest@foxmail.
com老衲五木出品}returnERR_OK;}returnERR_VAL;}哎,这个过程是十分的复杂纠结啊,下面这个图可能更容易理解.
这里函数tcpip_thread还是只处理TCPIP_MSG_API和TCPIP_MSG_INPKT这两种类型的消息.
从上面的图中可以清晰的看出两部分API函数之间的交互过程,以及应用程序和内核函数之间的交互过程.
API函数netconn_xxx在文件api_lib.
c中,而API实现的另一部分函数do_xxx在api_msg.
c中.
接下来的一节我们将精力集中在api_lib.
c中的各个函数,而不去过多关心api_msg.
c中的各个do_xxx是怎样与内核函数交互完成相关工作的.
netconn_xxx函数在LwIP说明文档16小节也有相关的描述,这里打算再重复的描述下各个函数的功能以及它们的实现过程.
E-mail:for_rest@foxmail.
com老衲五木出品30API函数及编程实例函数及编程实例函数及编程实例函数及编程实例函数netconn_new用来创建一个新的连接结构.
连接结构的类型可以选择为TCP或UDP等.
函数结构原型如下所示,参数type描述了连接的类型,可以为NETCONN_TCP或NETCONN_UDP等,这里都以TCP作为讨论的对象.
structnetconn*netconn_new(enumnetconn_typetype)该函数首先调用netconn_alloc函数分配并初始化一个netconn结构.
初始化的过程包括设置netconn结构类型字段,同时为该结构的op_completed创建一个信号量、recvmbox字段创建一个接收邮箱.
奇怪的是netconn_alloc函数并不是在文件api_lib.
c文件中,而是在api_msg.
c中,凌乱!
接下来函数netconn_new会构建一个api_msg消息,该消息要求内核执行函数do_newconn,最后函数tcpip_apimsg用来将消息包装成tcpip_msg结构并发送出去.
tcpip_thread函数解析该消息并调用函数do_newconn,do_newconn根据参数的类型最终调用函数tcp_new创建一个TCP控制块.
tcpip_apimsg会阻塞在一个信号量上,直至do_newconn释放该信号量.
函数netconn_delete用来删除一个连接结构netconn.
与前面的流程相同,它通过消息告诉内核执行do_delconn,调用tcp_close函数关闭TCP连接.
而后netconn_delete调用netconn_free函数释放netconn结构的空间.
注意这里的netconn_free函数netconn_alloc函数一样,也不是在文件api_lib.
c文件中,而是在api_msg.
c中,尽管他们都是netconn_xxx结构.
netconn_bind用于将一个IP地址及端口号与结构netconn绑定.
事实上,内核是通过函数do_bind调用tcp_bind完成相应TCP控制块得绑定工作的.
netconn_connect函数一般在客户端调用,用以将服务器端的IP地址和端口号与本地的netconn结构绑定.
该函数与内核tcp_connect函数对应.
netconn_listen函数一般在服务器端调用,用于将某个TCP控制块置为LISTEN状态.
类似的函数do_listen会被调用,该函数有两个重要的工作:为结构netconn字段acceptmbox创建邮箱,该邮箱用于接受外部连接;向相应TCP的PCB控制块中accept字段注册一个回调函数accept_function,当该PCB上有新连接出现时,回调函数会被调用,以向上面的acceptmbox邮箱中发送消息,告诉应用程序有新的连接到来,新连接的信息以netconn结构形式被保存在了邮箱中.
netconn_accept函数在服务器上使用,用于接收远端的连接,该函数主要在阻塞在上面所述的acceptmbox邮箱上,当接收到新的连接后,在该邮箱上取下连接的netconn结构并返回.
netconn_recv函数用于接收数据,接收到得数据被封装为netbuf结构.
这里内核函数tcp_recved会被协议栈调用,以通知内核数据被正常接收,内核因此调整发送窗结构,返回ACK确认等.
函数netconn_write用于向相应的TCP连接上发送数据,主要这个函数只用于发送TCP数据,用于发送UDP数据的函数叫netconn_send,这里先不讨论.
netconn_write函数原型如下,它用于将dataptr指向的size个数据放到连接conn的发送队列上,apiflags用于描述err_tnetconn_write(structnetconn*conn,constvoid*dataptr,intsize,u8_tapiflags)对该数据的操作,包括是否拷贝,是否立即发送两种选择.
最后netconn_close函数用于主动关闭连接.
E-mail:for_rest@foxmail.
com老衲五木出品API函数就说这么一点点了.
下面我们用这些API函数构造一个服务器程序.
这个服务器程序很简单,它能响应一个客户端的连接和数据请求,并向客户端发送一个固定字符串.
任务代码如下:constuint8data[]="helloworld!
";//待发送字符串voidmytcp_thread(void*arg){structnetconn*conn,*newconn;//API描述的连接结构structnetbuf*buf;//API数据缓冲conn=netconn_new(NETCONN_TCP);//创建新的TCP连接结构netconn_bind(conn,NULL,7);//该连接与端口7绑定netconn_listen(conn);//将结构置为侦听状态newconn=netconn_accept(conn);//接收到一个新的连接while(1){buf=netconn_recv(newconn);//在新连接上接收到一个数据netbuf_delete(buf);//删除接收到的数据netconn_write(newconn,data,sizeof(data),NETCONN_COPY);//将字符串发送的客户端}}服务器程序之所以要这样设计是为了测试的方便,因为手上恰好有个小程序可以用来测试这个服务器程序以及我们的LwIP协议栈运转是否正常.
这个小程序是当年参加中兴编程大赛的时候写的,名字叫报文监视器.
它能接收某个TCP连接上的数据并能按照用户要求对这些数据进行过滤,去除用户不关心的数据.
大嘴东哥和寝室的鹏鹏.
.
O(∩_∩)O~看到这个程序就想到了你们,大功臣啊.
.
.
测试结果如下:过滤表达式编辑框内的内容是用户输入的过滤条件,当接收的数据串满足过滤条件时,该字符串不会在接收结果中显示出来.
过滤条件是一系列的引号括起来的字符串,它们可以用or,and,not,括号等连接起来,组成很复杂的过滤条件.
.
不讲了.
首先,将过滤条件置为空,此时显示了从服务器接收到得所有数据"helloword!
",如图下方所示.
然后将过滤条件设置为"he"or"ww".
.
即字符串中含有"he"或者"ww"字样的数据串将被滤除掉不以显示.
.
这正如接收结果中的前两行所示.
OK…测试结果一切正常,E-mail:for_rest@foxmail.
com老衲五木出品我们的LwIP稳定的跑起来了!
不过,这里还可以用其他的测试方法,更常用的方法是构建一个http服务器,然后用我们的浏览器来连接服务器,这些在LwIP移植手册中有了很多例程以及详细的说明,不罗嗦了.
可见,使用LwIPAPI已经可以轻松完成所有TCP通信的相关任务了.
除此之外,LwIP还用自身的API函数实现了BSDSocketAPI函数.
因为很多的软件编写是基于BSD套接字的,BSD套接字更简单易懂,使用广泛,可见实现SocketAPI还是有必要的.
但是LwIP说明文档中这样写道:这一节提供使用LwIPAPI对BSDSocketAPI的一个简单实现.
这个实现只能作为一个参考,不能用于实际编程中,因为它并不完善,比如它没有容错机制等.
同时,这个简单实现也不支持BSDSocketAPI中的select()与poll()函数,因为LwIPAPI没有任何函数可以用于这两个函数的实现.
要实现这两个函数,BSDsocket实现需要直接与LwIP协议栈通讯,而不是使用其API.
所以这里不对BSDSocketAPI做详细讨论了,使用LwIPAPI完全可以完成相关的工作,且编程工作也很简单.
到这里,我们已经从头到尾的将LwIP协议走完了一遍,从网络接口层到ARP层,再到IP层,然后到TCP层,最后到API层.
通常实际应用中,TCP数据包也是按照这个次序依次被处理的.
LwIP还有很多其他内容还没有讨论到,首先是UDP,接下来是PPP,SILP,DNS,IGMP,DHCP,SNMP,IPV6等等.
这些都是在某些特殊的场合才会使用到的,不具有什么共性,所以这里先不涉及这些了.
全剧终.
.
.

日本CN2、香港CTG(150元/月) E5 2650 16G内存 20M CN2带宽 1T硬盘

提速啦简单介绍下提速啦 是成立于2012年的IDC老兵 长期以来是很多入门级IDC用户的必选商家 便宜 稳定 廉价 是你创业分销的不二之选,目前市场上很多的商家都是从提速啦拿货然后去分销的。提速啦最新物理机活动 爆炸便宜的香港CN2物理服务器 和 日本CN2物理服务器香港CTG E5 2650 16G内存 20M CN2带宽 1T硬盘 150元/月日本CN2 E5 2650 16G内存 20M C...

rfchost:洛杉矶vps/双向CN2 GIA,1核/1G/10G SSD/500G流量/100Mbps/季付$23.9

rfchost怎么样?rfchost是一家开办了近六年的国人主机商,一般能挺过三年的国人商家,还是值得入手的,商家主要销售VPS,机房有美国洛杉矶/堪萨斯、中国香港,三年前本站分享过他家堪萨斯机房的套餐。目前rfchost商家的洛杉矶机房还是非常不错的,采用CN2优化线路,电信双程CN2 GIA,联通去程CN2 GIA,回程AS4837,移动走自己的直连线路,目前季付套餐还是比较划算的,有需要的可...

gcorelabs:美国GPU服务器,8张RTX2080Ti,2*Silver-4214/256G内存/1T SSD/

gcorelabs提供美国阿什本数据中心的GPU服务器(显卡服务器),默认给8路RTX2080Ti,服务器网卡支持2*10Gbps(ANX),CPU为双路Silver-4214(24核48线程),256G内存,1Gbps独享带宽仅需150欧元、10bps带宽仅需600欧元,不限流量随便跑吧。 官方网站 :https://gcorelabs.com/hosting/dedicated/gpu/ ...

网卡的功能为你推荐
office2016激活密钥如何查询 office2016 安装密钥易pc易PC价格多少微信如何建群微信怎么建立群伪静态怎么做伪静态?网易公开课怎么下载怎么下载网易公开课里的视频 .......怎么点亮qq空间图标如何点亮QQ空间图标数据库损坏数据库坏了,怎么修复?服务器连接异常手机服务器连接异常网页打不开的原因为什么我的有些网页打不开呢?中国杀毒软件排行榜中国杀软排名
欧洲欧洲vps 国外免费vps 重庆vps租用 域名停靠一青草视频 美国独立服务器 国外主机 wavecom 国外bt payoneer 外国域名 360抢票助手 hinet 世界测速 hdd 上海服务器 七夕快乐英语 海外空间 英雄联盟台服官网 中国电信测速网站 电信宽带测速软件 更多