做工业通讯调试这么多年,碰到最多的一类问题不是网络不通、不是寄存器地址写错,而是“数据明明读回来了,解析出来却是错的”。最典型的现场画面:一台温度变送器,液晶面板上清清楚楚显示17.6℃,PLC的监视画面里却出现一个毫无逻辑的大数;变频器的状态字里Bit0本来是“运行中”,程序里看到的却是Bit8置位,联锁逻辑跟着全乱。最后追根溯源,基本都落在同一个地方——Modbus字节序。这篇文章就围绕“Modbus字节序反了”这个高频故障展开,讲讲在ST(结构化文本)语言里,怎么用按位拆解BYTE数组的办法,把从Modbus从站读回来的原始数据,准确还原成Word、DWord、Real,甚至是一组独立的位标志。内容主要面向天天和PLC、触摸屏、上位机、各种仪表打交道的自动化工程师,也适合刚接触Modbus、正在为解析问题头疼的朋友。
1. Modbus字节序乱的根源:协议规定与现实设备的落差
1.1 协议本身说的是“大端”
Modbus协议对16位寄存器的字节传输顺序有明确规定:读一个保持寄存器,报文数据区里先传的是高字节(MSB),再传低字节(LSB),也就是业界常说的大端序。以0x1234这个寄存器值为例,标准RTU响应帧的数据区里,先出现0x12,紧接着才是0x34。协议这样定的目的,就是让不同架构的PLC、仪表交换数据时,有一个双方默认的“书写顺序”。
因为有了这个默认规则,大多数PLC厂家、组态软件解析Modbus数据时都按大端处理:第1个字节是高字节,第2个字节是低字节。对于32位数,则是四个字节按“最高字节、次高字节、次低字节、最低字节”依次排下来。你要是问一个老工程师,Modbus到底是大端还是小端?他大概率会告诉你,大端,这是协议白纸黑字写好的。
1.2 但很多设备根本不按协议来
理想很丰满,现实很骨感。实际设备里,有相当一部分从站根本不遵守这个顺序。原因大致有三类。
第一类,嵌入式固件图省事。很多仪表和采集模块的MCU本身是小端架构,开发人员直接把内存里的float变量用memcpy拷进发送缓冲区,内存里的小端排列就被原封不动发出去了。第二类,为了“兼容”某款历史上位机软件,部分厂家故意把小端输出设为默认,你要是不知道这个背景,按大端解析必然出错。第三类,同一厂家不同固件版本行为不一致,老版本按协议大端,新版本换了内核库函数后变成小端,现场设备一升级,原有程序的数据全乱。
所以“Modbus字节序会反”不是个例,而是现场几乎必然遇到的坑。对待它的正确方式,不是指望所有设备都规规矩矩,而是在程序里准备好按位拆解的手段,把字节序变成可配置、可切换的参数。
1.3 字节序错乱的实际表现
我总结过实际调试中遇到的四种情况,用0x12345678这个32位整数来举例最直观:
| 设备实际发出的字节流 | 按大端协议拼接出的值 | 对应哪种错乱 |
|---|---|---|
| 12 34 56 78 | 0x12345678 | 正确,无错误 |
| 78 56 34 12 | 0x78563412 | 整体小端,每个字节全反 |
| 56 78 12 34 | 0x56781234 | 两个16位寄存器的顺序交换,内部保持大端 |
| 34 12 78 56 | 0x34127856 | 寄存器内部字节交换,寄存器间顺序不变 |
看到没有?字节序问题不是简单“颠倒一下”就能覆盖的,而是可能有四种组合。后面所有拆解代码,我都会把“字节序”当成一个明确的输入参数来对待,而不是写死成某一种。
2. ST语言里BYTE数组的存储模型与位运算基本功
2.1 BYTE数组和Modbus报文怎么对应
ST里的BYTE数组可以理解成一段连续的内存块,每个元素占8位。我们用功能码03读回来的数据,通常在通讯库里就是一个ARRAY[0..N-1] OF BYTE,数组内容和报文数据区一一对应,bData[0]就是数据区第一个字节。
这里必须强调一个很多人容易绕晕的点:如果从站设备严格按协议发送,那么bData[0]是寄存器40001的高字节,bData[1]是40001的低字节,bData[2]是40002的高字节,bData[3]是40002的低字节。也就是说,第n个寄存器的数据,落在数组下标2*(n-1)和2*(n-1)+1上。这个对应关系是后面所有解析的前提,先把它刻在脑子里再动手写代码。
2.2 位运算基础:ST里怎么按位操作
做按位拆解,离不开四个基本运算:左移SHL、右移SHR、按位与AND、按位或OR。它们的语义和C语言里的<<、>>、&、|是一样的,差异只在于ST是强类型语言,BYTE、WORD、DWORD之间不能随手混着算。
我举个实际例子。把一个BYTE变成WORD再左移8位,必须写成SHL(BYTE_TO_WORD(bData[0]), 8),不能直接在某个表达式里拿BYTE和WORD做OR,类型不匹配编译都过不去。虽然个别ST环境会做隐式扩展,但为了代码在Codesys、TwinCAT、施耐德这些平台之间都能稳定编译,显式转换是最省心的写法。如果你用的平台没有BYTE_TO_WORD这个转换函数,用INT_TO_WORD替代也完全可以。
位运算的核心逻辑并不复杂:左移n位等于低位腾出n个空位,按位与用来“扣”出某一位或某几位,按位或用来把分散的位拼起来。理解了这三个动作,拆解BYTE数组就是反复使用它们而已。
2.3 为什么不直接强转或者用MEMCPY
碰到字节序问题,很多人第一反应是“把BYTE数组直接MEMCPY到WORD变量里”。这条路在现场往往行不通。原因是MEMCPY这类拷贝指令,拷贝完以后在目标变量里的解释规则,取决于PLC运行时所在CPU的字节序,和Modbus设备实际的字节序没有必然关系。在x86架构的工控机上它按小端解释,在某些ARM架构的PLC里又可能是大端,你写代码时根本管不住这一层。结果就是同一段程序换一台设备、换一种CPU,解析结果完全不一样。
按位拆解的本质优势在于:它不依赖CPU、不依赖平台,每一步移位和位或的规则都由你自己写死。字节序怎么排,完全由代码逻辑决定。这才是处理Modbus字节序混乱的可控做法。
3. 按位拆解BYTE数组:核心实现的两种姿势
3.1 十六位数据:两个BYTE拼一个WORD
最常见的情况是解析一个16位寄存器,比如电压值、频率值、状态字。先写一个最底层的组合函数:
FUNCTION F_WordFromBytes : WORD VAR_INPUT bHigh : BYTE; bLow : BYTE; END_VAR F_WordFromBytes := SHL(BYTE_TO_WORD(bHigh), 8) OR BYTE_TO_WORD(bLow); END_FUNCTION这个函数把两个BYTE按“高字节在前”拼成一个WORD。调用它的时候,只要把参数的顺序按设备实际情况传入,就完成了字节序的适配。如果设备报文里低字节在前,调用时就故意调换:
wValue := F_WordFromBytes(bData[1], bData[0]);这样代码一眼就能看出来:不是改库函数,而是按设备的实际字节流调整输入顺序。我在项目里习惯把这个函数放在全局函数库中,所有通讯块统一调用,方便管理。
3.2 提取第n位:掩码加移位
拿到WORD之后,按位提取就简单了。判断第n位是否为1:
bBitN := (wValue AND SHL(WORD#16#1, n)) <> 0;这里面的原理是:构造一个只含第n位为1的掩码,其余位全0;用AND一扣,如果结果不为0,说明这一位是1。如果要一次性提取16个位并放到BOOL数组里,用循环写:
VAR i : INT; wMask : WORD; END_VAR FOR i := 0 TO 15 DO wMask := SHL(WORD#16#0001, i); bBitArray[i] := (wValue AND wMask) <> 0; END_FOR这套逻辑我在状态字、报警字解析里用了无数次,稳定、易懂、速度也足够快。有人会问,为什么不用移位后再判断末位?也能做,但掩码法的好处是原值不会被破坏,同一份数据可以反复提取不同位,不需要额外备份。
3.3 三十二位数据:四个BYTE拼DWORD
解析累计量、计数值或者IEEE 754浮点数时,需要把四个BYTE拼成一个DWORD。这里要特别小心,前面说的CDAB和BADC两种混合顺序,就是在这种场景下出现的。我给出一个同时兼容大端和小端的通用函数:
FUNCTION F_DWordFromBytes : DWORD VAR_INPUT bData : ARRAY[0..3] OF BYTE; eOrder : INT; (* 0=ABCD 1=DCBA 2=CDAB 3=BADC *) END_VAR CASE eOrder OF 0: (* ABCD:bData[0]是最高字节 *) F_DWordFromBytes := SHL(BYTE_TO_DWORD(bData[0]), 24) OR SHL(BYTE_TO_DWORD(bData[1]), 16) OR SHL(BYTE_TO_DWORD(bData[2]), 8) OR BYTE_TO_DWORD(bData[3]); 1: (* DCBA:bData[3]是最高字节 *) F_DWordFromBytes := SHL(BYTE_TO_DWORD(bData[3]), 24) OR SHL(BYTE_TO_DWORD(bData[2]), 16) OR SHL(BYTE_TO_DWORD(bData[1]), 8) OR BYTE_TO_DWORD(bData[0]); 2: (* CDAB:寄存器顺序交换,内部保持大端 *) F_DWordFromBytes := SHL(BYTE_TO_DWORD(bData[2]), 24) OR SHL(BYTE_TO_DWORD(bData[3]), 16) OR SHL(BYTE_TO_DWORD(bData[0]), 8) OR BYTE_TO_DWORD(bData[1]); 3: (* BADC:寄存器内部字节交换,寄存器间顺序不变 *) F_DWordFromBytes := SHL(BYTE_TO_DWORD(bData[1]), 24) OR SHL(BYTE_TO_DWORD(bData[0]), 16) OR SHL(BYTE_TO_DWORD(bData[3]), 8) OR BYTE_TO_DWORD(bData[2]); END_CASE END_FUNCTION这个函数算得上我现场排查字节序问题的“定海神针”。拿到一组乱序字节后,先把四种顺序都试一遍,看看哪个和真实物理量吻合,然后用那个顺序做正式解析。这样既快又不容易漏掉混合顺序的坑。
3.4 从DWORD转换到REAL
如果确认设备发的是32位浮点数,拼出DWORD以后,还需要把位模式转换成REAL。标准ST环境里通常有DWORD_TO_REAL或DWORD_TO_FLOAT这类转换指令:
rValue := DWORD_TO_REAL(dwCombined);这个转换只是告诉编译器“把这32位当成IEEE 754浮点数来解释”,和数值大小无关。如果你的平台没有这个指令,也可以用MEMCPY把DWORD的字节拷到REAL里,但要注意前面说过的平台字节序问题。实际项目里我一般优先找内置转换指令,实在没有才用MEMCPY,并且测试时要多验证几个非整值。
4. 三个必须面对的工程场景:状态字、浮点数、32位整数
4.1 场景一:变频器状态字里每个位都是信号
工业现场最常见的解析目标,是设备的状态字。比如一台变频器,厂商手册里写着:状态字寄存器40001,bit0=准备就绪,bit1=运行中,bit2=故障,bit3=报警,一共16个位。PLC程序里通常希望得到16个独立的BOOL变量,直接进梯形图或者逻辑判断。
按标准大端帧解析,写法就是先拼WORD再逐位提取:
wStatus := F_WordFromBytes(bData[0], bData[1]); bReady := (wStatus AND 16#0001) <> 0; bRunning := (wStatus AND 16#0002) <> 0; bFault := (wStatus AND 16#0004) <> 0; bAlarm := (wStatus AND 16#0008) <> 0;如果设备字节序是小端,那么bData[0]是低字节,状态字的bit0其实落在bData[1]的bit0上。这时候不调整顺序就提取,得到的bReady其实是真正的bit8,逻辑完全错乱。
这种场景最怕“只看着位提取写代码,不检查原始字节顺序”。我建议在协议调试阶段,一定把wStatus的原始字节打印出来,确认bit0真的在预期字节里,然后再写提取逻辑。
4.2 场景二:32位IEEE 754浮点数
处理模拟量时,很多设备用两个连续的寄存器存放一个32位浮点数。比如温度17.6℃,在IEEE 754下是0x418CCCCD,标准帧里四个字节按41 8C CC CD排列。PLC里拼出DWORD,再用DWORD_TO_REAL,就能得到17.6。
但相当一部分仪表小端发送,真实字节流是CD CC 8C 41。如果还用大端拼,得到的是一个对不上的乱值,必然与表显值不符。这种故障排查起来最迷惑的一点是:上位机用某款组态软件读出来正常,PLC用标准解析却不对——因为组态软件内置了“设备字节序”配置项,默认帮你处理了;而PLC程序里这套逻辑得自己写。
我给的F_DWordFromBytes在这时候就能派上用场,参数eOrder设成1,立刻得到正确结果。再把组合函数和DWORD_TO_REAL封装成一个浮点解析块,整个程序里统一调用,会省掉大量重复劳动。
4.3 场景三:32位整数和累积量
水表、电表、流量计的累积量经常是32位整数,存放在两个连续寄存器里。这里有个容易混淆的点:两个寄存器之间的先后顺序,和寄存器内部的字节顺序,其实是两回事。
一种情况是寄存器40001存高16位、40002存低16位,另一种是40001存低16位、40002存高16位。这两种如果不分清楚,读出的累计量直接错出几个数量级。我的做法是先把两个16位分别拼出来:
wHigh := F_WordFromBytes(bData[0], bData[1]); wLow := F_WordFromBytes(bData[2], bData[3]); dwValue := SHL(BYTE_TO_DWORD(bHigh), 16) OR BYTE_TO_DWORD(bLow);如果设备序列里低字在前,就把wHigh和wLow的赋值来源调换。逻辑本身很简单,但一定要写清楚注释,因为过两个月再看代码,很容易忘记当初定了哪种顺序。我在项目配置里会额外加一个“寄存器字序”字段,用注释或者结构体成员标明HIGH_WORD_FIRST还是LOW_WORD_FIRST。
5. 怎么验证拆解结果是对的:调试手段和边界测试
5.1 用从站模拟器生成你知道的数据
字节序问题最怕猜。我调试的流程是:先不和真实设备较劲,而是在PC上跑一个Modbus从站模拟器,把寄存器值设成几个特征数,然后看PLC收到的BYTE数组到底是什么顺序。这样一下就能摸清设备的真实字节序。
特征数怎么选?我建议用0x1234和0x12345678这类十六进制能肉眼分辨的数字。因为0x1234的字节是12和34,任何一个字节颠倒成34 12,一眼就看出来了。32位就用0x12345678,对应的四种排列是12 34 56 78、78 56 34 12、56 78 12 34、34 12 78 56,全部在之前那张表上,对着排查非常快。如果是浮点,直接用17.6的0x418CCCCD,41 8C CC CD四个字节的排列变化,一样清楚。
5.2 ST程序里自查原始字节
我在正式解析函数之前,习惯性地加一段诊断代码,把收到的原始字节数组拷到专门的诊断变量里,通过HMI或者编程软件的在线监控窗口直接看。别小看这一步,很多现场问题其实在“原始字节到底是什么”这一层就已经被掩盖了——解析函数封装得太好,中间过程全黑箱,反而没法定位。
诊断代码不需要多花哨:
dbgByte0 := bData[0]; dbgByte1 := bData[1]; dbgByte2 := bData[2]; dbgByte3 := bData[3];就这几行,把原始字节暴露出来。配合模拟器,几分钟内就能确定设备是哪种字节序。调试完成后再把这些诊断变量删掉,或者留着做现场的故障预诊断,都不影响运行。
5.3 别忘了边界值测试
字节序修正以后再测一下边界值,防止解析逻辑在特殊数据下翻车。我一般会测这几组:全0、全1(0xFFFFFFFF)、0x0000FFFF、0xFFFF0000,以及浮点里的0.0、正负无穷、NaN。全0和全1能验证掩码逻辑;0xFFFF0000能验证是否出现半字交换;浮点特殊值能确认DWORD_TO_REAL没有抛错。这些测试直接在模拟器里改寄存器值,PLC侧查看解析结果。如果边界值全对,就可以放心对接真实设备。
6. 现场踩过的坑,以及我现在的标准做法
6.1 坑一:调了字节序,忘了检查位序
有些设备更“狠”,不仅字节是高低温颠倒,负责串口的固件还把每个字节内部的位也反转发送。这种设备用0x8001特征值测非常明显。0x8001这个值,大端正确解析后bit0和bit15都是1;如果解析结果变成了bit1和bit14为1,而bit0和bit15却是0,基本可以怀疑位序也反了。
万一真遇到位序也反的设备,就得在字节序修正之后,再对每一个BYTE做一次位反转。循环函数长这样:
FUNCTION F_ReverseByte : BYTE VAR_INPUT bValue : BYTE; END_VAR VAR i : INT; END_VAR F_ReverseByte := 0; FOR i := 0 TO 7 DO IF (bValue AND SHL(BYTE#16#1, i)) <> 0 THEN F_ReverseByte := F_ReverseByte OR SHL(BYTE#16#1, 7 - i); END_IF END_FOR END_FUNCTION调用时,在进入组合函数之前先过一遍位反转:
FOR i := 0 TO 3 DO bData[i] := F_ReverseByte(bData[i]); END_FOR这个操作耗时很小,但很多人根本想不到会有这一层,直到被现场数据折磨一整天。
6.2 坑二:数组下标与寄存器序号混淆
再有一个很常见的坑是下标问题。功能码04读输入寄存器,返回的数据区里其实隐含了寄存器顺序。比如要读40001和40002两个寄存器,报文数据区一共4个字节,40001对应bData[0]、bData[1],40002对应bData[2]、bData[3]。有人习惯从1开始数,写成bData[1]是第一个寄存器的高字节,结果整整错开两个字节,解析出来的数值牛头不对马嘴。
我自己的习惯是:收到报文后先画一个字节位置示意,把寄存器序号、数组下标、高/低字节三行对应写出来,再开始写解析代码。这个习惯帮我省掉了大量无意义的排查时间。
6.3 坑三:循环提取位时忘了掩码类型
最后一个常见坑,是在循环里提取位的时候,掩码用错了数据类型。ST是强类型语言,WORD的掩码16#0001只能和WORD做AND。你要是拿一个BYTE掩码去和WORD运算,编译可能直接报错;就算不报错,某些环境自动扩展后结果也容易不符合预期。我把掩码统一写成带类型限定的十六进制字面量,比如WORD#16#0001,而不是裸写1、2、4,就是为了减少这类类型问题。
另外一个和它类似的细节:判断某位是否为1,最稳妥的写法是“AND后与0比较”,也就是(wValue AND wMask) <> 0。不要直接拿位运算结果去赋给BOOL变量,语义不够清晰,有些编译器还会报警告。
6.4 我现在写Modbus解析代码的标准套路
踩过这么多坑之后,我现在写Modbus从站解析程序基本固定成一套流程:第一步,把所有原始BYTE数组集中在一个通讯功能块里管理,不外散。第二步,所有Word、DWord组合都走像F_WordFromBytes、F_DWordFromBytes这样的统一函数,字节序用参数控制。第三步,位提取单独封装成一个小函数,输入WORD和位号,输出BOOL。第四步,每个设备的字节序配置做成结构体字段,写死在设备配置表里,换设备只改配置,不改逻辑。
这套流程处理下来,后期维护压力小很多,现场新增设备基本不用动代码。我始终觉得,像字节序这种基础问题,解决方案不该是“临时抱佛脚”地打补丁,而是从一开始就把解析层做得足够通用。你在这上面花半天写的封装,会在后面很多个项目的调试里不断替你省时间。工程里数据解析这种事,越往后越能体会“把底子打牢”这四个字的分量。