Unity3D C# 从零自定义通讯协议

2020年5月5日 0 作者 老王

1 引言

博主大学学的电气工程,毕业后做了一年多地铁环控和低压配电自控系统的工作,期间经常要调试各种各样的设备,比如说电表、PLC、电机。和这些设备打交道的一个核心就是要明白设备支持的通讯协议,比如工业上常用的Modbus Rtu、Modbus Tcp。那时只简单知道这些通讯协议该怎么用,该怎么发数据去设定参数或读取数据,但是不清楚为什么这些协议会这么规定。直到后来从事开发工作,在一个项目中自定义了通讯协议,才算理解了那些通讯协议如此定义的原因。
在这期间,我走了很多弯路,希望这篇文章能够帮助到有需要的同学。
接下来,咱们会根据一个简化后的通讯协议模型,然后刨析这个简化后的模型,并据此一步步实现一个咱们自己的通讯协议,包括

  1. 帧头帧尾的作用,帧头帧尾该怎么选
  2. 校验码的作用,校验码该如何计算
  3. 内容长度的作用,如何解决粘包、分包问题
  4. 版本号的作用
  5. 指令号该取多少个字节
  6. 大端模式、小端模式
  7. 什么是心跳包
  8. 协议加密
  9. 协议接收与解析框架。

2 简化的通讯协议模型

如图,这是简化后的通讯模型。有些通讯协议可能会没有版本号,但那些都是非常成熟的协议了,如果我们自定义协议的话,最好还是加上,方便以后的版本升级。
无论我们通过串口还是socket还是其它什么方式与设备或服务器通讯,发送的数据本质上都是Byte数组。这个Byte数组里面存放的数据是按照如下图的顺序排列的,比如说前几个Byte代表帧头,帧头后几个Byte代表版本号,依次类推(这个“几个Byte”中的几是我们人为约定好的)。理解了这一点,下面就来刨析一下,并从零开始设计一个属于咱们自己的通讯协议。
通讯协议的简化模型

2.1 帧头、帧尾

帧头、帧尾的作用是什么?答案是用来标识,标识帧头、帧尾中间这一段数据是我们自定义的协议数据。试想,许多种设备连接着我们的上位机,每种设备的通讯协议各不相同,只有其中一个是按照我们定义的协议进行收发数据的,那么我们上位机如何去把这个设备识别出来呢?答案就是通过我们独特的帧头和帧尾。
那么怎么设计才能做到独特呢,答案是随机选2 ~ 4个数然后组合起来,这个数的范围是(0x00, 0xff),不要选用0x00和0xff,因为许多设备的杂散信号也是0x00和0xff。那为什么选2 ~ 4个呢?答案是使用1个Byte当作帧头帧尾的话,我们发送或接收到的数据中可能会含有大量相同的数据,其实起不到标识的作用,会增大咱们的计算量。而超过4个之后,就完全没必要了,因为2 ~ 4个已经完全能够标识出咱们的协议了,多了反而会浪费内存。(ps: 0x00是16进制的0,0xff是16进制的255,文章后面都是使用16进制表示数字,以后不再赘述)
我们一般用两个Byte来表示帧头和帧尾,帧头和帧尾一般是互相颠倒的。什么意思?比如说咱们决定使用0xdd和0x99来表示帧头,那帧尾就是0x99和0xdd。所以,添加帧头、帧尾后的协议如下。
添加帧头和帧尾后协议

2.2 校验码

校验码有什么用?答案是用来验证这一段数据是否是有效的数据。
上一节咱们根据帧头、帧尾找到了咱们收发的数据,试想一种情况,有一段杂散数据刚好也满足咱们的帧头、帧尾,那我们如何把这段数据给辨别出来呢?答案就是通过校验码。那么校验码是如何辨别的呢?步骤如下:
1.根据帧头、帧尾和后面要说的长度把内容以及校验码给全部提取出来
2.将内容按照咱们约定好的计算方法去计算校验码
3.然后比对咱们计算的校验码与提取的校验码,如果它们相等,就认为这段数据是有效的(然后进行解析再处理),不等则人为无效(判定无效之后有些人会直接舍弃掉这段数据,但是不建议直接舍弃,而是只把帧头给舍弃掉,然后再继续查找,为什么要这样做见2.3节中的协议处理流程)。
现在有两个问题,校验码的计算方法该如何定义?校验码一般占几个Byte?
校验码的计算方法有别人已经定义好的,如CRC16、CRC32等等。当然咱们也可以自己定义计算方法,有一个基本要求是内容中的每一个Byte都要参与计算。我们这里就选用CRC16来计算校验码,校验码占2个Byte就行了,原理类似帧头帧尾,少了不够多了浪费。
添加校验码后的指令

2.3 长度

长度表示内容的长度。为什么要加一个长度,通过找到帧头、帧尾不就已经可以把数据提取出来了吗?答案是不能,光靠帧头帧尾并不能将数据提取出来,为什么?先考虑一种情况,假设我们发送的内容中包含和帧尾一样的数据,比如这里的0x99和0xdd,且我们没有将内容的长度发送过去,这时我们取出来的数据肯定是不能通过校验的,那么问题来了,本来这条数据应该是有效的,但是只是因为我们没有取对!除此之外,还有粘包分包的问题。解决这些问题的一个方法就是加上一个内容的长度。
那为什么加上长度之后就能取出正确的数据了呢?这里直接给出处理数据的流程(假设通过Tcp协议接收数据),相信大家看了就会明白了。

  1. 首先我们定义一个List<Byte>集合
  2. 然后开启Tcp监听端口
  3. 将每一次收到的数据(Byte数组)都添加到1的集合中
  4. 另外单独开一个线程去处理1中集合中的数据
  5. 首先从集合的第一个元素开始找帧头,如果前两个元素不是帧头就直接抛弃掉(从集合中移除)
  6. 找到帧头后,再根据长度去把内容提取出来,如果发现这一次处理的时候数据还未收完(Tcp分包了,发送的内容较长时会出现这种情况),就等待,直到收到的长度足够了,再进行步骤7
  7. 然后检测帧尾,并计算校验码。如果校验码通过,就从集合中将这段数据拷贝出来进行解析,同时集合中移除这一段数据,并回到步骤5继续重复执行5、6、7;如果校验不通过,则把帧头帧尾移除掉,再回到步骤5继续重复执行5、6、7

至于长度需要占几个Byte,一般选2个Byte及以上(原因还是1个Byte不够用),2个Byte能表示的值的范围为0~65535,如果项目发送给的内容长度不会超过这个范围,就选2个Byte就行。我们这里就选择2Byte。
加上长度的指令

2.4 版本号

为什么设计的协议要带一个版本号?为了方便升级维护!试想一下,我们做为客户端,有一天突然改动了某一条指令的定义,这时同一条指令就会有两个版本,但是指令中没有版本号,这时服务器那边该怎么办?服务器只能一脸懵逼不知道该如何解析,不知道该用旧的指令定义去解析还是改用新的指令定义去解析。
那版本号该怎么定义?我一般用两个Byte来表示,如第一个Byte表示大版本号,第二个Byte表示小版本号,如0x01 0x00就表示版本号为1.0,以此类推。当然这个可以约定好规则定义的。
加上版本号的指令

2.5 内容

内容中包含什么完全是由我们协议制定者规定的。一般前两个Byte表示指令号,为什么用2Byte来表示指令号?还是那个原因,2个Byte能够表示65536个指令,而如果用1个Byte的话只能表示256个指令,如果项目大了,可能完全不够用的!我们做项目什么地方都得留一手,刚好够用的话后期扩展会很蛋疼的。
指令号

2.6 大端模式、小端模式

我们知道一个int值占4个Byte,比如123456用16进制表示为0x0001e240,这时我们要把这个数据发送给服务器,那是按照0x00、0x01、0xe2、0x40(大端模式,先发送高位再发送低位)的顺序发送给服务器还是按照0x40、0xe2、0x01、0x00(小端模式,先发送低位再发送高位)发送给服务器呢?这个在设计协议时就得约定好,然后全部统一,不要一会儿用大端模式,一会儿用小端模式。

2.7 心跳包

心跳包地作用就是告诉对方(服务器)我一直还“活”着在,服务器端可以用来判断客户端是否还在正常工作。
心跳包的原理就是客户端定时发送(5秒,或1分钟,这个时间可以按照项目需要更改)一条指令给服务器,然后服务器回复。有些协议心跳包做得比较简单,就是一条简单的指令,什么都不带。如下是最简单的心跳包(不带任何附加内容),假设为心跳包的指令号为0x00 0x01。
最简心跳包
复杂一点的心跳包可以如下设计:

  1. 客户端定时发送心跳包,心跳包的内容包含有累加数C1(有些协议还会带有时间戳)
  2. 服务器收到客户端的心跳包后,将收到的累加数加1,即C2=C1+1,并回复给客户端;同时服务器开始计时,如果规定时间(按项目需要设定,如3分钟)内没有收到客户端包含C2的心跳包,就认为客户端断线,然后服务器强制断开这个客户端的连接
  3. 客户端收到服务器回复的心跳包后,将服务器发过来的累加数C2提取出来,然后下次发送心跳时就将C2发送过去

复杂一点的心跳包

2.7 指令加密

指令加密,其实就是对指令中的内容部分进行加密,加密后的内容长度会改变,这时就需要重新拼成新的一条指令再发送,然后服务器端收到之后,先把内容提取出来,然后进行解密之后再解析内容。
为什么要加密?很简单,为了避免别人模拟设备来做一些非法的事情。
简单的加密使用对称加密算法就是可以的,如des、aes加密,这里就不展开说了,就是调用api而已。
目前常用的加密方式是服务器发token的方式:每个客户端连接的时候,服务器都发送一条指令给这个客户端,这条指令中包含了一个永远唯一的token,以后客户端每次发送指令的时候,内容中都必须带有这个token,服务器端通过比对客户端发送过来的token和服务器端存储的token是否一致来决定客户端的指令是否有效。另外,服务器端会定时更改这个客户端的token,同时token中包含有时间戳,服务器可以用来检测超时。
这个有点类似于jsonwebtoken,大家可以去了解一下。

2.8 协议接收与解析框架

暂时未完成,todo.

ps:所有协议万变不离其宗,相信把以上部分弄懂,面对新的协议咱们也能以不变应万变。