动手做一个自组网的网络 - 网络协议栈

动手做一个自组网的网络 - 网络协议栈



介绍

  • 在起初想设计一个网络时,第一种想法就是希望尽量少的看别人的方案,然后默默思考细节,但是随着细节铺开,一个个的问题不断涌现让我想放弃,这些问题都是围绕怎么做到能这样又能那样,问题一多就容易停滞不前,好在先不管太多优化,直接code去实现功能然后再替换细节,迭代思维有时挺不错的,于是第一步实现数据转发,第二步实现路由建立查找,再然后细分消息类型,几个节点可以相互通信后再把不合理的坑全部优化掉,这样,细节就慢慢丰富了起来
  • 在搜寻资料时慢慢的接触了zigbeeble mesh,大概了解了一下是怎么实现的,这个时候还是没时间去学,但是隐隐感觉,像ble mesh这种弃疗的方式才应该是最终的胜者,相比之下,zigbee更像过度考虑了,遗憾的是我注定不能走与两者相同的路。
  • 接下来,回到一开始的想法,我需要逐步去实现他。

设计需求

  • 网络中不要有各种功能划分,没有额外协调器或者路由器,所有节点都是同一种角色
  • 电池供电的设备不参与网络数据转发,当唤醒时,需要尽快的处理完通信过程数据并进入休眠
  • 网络中所有节点都可以相互通信,节点变化(掉电或者新加入)时能自动优化路径

协议栈数据结构

  • 协议中使用一个字节地址意味着同一个网络下只能有这么多设备,还要去除00FF号设备(广播地址)

  • 如果要扩展设备,可以使用不同频段来划分,但这并不是设计初衷(简单)。

  • 网络中节点在配网时用自己的硬件ID生成MD5标识,然后给1号节点,1号节点返回对应的地址2 ~ 254

  • 1号节点是个特殊节点,网络中所有地址都由他来分配,同时也是树状路由的根节点,连接其他子网,1号节点也可以不参与数据转发,但必须有一个根节点

  • 单帧数据包结构如下,单帧发送固定为40字节,其中协议栈占用8字节,有效数据利用率 80%

struct frame_t{
    unsigned char receiver;        // 接收地址
    unsigned char relay;           // 中继地址
    unsigned char dst;             // 目标地址
    unsigned char src;             // 发送地址
    unsigned char msgid;           // 消息串号
    unsigned char type:4;          // 消息类型
    unsigned char flag:4;          // 消息标识
    unsigned char payload[32];     // 消息负载
    unsigned char crc_h;           // CRC-16
    unsigned char crc_l;           // CRC-16
};
  • 消息类型(待定)
typedef enum{
	MT_DATA,                       // 数据
	MT_PING,                       // 探测
	MT_ERROR,                      // 错误
	MT_HEARTBEAT,                  // 心跳
	MT_ACK,                        // 回复
	MT_ROUTE,                      // 路由信息
    MT_CONFIRM,                    // 确认信息
	MT_FILE,                       // 文件传输
}MSG_TYPE;

网络中的角色

供电设备
  • 该设备一般市电接入,没有低功耗要求,只需要降低运行功耗即可
  • 在网络中处于一直活动的状态,维护自己的路由表,能帮助其他节点进行数据转发
电池设备
  • 设备由电池供电,需要满足低功耗要求
  • 无需维护路由表,以广播的形式发送消息,基本处于离线状态,可能按计划进行定时唤醒
  • 当有消息给低功耗设备时,由应用程序暂存,当设备接入网络时可以下发

路由表

  • 首先需要确定网络的拓扑类型,这里借鉴了IP网络的结构,每个节点都是一级路由,子网之间通过上一级路由根节点转发消息实现互联
  • 每当需要加入网络时,他会扫描周边的路由广播,如果扫描到网络,他就将其标记为上一级路由并发送一个心跳包,除此以外什么也不用做
  • 于是, 只要解决两个问题,路由表便出来了
问题描述实现目标解决办法
自己的上一级路由是谁消息不知发给谁时就甩给他节点配网时就确定了
自己的路由表里有没有某个ID消息发给谁帮谁发过消息就记在路由表里
  • 现在,当几个距离比较远的节点上电后,我们可以看到如下虚拟拓扑图
1
2
3
  • 在网络接入阶段,假设此时2号节点挂在1号下面,3号距离1号比较远只能搜索到2号,所以挂在2号下面,此时如果有个4号节点只能收到3号节点的消息,他将在3号节点接入网络并广播自己的路由信息后才能通过3号节点接入网络,接入后给1号节点发了个心跳包表示自己加入组织了。

  • 倒杯茶休息会,我们重新加入更多设备,这时网络中所有的节点都按照规则接入到了网络中,除了9号节点,因为他是电池供电的,自觉冬眠去了

  • 新产生的网络拓扑如下

1
2
3
4
5
6
7
8
  • 简单描述下,数字表示自己的网络地址,1号节点为根节点,1号是2 3 4号节点的上一级路由,2号是5 6号节点的上一级路由,5号节点下面没有其他节点,其他类推。

  • 举例完整的通信流程

    • 8号节点需要发消息给5号时,因为他的路由表里没有5号的信息,所以会把消息转发给上一级路由,也就是7号节点。
    • 来到7号这边,7号收到一个数据包,他看了一下收件人并不是自己,那就找找路由表吧,这当然找不到,于是按照规定把消息重新整理后甩给了上一级路由4号,自己打王者去了。
    • 镜头转到4号,他慢慢拆开包发现不是自己的消息就按规定发给了上一级路由1号,然后睡觉去了。
    • 此时压力来到1号这里,他最忙了,别人找不到东西就给自己,都没时间玩游戏,和往常一样,他收到一个数据包一瞧,给5号的,5号刚刚给我发了一个心跳包,那就直接发给他吧,可是我直接联系不到他(路由表里没有),路由表里显示这小子的消息是通过2号转到我的,我就让2号去操心吧。
    • 2号收到消息后一看又是给5号的,5号就在自己的路由表里,顺利就把东西给了他并说了句不客气。
  • 到此,虽然没有涉及丢包、重试、确认等机制,但整个网络工作起来了。

  • 基于以上,路由表的结构将非常简单

static volatile unsigned char route_table[ROUTE_TABLE_SIZE];
  • 保存路由信息只需要
void save_to_routetable( int id, int prev )
{
    if( prev == context.prev ) return ;       
    route_table[id] = prev;
}
  • 查询路由信息只需要
int find_next_hop( const int id )
{       
    if( route_table[id] ){
        return route_table[id];
    }else{      
    	/* return to own route */
        return context.prev; 
    }
}
  • 在网络运行起来后有几个关键信息
    • 节点加入网络后需要发送心跳包给根节点,相当于内网穿透
    • 路由过程中节点不参与整个网络的路由算法,而是让每个节点自己选择
    • 节点需要确保自己到根节点的跳数是尽量短的
    • 已加入网络的设备需要定期广播自己的路由信息
    • 路由表需要建立超时机制来更新异常节点
    • 节点可以适当根据一些条件存储邻居信息来避免多跳传输

数据转发

  • 当一个数据包被转发时需要做两件事
    • 将数据包的头尾信息更改成自己的
    • 重新计算CRC并转发
  • 假设8号发给5号消息,7号收到数据帧的头应该是这样
    unsigned char receiver = 7;        // 接收地址
    unsigned char relay    = 8;        // 中继地址
    unsigned char dst      = 5;        // 目标地址
    unsigned char src      = 8;        // 发送地址
    ...                                // 其他数据无需更改
    unsigned char crc_h;               // CRC-16
    unsigned char crc_l;               // CRC-16
  • 当7号转发时,数据帧帧会改为这样

    unsigned char receiver = 4;        // 接收地址
    unsigned char relay    = 7;        // 中继地址
    unsigned char dst      = 5;        // 目标地址
    unsigned char src      = 8;        // 发送地址
    ...                                // 其他数据无需更改
    unsigned char crc_h;               // 新的CRC-16
    unsigned char crc_l;               // 新的CRC-16

鉴权和加密

  • 接入网络时需要用注册的地址换取TOKEN,在后续通信中均使用TOKEN当作密钥来加密,防止回放攻击。
  • 加密使用AES-ECB-128 对称加密,因为单次使用16 Byte, 所以负载设计成16 Byte对齐。

重试和丢包

  • 通信良好的情况下,使用 SmartRF Studio 模拟收发,丢包率大概在1%左右,这只是相互通信,在多级路由转发中,这个数值会被放大。

  • 实际应用中设计了一个缓冲器,当数据打包好后会先存到这个缓冲器中,每隔一段时间从缓冲器取出数据包发出,连续发送三次则删除该数据包。

  • 接收者在收到数据包后,如果需要回复则发送确认包,发送者收到确认包就删除缓冲器里的数据并结束重试过程,确认包不会被回复。

广播

  • 低功耗设备在唤醒时不知道自己离哪个节点最近,因为可能在休眠期间移动了位置,他需要快速发出消息并且进入休眠。
  • 运用简单暴力美学,使用广播来表达自己的信息,如果需要回复就等待,不需要就直接休眠。
  • 其他节点在收到广播后会转成中继包发出,直到数据被接收。
  • 为了防止泛洪,每个节点都维护一个消息池,收到数据后就写入消息特征,收到同样的消息后就忽略掉。
  • 多数情况下,供电节点之间不推荐使用广播方式来通信,这会造成通信链路的阻塞和延迟

数据处理

  • 最多的篇幅用来介绍协议栈的运作,关键的数据通信并没有被提及,因为越往上越抽象,越不好三言两语概括,于是就交给了程序员的交互方式 – 脚本

    • 在脚本语言上选择了很久,一开始想法就是microPython,网络帖子比较多,受众面广一些,无奈这玩意编译后太大了
  • 后来发现了lua,移植后也是很大,看着菜鸟教程(感谢)做了几个和C交互的例子感觉也就那样,在任务里面跑起来遇到语法解析错误就会泄露几十个字节,循环跑一会内存就爆了,32K内存很快就给我吃完了,气。

  • 再后来找到google在十几年前写的PicoCC语言脚本解释器呀,移植过来发现和C的交互也很自然,编译后的大小也就50KB左右,但是需要在运行脚本前指定栈大小,比较担心这点,就没有用,估计后面还能捡起来用到其他事情上。

  • 找来找去找不到一个尽量小的解释器,又因为不懂怎么实现的,中间萌发了自己写一个的想法,不懂才要写,写了不就懂了,评估下估计要用掉好多个周末后暗暗把想法雪藏了,等以后吧,优先主线任务。

  • 就在一筹莫展的时候,突然发现lua可以去掉默认加载的基础库,反正也用不到,屏蔽掉 LUA_USE_JUMPTABLE后,代码进一度缩小到60KB左右,内存泄漏的 问题虽然没找到,但是通过运行脚本时单独开一个任务,运行结束再杀掉任务的方式规避了这个问题,至此脚本确定了。

  • 初步构想是所有节点只运行协议栈,不参与具体数据结构和解析,数据收到后由脚本去处理和发送,这样我只需修改各自节点的脚本就可以了,协议由脚本自定义。

  • 下面是开关和灯的伪代码示例

#Switch
{
	if(io.open.press())
	rfmesh.write( id , "power on" , callback )
	
	if(io.close.press())
	rfmesh.write( id , "power off" , callback )
}

#Light
{
		if( rfmesh.read() == "power on" )
		io.open()

		if( rfmesh.read() == "power off" )
		io.close()
}

固件升级

  • 升级布局有两种方式,一种是三分区,一种是双分区,三分区的话涉及到升级失败问题有一定的变砖风险,之前遇到过,所以这次使用双分区,缺点是不灵活。

    • 三分区是 [Bootloader] [Application] [Download] ,下载固件放到Download分区,Bootloader仅用作校验和回写。
    • 双分区是 [Bootloader] [Application] ,下载固件时直接覆盖Application区,需要把固件分包下载协议内置到Bootloader,即使Application出错也能通过再次上电进入Boot loader后重新更新固件。
  • 因为所有节点使用的是同一款芯片,所以节点升级时只需要使用临近节点当作升级源即可,考虑安全的话可以仅使用某个节点的下发指令来指导升级过程。

  • STM32L432FLASH不像其他F4系列是大扇区,L432FLASH扇区都是2KiB的小扇区,我使用的版本总共256KiB,于是划分如下

地址范围空间大小功能划分
0x0800_0000 ~ 0x0800_000032KiBBootloader
0x0800_0000 ~ 0x0800_0000200KiBApplication
0x0800_0000 ~ 0x0800_00008KiBScript file vEEPROM
  • Bootloader实现上电检测升级标志位和升级信息,如果有升级则升级后再跳转Application,跳转前也会校验ApplicationMD5值。
  • Application限制代码大小,当有符合条件的更新时写入更新信息,跳转到Bootloader
  • 如果希望增加灵活性,可以在更新代码前先加载一段升级代码,由这段代码来完成整体升级。
  • 差分升级包可以在服务器完成单独下发,比较省时间(待定)。
  • 最后在FLASH的末尾预留了一些空间,这些空间用来存放设备的描述信息、网络信息等,为此设计了一个带损耗均衡的简单文件系统用来存放脚本文件。
  • 其实只要运气好,Bootloader也能更新,如果将Bootloader大小限制在可用内存范围内,就可以使用内存空间来反向升级Bootloader

网络共享

网络中节点可以自由通信时,只要其中一个节点具备某种功能,整个网络都将共享这种能力(中继网关),比如
  • 让其中一个节点接入蓝牙

  • 让其中一个节点兼容 TCP/IP 协议

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值