前语

一些闲话

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

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

今日这篇文章,咱们来说说串口通讯常用的几种校验办法的原理以及给出核算代码,当然,因为咱们讲的是安卓的串口通讯,所以代码将运用 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校验手算与直观演示

本文正在参与「金石计划」