04 · 协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer
本篇定位:从字节流的另一端看问题。上半篇对比三个主力协议包(ModBus、Siemens、Melsec)的类清单、报文构造风格、地址解析与错误码提取;下半篇讲
Core/Transfer(DataFormat四字节序的实现原理)、Core/Types(OperateResult家族)、以及SoftBasic/LogNet两个通用底座。
目录
- 1. 三个协议包的类清单与职责
- 2. 报文构造风格:四种并存
- 3. 地址解析三方对比
- 4. 错误码提取三方对比
- 5. Core/Transfer:字节序的四种实现
- 6. Core/Types:OperateResult 家族与 IDataTransfer
- 7. SoftBasic 与 Utilities:工具层
- 8. 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 里。结果是:
MelsecMcNet/MelsecMcAsciiNet/MelsecMcUdp/MelsecMcRNet复用同一份McHelper.Read(McHelper.java:51-111)。SiemensS7Net一个文件 1397 行,无法被同族复用。
这是三个包里架构质量差异最大的一点: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 记录(比如一个工单结构体)"能用一个类表达,读写自动序列化。但有问题:
- 行 64 的
tClass.newInstance()在 Java 9 已废弃、Java 11 标记 for-removal,且要求 public 无参构造。 - 行 66 把
Content = null后不报错,行 68 直接Content.getReadCount()→ NPE。 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 桥接实现)。
设计优点
DataFormat四顺序的抽象把"字节序"这个工业现场最容易出错的问题,收敛成一个枚举 + 两个 switch。现场遇到字节序妖怪设备,setDataFormat(DataFormat.CDAB)一行搞定,不用改任何通信代码。setByteTransform可热替换:把字节序从类型系统里拿出来放到对象属性上,是应对"同一型号不同固件字节序不同"的正确做法。- Modbus 的"富地址"语法(
s=2;x=3;100):把站号/功能码/写功能码塞进地址串,一条Read就能表达跨站、非对称读写。 MelsecMcDataType四元组(code/type/asciiCode/fromBase):让二进制帧与 ASCII 帧共用同一份地址解析结果,这是三个协议里最好的地址建模。SpliceArray(变参):一次分配、N 次System.arraycopy、顺手处理 null,是四报文构造风格里最优的。CopyErrorFromOther+CreateFailedResult让错误信息在泛型转换中原样透传,只丢数据不丢诊断信息。LogNet.BeforeSaveToFile提供了一个干净的落盘前拦截点;HourDeviation这种细节体现出真实的现场经验。
设计缺点
- 报文构造四种风格并存(定长数组 /
ArrayList<Byte>装箱 /MemoryStream/SpliceArray),没有统一约定。 - 重复调用 getter 造成大量临时数组:
McAsciiHelper.java:36-41同一个方法调 6 次;ModbusInfo.PackCommandToTcp的 4 次getBytes;Core/IMessage包内 16 处* 256手写字节序。逐帧高频调用下是确定的 GC 压力(具体损耗需实测确认)。 S7AddressData.ParseFrom的 8 段复制粘贴瀑布 if-else,是库内最大的重复块之一。ReverseWordTransform把默认DataFormat从DCBA改成了ABCD(ReverseWordTransform.java:14),破坏了继承契约。ByteTransformBase.CreateByDateFormat返回this而非克隆(ByteTransformBase.java:386-388),与接口名(Create)和三个子类的实现语义都不一致。DataFormat变换是"叠加"而非"替换":ReverseBytesTransform.TransInt32(行 31-38)先手工倒序再套DataFormat,语义不直观,容易误用。ReadCustomer用tClass.newInstance()(NetworkDeviceBase.java:64),Java 9 已废弃;且构造失败时赋 null 后不检查,行 68 直接 NPE。MemoryStream名不副实(无 read/seek/reset),是 C# 直译的命名误导。BitConverter与Utilities两套等价 API 混用。- 西门子包缺少
IReadWriteS7能力接口,读写逻辑全部内联在 1397 行的SiemensS7Net里,无法在同族复用;而 Modbus/Melsec 都做了接口抽象。 SiemensS7Net.Write(address, boolean[])是"读-改-写"(行 549-567),非原子。虽然 pipe 锁能保证本地串行,但两台 PLC 或上位机同时访问同一字节时会丢更新。这是安全上的重要提示。
可借鉴点
- 把字节序做成运行时可配的枚举,而不是编译期类型:你的代码如果也要对接多个品牌的设备,这个模式几乎必然要用到。
MelsecMcDataType四元组:把"同一个逻辑对象在多种编码下的表示"集中在一个类里,避免每加一种帧格式就要重写一遍地址表。SpliceArray的一次分配写法:先算总长再new,比ArrayList<Byte>累加省掉所有装箱。这是 Java 里拼字节数组的标准姿势。CopyErrorFromOther的错误透传模式:在"返回码而非异常"的体系里,能保证穿越 N 层泛型转换后诊断信息不丢失。LogNet.BeforeSaveToFile这种"落盘前拦截"钩子:让日志脱敏、采样、加字段都可以在不改框架的前提下实现,成本极低,价值很高。HourDeviation这类"为现场脏环境预留的修正旋钮":做工业软件时,总会有一些理论上不该存在但工程上必须有参数——预留它比事后打补丁体面得多。
上一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比
本文是《工业通信与数据采集》栏目第 13/22 篇 · 上一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比 | 栏目总览 | 下一篇:iot-communication 项目总览与架构