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 文件记录 / 屏蔽写 / 读写多个 / 设备识别 — — 仅有枚举定义,无实现(源码中未见)

功能码的"实现位置"是分层的:

  1. 枚举层:EMbFunctionCode 定义码值与描述;
  2. 报文层:8 个 Request/Response 类各自 toByteArray() / fromBytes(),并在构造器中绑定功能码(如 MbReadCoilRequest.java:59:this.functionCode = EMbFunctionCode.READ_COIL;);
  3. 业务层:ModbusSkeletonAbstract 的 readCoilBytes / readHoldRegister 等方法构造 PDU 并调用抽象方法 readModbusData(unitId, reqPdu)(ModbusSkeletonAbstract.java:168);
  4. 传输层: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 对比):

  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);

⚠️ 脆弱点:

  1. extractNumber("") 会抛 NumberFormatException(空字符串 parseInt 失败),而非友好的地址错误提示;
  2. 只取首字符判断区域(addList[0].substring(0,1)),所以 DB100 与 D100 等价,但 DBX 这种写法(DB100.DBX0.0)的第二段 DBX0 会被 extractNumber 剥成 0——碰巧正确,属于"能用但不严谨";
  3. 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)

✅ 设计优点

  1. Modbus 的四层解耦(枚举 → 报文 → 骨架 → 传输):ModbusSkeletonAbstract<T,R> 用泛型 + 3 个抽象方法复用了全部数据类型 API,TCP/RTU/ASCII 三个子类各只需 ~130 行。全库最成功的抽象。
  2. S7 的分包算法真正解决"单请求项超长":S7SequentialGroupAlg 能把 400 字节的请求切成 64|221|115 跨组传输并拼回,比很多同类库"单个请求项不能超 PDU"的限制更强。
  3. selfCheck() 统一回填长度/校验码:MBAP length、CRC16、LRC 都由它算,避免"改 body 忘改 header"。
  4. 实测结论入库:TSAP 取值(PLCNetwork.java:161)、PDU 大小推算(PLCNetwork.java:50-58)、字符串首字节偏移(S7Serializer.java:201-203)——作者把真机调试结论写进了注释与代码,这是协议库最有价值的部分。
  5. MbPdu.fromBytes() 按功能码分发:请求/响应两套工厂,异常码 |0x80 判定符合 Modbus 标准。
  6. 服务端错误不抛异常:ModbusTcpServer 把异常转成 Modbus 异常响应(|0x80 + 异常码),单个客户端的错误不影响连接。
  7. S7 服务端双锁分离:ReentrantLock 保护 dataMap 结构变更,ReadWriteLock 保护数据读写,读多写少场景下并发度更高。

❌ 设计缺点

  1. 命名与常见预期不符:S7Client / McClient 在源码中不存在(实际是 S7PLC / McPLC),新用户容易找不到入口。
  2. Modbus 无地址合并:读 10 个不相邻地址 = 10 次往返,是三兄弟里批量能力最弱的。
  3. MbapHeader.getNewNumber() 归零非原子(MbapHeader.java:108-115):getAndIncrement() 后再判断再 set(0),高并发下可能短暂返回 >65536 的值。
  4. 地址解析鲁棒性不足:AddressUtil.extractNumber("") 会抛 NumberFormatException 而非友好提示;Pattern.compile("\\D") 每次调用重建正则(未 static final)。
  5. DBX 写法靠"碰巧"正确:DB100.DBX0.0 的第二段 DBX0 被 extractNumber 剥成 0——能用但不严谨。
  6. 字符集默认值不一致:S7Serializer 默认 GB2312(S7Serializer.java:67),ByteArraySerializer 默认 US_ASCII(ByteArraySerializer.java:62)。
  7. 多处 TODO / 待确认:ModbusSkeletonAbstract.java:393(125 上限)、EMcSeries 的 IQ_R 帧类型——说明这些点未经充分实测。
  8. 功能码 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 与横向对比