.NET上位机踩坑:大小端和字节序

前言

大家好,我是 wacky。

今天聊个具体的坑------字节序和大小端。这坑我刚入行那会儿踩过,盯着采集上来的温度显示 1.347e-38,一度怀疑人生。

很多上位机新手有个错觉:从 PLC 把寄存器读上来,转换一下不就是个数了吗?实际上真没那么简单。一个 32 位浮点数跨两个寄存器,中间隔着"大小端"和"字节序"两道关,任何一道没对齐,你看到的就是天书。

一、大小端到底是什么

大小端(endianness)说的是:一个多字节的数,它的各个字节在内存里的存储顺序。

大端(big-endian):也叫大端模式或者网络字节序,在大端模式中,数据的高位字节,存储在低位地址内,而低位字节,存储在高位地址内。

举个例子,现在有一个数字0x12345678,按照大端存储,那么就是高位字节0x12存储在低位地址,低位字节0x78存储在高位地址。

如图所示:

小端(little-endian):也叫小端模式或者主机序。小端模式和大端模式刚好相反,数据的高位字节在高位地址内,而低位字节,存储在低位地址内。

继续以0x12345678为例,按照小端存储,就是低位字节0x78存储在低位地址,高位字节0x12存储在高位地址。

如同所示:

一句话总结:大端像人写字(高位先写),小端像 x86 存数(低位先放)。

工控程序里真正的麻烦在于:PLC 用大端、上位机用小端是常态。你从 Modbus 把字节读上来,直接丢给 BitConverter.ToSingle,出来的往往不是原来的数------因为字节顺序没对齐,你必须按照PLC的大小端去解析。

二、工控用 ABCD / BADC / CDAB / DCBA 四种记号

光说"大小端"还不够细。一个 32 位浮点数 = 4 字节 = 2 个 Modbus 寄存器,所以实际应用中还存在大端反转、小端反转的情况。因此其实有四种字节顺序,而工控领域习惯用四个字母描述这 4 个字节的顺序,记法固定------A 是最高位字节,D 是最低位字节:

复制代码
using System.ComponentModel;

namespace BigAndLittleDian
{
    internal enum DataFormat
    {
        [Description("按照顺序排序")]
        ABCD = 0,
        [Description("按照单字反转")]
        BADC = 1,
        [Description("按照双字反转")]
        CDAB = 2,
        [Description("按照倒序排序")]
        DCBA = 3,
    }
}
复制代码
大小端模式只是一种规定数据存储的字节顺序方式,在与不同的硬件进行通信时,上位机程序需要根据对方的大小端模式进行正确的解析和处理,而且不同类型的硬件大小端模式是在设计时已经确定,一般不会发生改变。

三、经典翻车现场:浮点数读成 1.347e-38

场景:PLC 那边把温度存成 32 位 float,占两个保持寄存器。比如地址 40001 是高字、40002 是低字(也可能反过来,这个和厂商有关)。

你的.NET 程序从 Modbus 读到两个 ushort,然后 BitConverter.ToSingle 一转,期望得到 25.5。结果屏幕上蹦出来 1.347e-38,或者一个几万的数。

为什么?因为 BitConverter 默认按小端(DCBA)解析。PLC 那边如果是大端(ABCD)或者字序有反转(CDAB),你直接 ToSingle,等于把字节全打乱重新拼,最后出来的结果当然不是原值。

当年我对着这个玩意儿纠结了半天,最后才发现是设备的字节序是 CDAB,而我按 ABCD 读了。差这一个字母,结果就天差地别。

四、C# 示例:四种顺序怎么转

核心思路:不管设备用哪种顺序存,先把读到的 4 个字节重排成 .NET 需要的 DCBA(小端),再 ToSingle。下面我们可以定义一个函数,把四种情况一网打尽:

复制代码
namespace BigAndLittleDian
{
    internal static class ReadAndWrite
    {
        // 从 2 个寄存器还原浮点数
        static float ReadFloat(ushort reg0, ushort reg1, DataFormat order)
        {
            byte[] raw = new byte[4];
            BitConverter.GetBytes(reg0).CopyTo(raw, 0);
            BitConverter.GetBytes(reg1).CopyTo(raw, 2);
            byte[] dcb = order switch
            {
                DataFormat.ABCD => new[] { raw[3], raw[2], raw[1], raw[0] },  // 大端
                DataFormat.DCBA => raw,                                      // 小端
                DataFormat.BADC => new[] { raw[2], raw[3], raw[0], raw[1] }, // 单字反转
                DataFormat.CDAB => new[] { raw[1], raw[0], raw[3], raw[2] }, // 双字反转
                _ => raw
            };

            return BitConverter.ToSingle(dcb, 0);

        }


        // 反过来:把浮点数按指定顺序写进 2 个寄存器
        static ushort[] WriteFloat(float value, DataFormat order)
        {
            byte[] dcb = BitConverter.GetBytes(value);
            byte[] raw = order switch
            {
                DataFormat.ABCD => new[] { dcb[3], dcb[2], dcb[1], dcb[0] },
                DataFormat.DCBA => dcb,
                DataFormat.BADC => new[] { dcb[2], dcb[3], dcb[0], dcb[1] },
                DataFormat.CDAB => new[] { dcb[1], dcb[0], dcb[3], dcb[2] },
                _ => dcb
            };

            return new[] { BitConverter.ToUInt16(raw, 0), BitConverter.ToUInt16(raw, 2) };
        }
    }
}

要点:不要靠猜,如果没有手册,那么就把四种组合都试一遍。PLC 里写的是123.45,分别用 ABCD / BADC / CDAB / DCBA 读,哪种正确就使用哪种,然后写进配置。

五、怎么快速判断是不是这个坑

几个典型症状:整数偶尔对、浮点必错 ------ 基本就是字节/字序问题。

数值数量级完全不对:1e-38、1e+38、或者动辄几万 ------ 字节被重排了。

排查办法很朴素:把原始的 4 个字节(或 2 个寄存器)通过日志打印出来,和 PLC 监控里的值对照,按 ABCD / BADC / CDAB / DCBA 四种组合算一遍,哪种对就能确定该使用哪种。

后记

字节序和大小端看似是个很小的知识点,实际上是上位机最经典的坑之一。它本质上是"你和 PLC 对数据格式的约定没对齐",就类似于我们Web前后端来开发接口,要约定好数据格式。我们把字节序的几种读取都封装好,把接口文档定义清楚,那么这个坑就填平了。

踩坑系列虽然没有体系可言,但是每个问题深究起来确实也很有意思,也希望能帮助到正在学习或者已经入坑的你们,诸君共勉!

本文首发于我的公众号【wacky的碎碎念】,欢迎关注追更!