[485通信] 我设计的 RS485 流控、数据完整性确保 及 数据分包机制

[复制链接]
165|1
 楼主 | 2018-6-5 18:31 | 显示全部楼层 |阅读模式
本帖最后由 dukedz 于 2018-6-5 18:55 编辑

首先 串口 的流控大家应该都有所了解,通常是硬件 CTS/RTS 或软件 XON/XOFF 这两种流控方式,然而因为 RS485 是总线形式,所以传统的方法都不再适用。

有人会觉得奇怪,貌似从来没有考虑过 RS485 流控的问题,没错,传统 RS485 都是一收一发,用不着考虑流控,然而这种一收一发的效率比较低,譬如在 IoT 火热的今天,如果用 RS485 来传输网络数据,那么传统的做法就很低效了。

然后,针对数据完整性确保的问题,很多同行都没有留意到一个细节问题,他们通常判断是否收到回复 OK 的数据包,如果没收到数据包就超时重发一次。
这种做法大多情况都没有问题,但是某些场景,譬如发送一个命令让滑轨左移 10mm, 滑轨成功接收命令并返回 OK, 然而主机因为干扰等各种问题没有收到滑轨的回复,那么重发命令就会导致滑轨错误左移 20mm.
当然你可以说目前用到的设备都是绝对位置控制,不会有影响,但万一哪天新做一个设备,到那时再改协议,难道就不考虑兼容自己以往的产品了吗?

<small>当然还是有很多朋友有注意到这个问题,本文使用的解决方法原理上跟这些朋友也是相同的。</small>

我接下来提出的方案最大的亮点是共用同一套机制,同时解决了流控、数据完整性确保、大数据分包等功能,而且比较高效和简单。

<br>
同样,最底层的协议我们依然使用 CDBUS, 因为它比较简单,又支持硬件增强(可以主动避让冲突,实现多主机、对等通讯、主动上报数据等功能),能最大程度体现出本文方法的性能优势。

你可能没有听过 CDBUS 这个名字,但你可能曾经或正在使用相似的协议,它的组成包含 3 个部分:
- 3 个字节的头:「源地址,目标地址,用户数据长度」
- 0~255 字节的用户数据(因为数据长度用 1 个字节表示)
- 2 个字节的 CRC 校验,涵盖整个数据包,校验算法同 ModBus.

数据包与数据包之间要有一定的空闲时间,来隔开不同的数据包,详细请参见 CDBUS 的协议定义:
https://github.com/dukelec/cdbus_ip

譬如地址 0x00 为主机,0x01 为 1 号从机,那么主机发送两个字节数据 0x10 0x11 给 1 号从机的完整数据为:  
[00 01 02  10 11  49 f0]

然后 1 号从机回覆单个 0x10 给主机:  
[01 00 01  10  04 b8]


<br>
然而 CDBUS 只是最底层的协议,接下来我们要定义上述用户数据的格式,最简单常用的方式就是首字节为命令号,然后后面跟可选命令参数;
回覆数据第一个字节通常为状态,然后是返回的数据。

这种方式完善之后也有一个名字,叫 CDNET, 它的定义在:https://github.com/dukelec/cdnet  
(本文的内容这个连接都有包含,但本文会更加通俗的讲解一下关键细节。)


CDNET 协议有 3 个级别,由首字节的高两位决定:

| Bit7 | Bit6   | 描述                                                           |
|------|------- |---------------------------------------------------------------|
| 0    | x      | Level 0: 上述最简单的形式                                        |
| 1    | 0      | Level 1: 支持跨网、组播、流控等高阶功能                            |
| 1    | 1      | Level 2: 裸数据模式,支持大数据拆包,譬如传输 IPv4/v6 数据包         |

实际使用根据情况自由选择某一个或某几个来用就好。

#### Level 0 格式

##### 请求
首字节:

| 位      | 描述                                               |
|-------- |---------------------------------------------------|
| [7]     | 等于 0: Level 0                                    |
| [6]     | 等于 0: 请求                                        |
| [5:0]   | dst_port, 范围 0~63                                |

CDNET 的端口号可以看做类似电脑的 UDP 端口,也可以看做是一个命令号。

第二字节及其后:命令参数

##### 回复
首字节:

| 位      | 描述                                               |
|-------- |---------------------------------------------------|
| [7]     | 等于 0: Level 0                                    |
| [6]     | 等于 1: 回复                                       |
| [5]     | 1: [4:0] 存放用户数据;0: 不使用                     |
| [4:0]   | 不使用或用户数据,数据必须 ≤ 31                       |

例如: 回复 `[0x40, 0x0c]` 和回复 `[0x6c]` 是相同的意思。
```
0x40: 'b0100_0000
0x0c: 'b0000_1100
----
0x6c: 'b0110_1100
```

首字节的用户数据(如果有)、第二个字节及其后:回复的状态 和/或 数据。

#### Level 1 格式
首字节:

| 位      | 描述                                               |
|-------- |---------------------------------------------------|
| [7]     | 等于 1                                             |
| [6]     | 等于 0                                             |
| [5]     | MULTI_NET (跨网)                                  |
| [4]     | MULTICAST (组播)                                  |
| [3]     | SEQUENCE  (序列号)                                |
| [2:0]   | PORT_SIZE (端口大小设置)                           |


##### MULTI_NET & MULTICAST

| MULTI_NET | MULTICAST | 描述                                                                               |
|-----------|-----------|-----------------------------------------------------------------------------------|
| 0         | 0         | Local net: 本地网络,不追加数据                                                      |
| 0         | 1         | Local net multicast: 追加 2 字节 `[multicast-id]` 组播号                            |
| 1         | 0         | Cross net: 追加 4 字节: `[src_net, src_mac, dst_net, dst_mac]`                     |
| 1         | 1         | Cross net multicast: 追加 4 字节: `[src_net, src_mac, multicast-id]`               |

这个与本文主题无关,就不展开了。

##### SEQUENCE
0: 无序列号;  
1: 追加 1 字节序列号 `SEQ_NUM`, 这个是重点,稍后会主要说明。


##### PORT_SIZE:

| Bit2 | Bit1 | Bit0   | SRC_PORT      | DST_PORT      |
|------|------|--------|---------------|---------------|
| 0    | 0    | 0      | Default port  | 1 byte        |
| 0    | 0    | 1      | Default port  | 2 bytes       |
| 0    | 1    | 0      | 1 byte        | Default port  |
| 0    | 1    | 1      | 2 bytes       | Default port  |
| 1    | 0    | 0      | 1 byte        | 1 byte        |
| 1    | 0    | 1      | 1 byte        | 2 bytes       |
| 1    | 1    | 0      | 2 bytes       | 1 byte        |
| 1    | 1    | 1      | 2 bytes       | 2 bytes       |

注:
- 默认端口通常定为 `0xcdcd`, 所以不用额外追加字节.
- 追加的字节按顺序,先是 `src_port` 再是 `dst_port`.


#### Level 2 格式
首字节:

| 位      | 描述                                               |
|-------- |---------------------------------------------------|
| [7]     | 等于 1                                             |
| [6]     | 等于 1                                             |
| [5:4]   | FRAGMENT(大数据分包)                               |
| [3]     | SEQUENCE(序列号)                                  |
| [2:0]   | User-defined flag                                 |

##### FRAGMENT:

| Bit5 | Bit4   | DST_PORT              |
|------|--------|-----------------------|
| 0    | 0      | Not fragment          |
| 0    | 1      | First fragment        |
| 1    | 0      | More fragment         |
| 1    | 1      | Last fragment         |

注:
- 使用分包功能的时候必须同时选择使用 `SEQUENCE`.
- 开始分包的时候 `SEQ_NUM` 不需要归零.

<hr>

一般情况下,要求不高,使用最简单的 Level 0 格式就好了,如果命令比较多,那么就可以用 Level 1 格式,用不到的功能不用理会即可。

Level 1 没有大数据分包功能,因为通常 MCU 也用不到那么大的数据包,即使是烧录代码这种要传大数据的功能,也是可以在命令内部定义地址和数据长度的,譬如我的 STM32 总线代码升级的命令定义:

```
// flash memory manipulation, port 11:
//   erase: 0xff, addr_32, len_32  | return [] on success
//   read:  0x00, addr_32, len_8   | return [data]
//   write: 0x01, addr_32 + [data] | return [] on success
```

而 Level 2 譬如可以用来传 IPv4/v6 数据包,那么就不得不加入拆包的功能了。因为 Level 1 和 Level 2 的序列号部分是一样的,所以接下来就混在一起讲了。

CDBUS 协议将前 0~9 保留专用,10 及其后的用户可以随便用,保留的部分目前也就用了 4 个,而且也不是强制的,用户愿意实现就实现,不用或者自己想怎么用就怎么用也没问题。
上篇文章说了端口或命令 0x01 是用来查询设备信息的,命令 0x03 是用来设置地址的,还详细说了如何使用这两个端口来实现地址自动分配,剩下两个端口其中 0x02 是用来设置波特率的,
对于本文最关键的端口 0x00 是用于流控、完整性确保、大数据拆包的了,其定义如下:

##### Port 0

配合 Level 1 和 2 头中的 `SEQUENCE` 字段使用。  
命令启用 `SEQUENCE` 后追加的对应字节 `SEQ_NUM[6:0]` 的低 7 位会每次自动加 1.  
而 `SEQ_NUM` 的第 7 位用来指示接收方是否要报告状态。  
Port 0 本身的命令不可以启用 `SEQUENCE`.  

Port 0 命令定义:
```
主动读目标的 SEQ_NUM:
  Write []
  Return: [SEQ_NUM] (如果没有记录 bit 7 置 1)

主动设置目标的 SEQ_NUM:
  Write [0x00, SEQ_NUM]
  Return: []

目标回复 SEQ_NUM:
  Write [SEQ_NUM]
  Return: None
```

实际示例:  
(`->` 和 `<-` 是端口层的数据流, `>>` 和 `<<` 是 CDNET 数据包层面的数据流,不含最低层的 CDBUS 的部分)

```
  设备 A                         设备 B       描述

  [0x00, 0x00]          ->      Port0        首次通讯设置对方的 SEQ_NUM
  Default port          <-      []           设置成功返回
  [0x88, 0x00, ...]     >>                   开始发送数据
  [0x88, 0x01, ...]     >>
  [0x88, 0x82, ...]     >>                   这次的数据标注了需要回复 SEQ_NUM @2
  [0x88, 0x03, ...]     >>
  [0x88, 0x04, ...]     >>
  Port0                 <-      [0x03]       回复 SEQ_NUM @2 (每成功接收一个包计数加 1, 回复当前计数 0x03)
  [0x88, 0x85, ...]     >>                   标注了需要回复 SEQ_NUM @5
  Port0                 <-      [0x06]       回复 SEQ_NUM @5
```

效率提升的重点就在这里,我们可以自行选择多久回复一次,而不是每次都要回复状态,如果最后一次数据包没有标注需要回复,那么会引发超时,然后主动读一次目标的 SEQ_NUM 以做同步。
之所以引发超时,是因为所有发出的数据包都不能立刻释放,要等确认对方收到才会释放,以防需要出错重传。
因为有 SEQ_NUM 号,所以即使同一个命令重复发送,对方也会只执行一次。

流控的功能也包含在内,譬如发送方时刻只允许最多 6 个数据包没有释放,那么等收到回复,释放掉 3 个,再发送 3 个数据包,这样可以最大化的利用总线带宽。
而且万一有多方发送数据至同一个节点,发送方也可以因频繁超时,来动态降低最大允许 pending 的数据包数量。

再来说大包拆分,也是很简单,拆分包有 3 个标记,分别是起始、继续、结束,譬如一个大包拆开了 4 个小包,且如果当前 SEQ_NUM 为 23,那么这四个小包的 SEQ_NUM 和标记对应关系就是:
```
23: 拆分启始标志
24: 继续
25: 继续
26: 结束
```
这样接收方也就很容易的把四个小包还原成原始的大包,万一出问题,也只是重新传输错掉或丢掉的包(及其后的包)。

为了简便,对于 CDNET 协议,并不是丢一个包就只重传一个包,其后传的包也需要重传,因为接收方只是简单判断序号,不对便拒绝接收,这么做是为了保持简单,毕竟错包、丢包的概率很低。


<br>
最后,想说的是,这篇文章的内容都是经过实践检验的,我有用来传输摄像头视频,DEMO 可以在这篇介绍文章中看到:https://github.com/dukelec/cdbus_doc/blob/master/intro_zh.md

协议的实现部分代码也是开源的,就是上面的 CDNET 连接,另外有一些使用 CDNET 的示例代码,譬如这个 STM32F103 的步进电机控制器:https://github.com/dukelec/stepper_motor_controller

当然,这些代码、库我也会进一步优化改善。

<br>
This work is licensed under a Creative Commons Attribution 4.0 International License.

这里贴的格式有些乱,原文地址:http://blog.dukelec.com/rs485-flow-control-and-ensure-data-integrity-zh
 楼主 | 2018-6-5 18:44 | 显示全部楼层
本帖最后由 dukedz 于 2018-6-5 18:45 编辑

实际上,CDNET 这个协议用起来可以非常简单,很可能你正在用的协议就非常的接近,但使用 CDNET 的好处是它充分考虑了各种功能扩展,同时保证简单易用、高性能、实时性和灵活性。
欢迎拍砖。
扫描二维码,随时随地手机跟帖
您需要登录后才可以回帖 登录 | 注册

本版积分规则

快速回复

您需要登录后才可以回帖
登录 | 注册
高级模式
我要创建版块 申请成为版主

论坛热帖

关闭

热门推荐上一条 /6 下一条

分享 快速回复 返回顶部 返回列表