04 · 协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer

本篇定位:从字节流的另一端看问题。上半篇对比三个主力协议包(ModBus、Siemens、Melsec)的类清单、报文构造风格、地址解析与错误码提取;下半篇讲 Core/Transfer(DataFormat 四字节序的实现原理)、Core/Types(OperateResult 家族)、以及 SoftBasic / LogNet 两个通用底座。

目录


1. 三个协议包的类清单与职责

1.1 ModBus/(10 文件 / 3356 行)

类 行数 职责
ModbusInfo 774 报文工厂:功能码常量 + Build*ModbusCommand + Pack/Explode + 错误码文案
ModbusHelper 333 读写编排:Read/Write/ReadBoolHelper/ReadWrite/ReadFile 的静态流程(依赖 IModbus 回调)
ModbusTcpNet — Modbus TCP 客户端(extends NetworkDeviceBase implements IModbus)
ModbusRtuOverTcp — RTU 帧 over TCP(GetNewNetMessage() 返回 ModbusRtuMessage)
ModbusUdpNet — UDP 变体
ModbusTcpServer 614 服务端模拟器
ModbusMappingAddress — 地址映射规则
IModbus — 协议能力接口(getStation/setDataFormat/ReadFromCoreServer/TranslateToModbusAddress)
Helper/ModbusDataDict · Helper/ModbusDataPool — 服务端数据区

1.2 Profinet/Siemens/(6 文件 / 约 2400 行)

类 行数 职责
SiemensS7Net 1397 S7 客户端,含 ISO-on-TCP 握手、PDU 协商、读写、字符串/WString、热复位等
SiemensFetchWriteNet — 老式 Fetch/Write 协议(GetNewNetMessage() 返回 FetchWriteMessage)
SiemensPPIOverTcp — PPI 串口透传 over TCP
SiemensS7Server 573 S7 服务端模拟器
SiemensPLCS — 型号枚举(S200/S200Smart/S300/S400/S1200/S1500)
SiemensDateTime — S7 时间格式(BCD 编码的 DATE_AND_TIME)
Helper/SiemensS7Helper — 响应解析(AnalysisReadByte/AnalysisReadBit)、字符串读写、地址分片(ArraySplitByLength/SplitS7Address)
Helper/SiemensPPIHelper 615 PPI 协议报文构造

1.3 Profinet/Melsec/(14 文件 / 约 6800 行)

类 职责
MelsecMcNet MC 二进制 3E 帧客户端(GetNewNetMessage() → MelsecQnA3EBinaryMessage)
MelsecMcAsciiNet / MelsecMcAsciiUdp MC ASCII 帧
MelsecMcUdp / MelsecMcRNet UDP 变体 / R 系列(冗余 CPU)
MelsecA1ENet / MelsecA1EAsciiNet A 兼容 1E 帧(帧头只有 2/4 字节,靠请求推断响应长度)
MelsecA3CNetOverTcp · MelsecFxLinksOverTcp · MelsecFxSerialOverTcp 串口透传变体
MelsecMcServer (807 行) MC 服务端模拟器
MelsecMcDataType · MelsecA1EDataType 软元件类型表(D/M/X/Y/W/L…)
MelsecHelper 通用报文片段构造 + GetErrorDescription
Helper/IReadWriteMc 协议能力接口
Helper/McHelper 读写编排(419 行)
Helper/McBinaryHelper (522) / McAsciiHelper / MelsecA3CNetHelper / MelsecFxLinksHelper / MelsecFxSerialHelper (646) 各帧格式的报文构造

1.4 三个包的共同骨架

<协议信息类>   ModbusInfo / SiemensS7Helper+PPIHelper / MelsecHelper+McBinaryHelper+McAsciiHelper
      ↑ 被调
<读写编排类>   ModbusHelper / (内联在 SiemensS7Net) / McHelper
      ↑ 被调
<客户端类>     ModbusTcpNet / SiemensS7Net / MelsecMcNet   (extends NetworkDeviceBase)
      ↑ 实现
<能力接口>     IModbus      / (无)          / IReadWriteMc

一个结构性观察:Modbus 与 Melsec 都把"读写编排"抽到了独立的 Helper 类(通过 IModbus / IReadWriteMc 回调客户端),而 Siemens 没有能力接口,读写逻辑全部内联在 SiemensS7Net 里。结果是:

这是三个包里架构质量差异最大的一点:Modbus/Melsec 的"能力接口 + 静态编排"模式明显更优。


2. 报文构造风格:四种并存

2.1 风格盘点

风格 典型位置 全库出现次数
A. 定长数组 + 逐下标赋值 McBinaryHelper.java:65-79、McAsciiHelper.java:23-52 主流
B. ArrayList<Byte> 累加 ModbusHelper.java:115-123、NetworkBase.java:190-210 51 处
C. MemoryStream(包 ByteArrayOutputStream) ModbusInfo.java:644-656(写文件记录) 11 处
D. SoftBasic.SpliceArray(变参) SoftBasic.java:770-787 21 处

先澄清一个常见误解:用户任务里提到的 ByteArrayList,未在本版本源码中见到这个类型。全库用的是 JDK 自带的 ArrayList<Byte>,伴随大量 Byte 装箱。

2.2 风格 A:定长数组逐下标赋值(推荐做法)

// Profinet/Melsec/Helper/McBinaryHelper.java:65-79
public static byte[] BuildReadMcCoreCommand(McAddressData addressData, boolean isBit) {
    byte[] command = new byte[10];
    command[0] = 0x01;                                                        // 批量读取数据命令
    command[1] = 0x04;
    command[2] = isBit ? (byte) 0x01 : (byte) 0x00;                           // 以点为单位还是字为单位成批读取
    command[3] = 0x00;
    command[4] = Utilities.getBytes(addressData.getAddressStart())[0];        // 起始地址低位
    command[5] = Utilities.getBytes(addressData.getAddressStart())[1];        // 行 72
    command[6] = Utilities.getBytes(addressData.getAddressStart())[2];
    command[7] = (byte) addressData.getMcDataType().getDataCode();            // 软元件类型
    command[8] = (byte) (addressData.getLength() % 256);                      // 软元件长度
    command[9] = (byte) (addressData.getLength() / 256);
    return command;
}

优点:零拷贝、零垃圾、输出长度一眼可见、每个字节有注释。这是工业协议报文构造最该用的写法。

但同一份代码里有明显的性能失误:相邻三行(行 71-73)为拿 3 个字节调用了 3 次 Utilities.getBytes(int),每次都新建一个 byte[4]。ASCII 版更夸张:

// Profinet/Melsec/Helper/McAsciiHelper.java:36-41  ★ 同一个方法被调用了 6 次只为取 6 个字节
command[10] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[0];
command[11] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[1];
command[12] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[2];
command[13] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[3];
command[14] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[4];
command[15] = MelsecHelper.BuildBytesFromAddress(addressData.getAddressStart(), addressData.getMcDataType())[5];

而 MelsecHelper.BuildBytesFromAddress(Profinet/Melsec/MelsecHelper.java:181)内部还调 SoftBasic.BuildAsciiBytesFrom → 每次新建数组。6 次调用 = 6 次完整数组构造。行 42-45 同样重复调了 4 次 BuildAsciiBytesFrom((short) length)。

这在整个库里是普遍模式(Core/IMessage 里也有类似的重复 getBytes),属于 C# 逐行翻译的典型后遗症。

2.3 风格 B / C:ArrayList<Byte> 与 MemoryStream(长度不定的场景)

// ModBus/ModbusHelper.java:115-123
ArrayList<Byte> resultArray = new ArrayList<Byte>();
for (int i = 0; i < command.Content.length; i++) {
    OperateResultExOne<byte[]> read = modbus.ReadFromCoreServer(command.Content[i]);   // 行 117
    if (!read.IsSuccess) return OperateResultExOne.<byte[]>CreateFailedResult(read);
    Utilities.ArrayListAddArray(resultArray, read.Content);                            // 行 120
}
return OperateResultExOne.CreateSuccessResult(Utilities.getBytes(resultArray));        // 行 123

ArrayList<Byte> 版本每个字节一次装箱;MemoryStream(包 ByteArrayOutputStream,如 ModbusInfo.java:644-656)无装箱开销却只在少数地方用。两种等价写法在同一份代码库里并存,说明没有统一约定。

2.4 风格 D:SoftBasic.SpliceArray(变参)

// BasicFramework/SoftBasic.java:770-787
public static byte[] SpliceArray( byte[]... arrays ) {
    int count = 0;
    for (int i = 0; i < arrays.length; i++) {
        if (arrays[i] != null && arrays[i].length > 0) count += arrays[i].length;   // 行 774
    }
    int index = 0;
    byte[] buffer = new byte[count];                                                // 行 779  一次分配
    for (int i = 0; i < arrays.length; i++) {
        if (arrays[i] != null && arrays[i].length > 0) {
            System.arraycopy(arrays[i], 0, buffer, index, arrays[i].length);        // 行 782
            index += arrays[i].length;
        }
    }
    return buffer;
}

这是四者中最优雅的:一次计算总长、一次分配、N 次 System.arraycopy,还顺手处理了 null 与空数组。常用于"读回多条命令的结果"这类长度事前可知的场景。

SoftBasic 里还有两个二路特化版本:SpliceTwoByteArray(byte[], byte[])(行 874)与 SpliceTwoByteArray(byte[]...)(行 911)——一个 byte[] 参数版和一个变参版重载,后者只有一个参数时行为相同。这是明确的重载冗余(变参版传一个数组时等价于复制该数组)。

2.5 Modbus 的封装/解封装

ModbusTcpNet 覆写两个钩子接入这套封装(ModbusTcpNet.java:123-129):PackCommandWithHeader 调 ModbusInfo.PackCommandToTcp(command, softIncrementCount.GetCurrentValue())(行 124),UnpackResponseContent 调 ModbusInfo.ExtractActualData(ModbusInfo.ExplodeTcpCommandToCore(response))(行 128)。

// ModBus/ModbusInfo.java:493-502
public static byte[] PackCommandToTcp(byte[] value, int id) {
    byte[] buffer = new byte[value.length + 6];          // 行 494  MBAP 6 字节(事务ID 2 + 协议号 2 + 长度 2)
    buffer[0] = Utilities.getBytes(id)[1];               // 行 495  事务 ID 高字节
    buffer[1] = Utilities.getBytes(id)[0];               // 行 496
    buffer[4] = Utilities.getBytes(value.length)[1];     // 行 497
    buffer[5] = Utilities.getBytes(value.length)[0];     // 行 498
    System.arraycopy(value, 0, buffer, 6, value.length); // 行 500
    return buffer;
}

注意 buffer[2]、buffer[3](协议标识)从未被赋值,恒为 0x00 ——恰好符合 Modbus 规范(该字段必须为 0)。这属于"靠默认值蒙对了",而不是显式设置。同一函数里 Utilities.getBytes 被调了 4 次,产生 4 个多余数组。


3. 地址解析三方对比

三个协议都把"字符串地址 → 结构体"放在 Core/Address/(17 个类)。对比三个代表:

维度 ModbusAddress (193 行) S7AddressData (242 行) McAddressData (580 行)
父类 DeviceAddressBase DeviceAddressDataBase (无,独立类)
解析入口 构造函数里 Parse(address)(ModbusAddress.java:31) 静态 ParseFrom(address, length)(S7AddressData.java:130) 静态 ParseMelsecFrom(address, length)(McAddressData.java:54)
解析方式 ; 分隔的富地址 startsWith 瀑布 if-else switch (address.charAt(0))
核心字段 Station / Function / Address / WriteFunction DataCode / DbBlock / AddressStart / Length McDataType / AddressStart / Length
返回形式 对象(异常靠构造抛) OperateResultExOne<S7AddressData> OperateResultExOne<McAddressData>
地址进制 支持十进制与十六进制后缀 H(行 129-131) 十进制,.n 位偏移 按软元件规定:X/Y/DX/DY 为 8 进制,D/W/L 为 10 进制

3.1 Modbus 的"富地址"语法

// Core/Address/ModbusAddress.java:136-157(节选)
@Override
public void Parse(String address) {
    if (address.indexOf(';') < 0) {
        setAddress(ParseAddressHelper(address));                                      // 行 140  正常地址,功能码 03
    } else {
        String[] list = address.split(";");                                           // 行 144
        for (int i = 0; i < list.length; i++) {
            if      (list[i].charAt(0) == 's' || list[i].charAt(0) == 'S')     this.Station      = Integer.parseInt(list[i].substring(2));   // s=2
            else if (list[i].charAt(0) == 'x' || list[i].charAt(0) == 'X')     this.Function     = Integer.parseInt(list[i].substring(2));   // x=3
            else if (list[i].startsWith("w=") || list[i].startsWith("W="))     this.WriteFunction = Integer.parseInt(list[i].substring(2));  // w=16
            else setAddress(ParseAddressHelper(list[i]));                             // 行 153
        }
    }
}

于是 "s=2;x=3;100" = 站号 2、功能码 03、地址 100。这是一个优雅的"地址即配置"设计——把站号、功能码这些本该是参数的东西塞进地址串,使得 device.Read("s=2;x=3;100", (short)10) 一条语句就能表达跨站读取。代价是每条读写都要做一次字符串 split。

配套 getWriteFunction()/setWriteFunction()(行 122-125)允许"读用 03、写用 10"这类非对称映射——这是为兼容国产畸形网关准备的。

3.2 西门子的瀑布 if-else

// Core/Address/S7AddressData.java:130-150(节选)
public static OperateResultExOne<S7AddressData> ParseFrom(String address, int length ) {
    S7AddressData addressData = new S7AddressData();
    try {
        addressData.setLength(length);
        addressData.DbBlock = 0;
        if (address.startsWith( "SM" ))                                  // 行 135  系统标志位
        {
            addressData.DataCode = 0x05;
            if (address.startsWith( "SMX" ) || address.startsWith( "SMB" ) || address.startsWith( "SMW" ) || address.startsWith( "SMD" ))
                addressData.setAddressStart( CalculateAddressStarted( address.substring( 3 ), false ) );   // 行 140
            else
                addressData.setAddressStart( CalculateAddressStarted( address.substring( 2 ), false ) );   // 行 142
        }
        else if (address.startsWith( "AI" ) || address.startsWith( "ai" )){    // 行 144  DataCode = 0x06,同样模式
        } else if (address.startsWith( "AQ" ) || address.startsWith( "aq" )){  // 行 150  DataCode = 0x07,同样模式
        } /* ... 后续 I / Q / M / DB / T / C / V 均是这 4 行的复制 */

"前缀 X / XX / XXB / XXW / XXD 五选一"这个模式被复制了大约 8 次(SM/AI/AQ/I/Q/M/DB/T/C/V)。同样的 4 行代码重复 8 遍——这是本版本最大的复制粘贴块之一,完全可以用一个 Map<String, Integer> prefix → (dataCode, offset) 表驱动。

位偏移换算在 CalculateAddressStarted(S7AddressData.java:96-110):无小数点时 isCT ? parseInt(address) : parseInt(address) * 8(行 101/103),有小数点时 parseInt(temp[0]) * 8 + parseInt(temp[1])(行 108,"100.3" → 803)。

* 8 这个魔法数贯穿全文件:S7 的基本寻址单位是位,而用户写的是字节地址。这个转换散落在多处,且 isCT 参数(C/T 区不乘 8)让调用方必须记得传对。

3.3 三菱的 switch + 进制表

// Core/Address/McAddressData.java:54-74(节选)
public static OperateResultExOne<McAddressData> ParseMelsecFrom( String address, int length ) {
    McAddressData addressData = new McAddressData();
    addressData.setLength(length);
    try {
        switch (address.charAt(0)) {
            case 'M': case 'm': {                                                      // 行 59-64
                addressData.McDataType = MelsecMcDataType.M;
                addressData.setAddressStart(Integer.parseInt(address.substring(1), MelsecMcDataType.M.getFromBase()));
                break;
            }
            case 'X': case 'x': {                                                      // 行 65-74
                addressData.McDataType = MelsecMcDataType.X;
                address = address.substring(1);
                if (address.startsWith("0"))
                    addressData.setAddressStart(Integer.parseInt(address, 8));          // 行 70  ★ 8 进制
                else
                    addressData.setAddressStart(Integer.parseInt(address, MelsecMcDataType.X.getFromBase()));
                break;
            }
            // ...

关键差异在 MelsecMcDataType(Profinet/Melsec/MelsecMcDataType.java:18-31)——它是一个四元组:

public MelsecMcDataType( short code, byte type, String asciiCode, int fromBase )
{
    DataCode = code;                // 二进制帧用的元件代号     行 20
    AsciiCode = asciiCode;          // ASCII 帧用的两字符代号   行 21
    FromBase = fromBase;            // 地址的进制(10 或 16)   行 22
    if (type < 2) DataType = type;  // 0=字,1=位              行 23
}

这个四元组设计很聪明:同一个软元件(比如 X)的二进制代号、ASCII 代号、地址进制、字/位属性集中在一处,于是 McBinaryHelper 和 McAsciiHelper 可以共用同一份地址解析结果,只是取 getDataCode() 还是 getAsciiCode() 的区别。

但也有一处奇怪:X/Y/DX/DY 走 8 进制的判断是"以 0 开头"(McAddressData.java:69)——意味着 X0 是 8 进制的 0,而 X0F 走 getFromBase()(16 进制)。这是把"前导零"当成了进制标记,属于隐晦约定(需实测确认在 X00 这类地址上的行为)。

3.4 三方对比结论

优雅度 可维护性 备注
ModbusAddress ★★★★ ★★★★ 富地址语法是加分项;用 ; 分隔避免了新协议要新字段的问题
McAddressData ★★★★ ★★★ MelsecMcDataType 四元组是最佳设计;前导零当进制标记是扣分项
S7AddressData ★★ ★ 8 段复制粘贴的 startsWith 瀑布;* 8 魔法数散落

4. 错误码提取三方对比

协议 触发条件 错误码来源 实现位置 文案映射
Modbus 响应功能码 >= 0x80 response[2](异常码) ModbusInfo.java:476 GetDescriptionByErrorCode(byte) 行 605-618
三菱 MC 二进制 getUShort(content, 9) != 0 结束码(2 字节) McBinaryHelper.java:50-51 MelsecHelper.GetErrorDescription(int) 行 305+
西门子 S7 在数据区里扫描到 0x05/0x06/0x0A 后跟 0x00 content[i] SiemensS7Helper.java:102-107 直接调 StringResources
// ModBus/ModbusInfo.java:473-484
public static OperateResultExOne<byte[]> ExtractActualData(byte[] response) {
    try {
        if ((response[1] & 0xff) >= 0x80)
            return new OperateResultExOne<byte[]>( response[2], ModbusInfo.GetDescriptionByErrorCode(response[2]));  // 行 476
        else if (response.length > 3)
            return OperateResultExOne.CreateSuccessResult(SoftBasic.BytesArrayRemoveBegin(response, 3));             // 行 478  剥 站号+功能码+字节数
        else
            return OperateResultExOne.CreateSuccessResult(new byte[0]);
    } catch (Exception ex) {
        return new OperateResultExOne<byte[]>(ex.getMessage());
    }
}
// Profinet/Melsec/Helper/McBinaryHelper.java:48-54
int errorCode = Utilities.getUShort( content, 9 );                    // 行 50  固定偏移 9
if (errorCode != 0) return new OperateResult( errorCode, MelsecHelper.GetErrorDescription( errorCode ) );
// Profinet/Siemens/Helper/SiemensS7Helper.java:102-108(节选)
else if (content[i] == 0x05 && content[i + 1] == 0x00)                         // 行 102
    return new OperateResultExOne<byte[]>(content[i], StringResources.Language.SiemensReadLengthOverPlcAssign());
else if (content[i] == 0x06 && content[i + 1] == 0x00)                         // 行 104
    return new OperateResultExOne<byte[]>(content[i], StringResources.Language.SiemensError0006());
else if (content[i] == 0x0A && content[i + 1] == 0x00)                         // 行 106
    return new OperateResultExOne<byte[]>(content[i], StringResources.Language.SiemensError000A());

西门子的方案最被动:它必须先已经解析到 payload 内部、逐个数据项扫描,才能"认出"某两项其实是错误项。这是 S7 协议 PDU 设计的历史包袱(错误项伪装成正常数据项),Hsl 只是忠实实现。但 content.length >= 21(SiemensS7Helper.java:84)这个魔数与 for (int i = 21; ...) 的起点,让这段代码极难维护。

三菱的错误码表最完整:MelsecHelper.GetErrorDescription 覆盖 0x0002 / 0x0051 / 0x0052 / 0x0054~0x0059 / 0x430D / 0xC04D / 0xC050 / 0xC051 / 0xC052… 一路到 0xC059 等(行 305 起),是三个协议里最认真的。


5. Core/Transfer:字节序的四种实现

Core/Transfer/ 共 7 个文件:接口 IByteTransform(351 行)、基类 ByteTransformBase(394 行)、DataFormat 枚举(23 行)、三个实现类 + 静态编排 ByteTransformHelper。

5.1 为什么需要它

同样的 4 个字节 [0x12, 0x34, 0x56, 0x78],在不同 PLC 上表示的 float 完全不同:

DataFormat 字节排列 谁在用
ABCD 12 34 56 78(原始大端) 三菱(默认)、部分国产
BADC 34 12 78 56(单字内反转) 常见国产 PLC
CDAB 56 78 12 34(双字内反转) Modbus 网关常见
DCBA 78 56 34 12(完全倒序) 西门子(ByteTransformBase 默认)
// Core/Transfer/DataFormat.java:6-23
public enum DataFormat {
    ABCD,      // 按照顺序的格式生成的解析规则
    BADC,      // 按照单字反转
    CDAB,      // 按照双字反转
    DCBA       // 按照倒序排序
}

5.2 实现原理:两个 switch

ByteTransformBase 把"四顺序"实现成两个受保护方法——ByteTransDataFormat4(4 字节,行 271-304)与 ByteTransDataFormat8(8 字节,行 323-373):

// Core/Transfer/ByteTransformBase.java:271-304(压缩为索引置换表)
protected byte[] ByteTransDataFormat4(byte[] value, int Index) {
    byte[] buffer = new byte[4];
    switch (dataFormat) {
        case ABCD: buffer[0]=value[Index+3]; buffer[1]=value[Index+2]; buffer[2]=value[Index+1]; buffer[3]=value[Index+0]; break;  // 行 275-278
        case BADC: buffer[0]=value[Index+2]; buffer[1]=value[Index+3]; buffer[2]=value[Index+0]; buffer[3]=value[Index+1]; break;  // 行 282-285
        case CDAB: buffer[0]=value[Index+1]; buffer[1]=value[Index+0]; buffer[2]=value[Index+3]; buffer[3]=value[Index+2]; break;  // 行 289-292
        case DCBA: buffer[0]=value[Index+0]; buffer[1]=value[Index+1]; buffer[2]=value[Index+2]; buffer[3]=value[Index+3]; break;  // 行 296-299
    }
    return buffer;
}

8 字节版 ByteTransDataFormat8(行 323-373)是同样的四个 case 扩展到 8 个字节。

调用方式是"先调序,再用小端 Utilities 读"(ByteTransformBase.java:78):return Utilities.getInt(ByteTransDataFormat4(buffer, index), 0);。每转换一个值都要新建一个 4 字节数组;批量版(行 81-87)对每个元素调一次,N 个 int 产生 N 个临时数组。这是明确的 GC 压力来源,但对 PLC 采集这种低频场景通常可接受。

5.3 三个实现类

IByteTransform (351 行接口)
   ↑ implements
ByteTransformBase (394 行)  —— DataFormat 默认 DCBA(行 20)
   ↑ extends
   ├─ RegularByteTransform  (18 行)   —— 什么都没改,纯语义命名
   ├─ ReverseBytesTransform (157 行)  —— 读之前先手工反转每个元素的字节
   └─ ReverseWordTransform  (105 行)  —— 以「字(2字节)」为单位反转

RegularByteTransform 全文只有 18 行,唯一实质内容是 CreateByDateFormat(行 13-17,新建一个同构对象并复制 IsStringReverse)。它的存在纯粹是为了命名对称:Regular(不反转)对 Reverse(反转),功能上等价于 ByteTransformBase。

ReverseBytesTransform(ReverseBytesTransform.java)——在调用 Utilities.getXxx 之前先手工把每个元素的字节倒过来:

public short TransInt16(byte[] buffer, int index) {
    byte[] tmp = new byte[2];
    tmp[0] = buffer[1 + index]; tmp[1] = buffer[0 + index];                  // 行 19-20  先交换
    return Utilities.getShort(tmp, 0);                                       // 行 21  再用小端读
}

TransInt32(行 31-38)先 tmp[0]=buffer[3+index] ... tmp[3]=buffer[0+index] 整体倒序(行 33-36),再调 Utilities.getInt(ByteTransDataFormat4(tmp), 0)(行 37)。先手工倒序,再叠加 DataFormat——两类变换是叠加而非替换的语义。西门子用 ReverseBytesTransform 且 DataFormat 保持默认 DCBA,两次变换叠加的结果恰好正确,这是一个容易踩的坑。

ReverseWordTransform(ReverseWordTransform.java)——以 2 字节(一个"字")为单位两两交换:

private byte[] ReverseBytesByWord(byte[] buffer, int index, int length) {
    byte[] tmp = new byte[length];
    for (int i = 0; i < length; i++)     tmp[i] = buffer[index + i];         // 行 36-38  先复制
    for (int i = 0; i < length / 2; i++) {                                   // 行 40-44  ★ 每两个字节交换
        byte b = tmp[i * 2 + 0]; tmp[i * 2 + 0] = tmp[i * 2 + 1]; tmp[i * 2 + 1] = b;
    }
    return tmp;
}

TransInt16 即 Utilities.getShort(ReverseBytesByWord(buffer, index, 2), 0)(行 55)。这就是 Modbus 的默认字节序(ModbusTcpNet.java:28 设 new ReverseWordTransform()):Modbus 寄存器是大端的 16 位字,但 Utilities.getShort 是小端,所以要把每个字的两个字节换一下。

5.4 三者的默认与使用者

类 默认 DataFormat 变换 典型使用者
RegularByteTransform DCBA(继承) 无 MelsecMcNet.java:35(三菱原生大端 → 不反转)
ReverseBytesTransform DCBA(继承,行 20) 每个元素全字节倒序 + DataFormat SiemensS7Net.java:63(西门子)
ReverseWordTransform ABCD(显式覆盖,行 14) 每个字内两字节交换 ModbusTcpNet.java:28(Modbus)

注意 ReverseWordTransform 的默认 ABCD 与父类 DCBA 不同(ReverseWordTransform.java:14 的 this.setDataFormat(DataFormat.ABCD);)——这是一个继承契约被破坏的例子,子类改了默认的字节序语义,容易让人误判。

5.5 CreateByDateFormat:克隆一个新变换器

IByteTransform 接口(行 349)要求实现 CreateByDateFormat(DataFormat)。ReverseWordTransform.java:100-104、RegularByteTransform.java:13-17 都新建对象,但基类 ByteTransformBase.java:386-388 的实现是 public IByteTransform CreateByDateFormat(DataFormat dataFormat) { return this; }——修改的是自身而不是克隆。

这是一个明确的设计不一致:接口契约看起来是"按新格式创建一个新对象"(名字叫 Create),基类实现却是"返回自己"。若某个类忘了覆写就会静默产生错误行为。


6. Core/Types:OperateResult 家族与 IDataTransfer

6.1 OperateResult 五件套

类 行数 泛型占位
OperateResult 208 无
OperateResultExOne<T> 168 Content
OperateResultExTwo<T1,T2> 177 Content1/Content2
OperateResultExThree<T1,T2,T3> 192 + Content3
OperateResultExFour<T1,T2,T3,T4> 198 + Content4

因为 Java 没有 C# 的"同名泛型重载",作者手抄了 5 遍。核心机制(第 01 篇已列)之外,值得补充的是错误传播:

// Core/Types/OperateResult.java:68-73
public void CopyErrorFromOther(OperateResult result) {
    if (result != null) { ErrorCode = result.ErrorCode; Message = result.Message; }
}
// Core/Types/OperateResultExOne.java:49-53
public static <T> OperateResultExOne<T> CreateFailedResult(OperateResult result) {
    OperateResultExOne<T> resultExOne = new OperateResultExOne<T>();
    resultExOne.CopyErrorFromOther(result);
    return resultExOne;
}

CopyErrorFromOther + CreateFailedResult 组合出了"低层错误原样透传到高层"的能力,且泛型转换时只丢数据不丢错误信息。这是这套设计的实用之处。

代价:全库写满了 if (!result.IsSuccess) return OperateResultExOne.CreateFailedResult(result); 这种三行样板。第 01 篇提到作者为此做了 Then/ThenExOne/...(OperateResult.java:153-204)——但全库对这些方法的调用次数是 0(实测 grep \.Then( / \.ThenExOne( 等,命中 0 处)。这是一套实现了却完全没被采用的 API。

6.2 IDataTransfer:自定义数据类型的串口化契约

// Core/Types/IDataTransfer.java:6-26
public interface IDataTransfer {
    short getReadCount();               // 行 12  读取长度(西门子=字节数,三菱/Modbus=字节数的一半)
    void  ParseSource(byte[] Content);  // 行 19  字节 → 对象
    byte[] ToSource();                  // 行 25  对象 → 字节
}

NetworkDeviceBase.ReadCustomer/WriteCustomer(NetworkDeviceBase.java:60-82)用它,关键片段:

// Core/Net/NetworkBase/NetworkDeviceBase.java:60-82(节选)
try { Content = tClass.newInstance(); }                                            // 行 64  ★ 反射构造(Java 9 已废弃)
catch (Exception ex) { Content = null; }
OperateResultExOne<byte[]> read = Read(address, Content.getReadCount());           // 行 68  ★ Content 可能为 null
if (read.IsSuccess) { Content.ParseSource(read.Content); result.Content = Content; result.IsSuccess = true; }
else { result.ErrorCode = read.ErrorCode; result.Message = read.Message; }         // 行 74-75

这个设计让"一条 PLC 记录(比如一个工单结构体)"能用一个类表达,读写自动序列化。但有问题:

  1. 行 64 的 tClass.newInstance() 在 Java 9 已废弃、Java 11 标记 for-removal,且要求 public 无参构造。
  2. 行 66 把 Content = null 后不报错,行 68 直接 Content.getReadCount() → NPE。
  3. IReadWriteDevice 接口里没有 ReadCustomer,它在 NetworkDeviceBase 上——意味着拿到 IReadWriteNet 引用的调用方看不到这个能力。

6.3 MemoryStream:34 行的 ByteArrayOutputStream 马甲

Core/Types/MemoryStream.java 全文 34 行:内部持有一个 ByteArrayOutputStream(行 33),对外只暴露 WriteByte / Write(byte[]) / Write(byte[],int,int) / ToArray() / getLength()(行 11-31)。纯粹是为了让 C# 的 MemoryStream 直译过来。没有 read/seek/position,也没有 reset——所以它只在"往里写、最后 ToArray"的单向场景可用(比如 ModbusInfo.BuildWriteFileModbusCommand 行 644-656、INetMessage.CheckReceiveDataComplete 的入参)。这是一个"名字叫 Stream 其实不是 Stream"的命名误导。

6.4 HslHelper:读长度的自动分片

Core/Types/HslHelper.java(548 行)里最重要的是 SplitReadLength——把一次超长读取切成多条协议允许的命令(受 Authorization 限制上限,见 ModbusInfo.java:159 与 622 行的 Authorization.asdniasnfaksndiqwhawfskhfaiw() 调用)。西门子的分片入口在 SiemensS7Net.Read(Profinet/Siemens/SiemensS7Net.java:280-292,按 pdu_length 切),三菱的入口在 McHelper.Read(McHelper.java:88-108,按 GetReadWordLength 切)。


7. SoftBasic 与 Utilities:工具层

7.1 两个工具类的分工

类 位置 行数 定位
BasicFramework/SoftBasic BasicFramework/ 1176 字节数组操作:切片、拼接、hex 转换、ASCII 渲染、bool 位操作
Utilities(根包) HslCommunication/ 913 基本类型互转:getShort/getInt/getLong/getFloat/... + 字符串 + 时间

注意:任务描述中提到 Utilities/SoftBasic,本版本实际路径是 BasicFramework/SoftBasic.java(另有一个根包的 Utilities.java)。Core/ 下没有 Utilities 包。

7.2 SoftBasic 高频方法

方法 行号 用途
SpliceArray(byte[]...) 770 变参拼接,一次分配(见 §2.4)
SpliceTwoByteArray(byte[], byte[]) 874 二路拼接(ReceiveByMessage 用它拼帧,NetworkBase.java:329)
BytesArrayRemoveBegin(byte[], int) 978 掐头(剥 MBAP 用)
BytesArrayRemoveLast(byte[], int) 990 去尾(剥 RTU 的 CRC 用)
BytesArraySelectMiddle(byte[], int, int) 1023 取中段
ArrayExpandToLengthEven(byte[]) 236 补到偶数长(写字符串到字设备前用,NetworkDeviceBase.java:262)
ByteToHexString(byte[], char) 471 日志用的 hex 串
BytesReverseByWord(byte[]) 592 按字反转
BoolOnByteIndex(byte, int) 796 取字节的第 n 位
ByteToBoolArray(byte[], int) 842 位展开
BoolArrayToByte(boolean[]) 822 位打包
GetUniqueStringByGuidAndRandom() 446 连接 ID(UUID + random(1000~9999))

7.3 Utilities 的字节序

public static short getShort(byte[] bytes,int index)      // 行 207
public static short getShortReverse(byte[] bytes,int index)  // 行 218  ← 有 Reverse 变体
public static int   getUShort(byte[] bytes,int index)     // 行 229
public static int   getInt(byte[] bytes,int index)        // 行 250
public static long  getUInt(byte[] bytes,int index)       // 行 263
public static float getFloat(byte[] bytes,int index)      // 行 348  Float.intBitsToFloat(getInt(...))
public static double getDouble(byte[] bytes,int index)    // 行 358  Double.longBitsToDouble(getLong(...))
// Utilities.java:250-255
public static int getInt(byte[] bytes,int index) {
    return (0xff & bytes[0 + index]) |                       // 行 251  byte[0] 是最低有效位
            (0xff00 & (bytes[1 + index] << 8)) |
            (0xff0000 & (bytes[2 + index] << 16)) |
            (0xff000000 & (bytes[3 + index] << 24));         // 行 254
}

全部是小端(little-endian),且每个类型都提供了 *Reverse 变体(getShortReverse 行 218、getUShortReverse 行 240、getIntReverse 行 275、getLongReverse 行 331)。这也是为什么 ByteTransformBase 的默认 DataFormat 是 DCBA(= 不做反转,§5.2 已说明)。

Core/Types/BitConverter(41 行)只是 Utilities 的别名层(BitConverter.ToInt32(v,i) → Utilities.getInt(v,i)),纯粹为迁就 C# 的命名习惯。库内两种命名(BitConverter.ToUInt16 与 Utilities.getUShort)混用,例如 MemobusMessage 用 BitConverter.ToUInt16,FinsMessage 用 Utilities.getInt。


8. LogNet 日志模块

LogNet/(6 文件 / 748 行)。

8.1 结构

ILogNet (接口)                      —— 6 个等级方法 + RecordMessage
   ↑ implements
LogNetBase (抽象类/基类)             —— 模板方法:等级方法 → RecordMessage → BeforeSaveToFile
   ↑ 配置
HslMessageDegree (枚举)             —— None / FATAL / ERROR / WARN / INFO / DEBUG
HslMessageItem                      —— 一条日志:id / Degree / ThreadId / Text / Time / KeyWord
LogNetManagment                     —— 两个静态工具:GetDegreeDescription / GetSaveStringFromException
GenerateMode                        —— 文件生成模式

8.2 等级

// LogNet/Core/HslMessageDegree.java:6-31
public enum HslMessageDegree {
    None, FATAL, ERROR, WARN, INFO, DEBUG      // 行 10-30  六级,语义为"记录该等级及以上"
}

LogNetBase 提供 WriteDebug/WriteDescription/WriteInfo/WriteWarn/WriteError/WriteFatal/WriteException 各一对重载(带/不带 keyWord,见 LogNet/Core/LogNetBase.java:67-128),每个方法内部不做等级过滤,一律落到 RecordMessage——等级过滤由具体子类(文件日志实现)负责。

8.3 三个可配置项

// LogNet/Core/LogNetBase.java:56-65
public void BeforeSaveToFile( HslMessageItem message ) { }   // 行 56  ★ 覆写点:落盘前可改内容
public boolean LogThreadID     = true;                        // 行 60  是否记录线程 ID
public boolean LogStxAsciiCode = true;                        // 行 62  文本中的控制符是否转义
public int     HourDeviation   = 0;                           // 行 65  时间偏移(小时)

HourDeviation 是一个很典型的"现场补丁":工控机 BIOS 电池没电导致系统时间跑偏,日志时间戳就不准,于是给了一个手动偏移修正。这种细节只有真做过现场项目的人才会加。

8.4 使用方式

通信类统一持有 public ILogNet LogNet(NetworkBase.java:50),在收发报文前后各打一条 DEBUG(NetworkDoubleBase.java:584-585, 626-627):

if (logNet != null) logNet.WriteDebug(this.toString(), StringResources.Language.Send() + " : " +
        (LogMsgFormatBinary ? SoftBasic.ByteToHexString(sendValue, ' ') : SoftBasic.GetAsciiStringRender(sendValue)));

LogMsgFormatBinary(NetworkDoubleBase.java:52)默认 true,即十六进制输出;对 ASCII 类协议(如 Modbus ASCII、条码枪)可置 false 输出可读文本。这是排障时的救命开关。

评价:LogNet 是这套库里最"正常"的模块——接口小、职责单一、有一个明确的覆写点(BeforeSaveToFile)。缺点是没有 SLF4J/Logback 桥接,你必须自己实现 ILogNet 才能接到自己的日志系统里(未在本版本源码中见到任何 SLF4J 桥接实现)。


设计优点

设计缺点

可借鉴点


上一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比


本文是《工业通信与数据采集》栏目第 13/22 篇 · 上一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比 | 栏目总览 | 下一篇:iot-communication 项目总览与架构