校验算法基础(一):CRC 原理与固件实战

各位同学,今天我们来聊聊固件校验里最基础、也最常用的算法——CRC。说实话,CRC 这玩意儿看着简单,但坑不少。我早年做 Bootloader 时,就因为 CRC 实现细节没抠清楚,导致固件升级成功率只有 70%。嗯,从那以后我对 CRC 就格外上心了。

CRC 到底是什么?

CRC,全称 Cyclic Redundancy Check,循环冗余校验。说白了,它就是把你的数据当成一个巨大的二进制数,然后除以一个固定的「生成多项式」,余数就是 CRC 校验值。

你可能会问:为什么不用简单的累加和(Checksum)?我举个例子你就明白了。假设数据是 0x01, 0x02,累加和是 0x03。如果数据变成 0x02, 0x01,累加和还是 0x03——这能检测出错误吗?不能。但 CRC 不一样,它对数据顺序敏感,能检测出几乎所有常见的传输错误。

核心要点:CRC 的本质是「多项式除法」在二进制域上的应用。它用「余数」作为数据的指纹,长度固定,但碰撞概率极低。

CRC-32 与 CRC-16:选哪个?

实际项目中,CRC-32 和 CRC-16 是最常见的两个变体。我整理了一张对比表,方便你快速决策:

特性 CRC-16 CRC-32
校验值长度 16 位(2 字节) 32 位(4 字节)
生成多项式(常用) 0x8005(CRC-16-IBM) 0x04C11DB7(CRC-32)
检测能力 可检测所有单/双比特错误、奇数个错误 可检测所有单/双/奇数个错误、任意长度突发错误(≤32 位)
固件场景 小数据块、扇区校验、通信协议 固件镜像完整性校验、文件校验
计算开销 低(适合 MCU) 中等(需要查表优化)

我个人习惯:如果固件镜像超过 64KB,我肯定用 CRC-32。小于 64KB 且 MCU 资源紧张时,CRC-16 也够用。但注意——CRC-16 的碰撞概率在镜像较大时会明显上升,别问我怎么知道的。

CRC 计算原理:手算 vs 查表

先看最原始的实现——按位计算。以 CRC-32 为例,核心逻辑是这样的:

uint32_t crc32_bitwise(const uint8_t *data, size_t len) {
    uint32_t crc = 0xFFFFFFFF;  // 初始值
    for (size_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 1) {
                crc = (crc >> 1) ^ 0xEDB88320;  // 反射多项式
            } else {
                crc >>= 1;
            }
        }
    }
    return crc ^ 0xFFFFFFFF;  // 异或输出
}

这段代码每处理一个字节要循环 8 次,效率很低。我在项目中从来不用这种实现——除非是教学演示。

避坑指南:注意多项式 0xEDB88320 是 0x04C11DB7 的反射版本。CRC 有两种流派:非反射(MSB-first)和反射(LSB-first)。大多数 PC 和 ARM 平台用反射版本,但有些 MCU 用非反射。我曾经因为没搞清楚这个,在 STM32 和 PC 之间对不上 CRC,排查了整整两天。

实际项目中,我们几乎都用查表法。提前算好 256 个表项,每个字节直接查表,速度提升 8 倍:

static uint32_t crc32_table[256];

void crc32_init_table() {
    for (uint32_t i = 0; i < 256; i++) {
        uint32_t crc = i;
        for (int j = 0; j < 8; j++) {
            if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320;
            else crc >>= 1;
        }
        crc32_table[i] = crc;
    }
}

uint32_t crc32_fast(const uint8_t *data, size_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (size_t i = 0; i < len; i++) {
        uint8_t index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc32_table[index];
    }
    return crc ^ 0xFFFFFFFF;
}

你看,查表法把内层循环去掉了,每个字节只需要一次查表和一次异或。在 Cortex-M4 上,处理 1MB 数据大概只需要 30ms 左右。

CRC 在固件中的典型应用

CRC 在固件领域主要有三个用途,我分别说说:

  1. 固件镜像完整性校验:这是最常见的场景。固件编译后,在镜像末尾附加 CRC-32 值。Bootloader 在升级前先计算整个镜像的 CRC,与附加值比对。不一致就直接拒绝升级。
  2. Flash 存储校验:每次写入 Flash 后立即回读并计算 CRC,确保写入正确。我遇到过 Flash 在高温下写入位翻转的情况,CRC 帮我抓住了好几次。
  3. 通信协议帧校验:I2C、SPI、UART 等通信中,每帧数据末尾加 CRC-16。虽然硬件 CRC 模块很常见,但软件实现作为 fallback 也很重要。

重要提醒:CRC 是「检错码」,不是「纠错码」。它只能告诉你数据是否损坏,不能修复。如果你需要纠错能力(比如卫星通信),请用 BCH 码或 Reed-Solomon。另外,CRC 不能抵抗恶意篡改——攻击者可以重新计算 CRC 并替换。防篡改请用 HMAC 或数字签名,这是后面章节的内容。

知识体系总览

下面这张图总结了 CRC 的核心知识结构,你可以把它当作本章的思维导图:

CRC 校验知识体系 CRC 循环冗余校验 原理:多项式除法 实现:按位 vs 查表 应用:固件校验 生成多项式 余数 = 校验值 按位计算 查表优化 镜像完整性 Flash 校验 注意:CRC 是检错码,不是纠错码,也不能防篡改 防篡改需要 HMAC 或数字签名(后续章节)

实战中的几个关键参数

实现 CRC 时,除了多项式,还有几个参数必须统一,否则两边算出来的值永远对不上:

  • 初始值(Init):CRC 寄存器开始计算前的值。常见的是 0x00000000 或 0xFFFFFFFF。
  • 结果异或值(XorOut):计算完成后与结果异或的值。常见的是 0x00000000 或 0xFFFFFFFF。
  • 输入反射(ReflectIn):每个字节是否按位反转后再处理。LSB-first 平台通常需要。
  • 输出反射(ReflectOut):最终结果是否按位反转。

这四个参数加上多项式,构成了 CRC 算法的「完整配置」。我建议你在项目文档里明确写出这五个参数,避免团队内部打架。比如标准 CRC-32 的完整配置是:

多项式: 0x04C11DB7
初始值: 0xFFFFFFFF
结果异或: 0xFFFFFFFF
输入反射: 是
输出反射: 是