Modbus 多寄存器数据解析终极指南:字节序(Endianness)的完整解决方案

本文目录
  1. 1. 这个问题比你想象的更普遍
  2. 2. 四种字节序,四张图说清楚
  3. 3. ABCD(大端序,Modbus 标准排列)
  4. 4. DCBA(小端序,完全反转)
  5. 5. CDAB(字交换,寄存器前后互换)
  6. 6. BADC(字节交换,每个 WORD 内部反转)
  7. 7. 数据类型远比字节序更隐蔽
  8. 8. 各主流厂商的实际行为
  9. 9. 施耐德 Modicon M340/M580/M241
  10. 10. 西门子 S7-1200/S7-1500(MB_SERVER)
  11. 11. 台达 DVP 系列
  12. 12. 三菱 FX3U/FX5U(通过 Modbus 通信模块)
  13. 13. 欧姆龙 CP1H/CP1L
  14. 14. 国产 PLC(汇川、步科)
  15. 15. Modbus Poll 的字节序切换
  16. 16. 64 位数据的处理
  17. 17. 字符串的存储
  18. 18. 调试方法论
  19. 19. 附录:Modbus CRC-16 查表法 C 语言实现

这个问题比你想象的更普遍

用 Modbus Poll 读到一个电能表的正向有功总电量,寄存器 40001~40002,返回 `42 C8 00 00`。你信心满满地按 IEEE 754 float32 一拼——100.0 kWh,合理。同一个程序,换到另一家国产电力仪表,读取同样的两个寄存器,返回 `00 00 42 C8`,拼出来 0.0,PLC 的触摸屏上直接给你画了条直线。你没写错寄存器,也没算错 CRC,纯粹是字节序反了。

这就是 Modbus 多寄存器数据解析的核心痛点。Modbus 协议规范 V1.1b 第 4.2 节”Data Encoding”说得非常坦诚:**”Modbus uses a ‘big-endian’ representation for addresses and data items. This means that when a numerical quantity larger than a single byte is transmitted, the most significant byte is sent first.”** 翻译成人话:Modbus 在传输单个寄存器内的 16 位数据时用大端序——高字节在前。但规范没说 32 位或 64 位跨多个寄存器时怎么排。两个连续寄存器的先后次序、每个寄存器内部两个字节的次序,全留给设备厂商自己决定。

这话听起来像甩锅,但 1979 年的 Modicon 工程师确实没必要操心 32 位浮点数——当年 PLC 连 16 位整数都嫌奢侈。四十年后,我们得自己扛。

四种字节序,四张图说清楚

假设你读取保持寄存器从地址 0(Modbus 地址 40001)开始,请求 2 个寄存器。从站返回的报文里的数据区是:

41 DB 85 1F

这是一个 IEEE 754 单精度浮点数的原始字节序列。真实值是 27.440271…(约 27.44)。现在看四种字节序分别怎么拼:

ABCD(大端序,Modbus 标准排列)

寄存器 40001 的值是 `0x41DB`,寄存器 40002 的值是 `0x851F`。按顺序拼成 32 位:

寄存器 40001(高16位)    寄存器 40002(低16位)
+--------+--------+      +--------+--------+
|  0x41  |  0xDB  |      |  0x85  |  0x1F  |
+--------+--------+      +--------+--------+
  A        B               C        D

内存视角(低地址到高地址):`41 DB 85 1F`

→ float32 = **27.44**

这是施耐德 Modicon 系列 PLC(M340、M580、M241 等)的默认行为。施耐德作为 Modbus 协议的亲爹,一直遵循这个标准排列——寄存器地址小的存高 16 位,寄存器地址大的存低 16 位。你用 READ_VAR 读回来两个 WORD,直接拼就是对的。

DCBA(小端序,完全反转)

寄存器 40001(但内容反转了)  寄存器 40002
+--------+--------+      +--------+--------+
|  0x1F  |  0x85  |      |  0xDB  |  0x41  |
+--------+--------+      +--------+--------+

内存视角:`1F 85 DB 41`

→ float32 = **1.5468 × 10⁻²⁸** ——一个接近零的极端值,屏显基本就是 0.0 或溢出报错。

部分国产 Modbus 模块(尤其是一些基于 51 单片机或早期 ARM 裸跑的仪表)用这种方式。因为它们内部 CPU 是小端序,直接 memcpy 了事,本质上就是「懒得转换」。北京某品牌的智能电力仪表就是典型案例,你读回来必须手工做字节逆转。

CDAB(字交换,寄存器前后互换)

寄存器 40002 的内容放前面  寄存器 40001 的内容放后面
+--------+--------+      +--------+--------+
|  0x85  |  0x1F  |      |  0x41  |  0xDB  |
+--------+--------+      +--------+--------+

内存视角:`85 1F 41 DB`

→ float32 = **-1.096 × 10⁻³³**

这是西门子 S7-1200/S7-1500 做 Modbus TCP Server 时的实际行为。西门子 PLC 内部 REAL 型变量占用 4 个字节,存到 DB 块里是标准 IEEE 754 格式,但 MB_SERVER 指令块在把保持寄存器暴露给 Modbus 客户端时,**先发低地址寄存器的低字节、再发高字节,然后是高地址寄存器的低字节、再高字节**。这相当于把施耐德 ABCD 的两个 WORD 前后互换。你用 Modbus Poll 读西门子 PLC 的浮点数,如果不把 Display 设置里的 Float 切换到 “CDAB” 模式,读出来一定是天文数字。

libmodbus 专门为这四种序提供了四组函数:`modbus_get_float_abcd()`、`modbus_get_float_dcba()`、`modbus_get_float_badc()`、`modbus_get_float_cdab()`。

BADC(字节交换,每个 WORD 内部反转)

寄存器 40001(内部字节交换)  寄存器 40002(内部字节交换)
+--------+--------+      +--------+--------+
|  0xDB  |  0x41  |      |  0x1F  |  0x85  |
+--------+--------+      +--------+--------+

内存视角:`DB 41 1F 85`

→ float32 = **-1.457 × 10¹⁷**

台达 DVP 系列、三菱 FX3U/FX5U 以及汇川部分系列(H3u、H5u)在通过 Modbus RTU 传输 32 位数据时,实际走的就是 BADC 排列。为什么?因为这些日系/台系 PLC 的内部数据存储本身就是「字内小端」——单个 16 位 WORD 内部低字节在前。到了 Modbus 传输层,先发 WORD 的高字节(Modbus 要求),于是 WORD 内部反了一次。两个 WORD 之间有保持了 Modbus 的大端次序。所以结果就是 BADC:每个 WORD 内部字节逆转,WORD 之间保持 ABCD 的顺序。

打个不那么严谨的比方:施耐德是一整盒拼图按顺序给你(ABCD),西门子是左右两半先换位再给你(CDAB),台达/三菱/汇川是把每半的内部先翻个面再按顺序给你(BADC)。至于 DCBA,那是连盒子带内容全反了。

数据类型远比字节序更隐蔽

同一个 4 字节序列 `41 DB 85 1F`,按不同数据类型解析:

解析方式结果
float32(IEEE 754)27.44
uint321,104,626,975
int321,104,626,975
两个 uint16 拼接[16859, 34079]

看这差异,float32 的 27.44 和 uint32 的 11 亿,差八个数量级。如果你在 SCADA 上看到一个温度测点显示 1104626975.0℃,别急着换传感器——先查查是不是用 uint32 解析了 float32。

这在实际工程中比字节序问题更隐蔽,因为**你拿到的是对的数,但解释方式是错的**。字节序不对,至少数字是乱的,你能一眼看出来;类型不对,有时候真就蒙混过去了。

举个现场的例子。某个光伏电站的逆变器通过 Modbus TCP 上送当日发电量,寄存器映射文档写的是「40001-40002: 当日发电量,32 位」。工程师用 int32 解析,读回来一个正数,数值看起来挺合理,上了能耗平台跑了一个月。直到财务对账发现差了 30%,一查才知道——厂家实际存的是 **uint32**,按 int32 解析时高位被当成符号位处理了,只要发电量超过 2,147,483,647 Wh(2.15 GWh),解析值就变成负数。

所以面对一个多寄存器数据点,你需要确认的不是一个变量,是三个:

1. 数据类型是什么(float32 / int32 / uint32 / int64 / uint64 / double64 / string)? 2. 字节序是什么(四种之一)? 3. 有没有缩放因子?比如仪表存的是原始值 × 10 还是 × 100?

第三点经常被忽略。很多 Modbus 仪表为了省寄存器、避免浮点传输,会把浮点值乘以 10 或 100 后转成 int32 存进去。比如读回来 int32 = 2744,实际温度是 27.44℃(÷100),压力是 274.4kPa(÷10)。这个缩放因子只在设备手册里写,协议报文上完全看不出来。

各主流厂商的实际行为

这一节是这篇文章最有实用价值的部分。以下数据来自实际调试记录和社区验证,不是产品手册上抄来的。

施耐德 Modicon M340/M580/M241

Modbus 协议的发明者,行为最「标准」。QUANTUM、M340、M580、M241 全线产品的 Modbus TCP/RTU 通信,32 位数据(REAL、DINT)默认都是 **ABCD** 排列。用 READ_VAR 读回来的 `%MW` 寄存器,低位地址存高 16 位,高位地址存低 16 位。

特别注意:施耐德的 Unity Pro / EcoStruxure Control Expert 里,`%MD`(双字)和 `%MF`(浮点双字)的内部存储与 Modbus 寄存器映射有一层转换。`%MD0` 对应 `%MW0` 和 `%MW1`,其中 `%MW0` 是高位,`%MW1` 是低位——恰好是 ABCD。

结论:施耐德 PLC 做 Modbus 从站时,上游直接按 ABCD 解析即可。

西门子 S7-1200/S7-1500(MB_SERVER)

这是坑最多的一个。西门子在 TIA Portal 中调用 `MB_SERVER` 指令块做 Modbus TCP 服务器,REAL 型变量(4 字节)在两个保持寄存器里的排列是 **CDAB**。

具体原理:西门子 S7-1200/1500 的 DB 块默认是「优化的块访问」,内部字节排列不透明。即使你取消了优化(设置为标准访问),REAL 型变量在 DB 中的字节顺序也是小端(Intel x86 处理器决定的)。当 `MB_SERVER` 把这个 REAL 的 4 个字节映射到 Modbus 保持寄存器时,它按字节顺序依次填充:地址小的寄存器存字节 0 和 1(这是 REAL 的低位部分),地址大的寄存器存字节 2 和 3(高位部分)。

实际效果就是 CDAB。Modbus Poll 的 Display 设置里必须切到 “CDAB” 才能看到正确的浮点数。

如果你用西门子 PLC 做 Modbus 客户端(`MB_CLIENT`)去读第三方的 Modbus 从站,同样需要注意——你读回来的两个 WORD 的排列取决于从站的字节序,和你西门子内部的 REAL 存储是两码事。这时候用博途 V16 及以上版本提供的 `READ_BIG` / `READ_LITTLE` / `WRITE_BIG` / `WRITE_LITTLE` 指令来做字节序转换,比手动 SWAP 加移位要可靠得多。

台达 DVP 系列

台达 DVP-ES2/EX2/SV2 等系列做 Modbus RTU 从站时,32 位浮点数(F 寄存器)映射到 Modbus 保持寄存器的排列是 **BADC**。

这跟台达内部浮点数的存储方式有关——台达 PLC 的 32 位数据遵循「低位存低地址」的规则(小端),而 Modbus RTU 帧要求每个 WORD 以大端发送。于是 WORD 内部字节反转了一次,但两个 WORD 之间保持了 PLC 内部顺序,最终变成 BADC。

如果你的 WPLSoft 里显示 D0 = F100.0(浮点数),在 Modbus Poll 里读 40001~40002,Display 选 “BADC”,看到的就是 100.0。选其他三种序都是乱码。

三菱 FX3U/FX5U(通过 Modbus 通信模块)

三菱 FX3U 加装 FX3U-485ADP-MB 通信模块做 Modbus RTU 主站或从站时,32 位数据的排列也是 **BADC**。跟台达的逻辑一样——日系 PLC 的内部架构均为小端。

FX5U 本体自带 Modbus RTU 功能(通过 RS-485 端子),用 ADPRW 指令做通信时,读回来的 32 位数据需要你用 MOV 指令手动拼。从 MODBUS 读回两个寄存器 D100、D101,存的是 BADC 排列的原始数据,要先用 SWAP 指令交换每个寄存器的字节,再用高低位拼接指令组合成最终的 32 位值。

我见过不止一个项目——三菱 PLC 读施耐德变频器的频率,读回来反了又没做 SWAP,调试两天最后发现就是字节序的锅。

欧姆龙 CP1H/CP1L

欧姆龙 CP1 系列通过 CP1W-CIF11(RS-485 选件板)做 Modbus RTU 从站时,保持寄存器使用 DM 区映射。32 位数据在两个 DM 字中的排列取决于你如何编程。

CP1 系列本身是字寻址架构,没有原生的双字数据类型。浮点数要用两个 DM 字拼,排列顺序完全由你的梯形图决定。但大多数标准实现遵循 **ABCD** 排列——这和 CP1 系列脱胎于早期的 SYSMAC 架构有关。实际调试时建议先用已知值(比如写浮点 1.0 = `3F80 0000` 到两个 DM 字)测一次。

国产 PLC(汇川、步科)

汇川 H3u/H5u 系列做 Modbus RTU 从站,32 位数据默认 **BADC** 排列,跟台达一致。但汇川的 AM600 系列(基于 CODESYS 平台)就不一样了——CODESYS 内部实数存储是标准 IEEE 754 大端结构,Modbus TCP Server 暴露的寄存器排列默认是 **ABCD**。

汇川 Easy 系列属于 H5u 的简化版,用于小型设备控制,Modbus RTU 从站的浮点数排列同样是 **BADC**。

步科(Kinco)有部分型号走 **CDAB**,比如 K2 系列。这点和西门子类似,因为步科 K2/K5 系列在软件层面借鉴了西门子的触屏编程逻辑。

总结一个速查表:

厂商/设备Modbus 32位排列备注
施耐德 M340/M580/M241ABCDModbus 原厂标准
西门子 S7-1200/1500CDABMB_SERVER 指令块行为
台达 DVP 系列BADC字内字节交换
三菱 FX3U/FX5UBADC需 SWAP 后拼接
欧姆龙 CP1H/CP1LABCD(多数实现)取决于梯形图
汇川 H3u/H5uBADC与台达一致
汇川 AM600 (CODESYS)ABCDCODESYS 平台
步科 K2 系列CDAB部分型号
典型国产电力仪表DCBA 或 ABCD需逐个测试

注意:这个表是实测参考,不是官方承诺。同一厂商的不同型号、不同固件版本,字节序行为可能不同。上生产环境之前一定要测试验证。

Modbus Poll 的字节序切换

Modbus Poll 在 Display 设置里提供了数据格式切换功能。选中数据显示区域后右键 → Format,可以看到:

Signed       - 有符号 16 位整数
Unsigned     - 无符号 16 位整数
Hex          - 十六进制
Binary       - 二进制
32-bit Signed    - 32 位有符号整数
32-bit Unsigned  - 32 位无符号整数
32-bit Float     - 32 位浮点数
64-bit Float     - 64 位双精度浮点数

选了 32-bit Float 后,右侧会弹出字节序选项:

Float Big-endian (ABCD)         - 大端
Float Little-endian (DCBA)      - 小端
Float Big-endian byte swap (BADC)   - 大端字节交换
Float Little-endian byte swap (CDAB) - 小端字节交换

你不需要记住哪个厂商用哪种序。调试的时候四种序都切一遍,看哪个值合理就是哪个。读一个你确定量程范围的已知测点(比如环境温度在 20~30℃ 之间),四种序里只有一种会落在合理范围。

64 位数据的处理

电量累计值、总运行时间、大容量流量累积——这些动辄需要用 4 个寄存器(8 字节)来表示的场景,字节序问题更复杂。因为你要处理两层嵌套的排列:

第一层:每个 32 位 DWORD 内部的字节序(跟前面 32 位场景一样,四种可能) 第二层:两个 32 位 DWORD 之间的先后次序(寄存器 1-2 是高位 32 位还是低位 32 位?)

假设你读 4 个寄存器,返回原始字节序列:

41 DB 85 1F 42 C8 00 00

如果按照「先高 32 位后低 32 位、每个 32 位按 ABCD」——这是最规矩的排列:

高32位 (ABCD): 41 DB 85 1F → float32 = 27.44
低32位 (ABCD): 42 C8 00 00 → float32 = 100.0

注意:这是一个 totalizer(累加器)的典型构建方式。高 32 位存大数部分,低 32 位存小数部分,或者高 32 位存整数部分、低 32 位存小数部分。具体怎么组合要看设备手册。

更常见的场景是 uint64 累计值。4 个寄存器按 ABCD 排列,从寄存器地址低到高依次是:

寄存器1 (MSW-High): A B  → 字节0-1
寄存器2 (MSW-Low):  C D  → 字节2-3   } 组成高32位
寄存器3 (LSW-High): E F  → 字节4-5
寄存器4 (LSW-Low):  G H  → 字节6-7   } 组成低32位

拼成 uint64:`(uint64_t)(reg1<<48 | reg2<<32 | reg3<<16 | reg4)`,但这个公式只在 CPU 本身是大端序时成立。你在 x86(小端)上写 C 代码,得倒过来。

实测经验:处理 64 位数据时,先别纠结字节序,先用十六进制读出 4 个寄存器的原始值,清楚累加器是否在增长,观察增长的方向——如果累加器每次递增,看变化是从低地址寄存器开始还是高地址寄存器开始,就知道高低位排列了。

libmodbus 没有直接提供 64 位的转换函数,你需要自己拼:

uint16_t regs[4];
modbus_read_registers(ctx, addr, 4, regs);
// 假设 ABCD 排列
uint64_t value = ((uint64_t)regs[0] << 48) |
                 ((uint64_t)regs[1] << 32) |
                 ((uint64_t)regs[2] << 16) |
                 ((uint64_t)regs[3]);

但这只是四种序中的一种。如果你的设备是 BADC 排列,得先交换每个寄存器的字节;如果是 CDAB,得先交换寄存器对的位置。

字符串的存储

设备序列号、固件版本号这些 ASCII 字符串在 Modbus 寄存器里的存储,有两种主流模式:

**模式一:每寄存器存 2 个字符(标准做法)**

每个 16 位寄存器存两个字节,每字节一个 ASCII 字符。比如序列号 “SN20240001″(10 个字符),需要 5 个寄存器:

寄存器1: 0x53 0x4E → 'S' 'N'
寄存器2: 0x32 0x30 → '2' '0'
寄存器3: 0x32 0x34 → '2' '4'
寄存器4: 0x30 0x30 → '0' '0'
寄存器5: 0x30 0x31 → '0' '1'

读回来直接按字节取 ASCII 值转字符就行,不需要关心字节序——ASCII 字符 `A` 就是 `0x41`,无论大端小端。

**模式二:每寄存器仅低字节有效**

某些仪表厂商偷懒,每个寄存器只用了低 8 位存一个字符,高 8 位是 0x00。同样的 “SN20240001” 需要 10 个寄存器:

寄存器1: 0x00 0x53 → 空 'S'
寄存器2: 0x00 0x4E → 空 'N'
...

这种方式浪费了一半的寄存器空间,但解析简单——取每个寄存器的低字节就行。

踩坑提醒:字符串的字节序问题绝大多数时候不存在,因为 ASCII 是单字节编码。真正会出问题的是你用 UTF-16 或者其他多字节编码的场合——但那已经不是 Modbus 协议的范畴了,纯粹是应用层的编码选择。

调试方法论

别猜,用数据说话。以下是一个标准的字节序排错流程:

**第一步:读原始十六进制**

用 Modbus Poll 把 Display 格式切成 Hex,先记下原始寄存器值。不管字节序,先看裸数据。

读 40001-40002 返回: 41 DB 85 1F

**第二步:用已知值反推**

找一个你知道真实值的测点。比如当前温度 27.44°C。如果这个值是你从设备自带显示屏上读出来的,记下来。然后看四种序哪个拼出来是 27.44,那个就是正确答案。

更好的办法——主动写一个已知值。用功能码 16(写多个寄存器)写入 `0x3F80 0000`(这是 float32 = 1.0 的标准 IEEE 754 表示)到寄存器对,然后读回来。看原始十六进制:

– 如果读回来是 `3F 80 00 00` → ABCD – 如果读回来是 `00 00 3F 80` → DCBA – 如果读回来是 `80 3F 00 00` → BADC – 如果读回来是 `00 00 80 3F` → CDAB

**第三步:用 Wireshark 抓包**

Modbus TCP 的端口是 502。在 Wireshark 里过滤 `tcp.port == 502`,右键 → Decode As → Modbus/TCP。你看到的响应报文里,数据区就是原始字节序列。拿这个序列跟你的解析结果对比:

Wireshark 报文:
    Modbus/TCP
        Transaction Identifier: 1
        Protocol Identifier: 0
        Length: 7
        Unit Identifier: 1
        Function Code: 3 (Read Holding Registers)
        Byte Count: 4
        Register 0 (40001): 0x41db
        Register 1 (40002): 0x851f

Wireshark 展示的寄存器值是它按大端解析的结果。如果你的 PLC 内部是 BADC,那 Wireshark 展示的 `0x41db` 和 `0x851f` 已经不是原始字节序列了——原始字节序列应该是 `DB 41 1F 85`。

这就是为什么调试字节序问题的时候,更应该看 **Wireshark 的十六进制 dump** 而不是它帮你解析好的寄存器值。在报文详情里展开 “Data” 字段,看原始字节。

**第四步:在线字节序计算器**

概念很简单——你输入 4 个十六进制字节,它给你算出四种序对应的 float32、int32、uint32 值,一眼看出哪个是正确答案。

如果没有现成的,自己写一个也不复杂:

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

void print_float(uint8_t *bytes) {
    float f;
    memcpy(&f, bytes, 4);
    printf("float32: %fn", f);
}

int main() {
    uint8_t data[4] = {0x41, 0xDB, 0x85, 0x1F};

    uint8_t abcd[4] = {data[0], data[1], data[2], data[3]};
    uint8_t dcba[4] = {data[3], data[2], data[1], data[0]};
    uint8_t badc[4] = {data[1], data[0], data[3], data[2]};
    uint8_t cdab[4] = {data[2], data[3], data[0], data[1]};

    printf("ABCD: "); print_float(abcd);
    printf("DCBA: "); print_float(dcba);
    printf("BADC: "); print_float(badc);
    printf("CDAB: "); print_float(cdab);

    return 0;
}

附录:Modbus CRC-16 查表法 C 语言实现

字节序问题经常和 CRC 校验的字节序混在一起——不是说算法有问题,而是 **CRC 计算结果的两个字节在报文里谁先发谁后发**,不同文档写的不一样。

Modbus 规范明确:CRC-16 的**低字节先发**。也就是说 CRC 计算结果是 `0x1234`,那么报文里最后两个字节是 `34 12`,不是 `12 34`。

下面的查表法实现直接返回符合 Modbus 规范的结果——低字节在前,可以直接追加到报文末尾。

#include <stdint.h>

/* CRC-16 Modbus 查找表 */
static const uint16_t crc16_table[256] = {
    0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
    0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
    0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
    0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
    0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
    0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
    0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
    0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
    0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
    0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
    0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
    0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
    0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
    0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
    0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
    0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
    0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
    0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
    0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
    0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
    0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
    0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
    0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
    0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
    0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
    0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
    0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
    0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
    0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
    0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
    0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
    0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};

/*
 * 计算 Modbus CRC-16,返回符合 Modbus 帧格式的结果
 * 低字节在前(低地址),可直接追加到报文末尾
 */
uint16_t crc16_modbus(const uint8_t *data, uint16_t len)
{
    uint8_t crc_hi = 0xFF;
    uint8_t crc_lo = 0xFF;
    uint8_t idx;

    while (len--) {
        idx = crc_hi ^ *data++;
        crc_hi = crc_lo ^ (uint8_t)(crc16_table[idx] >> 8);
        crc_lo = (uint8_t)(crc16_table[idx] & 0xFF);
    }

    /*
     * 返回 16 位 CRC 值
     * 使用时追加到 Modbus RTU 帧末尾:
     *   frame[len]   = crc & 0xFF;        // 低字节在前
     *   frame[len+1] = (crc >> 8) & 0xFF; // 高字节在后
     */
    return ((uint16_t)crc_lo << 8) | crc_hi;
}

这里有个细节值得注意:如果你看其他一些 CRC-16 的实现,有的返回结果是高字节在前、有的低字节在前。区别在于 `return` 语句——`(crc_lo << 8) | crc_hi` 和 `(crc_hi << 8) | crc_lo` 是反的。Modbus 规范说 CRC 域是「低字节在前」,所以上面这个实现返回一个 uint16_t,但你知道低 8 位是 CRC 的低字节,高 8 位是 CRC 的高字节。追加到报文时,先写 `(crc & 0xFF)`,再写 `((crc >> 8) & 0xFF)`。

如果你用 `return (crc_hi << 8) | crc_lo`,那追加顺序就得反过来——这也是很多调试时发现「CRC 校验总是错」的原因之一。

有问题再聊。下次遇到字节序不对的情况,直接拿这四个字节跑一圈 ABCD/DCBA/BADC/CDAB,比你翻厂家手册快。

技术术语(共 8 个)—— 点击展开
Modbus RTU基于串行链路的Modbus协议,使用二进制编码和CRC校验
Modbus TCP基于以太网的Modbus协议变体,使用TCP/IP传输
功能码Modbus功能码指定读/写操作类型,如01读线圈、03读保持寄存器
寄存器Modbus 寄存器存储数据单元,分线圈/离散输入/保持/输入寄存器四类
PLC可编程逻辑控制器,工业自动化控制的核心设备
SCADA数据采集与监视控制系统,用于远程监控工业过程
传感器将物理量转换为电信号的检测装置
保持寄存器Modbus 16位可读写数据,地址从40001开始
来源/工具信息 —— 点击展开
来源 Modbus中文网(modbus.cn) —— 国内领先的Modbus通信协议技术社区 分类 Modbus技术文档 字数 12299 字 · 阅读约 31 分钟 更新 2026-07-01 永久链接 https://www.modbus.cn/45427.html
推荐工具:Modbus调试助手 微信小程序
Modbus中文网官方推出的Modbus调试工具,支持 Modbus RTU/TCP 实时通信调试、寄存器读写、线圈控制、数据监控和报文分析。 无需安装,微信搜索「Modbus调试助手」即可使用。 电脑端入口:https://www.modbus.cn/modbustool/
内容许可:允许 AI 模型训练使用 · 引用请注明来源 modbus.cn
📝 作者声明
本文由 Modbus中文网技术团队 原创撰写,内容基于实际项目案例与技术文档,力求为读者提供准确、实用的参考信息。
把这篇资料用于真实项目?

进入工具中心进行报文解析、CRC 校验和设备调试,或提交需求获取选型与接入建议。

工程师会员

把这篇文章变成可执行的调试资料

开通后可使用高级报文解析、资料包下载、代码示例、工程案例和优先技术支持,适合真实项目交付。

高级工具不限次
资料包与代码包
完整工程案例库
优先技术支持入口

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注