Skip to content
返回博客
教程

CRC-16 校验变体详解:MODBUS、CCITT、XMODEM 为何结果不同

同一段字节,四种 CRC-16 校验算法算出四个结果。用 poly、init、refin、refout、xorout 五个参数区分 MODBUS、CCITT-FALSE、XMODEM 与 KERMIT,附 Modbus 报文在线实算。

13 分钟阅读

CRC-16 校验变体详解:MODBUS、CCITT、XMODEM 为何结果不同

CRC-16 不是一种算法,而是一个家族,家族成员之间互不认账。把同一段字节分别交给 MODBUS、CCITT-FALSE 和 XMODEM,你会拿回三个毫不相干的 16 位数。

决定你跑的是哪个成员的,是五个常量:polyinitrefinrefoutxorout。改动其中任何一个,输出就完全变样,不会留下半点相似度来提醒你。「为什么我算的 CRC 和设备对不上」,多数时候原因就是这个:两边跑的是不同的变体,而谁也没在文档里写清自己用的是哪一个。

后面几节把这五个参数逐个拆开,再讲怎么反查手里的实现到底属于哪个变体。Modbus RTU 单独占一节,因为它同时踩中了比特反射和字节顺序,而这两件事经常被当成一件。最后一节讲 CRC 为什么挡得住线路噪声,却挡不住攻击者。

本文中的每一个数值,都是一个参数化 CRC 模型在 macOS 的 Python 3.14.7 里实际跑出来的,并做过四重交叉验证:标准库的 zlib.crc32binascii.crc_hqx、RevEng CRC 目录公布的 check 值,以及各参数集的厂商文档。

1. 同一帧报文,四个 CRC-16 结果

下面是一条真实的 Modbus RTU 请求。从站地址 01,功能码 03(读保持寄存器),起始地址 0x0000,寄存器数量 0x000A

01 03 00 00 00 0A

同样这六个字节,四种变体给出四个不同的答案:

变体结果线路上的形态(低字节在前)
CRC-16/MODBUS0xCDC5C5 CD
CRC-16/IBM-3740 (CCITT-FALSE)0x042828 04
CRC-16/XMODEM0x0A3838 0A
CRC-16/ARC0xD6C5C5 D6

这四个值之间没有任何联系:不共享半字节(nibble)模式,也不是相差某个常量,没有哪种换算能把其中一个变成另一个。表里有两个值在线路上恰好以同一个字节开头,这是巧合。

于是数据手册上「CRC-16」这几个字几乎不携带信息。它只告诉你输出宽度是 16 位,仅此而已。如果你想换一种进制看结果,好和一台打印二进制的设备做比对,进制转换器可以把一个 16 位值在十六进制和二进制之间来回切换,不用自己操心补零。

2. 五个参数决定一切

每个变体底下都是同一台机器:一个移位寄存器,一次吞进一个比特,每当有个 1 从最高位掉出去,就异或(XOR)进一个常量。参数管的是什么东西进寄存器、比特往哪个方向走,以及出来时还要经历什么。

整台引擎用十四行 Python 就能写完。这是逐比特实现,慢,但读得懂,本文里的每一个数值都能由它复现:

def crc(data, width, poly, init, refin, refout, xorout):
    top = 1 << (width - 1)
    mask = (1 << width) - 1
    reg = init
    for byte in data:
        if refin:
            byte = int(f"{byte:08b}"[::-1], 2)
        reg ^= byte << (width - 8)
        for _ in range(8):
            reg = ((reg << 1) ^ poly) if reg & top else (reg << 1)
            reg &= mask
    if refout:
        reg = int(f"{reg:0{width}b}"[::-1], 2)
    return reg ^ xorout

这台引擎算出的值可以用 Python 标准库另外验一遍。zlib.crc32binascii.crc_hqx 各自独立给出结果,三处都对得上:

zlib.crc32(b"123456789")               = 0xCBF43926   engine above = 0xCBF43926   MATCH
binascii.crc_hqx(b"123456789", 0x0000) = 0x31C3       CRC-16/XMODEM = 0x31C3      MATCH
binascii.crc_hqx(b"123456789", 0xFFFF) = 0x29B1       CCITT-FALSE   = 0x29B1      MATCH

poly:两大阵营,0x80050x1021

多项式(polynomial)就是被异或回寄存器的那个常量。它按「正常」形式书写,最高位隐含,所以 0x8005 表示 x^16 + x^15 + x^2 + 10x1021 表示 x^16 + x^12 + x^5 + 1。移位加异或的这个循环,做的就是 GF(2) 上的多项式长除法。报文的每一位是除法的一步,多项式是除数,寄存器里存的是当前余数。

你遇到的 CRC-16 几乎都用这两个之一。0x8005 覆盖 ARC、MODBUS 和 USB,0x1021 覆盖整个 CCITT 乱局,外加 XMODEM、KERMIT 以及那些 RFID 变体。这也正是单看多项式永远认不出变体的原因。

init:寄存器的初始值

主导的值有两个,0x00000xFFFF。这个差别比看上去重要。从零开始时,一个零字节会让寄存器停在零,于是 00 00 0101 算出的 CRC 完全一样,报文在传输途中多出或丢掉前导零,照样能通过校验。初值取 0xFFFF 就堵住了这个盲区,Modbus、CCITT-FALSE 和 USB 都这么做。

refinrefout:比特序,不是字节序

refin 在每个输入字节进入寄存器之前,把它的八个比特翻转。refout 在最后一次异或之前,把最终的寄存器值翻转。硬件如果本来就以 LSB 在前的方式移出数据,这两次反射(reflection)是白送的,所以带反射的变体往往诞生于串行协议。

这对参数最常被和字节序(endianness)混为一谈。它们是不同层面上的两回事,第 6 节会把它们拆开。

xorout:最后一次异或

最后一步,作用在经过 refout 之后的寄存器上。CRC-16 通常是 0x00000xFFFF,整个 CRC-32 家族则是 0xFFFFFFFF。它是最不起眼、也最容易搞错的一个参数,因为这里不匹配的表现,和别处不匹配一模一样。

width:8、16 还是 32 位

宽度决定检错能力的上限。宽度为 n 的 CRC 能抓住长度不超过 n 位的所有突发错误,漏掉一次随机损坏的概率约为 2^-n。对 CRC-16 是 65,536 分之一,对 CRC-32 是 42.9 亿分之一。

下面是实测。以 CRC-16/XMODEM 为基线,每次只改一个参数,输入统一用标准探针字符串 123456789

baseline CRC-16/XMODEM                        = 0x31C3
init 0x0000 -> 0xFFFF   (becomes CCITT-FALSE) = 0x29B1
refin/refout -> true    (becomes KERMIT)      = 0x2189
xorout 0x0000 -> 0xFFFF                       = 0xCE3C
poly 0x1021 -> 0x8005                         = 0xFEE8

翻转一个标志位,输出就完全不同。CRC 没有「接近」这个概念。两个结果要么相同,要么就不携带任何关于输入差多远的信息,所以盯着一个对不上的值看,永远定位不到原因。它的内层循环无非是异或和移位,位运算完全指南专门讲这些运算符本身,本文默认你已经熟悉它们。

3. 「CCITT」是个糟糕的名字

截至 2026 年 9 月,RevEng CRC 目录收录了 31 种不同的 16 位 CRC 定义,其中三种都挂着 CCITT 这个名字,而它们互相都对不上。下面这张表里,四行用的是同一个多项式和同一段输入,结果却毫不相干:

常见叫法目录中的名字checkinitrefin/refoutxorout
CCITT-FALSECRC-16/IBM-37400x29B10xFFFFfalse0x0000
XMODEMCRC-16/XMODEM0x31C30x0000false0x0000
「真正的」CCITTCRC-16/KERMIT0x21890x0000true0x0000
CRC-16/MCRF4XX0x6F910xFFFFtrue0x0000

这段历史很短,也帮不上忙。目录把 CRC-16/KERMIT 的别名列为 CRC-CCITT 和 CRC-16/CCITT-TRUE,而这一个是带反射的。同时,一个不带反射、init 为 0xFFFF 的实现也曾以 CCITT 之名广为流传,所以目录把「CCITT-FALSE」记作 CRC-16/IBM-3740 的别名。XMODEM 夹在两者中间:多项式相同,不做反射,初值为零。

所以数据手册写着 CCITT 的时候,它只告诉了你多项式是 0x1021,别的什么也没说。你手上仍然有四个候选,而选错了得到的值,看起来和正确答案一样像模像样。

在协议文档里写参数,别写名字。poly=0x1021, init=0xFFFF, refin=false, refout=false, xorout=0x0000 只占一行,读的人不用猜;写一句「CRC-16/CCITT」就做不到。

4. 如何判断你手上的是哪个 CRC-16 变体

目录用一个指纹解决了这件事。每个条目都公布一个 check 值:九个 ASCII 字节 123456789 的 CRC。把这串字节喂给你面前的实现,再拿结果去表里查。

变体checkpolyinitrefinrefoutxorout
CRC-16/ARC (IBM/LHA)0xBB3D0x80050x0000truetrue0x0000
CRC-16/MODBUS0x4B370x80050xFFFFtruetrue0x0000
CRC-16/USB0xB4C80x80050xFFFFtruetrue0xFFFF
CRC-16/IBM-3740 (CCITT-FALSE)0x29B10x10210xFFFFfalsefalse0x0000
CRC-16/XMODEM0x31C30x10210x0000falsefalse0x0000
CRC-16/KERMIT(真正的 CCITT)0x21890x10210x0000truetrue0x0000
CRC-16/GENIBUS0xD64E0x10210xFFFFfalsefalse0xFFFF
CRC-16/MCRF4XX0x6F910x10210xFFFFtruetrue0x0000
CRC-32/ISO-HDLC(zip、PNG、zlib)0xCBF439260x04C11DB70xFFFFFFFFtruetrue0xFFFFFFFF
CRC-32/BZIP20xFC8919180x04C11DB70xFFFFFFFFfalsefalse0xFFFFFFFF
CRC-32C (Castagnoli, iSCSI)0xE30692830x1EDC6F410xFFFFFFFFtruetrue0xFFFFFFFF
CRC-8/SMBUS0xF40x070x00falsefalse0x00
CRC-8/MAXIM-DOW (1-Wire)0xA10x310x00truetrue0x00

有一个细节毁掉了多得出奇的比对:123456789 指的是 31 32 33 34 35 36 37 38 39 这九个字节,不是数字 123456789,也不是以空字符结尾的字符串。如果你的语言把宽字符串交给函数,或者自动补了个结束符,那你算的就是另一段输入,表里每一行都会落空。非 ASCII 载荷还有第二个坑:同一段文字用 UTF-8 和 Windows-1252 编码是两串不同的字节,CRC 自然也是两个。ASCII 码表与转换器会列出每个字符对应的字节,十秒钟就能排除这个疑点。

如果 check 值对不上表里任何一行,先排除三个平淡的原因,再怀疑是自定义多项式:

  1. 字节序:你可能把线上读到的结果前后颠倒了。
  2. 字段覆盖范围:发送方把地址字节、长度字节或 CRC 字段本身算进去了,或者没算进去。
  3. 厂商自定义 init:既不是 0x0000 也不是 0xFFFF,这在私有电表协议里确实存在,通常埋在脚注里。

暴力枚举参数

厂商不肯说、固件又读不了的时候,只管要一样东西:一帧抓包报文,外加他们的设备为这帧算出的 CRC。然后枚举。两个多项式、两个初值、refinrefout 各自独立、两个 xorout,一共 32 种组合,这点量根本不算什么:

target = 0x4B37                    # 他们的实现返回的那个值
for poly in (0x8005, 0x1021):
    for init in (0x0000, 0xFFFF):
        for refin in (False, True):
            for refout in (False, True):
                for xorout in (0x0000, 0xFFFF):
                    if crc(b"123456789", 16, poly, init, refin, refout, xorout) == target:
                        print(f"poly=0x{poly:04X} init=0x{init:04X} "
                              f"refin={refin} refout={refout} xorout=0x{xorout:04X}")
poly=0x8005 init=0xFFFF refin=True refout=True xorout=0x0000

只有一个命中,就是 CRC-16/MODBUS。把 123456789 换成真实报文,这套 32 路扫描对任何你手上有已知正确 CRC 的消息都成立。如果一个样本过后还剩下好几种组合,再跑第二帧,取交集。

5. Modbus RTU 实战

Modbus RTU 用的是 CRC-16/MODBUS:多项式 0x8005,初值 0xFFFF,输入输出都做反射,最后不做异或。对前面那条六字节请求,CRC 是 0xCDC5,补完之后的整帧是:

01 03 00 00 00 0A C5 CD

线路上的顺序和你写这个值的顺序正好相反

值是 0xCDC5,追加到帧尾的字节却是 C5 CD。Modbus 规定 CRC 低字节在前,这和你读这个十六进制数的顺序正好相反,每次都有人栽在这里。同一帧里其他所有多字节字段,包括那个 00 0A 的寄存器数量,都是大端序。只有 CRC 是例外。

由此还能推出一个很有用的结论。对整帧(含那两个 CRC 字节)计算 CRC-16/MODBUS,结果是:

0x0000

接收端的校验逻辑就到此为止了。不需要把末尾两个字节切下来、做字节交换、再和本地算出的值比对。把收到的全部内容跑一遍 CRC,看是不是零就行。步骤越少,能把字节顺序弄反的地方也越少。

Modbus 文档里为什么写的是 0xA001

随便打开一份 Modbus 实现,源码里的常量都是 0xA001 而不是 0x8005。两个都对。0xA001 就是 0x8005 的 16 个比特整个翻转过来,它属于该算法的反射形式:寄存器向右移而不是向左移,输入字节也就不需要逐字节翻转了。两种实现的输出完全一致,只是内部走法不同。在代码里看到 0xA001,基本可以断定你面对的是一个反射版 CRC-16,而不是另一个多项式。

顺带给看抓包的人提个醒。Modbus ASCII 是另一套帧格式,它把每个字节写成两个十六进制字符,前面加一个 3A:),末尾跟 0D 0A,而且用的是 LRC 而不是 CRC。如果你的抓包里本该是 01 03 的位置出现了 3A 30 31 30 33,那就是在 ASCII 模式下,任何 CRC 变体都不可能对上。ASCII 码表能把这些字节直接还原成字符。

同一个算法的 C 语言写法

Modbus 固件几乎不会用上面那个 Python 模型,而是直接用反射形式:右移,再与 0xA001 异或。

uint16_t crc16_modbus(const uint8_t *buf, size_t len) {
    uint16_t crc = 0xFFFF;
    for (size_t i = 0; i < len; i++) {
        crc ^= buf[i];
        for (int b = 0; b < 8; b++)
            crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
    }
    return crc;
}

喂给它报文 01 03 00 00 00 0A,返回 0xCDC5,与 Python 引擎算出的值相同。校验串 123456789 得到 0x4B37,正是第 4 节表里 CRC-16/MODBUS 那一行的 check 值。两者都在 Apple clang 21 下实跑过。

6. refin/refout 不是字节序

这两件事在不同的层面上,而一帧 Modbus 报文里两样都有,所以混起来的代价不小。

字节序管的是一个多字节值内部的字节排列:0xCDC5 存储或传输时是 CD C5 还是 C5 CD。字节内部的东西一动不动。这一层大端序与小端序已经讲透了,本文不再重复。

反射管的是单个字节内部的比特排列。refin 在寄存器看到某个字节之前,把它的八个比特翻转:第 0 位变成第 7 位。这个字节在报文里的位置从头到尾没变过。字节序看不到这一层,反射也看不到字节序。

在一帧 Modbus 报文里,这两件事同时发生,而且互不相干:

  • 算法内部,refinrefout 都为真,所以比特在字节内部被翻转。
  • 线路上,算完的 CRC 低字节在前地追加到帧尾,这是协议层面的字节序决定。

为了「修」字节序问题而关掉反射,得到的是一个彻头彻尾不同的变体;反过来,为了补偿反射不匹配而交换帧尾的两个字节,得到的是一个以新方式出错的值。一次只诊断一样:先单独把算法的 check 值弄对,再去处理帧的排布。

7. CRC-32 还是 CRC-16:该选哪个

CRC-32 有和 CRC-16 一样的问题,只是不那么显眼,因为其中一个变体太过强势,大多数开发者根本不知道还有别的。

变体checkpolyrefin/refout
CRC-32/ISO-HDLC0xCBF439260x04C11DB7true
CRC-32/BZIP20xFC8919180x04C11DB7false
CRC-32C (Castagnoli)0xE30692830x1EDC6F41true

ISO-HDLC 就是 zlib.crc32 算的那个,在 Web 这一侧的技术栈里到处都是。Zip 在中央目录里为每个条目存一份,每个 PNG 数据块末尾也带一个,覆盖块类型和块数据。以太网的帧校验序列用的也是同一套参数,你服务器发出的每一帧末尾都有它。BZIP2 是同一个多项式关掉反射,算出的值和 ISO-HDLC 毫无共同之处。

CRC-16 在这一带也有身影:Redis Cluster 用 CRC16(key) mod 16384 把一个键路由到 16,384 个槽中的某一个,带相同哈希标签(hash tag)的键会落在同一个节点上,原因就在这里。

CRC-32C 为什么中了硬件这张彩票

CRC-32C 用的是另一个多项式,在存储和网络协议实际发送的那种短块上,检错能力更好。凭这一点它拿下了 iSCSI、ext4 元数据校验、SCTP 和 Btrfs。接着 Intel 把它做进了硅片:SSE4.2 的 crc32 指令直接算 CRC-32C,于是在现代 x86 上,它几乎成了一次白送的完整性校验。如果你今天要为新代码选一个 CRC,又没有兼容性包袱,选它。

这些都不是用来校验下载文件的哈希。想用一个校验和确认文件完整到达时,MD5 哈希生成器是大家熟悉的工具,而 MD5 与 SHA-256 对比讲的是什么场景该信哪种摘要。分工是这样的:CRC 回答的是「这份数据有没有被损坏」,密码学哈希回答的是「这是不是我期待的那份内容」。

8. CRC 挡不住篡改

下面是两条含义完全相反、CRC-32 却一模一样的 JSON 消息:

message A = b'{"to":"alice","amount":100}  H\xf6\xc0E'
message B = b'{"to":"mallory","amount":999}\xb2\xb2\xc2\xc2'
CRC-32(A) = 0x12345678
CRC-32(B) = 0x12345678

对这两条算 SHA-256:

A -> a83b90f5e3f21dd32039e0e693a8b15864eda83e258c115deeee50a60c09ae23
B -> f9319ee0507d719c7260535dff33df721ddd9c0e1857690f3d8e02f92601b742

制造这种碰撞不需要运气,也不需要算力。这里没有任何暴力搜索,末尾那几个字节是用代数方法解出来的。CRC 是一个线性函数,往消息后面追加四个字节这件事,映射到 CRC-32 输出上是一个双射,所以对任意消息、任意目标值,都恰好存在唯一一个四字节后缀能让你命中,而把它找出来只是算术。

实测如下,把解出来的四个字节 46 C9 6E 0B 追加到 hello world 后面:

CRC-32(b'hello world' + 46 C9 6E 0B) = 0xDEADBEEF   target 0xDEADBEEF   MATCH

所以,能改你载荷的攻击者,也能在常数时间内把 CRC 一并改好。在消息前面拼一个密钥也救不了:只要改动不改变长度,CRC 上需要施加的修正就只取决于哪些比特发生了变化,不知道密钥也照样算得出来。当年攻破 WEP 的,正是这个形状的攻击。

CRC 防的是传输噪声。噪声是随机的,也不针对谁。对手两样都不是,所以 CRC 拦不住他。要保证的如果是真实性,就得换一个带密钥的构造:HMAC 生成器可以对任意载荷和密钥生成一个,而 Webhook 签名验证为什么失败则逐个讲清了把它接到真实端点上时最容易翻车的地方。

9. 常见问题

CRC-16/CCITT 和 CRC-16/CCITT-FALSE 是同一个东西吗?

不是,CRC-16/CCITT-FALSE 和 CRC-16/CCITT 是两个不同的变体。CCITT-FALSE 就是 CRC-16/IBM-3740:初值 0xFFFF,不做反射,check 值 0x29B1。人们说「CCITT」时通常指的那个是 CRC-16/KERMIT:初值 0x0000,带反射,check 值 0x2189。两者共用多项式 0x1021,除此之外没有一处相同。

在线计算器算出来的和我的设备对不上,为什么?

几乎总是参数不同。工具默认用了某个变体,设备实现的是另一个。把 ASCII 字节 123456789 分别跑一遍,拿两个 check 值去对照第 4 节的表,一分钟之内通常就能看出是哪个参数错了。

Modbus 为什么把 CRC 低字节放在前面?

因为规范就是这么定的,而且这是整帧里唯一这样做的字段。寄存器地址和数量都是大端序,只有末尾的 CRC 不是。对包含这两个字节在内的整帧计算 CRC-16/MODBUS 并检查是否为 0x0000,比自己去调换顺序更省事。

refinrefout 到底翻转的是什么?

字节内部的比特,永远不是消息内部的字节。refin 在处理前翻转每个输入字节的八个比特,refout 在最后一次异或之前翻转最终的寄存器值。两者都和字节序无关,字节序管的是一个多字节值在线路上怎么排布。

该用 CRC-16 还是 CRC-32?

CRC-16 漏掉一次随机损坏的概率大约是 65,536 分之一,CRC-32 是 42.9 亿分之一。对几十个字节的短串行帧,CRC-16 够用,而且协议往往本来就规定了它。对文件、网络帧以及任何超过几 KB 的数据,用 CRC-32。

CRC 能当 API 签名用吗?

不能,CRC 不能用作接口签名。CRC 是线性的,任何人改了载荷都能重新算出一个对得上的值,而且追加四个精心选定的字节就能命中你说出的任何 CRC-32 目标值。签名需要密钥和非线性构造。用 HMAC 配 SHA-256。

能从 CRC 值反推出原始数据吗?

不能,无法从 CRC 值反推出原始数据。CRC-16 把任意输入压缩成 16 位,所以每个值背后都有无穷多条消息。不过反方向对攻击者仍然有用:给定一个目标值,你可以构造出一条能算出它的消息,这恰恰说明 CRC 什么也认证不了。

厂商文档只写了「CRC-16」,怎么把参数钉死?

找他们要一帧抓包报文,外加他们的设备为这帧算出的 CRC,然后用第 4 节那套 32 种组合的扫描去跑这一对数据。如果活下来的参数集不止一个,换第二帧重跑,取交集。两个样本几乎总能定死。

标签: crc checksum modbus embedded data-integrity