ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Modbus RTU与ASCII校验算法详解:CRC16和LRC原理及实现

Modbus RTU与ASCII校验算法详解:CRC16和LRC原理及实现 调试一块带 Modbus RTU 接口的温控器串口调试助手把01 03 00 00 00 0A发出去设备一点反应都没有。波特率、站号、接线查了个遍都正常最后才发现是 CRC 校验字节的顺序写反了。这种问题在 Modbus 开发里实在太常见了。CRC循环冗余校验和 LRC纵向冗余校验是 Modbus 协议里绕不开的两个算法RTU 模式用 CRC16ASCII 模式用 LRC。这篇就把两个算法的原理、推导和代码实现一次性讲清楚工程上可以直接抄也适合刚接触嵌入式通信的工程师入门。1. CRC和LRC分别负责哪一段先看清Modbus帧结构1.1 三种帧格式与校验算法的对应关系Modbus 协议栈分了几种物理层和链路层组合最常见的三种模式是 RTU、ASCII 和 TCP。很多人看协议文档时只看功能码和寄存器地址忽略了每种模式帧尾的校验方式完全不一样。Modbus RTU二进制帧一帧数据由地址、功能码、数据域、CRC 校验组成。CRC 是 16 位占用 2 个字节放在帧末。这是工业现场用得最多的形式RS485 总线上几乎都是它。Modbus ASCII把所有数据字节转换成十六进制 ASCII 字符发送帧头是冒号:帧尾是回车加换行。校验用的是 LRC占 1 个字节同样转成两个 ASCII 字符附在数据后面。Modbus TCP走以太网协议头是 MBAP理论上不再需要 CRC/LRC因为 TCP/IP 协议栈本身有校验。但实际项目里经常有串口服务器、DTU 透传 RTU 帧到 TCP 负载的情况所以很多工程师还是会把 CRC 计算函数保留下来备用。模式帧格式校验方式校验位置典型场景RTU二进制字节CRC16-Modbus帧尾2字节RS485现场总线、绝大多数仪表ASCII可打印十六进制字符LRC帧尾1字节老式PLC、长距离低速无线透传TCP以太网报文无依赖TCP/IP无工业以太网、上位机通信CRC 和 LRC 的计算范围都是从从站地址开始到数据域最后一个字节结束不包括校验自身也不包括帧头帧尾。这句话看着简单实际上很多通信对不上的问题都出在“范围搞错”上。1.2 为什么RTU模式选CRCASCII模式选LRC这不是拍脑袋定的背后是检错能力和计算开销的权衡。CRC 的检错能力远强于 LRC。它能检测出单位错、双位错、奇数位错、突发错误而且对“数据字节顺序调换”非常敏感。比如01 02 03和03 02 01LRC 的累加和完全一样算出来的校验值也一模一样但 CRC 会因为移位反馈机制算出两个完全不同的结果。这种特性对工业总线来说非常重要因为 RS485 上的干扰经常是成片出现的不是单个位翻转那么简单。LRC 只是一个简单的累加取补码。它的好处是计算量极小一个加法运算就能搞定。在早期 8 位单片机上CPU 主频只有几兆赫兹内存以字节为单位计数跑一个完整 CRC 的 16 次循环都会心疼LRC 几乎是零成本。同时 ASCII 模式本身把每个字节拆成两个可见字符传输很多传输错误会破坏字符集的合法性接收端可以一眼看出来然后丢弃LRC 只是兜底不需要 CRC 那么强的纠错能力。所以这不是“LRC 比 CRC 差所以放在 ASCII 模式”而是协议设计者根据模式特点做的匹配选择。强校验配二进制高效帧轻校验配可读性优先的文本帧各得其所。1.3 多项式0xA001的来历从0x8005反转过来了CRC 算法的核心是多项式。Modbus 用的 CRC 在标准 CRC-16 族里有个专属名字叫 CRC-16/MODBUS参数表如下参数值宽度 width16多项式 poly0x8005初始值 init0xFFFF输入反转 refintrue输出反转 refouttrue输出异或 xorout0x0000标准多项式是 0x8005对应的二进制是1000 0000 0000 0101。但 Modbus 的实现里移位方向是低位先处理也就是把多项式做了位反转0x8005 1000 0000 0000 0101 反转 1010 0000 0000 0001 0xA001所以你在网上看到的大多数 Modbus CRC 代码循环里异或的都是0xA001而协议文档里写的是0x8005。这两个是同一个东西的两种表达方式一个是数学定义一个是工程实现。理解了这一点再看那些代码里的魔数就不会觉得莫名其妙了。2. CRC16-Modbus核心原理与两种落地写法2.1 CRC到底在算什么CRC 的数学本质是“二进制模 2 除法取余数”把数据当作一个很长的二进制数除以生成多项式得到的余数就是校验码。发送方把余数附在数据后面接收方用同样的多项式去除余数为零说明传输过程没出错。这个说法很严谨但初学时容易绕晕。我习惯用一个更直观的比喻CRC 相当于给整帧数据算一个“指纹”。只要你动了原始数据里的任何一个位最后的指纹就会变。因为每一位在移位过程中会不断和多项式异或影响会扩散到整个寄存器。实际工程实现里没有人真的去做二进制长除法而是用“移位寄存器加反馈异或”的方式模拟这个过程。你完全可以把它当作一个黑盒丢进去一串字节吐出来一个 16 位数只要不同实现之间参数一致结果一定一致。2.2 直接计算法先搞懂每一步在做什么先写最笨、但最容易理解的版本适合用来验证逻辑#include stdint.h uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; // 初始值必须为0xFFFF for (uint16_t i 0; i len; i) { crc ^ data[i]; // 当前字节异或到CRC低8位 for (uint8_t j 0; j 8; j) { if (crc 0x0001) // 看最低位 crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }每一步的含义初始值必须是 0xFFFF这是 CRC-16/MODBUS 参数表的硬性规定不能改成 0x0000。0xFFFF 初始值配合反射多项式可以避免数据前面补零导致校验失效的问题。crc ^ data[i]把当前字节和 CRC 低 8 位异或相当于把数据“喂”进移位寄存器。循环 8 次每一位处理一次。看寄存器最低位是 1 还是 0如果是 1右移一位后再和多项式 0xA001 异或如果是 0直接右移。结束不需要再次取反。这是 CRC-16/MODBUS 和某些其他 CRC16 变体的关键区别后面的踩坑部分会细说。对于一帧 8 字节左右的典型请求这个函数在任何跑得动 C 语言的 MCU 上都能几十微秒内算完。它的优点是省内存缺点是每处理一个字节要循环 8 次帧很长或者频率很高时会有性能压力。2.3 查表法用一张512字节的表换速度查表法的思路是把“一个字节对整个 CRC 的影响”提前算好存成一张 256 项的表格。真正处理数据时每收到一个字节只需要一次查表加几次位运算不再需要内层循环 8 次。先看表怎么生成uint16_t crc_table[256]; void crc_table_init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; // 假设CRC的低8位就是这个字节 for (uint8_t j 0; j 8; j) { if (crc 1) crc (crc 1) ^ 0xA001; else crc 1; } crc_table[i] crc; } }生成的表长什么样前几项是索引表值0x000x00000x010xC0C10x020xC1810x030x01400x040xCC01这张表的实际意义是每个字节独立“扰动”CRC 的结果。处理数据时用当前 CRC 的低 8 位和待处理字节异或得到一个 0 到 255 的索引查出该字节带来的扰动值再和 CRC 高 8 位右移后的值异或uint16_t modbus_crc16_table(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t idx (uint8_t)(crc ^ data[i]); crc (crc 8) ^ crc_table[idx]; } return crc; }查表法每处理一个字节只需要 2 次移位、2 次异或、1 次查表速度快了一个数量级。代价是 256 项 16 位数据RAM 或 Flash 里多占 512 字节。对于如今动不动几十 KB 内存的 MCU这点开销可以忽略不计。2.4 发送字节序和三个最常见错误CRC 计算出来是一个 16 位数值但 Modbus RTU 规定发送时低字节在前高字节在后。举个例子如果我们算出某个帧的 CRC 是0xCDC5那么真正发到串口上的字节顺序是C5 CD不是CD C5。这个细节坑过无数新手。三个高频问题我在做技术支持的几年里反复遇到初始值写错。有人习惯性把 CRC 初始值写成 0x0000算出来的结果永远跟标准报文对不上。字节序反了。计算函数没问题但发送时先发了高字节。这种错误最隐蔽因为设备有时候能通有时候通不了取决于对端是严格要求还是宽松处理。多算了或者少算了字节。比如把 CRC 自身的两个字节也放进计算范围或者把帧头地址 0x01 漏掉。一个简单的检查办法用下面这组测试向量锁死实现输入01 03 00 00 00 0A期望结果是0xCDC5发送字节为C5 CD。提示不同 CRC 算法变体之间的差异就集中在四个参数上——多项式、初始值、输入是否反转、输出是否取反。写代码前先画个表把这四项列出来就不会被网上一堆互相矛盾的代码带偏。3. LRC校验一个累加加补码的事3.1 LRC的计算规则LRC 全称 Longitudinal Redundancy Check纵向冗余校验。它的规则比 CRC 简单得多把从站地址到数据域末尾所有字节做无符号累加累加和取二进制补码补码就是这个校验值。二进制补码的意思就是“取反加一”。用公式表达就是LRC (0x00 - sum) 0xFF或者等效地LRC (~sum 1) 0xFF为什么用补码而不是直接取反或者用原始累加和因为接收方把“原始数据字节 LRC 字节”全部累加起来结果应该等于 0。这方便接收端做校验把所有字节含校验字节加起来如果低 8 位结果是 0校验通过否则失败。这个设计非常优雅一条加法指令就能完成验证。3.2 代码实现#include stdint.h uint8_t modbus_lrc(uint8_t *data, uint16_t len) { uint8_t sum 0; for (uint16_t i 0; i len; i) { sum data[i]; } return (uint8_t)(0 - sum); }注意累加变量用了uint8_t这样自然只保留低 8 位。也可以用uint16_t累加最后强制转成uint8_t效果一样但前者更贴近“只关心低 8 位”的语义。这段代码比 CRC 简单太多基本不存在写错的可能。真正容易出错的反而是 ASCII 帧怎么拼。3.3 ASCII模式下的帧拼装细节Modbus ASCII 模式下每个原始二进制字节会被拆成两个 ASCII 十六进制字符发送。比如原始字节0x01发送时是字符0和1即0x30和0x31。一个完整的 Modbus ASCII 帧长这样:01030000000AF2\r\n拆开看:是起始符一个字节01030000000A是地址、功能码和数据域转换成的 ASCII 字符F2是 LRC 校验值转换成的两个十六进制字符\r\n是结束符。这里最容易搞错的一点LRC 计算的是原始二进制字节不是 ASCII 字符。先对01 03 00 00 00 0A这 6 个二进制字节做累加取补得到0xF2再把它转成字符F2拼到帧里。如果你错误地对 ASCII 字符0 1 0 3 ...做累加算出来的值完全是另一个数。3.4 LRC的局限为什么它只能用在ASCII模式前面提过LRC 对“字节调换顺序”完全没有检测能力。01 02 03和03 02 01的累加和都是 0x06LRC 都是 0xFA接收方根本看不出顺序被调换了。串口通信里如果出现两个字节内容互换的情况LRC 会直接放行。另外两个错误字节“一增一减”也可能互相抵消。比如一个字节从 0x10 变成 0x11另一个字节从 0x20 变成 0x1F累加和不变LRC 同样检测不出来。这不是 LRC 的 bug而是这种轻量校验的固有边界。ASCII 模式之所以敢用是因为它的字符集有限很多错乱会直接变成非法字符被接收端提前丢弃LRC 只需要防住剩下的低概率问题。如果对数据完整性要求很高老老实实上 RTU 模式加 CRC。4. 手工验算一遍用标准报文验证你的实现4.1 一帧读保持寄存器请求的完整计算过程选一个最经典的 Modbus RTU 功能码 03 请求读从站 1 的保持寄存器起始地址 0x0000读取数量 0x000A10 个寄存器。原始报文是01 03 00 00 00 0A先算 LRC累加和 0x01 0x03 0x00 0x00 0x00 0x0A 0x0E 补码 0x100 - 0x0E 0xF2所以 ASCII 模式的完整帧是:01030000000AF2\r\n。再算 CRC16-Modbus初始值0xFFFF处理0x01后CRC 变为0x807E处理0x03后CRC 变为0x2140处理第一个0x00后CRC 变为0xF020处理第二个0x00后CRC 变为0xD8F1处理第三个0x00后CRC 变为0x8419处理0x0A后CRC 变为0xCDC5最终 CRC 是0xCDC5按低字节在前发送C5 CD。完整的 RTU 报文就是01 03 00 00 00 0A C5 CD你可以拿这组数据去任何在线 CRC 计算工具验证只要是 CRC-16/MODBUS 参数配置结果一定是这个。4.2 第二个测试向量防止实现细节被掩盖只用一组数据不够因为有些错误写法碰巧能通过一组测试但换一组就露馅。再给一组01 03 00 00 00 01这帧读 1 个保持寄存器。CRC 计算过程基于上一组的数据继续推从第三字节开始是0x00该帧只有两个0x00处理到第二个0x00后 CRC 是0xD8F1再处理最后的0x01crc 0xD8F1 ^ 0x01 0xD8F0 8次移位后 crc 0x0A84发送字节顺序84 0A。完整报文01 03 00 00 00 01 84 0ALRC 也顺便算了01 03 00 00 00 01 0x05补码是0xFBASCII 帧为:010300000001FB\r\n。把这两组向量做成单元测试无论怎么改平台、换编译器跑一遍都能确认 CRC 和 LRC 实现没有被破坏。4.3 用Modbus Poll、Modbus Slave做交叉验证很多人在 PC 上开发上位机或者调设备时会用到 Modbus Poll 和 Modbus Slave 这两个工具。它们不只是拿来读写寄存器的还有一个很实用的功能验证你自己的 CRC/LRC 实现。常见用法写一个从站程序用 Modbus Poll 去读它。如果 Poll 能正常读到数据说明你的从站发出的帧包括校验字节都是被认可的。写一个主站程序用 Modbus Slave 做从站在你的主站发送报文时让 Slave 正常响应说明你的请求帧校验正确。用串口调试助手抓总线上的原始字节对照上面给的两组测试向量直接检查 CRC 字节顺序对不对。现场无响应的排查顺序我也总结了一个套路先看 RS485 接线和方向控制A/B 是否接反、收发切换是否正常用串口助手看实际发出的字节确认 CRC 顺序是低字节在前用 Modbus Poll 或 Slave 交叉验证同一帧数据排除从站程序的问题最后才回头检查自己的 CRC 函数重点看初始值、多项式、计算范围。5. 单片机实战代码落地时真正要注意的细节5.1 直接计算法还是查表法先看资源再拍板很多文章会告诉你“查表法更快所以用查表法”但实际选型要看你的项目处于什么阶段。直接计算法的优势是零额外存储一个函数搞定适合 8 位小 RAM 单片机或者项目中只有偶尔一两个地方需要算 CRC。查表法的优势是快适合频繁收发、帧长、实时性要求高的场景。方案速度额外资源适合场景直接计算法每字节约8次循环几乎为0RAM紧张、低频通信动态生成表开机算一次之后查表RAM 512BRAM充足不想固化表常量表最快Flash 512B大多数常规项目以 115200 波特率为例一个字节的传输时间大约 86.8 微秒。查表法处理一个字节通常在几微秒以内直接计算法最多也就几十微秒。所以在 115200 波特率下两种方法都够用。但如果是 921600 波特率、一帧几十个字节、每秒收发几百帧累积的差距就不能忽视了这时候直接用常量表。5.2 接收中断里逐字节更新CRC而不是收完再算我见过不少工程师把整帧收进缓冲区然后帧结束之后调用 CRC 函数遍历一遍。这种写法没问题但多了一次遍历开销。更好的做法是在接收中断里每收一个字节就实时更新 CRC帧结束时直接比对volatile uint16_t rx_crc 0xFFFF; extern const uint16_t crc_table[256]; void on_rx_byte(uint8_t byte) { uint8_t idx (uint8_t)(rx_crc ^ byte); rx_crc (rx_crc 8) ^ crc_table[idx]; // 同时把 byte 存入接收缓冲区 }帧结束的判断要靠帧间空闲时间。RTU 协议规定两个帧之间至少要有 3.5 个字符时间的静默期。这个静默要用 MCU 的定时器来测量收到最后一个字节后开启定时如果 3.5 个字符时间内没有新字节进来判定一帧结束这时去比较rx_crc和收到的 CRC 字节是否一致。注意一个细节RTU 帧末尾的 CRC 字节也会触发接收中断所以上述函数计算时会把 CRC 自身也“搅”进rx_crc。正确做法是接收完数据域后让rx_crc保持“只算到数据域最后一个字节”的状态然后用它和收到的两个 CRC 字节比较。简单实现就是在处理完数据域之后做个标记从 CRC 字节开始不再更新rx_crc。5.3 16位右移的符号扩展坑某些编译器上会翻车这是我在实际项目中踩过的一个隐蔽坑值得单独说。C 语言里uint16_t在表达式求值时会先做整型提升。如果目标平台的int是 16 位比如 AVR、某些老式 8051 编译器那么uint16_t会和int等宽但只要数据第 15 位是 1提升后就变成了负数。此时再做右移编译器生成的是算术右移最高位补 1 而不是补 0CRC 结果直接错误。解决办法很简单移位前强制转回无符号类型crc (uint16_t)(crc 1) ^ 0xA001;或者干脆把crc声明成uint32_t只在返回时强制转成uint16_t这样在任何平台上都不会因为符号扩展出错。检查你的代码有没有这个隐患有个简单方法用上面 4.2 那组测试向量01 03 00 00 00 01去测本地 x86 平台通了不代表 8 位单片机平台也通。换平台后一定要重新跑一次向量测试。5.4 查表法表没初始化这个低级错误查表法的表有两种放法一种是crc_table_init()在运行时生成另一种是直接写死成一个const常量表。运行时生成的问题是如果哪个模块提前调用了modbus_crc16_table()而忘了先初始化表或者复制代码时把初始化函数漏了表里全是 0算出来的 CRC 永远错误而且不易察觉。这种 bug 的表现是“设备时通时不通”排查起来非常费劲。我个人的习惯是直接放常量表。虽然代码看起来不如运行时生成“技术含量高”但省心不需要担心初始化顺序也不会占用 RAM。512 字节的 Flash 在现代 MCU 里根本不值一提。常量表不需要自己手敲把前面crc_table_init()的代码在 PC 上跑一遍把数组内容打印出来复制到工程里就行。注意要把声明改成static const uint16_t crc_table[256]编译器才会把它放在只读区。5.5 日志调试时的一个小坑别把校验值按有符号数格式化调试上位机时我见过有人这样打印 CRCprintf(CRC %04X\n, crc);这没问题因为%X配合 16 位整数不会出问题。但如果是通过串口往日志服务器发或者用某些变参机制把uint16_t默认提升成int后位数不一致打印出来就可能是FFFFFFFF开头的乱码。更隐蔽的问题是有人把 CRC 结果强转成uint8_t去打印只看到低 8 位怎么比对都对不上。调试时尽量保持 CRC 为 16 位完整变量打印输出用%04X并且确认传入值没有被隐式转换成带符号类型。6. 再补一个很多人忽略的ASCII模式下LRC和帧结构联调的经验6.1 用ASCII模式调试老设备时注意区分“格式错误”和“校验错误”ASCII 模式设备近年见得少了但老旧 PLC、某些电力规约转换设备还在用。调试这种设备时有个容易混淆的地方对端收到错误帧后错误码返回的可能是0x03非法数据值或者干脆没有响应并不直接告诉你到底是帧格式错了还是 LRC 错了。我曾经处理过一个项目现象是“LRC 明明算对了设备就是不响应”。后来抓总线发现我的程序把 ASCII 字符F 2发成了二进制字节0xF2。设备收到后把0xF2当成一个不可打印字符直接判定为非法帧丢弃。这个坑的根源是发送 ASCII 模式帧时每个字节必须先用sprintf之类的函数转成字符再写入串口不能直接把原始二进制值发出去。同理接收端收到的是字符要先把两个字符转成一个十六进制字节再做 LRC 验算。6.2 十六进制字符大写和小写的问题Modbus ASCII 规范里十六进制字符A到F建议用大写。实际测试中有些设备对大小写敏感有些设备不敏感。最稳妥的做法是统一转成大写static uint8_t to_hex_char(uint8_t nibble) { nibble 0x0F; return nibble 10 ? (0 nibble) : (A nibble - 10); }在做一个跨厂商项目时如果对接的设备固件比较老先把大小写格式问清楚或者直接看它的报文示例别想当然。6.3 帧间间隔在ASCII模式下同样重要很多人以为只有 RTU 有 3.5 字符间隔的要求ASCII 模式就没有。实际上 ASCII 模式同样要求帧间静默只是时间基准不一样。ASCII 模式下如果两个字符之间的间隔超过 1 秒接收方也可以判定为新的帧开始。这个 1 秒的超时看似宽松但对一些用定时器轮询的裸机程序来说如果不小心把超时时间设成几毫秒一帧 ASCII 数据在低速串口上还没发完就被判定为“新帧开始”LRC 计算范围就会从中间切断结果必然错误。7. 一组开箱即用的综合代码片段写到最后把 CRC 和 LRC 的完整实现整合一下方便直接复制到工程里。这里的表生成代码可以在 PC 上运行一次然后把生成的常量表固化到程序里。#include stdint.h // 生成查表用编译目标机上跑一次把表格数据复制为static const void crc_table_generate(uint16_t *table) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t j 0; j 8; j) { crc (crc 1) ? (uint16_t)((crc 1) ^ 0xA001) : (uint16_t)(crc 1); } table[i] crc; } } // CRC16-Modbus 查表法 uint16_t modbus_crc16(const uint8_t *data, uint16_t len, const uint16_t *crc_table) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t idx (uint8_t)(crc ^ data[i]); crc (uint16_t)((crc 8) ^ crc_table[idx]); } return crc; } // LRC 校验字节 uint8_t modbus_lrc(const uint8_t *data, uint16_t len) { uint8_t sum 0; for (uint16_t i 0; i len; i) { sum data[i]; } return (uint8_t)(0 - sum); }调用示例对报文01 03 00 00 00 0A计算 CRC然后按低字节在前拼帧uint8_t frame[8] {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A}; uint16_t crc modbus_crc16(frame, 6, crc_table); frame[6] (uint8_t)(crc 0xFF); // 低字节 C5 frame[7] (uint8_t)((crc 8) 0xFF); // 高字节 CDLRC 对应拼字符帧uint8_t lrc modbus_lrc(frame, 6); // 结果是 0xF2我在这里特意强调低字节先放因为这是最容易在实际接线后暴露出来的问题。最后再分享一个个人习惯每次新项目开始写 Modbus 驱动我都会先把01 03 00 00 00 0A C5 CD和01 03 00 00 00 01 84 0A这两组测试向量做成单元测试换平台、换编译器的时候先跑一遍。这个习惯帮我避免了好几次“代码看着对线上就翻车”的尴尬。CRC 和 LRC 本身不难难的是细节——字节序、初始值、表初始化、帧边界哪个没注意都会让你在调试台上耗掉一整天。
返回列表