CRC演算法原理、推導及實現

QIYUEXIN發表於2024-08-16

CRC, Cyclic Redundancy Check, 迴圈冗餘校驗

1. 基本原理

CRC的本質是除法,把待檢驗的資料當作一個很大(很長)的被除數,兩邊選定一個除數(有的文獻叫poly),最後得到的餘數就是CRC的校驗值。

判定方法:

  1. 將訊息和校驗和分開。計算訊息的校驗和(在附加W個零後),並比較兩個校驗和。

  2. 把校驗值放到資料的結尾,對整批進行校驗和(不附加零),看看結果是否為零!

1.1. 為什麼用CRC

比較常見的是累加和校驗,但是有以下缺點:

  1. 8080 00 .. 00的計算結果一致,即如果資料裡參雜了00是檢測不出來的,對於不定長的檢測不友好

  2. 因為是累加和,所以80 00有非常多的組合是校驗值相等的,比如70 10, 79 06 等等

那麼什麼情況下會導致CRC失敗呢?

2. 推導前準備

2.1. 無進位加法及減法

CRC算術中的兩個數字相加與普通二進位制算術中的數字相加相同,除了沒有進位。

無進位的加法及減法其實是異或運算(異或,不一樣就或在一起:不一樣為1,相同為0)

這意味著每對對應的位元確定對應的輸出位元,而不參考任何其他位元位置。例如

    10011011
   +11001010
    --------
    01010001
    --------

加法的4中情況:

    0+0=0
    0+1=1
    1+0=1
    1+1=0  (no carry)

減法也是類似的:

    10011011
   -11001010
    --------
    01010001
    --------

with

    0-0=0
    0-1=1  (wraparound)
    1-0=1
    1-1=0

這麼一來,我們相當於把加法和減法合併成為了一種演算法,或者可以理解為加法和減法這裡稱為了一種互逆運算,比如我們可以透過加減相同的數量,可以從1010到1001:

    1001 = 1010 + 0011
    1001 = 1010 - 0011

所以在無進位的加減法裡,1010不再可以被視為大於1001;

這麼做有什麼好處?

你會發現無論多長的資料bit在運算時都不再依賴於前一位或者後一位的狀態,這和帶進位的加法及帶借位的減法不同,你可以理解為執行平行計算:

  1. 帶進位的加法,高位的計算結果需要累加低位結果產生的進位,這就導致了必須要先計算低位,之後才能計算高位;比如下面的例子,如果帶進位的話就必須從最右邊開始計算,依次算到最左邊得到結果。但是如果我們把進位取消,就會發現我從那邊開始算都可以,當然也可以多位同時一起算(平行計算)

              1011                         1011
            + 1101                       + 1101
              ----                         ----        
            1 1000 (with carry)            0110 (no carry)
    
  2. 減法也是如此,不再贅述。

2.2. 無進位乘法

定義了加法後,我們可以進行乘法和除法。乘法是簡單的,只不過在加法運算的時候使用XOR就行了

    1101
  x 1011
    ----
    1101
   1101.
  0000..
 1101...
 -------
 1111111  Note: The sum uses CRC addition
 -------

2.3. 無進位除法

除法也是類似的,只不過有兩點需要注意:

  1. 當除數和被除數的最高位都是1的時候,就當作是對齊了,就可以開始XOR運算,不要比較資料大小,比如 1001可以被1011除,至於商的結果是1或者0,沒有人去關注,自己開心就好,因為這個演算法壓根就不用;

  2. 被除數和除數做減法時,需要使用無進位的減法,即XOR運算;

               1 = 商 (nobody cares about the quotient)
           ______
     1011 ) 1001 除數
     =Poly  1011
            ----
            0010  
    

3. 演算法推導

即使我們知道CRC的演算法是基於除法,我們也不能直接使用除法運算,一個是待校驗的資料很長,我們沒有這麼大的暫存器;再則,你知道除法在MCU中是怎麼實現的嗎?

3.1. 仿人算方法

現在我們假設一個訊息資料為1101011011,選取除數為10011,使用CRC演算法將訊息除以poly:

            1100001010 = Quotient (nobody cares about the quotient)
       _______________
10011 ) 11010110110000 = Augmented message (1101011011 + 0000)
 =Poly  10011,,.,,..|.
        -----,,.,,|...
         10011,.,,|.|.
         10011,.,,|...
         -----,.,,|.|.
              10110...
              10011.|.
              -----...
               010100.
                10011.
                -----.
                 01110
                 00000
                 -----
                  1110 = Remainder = THE CHECKSUM!!!!

除數poly的左邊的高位的作用其實是給人看的(實際上參與運算的是0011),目的是幹掉當前最高位的被除數,本質上是讓poly和被除數對齊,然後開始XOR運算。

那麼什麼情況算是對齊呢? 從例子上看,當被除數和除數的最高位都是1時,就算是對齊了。

轉換成演算法的思路就是,你也可以理解成一長串的資料不斷的從右邊移位到暫存器中,當暫存器最左邊溢位的數值是1的時候,那麼當前暫存器的資料就可以和poly異或運算了,用演算法表示,大概是這樣:

                  3   2   1   0   Bits
                +---+---+---+---+
       Pop! <-- |   |   |   |   | <----- Augmented message
                +---+---+---+---+

             1    0   0   1   1   = The Poly

用演算法語言描述就是:

暫存器清零
資料最右邊補齊W位0 // W是CRC校驗值的位數
when(還有資料){
    左移暫存器1位,讀取資料的下一位到暫存器的bit 0
    if (左移暫存器時出現溢位){
        暫存器 ^= poly;    // 這裡的poly=0011,按照上面的例子
    }
}
暫存器的值就是校驗值了

用C語言:

// CRC8生成多項式
#define POLYNOMIAL 0x07

// 計算CRC8校驗值
uint8_t crc8_data(const uint8_t dat8) {
    uint8_t crc = dat8;
    for (j = 8; j; j--) {
        if (crc & 0x80)
            crc = (crc << 1) ^ POLYNOMIAL;
        else
            crc <<= 1;
    }
    return crc;
}

但是這個方法太笨了,按位進行計算,效率有待提升。

3.2. 使用Table驅動計算CRC4

3.2.1. 4-bit 資料計算

為了方便描述,我們舉例W=4poly=3的情況,比如我們計算一個3的CRC值為5,我們寫成XOR的計算過程:

0011 0000    // 補W=4個零 (值1)
,,10 011     // poly對齊  (值2)
---------
0001 0110    
,,,1 0011    // poly對齊  (值3)
---------
0000 0101    // CRC值     (值4)           

上面的計算經過了N次迭代運算(其實多少次迭代我們並不關心),等價於

CRC值 = 值1 ^ 值2  ^ 值3
      = 值1 ^ (值2  ^ 值3)
      = 值1 ^ 查表值            // 令 `查表值` = 值2 ^ 值3

需要注意的是,在CRC計算時,末尾補了4個0,但是我們是清楚的,任何數和0的XOR運算都是其本身,所以補0不會影響最後CRC的值,只不過相當於把CRC的值提取出來。 CRC計算等價於一系列的移位和XOR運算,所以上面的表示式實際上為:

CRC值 = (值1 ^ 0) ^ 值2  ^ 值3
      = (值1 ^ 0) ^ (值2  ^ 值3)
      = (值1 ^ 0) ^ 查表值      
      = 值1 ^ 查表值            // 令 `查表值` = 值2 ^ 值3

也就是說,我們可以實現把0~15的CRC的值先預先算一遍,然後存起來,這樣下次再計算就可以直接查表計算,這很好理解。

3.2.2. 8-bit 資料計算

想象一下,一個8-bit的位元組是可以拆分成兩個4-bit資料的,如果我們可以利用查表的方法,是不是透過兩次計算就可以得到一個8-bit的CRC值了?具體要怎麼做呢,我們舉例W=4poly=3的情況,比如我們計算一個33h的CRC值,我們寫成XOR的計算過程:

0011 0011 0000    // 補W=4個零 (值1)
,,10 011          // poly對齊  (值2)
--------------
0001 0101 0000   
,,,1 0011         // poly對齊  (值3)
--------------
0000 0110 0000    // 變回4-bit CRC計算    (值4)

下面的計算我們就熟悉了,回到 計算4-bit資料為 6 的CRC值:

0110 0000    // 補W=4個零
,100 11      // poly對齊
---------
0010 1100    
,,10 011     // poly對齊  
---------
0000 1010    // CRC值   

我們發現一個有意思的事情,原來4-bit資料3的CRC值是5,但是當33h先進行計算高4-bit的CRC值卻是6,和之前的不一樣(也幸虧不一樣,如果後面無論跟什麼資料都一樣還有校驗幹嘛用),這是什麼原因?

首先,我們看一下8-bit計算和原來4-bit計算的區別在於末尾補數:

  • 4-bit CRC計算時,末尾補的是0,是不影響計算結果的;

  • 8-bit CRC計算時,末尾補的是後面跟的低4-bit資料,是會影響原來計算結果的:

    為了方便描述,我們把8-bit的值1的高4-bit資料記為H4,低4-bit資料記為L4

    `值4` = `值1` ^ 值2 ^ 值3
        = `值1` ^ `查表值`                // 令 `查表值` = 值2 ^ 值3
        = `(H4<<4 ^ L4)` ^ `查表值`    
        = `(H4<<4)` ^ `查表值` ^ L4       // (H4<<4)其實就是計算H4的CRC值且末尾補0的情況
    

所以,我們可以得2段4-bit的計算流程:

  1. 去掉位元組的高4-bit值為H4
  2. 將H4值進行查表計算,得到值TMP1
  3. 把TMP1的值異或上低4位的值L4,得到值TMP2
  4. 然後用TMP2的值進行查表計算,得到值CRC

4. 演算法改進

4.1. CRC8計算

現在我們可以根據CRC4的計算過程類比到CRC8計算,其實主要的區別就是暫存器的位數從4位提升到了8位,一個典型的CRC8計算模型如下,現在你應該可以讀懂了。

#include <stdio.h>
#include <stdint.h>

// CRC8生成多項式
#define POLYNOMIAL 0x07

// 初始化CRC8查詢表
void init_crc8_table(void) {
    uint8_t i, j;
    for (i = 0; i < 256; i++) {
        uint8_t crc = i;
        for (j = 8; j; j--) {
            if (crc & 0x80)
               crc = (crc << 1) ^ POLYNOMIAL;
            else
               crc <<= 1;
        }
        crc8_table[i] = crc;
    }
}

// 計算CRC8校驗值
uint8_t crc8(const void *data, size_t len) {
    const uint8_t *byte = data;
    uint8_t crc = 0x00;

    for (; len > 0; len--) {
        crc = crc8_table[(crc ^ *byte++) & 0xFF];
    }

    return crc;
}

int main(int argc, char *argv[]) {
    int fd;
    uint8_t buffer;
    size_t bytes_read;
    uint8_t crc;

    if (argc != 2) {
        fprintf(stderr, "Usage: %s filename\n", argv);
        exit(1);
    }

    fd = open(argv, O_RDONLY);
    bytes_read = read(fd, buffer, sizeof(buffer));
    crc = crc8(buffer, bytes_read);
    printf("CRC: 0x%02X\n", crc);<q refer="1"></q><span class="_q_s_"></span>

    close(fd);
    return 0;
}

4.2. CRC8計算-改進型

上面的演算法還是不夠好,因為Table的表太大了佔用256位元組,對於FLASH空間緊張的MCU來說不怎麼友好,能不能把一個8-bit資料拆分成兩次4-bit資料的計算,這樣是不是就可以搞成16位元組的表了?我們來試一下!

實際上使用了32位元組

idx L4 H4 H4說明
0 0 0 (L4<<4) ^ 07*0
1 07 70 (L4<<4) ^ 07*0
2 0E E0 (L4<<4) ^ 07*0
3 09 90 (L4<<4) ^ 07*0
4 1C C7 (L4<<4) ^ 07
5 1B B7 (L4<<4) ^ 07
6 12 27 (L4<<4) ^ 07
7 15 57 (L4<<4) ^ 07
8 38 89 (L4<<4) ^ ((07<<1) ^ 07)
9 3F F9 (L4<<4) ^ ((07<<1) ^ 07)
A 36 69 (L4<<4) ^ ((07<<1) ^ 07)
B 31 19 (L4<<4) ^ ((07<<1) ^ 07)
C 24 4E (L4<<4) ^ (07<<1)
D 23 3E (L4<<4) ^ (07<<1)
E 2A AE (L4<<4) ^ (07<<1)
F 2D DE (L4<<4) ^ (07<<1)
// 計算CRC8校驗值
uint8_t crc8(const void *data, size_t len) {
    const uint8_t *byte = data;
    uint8_t crc = 0x00;

    for (; len > 0; len--) {
        crc = crc8_table_h4[(crc ^ *byte)>>4] ^ 
              crc8_table_l4[(crc ^ *byte) & 0xF] ; 
              
              byte++;
    }

    return crc;
}

驗證:

  1. CRC8計算單位元組

    crc8(88) = 38 ^ 89 = B1
    
  2. CRC8計算多位元組

    crc8(8888) = crc8(B1 ^ 88) = crc8(39) = 90^3F=AF
    crc8(1234) = crc8(crc8(12) ^ 34) = crc8(7E ^ 34 = 4A) = C7^36=F1
    

4.3. CRC16計算-改進型

進一步地,我們可不可以使用相同的原理實現CRC16演算法?W=16, poly=8005

idx X[3:0] X[7:4] X[7:4]說明 X[11:8] X[11:8]說明 X[15:12] X[15:12]說明
0 0 0
1 8005 8063 X[3:0]<<4 ^ 8033 8603 X[3:0]<<8 ^ 8303 E003 X[3:0]<<12 ^ B003
2 800F 80C3 X[3:0]<<4 ^ 8033 8C03 X[3:0]<<8 ^ 8303 4003 X[3:0]<<12 ^ B003
3 0A 00A0 X[3:0]<<4 A00 X[3:0]<<8 A000 X[3:0]<<12
4 801B 8183 X[3:0]<<4 ^ 8033 9803 X[3:0]<<8 ^ 8303 8006 X[3:0]<<12 ^ B003 ^ 8005
5 1E 1E0 X[3:0]<<4 1E00 X[3:0]<<8 6005 X[3:0]<<12 ^ 8005
6 14 140 X[3:0]<<4 1400 X[3:0]<<8 C005 X[3:0]<<12 ^ 8005
7 8011 8123 X[3:0]<<4 ^ 8033 9203 X[3:0]<<8 ^ 8303 2006 X[3:0]<<12 ^ B003 ^ 8005
8 8033 8303 X[3:0]<<4 ^ 8033 B003 X[3:0]<<8 ^ 8303 8009 X[3:0]<<12 ^ B003 ^ A
9 36 360 X[3:0]<<4 3600 X[3:0]<<8 600A X[3:0]<<12 ^ A
A 3C 3C0 X[3:0]<<4 3C00 X[3:0]<<8 C00A X[3:0]<<12 ^ A
B 8039 83A3 X[3:0]<<4 ^ 8033 BA03 X[3:0]<<8 ^ 8303 2009 X[3:0]<<12 ^ B003 ^ A
C 28 280 X[3:0]<<4 2800 X[3:0]<<8 000F X[3:0]<<12 ^ 800F
D 802D 82E3 X[3:0]<<4 ^ 8033 AE03 X[3:0]<<8 ^ 8303 E00C X[3:0]<<12 ^ B003 ^ 800F
E 8027 8243 X[3:0]<<4 ^ 8033 A403 X[3:0]<<8 ^ 8303 400C X[3:0]<<12 ^ B003 ^ 800F
F 22 220 X[3:0]<<4 2200 X[3:0]<<8 A00F X[3:0]<<12 ^ 800F

驗證:

  1. CRC16計算雙位元組

    比如計算ABCD的CRC16:

    crc16(A000) = C00A
    crc16(0B00) = BA03
    crc16(00C0) = 0280
    crc16(000D) = 802D
    故crc16(ABCD) = C00A ^ BA03 ^ 0280 ^ 802D = F8A4
    
  2. CRC16計算四位元組

    如果資料是連貫的呢,比如ABCD

      crc(ABCD) = F8A4
      
      crc(ABCD1234) 
    = crc(F8A4 ^ 1234) = crc(EA90) = 400C ^ 3C00 ^ 360 ^ 0 = 7F6C
    

相關文章