干Modbus调试,最难受的不是连不上,而是数据通了一看,全是乱码。我有回在现场调一台温控仪表,CRC校验全过、寄存器也读得出来,结果PLC上面板温度显示65280℃。仪表那边明明输出的是25.5℃,可程序里读出来的值怎么都对不上。折腾了将近三个小时,最后才确认就是字节序反了——仪表寄存器里存的是0x00FF,PLC按大端当0xFF00读出来,65280就这么来的。
这种问题,用ST语言(结构化文本)按位拆解BYTE数组,往往几分钟就能解决。所谓按位拆解,不是简单把两个字节对调,而是把通信缓冲区里的BYTE数组按你需要的方式逐字节、逐位重组。这篇文章我会把几种常见的字节序错乱情况、ST的拆解代码、以及这几年我踩过的坑全部整理出来,正在做PLC和第三方设备Modbus联调的工程师可以直接照用,刚接触协议解析的朋友也能顺着思路搞懂原理。
1. 字节序这事儿,先搞明白再动手
1.1 四种字节序:同一个数,四种长相
先把32位数据拿出来举例。假设一个DWORD数是0x12345678,拆开是四个字节:0x12、0x34、0x56、0x78,我分别记为A、B、C、D。这四个字节在报文里、在内存里出现的顺序,常见的有四种:
| 顺序类型 | 字节排列 | 典型表现 |
|---|---|---|
| 标准高字节在前 | 12 34 56 78 | 寄存器1=0x1234,寄存器2=0x5678,符合Modbus大端约定 |
| 字内字节交换 | 34 12 78 56 | 每个16位寄存器内部高低字节颠倒,寄存器顺序没变 |
| 字顺序交换 | 56 78 12 34 | 两个16位寄存器前后顺序反了,各自内部字节正常 |
| 全部反序 | 78 56 34 12 | 字内字节和寄存器顺序全都反了 |
很多资料里把这四种叫法写成ABCD、CDAB、BADC、DCBA,不同文章里名字还可能互相打架,所以不要死记名字,关键是看最终的字节排列。我项目里干脆用数字模式编号(0到3),现场对号入座。
生活里也有类似的事。同样是“三月二十一号”,有人写03-21,有人写21-03,还有人写2025-03-21。纸面上都是对的,但两边习惯不一样,看到的日子就不是同一个。Modbus字节序就是这样:设备端发出来的字节排列和PLC解析时用的排列不一致,数据就被读“歪”了。
1.2 Modbus协议明明规定了,设备为什么还是乱来
Modbus协议对单条16位寄存器报文的传输格式是有明确规定的:先发高字节,再发低字节。比如寄存器值是0x1234,RTU报文里的原始字节是0x12、0x34。从这个角度说,Modbus线缆上的字节序是标准的“大端”。
问题出在哪呢?协议规定了线缆上怎么传,却管不到设备内部固件怎么存储这个值。现在很多设备主控芯片是ARM或x86架构,内存默认小端。如果固件直接把内存里的WORD按连续字节丢给Modbus栈,发出来的就是低字节在前。还有一些设备做32位浮点数的寄存器拼接时,自己定了“寄存器地址从低到高分别对应数据高字还是低字”,各家并不统一。加上有些上位机驱动默认按本机字节序解析,应用层看到的数值自然就反了。
所以“字节序反了”不一定是设备坏了,也不一定是PLC搞错了,更多时候是两边的“读法”没对上。你拿Modbus调试助手抓包的时候也一样——工具如果默认按某个字节序显示原始寄存器值,你还以为报文没问题,其实问题早藏在显示层之下了。
1.3 比字节序更闹心的:还有位序反的
字节序好歹按字节为单位,肉眼还能比对。有些冷门设备会离谱到把16位寄存器整个按位倒过来:bit0放原本bit15的数据,bit1放bit14,以此类推。这种数据你光做字节交换和字序交换都解决不了,因为每一个位都挪了家。
这也是为什么我在标题里特别强调“按位拆解”——普通字节交换是“整箱搬家”,位序反了就必须把箱子拆开,一个一个零件重新装。处理这种场景,ST语言的位运算几乎是唯一正路。
2. 为什么推荐用ST按位拆解BYTE数组
2.1 ST和BYTE数组是协议解析的天然搭档
Modbus RTU和Modbus TCP的数据区,本质都是一长串BYTE数组。PLC通信指令读回来的数据就放在连续缓冲区里,很多主站指令还会给你一个数据指针。而ST语言的特点就是可以像普通数组一样按下标访问、写循环、做位运算,和协议解析的需求天然匹配。
这种做法最大的好处是:解析逻辑全掌握在自己手里。缓冲区里哪个下标对应哪个字节、哪个字节该挪到哪一位,代码里写得明明白白。出问题的时候可以一步步监控追踪,而不是依赖厂商封装好的“黑盒指令”,出了问题只能瞎猜。
如果遇到字节序问题就随手调用“字节交换指令”,经常会碰到几种尴尬:有些PLC的交换指令只做16位交换,32位场景完全不适用;有些指令处理不了字序和字节序同时反的情况;还有的指令在不同型号PLC上行为不完全一致。按位拆解配合模式枚举,反而是一次性解决所有排列问题的通用方案。
2.2 四个位运算“武器”:本质是拆积木、拼积木
按位拆解听着玄乎,核心其实就四个操作:
- AND(按位与):用来“摘出”某一位。比如
SHR(Value, i) AND 16#0001,就是把第i位搬到最低位后用掩码把其他位清零,剩下的就是那一位的值。 - OR(按位或):用来“放入”某一位。比如
Result OR SHL(WORD#1, 15 - i),就是把某一位放到结果的第15-i位,而且不影响已经拼好的其他位。 - SHL(左移):把位往高位挪。左移1位相当于乘2,左移8位相当于乘256,左移24位就能把一个BYTE放到DWORD的最高8位上。
- SHR(右移):把位往低位挪,配合AND就能提取任意指定位置的位。
打个比方,这就像拼乐高:一堆零件散在地上,AND负责从某个零件堆里精准拔出一颗,SHL/SHR负责把零件挪到目标卡槽,OR负责把它卡进结构里。一个字节一个字节、一个位一个位地组合。
这里要特别提醒一下优先级问题。IEC 61131-3标准里,ST的逻辑运算符优先级有规定:NOT大于AND大于XOR大于OR,但不同厂商的编译器在实际处理时仍可能有细微差别。我的习惯是,只要表达式里同时出现位移、按位与、按位或,一律加括号把顺序写死,不给编译器自由发挥的空间。
2.3 自写功能块,比临时改代码更靠谱
也许有人会说,有些PLC库已经提供了字节序转换功能。实际项目里,我仍然更倾向自己写功能块,原因有四个:
- 不依赖特定PLC品牌,换平台逻辑照样能用;
- 四种常见乱序一次覆盖,现场出问题切个模式就行;
- 可以批量处理,以后一次性解析几十个寄存器也无需改动核心逻辑;
- 可读性好,新接手的同事看到代码能立刻明白这台设备是按哪种顺序重组数据的。
这套东西说白了就是“沉淀标准件”。这次调完这台仪表,下次遇到一个字节序同样混乱的变频器,直接把功能块拖过来,选好OrderMode,活就干完了。
3. 完整实操:ST拆解BYTE数组的全套代码
3.1 功能块接口定义
我设计了一个功能块FB_ModbusBytesToDword,输入是一个4字节数组和模式号,输出是重组后的DWORD和REAL。为什么同时输出两种类型?因为DWORD和REAL在内存里位模式完全相同,重组完DWORD后,一句DWORD_TO_REAL就能得到浮点数,一次搞定整型和浮点两种场景。
FUNCTION_BLOCK FB_ModbusBytesToDword VAR_INPUT RawData : ARRAY[0..3] OF BYTE; OrderMode : INT; // 0-标准排列,1-字内字节交换,2-字顺序交换,3-全部反序 END_VAR VAR_OUTPUT ResultDword : DWORD; ResultReal : REAL; END_VAR VAR_TEMP b0, b1, b2, b3 : BYTE; nTmp : DWORD; END_VAROrderMode的编号含义我在现场就直接印在注释里,防止过两个月自己都忘了。这里以4个字节为例,是因为多数Modbus项目里最常用的32位浮点数正好是4个字节。如果以后遇到64位数据,思路完全一样,扩展到8个字节即可。
3.2 四种模式的重组核心代码
主体代码就是一个CASE分支,把原始数组的四个字节按不同顺序移位拼接。具体实现如下:
b0 := RawData[0]; b1 := RawData[1]; b2 := RawData[2]; b3 := RawData[3]; CASE OrderMode OF 0: // 标准排列:12 34 56 78 nTmp := SHL(TO_DWORD(b0), 24) OR SHL(TO_DWORD(b1), 16) OR SHL(TO_DWORD(b2), 8) OR TO_DWORD(b3); 1: // 字内字节交换:34 12 78 56 nTmp := SHL(TO_DWORD(b1), 24) OR SHL(TO_DWORD(b0), 16) OR SHL(TO_DWORD(b3), 8) OR TO_DWORD(b2); 2: // 字顺序交换:56 78 12 34 nTmp := SHL(TO_DWORD(b2), 24) OR SHL(TO_DWORD(b3), 16) OR SHL(TO_DWORD(b0), 8) OR TO_DWORD(b1); 3: // 全部反序:78 56 34 12 nTmp := SHL(TO_DWORD(b3), 24) OR SHL(TO_DWORD(b2), 16) OR SHL(TO_DWORD(b1), 8) OR TO_DWORD(b0); END_CASE; ResultDword := nTmp; ResultReal := DWORD_TO_REAL(nTmp);这段代码的核心思路就是把每个字节当独立零件,通过SHL移动到它在目标DWORD中的位置,然后用OR拼起来。模式1到模式3只是改了移动的映射关系,并没有增加任何新操作。
比如RawData数组是[12, 34, 56, 78]:
- 模式0得到0x12345678;
- 模式1得到0x34127856;
- 模式2得到0x56781234;
- 模式3得到0x78563412。
设备手册如果写明“32位数据按两个寄存器呈低字节在前排列”,就直接选模式3。对不上就换一个模式试,通常一次就能命中。
代码里的OR拼接就是热词“按位或赋值”的实际应用,不过ST语言没有C语言那种|=写法,通常直接在一个表达式里用多个OR完成。有一点经验值得提:BYTE参与移位前,我习惯先TO_DWORD显式转换,不要依赖自动类型提升,否则遇到0x80这类高位置1的字节,有些编译器会做符号扩展,把高位污染成一堆FFFF。
3.3 位反转场景:暴力逐位拆解
如果设备把16位寄存器整个位序都反了,字节交换完全不奏效,这时候就要上循环逐位处理。我写了一个位反转功能块FB_ReverseBitsInWord:
FUNCTION_BLOCK FB_ReverseBitsInWord VAR_INPUT Value : WORD; END_VAR VAR_OUTPUT Result : WORD; END_VAR VAR_TEMP i : INT; bitMask : WORD; END_VAR Result := WORD#16#0000; FOR i := 0 TO 15 DO bitMask := SHR(Value, i) AND WORD#16#0001; IF bitMask = WORD#16#0001 THEN Result := Result OR SHL(WORD#16#0001, 15 - i); END_IF; END_FOR逻辑不复杂:循环16次,先把第i位移到最低位,再用AND 1提取出来。如果是1,就把它放到结果的第15-i位上。每循环一次,就等于把一个位从对称位置搬了一次家。
拿16#0001举例,二进制就是最低位为1,其它位全是0。按这个代码执行一遍:i=0时提取到bit0是1,放到第15位,Result变成0x8000;i=1到15时全是0,最终结果就是16#8000。一个最低位的1被翻到了最高位,位序反转完成。
再比如16#1234,二进制是0001 0010 0011 0100,逐位反转后是0010 1100 0100 1000,也就是16#2C48。这类数据在普通仪表里不常见,但在某些定制传感器和老式计量模块上真遇到过,没有这套逐位拆解代码,当时还真没辙。
3.4 把4个BYTE拼成REAL:浮点数场景
Modbus项目里最常见的32位数据是REAL浮点,变频器频率、流量、压力都是。重组得到DWORD后,用DWORD_TO_REAL转换就得到浮点值,这也是为什么功能块同时输出DWORD和REAL两个变量。
我经常在调试现场用一个“已知数值验证法”:让设备输出一个固定已知值,比如10.0Hz。IEEE 754单精度表示下,10.0的十六进制是0x41200000,标准报文字节就是41 20 00 00。如果设备存储习惯是全反序,缓冲区里读出来就变成00 00 20 41。这时用模式3重组,得到0x41200000,DWORD_TO_REAL之后就变回10.0。
还有一种情况更容易被忽略:设备寄存器顺序是对的,但浮点字节顺序在内部反了,读出来是一个极其接近0的“天文小数字”。这种值看起来不像明显乱码,但明显不合理,容易让人误判成量程问题。其实只要把四种模式都试一遍,哪个能解出正常工程值,就用哪个模式。
3.5 在主程序里怎么调用
功能块写好后,调用非常直接。假设MB_MASTER已经把数据读到DataBuffer数组里:
VAR DataBuffer : ARRAY[0..3] OF BYTE; conv : FB_ModbusBytesToDword; f : REAL; END_VAR conv(RawData := DataBuffer, OrderMode := 3); f := conv.ResultReal;如果一次读了一大片数据、需要连续解析多个浮点数,外层套个FOR循环就行,每次取4个字节调用功能块,输出放进REAL数组。模式号统一,不到十行代码就能批量转换。
有人可能会纠结实参是数组怎么传的问题。实际项目中如果缓冲区比较大、数据地址不连续,可以把功能块输入改成POINTER TO BYTE,或者先用MEMCPY把目标数据拷贝到RawData数组里。为了演示清晰,这里用静态数组,现场按实际需求调整即可。
4. 踩坑实录与排查速查表
4.1 坑一:数组下标0不一定是报文的第一个字节
这是我最早期犯过的错。Modbus主站指令把数据写进缓冲区时,下标0到底对应寄存器高字节还是低字节,由通信库实现决定。有的PLC还会在缓冲区前面塞地址、长度之类的附加信息。我一开始默认下标0是高字节,结果回回解析都是反的。
后来学乖了:先给设备写入一个已知值,比如0x1234,读回来用监控表看数组内容。亲眼确认哪个下标放的是0x12、哪个放的是0x34,再往下写解析逻辑。这个动作几乎能消除后面所有关于字节位置的猜测成本。
4.2 坑二:把缩放系数问题当成字节序问题
字节序出错的时候,数据通常会非常离谱,要么是六位数,要么是极小数。但如果读到的数值只是差一个固定倍数,比如仪表输出25.5℃、程序里读出255,或者10.0倍率读成100,那一般不是字节序问题,而是单位倍率和量程转换没做对。
我有一次在现场陪客户折腾了一下午的字节交换,最后翻手册才发现,设备寄存器单位是0.1,程序里少乘了10。所以动手拆字节前,先把数据合理性评估一遍:如果数值和期望值只差倍数,先查手册里的量纲定义,别在字节序这个方向死磕。
4.3 坑三:负数被读成巨大正数
Modbus寄存器里存0xFFFE,表示有符号数-2;如果程序直接按WORD解析,读出来是65534。这其实不属于字节序问题,而是类型转换没做对。正确做法是:字节序重组完成后,16位有符号数用WORD_TO_INT转,32位有符号数用DWORD_TO_DINT转。
顺序一定要先重组、再转换。如果反过来先把WORD转成INT,再去换字节、做位运算,符号扩展会干扰你的移位结果,最后怎么调都差一位。
4.4 坑四:隐式类型转换和符号扩展趁虚而入
ST语言有些环境下,BYTE值0x80如果参与自动类型提升,可能被当成有符号数扩展成0xFF80,再参与左移,高位就出现一堆FFF。这类问题排查起来特别隐蔽:测试数据在0x7F以下一切正常,一碰到0x80以上的数据就全乱。
所以我在所有位拼接场景,都坚持写显式转换:TO_BYTE、TO_WORD、TO_DWORD,绝不指望编译器自动完成。一句话,类型转换写得越啰嗦,半夜被叫起来处理现场的可能性越低。
4.5 问题排查速查表
| 现象 | 可能原因 | 建议动作 |
|---|---|---|
| 16位数据高低完全反(0x00FF变成0xFF00) | 字内字节序反 | 用OrderMode=1试一遍 |
| 32位数据像两个16位数交换了位置 | 寄存器字序反 | 用OrderMode=2试一遍 |
| 32位数据毫无规律,每个字节都错位 | 字节序和字序都反 | 用OrderMode=3试一遍 |
| 数据等于-1时读出65535 | 没有做有符号转换 | 重组后加WORD_TO_INT |
| 浮点数读出极小值或NaN | 32位浮点字节序不对 | 四种模式顺序验证 |
| 数值正确但差固定倍数 | 缩放系数或单位倍率 | 查手册,别在字节序上死磕 |
| 单寄存器内BIT位倒序 | 设备位序LSB-first | 调FB_ReverseBitsInWord |
这个表是我每次排查Modbus数据异常时的行动清单。先确认数值离不离谱,再确认数组下标位置,最后套模式。前两步排除了,第三步基本一击就中。
调试时还有一个实用技巧:在触摸屏或监控界面把四种模式的解析结果同时显示出来,直接用肉眼判断哪个读数像正常工程值。很多时候不用纸面推算,看一眼界面就知道该选哪个模式,比对着报文一格格推演快得多。
说到底,Modbus字节序反了不是什么大病,但确实磨人。我现在接手新设备时,第一件事就是确认设备手册里的“寄存器存储格式”,然后用一个已知固定值验证数组位置,最后才决定用哪个OrderMode。这套功能块沉淀成标准库之后,新项目再遇到同类问题基本三分钟解决,偶尔碰到位序更诡异的设备,也只需要在最外层再加一层位反转循环就行——思路是通的,代码是复用的,剩下的只是时间问题。