本文所属图书 > 传感网原理与技术
-
汇聚数据到基站是传感网应用程序的常见需求。常用的方法是建立至少一棵汇聚树,树根节点作为基站。当节点产生的数据要汇聚到根节点时,它沿着汇聚树往上发,当节点收到数据时,则将它转发给其他节点。有时汇聚协议需要根据汇聚数据的形式检查过往的数据包,以便获取统计信息,计算聚合度并抑制重复的传输。
汇聚协议的数据流与一对多的分发协议相反,它提供了一种多对一、尽力、多跳将数据包发送到根节点的方法。
当网络中具有不止一个根节点时,就形成了一片森林。汇聚协议通过选择父节点隐式地让节点加入其中一棵汇聚树中。汇聚协议提供了到根节点的尽力、多跳传输,它是一个任意播协议,意味着这个协议会将消息尽力传输到任意节点中的至少一个。但是这个传输并不保证必定是成功的,另外还有传到多个根节点的问题,而且数据包到达的顺序也没有保证。
由于节点的存储空间有限并且建树的算法要求是分布式的,因此汇聚协议的实现将遇到许多挑战,主要包括以下几点。
1)路由环路检测:检测节点是否选择了子孙节点作为父节点。
2)重复抑制:检测并处理网络中重复的包,避免浪费带宽。
3)链路估计:估计单跳的链路质量。
4)自干扰:防止转发的包干扰自己产生的包的发送。
实例:TinyOS CTP协议
CTP(Collection Tree Protocol,汇聚树协议)[7]是TinyOS 2.x中自带的汇聚协议,也是实际应用中最常用的汇聚协议之一。下文将详细阐述CTP的总体架构、涉及的基本概念和工作流程。
CTP可以分为三个部分:链路估计器、路由引擎和转发引擎。这三个部分的关系如图2-17所示。其中链路估计器位于最底层,负责估计两个相邻节点间的通信质量。路由引擎位于中间层,使用链路估计器提供的信息选择到根节点传输代价最小的节点作为父节点。转发引擎维护本地包和转发包的发送队列,选择适当的时机把队头的包发送给父节点。
链路估计器主要用于估计节点间的链路质量,以供路由引擎计算路由。TinyOS 2.x中实现的链路估计器结合了广播 LEEP 帧的收发成功率和单播数据包的发送成功率来计算单跳双向链路质量。
链路估计交换协议(Link Estimation Exchange Protocol,LEEP)用于在节点间交换链路估计信息,定义了交换信息使用的 LEEP 帧的详细格式。
入站链路质量如图2-18a所示,有节点对(A,B),以B作为参考节点,A向B发送的总帧数为totalin,其中 B 成功接收到的帧数为successin,从而有:
totalin的值可以通过A节点广播的 LEEP 帧中的顺序号间接计算而得。LEEP 帧中设有顺序号字段,节点A每广播一次 LEEP 帧,会将该字段加1,B节点只需要计算连续收到的 LEEP 帧顺序号的差值就可以得到A总共发送的 LEEP 帧数。
入站链路质量也可以通过其他途径得到,比如 LQI 或 RSSI 之类的链路质量指示器,不过这需要无线模块支持这类功能。
出站链路质量如图2-18b所示,节点对(A,B),以 B 作为参考点,B 向 A 发送帧数为totalout,其中 A 成功接收到帧数为successout,从而有:
由于 LEEP 帧是通过广播方式发送的,节点 B 无法得知节点 A 是否收到,从而无法计算successout。但 B 到 A 的出站链路质量即 A 到 B 的入站链路质量,要解决该问题,只有让 A 把它与 B 间的入站链路质量回馈给 B,这其实就是 LEEP 帧的主要功能之一。
TinyOS 2.x 中用 8 位无符号整数表示出站或入站链路质量。为了减少精度损失和充分利用 8 位的空间,TinyOS 2.x 在实际存储该值时对它扩大 255倍。
双向链路质量如图2-18c所示,对于有向节点对(A,B),双向链路质量定义如下:
双向链路质量=入站链路质量×出站链路质量
本地干扰或噪声可以引起(A,B)和(B,A)链路质量不同,定义双向链路质量就是为了将这种情况考虑在内。
TinyOS 2.x 中使用 EETX(Extra Expected number of Transmission)值表示双向链路质量的估计值。在 LEEP中使用的有两种 EETX 值:窗口EETX 和累积 EETX。窗口 EETX 是接收到的 LEEP 帧数或发送的数据包数达到一个固定的窗口大小时,根据窗口中的收发成功率计算出的 EETX。而累积 EETX 则是本次窗口 EETX 和上次累积 EETX 加权相加得到的。根据指数移动平均的原理让旧值的权重逐渐减少,以适应链路质量的变化,是比较符合实际的统计方法。
LEEP 对数据链路层有以下3个要求:1)有单跳源地址;2)提供广播地址;3)提供 LEEP 帧长度。其中,有单跳源地址的要求是为了让收到广播 LEEP 帧的节点确定更新邻居表中哪一项的出站链路质量。现有节点的数据链路层一般都可以满足这 3个要求。
根据以上分析可以得知 LEEP 帧至少应具备一个顺序号和与邻居节点间的入站链路质量。TinyOS 2.x 中实现的 LEEP 帧结构如图2-19所示。
各字段定义如下:
nentry:尾部的 LI 项个数。
seqno:LEEP 帧顺序号。
rsrvd:保留字段必须设为0。
链路信息项格式如图2-21所示。
各字段定义如下:
node addr:邻居节点的链路层地址。
link quality:从与 node id 对应的节点到本节点的入站链路质量。
TinyOS 中可选的链路估计器有两种:标准 LE 估计器和 4 位链路估计器。可以通过更改应用程序 Make?le 中对应的路径选择使用哪一个链路估计器。标准 LE 估计器的实现在 tos/lib/net/le 目录下。LinkEstimator.h头文件包含了邻居表大小、邻居表项结构、LEEP帧头尾结构以及 LEEP 协议中用到的常数的定义。LinkEstimator.nc包含了其他组件可以从 LinkEstimator中调用的方法。由图2-22中的代码所示,这些方法可以分3类:一类用于获取链路质量,一类用于操作邻居表,还有一类用于数据包估计。
LinkEstimatorC.nc配件用于说明链路估计器提供 LinkEstimator接口。LinkEstimatorP.nc模块是LEEP的具体实现。LEEP的目的是得到本节点到邻居节点间的双向链路质量。在LEEP的实现中使用两种策略相结合来计算估计值,两者间的关系如图2-23所示。
根据 LEEP 帧的估计称为L 估计。它通过 LEEP 帧的信息来估计 EETX 值。LEEP 帧的发送使用 Send.send()方法,它调用 addLinkEstHeaderAndFooter()函数添加 LEEP的帧头和帧尾。尾部存放的是本节点到邻居节点的链路质量表,如果 LEEP 帧中一次放不下这个表,则在下次发 LEEP 帧时从首个上次放不下的表项放起,以保证每个表项有平等的发送机会。每发一个包都将帧中的顺序号字段加 1。发送的时机由 LinkEstimator 的使用者决定。
每当收到一个 LEEP 帧,会触发 SubReceive.receive()事件。处理程序根据 LEEP 的帧头和帧尾信息更新邻居表。这些操作集中在函数processReceiveMessage()中进行,该函数找到这个LEEP 帧发送者对应的邻居表项,调用 updateNeighborEntryIdx()函数更新收到包数计数值和丢包数计数值。其中的丢包数就是本次与上次 LEEP 帧中顺序号字段的差值。
当收到的包数达到一个固定窗口的大小时,调用 updateNeighborTableEst()函数计算该窗口中的入站链路质量inqualitywin:
路由引擎的责任是选择传输的下一跳。一个理想的路由引擎应当可以选择到根节点跳数尽量少而连接质量尽量好的传输路径,这样可以减少转发次数和丢包率,从而降低传感器网络的能量消耗,延长网络的生存期。但由于节点的存储容量和处理能力一般都非常有限,难以存储大量的路由信息和使用复杂的路由算法,故而有线网络中常用的路由协议,如TCP/IP 中的OSPF 和RIP 协议,在这里是不适用的。传感器网络的路由设计注重简单有效,使用有限的资源达到最好的效果。TinyOS 2.x 中CTP实现的路由引擎可以较好地实现这个目标。它用于建立到根节点的汇聚树,利用链路质量估计器提供的信息合理地选择下一跳节点,使采样节点到根节点的传输次数尽可能的少。
首先需要明确CTP中所用的路由度量,称为路径ETX(Expected number of Transmission),它是父节点到根节点的ETX与本节点和父节点间的单跳ETX 之和,如图2-24所示。单跳ETX 与链路估计器提供的EETX 值关系为ETX= EETX+1。它的大小可以反映出到根节点的跳数。在一般情况下,路径ETX 越小说明离根节点越近,路由引擎正是根据这一事实选择ETX 最小的邻居作为父节点,以期获得根节点的最少传输次数。
路由表是路由引擎的核心数据结构。CTP中使用的路由表结构如图2-25所示,它存储了邻居节点信息,主要是邻居的路径ETX值。路由表的大小取决于链路估计器邻居表的大小,因为不在邻居表的节点无法作为邻居节点进入路由表。
各字段意义如下:
P:取路由位。如果节点收到一个P 位置位的包,它应当尽快传输一个路由帧。
C?:拥塞标识。如果节点丢弃了一个CTP 数据帧,则必须将下一个传输路由帧的C 位置位。
parent:节点的当前父节点。
ETX:节点的当前ETX值。
当节点接收到一个路由帧时,它必须更新路由表相应地址的ETX 值。如果节点的ETX 值变动很大,那么CTP 必须传输一个广播帧以通知其他节点更新它们的路由。与CTP 数据帧相比,路由帧用父节点地址代替了源节点地址。父节点可能发现子节点的ETX 值远低于自己的ETX 值的情况,这时它需要准备尽快传输一个路由帧。当前路由信息表中记录了当前使用的父节点的信息。如它的AM 层地址、路径ETX 等。
TinyOS 中路由引擎的实现在tos/lib/net/ctp 目录的下列文件中:CtpRoutingEngineP.nc模块,它是路由引擎的具体实现;TreeLouting.h,它定义了路由引擎中使用的一些结构和常数;Ctp.h,它定义了路由帧的结构。
从图2-27所示的源码中可以看到,CtpRoutingEngineP 是一个通用组件,可以通过参数设定路由表大小、信标帧发送的最小和最大间隔。它使用了链路估计器、两个定时器和一些包收发处理接口;提供的接口主要是Routing 路由接口,它包含了一个最重要的命令nexthop()用于为上层组件提供下一跳的信息。
信标帧定时器(BeaconTimer)用于周期性地发送信标帧。发送间隔是指数级增长的。初始的间隔是一个常数minInterval(其值为128),在每更新一次路由信息后,将间隔加倍。因此随着网络的逐渐稳定,将很少看到节点广播信标帧。定时器间隔在使用指数级增长的基础上还加上随机数,以错开发送信标帧的时机,避免节点同时发送信标帧导致信道冲突。此外,定时器可以重置为初始值,这主要用于处理一些特殊情况,比如节点收到一个P 位置位的包要求尽快发信标帧,或者提供给上层使用者重置间隔的功能。
路由定时器(RouteTimer)用于周期性地启动更新路由任务。更新间隔固定为一个常数BEACON_INTERVAL,其值为8192。该定时器触发后将启动更新路由选择任务。
发送信标帧任务由信标帧定时器触发。以广播的方式告知其他节点本节点的ETX 值、当前父节点和拥塞信息。更新路由选择任务一般由路由定时器触发,但也可以在其他条件下触发,如信标帧定时器到期、重新计算路由、剔除了某个邻居等需要更新路由选择的情况下触发。更新路由选择任务通过遍历路由表找出路径ETX 值最小的节点作为父节点,并且该节点不能是拥塞的或是本节点的父节点。
信标帧接收事件,即BeaconReceive.receive()事件会在收到其他节点的信标帧时触发。它将根据信标帧的发送者和ETX 值更新相应的路由表项。如果收到的是根节点的信标帧,则调用链路估计器将它固定在邻居表中。如果信标帧的P 位置位,则重设信标帧定时器,以便尽快广播本节点的信标帧让请求者收到。
路由引擎工作流程如图2-28所示。节点启动时将初始化路由引擎。路由引擎通过将Init 接口接到MainC 的SoftwareInit 接口来实现节点启动时自动初始化路由引擎。初始化的工作有:初始化当前路由信息、初始化路由表为空、初始化路由帧消息缓冲区以及一些状态变量等。
应用程序通过StdControl 接口的start()方法正式启动路由引擎,这将启动两个定时器RouteTimer 和BeaconTimer。其中RouteTimer 的时间间隔设为BEACON INTERVAL(8192),BeaconTimer 的下一次发送时间初始值设为minInterval(128)。
由于BeaconTimer 的触发时间值设置的比RouteTimer 的触发间隔小得多,因此BeaconTimer将率先触发,并投递updateRouteTask()以更新路由选择,接着投递sendBeaconTask()任务发送信标帧。此后,RouteTimer 以恒定的时间间隔触发并投递updateRouteTask(),而BeaconTimer 触发后会将下次触发的时间间隔加倍。
除了定时器在不断地触发以投递任务外,路由引擎还需要处理其他节点的信标帧。当接收到一个广播的信标帧时,会触发BeaconReceive.receive()事件,并根据信标帧中的发送者和它的ETX 值更新相应的路由表项。
另外,如果链路估计器剔除了一个候选邻居,则路由引擎也要相应地从路由表把该邻居移除,并更新路由选择,从而保证了路由表和邻居表的一致性。
转发引擎主要负责以下5 种工作:
1)向下一跳传递包,在需要时重传,同时根据是否收到ACK 向链路估计器传递相应信息。
2)决定何时向下一跳传递包。
3)检测路由中的不一致性,并通知路由引擎。
4)维护需要传输的包队列,它混杂了本地产生的包和需要转发的包。
5)检测由于丢失ACK 引起的单跳重复传输。
路由环路是指某个节点将数据包转发给下一跳,而下一跳节点是它的子孙节点或者它本身,从而造成了数据包在该环路中不断循环传递,如图2-29所示,由于节点E在某个时刻错误地选择了H节点作为父节点,从而造成了路由环路。
包重复是指节点多次收到具有相同内容的包。这主要是由于包重传引起的。比如发送者发送了一个数据包,接收者成功地收到了该数据包并回复ACK,但ACK 在中途丢失,因此发送者会将该包再一次发送,从而在接收者处造成了包重复现象。
CTP 数据帧是转发引擎在发送本地数据包时所使用的格式。它在数据包头增加一些字段用于抑制包重复和路由循环。CTP 数据帧格式如图2-30所示。
各字段定义如下:
P:取路由位。P 位允许节点从其他节点请求路由信息。如果节点收到一个P 位置位的包,它应当传输一个路由帧。
C?:拥塞标志位。如果节点丢弃了一个CTP 数据帧,它必须在下一个传输的数据帧中置C 位。
THL?:(Time Have Lived,已存活时间),它主要用于解决路由循环问题。当节点产生一个CTP 数据帧时,它必须设THL 为0。当节点接收到一个CTP 数据帧时,它必须增加THL 值。如果节点接收到的数据包THL为255,则将它回绕为0。该字段主要用于解决数据包在环路中停留太久的问题,但在当前版本的CTP中暂时还没有实现这一功能。
ETX?:单跳发送者的ETX 值。当节点发送一个CTP 数据帧时,它必须将到单跳目的地的路由ETX 值填入ETX 字段。如果节点接收到的路由梯度比自己的小,则它必须准备发送一个路由帧。
origin:包的源地址。转发的节点不可修改这个字段。
seqno:源顺序号。源节点设置了这个字段,转发节点不可修改它。
collect_id:高层协议标识。源节点设置了这个字段,转发节点不可修改它。
data:数据负载。0 个或多个字节。转发节点不可修改这个字段。
origin、seqno、collect_id 合起来标识了一个唯一个源数据包,而origin、seqno、collect_id、THL合起来标识了网络中唯一一个数据包实例。两者的区别在路由循环中的重复抑制是很重要的。如果节点抑制源数据包,则它可能丢弃路由循环中的包;如果它抑制包实例,则它允许转发处于短暂的路由循环中的包,除非THL 凑巧回绕到与上次转发时相同的状况。
消息发送队列结构是转发引擎的核心结构。它存放了队列项的指针,队头元素指向的队列项中的消息将被优先发送。队列项(queue entry)中存放了对应消息的指针、对应的发送者和可重传次数。本地包与转发包的队列项分配方法有所不同:转发包的队列项是通过缓冲池分配的,而本地包的队列项是编译期间静态分配的。
缓冲池是操作系统中用于统一管理缓冲区分配的一个设施。应用程序可以使用缓冲池提供的接口方便地获取和释放缓冲区。对于不能动态分配存储空间的TinyOS 来说,这一点非常有价值,因为它可以重复利用一段静态存储空间。
转发引擎中使用了两个缓冲池:队列项缓冲池(QEntryPool)和消息缓冲池(MessagePool)。队列项缓冲池用于为队列项分配空间,如图2-31所示,当转发引擎收到一个需要转发的消息时,它会从队列项缓冲区中取出一个空闲的队列项,作相应的初始之后把队列项的指针放入消息队列队尾。在成功地发送了一个消息并收到ACK 或消息重发次数过多被丢弃时,转发引擎会从队列项缓冲池中释放这个消息对应的队列项,使它变为空闲,因此队列缓冲池就可以把这块空间分配给后续的消息。
消息缓冲池的工作原理与队列项缓冲池类似,只不过它存的是消息结构。在TinyOS 2.x 中, 消息缓冲池的初始大小设定为一个常数FORWARD_COUNT(值为12)。队列项缓冲池的初始大小为CLIENT_COUNT + FORWARD_COUNT,其中CLIENT_COUNT 是CollectionSenderC 使用者的个数,加上它是考虑到了本地产生的包也会进入发送队列,而本地包的最大个数正是CollectionSenderC 使用者的个数,这样就保证了发送队列不会因为本地发送者太多而不断产生溢出。如果不考虑这个因素,则在本地发送者很多的情况下节点可能产生拥塞的假象。
缓冲区交换是转发过程中一个比较微妙的环节。如图2-32所示,从缓冲池中获得的消息结构并不是直接用于存储当前接收到的消息,而是用于存储下一次接收到的消息。由于当前接收到的消息必定已经有了它自己的存储空间,因此只要让相应的队列项指向它就可以找到这个消息的实体。但是下一个接收到的消息就不应该存储在这一块空间,而缓冲区交换正是用于为下一次收到的消息分配另外一块空闲的存储空间。传统的做法通常是设置一个消息结构用于接收消息,每当收到一个消息后将它整个复制到空闲存储空间中。相比之下,缓冲区交换可以省去一次复制的开销。
组件tos/lib/net/ctp/CtpForwardingEngineP.nc 实现了转发引擎。从下列源码中可以看到,CtpForwardingEngine 使用了路由引擎提供的接口UnicastNameFreeRouting 用于得到下一跳信息,使用了系统提供的Queue、Pool、SendCache 接口分别实现消息发送队列、队列项缓冲池、消息缓冲池和发送消息缓存,同时也使用了LinkEstimator 用于向链路估计器反馈数据包发送成功与否的信息。
CtpForwardingEngine 提供的接口分别为网络中的4种扮演不同角色的节点服务。为发送者提供Send 接口,为侦听者提供Snoop 接口,为网络处理者提供Intercept 接口,为接收者提供Receive 接口。
转发引擎的4个关键函数为包接收SubReceive.receive(),包转发forward(),包传输SendTask()和包传完之后的善后工作SubSend.sendDone()。
receive()函数决定节点是否转发一个包。它有一个缓冲区缓存了最近收到的包,通过检查这个缓冲区可以确定它是否是重复的。如果不是,则调用forward()函数进行转发。
forward()函数格式化需要转发的包。它检查收到的包是否有路由循环,使用的方法是判断包头中的ETX 值是否比本节点的路径ETX小。接着检查发送队列中是否有足够的空间,如果没有,则丢弃该包并置C 位。如果传输队列为空,则投递SendTask 任务准备发送。
SendTask 任务检查位于发送队列队头的包,请求路由引擎的路由信息,为到下一跳的传输做好准备,并将消息提交到AM 层。
当发送结束时,sendDone 事件处理程序会检查发送的结果。如果包被确认,则将包从传输队列中取出。如果包是本地产生的,则将sendDone 信号向上传。如果包是转发的,则将该消息结构释放到消息缓冲池。如果队列中还有剩余的包(比如没有被确认的),它启动一个随机定时器以重新投递这个任务。该定时器实质上用于限制CTP 的传输速率,不让它尽快地发包,这是为了防止在通路上自我冲突。
当转发引擎收到一个转发包时,它会检查该数据包是否在缓存或发送队列中,这主要是为了抑制包重复。如果不是重复包,则调用forward()进行转发。forward()函数为该消息在消息池中分配队列项和消息结构,然后把队列项指针放入发送队列。如果此时投递发送消息任务的定时器没有运行,则立即投递发送消息任务,以选取发送队列队头的数据包进行发送。发送成功之后将触发sendDone 事件做一些善后工作,比如检查刚发送的包是否收到链路层ACK,如果收到,则从队列中删除这个包的队列项,并释放相关资源。如果没有收到ACK,则启动重传定时器,再一次投递发送消息任务进行重传。若重传次数超过CLIENT-COUNT 次,则丢弃该包。
转发引擎也负责本地数据包的发送。应用程序通过使用CollectionSenderC 组件发送本地包。nesC 编译器会根据CollectionSenderC 组件使用者的个数为每个使用者静态地分配一个队列项,并用一个指针数组指向各自的队列项。如果某个使用者需要发送数据包,则先检查它对应的指针是否为空。若为空,则说明该使用者发送的前一个数据尚未处理完毕,返回发送失败;若不为空,则说明它指向的队列项可用,用数据包的内容填充队列项并把它放入发送队列等待发送。