1,应用背景分析
在很多的实际工程应用中,通讯方式通常是RS232、RS485、I2C和SPI等等。这类接口都有一个共同的特点:按照字节流的方式来进行通讯,即每中断一次,表明成功传送或者接收一个字节。
还有一些接口传输的是数据块,即一次传送或者接收多个字节,比如CAN、USB和以太网等等。CAN和USB通常一次传输几十个字节,和字节流的方式类似。所不同的是,我们可以利用CAN和USB接口的一些优势,简化通讯协议和提高可靠性。比如,CAN和USB都具有硬件校验的功能,那么我们就不需要在通讯协议中增加校验域。CAN网具有冲突检测和自动重传的功能,那么我们可以很容易的实现多点对多点通讯,相比之下,RS485适合做一点对多点的通讯,要实现多点对多点通讯比较麻烦,需要解决冲突检测问题自动重传等问题。
而以太网一次可以传输1K多个字节。由于以太网有专门的TCP/IP协议栈来处理,这里我们不讨论。
按字节方式域小数据块方式(CAN和USB)通讯的接口,不同之处在于:一次接收的数据长度不一样。为了提高算法的通用性,我们应该抽象这些硬件接口的不同,也就是说,我们应该屏蔽不同接口之间的差异。对于的数据处理程序来说,它可以认为这类的接口传过来的是字节流,也就是一串数据。这里指的USB接口是指嵌入式设备的USB做从口来实现和主机通讯的情况。
2 字节流通讯协议的一般格式
字节流通讯协议一般包含这样几个域:
前导码 + 帧长度 + 帧号 + 数据域 + 校验
实际的通讯协议需要定义各个域的长度和每一个bit的确切的含义。有的通讯协议将帧长度放在帧号的后面。
3 字节流数据处理算法的实现
我们先看看什么是循环FIFO缓冲区。
FIFO缓冲区是具有先进先出功能的缓冲区。可以使用如下结构体来定义:
Typedef Struct Buffer_t{
Int head;
Int tail;
Char da
}Buffer;
这个结构体非常简单,head记录缓冲区的头,tail记录缓冲区的尾,da
(tail + BUFFER_LEN - 1 - head)%BUFFER_LEN。
在这个结构体的基础上,按照FIFO的方式实现缓冲区的初始化、写入和读出3个函数,也就实现了FIFO缓冲区。循环缓冲是指当数据写到最后一个da
实现算法时,需要注意以下几点:
1,缓冲区空和满的判断条件:当head和tail相等的时候,缓冲区空,而当缓冲区中已经写入了BUFFER_LEN-1个数据时,缓冲区满。为什么不写入BUFFER_LEN个数据呢?因为写入BUFFER_LEN个数据后,head和tail相等,这就无法判断缓冲区是空还是满。
2,写入和读出的策略
当读取或者写入缓冲区时,需要检查缓冲区中的数据或者空间是否足够。
在读取时,如果没有足够的数据,是读取已有的数据还是不读取任何数据,而在写入时,如果空间不够,是部分写入还是不写入任何数据,这取决于你的应用。一般情况下,在空间不够时,可以不做任何操作。当出现上述情况,留给上层的程序去处理。在实际应用中,如果读取和写入的程序设计的合理,缓冲区的大小合适,一般是不会出现写入失败的情况的。
另外一个编程的细节是,对于数组的操作,一定要小心数组越界的情况。千万千万要严格检查,否则,在调试的时候,他可能让你郁闷很长时间。
你可能早就想问,为什么要使用循环缓冲区呢?不着急,咱们来慢慢分析。
了解了字节流设备的底层细节,也了解了字节流设备的一般协议格式,还知道了循环FIFO缓冲区的原理和实现方法,我们不难看出,缓冲区具有如下的优点:
1,对于上层函数,抽象了对硬件接口的操作细节,提高了可移植性。简单一点说,就是将上层函数和底层驱动(实际完成接口操作的函数)之间通过循环FIFO缓冲连接起来。当底层的接口由RS232换成CAN或者USB等等接口时,对通讯协议处理的上层的算法不用改动,只需要改动底层的驱动,将接收的数据正确的放入缓冲区或者将缓冲区中要发送的数据发送出去就可以了。
2,使用循环缓冲区,可以非常方便的实现:前导码的搜索,错误包的处理
前导码的搜索:前导码通常位于一个有效数据包的前端,因此,对循环缓冲区实现一个scan函数,完成对接收缓冲区中前几个数据的浏览功能,即只是查看(复制出来),不从缓冲区中读出。假设我们的前导码是:0xA5,0x5A,0xA5,0x5A,处理前导码的搜索可以这样:每次从缓冲区中scan出4个字节,与前导码比较,如果相同,则搜索成功,如果不同,则从缓存区中读出一个字节丢弃之,再scan缓冲区,直到搜索成功。
我们来看看协议的一般处理流程:
数据处理的起始条件是:缓冲区中的有效数据长度大于或者等于最小包长。
1,搜索前导码:如果成功,转到2。失败则丢弃一个字节,继续搜索。
2,帧长度的检查:scan出长度域,通过协议中可能出现的最大和最小包长检查,如果正常,则转到3,否则丢弃一个字节,转到1。
3,帧号的检查:scan出帧号,检查帧号是否为有效的帧号,有效,则转到4,否则,丢弃一个字节,转到1。
4,校验和的检查:scan出长度后的数据域和校验域,检查校验和是否正确,错误则丢弃一个字节,转到1。如果正确,则读取这一完整的帧,取出帧号和数据域。转到5。
5,根据帧号,执行相应的操作。
利用循环FIFO缓冲区的特性,提供对数据的缓存和对字节流数据灵活多样的访问方式,来对协议的各个域进行严格检查,实现对部分域错误的包和不完整的包的完美过滤,以及对混乱数据中正确包准确无误的抽取。所有的这些特性,正是字节流设备所需。
4 算法的改进
4.1 Buffer数据结构的改进
这个数据结构是我最早设计Buffer数据结构设计的模型,它满足了我当时工程的需要。但是,有其自身的缺陷,也许你已经想到了。
由于我先前的设备只有一个接口使用这个Buffer,运行的非常好。当我在做新的项目时,有多个接口需要这样的buffer,问题就出现了:我的Buffer的长度是一个固定值(BUFFER_LEN),如果多个接口需要的缓冲区不一样,这个Buffer就不能很好的满足,也就是说,我的Buffer是一个固定的值,不能灵活的改变来适应不同接口对Buffer长度的不同需要。因此,做如下改进:
Typedef Struct Buffer_t{
Int head;
Int tail;
Char *da
Int len;
Void *psem;
}Buffer;
对Buffer初始化时,传送一个由外部声明的全局数组指针和数组长度,来实现对da
需要注意的是,da
4.2 面向接口的buffer
我们知道,通讯接口一般都有接收和发送功能,那么,针对一个接口,我们需要两个缓冲区,一个用于接收,另一个用于发送。因此,可以定义如下结构:
Typedef Struct Com_Buffer_t{
Buffer TxBuf;
Buffer RxBuf;
}Com_Buffer;
在此结构体的基础上,实现对设备的读、写和浏览等功能即可。
4.3 使用堆提高内存的使用率
如果我们的系统中有多个字节流设备,每一个字节流设备将使用两个全局数组来作缓冲区。实际中,我们的这些接口多半不会一直工作,但它却永远的占用了内存。如果在设备需要工作的时候,动态的获取内存,使用完后,释放内存,这将减少设备对内存的占用时间,提高内存的使用率。
使用堆需要注意的是,如果直接使用库中所实现的malloc函数,极有可能出现内存碎片。因此,应该使用专门的内存管理的函数。
4.4 关于前导码的丢弃
我们先前假设前导码是:0xA5,0x5A,0xA5,0x5A,实际上,当前导码搜索成功后,我们在丢弃的时候,可以一次丢掉2个字节,因为前导码前两个字节和后两个字节是一样的。类似的,如果前导码为“ABCA”,一次可以丢掉3个字节,如果前导码为“ABCD”,一次可以丢掉4个字节。显然,这个可以加快搜索的速度。而对于前导码的搜索,也可以采用相同的技巧。
4.5 其他有用的buffer操作函数
1,buffer的清空函数:用来将缓冲区清空,准备接收新的数据。最好将所有的数据清零。
2,buffer的数据丢弃函数:替换用从缓冲区读来丢弃数据,提高程序的执行速度。
5 循环FIFO缓冲区的缺点
老子说过:“祸兮福之所倚,福兮祸之所伏”。中国的先哲太伟大了。
世界上的任何事物,都具有两面性。优点,正是其缺点的所在。
循环FIFO缓冲区将字节流按照先后顺序排列成一个有序数据块来存储,供上层函数来读取,抽象了接口。但是,先进先出的特性也表明,先到的命令先执行,后到的命令后执行。对于紧急的命令,也只能等到前面的命令执行完了才能执行。如果在紧急命令之前有很多耗时的命令,那么,紧急命令的实时性将很难得到保证。
在实际工程中,这种需求可能很少。但我们还是要考虑这些情况,防止意外发生。
其实,提高命令响应的实时性是有办法解决的,参考的原型就是串口的软件流控方式。
实现原理是这样:设置一个紧急命令字节(URGENT_CHAR = 0xFF,FILL_CHAR=0x00),当串口收到紧急命令字节时,清空buffer,等待紧急命令的到来。为了实现数据的透明传输,显然在实现缓冲区发送时,需要做字节填充,也就是当用户的数据中存在紧急命令字节时,在其后填充一个FILL_CHAR。
到此为止,问题视乎完美解决了。
6 解决了旧的问题,新的问题又将出现
无论是在带有OS的系统中还是在没有OS的系统中,中断中操作的函数必须可重入的,在操作的全局变量时,也要特别小心。buffer的清空函数正是集这两个问题于一身。
当任务在读取缓冲区时,紧急命令字节到达,执行buffer的清空函数,将损坏缓冲区的数据。中断返回到应用程序,读取的缓冲区数据是错误的,有可能导致执行错误的命令。
如果在命令校验为正确之前,清空缓冲区不会出现任何错误。只有当命令校验正确后,从缓冲区读取数据时,发生中断,清空缓存区会导致读出的数据不正确,可能发生异常的情况。
在OS系统中,很好解决。可以杀死数据处理任务,创建紧急任务来处理紧急命令。
在没有OS的系统中,我们可以设置一个紧急标志,当读取数据返回时,检查这个标志,如果有紧急命令,则返回读取失败。然而,这并不能完美的解决仅仅命令的实时性问题。
有两种糟糕的情况:
1,当紧急标志的判断之后出现紧急命令,意味着紧急命令还将等待一条命令的执行完毕。
2,当紧急命令发送之前,程序在处理一个耗时较长的命令,意味着紧急命令还将等待这条条命令执行完毕。
7 总结
实际使用中,如果对实时性有要求的话,强烈建议使用实时操作系统。没有实时性要求的系统,根据自己的实际情况,权衡利弊,对以上策略选择性的实现。