1 设计基础
这里指的协议是应用层协议,针对应用协议的设计,需要注意的有几个基本点:可识别,兼容性,访问控制,可追溯,数据完整性校验。
首先是可识别,一般我们采用一个帧头来表示整个报文的起始位置,这个帧头可以用一个32位(uint32_t)的数值来标识,比如 0xFE01A0BC,大端序是 0xFE,0x01,0xA0,0xBC;
通常我们把这个数值称为魔数,magic number。
然后是兼容性,一般我们用一个字节来标识报文的版本号,这个版本号的作用是以后协议格式发生改变时,可以上下兼容;我们称这个字节标识为:revision;兼容的方式在应用逻辑上可以是这样的:上位机检查下位机的revision,协商一个两边都能解析处理的版本进行数据处理。
访问控制可以使用1个字节(uint8_t)或者2个字节(uint16_t),称为access control;数据的组织格式可以采用独热码的方式,也就是每个位表示一种标识,1个字节(8个位)最多8种标识,2个字节(16位)最多16种标识。比如0x0100表示同步帧,0x1000表示需要响应,那么0x0100 | 0x1000就标识这个帧是同步帧,而且需要响应。我们可以利用访问控制来做分包传输的逻辑。
可追溯一般指下发的报文和响应的报文要能够对得上,于是需要在每个报文中带上一个序列码,称为sequence number;可以是1到2个字节,每次传输都采用自加的方式,当报文需要响应时,响应报文的序列码保持一致。
接着就是用户数据了,一般采用2个字节(最多65535)来表示数据长度,紧接着是用户数据,也称为payload。
数据完整性校验可以采用CRC16的计算方式,需要2个字节;计算从包头到payload的结尾进行CRC16的运算,用来保证数据的完整性(没有发生篡改或者误码情况)。
2 协议格式
基于上述的出发点,我们可以把协议定义如下:
| 起始码 |
revision |
访问控制 |
序列码 |
数据长度 |
用户数据 |
CRC16 |
| 4 bytes |
1 byte |
2 bytes |
1 byte |
2 bytes |
—— |
2 bytes |
这样的协议基本能够满足大部分的应用场景了;访问控制也可以在细化为控制段和数据段,比如第一个字节表示控制信息,第二个字节存储数据。
同时需要说明的还有字节序的问题,也就是大端序还是小端序的问题,这里我们推荐使用大端序,所谓的大小端序,其实就是字节排列的顺序高低位的问题,比如0x12AB这个数据是16位,2个字节,大端序表示:0x12 0xAB,小端序反过来:0xAB 0x12;上下位机通信时保持一致即可。
还有一个需要注意的,串口通信传输的数据本质上是流式数据,也就是起始和结束需要我们自行判断处理,最容易出问题的也在这里,经常有读取串口数据,读到了就处理,然而在大数据块的传输下,发现经常会误码或者丢包;这其实是对流式数据理解程度不够导致的,因此我们每次读取串口数据后,都需要对数据进行预处理,得到完整报文再进行应用。
最后补充的是,同异步的问题,串口通信是全双工通信,是典型的异步数据处理逻辑,这个和SPI是有很大区别的,也是为什么主从之间强烈推荐使用串口通信而不是SPI的原因,因为可以做异步处理,提高整体效率,SPI传输快,但需要同步处理,非常麻烦。
3 zb_msg
3.1串口通信类serial
serial是一个完全和串口相关的类,不会涉及到应用逻辑;zb_msg是和应用逻辑相关的类,不会涉及到串口逻辑,这样就完成解耦了;zb_msg的命名方式是因为我们的从机是zigbee设备,因此称为zb_msg,意思是zigbee的消息,这个大家可以自行修改。
串口类的接口有:
serial:构造时需要指定串口所在的路径,比如/dev/ttyS0等,还有波特率,默认115200。
open:打开串口,需要指定硬件流控,数据位,停止位,校验位,建议默认即可。
close:用完关闭串口。
write:向串口写入数据。
on_stream:流式数据解析。
on_packet:完整报文,接收到数据后会回调这个函数。
这些接口的实现在serial.cpp源码中,我们挑出几个核心点来讲解,该源码完全可以用于产品开发。由于串口需要做异步处理,因此我们使用前面章节中的link来支持,link中新加了一个接口:link_listen_fd,用来监听文件描述符,有数据就异步返回。另外我们需要在驱动层确保driver是可用的,具体参考系统篇中的串口章节。
3.2 协议处理zb_msg
虽然是C++,但是我们的写法是嵌入式C++的方式,非常贴近C语言,仅仅使用了C++最基础的特性,如果大家想把C++改成C语言,按照继承逻辑就可以了。
接口中的commit是发送数据用的,payload是用户数据,调用该接口会自动打包成串口数据协议格式的数据。
这个类里面最重要的是on_stream和on_packet,其它函数都是解析报文后的用户层面逻辑,on_stream是用来解析流数据的,也就是收到串口数据后,判断是否是完整的报文,具体代码如下:
代码的逻辑就是不断地探测buffer,直到报文格式完整(注意:这里并不会判断数据是否出错,只保证是一个完整包)为止。当然还有个地方有待完善,就是不同revision的判断也是有差别的,这个大家可以进行修改。
接着就是on_packet了:
- void zb_msg::on_packet(const char *data, uint16_t len)
- {
- if (len < 4 || !(data[0] == 0xFE && data[1] == 0x01 && data[2] == 0xA0 && data[3] == 0xBC)) return;
- /* CRC16 */
- uint16_t crc16 = ((uint16_t)data[len-2] << 8) | (uint16_t)data[len-1];
- /* Check CRC16 */
- if (crc16 != crc16_x25((const uint8_t *)data, len - 2)) return;
- /* Revision */
- uint8_t revision = data[4];
- /* Access control: control flag */
- uint8_t actrl = data[5];
- if (actrl & ZB_MSG_ACTRL_SYNC) {
- if (revision > this->revision_) this->commit(NULL, 0, (uint16_t)ZB_MSG_ACTRL_SYNC << 8, 255);
- else this->revision_ = revision;
- return;
- }
- if (revision > this->revision_) { this->commit(NULL, 0, (uint16_t)ZB_MSG_ACTRL_REVISION_ERR << 8, 0xFF); return; }
- if (revision == 0x00) {
- /* Access control: data flag */
- uint8_t adata = data[6];
- /* Seq */
- uint8_t seq = data[7];
- /* Payload length */
- uint16_t size = ((uint16_t)data[8] << 8) | (uint16_t)data[9];
- if (actrl & ZB_MSG_ACTRL_ACK_REQ) this->commit(NULL, 0, (uint16_t)ZB_MSG_ACTRL_ACK_RSP << 8, seq);
- this->handle((const uint8_t *)&data[10], size);
- (void)adata;
- }
- }
代码逻辑比较简单,检测完整性,判断revision进行兼容,并处理访问控制符,最后把用户数据交给handle去处理。复杂一点我们还可以根据访问控制进行分帧处理,访问控制符的标识我们可以自己定义,比如定义同步报文,定义是否需要响应标识等。
然后我们看main函数怎么进行使用:
数据的处理是在zb_msg中的handle函数,我们的用户逻辑和业务代码就在该函数中去处理即可。
3.3交叉编译测试
整个代码树如下:
Makefile已经指定了编译工具还有目标文件等:
直接make即可:
无警告和错误。
目标文件是zb_msg,可以直接copy到板子上运行。
============================================================================================================================================================
如果觉得对您有帮助并想进一步深入学习交流可以扫描以下微信二维码或加入QQ群:928840648
欢迎共同学习成长,有一群爱学习的小伙伴一起勉励!!一起加油!!也可点击
笔者基于嵌入式系统框架内容如下整理编辑: