动手做一个自组网的网络 - 网络协议栈
介绍
- 在起初想设计一个网络时,第一种想法就是希望尽量少的看别人的方案,然后默默思考细节,但是随着细节铺开,一个个的问题不断涌现让我想放弃,这些问题都是围绕怎么做到能这样又能那样,问题一多就容易停滞不前,好在先不管太多优化,直接
code
去实现功能然后再替换细节,迭代思维有时挺不错的,于是第一步实现数据转发,第二步实现路由建立查找,再然后细分消息类型,几个节点可以相互通信后再把不合理的坑全部优化掉,这样,细节就慢慢丰富了起来 - 在搜寻资料时慢慢的接触了
zigbee
和ble mesh
,大概了解了一下是怎么实现的,这个时候还是没时间去学,但是隐隐感觉,像ble mesh
这种弃疗的方式才应该是最终的胜者,相比之下,zigbee
更像过度考虑了,遗憾的是我注定不能走与两者相同的路。 - 接下来,回到一开始的想法,我需要逐步去实现他。
设计需求
- 网络中不要有各种功能划分,没有额外协调器或者路由器,所有节点都是同一种角色
- 电池供电的设备不参与网络数据转发,当唤醒时,需要尽快的处理完通信过程数据并进入休眠
- 网络中所有节点都可以相互通信,节点变化(掉电或者新加入)时能自动优化路径
协议栈数据结构
-
协议中使用一个字节地址意味着同一个网络下只能有这么多设备,还要去除
00
和FF
号设备(广播地址) -
如果要扩展设备,可以使用不同频段来划分,但这并不是设计初衷(简单)。
-
网络中节点在配网时用自己的硬件
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 | 消息发给谁 | 帮谁发过消息就记在路由表里 |
- 现在,当几个距离比较远的节点上电后,我们可以看到如下虚拟拓扑图
-
在网络接入阶段,假设此时
2
号节点挂在1
号下面,3
号距离1
号比较远只能搜索到2
号,所以挂在2
号下面,此时如果有个4
号节点只能收到3
号节点的消息,他将在3
号节点接入网络并广播自己的路由信息后才能通过3
号节点接入网络,接入后给1
号节点发了个心跳包表示自己加入组织了。 -
倒杯茶休息会,我们重新加入更多设备,这时网络中所有的节点都按照规则接入到了网络中,除了
9
号节点,因为他是电池供电的,自觉冬眠去了 -
新产生的网络拓扑如下
-
简单描述下,数字表示自己的网络地址,
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
在十几年前写的PicoC
,C
语言脚本解释器呀,移植过来发现和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
后重新更新固件。
- 三分区是 [
-
因为所有节点使用的是同一款芯片,所以节点升级时只需要使用临近节点当作升级源即可,考虑安全的话可以仅使用某个节点的下发指令来指导升级过程。
-
STM32L432
的FLASH
不像其他F4
系列是大扇区,L432
的FLASH
扇区都是2KiB
的小扇区,我使用的版本总共256KiB
,于是划分如下
地址范围 | 空间大小 | 功能划分 |
---|---|---|
0x0800_0000 ~ 0x0800_0000 | 32KiB | Bootloader |
0x0800_0000 ~ 0x0800_0000 | 200KiB | Application |
0x0800_0000 ~ 0x0800_0000 | 8KiB | Script file vEEPROM |
Bootloader
实现上电检测升级标志位和升级信息,如果有升级则升级后再跳转Application
,跳转前也会校验Application
的MD5
值。Application
限制代码大小,当有符合条件的更新时写入更新信息,跳转到Bootloader
。- 如果希望增加灵活性,可以在更新代码前先加载一段升级代码,由这段代码来完成整体升级。
- 差分升级包可以在服务器完成单独下发,比较省时间(待定)。
- 最后在
FLASH
的末尾预留了一些空间,这些空间用来存放设备的描述信息、网络信息等,为此设计了一个带损耗均衡的简单文件系统用来存放脚本文件。 - 其实只要运气好,
Bootloader
也能更新,如果将Bootloader
大小限制在可用内存范围内,就可以使用内存空间来反向升级Bootloader
。
网络共享
网络中节点可以自由通信时,只要其中一个节点具备某种功能,整个网络都将共享这种能力(中继网关),比如
-
让其中一个节点接入蓝牙
-
让其中一个节点兼容
TCP/IP
协议