CRC计算与实现:软件查表法、硬件LFSR实现、位序与初始值

说实话,CRC 这块儿光讲原理,你可能觉得挺简单的——不就是多项式除法嘛。但真正落地的时候,软件怎么算快、硬件怎么省资源、位序怎么排、初始值填什么……这些坑我当年都踩过。今天咱们就把这四件事儿一次说透。

4.1 软件查表法:拿空间换时间

CRC 的纯软件计算,如果一位一位地算,那效率太低了。尤其数据量大时,你想想看,每 byte 要跑 8 个循环,CPU 全浪费在移位和异或上了。

我个人的习惯是, 只要不是在极端受限的 MCU 上,一定用查表法。核心思路很简单:提前算好所有可能的 8 位输入对应的 CRC 部分结果,存成一张 256 个条目的表。计算时每取一个字节,直接从表里查,再跟寄存器做异或和移位。这样每个字节只需要几条指令。

4.1.1 查表法的表生成

表怎么来的?其实就是对着多项式,对 0x00 ~ 0xFF 每个值,跑一遍标准的 CRC 计算。以 CRC-16/MODBUS 为例,多项式 0x8005,初始值 0xFFFF。我写个 C 代码给你看:

// 生成 CRC-16 MODBUS 查表
uint16_t crc16_table[256];
void crc16_init_table(void) {
    for (int i = 0; i < 256; i++) {
        uint16_t crc = i << 8;
        for (int bit = 0; bit < 8; bit++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x8005;
            else
                crc <<= 1;
        }
        crc16_table[i] = crc;
    }
}

你看,生成表只需要跑一次,之后查表就快了。嗯,这里要注意:表的大小和最终 CRC 宽度对应,8 位 CRC 用 256 条 8 位条目,16 位用 256 条 16 位,32 位同理。

4.1.2 快速查表计算

查表计算时,标准做法是:把当前 CRC 高 8 位与输入字节异或,作为索引查表,然后 CRC 左移 8 位再异或表值。

uint16_t crc16_fast(uint8_t *data, int len) {
    uint16_t crc = 0xFFFF;          // 初始值
    for (int i = 0; i < len; i++) {
        uint8_t index = (crc >> 8) ^ data[i];
        crc = (crc << 8) ^ crc16_table[index];
    }
    return crc;
}

我在项目中遇到过 一个坑:有人直接把表生成的是反射版本(reflected),但查表的时候没有按反射的位序处理,结果算出来的 CRC 永远不对。所以查表之前,一定要搞清楚你的多项式位序是正常还是反射。

小技巧: 如果硬件已经提供了 CRC 外设,软件查表法可能反而多余。但在那些没有硬件 CRC 的老 MCU(比如 STM32F1 某些系列)上,查表法就是你的救命稻草。

4.2 硬件 LFSR 实现:从移位寄存器角度看 CRC

硬件实现 CRC,说白了就是一个线性反馈移位寄存器(LFSR)。你想想看,多项式除法在硬件里根本不需要做除法,只要把异或门放在合适的位置就行。

比如实现 CRC-8-ATM(多项式 x⁸ + x² + x + 1,即 0x07),8 级移位寄存器,数据从低位或高位进去,每一步移一位,遇到反馈位就异或。标准电路长这样:

  • 寄存器初值赋为初始值(比如 0x00 或 0xFF)。
  • 每个时钟周期,输入 1 bit,所有寄存器右移一位。
  • 移出的那一位如果是 1,则在反馈路径上异或多项式(除去最高位)。
  • 全部数据移完后,寄存器的值就是 CRC(可能还要异或最终异或值)。

我记得有一次 调试一个 FPGA 项目,代码仿真 CRC 全对,上板就不对。最后发现是 LFSR 的复位信号没处理好,初始值根本没加载进去。所以硬件设计时,初始值加载一定要和有效数据时序对齐。

4.2.1 LFSR 的 Verilog 示例

// CRC-8-ATM LFSR 实现
module crc8_atm (
    input clk,
    input rst_n,
    input data_in,
    input data_valid,
    output reg [7:0] crc_out
);
    reg [7:0] lfsr;
    wire feedback = lfsr[7] ^ data_in;  // 数据从高位异或进入
    
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            lfsr <= 8'hFF;   // 初始值 0xFF
        else if (data_valid) begin
            lfsr[0] <= feedback;
            lfsr[1] <= lfsr[0] ^ feedback;  // 多项式 x^1 位置
            lfsr[2] <= lfsr[1] ^ feedback;  // 多项式 x^2 位置
            lfsr[7:3] <= lfsr[6:2];        // 其余直接移位
        end
    end
    
    assign crc_out = lfsr;
endmodule

注意上面代码里,多项式 bit1 和 bit2 位置加了个额外的异或门。如果你用多项式 0x07(二进制 00000111),实际反馈位是 bit0、bit1、bit2,但因为 CRC-8 最高位隐含了 x⁸,所以反馈只在低 8 位中 bit2 和 bit1 处异或(bit0 是移出位自动丢弃后的反馈输入)。

警告: 上面代码是简化版本。实际工程中要考虑字节内位序(MSB first 还是 LSB first),以及最后一个字节移完后是否需要额外翻转。我曾经见过有人直接把软件算法翻译成 LFSR,结果因为位序没反转,怎么都对不上硬件 CRC 计算结果。

4.3 位序与初始值:两个最容易翻车的细节

CRC 计算里,位序(bit ordering)和初始值(init value)看起来是小问题,但出问题概率极高。我教你一个排查方法:手动算一个短数据的 CRC,用在线计算器或 Python 脚本验证。

4.3.1 位序:MSB first 与 LSB first

大多数 CRC 标准用 MSB first(高位先送),比如 CRC-32 以太网。但 Modbus 和某些串行协议用 LSB first(低位先送)。位序不同,查表时索引的生成方式也要反过来。

标准 位序 字节内比特顺序 表索引计算
CRC-32 以太网 MSB first (non-reflected) bit7 → bit0 ((crc>>24) ^ data[i]) & 0xFF
CRC-16 Modbus LSB first (reflected) bit0 → bit7 (crc ^ data[i]) & 0xFF
CRC-8 Dallas LSB first bit0 → bit7 (crc ^ data[i]) & 0xFF

我曾经 在一个串口 Bootloader 项目中,CRC 校验一直失败。排查了三天,最后发现是位序反了——我的软件用 MSB first 查表,但硬件计算器是 LSB first。从那以后,我每写一个 CRC 函数,第一件事就是打印出第一个字节的 CRC 中间值,跟已知标准值对比。

4.3.2 初始值:不只是全0或全1

CRC 的初始值决定了计算起点。常见的有 0x0000、0xFFFF、0xFFFFFFFF 等。但有些协议会在 CRC 计算后还需要异或一个最终异或值(XOROUT),比如 CRC-32 以太网的 XOROUT 是 0xFFFFFFFF。

下表列出常见 CRC 参数:

CRC 标准 宽度 多项式 初始值 位序 XOROUT
CRC-8/MAXIM 8 0x31 0x00 MSB first 0x00
CRC-16/MODBUS 16 0x8005 0xFFFF LSB first (reflected) 0x0000
CRC-16/CCITT 16 0x1021 0xFFFF MSB first 0x0000
CRC-32 以太网 32 0x04C11DB7 0xFFFFFFFF LSB first (reflected) 0xFFFFFFFF

你可能会问: 初始值为什么有时候是 0xFFFF?其实就是为了避免前导零影响 CRC 结果。如果初始值是全 0,那么多项式除法会把前导零当作有效位,导致前面加多少零结果都一样。所以大部分协议把初始值设成全 1,或者用其他方法「加扰」一下。

核心建议: 当你实现一个 CRC 算法时,不要自己猜参数。去网上找一个 CRC 参数表(比如 "CRC Parameter List"),按标准来。写代码之前,先用软件工具(如 pycrc 或在线计算器)算几个已知数据的 CRC 值作为测试向量。

4.4 本章知识体系一览

为了帮你理清思路,我画了一张图,把软件查表法、硬件 LFSR、位序、初始值这四个环节串起来。看完这张图,你应该能清晰看到每个环节的输入输出和注意事项。

CRC 计算实现:四步逻辑关系图 软件查表法 预计算256项查找表 每字节查表 + 异或 + 移位 适用:通用 MCU / 软件栈 注意:表结构依赖位序 硬件 LFSR 实现 移位寄存器 + 异或反馈门 每个时钟周期处理1 bit 适用:FPGA / ASIC / 硬件加速 注意:时序对齐 / 复位初值 位序(Bit Order) MSB first / LSB first 决定表索引 & 移位方向 初始值 Init / XOROUT 影响 CRC 起点和终点 验证方法 在线计算器 / 标准向量 手动算首个中间值 决策 依赖 需加载 配合 检验 关键原则:先确定标准参数(多项式、初始值、位序、XOROUT),再选择实现方式。 无论是查表还是 LFSR,最终 CRC 必须与标准输出一致。

4.5 实战中的避坑清单

最后,结合我的实战经验,列一个 checklist。你每次写 CRC 相关代码时,照着过一遍,能省不少调试时间。

  1. 多项式对不对? 看标准文档,不要从别人代码里复制一份就完事——多项式笔误率很高。
  2. 初始值跟标准一致吗? 包括 XOROUT,很多版本的 CRC 参数表把 XOROUT 单独列出来,别忘了最后异或。
  3. 位序: 硬件 LFSR 是 MSB first 还是 LSB first?软件查表时索引是不是已经做了反射?我建议生成一个单字节 0x01 的 CRC 结果,用在线计算器验证。
  4. 数据长度: 有些协议在数据后追加 CRC 再送,有的不追加。接收方计算时要不要包含收到的 CRC 值?
  5. 多字节顺序: CRC 结果是大端存放还是小端?Modbus 的 CRC 是低字节在前,以太网 CRC 是高字节在前。
  6. 边界情况: 输入为空时,CRC 应该等于初始值 XOROUT 之后的值,而不是 0。很多人的 bug 就出在这儿。

嗯,CRC 的计算实现其实就这些内容。你只要把位序和初始值这两个变量控制住,软件和硬件完全可以等价替换。下节课咱们聊聊怎么在实际设备中用 MAC 计数器定位 CRC 错误——那才是真正的调试利器。