ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modbus字节序解析:用ST语言按位拆解BYTE数组修复浮点数错误

Modbus字节序解析:用ST语言按位拆解BYTE数组修复浮点数错误 上个月在污水站调一台变频器的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 大端序0x420x370x330x33老式仪表、组态软件CD AB 字交换0x370x420x330x33部分国产仪表、变频器DC BA 双字颠倒0x330x330x420x37某些编码器、智能传感器BA DC 全颠倒0x330x330x370x42小端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^240x42bData[1]次高字节 2^160x37bData[2]次低字节 2^80x33bData[3]最低字节 2^00x33所有字节序修正本质上就是调整这张表的排列。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序内存图就可以这样画位置设备原始发送目标理想顺序Byte00x370x42Byte10x420x37Byte20x330x33Byte30x330x33画完图之后再做移位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]0x34bData[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里。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进