安卓与串口通信-校验篇

前言

一些闲话

时隔好几个月,终于又继续更新安卓与串口通信系列了。

这几个月太颓废了,每天不是在睡觉就是虚度光阴,最近准备重新开始上进了,所以将会继续填坑。

今天这篇文章,我们来说说串口通信常用的几种校验方式的原理以及给出计算代码,当然,因为我们讲的是安卓的串口通信,所以代码将使用 kotlin 来编写。

基础知识

在正式开始我们今天的内容之前,我先提一个问题:什么是数据校验?以及为什么要进行数据校验?

其实如果有看过我们这系列的前面几篇文章的话,相信这个问题不用我说你们也会知道答案。

正如我们前面文章中也介绍过的,在串口通信中,由于可能受到静电之类的电磁干扰,会使得传输过程中的电平发生波动,最终导致数据出错。

并且串口通信实际上是采用帧数据传输的,数据可能会被分割成很多份来传输,如果出现错误应当及时告知发送方,并让其重传,以保证数据的可靠性和完整性。

我们回顾一下之前的知识:在串口的一帧数据中,包含了 起始位、数据位、校验位、停止位 四个不同的数据区块。其中的校验位就是用来存放我们今天要讲的校验信息的。

串口一帧数据有 5-8 位,校验位只有 1 位,所以在一帧数据中的校验通常使用的是奇校验(odd parity)和偶校验(even parity)。不过实际使用过程中大多数情况下都会选择不设置校验位(none parity),转而在自己的通信协议中另外使用其他的校验方式校验整体的数据,而不是校验单独一帧的数据。

而对于整体数据的校验,校验方法就多得多了,常见的有以下几种:校验和、BCC、CRC。

常见校验算法

奇偶校验(Parity)

正如前言中所述,奇偶校验通常用于一帧数据的校验,因为它的算法很简单,而且校验码也只需要一位。

奇偶校验的原理十分简单,就是在数据的末尾添加 1 或 0,使得这串数据(包括末尾添加的一位)的 1 的个数为奇数(奇校验)或偶数(偶校验)。

例如:需要传输数据 01101001

如果使用 奇校验 的话,由于原数据中 1 的个数是 4 个,是偶数个,所以我们需要在末尾添加 1 使其变为 5 个 1 ,也就是奇数个 1,即带有奇校验的数据应该是 011010011

而如果使用偶校验的话同理,由于原数据中已经是偶数个 1 了,所以在末尾应该添加一个 0,即带有偶校验的数据应该是 011010010

奇偶的校验由于简单、快速、效率高,对数据传输量小的场景比较适用。此外,与其他校验方式相比,奇偶校验的计算量较小,对于嵌入式设备和低功耗设备等资源有限的场景更为适合。

但是,奇偶校验只能检测单比特错误,不能检测多比特错误,什么意思呢?比如我们有一个数据 1110111001 在传输过程中受到干扰变成了 0100011000 ,此时如果使用奇偶校验,那么这个数据是可以校验通过的,因为它的数据中多个比特都受到了干扰,恰好还是保持了 1 的个数为奇数个,所以使用奇偶校验并不能校验出这个问题来。

下面贴上一个使用 kotlin 实现的奇偶校验代码:

fun evenParity(data: Byte): Byte {
    var numOnes = 0
    var value = data.toInt()
    for (i in 0 until 8) {
        if (value and 0x01 != 0) {
            numOnes++
        }
        value = value shr 1
    }
    
    return if (numOnes % 2 == 0) 0 else 1
}


fun main() {
    val data = 0x69.toByte() // 1101001

    println("偶校验位=${evenParity(data)}")
}

evenParity() 函数会输出传递的 data 的校验位,上面的代码会输出:

偶校验位=1

当然,计算奇校验位同理,只要把函数返回的地方改为 return if (numOnes % 2 == 0) 1 else 0 即可。

这里的使用的算法也非常简单,就是把传入 data 的每一位从左到右依次对 1 做与运算,如果运算结果不为 0 即该位是 1, 则 1 的数量加一,然后判断 1 的数量是否为偶数。

所以其实还有一个更简短的方法,那就是 kotlin 其实已经给我们封装好了计算一个 byte 中 1 的比特数的函数:

fun evenParity(data: Byte): Byte {
    val numOnes = data.countOneBits()
    
    return if (numOnes % 2 == 0) 0 else 1
}


fun main() {
    val data = 0x69.toByte() // 1101001

    println("偶校验位=${evenParity(data)}")
}

对了,一个 byte(字节)等于 8 个比特(就是八个不同的0和1)这个是基础中的基础,应该不用我再说了吧?哈哈哈。

ps:在实际使用中,我们还可以定义校验位为:

  1. PARITY_MARK,即校验位恒为 1
  2. PARITY_SPACE,即校验位恒为 0

校验和(Check Sum)

校验和的算法也十分好理解,就是把要发送的所有数据依次相加,然后将得出的结果取最后一个字节作为校验码附在数据后面一起发送。

接收端在接收到数据和校验码后同样将数据依次相加得到一个值,将这个值的最后一子节与校验码对比,如果一致则认为数据没有出错。

例如,我们要发送一串数据: 0x45 0x4C 0x32 0x55

则我们在计算校验码时直接将其相加,得到 0x118,仅截取最后一个字节,即为 0x18

所以实际发送的包含校验码的数据是: 0x45 0x4C 0x32 0x55 0x18

不过实际在使用校验和这个校验算法时,通常会根据情况定义一些其他的规则。

比如,有时候我们会定义在将需要传输的数据全部累加之后,将得到的结果按比特取反后附加到数据后面作为校验码,接收端接收到数据后,将所有数据(包含校验码)累加,最终得到的数据全为 1 则表示数据传输没有出错。

例如,我们要发送数据: 0x45 0x4C 0x32

相加后得到 0xC3,二进制数据为 1100 0011

取反后为 0011 1100,即十六进制 0x3C

所以实际发送的数据是 0x45 0x4C 0x32 0x3C

接收端在接收到这个数据后,将其连同校验码一起累加,得到结果 0xFF 即 二进制的 1111 1111 ,所以认为数据传输没有出错。

有时候也会有规则定义为把要传输的数据全部按位取反后再相加,总之无论是什么变体规则,万变不离其宗,其基本原理都是一样的,我们只需要在实际使用时按照厂商要求的规则编写校验即可。

说完这些,总结一下,校验和的优点是计算简单、速度快,可以快速检测到数据传输中的错误。

缺点是无法检测出所有的错误,例如两个字节交换位置的错误可能会被误认为是正常的校验和,因此不能保证100%的可靠性。

下面贴上使用 Kotlin 实现的校验和算法:

fun checkSum(data: ByteArray): Char {
    if (data.isEmpty()) {
        return 0xFF.toChar()
    }

    var res = 0x00.toChar()
    for (datum in data) {
        res += (datum.toInt() and 0xFF).toChar().code
    }
    // res = (res.code xor  0xFF).toChar() // 如果要做取反则去掉这个注释
    res = (res.code and 0xFF).toChar()
    return res
}


fun main() {
    println(checkSum(byteArrayOf(0x45, 0x4C, 0x32, 0x55)).code.toString(16))
}

上述代码输出: 18

当然,这里给出的代码是我们说的第一种情况,直接计算所有子节的和。

如果我们想要计算的是我们说的第二种情况,即把结果按位取反的话只需要把代码中注释掉的地方去掉即可:

fun checkSum(data: ByteArray): Char {
    if (data.isEmpty()) {
        return 0xFF.toChar()
    }

    var res = 0x00.toChar()
    for (datum in data) {
        res += (datum.toInt() and 0xFF).toChar().code
    }
    res = (res.code xor  0xFF).toChar() // 如果要做取反则去掉这个注释
    res = (res.code and 0xFF).toChar()
    return res
}


fun main() {
    println(checkSum(byteArrayOf(0x45, 0x4C, 0x32)).code.toString(16))
}

上述代码输出 3c

BCC(Block Check Character)

BCC 校验的原理是通过对数据块中的每个字节进行异或操作,得到一个 BCC 值,然后将该值添加到数据块的末尾进行传输,接收方计算接收到的数据后与 BCC 值比较,如果值一致则认为数据传输没有出错。

BCC 计算过程简单说就是先定义一个初始 BCC 值 0x00 ,然后将待计算的数据第一个字节与 BCC 值做异或运算,运算之后得到新的 BCC 值,然后再用这个新的 BCC 与待计算数据的第二个字节做 BCC 运算,以此类推,直到待计算的所有数据都与 BCC 值做了异或计算,此时得到的 BCC 值即为最终的 BCC 值。

然后将这个 BCC 值附加到原始数据后面一同发送,接收端在接收到数据后,将数据部分按照上述算法计算出一个值,然后将这个值与接收到的 BCC 值对比,如果一致则认为数据传输正确。

对了这里插一段,异或计算就是按照对应位上的值相同为 0 不同为 1,例如 0x45 异或 0x4C 即:

0100 0101 (0x45)
xor
0100 1100 (0x4C)
=
0000 1001 (0x9)

所以不难看出任何数与 0x00 做异或得到的还是这个数,所以我们才能把 BCC 初始值定义为 0x00。

下面我们举个计算 BCC 的例子,我们需要计算数据 0x45 0x4C 0x32 0x55 的BCC值:

  1. 预设 BCC = 0x00
  2. 计算 BCC = 0x00 xor 0x45 = 0x45
  3. 计算 BCC = 0x45 xor 0x4C = 0x09
  4. 计算 BCC = 0x09 xor 0x32 = 0x3B
  5. 计算 BCC = 0x3B xor 0x55 = 0x6E

所以最终计算得出的 BCC 值为 0x6E。

BCC校验的优点是计算简单、速度快,并且可以检测出数据块中多个字节的错误。与其他校验方式相比,BCC校验的错误检测能力更强,因为它可以检测出更多类型的错误。缺点是不能纠正错误,只能检测错误,而且对于较长的数据块,BCC校验的误判率可能会增加。

下面贴上使用 kotlin 实现的 BCC 算法:

fun computeBcc(data: ByteArray): Byte {
    var bcc: Byte = 0
    for (i in data.indices) {
        bcc = bcc.xor(data[i])
    }
    return bcc
}



fun main() {
    println(computeBcc(byteArrayOf(0x45, 0x4C, 0x32, 0x55)).toString(16))
}

上面代码输出: 6e

CRC(Cyclic Redundancy Check)

CRC校验的原理是基于多项式的除法进行计算,在计算时会将数据块看作一个多项式,对其进行除法运算,计算得到的余数即为 CRC 校验码,然后将其附加到原数据的末尾随数据一起传输,接收方接收到数据后按照相同的算法对其中的数据进行计算,并用计算的到的值与接收到的 CRC 校验码进行对比,如果一致则认为传输数据没有出错。

而按照校验码的长度不同,CRC又具有不同的分类算法,例如常见的有 CRC-8 、 CRC-16、CRC-32 三种不同的分类。它们分别表示计算出来的校验码长度是 8 位、 16 位 、 32位 。同时它们检测错误的长度也不同,例如 CRC-8 可以检测长度小于等于 8 位的错误。另外,不同的算法使用的多项式也不相同。

下面我们以 CRC-16 为例子说说它的计算过程。(计算过程来自参考资料 4)

  1. 首先选定一个有 K 位的二进制数作为标准除数(这个二进制数由多项式得到,可以自定义,但是也有一些约定俗成的固定数值)
  2. 将需要计算的 m 位原始数据后面加上 K-1 位 0,得到一个长度为 m+K-1 位的新数据,然后使用模2除法除以 步骤 1 中定义的标准除数,得到一个余数,继续重复计算直至余数比除数少且只少一位(不够就补0),此时的余数即为 CRC 校验码。
  3. 将计算出的校验码附在原始数据后面,即可得到需要发送的数据,长度为 m+K-1 位。
  4. 此时接收端接收到数据后,将其除以步骤 1 中定义的除数,如果余数为 0 则表示数据传输没有出错。(ps:理论上应该是这样去校验数据 ,但是实际使用时更多的是偷懒直接重新算一遍 CRC 校验码,然后和接收到的校验码对比,🤦)

因为 CRC 的算法比较复杂,直接说可能理解起来不太直观,推荐看一下参考资料 5 的视频,这样就能有一个直观的认识。

如果我们想要使用算法实现的话,则可以通过以下步骤:

  1. 将数据块看作一个二进制数,将它的最高位对齐 CRC-16 校验码的最高位。
  2. 将 CRC-16 校验码的每一位都与对应的数据位异或,并将结果赋给一个临时变量 temp 。
  3. 如果 temp 的最高位是1,就将它右移一位并将预置值 0x8005 (这个值就是定义的标准除数)与它异或,否则直接右移一位。
  4. 重复执行步骤 2、3,直到所有数据位都被处理完毕。
  5. 处理完所有数据位后,temp 中保存的就是 CRC-16 校验码。

总的来说,CRC校验的优点在于其具有高效、可靠的校验能力,能够检测多种类型的数据传输错误,如位反转、位移、插入、删除等。

与之对应的 CRC 的计算复杂度相较上述的几种算法更高,因此需要消耗较多的计算资源,尤其是对于一些低性能的设备或嵌入式系统而言,可能会对系统性能造成较大的影响。另外,CRC校验值的长度比较长,例如 CRC-32 的校验值有 32 位,这无疑会增加传输的开销。

下面贴上使用 kotlin 实现的 CRC-16 代码:

fun calculateCRC16(data: ByteArray): Int {
    val polynomial = 0x8005
    var crc = 0xFFFF
    for (b in data) {
        crc = crc xor (b.toInt() and 0xFF)
        for (i in 0 until 8) {
            crc = if (crc and 0x0001 != 0) {
                crc shr 1 xor polynomial
            } else {
                crc shr 1
            }
        }
    }
    return crc and 0xFFFF
}

注意,这里我们使用的多项式值(标准除数)是 0x8005 ,各位在使用的时候需要换成设备厂商或者你们自己约定好的值,比如我之前接入的一块使用 MODBUS 通信的 PLC 主板约定的值就是 0xA001 而非 0x8005。

另外,可能有些设备厂商会对 CRC 校验码的高低位顺序有要求,例如需要保证高位在前,低位在后,则我们可以在后面额外加上几段代码来实现:

    val polynomial = 0x8005
    var crc = 0xFFFF
    for (b in data) {
        crc = crc xor (b.toInt() and 0xFF)
        for (i in 0 until 8) {
            crc = if (crc and 0x0001 != 0) {
                crc shr 1 xor polynomial
            } else {
                crc shr 1
            }
        }
    }
    val lowByte: Byte = (crc  shr 8 and 0xFF).toByte()
    val highByte: Byte = (crc and 0xFF).toByte()
    return ByteArray(0).plus(highByte).plus(lowByte)
}

对了,计算 CRC-8 和 CRC-32 的算法是一样的,只需要更改对应的初始值(crc = 0xFFcrc = 0xFFFFFFFF)和多项式值即可。

总结

总的来说,在串口通信中常用的校验方式为:

  1. 奇偶校验,主要用于串口一帧(1字节)数据的校验,这意味着每字节数据都需要额外添加校验位,所以通常使用时都会选择无校验。
  2. CRC校验,由于CRC校验的相对来说更加可靠,而且校验的是整体的数据而非单比特数据,所以实际使用时通常会使用到它。

当然,这篇文章中介绍的只是几个常见的校验方法,还有更多校验方法这里没有说到,如果有需要的话欢迎补充。

参考资料

  1. 串口通信校验方式:奇偶校验、累加和校验
  2. 串口通信协议常用校验计算以及一些常用方法
  3. BCC校验(异或校验)原理
  4. 一文讲透CRC校验码-附赠C语言实例
  5. CRC校验手算与直观演示