1. 字节序问题到底出在哪:从一次现场调试翻车说起
搞工业通讯的人,迟早会被字节序教做人。我印象特别深,前两年做一个西门子S7-1200跟第三方电表通讯的项目,Modbus RTU读上来的电压值,在PLC里显示出来永远是离谱的数字——明明电表上显示220.3V,PLC里读出来是1.7e-38这种鬼东西。当时第一反应是寄存器地址错了,查了半天地址没问题;又怀疑波特率、校验位,折腾了一下午,最后发现是字节序反了。
这个场景太典型了。Modbus协议本身规定数据以**大端序(Big-Endian)**传输,也就是高字节在前、低字节在后。但问题在于,不同厂家的设备、不同的PLC平台、不同的编程软件,对多字节数据的存储和解析方式各不相同。西门子、三菱、欧姆龙、施耐德,各有各的脾气。你从Modbus总线上抓到的4个字节,到底怎么拼成一个32位浮点数,或者怎么拼成一个32位整数,完全取决于你怎么“拆”这个BYTE数组。
标题里说的“ST按位拆解BYTE数组”,核心就是解决这个问题。ST(Structured Text,结构化文本)是IEC 61131-3标准里的一种高级编程语言,在CODESYS、倍福TwinCAT、施耐德Machine Expert、汇川InoProShop等平台上都能用。用ST来手动拆解BYTE数组,是处理字节序问题最灵活、最可控的方式——因为你完全掌控了每一个字节的去向,不依赖任何平台的“自动转换”功能。
这篇文章适合谁看?如果你正在做Modbus通讯,读上来的数据要么是乱码、要么是天文数字、要么正负号反了,那这篇就是写给你的。如果你刚开始接触ST语言,不知道怎么操作BYTE数组和位运算,这篇也能给你一套可以直接抄的模板。我会从字节序的底层原理讲起,然后给出完整的ST拆解代码,再分享几个我踩过的坑和排查技巧。
2. 字节序的底层逻辑:为什么你的数据会“翻车”
2.1 大端序与小端序的本质区别
要理解字节序问题,得先搞清楚一个基本概念:一个多字节数据在内存里是怎么排列的。
假设有一个32位整数0x12345678,它由4个字节组成:0x12、0x34、0x56、0x78。在内存中,这4个字节可以有两种排列方式:
- 大端序(Big-Endian):高字节存在低地址,低字节存在高地址。内存里依次是
12 34 56 78。 - 小端序(Little-Endian):低字节存在低地址,高字节存在高地址。内存里依次是
78 56 34 12。
Modbus协议明确规定,在总线上传输的多字节数据采用大端序。也就是说,当你从Modbus从站读取一个32位数据时,它占用了两个连续的16位寄存器,第一个寄存器存的是高16位,第二个寄存器存的是低16位。而在每个16位寄存器内部,又是高字节在前、低字节在后。
但问题来了:你的PLC或控制器,它的CPU架构可能是小端序的(比如x86架构的工控机,或者某些ARM架构的控制器)。当Modbus数据从总线上收进来,存到PLC的内存里时,如果中间没有做字节序转换,你直接去读那个内存区域,拿到的就是“反”的数据。
2.2 为什么Modbus通讯中字节序问题如此普遍
我总结下来,字节序问题在Modbus通讯中特别常见,主要有三个原因:
第一,Modbus标准本身只规定了传输序,没规定存储序。协议说“我在总线上按大端序发”,但没说“你收到之后必须按大端序存”。这就给了各个PLC厂家自由发挥的空间。西门子的S7系列,在存储多字节数据时用的是大端序,跟Modbus一致,所以西门子PLC做Modbus主站时,字节序问题相对少一些。但很多国产PLC、运动控制器、智能仪表,内部用的是小端序存储,收完数据直接扔内存里,你不手动拆,读出来就是反的。
第二,32位和64位数据的拆解方式不统一。一个32位浮点数占两个寄存器,但这两个寄存器谁在前谁在后,不同厂家有不同的约定。有的厂家高字在前(跟Modbus标准一致),有的厂家低字在前。更麻烦的是,有些设备连寄存器内部的字节顺序也跟你反着来。这就导致了所谓的“字交换”和“字节交换”两种操作,有时候甚至需要同时做字交换和字节交换。
第三,ST语言的位操作不像C语言那么直观。在C语言里,你可以用指针强转、用union联合体,几行代码就能搞定字节序转换。但在ST语言里,没有指针,没有union,你得老老实实地用位运算和类型转换来拆解。很多刚接触ST的工程师,习惯了梯形图的思维方式,遇到BYTE数组拆解就懵了。
2.3 按位拆解BYTE数组的核心思路
所谓“按位拆解”,并不是真的一个bit一个bit去操作,而是指以字节为最小单位,通过移位和掩码运算,把BYTE数组中的各个字节重新组合成目标数据类型。
核心操作就三个:
- 移位(SHL/SHR):把一个字节或一个字左移或右移若干位,用来把数据放到正确的位置上。
- 按位与(AND):用一个掩码(比如16#FF)取出某个字节,屏蔽掉其他不需要的位。
- 类型转换:把组合好的32位数据转换成REAL或DINT等目标类型。
举个例子,假设你从Modbus读到了4个字节,存在abyData[0]到abyData[3]里,你想把它们拼成一个32位整数。如果这4个字节是大端序的(abyData[0]是最高字节),那么:
dwValue := SHL(BYTE_TO_DWORD(abyData[0]), 24) OR SHL(BYTE_TO_DWORD(abyData[1]), 16) OR SHL(BYTE_TO_DWORD(abyData[2]), 8) OR BYTE_TO_DWORD(abyData[3]);如果这4个字节是小端序的(abyData[0]是最低字节),那就反过来:
dwValue := SHL(BYTE_TO_DWORD(abyData[3]), 24) OR SHL(BYTE_TO_DWORD(abyData[2]), 16) OR SHL(BYTE_TO_DWORD(abyData[1]), 8) OR BYTE_TO_DWORD(abyData[0]);就这么简单。但实际项目中,情况往往比这复杂——你可能需要处理字交换、字节交换、浮点数转换、有符号数处理等各种变体。下面我会逐一拆解。
3. ST语言拆解BYTE数组的完整实操方案
3.1 基础准备:定义数据结构与变量
在写拆解代码之前,先把变量定义清楚。我习惯用一套通用的变量命名规范,这样代码可读性好,后期维护也方便。
VAR // 原始字节数组,假设从Modbus读上来4个字节 abyRawData : ARRAY[0..3] OF BYTE; // 中间变量 dwTemp : DWORD; wHighWord : WORD; wLowWord : WORD; // 最终结果 rValue : REAL; diValue : DINT; dwValue : DWORD; // 字节序配置标志 bSwapWord : BOOL := FALSE; // 是否需要字交换 bSwapByte : BOOL := FALSE; // 是否需要字节交换 END_VAR这里解释一下几个关键点。abyRawData是原始字节数组,通常你从Modbus通讯功能块读上来的数据就是存在这样的数组里。dwTemp用来暂存组合后的32位数据。bSwapWord和bSwapByte是两个配置标志,用来灵活应对不同的字节序情况——这样你就不需要为每种设备写一套代码,改两个BOOL值就行。
注意:不同PLC平台对BYTE、WORD、DWORD、REAL这些类型的位宽定义是一致的(BYTE=8位,WORD=16位,DWORD=32位,REAL=32位),但类型转换函数的名称可能略有不同。比如CODESYS里用
BYTE_TO_DWORD,有些平台用BYTE_TO_DWORD或BYTE_TO_DWORD。写代码前先查一下你所用平台的函数库。
3.2 核心拆解逻辑:从BYTE数组到32位数据
先来看最基础的情况:把4个字节拼成一个DWORD。我写了一个通用的函数块,支持字交换和字节交换两种配置。
FUNCTION_BLOCK FB_ByteArrayToDword VAR_INPUT abyData : ARRAY[0..3] OF BYTE; // 输入字节数组 bSwapWord : BOOL; // TRUE=交换高低字 bSwapByte : BOOL; // TRUE=交换高低字节 END_VAR VAR_OUTPUT dwResult : DWORD; // 输出32位数据 END_VAR VAR byTemp : ARRAY[0..3] OF BYTE; i : INT; END_VAR // 第一步:根据配置决定字节排列顺序 IF bSwapByte THEN // 字节交换:0<->3, 1<->2 byTemp[0] := abyData[3]; byTemp[1] := abyData[2]; byTemp[2] := abyData[1]; byTemp[3] := abyData[0]; ELSE byTemp[0] := abyData[0]; byTemp[1] := abyData[1]; byTemp[2] := abyData[2]; byTemp[3] := abyData[3]; END_IF // 第二步:根据配置决定字排列顺序,并组合成DWORD IF bSwapWord THEN // 字交换:高字和低字互换 dwResult := SHL(BYTE_TO_DWORD(byTemp[2]), 24) OR SHL(BYTE_TO_DWORD(byTemp[3]), 16) OR SHL(BYTE_TO_DWORD(byTemp[0]), 8) OR BYTE_TO_DWORD(byTemp[1]); ELSE dwResult := SHL(BYTE_TO_DWORD(byTemp[0]), 24) OR SHL(BYTE_TO_DWORD(byTemp[1]), 16) OR SHL(BYTE_TO_DWORD(byTemp[2]), 8) OR BYTE_TO_DWORD(byTemp[3]); END_IF这段代码的逻辑很清晰:先处理字节交换,再处理字交换,最后组合。为什么先字节后字?因为字节交换影响的是每个字内部的排列,字交换影响的是字与字之间的排列。先处理内部,再处理外部,逻辑上更清晰,不容易搞混。
3.3 浮点数转换:从DWORD到REAL的关键一步
把字节数组拼成DWORD之后,如果你需要的是浮点数,还得再做一步转换。在ST语言里,DWORD到REAL的转换不能直接赋值,需要用平台提供的转换函数。
在CODESYS和大多数IEC 61131-3平台上,可以用DWORD_TO_REAL函数。但要注意,这个函数做的是位模式重解释,不是数值转换。也就是说,它把DWORD的32个bit原封不动地重新解释为一个REAL,而不是把整数值转成浮点值。
// 假设dwValue已经通过上面的逻辑组合好了 rValue := DWORD_TO_REAL(dwValue);如果你用的是西门子TIA Portal的SCL(本质上也是ST),转换函数叫DWORD_TO_REAL或者用REAL_TO_DWORD的反向操作。有些平台可能需要用UNION或者AT覆盖声明来实现,比如:
VAR dwValue : DWORD; rValue : REAL; END_VAR VAR_OVERLAP dwValue AT rValue : DWORD; END_VAR这种写法在CODESYS里可以用AT关键字实现变量覆盖,效果跟C语言的union一样。但不同平台对AT的支持程度不同,用之前最好查一下手册。
实操心得:我一般会在函数块里同时输出DWORD和REAL两个结果,这样不管上层需要整数还是浮点数,都能直接用。多定义一个输出变量而已,不费事,但省去了后期改代码的麻烦。
3.4 有符号整数的处理:别忘了符号位
如果你处理的是有符号整数(比如温度值可能是负数),拼成DWORD之后不能直接转DINT,因为DWORD是无符号的。正确的做法是先判断最高位(第31位)是否为1,如果是1,说明是负数,需要做补码转换。
// 判断符号位并转换 IF (dwValue AND 16#80000000) <> 0 THEN // 负数:取反加一 diValue := DWORD_TO_DINT(NOT dwValue + 1) * -1; ELSE diValue := DWORD_TO_DINT(dwValue); END_IF不过,大多数PLC平台的DWORD_TO_DINT函数会自动处理符号位,你直接转就行。但如果你发现转出来的负数不对,那就得手动处理补码。这个坑我在一个称重仪表的项目里踩过——仪表发过来的重量值是负数的时候,PLC读出来变成了一个巨大的正数,就是因为DWORD_TO_DINT没有正确处理符号位。
4. 不同场景下的字节序组合与实战案例
4.1 四种典型字节序组合速查
实际项目中,你遇到的字节序问题基本逃不出以下四种组合。我整理了一个速查表,方便你快速定位。
| 组合类型 | 字节排列(从低地址到高地址) | 典型设备/平台 | 处理方式 |
|---|---|---|---|
| 大端序(Big-Endian) | B0 B1 B2 B3(B0为最高字节) | Modbus标准、西门子S7 | 无需交换 |
| 小端序(Little-Endian) | B3 B2 B1 B0(B0为最低字节) | x86工控机、部分国产PLC | 字节交换 |
| 字交换(Word Swap) | B2 B3 B0 B1 | 部分电表、流量计 | 字交换 |
| 字+字节交换 | B1 B0 B3 B2 | 某些日系设备 | 字交换+字节交换 |
这张表是我这些年做项目总结出来的,基本上覆盖了90%以上的情况。剩下10%是更奇葩的排列,比如只交换中间两个字节之类的,那种就得具体问题具体分析了。
4.2 实战案例:电表电压电流读取
拿一个真实的项目来说。我之前做一个能耗监测系统,用的是某国产多功能电表,Modbus RTU通讯,读电压、电流、功率等参数。电表的通讯手册上写着:电压值占用2个寄存器,地址0x0000和0x0001,数据类型为FLOAT,字节序为“小端序”。
这里有个坑:手册上说的“小端序”,有时候指的是字交换,有时候指的是字节交换,有时候两个都包含。你不能只看手册,得实际测试。
我的做法是:先按大端序读一次,看看数据对不对。如果不对,再试字交换、字节交换、字+字节交换。一般试到第三种就能找到正确的组合。测试的时候,让电表带一个已知的负载,比如接一个220V的灯泡,看读出来的电压是不是接近220V。如果是,那就对了。
// 电表电压读取示例 // 假设abyVoltageData是从Modbus读到的4个字节 fbVoltage(abyData := abyVoltageData, bSwapWord := TRUE, // 先试字交换 bSwapByte := FALSE); rVoltage := DWORD_TO_REAL(fbVoltage.dwResult); // 如果读出来不对,再试其他组合我实测下来,这块电表需要字交换+字节交换同时开启才能读到正确的值。也就是说,它的字节排列是 B1 B0 B3 B2。这种组合在国产电表里挺常见的,大家遇到了不用慌,两个标志都设成TRUE就行。
4.3 实战案例:变频器频率给定值
另一个典型案例是变频器的频率给定。很多变频器支持Modbus通讯,你可以通过写寄存器来设定运行频率。频率值通常是16位整数,单位是0.01Hz。比如你要设50Hz,就得写5000。
16位整数的字节序问题比32位简单一些,因为只涉及两个字节的交换。但也不能掉以轻心。我遇到过一台变频器,它的频率给定寄存器是低字节在前、高字节在后,跟Modbus标准反着来。当时写5000(0x1388),变频器收到的却是0x8813,频率直接飙到348Hz,差点把设备搞坏。
处理16位字节序的代码更简单:
// 16位数据字节交换 FUNCTION SwapWord : WORD VAR_INPUT wInput : WORD; END_VAR VAR byLow : BYTE; byHigh : BYTE; END_VAR byLow := WORD_TO_BYTE(wInput AND 16#00FF); // 取低字节 byHigh := WORD_TO_BYTE(SHR(wInput, 8) AND 16#00FF); // 取高字节 SwapWord := SHL(WORD#(byLow), 8) OR WORD#(byHigh);这段代码的逻辑是:把输入字的低字节和高字节分别取出来,然后交换位置重新组合。用位运算实现,不依赖任何平台特有的函数,移植性很好。
注意:写变频器频率给定值之前,一定要先读一下当前频率,确认通讯正常、字节序正确,再写给定值。我一般会先用一个很小的值(比如0.5Hz)做测试,确认变频器实际输出频率跟设定值一致,再逐步加大。千万别一上来就写50Hz,万一字节序反了,后果可能很严重。
5. 常见问题与排查技巧实录
5.1 数据跳变不稳定是什么原因
有时候你读上来的数据不是完全错误,而是偶尔跳变一下,比如电压值在220V和0V之间来回跳。这种情况通常不是字节序问题,而是通讯质量问题。可能的原因包括:波特率不匹配、终端电阻没接、屏蔽线没接地、通讯线太长、电磁干扰太大。
排查思路:先用Modbus Poll或Modbus Slave这类调试工具,直接连到设备上读数据。如果调试工具读出来稳定,说明设备没问题,问题出在你的PLC程序或接线;如果调试工具读出来也跳,那就是设备或线路的问题。
5.2 浮点数读出来是NaN或无穷大
如果你把DWORD转成REAL之后,得到的是NaN(非数字)或Inf(无穷大),说明字节序组合还是不对。因为浮点数的位模式有特定的规范(IEEE 754),如果字节序错了,很可能拼出一个非法的浮点数。
这时候别急着改代码,先用手算验证一下。把读到的4个字节按十六进制打印出来,然后手动按IEEE 754格式解析,看看正确的字节序应该是什么。比如你读到00 00 43 43,手动解析一下,0x43430000对应的浮点数是195.0,而0x00004343对应的是一个极小的数。这样你就能确定需要做字交换。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 数据完全不对,数值极大或极小 | 字节序错误 | 手动解析十六进制 | 尝试字交换/字节交换 |
| 数据偶尔跳变 | 通讯干扰 | 用调试工具对比 | 检查接线、加终端电阻 |
| 负数变成大正数 | 符号位未处理 | 检查最高位 | 手动补码转换 |
| 浮点数为NaN/Inf | 字节序组合错误 | 手动IEEE 754解析 | 调整交换组合 |
| 16位数据高低字节反 | 字节序问题 | 对比已知值 | 用SwapWord函数 |
| 写给定值后设备异常 | 字节序反导致写入错误值 | 先读后写,小值测试 | 确认字节序后再写 |
5.4 我的独家避坑技巧
技巧一:先读后写,永远不要盲写。不管你跟设备通讯的目的是读还是写,第一次连上设备时,先读一个已知的、不会变化的寄存器(比如设备型号、序列号),确认通讯正常、字节序正确,再进行写操作。这个习惯帮我避免了好几次可能的事故。
技巧二:用已知值反推字节序。如果你知道设备当前的实际值(比如电表上显示电压220V),那就用这个已知值去反推字节序。把读到的原始字节打印出来,手动拼一下,看哪种组合能得到220,那就是正确的字节序。这比盲目试错快得多。
技巧三:把字节序配置做成参数。不要为每个设备写一套代码,而是把字交换和字节交换做成输入参数。这样同一个函数块可以复用到不同项目、不同设备上,只需要改两个BOOL值。我现在的项目里,所有Modbus数据解析都走同一个函数块,维护起来省心很多。
技巧四:注释里写清楚设备的字节序。项目做完之后,在代码注释里写清楚每个设备用的字节序组合。过半年再回来维护,你绝对记不住哪台设备是字交换、哪台是字节交换。写清楚,省的是自己的时间。
技巧五:用Modbus Poll做对照测试。Modbus Poll这个工具可以直观地显示寄存器的原始值,还能切换不同的数据显示格式(Signed、Unsigned、Float、Hex等)。当你怀疑字节序有问题时,用Modbus Poll读一下,切换几种格式看看,很快就能定位问题。我一般会在电脑上常备这个工具,现场调试时特别有用。
6. 跨平台适配与代码移植要点
6.1 CODESYS与TIA Portal的差异
CODESYS和西门子TIA Portal都支持ST/SCL语言,但细节上有不少差异。比如类型转换函数的名称,CODESYS里是BYTE_TO_DWORD,TIA Portal里是BYTE_TO_DWORD(其实一样,但有些函数名不同)。位运算的语法基本一致,SHL、SHR、AND、OR、NOT 这些操作符两个平台都支持。
最大的差异在于变量覆盖声明。CODESYS支持AT关键字,可以很方便地实现union效果。TIA Portal也支持AT,但用法略有不同。如果你需要在两个平台之间移植代码,建议把字节序转换的部分封装成独立的函数块,不要依赖平台特有的语法。
6.2 汇川、倍福等国产平台的注意事项
汇川的InoProShop和倍福的TwinCAT都基于CODESYS内核,所以ST语言的兼容性很好。但汇川的某些小型PLC(比如H5U系列)对ST的支持有限,可能不支持函数块、不支持某些类型转换函数。这种情况下,你得用梯形图或者指令表来实现字节序转换,或者升级到支持ST的型号。
倍福TwinCAT的ST功能非常强大,支持面向对象编程,可以用方法和属性来封装字节序转换逻辑。如果你用TwinCAT,我建议把字节序转换写成一个FB(功能块),然后用方法来实现不同的转换模式,代码会更优雅。
6.3 代码移植检查清单
当你把字节序转换代码从一个平台移植到另一个平台时,检查以下几点:
- 类型转换函数名称是否一致(BYTE_TO_DWORD、DWORD_TO_REAL等)
- 位运算操作符是否支持(SHL、SHR、AND、OR、NOT)
- 数组下标是从0开始还是从1开始(IEC 61131-3标准是0,但有些平台默认从1开始)
- 是否支持AT覆盖声明
- 是否支持函数块和方法
- 整数类型的位宽是否一致(DWORD一定是32位,但INT可能是16位或32位)
实操心得:我一般会在新平台上先写一个最简单的测试程序,把已知的字节数组转成DWORD,然后用在线监控看结果对不对。确认基础功能没问题之后,再移植完整的代码。这样出了问题容易定位,不会一上来就被一堆编译错误搞晕。
7. 写在最后:几个让我印象深刻的踩坑经历
做工业通讯这些年,字节序问题给我留下的心理阴影不少。有一次做一个出口项目,设备发到国外之后,客户反馈数据全都不对。我在国内远程调试,折腾了两天才发现,是国内用的PLC和国外用的PLC型号不同,一个是大端序存储,一个是小端序存储,同一套程序跑出来的结果完全相反。从那以后,我养成了一个习惯:任何Modbus通讯项目,不管设备手册怎么写,我都要实际测试字节序,确认无误后再往下做。
还有一次更离谱,一台设备的Modbus手册上写的字节序是“大端序”,我按大端序解析,数据一直不对。后来打电话给厂家技术支持,对方说“手册写错了,实际是小端序”。所以,手册只能作为参考,实际测试才是王道。
最后分享一个我常用的调试方法:在ST程序里加一段临时代码,把Modbus读到的原始字节以十六进制字符串的形式显示在HMI上。这样你可以直观地看到每个字节的值,手动拼一下就能确定正确的字节序。确认之后,再把这段临时代码删掉。这个方法虽然笨,但特别有效,尤其是在现场调试的时候,比反复改代码试错快得多。
字节序这个问题,说难不难,说简单也不简单。核心就是搞清楚数据的排列顺序,然后用位运算把它拼成正确的格式。ST语言给了你足够的灵活性来做这件事,关键是你要理解每一个字节在干什么。希望这篇文章能帮你少走一些弯路,下次遇到Modbus字节序问题的时候,能快速定位、快速解决。