上个月在污水站调一台变频器的Modbus通讯,上位机读回来的频率数值怎么算都不对:面板显示45.8Hz,我按协议文档算出592.5。查了半天才发现不是计算错误,而是字节序反了。这种事在Modbus项目里太常见了,尤其当你用ST语言处理PLC收到的BYTE数组时,高字节低字节一错位,轻则数值离谱,重则浮点数直接变NaN。这篇内容就围绕“Modbus字节序 + ST按位拆解BYTE数组”展开,适合正在写PLC通讯程序、被寄存器数据拼装坑过的同行,也适合刚入门想搞清楚字节序原理的新手。
1. Modbus字节序问题的根源:协议没管,设备各说各话
1.1 Modbus协议为什么没有统一字节序
很多人在第一次接触Modbus时都会默认:协议是标准的,数据格式肯定也是标准的。实际上Modbus标准只规定了帧结构、功能码、地址范围这些框架性的东西,数据内容的字节序并没有被强制统一。一个16位寄存器有高字节在前和低字节在前两种解释,一个32位浮点数跨两个寄存器时,组合顺序更是五花八门。
你可以把Modbus协议想象成一套通用邮政规则:信封多大、邮票贴哪、地址怎么写都有规定,但信纸上的内容横着写还是竖着写,协议不管。于是不同设备厂家就按自己的习惯来:有人按大端模式存数据,有人按小端模式存数据,还有人为了兼容自家老产品,故意做成交叉顺序。这就是为什么同一个地址读同一台设备,换一个品牌的上位机软件就显示不同数值。
在PLC侧,一旦用Modbus从站功能读取多个BYTE并存入数组,你就必须自己搞清楚:
- 第一个BYTE是寄存器的高字节还是低字节
- 32位数据跨两个寄存器时,哪个寄存器在前
- 字节内部的位排列顺序是否需要处理
这三个问题任何一个搞错,最终拼装出来的数值都会出错。
1.2 寄存器、双字和浮点数的存储模型
要理解字节序,先得明白Modbus的数据存储模型。Modbus里最小的数据单位是寄存器,一个寄存器16位,占两个字节。读保持寄存器时,一条报文可以连续读多个寄存器,这些寄存器在内存里是连续的。
16位整数只占一个寄存器时,问题相对简单,最多就是高字节和低字节对调。但32位整数或IEEE 754浮点数需要占用两个寄存器,情况就复杂了。比如一个浮点数45.8,在内存里的IEEE 754编码是0x42373333,按字节拆分就是42 37 33 33。如果设备把第一个寄存器发成0x4237,第二个寄存器发成0x3333,这叫大端排列;如果设备把第一个寄存器发成0x3333,第二个寄存器发成0x4237,那就是双字交换。
我在实际项目里总结过,常见的字节序有这么几种:
| 存储模式 | 第一个寄存器高字节 | 第一个寄存器低字节 | 第二个寄存器高字节 | 第二个寄存器低字节 | 常见场景 |
|---|---|---|---|---|---|
| AB CD 大端序 | 0x42 | 0x37 | 0x33 | 0x33 | 老式仪表、组态软件 |
| CD AB 字交换 | 0x37 | 0x42 | 0x33 | 0x33 | 部分国产仪表、变频器 |
| DC BA 双字颠倒 | 0x33 | 0x33 | 0x42 | 0x37 | 某些编码器、智能传感器 |
| BA DC 全颠倒 | 0x33 | 0x33 | 0x37 | 0x42 | 小端CPU的嵌入式设备 |
这里面的AB CD模式,正好对应大多数人直觉里的顺序:第一个字节是最高有效字节。CD AB和DC BA这两种模式是坑最多的地方,因为它们和AB CD只差一两个字交换,换算出来的数值完全离谱,但又不至于报错,排查起来特别费劲。
1.3 为什么用ST按位拆解而不是直接内存拷贝
有的PLC支持指针操作,可以用MEMCPY之类的方式直接把BYTE数组的内存内容重解释为REAL变量。这种写法代码很短,但存在两个隐患:
第一,指针和内存重解释在不同PLC平台上的行为并不一致。有的品牌PLC读内存区域时默认按本地CPU的字节序处理,你在程序里看到的是已经“修正”过的数据,换一个平台结果又变了。
第二,直接内存拷贝没法处理“自定义字节序”。当设备发出的字节顺序不是简单的整体反转,而是交叉顺序时,你还得先做一轮重排,绕一大圈不如按位运算来得直接。
按位拆解用的都是IEC 61131-3标准里的基本指令,比如SHL、SHR、AND、OR,这些指令在所有主流PLC平台上都一致。即使换平台、换品牌、换项目,只要逻辑不变,代码迁移基本无脑。这也是我在项目里坚持用ST按位处理BYTE数组的原因。
2. ST语言按位拆解基础:先把字节、位和掩码理顺
2.1 字节、十六进制和位的关系
一次Modbus读操作返回的BYTE数组里,每个BYTE都是8个二进制位。一个BYTE用十六进制表示就是两位,比如0x42对应的二进制是0100 0010。高位在左、低位在右,这个基本规则很多人都知道,但在拆解多字节数据时,容易乱的不是单字节内部,而是字节之间的权重。
如果把四个BYTE拼成一个32位值,它们的权重从左到右依次是2的24次方、2的16次方、2的8次方、2的0次方。也就是说,组合顺序决定了数值大小。按位拆解的核心任务,就是把BYTE数组里的字节放到正确的位置上,也就是给每个字节分配正确的权重。
我习惯的做法是先把BYTE数组写成一张权重表:
| 数组下标 | 理想权重 | 十六进制示例 |
|---|---|---|
| bData[0] | 最高字节 2^24 | 0x42 |
| bData[1] | 次高字节 2^16 | 0x37 |
| bData[2] | 次低字节 2^8 | 0x33 |
| bData[3] | 最低字节 2^0 | 0x33 |
所有字节序修正,本质上就是调整这张表的排列。
2.2 SHL、SHR、AND、OR四个指令的用法
ST语言里做按位拆解,最常用的四个操作是移位和逻辑运算。
SHL是把一个数据的二进制位整体向左移动,左边溢出的位丢弃,右边补零。移动一位相当于乘以2,移动8位相当于乘以256。比如BYTE类型的0x42向左移动8位,就变成了WORD类型的0x4200。这个操作的意义在于:把一个字节推到更高的权重位置。
SHR正好相反,向右移动,左边补零。它经常用来把高权重位置的位“降下来”,方便提取某个位或某个字节。
AND是按位与,两个位都是1结果才是1。它的典型用途是掩码提取:想提取某个字节的低4位,就用0x0F和它做AND运算,高4位被清零,低4位原样保留。OR是按位或,只要有1结果就是1。它的典型用途是拼装:把两个各自已经放在正确位置的字节用OR组合起来,既不互相覆盖,又能合并成一个完整的字或双字。
这里有个容易混淆的点:SHL针对的是整个操作数,不是只移动其中某一位。比如对WORD做SHL 8,是整个16位都移动8位,低字节变高字节,高字节溢出丢弃,低字节补零。理解了这个,才能明白为什么拼接时先移位再OR。
2.3 动手之前先画内存图
这是我在项目里吃过亏之后养成的习惯。无论多简单的字节序修正,我都会先在纸上或者表格里画一遍内存图,把设备发送的原始字节顺序和目标字节顺序并排列出来,标注每个字节当前所在位置和最终应该所在的位置。
比如目标是AB CD大端序,设备发来的是CD AB序,内存图就可以这样画:
| 位置 | 设备原始发送 | 目标理想顺序 |
|---|---|---|
| Byte0 | 0x37 | 0x42 |
| Byte1 | 0x42 | 0x37 |
| Byte2 | 0x33 | 0x33 |
| Byte3 | 0x33 | 0x33 |
画完图之后再做移位OR操作,每一步的赋值对象和移位位数都清清楚楚,不会再出现“移了8位发现多此一举”的尴尬。
3. 实操:用ST按位拆解BYTE数组修复字节序
3.1 场景定义:浮点数45.8读回变成592.5
先说一个完整案例。某台变频器通讯协议文档里写明:频率值占两个寄存器,类型为32位浮点数。我用Modbus轮询工具直接调试时看到的值是对的,变频器面板显示45.8Hz时,原始报文数据是42 37 33 33。但PLC里通过BYTE数组收到的顺序变成了37 42 33 33。
也就是说,单字节内部顺序被调换了,两个寄存器之间的顺序反而保持了原样。这种就是前面表格里的CD AB字交换模式。
如果我用常规的大端序解析,直接把BYTE数组从左到右拼成DWORD,得到的是0x37423333,换算成十进制就是927M,再按浮点解释就是个巨大且不合理的数,这也是592.5这种离谱值的由来。
正确的处理方式是把Byte0和Byte1对调,然后重新拼装成DWORD再转换成REAL。
3.2 实现一:移位OR法拼接DWORD
下面是完整的ST功能块代码,用来把CD AB序的四个字节转成AB CD序并输出浮点数。以CODESYS风格为例,其他平台请根据IDE的语法提示补全类型转换。
FUNCTION_BLOCK FB_FloatFromBytes VAR_INPUT bData : ARRAY[0..3] OF BYTE; END_VAR VAR_OUTPUT rValue : REAL; END_VAR VAR dwTemp : DWORD; END_VAR // 设备发送顺序为:bData[0]=0x37, bData[1]=0x42, bData[2]=0x33, bData[3]=0x33 // 目标大端顺序为:0x42, 0x37, 0x33, 0x33 // 将原bData[1]放到最高字节位置 dwTemp := SHL(BYTE_TO_DWORD(bData[1]), 24); // 将原bData[0]放到次高字节位置 dwTemp := dwTemp OR SHL(BYTE_TO_DWORD(bData[0]), 16); // 将原bData[2]放到次低字节位置 dwTemp := dwTemp OR SHL(BYTE_TO_DWORD(bData[2]), 8); // 将原bData[3]放到最低字节位置,不需要移位 dwTemp := dwTemp OR BYTE_TO_DWORD(bData[3]); // DWORD按位序列对应0x42373333,转换为浮点数 rValue := DWORD_TO_REAL(dwTemp);这段代码的核心逻辑是:不要按原数组顺序从头到尾拼接,而是按目标字节序把每个BYTE放到指定的权重位置。第一句SHL 24,原bData[1]变成了最高字节,第二句把bData[0]放在次高字节,原顺序就完成了对调。后面的bData[2]和bData[3]本来位置就正确,直接放上去即可。
我特别想提醒的是,很多初学者在这里写出的代码是先拼出一个看似正常的DWORD,再对DWORD做整体字节反转。这样绕了一圈,而且很容易在反转方向上再次出错。直接按目标位置分配字节,逻辑最直白,也不容易引入二次错误。
3.3 实现二:按位提取报警字的状态位
字节序问题不只是浮点数才有,16位报警字或状态字也经常要和位打交道。比如变频器的状态寄存器里定义:Bit0是运行中,Bit2是过温报警,Bit5是故障,其他位保留。
这种场景本质上不是字节序颠倒,而是位提取。但很多人在处理完整BYTE数组时,往往会忽略位级操作,导致状态判断出错。
假设PLC的保持寄存器读回来是一个WORD类型变量wAlarm,需要按位拆解:
VAR wAlarm : WORD; xRunning : BOOL; xOverTemp : BOOL; xFault : BOOL; END_VAR // Bit0判断,掩码0x0001 xRunning := (wAlarm AND WORD#16#0001) <> 0; // Bit2判断,掩码0x0004 xOverTemp := (wAlarm AND WORD#16#0004) <> 0; // Bit5判断,掩码0x0020 xFault := (wAlarm AND WORD#16#0020) <> 0;如果还要判断Bit3到Bit6之间有任何一个位置1,可以用一个组合掩码:
xAnyWarn := (wAlarm AND WORD#16#0048) <> 0;这里的0x0048是Bit3和Bit6的掩码之和,0x0008加0x0040。AND之后只要任一位置1,结果就不为零。
这种位提取逻辑在设备通讯排查里非常实用。当仪表显示某个报警但PLC没有动作时,先按位拆开状态字,你很快就能发现是协议位定义对不上,还是寄存器地址错位。
3.4 实现三:16位寄存器的高低字节互换
16位整数只有两个BYTE,字节序修正比32位浮点简单得多。假设Modbus返回一个寄存器0x1234,但设备发到BYTE数组里的顺序是bData[0]=0x34,bData[1]=0x12,目标顺序需要还原为0x1234。
VAR bData : ARRAY[0..1] OF BYTE; wValue : WORD; END_VAR // 方法一:移位OR wValue := SHL(BYTE_TO_WORD(bData[1]), 8) OR BYTE_TO_WORD(bData[0]); // 方法二:乘法等价式,容易理解但不够规范 // wValue := (WORD_TO_WORD(bData[1]) * 256) OR WORD_TO_WORD(bData[0]);方法一的含义是:bData[1]原本是低字节,通过SHL 8放到高字节位置,然后OR上bData[0]的内容。很多PLC平台的ST编译器会自动做类型提升,BYTE参与运算时会自动变成WORD或DWORD,但为了严谨,我一般还是显式转换。
3.5 自测代码:用已知值验证修正逻辑
字节序修正写完以后,千万别直接上真机调试。先在代码里造一个已知数组做自测,比到现场拿万用表测半天效率高得多。
以3.2节的功能块为例,我造了一个测试序列。用IEEE 754编码1.0等于0x3F800000来验证,故意按CD AB序发进去:
// 测试数据:1.0的IEEE754编码是3F 80 00 00 // 设备按CD AB序发送:bData[0]=0x80, bData[1]=0x3F, bData[2]=0x00, bData[3]=0x00 bData[0] := BYTE#16#80; bData[1] := BYTE#16#3F; bData[2] := BYTE#16#00; bData[3] := BYTE#16#00; // 调用功能块后,rValue 应等于 1.0如果修正逻辑正确,rValue会精确等于1.0。如果输出是2.36E-43或者极大值,说明字节序没修正对,或者掩码用错了。
我建议把这段自测代码保留在程序里,用M0或者某个内部布尔量触发,专门做通讯诊断。现场人员将来怀疑数据不对时,按一下按钮就能用已知值验证程序逻辑,不用靠猜。
4. 另一种思路:指针重映射和联合体的取舍
4.1 短平快的MEMCPY方案
有些PLC平台允许把BYTE数组的内存直接重解释为REAL变量,代码量少得可怜:
VAR bData : ARRAY[0..3] OF BYTE; rValue : REAL; END_VAR // 直接把数组内存映射到REAL变量 // rValue := DWORD_TO_REAL(ADR(bData));这种写法的好处是快,缺点是要保证两个前提:第一,bData必须是连续内存且对齐;第二,目标CPU的本地字节序恰好和设备发送的字节序一致。如果这两个条件不满足,结果就是错的,而且这种错误更隐蔽,因为代码看着没问题。
4.2 联合体UNION方式
部分支持IEC 61131-3扩展的IDE提供了UNION结构体,可以把BYTE数组和DWORD放在同一块内存:
TYPE U_ByteDword : UNION bData : ARRAY[0..3] OF BYTE; dwData : DWORD; END_UNION END_TYPE这种方式写起来很直观,修改字节数组就等于修改DWORD的低字节到高字节,反过来也成立。但不同平台对UNION的支持程度不一样,有的IDE编译不过,有的会有字节序隐式转换。把它用于快速验证可以,长期维护我还是倾向按位运算。
4.3 指针方案两条隐性成本
第一,边界问题。BYTE数组的总长度如果不是4的整数倍,直接重解释可能越界。我遇到过DWORD读取时数组只声明了3个BYTE,结果把相邻变量内存也读进来了,导致数值飘忽不定。
第二,可读性差。后来维护这个程序的人不一定理解你在做什么,一到现场排查串数据时,别人不像你一样一眼看出这里发生了内存重解释,容易查错方向。
按位拆解虽然代码长一点,但每一步把权重和移位都写得很清楚。哪怕半年后回来改程序,看着SHL 24、SHL 16这些字眼,马上就能回忆起当时的意图。在工业项目里,这种可维护性比多写十行代码重要得多。
5. 真实踩坑记录与排查技巧速查
5.1 现象一:数值接近正确,但小数点位置不对
现场遇到过频率显示44.8,实际应该是45.8,差值有一定规律但总觉得是偏差。这种通常是浮点数的尾数部分被整体移动了位数,比如寄存器边界没对齐,或者漏了一个字节。排查思路是先读原始BYTE数组,人工按IEEE 754解码,看和正确差异处在哪一位。
5.2 现象二:读回来是大数或NaN
浮点数变成1.4E-45这种极小值或NaN,一般是符号位和指数位完全错乱,说明双字顺序整体反了。比如设备发送顺序是DC BA,你按AB CD解析,结果大概率就是这种样子。直接调换两个寄存器的解析顺序通常能解决。
5.3 现象三:启动瞬间正常,运行一阵子后错乱
这个问题最烦人,因为字节序修正逻辑本身没问题。后来排查发现是通讯断包导致数组内容错位:一帧报文本该是6个字节,实际收到7个或者5个,留存在数组里的数据整体偏移了一个字节。解法是在解析前先做CRC校验,CRC不过就丢弃这一帧,不进解析逻辑。
5.4 现场排查速查表
| 现象 | 最可能原因 | 优先排查方向 |
|---|---|---|
| 数值完全不对且无规律 | 寄存器地址错位 | 用Modbus轮询工具读原始报文 |
| 浮点数变NaN或极大值 | 双字顺序颠倒 | 检查DC BA模式 |
| 数值接近但精度不对 | 高字节低字节互换 | 检查CD AB模式 |
| 启动正常,运行后偶发错乱 | 通讯断包、CRC未校验 | 先校验完整性再解析 |
| 使用指针方案时数据跳动 | 内存对齐或越界 | 改用按位拆解 |
5.5 调试小技巧:先用环回测试确定设备采用了哪种字节序
刚到现场不知道设备具体用哪种字节序时,不要急着写解析程序。先在Modbus调试工具里写一个已知浮点数到设备,比如写1.0,然后读回来观察原始字节。1.0的IEEE 754编码是0x3F800000,通过观察返回报文中3F、80、00、00这四个字节的相对位置,立刻就能判断设备采用的是什么排列顺序。
这个技巧是我屡试不爽的。它把字节序问题变成了一个简单的观察题:看到报文里3F 80 00 00的顺序,对照前文表格,找一个匹配的模式,解析代码照着写就行。
6. 一点实战体会
在现场搞Modbus通讯这几年,我最大的感受是:字节序问题不是懂不懂协议的问题,而是愿不愿意静下心来做验证的问题。协议文档写几万字的厂家比比皆是,但真正把字节排列顺序写清楚的文档反而很少。与其指望文档,不如自己造一个已知浮点数来回测试,一眼定位排列模式。
我现在的固定做法是:新接一个设备,先读固件信息或状态寄存器,再看原始字节。拿到完整的BYTE数组后,先在Excel或者纸上画好权重表,再用ST按位拆解。只要数组里的顺序看清了,代码基本是模板化的,SHL移到位、OR拼起来、类型转换收尾,跑起来就稳了。
如果你还没有养成这个习惯,下次再遇到Modbus数值不对的时候,可以试试这个方法:不要急着调整地址或怀疑计算,先看一眼原始BYTE数组里的十六进制字节排列,也许真相就在那几排0x42、0x37、0x33、0x33里。