03 · 协议实现详解(上):Modbus 与 S7
本文分析两大 PLC 协议族的实现:Modbus(
protocol/modbus)与 西门子 S7(protocol/s7)。每块包含:支持模式、报文封装类字段与序列化、功能码/指令实现位置、地址解析、批量读写构造、服务端实现,并附真实源码片段(带行号)。三菱 MC 见姊妹篇 协议实现详解(下):三菱 MC 与横向对比。
本文目录
第一部分:Modbus
1.1 文件清单与支持模式
protocol/modbus 共 32 个文件:
protocol/modbus/
├── enums/ EMbFunctionCode(15 功能码+8 异常码) / EMbExceptionCode
├── model/ 通用:MbPdu / MbErrorResponse / MbapHeader
│ TCP:MbTcpRequest|Response RTU:MbRtuRequest|Response ASCII:MbAsciiRequest|Response
│ 8 类功能码的 Request/Response(ReadCoil/DiscreteInput/HoldRegister/InputRegister、
│ WriteSingleCoil/SingleRegister/MultipleCoil/MultipleRegister)
└── service/ ModbusSkeletonAbstract.java (1522 行,抽象骨架 + 全部数据类型 API)
ModbusTcp(168) / ModbusRtuOverTcp(136) / ModbusAsciiOverTcp(162) / ModbusTcpServer(510)
| 模式 | 类 | 说明 | 帧界定 |
|---|---|---|---|
| Modbus TCP | ModbusTcp |
MBAP 头 + PDU | MBAP length 字段 |
| Modbus RTU over TCP | ModbusRtuOverTcp |
地址 + PDU + CRC16 | 无(靠一次 read 拿全) |
| Modbus ASCII over TCP | ModbusAsciiOverTcp |
: + Hex文本 + LRC + \r\n |
: 与 \r\n |
| Modbus RTU(串口) | — | 不支持(源码中未见串口实现,无 rxtx/jssc 依赖) | — |
| Modbus TCP 服务端 | ModbusTcpServer |
支持 8 个功能码 | MBAP length |
1.2 报文封装模型
1.2.1 MBAP 头(7 字节)
protocol/modbus/model/MbapHeader.java:42-100:
42→public class MbapHeader implements IObjectByteArray {
44→ private static final AtomicInteger index = new AtomicInteger();
46→ public static final int BYTE_LENGTH = 7;
54→ private int transactionId; // 事务元标识符,2字节,字节序数 0-1
62→ private int protocolId = 0; // 协议标识符,2字节,字节序数 2-3
70→ private int length = 0; // 长度(含 unitId + PDU),2字节,字节序数 4-5
78→ private int unitId = 0; // 单元标识符,1字节,字节序数 6
序列化(MbapHeader.java:92-100):
93→ public byte[] toByteArray() {
94→ return ByteWriteBuff.newInstance(BYTE_LENGTH)
95→ .putShort(this.transactionId)
96→ .putShort(this.protocolId)
97→ .putShort(this.length)
98→ .putByte(this.unitId)
99→ .getData();
transactionId 用 AtomicInteger 自增并在 65536 处归零(MbapHeader.java:108-115):
109→ int res = index.getAndIncrement();
110→ if (res >= 65536) {
111→ index.set(0);
112→ res = 0;
113→ }
⚠️ 潜在并发问题:归零逻辑是
getAndIncrement()后判断>= 65536再set(0)——非原子的 check-then-act。高并发下可能短暂返回 >65536 的值。影响极小(只是事务号),但属于典型的"伪原子"写法。
1.2.2 三层嵌套结构
MbTcpRequest / MbTcpResponse ← 顶层,implements IObjectByteArray
├── MbapHeader header (7 字节)
└── MbPdu pdu (功能码 + 数据)
├── EMbFunctionCode functionCode (1 字节)
└── 具体子类字段(address / quantity / values ...)
序列化(protocol/modbus/model/MbTcpRequest.java:62-73):
63→ public int byteArrayLength() {
64→ return this.header.byteArrayLength() + this.pdu.byteArrayLength();
65→ }
68→ public byte[] toByteArray() {
69→ return ByteWriteBuff.newInstance(this.byteArrayLength())
70→ .putBytes(this.header.toByteArray())
71→ .putBytes(this.pdu.toByteArray())
72→ .getData();
selfCheck() 回填长度(MbTcpRequest.java:79-87)——length = PDU长度 + 1(unitId):
86→ this.header.setLength(this.pdu.byteArrayLength() + 1);
1.2.3 PDU 基类的"功能码分发工厂"
protocol/modbus/model/MbPdu.java:80-117 是一个按功能码分发的静态工厂——这是 Modbus 模块的核心路由:
81→ ByteReadBuff buff = new ByteReadBuff(data, offset);
82→ byte aByte = buff.getByte();
83→ EMbFunctionCode functionCode = EMbFunctionCode.from(aByte);
88→ switch (functionCode) {
89→ case READ_COIL:
90→ return MbReadCoilResponse.fromBytes(data, offset);
...
105→ case ERROR_READ_COIL:
113→ return MbErrorResponse.fromBytes(data, offset);
有两个工厂方法:fromBytes()(第 80 行)→ 解析成 Response(含 8 个 ERROR_* 分支);fromBytesToRequest()(第 136 行)→ 解析成 Request(无 ERROR_* 分支)。"同名双份 switch"有重复,但语义清晰(请求方不会收到异常码响应)。
1.2.4 RTU / ASCII 封装差异
| 类 | 文件 | 字段 | 校验 | 长度计算 |
|---|---|---|---|---|
MbRtuRequest |
model/MbRtuRequest.java:41 |
unitId(1) + pdu + crc(2) |
CRC16 | 3 + pdu.length |
MbRtuResponse |
model/MbRtuResponse.java:44 |
同上 + checkCrc() |
CRC16 | 同上 |
MbAsciiRequest |
model/MbAsciiRequest.java |
unitId(1) + pdu + lrc(1) |
LRC | 2 + pdu.length |
MbAsciiResponse |
model/MbAsciiResponse.java:42 |
同上 + checkLrc() |
LRC | 同上 |
RTU 的 CRC 由 selfCheck() 计算(MbRtuRequest.java:90-99):
94→ byte[] data = ByteWriteBuff.newInstance(1 + this.pdu.byteArrayLength())
95→ .putByte(this.unitId)
96→ .putBytes(this.pdu.toByteArray())
97→ .getData();
98→ this.crc = CRCUtil.crc16ToByteArray(data);
ASCII 的 LRC 同理(MbAsciiResponse.java:81-90),但 ASCII 实际传输是文本——ModbusAsciiOverTcp.java:89 在外层拼 : 和 \r\n:
89→ String reqStr = ":" + HexUtil.toHexString(req.toByteArray(), "") + "\r\n";
90→ byte[] reqBytes = reqStr.getBytes(StandardCharsets.US_ASCII);
1.3 功能码实现位置
EMbFunctionCode 定义了 15 个功能码 + 8 个异常码(model/../enums/EMbFunctionCode.java:41-110 起):
| 功能码 | 名称 | Request 类 | Response 类 | 骨架层 API 方法 |
|---|---|---|---|---|
| 0x01 | READ_COIL | MbReadCoilRequest |
MbReadCoilResponse |
readCoil / readCoilBytes |
| 0x02 | READ_DISCRETE_INPUT | MbReadDiscreteInputRequest |
同名 Response | readDiscreteInput / readDiscreteInputBytes |
| 0x03 | READ_HOLD_REGISTER | MbReadHoldRegisterRequest |
同名 Response | readHoldRegister + 全部数据类型读 |
| 0x04 | READ_INPUT_REGISTER | MbReadInputRegisterRequest |
同名 Response | readInputRegister |
| 0x05 | WRITE_SINGLE_COIL | MbWriteSingleCoilRequest |
同名 Response | writeCoil(addr, boolean) |
| 0x06 | WRITE_SINGLE_REGISTER | MbWriteSingleRegisterRequest |
同名 Response | writeHoldRegister(addr, int) |
| 0x0F | WRITE_MULTIPLE_COIL | MbWriteMultipleCoilRequest |
同名 Response | writeCoil(addr, List<Boolean>) |
| 0x10 | WRITE_MULTIPLE_REGISTER | MbWriteMultipleRegisterRequest |
同名 Response | writeHoldRegister(addr, byte[]/List<Integer>) |
| 0x14 / 0x16 / 0x17 / 0x2B | 文件记录 / 屏蔽写 / 读写多个 / 设备识别 | — | — | 仅有枚举定义,无实现(源码中未见) |
功能码的"实现位置"是分层的:
- 枚举层:
EMbFunctionCode定义码值与描述; - 报文层:8 个 Request/Response 类各自
toByteArray()/fromBytes(),并在构造器中绑定功能码(如MbReadCoilRequest.java:59:this.functionCode = EMbFunctionCode.READ_COIL;); - 业务层:
ModbusSkeletonAbstract的readCoilBytes/readHoldRegister等方法构造 PDU 并调用抽象方法readModbusData(unitId, reqPdu)(ModbusSkeletonAbstract.java:168); - 传输层:
ModbusTcp/ModbusRtuOverTcp/ModbusAsciiOverTcp各自实现readModbusData(),把 PDU 包进各自的帧格式发出。
这个分层是教科书式的四层解耦,也是本库 Modbus 模块最值得肯定的部分。
以"读线圈"为例的完整链路(ModbusSkeletonAbstract.java:221-235):
228→ BitWriteBuff buff = new BitWriteBuff(quantity);
229→ LoopGroupAlg.loopExecute(quantity, this.maxLengthOfReadCoil, (off, len) -> {
230→ MbReadCoilRequest reqPdu = new MbReadCoilRequest(address + off, len);
231→ MbReadCoilResponse resPdu = (MbReadCoilResponse) this.readModbusData(unitId, reqPdu);
232→ buff.writeBits(resPdu.getCoilStatus(), len);
233→ });
234→ return buff.toByteArray();
注意位数据用 BitWriteBuff 按位累加(跨包拼接位数据),而字节数据用 ByteWriteBuff。详见第四篇 §2。
1.4 三种传输模式的差异
| 维度 | ModbusTcp |
ModbusRtuOverTcp |
ModbusAsciiOverTcp |
|---|---|---|---|
| 泛型绑定 | <MbTcpRequest, MbTcpResponse> |
<MbRtuRequest, MbRtuResponse> |
<MbAsciiRequest, MbAsciiResponse> |
| 读策略 | 两阶段读(MBAP 长度) | 一次读 1024 | 一次读 1024 + 文本剥离 |
| 响应校验 | transactionId 一致 + 功能码一致 | CRC 校验 + 功能码一致 | LRC 校验 + 功能码一致 |
| 额外回调 | — | — | comStringCallback(BiConsumer<String,String>,看文本报文) |
| 异常判定 | ModbusTcp.java:124-142 |
ModbusRtuOverTcp.java:103-121 |
ModbusAsciiOverTcp.java:128-146 |
三者的 checkResult() 都是"三步校验"(ModbusTcp.java:124-142):
125→ if (ack.getPdu() == null) {
126→ throw new ModbusCommException("PDU is null");
127→ }
128→ if (req.getHeader().getTransactionId() != ack.getHeader().getTransactionId()) {
130→ throw new ModbusCommException("The transaction meta identifier Id is inconsistent");
131→ }
132→ if (ack.getPdu().getFunctionCode().getCode() == (req.getPdu().getFunctionCode().getCode() | (byte) 0x80)) {
133→ MbErrorResponse response = (MbErrorResponse) ack.getPdu();
135→ throw new ModbusCommException("The response returns an exception, the exception code: " + ...);
136→ }
| (byte) 0x80 判定"异常响应功能码"是 Modbus 标准做法(正常功能码最高位置 1 即为异常响应)。
1.5 批量读优化分析
结论:本库 Modbus 模块没有"合并相邻地址"的优化。(源码中未见任何按地址排序/合并的逻辑)
它做的是自动分包(把超长请求切成多个符合协议上限的请求),分 7 个上限字段控制(ModbusSkeletonAbstract.java:87-117):
| 字段 | 行 | 默认值 | 说明 |
|---|---|---|---|
maxLengthOfReadCoil |
87 | 2000 | 单次读线圈最大点数(协议上限 0x7D0 = 2000) |
maxLengthOfWriteCoil |
93 | 1968 | 单次写线圈最大点数 |
maxLengthOfReadDiscreteInput |
99 | 2000 | 单次读离散输入 |
maxLengthOfReadHoldRegister |
105 | 125 | 单次读保持寄存器(协议上限 0x7D = 125) |
maxLengthOfWriteHoldRegister |
111 | 123 | 单次写保持寄存器 |
maxLengthOfReadInputRegister |
117 | 125 | 单次读输入寄存器 |
分包通过 LoopGroupAlg.loopExecute 实现(见第四篇 §4):
// ModbusSkeletonAbstract.java:394-400
394→ ByteWriteBuff buff = ByteWriteBuff.newInstance(quantity * 2);
395→ LoopGroupAlg.loopExecute(quantity, this.maxLengthOfReadHoldRegister, (off, len) -> {
396→ MbReadHoldRegisterRequest reqPdu = new MbReadHoldRegisterRequest(address + off, len);
397→ MbReadHoldRegisterResponse resPdu = (MbReadHoldRegisterResponse) this.readModbusData(unitId, reqPdu);
398→ buff.putBytes(resPdu.getRegister());
399→ });
400→ return buff.getData();
源码里留了作者的自我怀疑(ModbusSkeletonAbstract.java:393:// TODO: 实际在slave中测试,没有125的约束,暂时先这么写着),以及第 402-404 行被注释掉的"不分包的原始实现"——说明分包是后来加的。
批判:如果上层要读 10 个不相邻的地址(如 40001、40005、40010...),本库会发 10 次独立请求。对比 S7 的
MultiAddressRead(一次报文读多个不连续地址)和 MC 的readDeviceRandomInWord,Modbus 模块在"多地址批量读"上是三兄弟里最弱的。这既是协议本身的限制(Modbus 只有连续地址批量读),也是本库没做"按区间合并"的结果。
1.6 服务端实现
ModbusTcpServer extends TcpServerBasic(service/ModbusTcpServer.java:61)。数据存储用 4 个容器(ModbusTcpServer.java:67-92):
| 字段 | 行 | 类型 | 默认大小 |
|---|---|---|---|
coils |
73 | List<Boolean> |
2000 |
discreteInputs |
79 | List<Boolean> |
2000 |
inputRegisters |
86 | byte[] |
4000 字节(2000 寄存器) |
holdRegisters |
92 | byte[] |
4000 字节 |
并发控制用 ReadWriteLock(ModbusTcpServer.java:67:private ReadWriteLock rwLock = new ReentrantReadWriteLock();)。
功能码分发(ModbusTcpServer.java:160-194):
161→ MbTcpRequest request = this.readModbusDataFromClient(socket);
164→ switch (request.getPdu().getFunctionCode()) {
165→ case READ_COIL:
166→ response = this.readCoil(request);
167→ break;
189→ default:
190→ EMbFunctionCode errorFunctionCode = EMbFunctionCode.from((byte) (request.getPdu().getFunctionCode().getCode() | ((byte) 0x80)));
191→ response = new MbTcpResponse(request.getHeader(), new MbErrorResponse(errorFunctionCode, EMbExceptionCode.ILLEGAL_FUNCTION));
193→ }
194→ this.write(socket, response.toByteArray());
错误统一转成 Modbus 异常响应而非抛异常(ModbusTcpServer.java:195-199):功能码 |0x80 + SLAVE_DEVICE_FAILURE。这个处理是对的——服务端不该因一个客户端的错误请求而崩掉连接。
⚠️ 注意:类注释明确写了 "ignore unitId"(
ModbusTcpServer.java:53)——服务端忽略单元标识符,所有 unitId 共享同一份数据区。做多从站模拟时不能用它。
第二部分:S7
2.1 S7PLC API 形态
说明:本库中没有名为
S7Client的类(源码中未见)。对等的 API 门面是S7PLC(protocol/s7/service/S7PLC.java:53,1440 行),继承链为S7PLC → PLCNetwork → TcpClientBasic → ICommunicable。
2.1.1 构造与配置
6 个构造重载(S7PLC.java:55/59/63/67/71/75),从无参到 (EPlcType, ip, port, rack, slot, pduLength) 全参数。PLC 型号由 EPlcType 枚举承载:S200 / S200_SMART / S300 / S400 / S1200 / S1500 / SINUMERIK_828D。
2.1.2 API 形态:三档粒度
| 档位 | 示例方法 | 行 | 说明 |
|---|---|---|---|
| 原始 | readRaw(EParamVariableType, count, EArea, dbNumber, byteAddress, bitAddress) |
97 | 完全手工指定请求项 |
| 原始(写) | writeRaw(...) |
585 | 同上 |
| 字节 | readByte(String address, int count) / readByte(String address) |
135 / 147 | 地址字符串 + 字节数 |
| 类型化 | readBoolean / readInt16 / readUInt16 / readInt32 / readUInt32 / readInt64 / readFloat32 / readFloat64 / readString |
158~467 | 单地址 |
| 类型化(多地址) | readInt16(String... address) / readInt16(List<String>) |
206 / 217 | 一次报文读多个地址 |
| 批量 | readMultiByte(MultiAddressRead) / writeMultiData(MultiAddressWrite) |
122 / 723 | 地址包装类 |
| 控制 | hotRestart / coldRestart / plcStop / copyRamToRom / compress |
848~880 | PLC 控制指令 |
| NCK(机床) | readCncId / readCncVersion / readMachinePosition / readToolNumber ... |
914~1088 | 西门子 828D 数控机床 |
| 上传下载 | uploadFile / downloadFile / insert |
PLCNetwork 544/579;S7PLC 891 | MC7 文件块 |
API 形态的关键特征(与 Hsl 对比):
- 返回值裸类型(
short/int/float),失败抛S7CommException; - 多地址读有两种形态:
readInt16(String... addresses)(分散参数)和MultiAddressRead(Builder 风格); MultiAddressRead是一个极简 Builder(service/MultiAddressRead.java:46-66):
46→public class MultiAddressRead {
52→ List<RequestItem> requestItems = new ArrayList<>();
62→ public MultiAddressRead addData(String address, int count) {
63→ this.requestItems.add(AddressUtil.parseByte(address, count));
64→ return this;
65→ }
66→}
2.2 TPKT / COTP 握手构造
S7 通信建立在 ISO-on-TCP(RFC1006)之上,握手分两步:COTP Connection Request → Setup Communication(PDU 协商)。
2.2.1 TPKT(4 字节)
protocol/s7/model/TPKT.java:39-83:
39→public class TPKT implements IObjectByteArray {
41→ public static final int BYTE_LENGTH = 4;
49→ private byte version = 0x03; // 版本号,常量 0x03
57→ private byte reserved = 0x00; // 预留
65→ private int length = 0x0000; // 长度 = payload + version + reserved + length
77→ public byte[] toByteArray() {
78→ return ByteWriteBuff.newInstance(BYTE_LENGTH)
79→ .putByte(this.version)
80→ .putByte(this.reserved)
81→ .putShort(this.length)
82→ .getData();
2.2.2 COTP 连接请求(18 字节常量数组)
这是本文要求的"字节数组常量代码"——protocol/s7/model/COTPConnection.java:171-188:
172→ COTPConnection connection = new COTPConnection();
173→ connection.length = 0x11; // 17,不含 length 字段本身
174→ connection.pduType = EPduType.CONNECT_REQUEST;
177→ connection.flags = (byte) 0x00;
178→ connection.parameterCodeTpduSize = (byte) 0xC0; // 参数代码 TPDU-Size
180→ connection.tpduSize = (byte) 0x0A; // TPDU Size = 2^10 = 1024
181→ connection.parameterCodeSrcTsap = (byte) 0xC1; // 参数代码 SRC-TSAP
... // 183: sourceTsap = local; 184-186: 0xC2 / len=2 / destinationTsap = remote
序列化(COTPConnection.java:144-162)就是逐字段 putByte/putShort,BYTE_LENGTH = 18(第 46 行)。
TSAP 计算(PLCNetwork.java:158-184)——这是 S7 最容易踩坑的地方,作者把实测结论直接写进了注释(第 161 行:经测试:S1200支持0x0100、0x0200、0x0300,S200Smart支持0x0200、0x0300):
160→ int local = 0x0100; // 对应 0xC1(本地 TSAP)
162→ int remote = 0x0300; // 对应 0xC2(远程 TSAP)
163→ switch (this.plcType) {
164→ case S200: local = 0x4D57; remote = 0x4D57; break; // 166-167
169→ case S200_SMART: local = 0x1000; remote = 0x0300; break; // 170,172
174→ case S300: case S400: case S1200: case S1500:
178→ remote += 0x20 * this.rack + this.slot; break;
180→ case SINUMERIK_828D: local = 0x0400; remote = 0x0D04; break; // 181-182
TSAP 规则表(PLCNetwork.java:150-156 的注释):1500/1200/300/400 本地 0x0102、远程 0x0100(1200/1500) 或 0x0102(300) / 0x0103(400);200 本地 0x4d57、远程 0x4d57;200Smart 本地 0x1000、远程 0x0300。
2.2.3 COTP Data(3 字节)
createDefault()(COTPData.java:98-105):length=2、pduType=DT_DATA、tpduNumber=0、lastDataUnit=true。tpduNumber 与 lastDataUnit 共用一个字节,最高位是 lastDataUnit(COTPData.java:68):
68→ .putByte((byte) (BooleanUtil.setBit((byte) 0x00, 7, this.lastDataUnit) | (this.tpduNumber & 0xFF)))
2.2.4 完整握手流程
Client PLC
│ TCP connect (port 102) │
│──────────────────────────────────────────────►│
│ [1] COTP Connection Request │
│ TPKT(4) + COTPConnection(18) │
│──────────────────────────────────────────────►│
│ [2] COTP Connection Confirm │ 校验 pduType==CONNECT_CONFIRM,PLCNetwork:187-190
│◄──────────────────────────────────────────────│
│ [3] Setup Communication │
│ TPKT(4)+COTPData(3)+Header(10)+Param(8) │
│──────────────────────────────────────────────►│
│ [4] Setup Ack(PLC 实际 PDU 长度) │ this.pduLength = ack 值,PLCNetwork:210
│◄──────────────────────────────────────────────│
│ ... 后续 Read/Write Variable 交互 ... │
握手触发点是 doAfterConnected()(PLCNetwork.java:134-140):
136→ this.connectionRequest();
137→ // 存在设置的PDULength != 实际PLC的PDULength,因此以PLC的为准
138→ this.pduLength = this.connectDtData();
2.3 PDU 协商
关键设计:以 PLC 返回的 PDU 长度为准,忽略用户配置(PLCNetwork.java:199-216):
200→ S7Data req = S7Data.createConnectDtData(this.pduLength);
201→ S7Data ack = this.readFromServer(req);
202→ if (ack.getCotp().getPduType() != EPduType.DT_DATA) {
204→ throw new S7CommException("Connection Setup response error");
205→ }
210→ int length = ((SetupComParameter) ack.getParameter()).getPduLength();
211→ if (length <= 0) {
213→ throw new S7CommException("The maximum length of a PDU is less than 0");
214→ }
215→ return length;
PDU 长度的用途——它决定了单次读写的上限,源码注释里有非常详细的实测推算(PLCNetwork.java:50-58):
源码注释里有非常详细的实测推算(PLCNetwork.java:50-58):以 S1200 为例,读时"接收最大 222 = 240 − 18(12 header + 2 param + 4 dataItem)"、"发送最大 216 = 240 − 24";写时"发送最大 212 = 240 − 28"、"接收最大 225 = 240 − 15"。这组常量直接体现在分包算法调用里(PLCNetwork.java:394、469):
394→ List<S7ComGroup> s7ComGroups = S7SequentialGroupAlg.readRecombination(rawNumbers, this.pduLength - 14, 5, 12);
469→ List<S7ComGroup> s7ComGroups = S7SequentialGroupAlg.writeRecombination(rawNumbers, this.pduLength - 12, 17);
2.4 多包读取与自动分包
2.4.1 算法:S7SequentialGroupAlg
protocol/s7/algorithm/S7SequentialGroupAlg.java:101-128:
106→ for (int i = 0; i < src.size(); i++) {
107→ int number = src.get(i); int offset = 0;
109→ while (number > 0) { // 单个请求项可被切成多段,跨组传输
110→ S7ComItem item = new S7ComItem(i, src.get(i), offset, 0, extraSize, threshold);
111→ if (sum + number + item.getExtraSize() > targetSize) {
112→ item.setRipeSize(targetSize - sum - item.getExtraSize()); // 截断到本组剩余容量
114→ } else { item.setRipeSize(number); }
116→ sum += item.getTotalLength(); number -= item.getRipeSize(); offset += item.getRipeSize();
119→ group.add(item);
120→ if (sum + threshold >= targetSize) { // 达到阈值 → 开新组(新报文)
121→ group = new S7ComGroup(); groupList.add(group); sum = 0; // 122-123
writeRecombination()(第 58-85 行)逻辑相同,但无 threshold 参数(用 extraSize 判断切组)。
三个关键概念(见 S7ComItem.java:47-87):
| 字段 | 含义 |
|---|---|
index |
原始数据列表中的索引(第几个请求项) |
rawSize |
该项原始数据大小 |
splitOffset |
该项被分割后的数据偏移量(一个大的请求可被切成多段) |
ripeSize |
分割后本次实际传输的大小 |
extraSize |
每个请求项额外占用的协议字节(读=5,写=17) |
threshold |
阈值下限:getTotalLength() = max(ripeSize + extraSize, threshold) |
算法注释里的示例(S7SequentialGroupAlg.java:90-93):
目标值:226 ,额外数据大小:5,阀值数据大小:12
1, 9, 102, 33, 2, 4, 8, 326, 2, 2, 2, 2, 2, 400, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 99
1, 9, 102, 33, 2, 4, 8, 13| 221| 92, 2, 2, 2, 2, 2, 64|221|115, 2, 2, 2, 2, 2, 2, 2, 2| 2, 2, 2, 99
即:326 这个超长请求被切成 13 | 221 | 92 三段跨组传输,400 被切成 64 | 221 | 115。这是"S7 多包读"的真正实现:不只是"一个请求项一包",而是单个超大请求项也能跨包切分。
2.4.2 组包执行
PLCNetwork.java:379-426:
385→ List<Integer> rawNumbers = requestItems.stream().map(RequestItem::getCount).collect(Collectors.toList());
387→ List<DataItem> resultList = requestItems.stream().map(x -> DataItem.createReq(new byte[x.getByteCount()], ...)).collect(...);
394→ List<S7ComGroup> s7ComGroups = S7SequentialGroupAlg.readRecombination(rawNumbers, this.pduLength - 14, 5, 12);
399→ List<RequestItem> newRequestItems = comItemList.stream().map(i -> {
401→ item.setCount(i.getRipeSize()); // 本次只传这一段
402→ item.setByteAddress(item.getByteAddress() + i.getSplitOffset());
407→ S7Data req = S7Data.createReadRequest(newRequestItems);
408→ S7Data ack = this.readFromServer(req);
413→ for (int i = 0; i < comItemList.size(); i++) { // 拼回结果
417→ System.arraycopy(src, 0, des, comItem.getSplitOffset(), src.length); // 415-416
关键点:① 先按原始大小预分配 resultList(第 387 行);② 每组发一个请求,组内每项可能是某原始请求的一段;③ 收完按 splitOffset 拼回。
这就是"S7 多包读取"的完整闭环——对外表现为一次 readS7Data(List<RequestItem>),内部自动拆成 N 个报文,再把结果拼回去。
写路径对称(PLCNetwork.java:457-497),只是 writeRecombination(rawNumbers, pduLength - 12, 17)(extraSize=17,无 threshold)。
2.4.3 响应校验
PLCNetwork.java:325-366 的 checkPostedCom(),四层校验:
| 校验 | 行 | 异常 |
|---|---|---|
errorClass == null → 未知异常 |
331 | S7CommException |
errorClass != NO_ERROR |
335 | 带错误类型 + 错误码描述 |
pduReference 请求/响应一致 |
341 | "PDU 引用编号不一致" |
每个 ReturnItem.returnCode == SUCCESS |
360-364 | 带地址信息的异常 |
第 3 项(PDU 引用编号)是防止响应错位的关键——pduReference 由 Header.getNewPduNumber() 自增(Header.java:88-95),与 Modbus 的 transactionId 作用相同。
2.5 地址解析
protocol/s7/utils/AddressUtil.java —— 一个纯字符串解析工具,支持格式见类注释(AddressUtil.java:37-41):
DB1.0.1、DB1.1、DB100.DBX0.0、DB100.DBB5、DB100.DBW6
M1.1、M1、MB1、MW1、MD1
V1.1、V1、VB100、VW100、VD100
I0.1、I0、IB1、IW1、ID1
Q0.1、Q0、QB1、QW1、QD1
核心解析(AddressUtil.java:83-106):
91→ address = address.toUpperCase();
92→ String[] addList = address.split("\\.");
94→ RequestItem item = new RequestItem();
95→ item.setVariableType(variableType);
96→ item.setCount(count);
97→ item.setArea(parseArea(addList));
98→ item.setDbNumber(parseDbNumber(addList));
99→ item.setByteAddress(parseByteAddress(addList));
100→ item.setBitAddress(parseBitAddress(addList, variableType));
101→ if (item.getBitAddress() > 7) {
103→ throw new IllegalArgumentException("...the bit index can only be [0-7]");
104→ }
区域映射(AddressUtil.java:115-135):
| 前缀 | EArea |
备注 |
|---|---|---|
I |
INPUTS |
输入映像区 |
Q |
OUTPUTS |
输出映像区 |
M |
FLAGS |
位存储器 |
D |
DATA_BLOCKS |
DB 块,parseDbNumber 从 DB100 中提取 100 |
V |
DATA_BLOCKS |
S7-200 Smart 的 V 区 = DB1.X(AddressUtil.java:125,149-150) |
T |
S7_TIMERS |
定时器 |
C |
S7_COUNTERS |
计数器 |
DB 与 非 DB 的地址/位索引解析有差异(AddressUtil.java:163-188):
163→ private static int parseByteAddress(String[] addList) {
164→ switch (addList[0].substring(0, 1)) {
166→ case "D": return addList.length >= 2 ? extractNumber(addList[1]) : 0; // DB100.5 → 5
168→ default: return extractNumber(addList[0]); // M10 → 10
223→ public static int extractNumber(String src) {
224→ String number = Pattern.compile("\\D").matcher(src).replaceAll("").trim();
225→ return Integer.parseInt(number);
⚠️ 脆弱点:
extractNumber("")会抛NumberFormatException(空字符串parseInt失败),而非友好的地址错误提示;- 只取首字符判断区域(
addList[0].substring(0,1)),所以DB100与D100等价,但DBX这种写法(DB100.DBX0.0)的第二段DBX0会被extractNumber剥成0——碰巧正确,属于"能用但不严谨";Pattern.compile("\\D")在每次调用时重建正则(未提取为 static final),高频解析有性能损耗。
2.5.1 请求项的字节编码
protocol/s7/model/RequestItem.java:115-131,注意地址是按位编码的:
117→ .putByte(this.specificationType) // 0x12
118→ .putByte(this.lengthOfFollowing) // 0x0A
119→ .putByte(this.syntaxId.getCode()) // S7ANY
121→ .putShort(this.count) // 120-123: variableType / dbNumber / area
124→ // 地址只有3个字节,因此只取后面的3字节,第一个字节舍弃
125→ if (this.area == EArea.S7_COUNTERS || this.area == EArea.S7_TIMERS) {
127→ buff.putBytes(IntegerUtil.toByteArray(this.byteAddress << 2), 1); // Address(bit) = byte*4
130→ } else { buff.putBytes(IntegerUtil.toByteArray((this.byteAddress << 3) + this.bitAddress), 1); } // byte*8+bit
定时器/计数器用 ×4,其余用 ×8 + bit——这是 S7 协议的规定,实现正确。
2.6 数据类型支持与序列化器
2.6.1 支持的数据类型
EDataType 枚举(common/enums/EDataType.java:39-82)共 14 个:BOOL(1) / BYTE(1) / UINT16·INT16(2) / UINT32·INT32(4) / INT64(8) / FLOAT32(4) / FLOAT64(8) / STRING(1),另有 4 个 S7 专用:TIME(4→long)、DATE(2→LocalDate)、TIME_OF_DAY(4→LocalTime)、DTL(12→LocalDateTime)。
S7PLC 对应方法:readTime(503)、readDate(514)、readTimeOfDay(526)、readDTL(538)。UINT32/INT32/INT64/FLOAT32/FLOAT64 受 EByteBuffFormat 字序影响(见第四篇 §1.3)。
2.6.2 @S7Variable 注解序列化
protocol/s7/serializer/S7Variable.java:39-67:
39→public @interface S7Variable {
47→ String address() default ""; // 如 "DB1.0"、"M100.1"
56→ EDataType type() default EDataType.BYTE;
65→ int count() default 1; // 除 BYTE 和 STRING 外必须为 1
S7Serializer.read(Class<T>) 三步走(S7Serializer.java:216-220):
217→ List<S7ParseData> s7ParseDataList = this.parseBean(targetClass); // ① 反射扫描 @S7Variable
218→ this.readDataByCondition(s7ParseDataList); // ② 一次读回全部
219→ return this.fillData(targetClass, s7ParseDataList); // ③ 填充字段
第 ② 步是性能关键——把所有字段的请求项合成一个 List,走 readS7Data() 一次读回(含自动分包)(S7Serializer.java:168-169):
168→ List<RequestItem> requestItems = s7ParseDataList.stream().map(S7ParseData::getRequestItem).collect(Collectors.toList());
169→ List<DataItem> dataItems = this.s7PLC.readS7Data(requestItems);
STRING 类型的坑处理得很好(S7Serializer.java:199-207):
200→ RequestItem requestItem = AddressUtil.parseByte(p.getAddress(), 1 + p.getCount() * p.getDataType().getByteLength());
201→ // 为什么字节索引+1,为了避免修改PLC中string[60]类型的第一个字节数据,该数据为字符串的允许最大长度
204→ int offset = this.s7PLC.getPlcType() == EPlcType.S200_SMART ? 0 : 1;
205→ requestItem.setByteAddress(requestItem.getByteAddress() + offset);
即:非 S200Smart 的 PLC,字符串首字节是"最大长度",第二个字节才是"实际长度",读时要跳过 1 字节;S200Smart 不用跳。这是很典型的现场经验,能看出作者真机测过。
⚠️ 默认字符集是 GB2312(
S7Serializer.java:67:this.charsets = Charset.forName("GB2312");),而ByteArraySerializer默认是US_ASCII(ByteArraySerializer.java:62)——两处默认值不一致,跨模块使用需注意。
2.6.3 S7 服务端
S7PLCServer extends TcpServerBasic(service/S7PLCServer.java:57),数据区是 HashMap<String, byte[]>(S7PLCServer.java:75),默认建 6 个区各 64KB(S7PLCServer.java:83-88):DB1 / M / I / Q / T / C。
握手校验重写了 checkHandshake(S7PLCServer.java:126-150),完整复刻 COTP 连接请求 + Setup 两步。并发用 ReentrantLock(dataMap 结构变更)+ ReadWriteLock(数据读写)双锁(S7PLCServer.java:63,69)。
设计优点 / 缺点 / 可借鉴点(Modbus + S7)
✅ 设计优点
- Modbus 的四层解耦(枚举 → 报文 → 骨架 → 传输):
ModbusSkeletonAbstract<T,R>用泛型 + 3 个抽象方法复用了全部数据类型 API,TCP/RTU/ASCII 三个子类各只需 ~130 行。全库最成功的抽象。 - S7 的分包算法真正解决"单请求项超长":
S7SequentialGroupAlg能把 400 字节的请求切成64|221|115跨组传输并拼回,比很多同类库"单个请求项不能超 PDU"的限制更强。 selfCheck()统一回填长度/校验码:MBAPlength、CRC16、LRC 都由它算,避免"改 body 忘改 header"。- 实测结论入库:TSAP 取值(
PLCNetwork.java:161)、PDU 大小推算(PLCNetwork.java:50-58)、字符串首字节偏移(S7Serializer.java:201-203)——作者把真机调试结论写进了注释与代码,这是协议库最有价值的部分。 MbPdu.fromBytes()按功能码分发:请求/响应两套工厂,异常码|0x80判定符合 Modbus 标准。- 服务端错误不抛异常:
ModbusTcpServer把异常转成 Modbus 异常响应(|0x80+ 异常码),单个客户端的错误不影响连接。 - S7 服务端双锁分离:
ReentrantLock保护dataMap结构变更,ReadWriteLock保护数据读写,读多写少场景下并发度更高。
❌ 设计缺点
- 命名与常见预期不符:
S7Client/McClient在源码中不存在(实际是S7PLC/McPLC),新用户容易找不到入口。 - Modbus 无地址合并:读 10 个不相邻地址 = 10 次往返,是三兄弟里批量能力最弱的。
MbapHeader.getNewNumber()归零非原子(MbapHeader.java:108-115):getAndIncrement()后再判断再set(0),高并发下可能短暂返回 >65536 的值。- 地址解析鲁棒性不足:
AddressUtil.extractNumber("")会抛NumberFormatException而非友好提示;Pattern.compile("\\D")每次调用重建正则(未 static final)。 DBX写法靠"碰巧"正确:DB100.DBX0.0的第二段DBX0被extractNumber剥成0——能用但不严谨。- 字符集默认值不一致:
S7Serializer默认 GB2312(S7Serializer.java:67),ByteArraySerializer默认 US_ASCII(ByteArraySerializer.java:62)。 - 多处 TODO / 待确认:
ModbusSkeletonAbstract.java:393(125 上限)、EMcSeries的 IQ_R 帧类型——说明这些点未经充分实测。 - 功能码 0x14/0x16/0x17/0x2B 只有枚举无实现(源码中未见),属于"半拉子"枚举。
💡 可借鉴点
| 做法 | 借鉴到 |
|---|---|
ModbusSkeletonAbstract<T,R> 泛型骨架 + 3 个抽象方法 |
同一协议多种传输变体(TCP/RTU/ASCII/UDP) |
selfCheck() 回填长度/校验码 |
任何"header 里有 body 长度"的协议 |
S7SequentialGroupAlg 的 extraSize + threshold 双参数模型 |
需要按协议开销精确分包的场景 |
MbPdu 静态工厂按功能码分发(请求/响应两套) |
一字节类型字段决定后续结构的协议 |
| 把真机实测结论写进注释(TSAP、PDU 推算、字符串首字节) | 协议类库的文档价值核心 |
| 服务端把异常转成协议错误响应而非抛异常 | 服务端健壮性 |
ReentrantLock + ReadWriteLock 双锁分工 |
读多写少的共享数据区 |
MultiAddressRead 极简 Builder + addData 链式 |
批量读地址收集 |
下一篇:协议实现详解(下):三菱 MC 与横向对比 —— 三菱 MC(Melsec)协议实现 + 三大协议横向对比。
本文是《工业通信与数据采集》栏目第 16/22 篇 · 上一篇:网络层与粘包处理 | 栏目总览 | 下一篇:协议实现详解(下):三菱 MC 与横向对比