安卓与串口通信-数据分包的处理

前言

本文是安卓串口通信的第 5 篇文章。本来这篇文章不在计划内,但是最近在项目中遇到了这个问题,正好借此机会写一篇文章,在加深自己理解的同时也让大伙对串口通信时接收数据可能会出现分包的情况有所了解。

其实关于串口通信会可能会出现分包早有耳闻,但是我自己实际使用时一直没有遇到过,或者准确的说,虽然遇到过,但是并没有特意的去处理:

分包?不就是传过来的数据不完整嘛,那我把这个数据丢了,等一个完整的数据不就得了。

亦或者,之前使用的都是极少量的数据,一次读取的数据只有 1 byte ,所以很少出现数据包不完整的情况。

何为分包?

严格意义上来说,其实并不存在分包的概念。

因为由于串口通信的特性,它并不知道不知道也无法知道所谓的 “包” 是什么,它只知道你给了数据给它,他就尽可能的把数据发出去。

因为串口通信时使用的是流式传输,也就是说,所有数据都是以流的形式进行发送、读取,也不存在所谓的“包”的概念。

所谓的“包”只是我们在应用层人为的规定了多少长度的数据或者满足什么样格式的数据为一个“包”。

而为了最大程度的减少通信时的请求次数,在处理数据流时,通常会尽可能多的读取数据,然后缓存起来(即所谓的缓冲数据),直至达到设置的某个大小或超过某个时间没有读取到新的数据。

例如,我们人为的规定了一个数据包为 10 字节,PLC 或 其他串口设备发送时将这 10 个字节的数据连续的发送出来。但是安卓设备或其他主机在接收时,由于上面所说的原因,可能会先读到 4 字节的数据,再读到 6 字节的数据。也就是说,我们需要的完整数据不会在一次读取中读到,而是被拆分成了不同的“数据包”,此即所谓的 “分包”:

1

怎么处理分包?

其实谜底就在谜面上,通过上面对分包出现的原因进行简单的解释之后,相信大伙对于怎么解决分包问题已经有了自己的答案。

解决分包的核心原理说起来非常简单,无非就是把我们需要的完整的数据包从多次读取到的数据中取出来,再拼成我们需要的完整数据包即可。

问题在于,我们应该怎么才能知晓读取到数据属于哪个数据包呢?我们又该怎么知道数据包是否已经完整了呢?

这就取决于我们在使用串口通信时定义的协议了。

一般来说,为了解决分包问题,我们常用的定义协议的方法有以下几种:

  1. 规定所有数据为固定长度。
  2. 为一个完整的数据规定一个终止字符,读到这个字符表示本次数据包已完整。
  3. 在每个数据包之前增加一个字符,用于表示后续发送的数据包长度。

固定数据包长度

固定数据长度指我们规定每次通信时发送的数据包长度都是固定的长度,如果实际长度不足规定的长度则使用某些特殊字符如 \0 填充剩余的长度。

对于这种情况,非常好处理,只要我们每次读取数据时都判断读取到的数据长度,如果数据长度没有达到符合的固定长度,则认为读取数据不完整,就接着读取,直至数据长度符合:

val resultByte = mutableListOf<Byte>()
private fun getFullData(count: Int = 0, dataSize: Int = 20): ByteArray {
    val buffer = ByteArray(1024)
    val readLen = usbSerialPort.read(buffer, 2000)
    for (i in 0 until readLen) {
        resultByte.add(buffer[i])
    }
    
    // 判断数据长度是否符合
    return if (resultByte.size == dataSize) {
        resultByte.toByteArray()
    } else {
        if (count < 10) {
            getFullData(count + 1, dataSize)
        }
        else {
            // 超时
            return ByteArray(0)
        }
    }
}

但是这种方式也有一个明显的缺点,那就是使用场景局限性特别强,只适合于主机发送请求,从机器回应的这种场景,因为如果是在从机不停的发送数据,而主机可能在某个时间段读取,也可能一直轮询读取的情况下,光靠数据长度判断是不可靠的,因为我们无法确保我们读到的指定长度的数据一定就是同一个完整数据,有可能参杂了上一次的数据或者下一次的数据,而一旦读取错一次,就意味着以后每次读取的数据都是错的。

增加结束符

为了解决上述方式导致的局限性,我们可以给每一帧数据增加一个结束符号,通常来说我们会规定 \r\n 即 CRLF (0x0D 0x0A)为结束符号。

所以,我们在读取数据时会循环读取,直至读取到结束符号,则我们认为本次读取结束,已经获得了一个完整的数据包:

val resultByte = mutableListOf<Byte>()
private fun getFullData(): ByteArray {
    var isFindEnd = false

    while (!isFindEnd) {
        val buffer = ByteArray(1024)
        val readLen = usbSerialPort.read(buffer, 2000)
        if (readLen != 0) {
            for (i in 0 until readLen) {
                resultByte.add(buffer[i])
            }
            if (buffer[readLen - 1] == 0x0A.toByte() && buffer.getOrNull(readLen - 2) == 0x0D.toByte()) {
                isFindEnd = true
            }
        }
    }

    return resultByte.toByteArray()
}

但是这个方法显然也有一个缺陷,那就是如果是单次间隔读取或者轮询时第一次读取数据有可能也是不完整的数据。

因为我们虽然读取到了结束符号,但是并不意味着这次读取的就是完整的数据,或许前面还有数据我们并没有读到。

不过这种方式可以确保轮询时只有第一次读取数据有可能不完整,但是后续的数据都是完整的。

只是单次间隔读取的话就无法保证读取到的是完整数据了。

在开头增加数据包长度

和增加结束符类似,我们也可以在数据包开头增加一个特殊字符,然后在后面紧跟着一个指定长度(1byte)字符指定接下来的数据包长度有多长。

这样,我们就可以在解析时首先查找这个开始符号,查找到之后则认为一个新的数据包开始了,然后读取之后 1byte 的字符,获取到这个数据包的长度,接下里按照这个这个指定长度,循环读取直到长度符合即可。

具体读取方式其实就是上面两种方式的结合,所以这里我就不贴代码了。

最好的情况

最方便的解决数据分包的方法当然是在数据中既包括固定数据头、固定数据尾、甚至连数据长度都是固定的。

例如某款温度传感器,发送的是数据格包为固定 10 位长度,且有结束符 CRLF,并且数据包开头有且只有 -+ (0x2B 0x2D 0x20)三种情况,那么我们在接收数据时就可以这么写:

val resultByte = mutableListOf<Byte>()
val READ_WAIT_MILLIS = 2000
private fun getFullData(count: Int = 0, dataSize: Int = 14): ByteArray {
    var isFindStar = false
    var isFindEnd = false
    while (!isFindStar) { // 查找帧头
        val buffer = ByteArray(1024)
        val readLen = usbSerialPort.read(buffer, READ_WAIT_MILLIS)
        if (readLen != 0) {
            if (buffer.first() == 0x2B.toByte() || buffer.first() == 0x2D.toByte() || buffer.first() == 0x20.toByte()) {
                isFindStar = true
                for (i in 0 until readLen) { // 有帧头,把这次结果存入
                    resultByte.add(buffer[i])
                }
            }
        }
    }

    while (!isFindEnd) { // 查找帧尾
        val buffer = ByteArray(1024)
        val readLen = usbSerialPort.read(buffer, READ_WAIT_MILLIS)
        if (readLen != 0) {
            for (i in 0 until readLen) { // 先把结果存入
                resultByte.add(buffer[i])
            }
            if (buffer[readLen - 1] == 0x0A.toByte() && buffer.getOrNull(readLen - 2) == 0x0D.toByte()) { // 是帧尾, 结束查找
                isFindEnd = true
            }
        }
    }


    // 判断数据长度是否符合
    return if (resultByte.size == dataSize) {
        resultByte.toByteArray()
    } else {
        if (count < 10) {
            getFullData(count + 1, dataSize)
        }
        else {
            return ByteArray(0)
        }
    }

粘包呢?

上面我们只说了分包情况,但是在实际使用过程中,还有可能会出现粘包的现象。

粘包,顾名思义就是不同的数据包在一次读取中混合到了一块。

如果想要解决粘包的问题也很简单,类似于解决分包,也是需要我们在定义协议时给出能够区分不同数据包的方式,这样我们按照协议解析即可。

总结

其实串口通信中的分包或者粘包解决起来并不难,问题主要在于串口通信一般都是每个硬件设备厂商或者传感器厂商自己定义一套通信协议,而有的厂商定义的协议比较“不考虑”实际,没有给出任何能够区分不同数据包的标志,这就会导致我们在接入这些设备时无法正常的解析出数据包。

但是也并不是说就没有办法去解析,而是需要我们具体情况具体分析,比如温度传感器,虽然通信协议中没有给出数据头、数据尾、数据长度等信息,但是其实它返回的数据格式几乎都是固定的,我们只要按照这个固定格式去解析即可。