56 KiB
Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
Siemens S7 PLCs do not speak Modbus TCP natively at the OS/firmware level. Every
S7 Modbus-TCP-server deployment is either (a) the MB_SERVER library block
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
ET 200SP), or (b) the MODBUSCP function block running on a separate
communication processor (CP 343-1 / CP 343-1 Lean on S7-300, CP 443-1 on
S7-400), or (c) the MODBUSPN block on an S7-1500 PN port via a licensed
library. That means the quirks a Modbus client has to cope with are as much
"this is how the user's PLC programmer wired the library block up" as "this is
how the firmware behaves" — the byte-order and coil-mapping rules aren't
hard-wired into silicon like they are on a DL260. This document catalogues the
behaviours a driver has to handle across the supported model/CP variants, cites
primary sources, and names the ModbusPal integration test we'd write for each
(convention from docs/v2/modbus-test-plan.md: S7_<model>_<behavior>).
Model / CP Capability Matrix
| PLC family | Modbus TCP server mechanism | Modbus TCP client mechanism | License required? | Typical port 502 source |
|---|---|---|---|---|
| S7-1200 (V4.0+) | MB_SERVER on integrated PN port |
MB_CLIENT |
No (in TIA Portal) | CPU's onboard Ethernet [1][2] |
| S7-1500 (all) | MB_SERVER on integrated PN port |
MB_CLIENT |
No (in TIA Portal) | CPU's onboard Ethernet [1][3] |
| S7-1500 + CP 1543-1 | MB_SERVER on CP's IP |
MB_CLIENT |
No | Separate CP IP address [1] |
| ET 200SP CPU (1510SP, 1512SP) | MB_SERVER on PN port |
MB_CLIENT |
No | CPU's onboard Ethernet [3] |
| S7-300 + CP 343-1 / CP 343-1 Lean | MODBUSCP (FB MODBUSCP, instance DB per connection) |
Same FB, client mode | Yes — 2XV9450-1MB00 per CP | CP's Ethernet port [4][5] |
| S7-400 + CP 443-1 | MODBUSCP |
MODBUSCP client mode |
Yes — 2XV9450-1MB00 per CP | CP's Ethernet port [4] |
| S7-400H + CP 443-1 (redundant H) | MODBUSCP_REDUNDANT / paired FBs |
Not typical | Yes | Paired CPs in H-system [6] |
| S7-300 / S7-400 CPU PN (e.g. CPU 315-2 PN/DP) | MODBUSPN library |
MODBUSPN client mode |
Yes — Modbus-TCP PN CPU lib | CPU's PN port [7] |
| "CP 343-1 Lean" | Server only (no client mode supported by Lean) | — | Yes, but with restrictions | CP's Ethernet port [4][5] |
- CP 343-1 Lean is server-only. It can host
MODBUSCPin server mode only; client calls return an immediate error. A surprising number of "Lean + client doesn't work" forum posts trace back to this [5]. - Pure OPC UA / PROFINET CPs (CP 1542SP-1, CP 1543-1) support Modbus TCP on
S7-1500 via the same
MB_SERVER/MB_CLIENTinstructions by passing the CP'shw_identifier. There is no separate "Modbus CP" license needed on S7-1500, unlike S7-300/400 [1]. - No S7 Modbus server supports function codes 20/21 (file records),
22 (mask write), 23 (read-write multiple), or 43 (device identification).
Sending any of these returns exception
01(Illegal Function) on every S7 variant [1][4]. Our driver must not negotiate FC23 as a "bulk-read optimization" when the profile is S7.
Test names:
S7_1200_MBSERVER_Loads_OB1_Cyclic,
S7_CP343_Lean_Client_Mode_Rejected,
S7_All_FC23_Returns_IllegalFunction.
Address / DB Mapping
S7 Modbus servers do not auto-expose PLC memory — the PLC programmer has to
wire one area per Modbus table to a DB or process-image region. This is the
single biggest difference vs. DL205/Modicon/etc., where the memory map is
fixed at the factory. Our driver must therefore be tolerant of "the same
40001 means completely different things on two S7-1200s on the same site."
S7-1200 / S7-1500 MB_SERVER
The MB_SERVER instance exposes four Modbus tables to each connected client;
each table's backing storage is a per-block parameter [1][8]:
| Modbus table | FCs | Backing parameter | Default / typical backing |
|---|---|---|---|
| Coils (0x) | FC01, FC05, FC15 | implicit — Q process image | %Q0.0–%Q1023.7 (→ coil addresses 0–8191) [1][9] |
| Discrete Inputs (1x) | FC02 | implicit — I process image | %I0.0–%I1023.7 (→ discrete addresses 0–8191) [1][9] |
| Input Registers (3x) | FC04 | implicit — M memory or DB (version-dependent) | Some firmware routes FC04 through the same MB_HOLD_REG buffer [1][8] |
| Holding Registers (4x) | FC03, FC06, FC16 | MB_HOLD_REG pointer |
User DB (e.g. DB10.DBW0) or %MW area [1][2][8] |
MB_HOLD_REGis a pointer (VARIANT / ANY) into a user-defined DB whose first byte is holding-register 0 (40001in 1-based Modicon form). Byte offset 2 is register 1, byte offset 4 is register 2, etc. [1][2].- The DB must have "Optimized block access" UNCHECKED. Optimized DBs let
the compiler reorder fields for alignment; Modbus requires fixed byte
offsets. With optimized access on, the compiler accepts the project but
MB_SERVERreturns STATUS0x8383(misaligned access) or silently reads zeros [8][10][11]. This is the #1 support-forum complaint. - FC01/FC02/FC05/FC15 hit the Q and I process images directly — not the
MB_HOLD_REGDB. Coil address 0 =%Q0.0, coil 1 =%Q0.1, coil 8 =%Q1.0. The S7-1200 system manual publishes this mapping as00001 → Q0.0through09999 → Q1023.7and10001 → I0.0through19999 → I1023.7in 1-based form; on the wire (0-based) that's coils 0-8191 and discrete inputs 0-8191 [9]. %Mmarkers are NOT automatically exposed. To expose%Mover Modbus the programmer must either (a) copy%Mto theMB_HOLD_REGDB each scan, or (b) define an Array[0..n] of Bool inside that DB and copy bits in/out of%M. Siemens has no "MB_COIL_REG" parameter analogous toMB_HOLD_REG— this confuses users migrating from Schneider [9][12].- Bit ordering within a Modbus holding register sourced from an
Array of Bool: S7 stores bool[0] atDBX0.0which is bit 0 of byte 0 which is the low byte, low bit of Modbus register40001. A naive client that reads register40001and masks0x0001gets bool[0]. A client that masks0x8000gets bool[15] because the high byte of the Modbus register is the second byte of the DB. Siemens programmers routinely get this wrong in the DB-via-DBX form;Array[0..n] of Boolis the recommended layout because it aligns naturally [12][13].
S7-300/400 + CP 343-1 / CP 443-1 MODBUSCP
Different paradigm: per-connection parameter DB (template
MODBUS_PARAM_CP) declares a table of up to 8 register-area mappings. Each
mapping is a tuple (data_type, DB#, start_offset, length) where data_type
picks the Modbus table [4]:
B#16#1= CoilsB#16#2= Discrete InputsB#16#3= Holding RegistersB#16#4= Input Registers
The holding_register_start and analogous coils_start parameters declare
which Modbus address range the CP will serve, and the DB pointers say
where in S7 memory that range lives [4][14]. Unlike MB_SERVER, the CP does
not reach into %Q/%I directly — everything goes through a DB. If an
address outside the declared ranges is requested, the CP returns exception
02 (Illegal Data Address) [4].
Test names:
S7_1200_FC03_Reg0_Reads_DB10_DBW0,
S7_1200_Optimized_DB_Returns_0x8383_MisalignedAccess,
S7_1200_FC01_Coil0_Reads_Q0_0,
S7_CP343_FC03_Outside_ParamBlock_Range_Returns_IllegalDataAddress.
Data Types and Byte Order
Siemens CPUs store scalars big-endian internally ("Motorola format"), which
is the same byte order Modbus specifies inside each register. So for 16-bit
values (Int, Word, UInt) the on-the-wire layout is straightforward
AB — high byte of the PLC value in the high byte of the Modbus register
[15][16]. No byte-swap trap for 16-bit types.
The trap is 32-bit types (DInt, DWord, Real). Here's what actually
happens across the S7 family:
S7-1200 / S7-1500 MB_SERVER
- The backing DB stores 32-bit values in big-endian byte order, high word
first — i.e.
ABCDwhen viewed as two consecutive Modbus registers. ARealatDB10.DBD0with value0x12345678reads over Modbus as register 0 =0x1234, register 1 =0x5678[15][16][17]. - This is
ABCD, notCDAB. Clients that hard-code CDAB (common default for meters and VFDs) will get wildly wrong floats. Configure the S7 profile withWordOrder = ABCD(aka "big-endian word + big-endian byte" aka "high-word first") [15][17]. MB_SERVERdoes not swap. It's a direct memcpy from the DB bytes to the Modbus payload. Whatever byte order the ladder programmer stored into the DB is what the client receives [17]. This means a programmer who usedMOVE_BLKfrom two separateWords intoDBDwith the "wrong" order can produceCDABwithout realising.Realis IEEE 754 single-precision — unambiguous, no BCD trap like on DL series [15].- Strings: S7
String[n]has a 2-byte header (max length, current length) before the character bytes. A client reading a string over Modbus gets the header in the first register and then the characters two-per-register in high-byte-first order.WStringis UTF-16 and the header is 4 bytes [18]. Our driver's string decoder must expose the "skip header" option for S7 profile.
S7-300/400 MODBUSCP (CP 343-1 / CP 443-1)
- The CP writes the exact DB bytes onto the wire — again
ABCDif the DB storesDInt/Realin native Siemens order [4]. MODBUSCPhas nodata_typebyte-swap knob. (Thedata_typeparameter names the Modbus table, not the byte order — see the Address Mapping section.) If the other end of the link expectsCDAB, the programmer has to swap words in ladder before writing the DB [4][14].
Operator-reported oddity
- Some S7 drivers (Kepware's "Siemens TCP/IP Ethernet" driver, Ignition's
"Siemens S7" driver) expose a per-tag
Float Byte Orderwith optionsABCD/CDAB/BADC/DCBAbecause end-users have encountered every permutation in the field — not because the PLC natively swaps, but because ladder programmers have historically stored floats every which way [19]. Our S7 Modbus profile should default toABCDbut expose a per-tag override. - Unconfirmed rumour: that S7-1500 firmware V2.0+ reverses float byte
order for
MB_CLIENTonly. Not reproduced; the Siemens forum thread that launched it was a user error (the remote server was the swapper, not the S7) [20]. Treat as false until proven.
Test names:
S7_1200_Real_WordOrder_ABCD_Default,
S7_1200_DInt_HighWord_First_At_DBD0,
S7_1200_String_Header_First_Two_Bytes,
S7_CP343_No_Internal_ByteSwap.
Coil / Discrete Input Mapping
On MB_SERVER the mapping from coil address → S7 bit is fixed at the
process-image level [1][9][12]:
| Modbus coil / discrete input addr | S7 address | Notes |
|---|---|---|
| Coil 0 (FC01/05/15) | %Q0.0 |
bit 0 of output byte 0 |
| Coil 7 | %Q0.7 |
bit 7 of output byte 0 |
| Coil 8 | %Q1.0 |
bit 0 of output byte 1 |
| Coil 8191 (max) | %Q1023.7 |
highest exposed output bit |
| Discrete input 0 (FC02) | %I0.0 |
bit 0 of input byte 0 |
| Discrete input 8191 | %I1023.7 |
highest exposed input bit |
Formulas:
coil_addr = byte_index * 8 + bit_index (e.g. %Q5.3 → coil 43)
discr_addr = byte_index * 8 + bit_index (e.g. %I10.2 → disc 82)
- 1-based Modicon form adds 1: coil 0 (wire) =
00001(Modicon), etc. Our driver sends the 0-based PDU form, so%Q0.0writes to wire address 0. - Writing FC05/FC15 to
%Qis accepted even while the CPU is in STOP — the PLC's process image doesn't care about the user program state. But the output won't propagate to the physical module until RUN (see STOP section below) [1][21]. %Mmarkers require a DB-backedArray of Boolas described in the Address Mapping section. Our driver can't assume "coil N = MN.0" like it can on Modicon — on S7 it's always Q/I unless the programmer built a mapping DB [12].- Bit-inside-holding-register: for
Array of Boolinside theMB_HOLD_REGDB, bool[0] is bit 0 of byte 0 → low byte, low bit of Modbus register 40001. Most third-party clients probe this in the low byte, so the common case works; the less-common case (bool[8]) is bit 0 of byte 1 → high byte, low bit of Modbus register 40001. Clients that test only bool[0] will pass and miss the mis-alignment on bool[8] [12][13].
Test names:
S7_1200_Coil_0_Is_Q0_0,
S7_1200_Coil_8_Is_Q1_0,
S7_1200_Discrete_Input_7_Is_I0_7,
S7_1200_Coil_Write_In_STOP_Accepted_But_Output_Frozen.
Function Code Support & Max Registers Per Request
| FC | Name | S7-1200 / S7-1500 MB_SERVER | CP 343-1 / CP 443-1 MODBUSCP | Max qty per request |
|---|---|---|---|---|
| 01 | Read Coils | Yes | Yes | 2000 bits (spec) |
| 02 | Read Discrete Inputs | Yes | Yes | 2000 bits (spec) |
| 03 | Read Holding Registers | Yes | Yes | 125 (spec max) |
| 04 | Read Input Registers | Yes | Yes | 125 |
| 05 | Write Single Coil | Yes | Yes | 1 |
| 06 | Write Single Register | Yes | Yes | 1 |
| 15 | Write Multiple Coils | Yes | Yes | 1968 bits (spec) — see note |
| 16 | Write Multiple Registers | Yes | Yes | 123 (spec max for TCP) |
| 07 | Read Exception Status | No (RTU only) | No | — |
| 17 | Report Server ID | No | No | — |
| 20/21 | Read/Write File Record | No | No | — |
| 22 | Mask Write Register | No | No | — |
| 23 | Read/Write Multiple | No | No | — |
| 43 | Read Device Identification | No | No | — |
- S7-1200/1500 honour the full spec maxima for FC03/04 (125) and FC16 (123) [1][22]. No sub-spec cap like DL260's 100-register FC16 limit.
- FC15 (Write Multiple Coils) on
MB_SERVERwrites into%Q, which maxes out at 1024 bytes = 8192 bits, but the spec's 1968-bit per-request limit caps any single call first [1][9]. MB_HOLD_REGbuffer size is bounded by DB size — max DB size on S7-1200 is 64 KB, on S7-1500 is much larger (several MB depending on CPU), so the practicalMB_HOLD_REGlimit is 32767 16-bit registers on S7-1200 and effectively unbounded on S7-1500 [22][23]. The per-request limit is still 125.- Read past the end of
MB_HOLD_REGreturns exception02(Illegal Data Address) at the start of the overflow register, not a partial read [1][8]. - Request larger than spec max (e.g. FC03 quantity 126) returns exception
03(Illegal Data Value). Verified on S7-1200 V4.2 [1][24]. - CP 343-1
MODBUSCPper-request maxima are spec (125/125/123/1968/2000), matching the standard [4]. The CP'sMODBUS_PARAM_CPcaps the total exposed range, not the per-call quantity.
Test names:
S7_1200_FC03_126_Registers_Returns_IllegalDataValue,
S7_1200_FC16_124_Registers_Returns_IllegalDataValue,
S7_1200_FC03_Past_MB_HOLD_REG_End_Returns_IllegalDataAddress,
S7_1200_FC17_ReportServerId_Returns_IllegalFunction.
Exception Codes
S7 Modbus servers return only the four standard exception codes [1][4]:
| Code | Name | Triggered by |
|---|---|---|
| 01 | Illegal Function | FC not in the supported list (17, 20-23, 43, any undefined FC) |
| 02 | Illegal Data Address | Register outside MB_HOLD_REG / outside MODBUSCP param-block range |
| 03 | Illegal Data Value | Quantity exceeds spec (FC03/04 > 125, FC16 > 123, FC01/02 > 2000, FC15 > 1968) |
| 04 | Server Failure | Runtime error inside MB_SERVER (DB access fault, corrupt DB header, MB_SERVER disabled mid-request) [1][24] |
- No proprietary exception codes (05/06/0A/0B) are used on any S7 Modbus server [1][4]. Our driver's status-code mapper can treat these as "never observed" on the S7 profile.
- CPU in STOP →
MB_SERVERkeeps running if it's in OB1 of the firmware's communication task, but OB1 itself is not scanned. In practice:- Holding-register reads (FC03) continue to return the last DB values
frozen at the moment the CPU entered STOP. The
MB_SERVERblock is in OB1 so it isn't re-invoked; however the TCP stack keeps the socket open and returns cached data on subsequent polls [1][21]. Unconfirmed whether this is cached in the CP or in the CPU's communication processor; behaviour varies between firmware 4.0 and 4.5 [21]. - Holding-register writes (FC06/FC16) during STOP return exception
04(Server Failure) on S7-1200 V4.2+, and return success-but-discarded on older firmware [1][24]. Our driver should treat FC06/FC16 during STOP as non-deterministic and not rely on the response code. - Coil writes (FC05/FC15) to
%Qare accepted by the process image during STOP, but the physical output freezes at its last RUN-mode value (or the configured STOP-mode substitute value) until RUN resumes [1][21].
- Holding-register reads (FC03) continue to return the last DB values
frozen at the moment the CPU entered STOP. The
- Writing a read-only address via FC06/FC16: returns
02(Illegal Data Address), not04. S7 does not have "write-protected" holding registers — the programmer either exposes a DB for read-write or doesn't expose it at all [1][12].
STATUS codes (returned in the STATUS output of the block, not on the wire):
0x0000— no error.0x7001— first call, connection being established.0x7002— subsequent cyclic call, connection in progress.0x8383— data access error (optimized DB, DB too small, or type mismatch) [10][24].0x8188— invalid parameter combination (e.g. MB_MODE out of range) [24].0x80C8— mismatched UNIT_ID between MB_CLIENT andMB_SERVER[25].
Test names:
S7_1200_FC03_Outside_HoldReg_Returns_IllegalDataAddress,
S7_1200_FC16_In_STOP_Returns_ServerFailure,
S7_1200_FC03_In_STOP_Returns_Cached_Values,
S7_1200_No_Proprietary_ExceptionCodes_0x05_0x06_0x0A_0x0B.
Connection Behavior
- Max simultaneous Modbus TCP connections:
- S7-1200: shares a pool of 8 open-communication connections across
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
still 8 shared among PG/HMI/OUC/Modbus. Each
MB_SERVERinstance reserves one. A typical site with a PG + 1 HMI + 2 Modbus clients uses 4 of the 8 [1][26]. - S7-1500: up to 8 concurrent Modbus TCP server connections per
MB_SERVERport, across multipleMB_SERVERinstance DBs each with a unique port. Total open-communication resources depend on CPU (e.g. CPU 1515-2 PN supports 128 OUC connections total; Modbus is a subset) [1][27]. - CP 343-1 Lean: up to 8 simultaneous Modbus TCP connections on port 502 [4][5]. Exceeding this refuses at TCP accept.
- CP 443-1 Advanced: up to 16 simultaneous Modbus TCP connections [4].
- S7-1200: shares a pool of 8 open-communication connections across
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
still 8 shared among PG/HMI/OUC/Modbus. Each
- Multi-connection model on
MB_SERVER: one instance DB per connection. An instance DB listening on port 502 serves exactly one connection at a time; to serve N simultaneous clients you need N instance DBs each with a unique port (502/503/504...). This is a real trap — most users expect port 502 to multiplex [27][28]. Our driver must not assume port 502 is the only listener. - Keep-alive: S7-1500's TCP stack does send TCP keepalives (default every ~30 s) but the interval is not exposed as a configurable. S7-1200 is the same. CP 343-1 keepalives are configured via HW Config → CP properties → Options → "Send keepalive" (default off on older firmware, default on on firmware V3.0+) [1][29]. Driver-side keepalive is still advisable for S7-300/CP 343-1 on old firmware.
- Idle-timeout close:
MB_SERVERdoes not close idle sockets on its own. However, the TCP stack on S7-1500 will close a socket that fails three consecutive keepalive probes (~2 minutes). Forum reports describeMB_SERVERconnections "dying overnight" on S7-1500 when an HMI stops polling — the fix is to enable driver-side periodic reads or driver-side TCP keepalive [29][30]. - Reconnect after power cycle: MB_SERVER starts listening ~1-2 seconds
after the CPU reaches RUN. If the client reconnects during STARTUP OB
(OB100), the connection is refused until OB1 runs the block at least once.
Our driver should back off and retry on
ECONNREFUSEDfor the first 5 seconds after a power-cycle detection [1][24]. - Unit Identifier:
MB_SERVERaccepts any Unit ID by default — there is no configurable filter; the PLC ignores the Unit ID field entirely.MB_CLIENTdefaults to Unit ID = 255 as "ignore" [25][31]. Some third-party Modbus-TCP gateways require a specific Unit ID; sending anything to S7 is safe. CP 343-1MODBUSCPalso accepts any Unit ID in server mode, but the parameter DB exposes asingle_write/unit_idfield on newer firmware to allow filtering [4].
Test names:
S7_1200_9th_TCP_Connection_Refused_On_8_Conn_Pool,
S7_1500_Port_503_Required_For_Second_Instance,
S7_1200_Reconnect_After_Power_Cycle_Succeeds_Within_5s,
S7_1200_Unit_ID_Ignored_Any_Accepted.
Behavioral Oddities
- Transaction ID echo is reliable on all S7 variants.
MB_SERVERcopies the MBAP TxId verbatim. No known firmware that drops TxId under load [1][31]. - Request serialization: a single
MB_SERVERinstance serializes requests from its one connected client — the block processes one PDU per call and calls happen once per OB1 scan. OB1 scan time of 5-50 ms puts an upper bound on throughput at ~20-200 requests/sec per connection [1][30]. MultipleMB_SERVERinstances (one per port) run in parallel because OB1 calls them sequentially within the same scan. - OB1 scan coupling:
MB_SERVERmust be called cyclically from OB1 (or another cyclic OB). If the programmer puts it in a conditional branch that doesn't fire every scan, requests time out. The STATUS0x7002"in progress" is expected between calls, not an error [1][24]. - Optimized DB backing
MB_HOLD_REG— already covered in Address Mapping; STATUS becomes0x8383. This is the most common deployment bug on S7-1500 projects migrated from older S7-1200 examples [10][11]. - CPU STOP behaviour — covered in Exception Codes section. The short version: reads may return stale data without error; writes return exception 04 on modern firmware.
- Partial-frame disconnect: S7-1200/1500 TCP stack closes the socket on
any MBAP header where the
Lengthfield doesn't match the PDU length. Driver must detect half-close and reconnect [1][29]. - MBAP
Protocol IDmust be 0. Any non-zero value causes the CP/CPU to drop the frame silently (no response, no RST) on S7-1500 firmware V2.0 through V2.9; firmware V3.0+ sends an RST [1][30]. Unconfirmed whether V3.1 still sends RST or returns to silent drop. - FC01/FC02 access outside
%Q/%Irange: on S7-1200, requesting coil address 8192 (=%Q1024.0) returns exception02(Illegal Data Address) [1][9]. The 8192-bit hard cap is a process-image size limit on the CPU, not a Modbus protocol limit. MB_CLIENTUNIT_ID mismatch with remoteMB_SERVERproduces STATUS0x80C8on the client side, and the server silently discards the frame (no response on the wire) [25]. This matters for Modbus-TCP-to-RTU gateway scenarios where the Unit ID picks the RTU slave.- Non-IEEE REAL / BCD: S7 does not use BCD like DirectLOGIC.
Realis always IEEE 754 single-precision.LReal(8-byte double) occupies 4 Modbus registers inABCDEFGHorder (big-endian byte, big-endian word) [15][18]. MODBUSCPsingle-write on CP 343-1: a parametersingle_writein the param DB controls whether FC06 on a register in the "holding register" area triggers a callback to the user program vs. updates the DB directly. Default is direct update. If a ladder programmer enables the callback without implementing the callback OB, FC06 writes hang for 5 seconds then return exception04[4].
Test names:
S7_1200_TxId_Preserved_Across_Burst_Of_50_Requests,
S7_1200_MBSERVER_Throughput_Capped_By_OB1_Scan,
S7_1200_MBAP_ProtocolID_NonZero_Frame_Dropped,
S7_1200_Partial_MBAP_Causes_Half_Close.
Model-specific Differences Worth Separate Test Coverage
- S7-1200 V4.0 vs V4.4+: Older firmware does not support
WStringoverMB_HOLD_REGand returns0x8383if the DB contains one [18][24]. Test both firmware bands separately. - S7-1500 vs S7-1200: S7-1500 supports multiple
MB_SERVERinstances on the same CPU with different ports cleanly; S7-1200 can too but its 8-connection pool is shared tighter [1][27]. Throughput per-connection is ~5× faster on S7-1500 because the comms task runs on a dedicated core. - S7-300 + CP 343-1 vs S7-1200/1500: parameter-block mapping (not
MB_HOLD_REGpointer), per-connection license, no%Q/%Idirect access for coils (everything goes through a DB), different STATUS codes (DONE/ERROR/STATUSword pairs vs. the single STATUS word) [4][14]. Driver-side it's a different profile. - CP 343-1 Lean vs CP 343-1 Advanced: Lean is server-only; Advanced is client + server. Lean's max connections = 8; Advanced = 16 [4][5].
- CP 443-1 in S7-400H: uses
MODBUSCP_REDUNDANTwhich presents two Ethernet endpoints that fail over. Our driver's redundancy support should recognize the S7-400H profile as "two IP addresses, same server state, advertise viaServerUriArray" [6]. - ET 200SP CPU (1510SP / 1512SP): behaves as S7-1500 from
MB_SERVERperspective. No known deltas [3].
Performance (native S7comm driver)
This section covers the native S7comm driver (ZB.MOM.WW.OtOpcUa.Driver.S7),
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
different libraries, and different optimization levers.
Block-read coalescing
The S7 driver runs a coalescing planner before every read pass: same-area /
same-DB tags are sorted by byte offset and merged into single
Plc.ReadBytesAsync requests when the gap between them is small. Reading
DB1.DBW0, DB1.DBW2, DB1.DBW4 issues one 6-byte byte-range read
covering offsets 0..6, sliced client-side instead of three multi-var items
(let alone three individual Plc.ReadAsync round-trips). On a 50-tag
contiguous workload this reduces wire traffic from 50 single reads (or 3
multi-var batches at the 19-item PDU ceiling) to 1 byte-range PDU.
Default 16-byte gap-merge threshold
The planner merges two adjacent ranges when the gap between them is at most 16 bytes. The default reflects the cost arithmetic on a 240-byte default PDU: an S7 request frame is ~30 bytes and a per-item response header is ~12 bytes, so over-fetching 16 bytes (which decode-time discards) is cheaper than paying for one extra PDU round-trip.
The math also holds for 480/960-byte PDUs but the relative cost flips — on a 960-byte PDU you can fit a much larger request and the over-fetch ceiling is less of a concern. Sites running the extended PDU on S7-1500 can safely raise the threshold (see operator guidance below).
Opaque-size opt-out for STRING / array / structured-timestamp tags
Variable-width and header-prefixed tag types never participate in coalescing:
- STRING / WSTRING carry a 2-byte (or 4-byte) length header, and the
per-tag width depends on the configured
StringLength. - CHAR / WCHAR are routed through the dedicated
S7StringCodecdecode path, which expects an exact byte slice, not an offset into a larger buffer. - DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime route through
S7DateTimeCodecfor the same reason. - Arrays (
ElementCount > 1) carry a per-tag width ofN × elementBytesand would silently mis-decode if the slice landed mid-block.
Each opaque-size tag emits its own standalone Plc.ReadBytesAsync call.
A STRING in the middle of a contiguous run of DBWs will split the
neighbour reads into "before STRING" and "after STRING" merged ranges
without straddling the STRING's bytes — verified by the
S7BlockCoalescingPlannerTests unit suite.
Operator tuning: BlockCoalescingGapBytes
Surface knob in the driver options:
{
"Host": "10.0.0.50",
"Port": 102,
"CpuType": "S71500",
"BlockCoalescingGapBytes": 16, // default
// ...
}
Tuning guidance:
- Raise the threshold (32-64 bytes) when the PLC has chatty firmware (S7-1200 with default 240-byte PDU and many DBs scattered every few bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
- Lower the threshold (4-8 bytes) when DBs are sparsely populated with hot tags far apart — over-fetching dead bytes wastes the PDU envelope and the saved round-trip never materialises.
- Set to 0 to disable gap merging entirely (only literally adjacent
ranges with
gap == 0coalesce). Useful as a debugging knob: if a driver is misreading values you can flip the threshold to 0 to confirm the slice math isn't the culprit. - Per-DB tuning isn't supported yet — the knob is global per driver
instance. If a site needs different policies for two DBs they live in
different drivers (different
Host:Portrows in the config DB).
Diagnostics counters
The driver surfaces three coalescing counters via DriverHealth.Diagnostics
under the standard <DriverType>.<Counter> naming convention:
S7.TotalBlockReads— number ofPlc.ReadBytesAsynccalls issued by the coalesced path. A fully-coalesced contiguous workload bumps this by 1 perReadAsync.S7.TotalMultiVarBatches—Plc.ReadMultipleVarsAsyncbatches issued for residual singletons that didn't merge. With perfect coalescing this stays at 0.S7.TotalSingleReads— per-tag fallbacks (strings, dates, arrays, 64-bit ints, anything that bypasses both the coalescer and the packer).
Observe via the driver-diagnostics RPC (/api/v2/drivers/{id}/diagnostics)
or the Admin UI's per-driver dashboard.
Diagnostics surfacing
Beyond the coalescing counters above, the S7 driver also surfaces the
negotiated PDU size captured during the COTP/S7comm handshake under the
same <DriverType>.<Counter> naming convention:
S7.NegotiatedPduSize— the PDU envelope size advertised by the CPU duringPlc.OpenAsync. Default S7-1500 CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960 bytes. The value is0before the first successful connect and is reset to0on driver shutdown so an operator inspecting the Admin UI dashboard can immediately tell whether the driver is currently online.
Together these counters answer the most common operator questions about S7 driver health without reaching for a Wireshark capture:
- "Is the driver actually connected?" →
S7.NegotiatedPduSize > 0 - "Is coalescing working?" →
S7.TotalBlockReadsclimbing whileS7.TotalMultiVarBatchesstays flat - "Why is throughput poor?" →
S7.NegotiatedPduSizeis 240 instead of 960 (operator can switch the CPU to extended PDU if the project allows)
The values render alongside Modbus / OPC UA Client metrics in the Admin UI driver-diagnostics panel — same RPC, same dashboard row layout.
Per-tag scan groups
Before PR-S7-C3, ISubscribable.SubscribeAsync took one publishing
interval and applied it to every tag in the input list. A site that wanted
mixed cadences — say a 100 ms HMI pulse, a 1 s dashboard tile, and a 10 s
slow-poll for trend data — had to issue three separate subscribe calls,
each with its own list of tags. That works, but it pushes the partitioning
problem up to the caller (the OPC UA address space layer) and means an
operator can't express "this tag is slow-poll" purely in driver config.
PR-S7-C3 adds per-tag scan groups so a single SubscribeAsync call
naturally splits into N independent poll loops:
S7TagDefinition.ScanGroup(string, optional) — the group identifier the tag belongs to. Tags with no group (or with a group not declared in the rate map below) keep the legacy behaviour and inherit the subscription-default publishing interval.S7DriverOptions.ScanGroupIntervals(IReadOnlyDictionary<string, TimeSpan>, optional) — the rate map. Group names are matched case-insensitively. Any group with a non-positive interval (≤ 0 ms) is silently dropped at config load and tags falling back to that group land in the default partition.
At subscribe time the driver buckets the input tag list by resolved
publishing interval (per-tag group → map lookup → fallback to the
subscription default), then spins up one background poll loop per distinct
interval. Each loop owns its own CancellationTokenSource and its own
LastValues cache; UnsubscribeAsync cancels and disposes every per-group
loop together so a multi-rate subscription can't leak background tasks.
JSON config example
{
"Host": "10.0.0.50",
"ScanGroupIntervalsMs": {
"Fast": 100,
"Medium": 1000,
"Slow": 10000
},
"Tags": [
{ "Name": "PressureSetpoint", "Address": "DB1.DBW0", "DataType": "Int16", "ScanGroup": "Fast" },
{ "Name": "BatchTotal", "Address": "DB1.DBD10", "DataType": "Int32", "ScanGroup": "Medium" },
{ "Name": "TrendBucket", "Address": "DB1.DBD20", "DataType": "Float32", "ScanGroup": "Slow" }
]
}
A single SubscribeAsync(["PressureSetpoint","BatchTotal","TrendBucket"], 1s)
call against this driver produces three independent poll loops —
the fast HMI tag ticks at 100 ms, the dashboard tile at 1 s, the trend
bucket at 10 s. The caller-supplied 1 s default is unused because every
tag carries an explicit group.
100 ms floor applies per partition
The 100 ms floor that protects the S7 mailbox from sub-scan polling
applies to both the subscription default and every per-group rate.
A typo'd entry like {"TooFast": 25} is silently floored to 100 ms at
partition-build time — the driver never schedules a sub-100 ms Task.Delay
even if the operator tries.
_gate contention caveat — "1 connection / 1 mailbox"
Partitioning into N poll loops does not parallelise wire-level reads.
S7netplus's documented pattern is one Plc instance per CPU, and the
driver enforces that with a per-instance SemaphoreSlim (_gate) that
every read takes before touching the socket. All N partitions share the
same gate, so the mailbox is still strictly serial — what the multi-rate
split actually buys you is cadence decoupling:
- Before PR-S7-C3: every tag ticked at the slowest configured interval (or required three separate subscribe calls and three separate logical subscription handles, complicating the address-space layer).
- After PR-S7-C3: a 100 ms HMI tag isn't blocked behind a 10 s slow-poll
batch's
Task.Delay. While Slow is sleeping, the gate is free and Fast acquires it, polls, releases. The CPU sees more frequent small requests rather than infrequent large ones — which is what you want for a responsive HMI surface.
The caveat to be aware of: if Fast's per-tick read takes longer than its tick interval (e.g. 100 ms tick but 200 ms gate-held read because Medium or Slow happens to be mid-read on the gate), Fast's effective cadence slows to "as fast as the gate lets me." That's a property of S7netplus's single-connection design, not of partitioning — three separate driver instances against the same CPU would just waste the CPU's 8-64-connection-resource budget without speeding anything up.
Diagnostics
Partition counts aren't yet surfaced under
DriverHealth.Diagnostics (planned for a follow-up alongside per-partition
tick rate). Tests can call the internal helpers S7Driver.GetPartitionCount
and S7Driver.GetPartitionSummary to inspect the resolved partitioning of
a live subscription handle.
Deadband / on-change
Before PR-S7-C4 the subscription poll loop emitted OnDataChange whenever
the freshly-read value differed from the last cached one — a strict
!Equals(prev, current) test. That's correct for booleans and discrete
state, but for analog tags (Float32 / Float64 / scaled integer set-points)
it floods the OPC UA subscription queue with insignificant noise: the last
counts of an ADC's least-significant-bit jitter, sub-percent setpoint drift,
sensor-grade flutter on a flow rate. PR-S7-C4 lets the operator configure
per-tag deadband thresholds so the driver suppresses uninteresting
publishes at source, before they cross the OPC UA boundary.
Two knobs, both optional, both per-tag:
DeadbandAbsolute(double?) — minimum value change in raw units. Suppress when|new - prev| < DeadbandAbsolute.DeadbandPercent(double?, 0..100) — minimum value change as a percentage of the previous published value. Suppress when|new - prev| < |prev| * DeadbandPercent / 100.
When both knobs are set the filters are OR'd — the value publishes if either threshold says publish. This matches Kepware's documented "either threshold triggers" semantics and mirrors the AbLegacy driver's shipped behaviour for cross-driver consistency.
JSON config example
{
"Host": "10.0.0.50",
"Tags": [
{ "Name": "BoilerPressure", "Address": "DB1.DBD0", "DataType": "Float32",
"DeadbandAbsolute": 0.5 },
{ "Name": "FlowRate", "Address": "DB1.DBD4", "DataType": "Float32",
"DeadbandPercent": 1.0 },
{ "Name": "Temperature", "Address": "DB1.DBD8", "DataType": "Float32",
"DeadbandAbsolute": 0.1, "DeadbandPercent": 0.5 }
]
}
BoilerPressure only republishes after a 0.5-bar change; FlowRate only
when the rate moves by more than 1% of its last published value;
Temperature whenever either 0.1 °C absolute or 0.5% of last
is satisfied.
Edge cases
-
First sample.
PollOnceAsyncgatesforceRaiseand the no-prior-value case ahead of the deadband filter — the first sample for a tag always publishes (otherwise an OPC UA subscription would never see an initial-data push). -
Status-code change. Any transition in the OPC UA
StatusCodechannel (Bad → Good,Good → Bad, etc.) bypasses deadband and publishes, because quality is a semantically different signal from value. -
Non-numeric types.
String/WString/Char/WChar/DateTime/ byte-array tags ignore deadband entirely and keep the legacy!Equalssemantics. ConfiguringDeadbandAbsoluteon aStringtag is harmless — the filter just doesn't engage. -
NaNsamples. If eitherprevorcurrentisNaN, the filter publishes. NaN never equals NaN; treating it as "changed" surfaces the degenerate float to the client rather than hiding it. -
±Infinitysamples. Same rationale as NaN — degenerate values are always published, never deadbanded. -
Sign flip. A tag swinging
+10 → -10produces|delta|=20; the deadband math operates on the absolute delta so a sign flip withDeadbandAbsolute=1always publishes. This is the right answer for bidirectional set-points (positive / negative torque, valve-direction flags encoded as signed scalars). -
Near-zero baseline (
|prev| < 1e-6). A percent threshold against a zero or near-zero baseline diverges (any tiny change is "infinity percent"), so the driver falls back to absolute when|prev| < 1e-6:- If
DeadbandAbsoluteis also configured, that threshold takes over. - If only
DeadbandPercentis set (no absolute fallback), the sample publishes — there's no usable threshold and silently dropping changes against a near-zero baseline would mask a genuine signal.
The
1e-6cutoff is a deliberately conservative floor: floats below~1e-7are already in denormal-precision territory; anything above~1e-6carries enough magnitude that|prev| * pct / 100produces a meaningful threshold. - If
Implementation notes
- The filter is the pure-function helper
S7Driver.ShouldPublish(tag, prev, current). It's exposed atinternalscope so unit tests can drive every decision branch (NaN, ±Inf, sign flip, near-zero baseline, both-set OR semantics) without spinning up a partition or poll loop. LastValuescontinues to cache the last published snapshot, not the last polled one. After a deadband suppression the next sample compares against the cached (previously published) value, so a slow drift that never crosses the threshold in any single tick still gets caught the moment cumulative drift exceeds the threshold.- Deadband is a publish-time filter, not a wire-level one — every
configured tag is still read every tick, the filter only decides
whether to invoke
OnDataChange. The mailbox / PDU / coalescing path is untouched.
Pre-flight PUT/GET enablement
S7-1200 / S7-1500 CPUs ship with PUT/GET communication disabled by
default. The COTP / S7comm handshake itself succeeds against these
locked-down CPUs (you can OpenAsync / negotiate PDU size cleanly), so
the failure surfaces only on the first Plc.ReadAsync — at which
point the driver is already past InitializeAsync, has flipped to
DriverState.Healthy, and dependent code (subscriptions, Admin UI) is
binding against a connection it can't actually use. Operators see
BadDeviceFailure per tag instead of a single, actionable
configuration error.
PR-S7-C5 adds a post-OpenAsync pre-flight probe: a tiny 2-byte
read against Probe.ProbeAddress (default MW0). If the PLC rejects
that read with the wire-level "function not allowed in current
operating state" response (S7 error family D6 05 / 85 00),
S7netplus surfaces the rejection as PlcException with one of
ErrorCode.WrongCPU_Type (CPU drops the connection mid-response) or
ErrorCode.ReadData (CPU sends an S7-level error byte). The driver
classifies that pair as "PUT/GET disabled" and throws a typed
S7PutGetDisabledException from InitializeAsync so the operator sees
the TIA-Portal fix path immediately:
PUT/GET communication is disabled on the PLC. Enable it in TIA Portal: Device → Properties → Protection & Security → Connection mechanisms → "Permit access with PUT/GET communication from remote partner". Re-deploy the hardware config and restart the S7 driver.
S7PreflightClassifier.IsPutGetDisabled(PlcException) is the pure
function that decides whether a given PlcException qualifies; it
matches only WrongCPU_Type and ReadData. Other error codes
(ConnectionError, IPAddressNotAvailable, WrongVarFormat, …)
indicate transport / framing faults rather than PUT/GET gating, so the
driver re-throws the original PlcException unchanged and the existing
DriverState.Faulted path takes over with the original message.
Knobs
Two opt-out knobs on S7ProbeOptions:
ProbeAddress(string?, default"MW0") — address probed by both the background liveness loop and the pre-flight read. Set tonull(or empty string in JSON) to skip the pre-flight entirely. Useful for sites where no fingerprint address has been wired and an arbitrary read atMW0would itself be misleading.SkipPreflight(bool, defaultfalse) — opt out of the pre-flight read while keeping the background probe. Init succeeds against a PUT/GET-disabled CPU; per-tag reads still surfaceBadDeviceFailureat runtime. Useful for staged deployments where the operator hasn't enabled PUT/GET yet but wants the driver visible in the Admin UI.
Why MW0?
The convention from Driver.S7.Cli.md's probe command. MW0 exists
on every S7 CPU regardless of project — Merker memory is universal —
so it's a safe default that doesn't require a per-site DB to be wired.
Sites with a dedicated fingerprint DB can override to e.g.
DB1.DBW0.
JSON config example
{
"Host": "10.0.0.50",
"Probe": {
"Enabled": true,
"IntervalMs": 5000,
"TimeoutMs": 2000,
"ProbeAddress": "DB1.DBW0",
"SkipPreflight": false
}
}
To skip the pre-flight (defer the check to first read):
{
"Host": "10.0.0.50",
"Probe": { "SkipPreflight": true }
}
To skip the probe entirely (no pre-flight, no liveness loop):
{
"Host": "10.0.0.50",
"Probe": { "Enabled": false, "ProbeAddress": "" }
}
TSAP / Connection Type
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
PDU carries a 16-bit TSAP pair (local + remote) that the CPU validates
before any S7comm payload flows. S7netplus's default Plc(CpuType, host, port, rack, slot) constructor picks a PG-class TSAP pair via
TsapPair.GetDefaultTsapPair. That choice works against most lab S7-1200 /
S7-1500 CPUs and against TIA Portal itself, but hardened deployments
(security-config'd S7-1500, ET 200SP, locked-down PROFINET projects) reject
PG class outright at COTP-handshake time, returning the same connection-refused
shape as a wrong slot byte.
PR-S7-C2 surfaces a TsapMode enum on S7DriverOptions so an operator can
force a specific class without re-flashing the PLC project. It applies equally
to the Admin-UI-driven config DB row and to the otopcua-s7-cli test client.
Raw-TSAP byte table
The high byte is the connection class. The local low byte is conventionally
0x00 (caller / unprivileged), and the remote low byte is
(rack << 5) | slot per the S7 spec — the same convention S7netplus's
TsapPair.GetDefaultTsapPair(CpuType, rack, slot) uses for the remote endpoint.
| Class | High byte | Local TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=2) | Typical use |
|---|---|---|---|---|---|
| PG | 0x01 |
0x0100 |
0x0100 |
0x0102 |
TIA Portal, dev laptops, lab S7-1200/1500 |
| OP | 0x02 |
0x0200 |
0x0200 |
0x0202 |
Operator panels, hardened-CPU S7-1500 |
| S7-Basic | 0x03 |
0x0300 |
0x0300 |
0x0302 |
WinCC BasicPanel SDK, S7-Basic clients |
| Other | caller | caller-supplied | caller-supplied | caller-supplied | escape hatch — unusual fixed-TSAP firmware |
TsapMode enum
| Mode | Behaviour |
|---|---|
Auto |
Existing behaviour — S7netplus picks the TSAP pair from CpuType. Explicit LocalTsap / RemoteTsap are ignored under Auto. |
Pg |
Force PG class (high byte 0x01). Local / remote computed from rack + slot. |
Op |
Force OP class (high byte 0x02). |
S7Basic |
Force S7-Basic class (high byte 0x03). |
Other |
Caller-supplied LocalTsap + RemoteTsap. Both must be set or driver init throws InvalidOperationException. |
Explicit LocalTsap / RemoteTsap overrides win over the class-derived
defaults under any non-Auto mode — a site that needs a fixed source-TSAP for
firewall reasons can pin LocalTsap while keeping TsapMode = Pg for the
remote computation.
Worked example: hardened S7-1500 requiring OP class
{
"Host": "10.50.12.30",
"CpuType": "S71500",
"Rack": 0,
"Slot": 0,
"TsapMode": "Op",
"Tags": [ /* … */ ]
}
This produces local = 0x0200, remote = 0x0200 (rack=0, slot=0). The same
PLC under TsapMode = "Auto" (PG class) returns COTP rejection — same packet
capture shape as a wrong-slot misconfig, which is the failure-mode footnote
under §5 of driver-specs.md.
Why not just expose LocalTsap / RemoteTsap directly?
Most operators don't know the byte format off-hand and reach for Pg /
Op / S7Basic based on Siemens-doc terminology. Keeping the enum lets the
Admin UI render a dropdown with sensible labels, while the ushort? fields
stay available as the manual escape hatch when a site has truly unusual
firmware (e.g. third-party S7-protocol gateways with fixed proprietary
TSAPs). Both paths are exercised in the unit-test mapping table.
Live-firmware verification
The PG/OP/S7-Basic byte table above is the documented Siemens convention; the
actual handshake is verified against the dev-box S7-1500 lab rig (a hardened
project that rejects PG and accepts OP). That test is documented in
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests but only runs against
real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
References
- Siemens Industry Online Support, Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions
MB_CLIENTandMB_SERVER, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf - Siemens TIA Portal Online Docs, MB_SERVER instruction. https://docs.tia.siemens.cloud/r/simatic_s7_1200_manual_collection_eses_20/communication-processor-and-modbus-tcp/modbus-communication/modbus-tcp/modbus-tcp-instructions/mb_server-communicate-using-profinet-as-modbus-tcp-server-instruction
- Siemens, SIMATIC S7-1500 Communication Function Manual (covers ET 200SP CPU). http://public.eandm.com/Public_Docs/s71500_communication_function_manual_en-US_en-US.pdf
- Siemens Industry Online Support, SIMATIC Modbus/TCP communication using CP 343-1 and CP 443-1 — Programming Manual, Entry ID 103447617. https://cache.industry.siemens.com/dl/files/617/103447617/att_106971/v1/simatic_modbus_tcp_cp_en-US_en-US.pdf
- Siemens Industry Online Support FAQ "Which technical data applies for the SIMATIC Modbus/TCP software for CP 343-1 / CP 443-1?", Entry ID 104946406. https://www.industry-mobile-support.siemens-info.com/en/article/detail/104946406
- Siemens Industry Online Support, Redundant Modbus/TCP communication via CP 443-1 in S7-400H systems, Entry ID 109739212. https://cache.industry.siemens.com/dl/files/212/109739212/att_887886/v1/SIMATIC_modbus_tcp_cp_red_e_en-US.pdf
- Siemens Industry Online Support, SIMATIC MODBUS (TCP) PN CPU Library — Programming and Operating Manual 06/2014, Entry ID 75330636. https://support.industry.siemens.com/cs/attachments/75330636/ModbusTCPPNCPUen.pdf
- DMC Inc., Using an S7-1200 PLC as a Modbus TCP Slave. https://www.dmcinfo.com/blog/27313/using-an-s7-1200-plc-as-a-modbus-tcp-slave/
- Siemens, SIMATIC S7-1200 System Manual (V4.x), "MB_SERVER" pages 736-742. https://www.manualslib.com/manual/1453610/Siemens-S7-1200.html?page=736
- lamaPLC, Simatic Modbus S7 error- and statuscodes. https://www.lamaplc.com/doku.php?id=simatic:errorcodes
- ScadaProtocols, How to Configure Modbus TCP on Siemens S7-1200 (TIA Portal Step-by-Step). https://scadaprotocols.com/modbus-tcp-siemens-s7-1200-tia-portal/
- Industrial Monitor Direct, Reading and Writing Memory Bits via Modbus TCP on S7-1200. https://industrialmonitordirect.com/blogs/knowledgebase/reading-and-writing-memory-bits-via-modbus-tcp-on-s7-1200
- PLCtalk forum "Siemens S7-1200 modbus understanding". https://www.plctalk.net/forums/threads/siemens-s7-1200-modbus-understanding.104119/
- Siemens SIMATIC S7 Manual, "Function block MODBUSCP — Functionality" (ManualsLib p29). https://www.manualslib.com/manual/1580661/Siemens-Simatic-S7.html?page=29
- Chipkin, How Real (Floating Point) and 32-bit Data is Encoded in Modbus. https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages
- Siemens Industry Online Support forum, MODBUS DATA conversion in S7-1200 CPU, Entry ID 97287. https://support.industry.siemens.com/forum/WW/en/posts/modbus-data-converson-in-s7-1200-cpu/97287
- Industrial Monitor Direct, Siemens S7-1500 MB_SERVER Modbus TCP Configuration Guide. https://industrialmonitordirect.com/de/blogs/knowledgebase/siemens-s7-1500-mb-server-modbus-tcp-configuration-guide
- Siemens TIA Portal, Data types in SIMATIC S7-1200/1500 — String/WString header layout (system manual, "Elementary Data Types").
- Kepware / PTC, Siemens TCP/IP Ethernet Driver Help, "Byte / Word Order" tag property. https://www.opcturkey.com/uploads/siemens-tcp-ip-ethernet-manual.pdf
- Siemens SiePortal forum, Transfer float out of words, Entry ID 187811. https://sieportal.siemens.com/en-ww/support/forum/posts/transfer-float-out-of-words/187811 (operator-reported "S7 swaps float" claim — traced to remote-device issue; unconfirmed.)
- Siemens SiePortal forum, S7-1200 communication with Modbus TCP, Entry ID 133086. https://support.industry.siemens.com/forum/WW/en/posts/s7-1200-communication-with-modbus-tcp/133086
- Siemens SiePortal forum, S7-1500 MB Server Holding Register Max Word, Entry ID 224636. https://support.industry.siemens.com/forum/WW/en/posts/s7-1500-mb-server-holding-register-max-word/224636
- Siemens, SIMATIC S7-1500 Technical Specifications — CPU-specific DB size limits in each CPU manual's "Memory" table.
- Siemens TIA Portal Online Docs, Error messages (S7-1200, S7-1500) — Modbus instructions. https://docs.tia.siemens.cloud/r/en-us/v20/modbus-rtu-s7-1200-s7-1500/error-messages-s7-1200-s7-1500
- Industrial Monitor Direct, Fix Siemens S7-1500 MB_Client UnitID Error 80C8. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-mb-client-on-s7-1500-cpu-1515sp-modbus-tcp
- Siemens SiePortal forum, How many TCP connections can the S7-1200 make?, Entry ID 275570. https://support.industry.siemens.com/forum/WW/en/posts/how-many-tcp-connections-can-the-s7-1200-make/275570
- Siemens SiePortal forum, Simultaneous connections of Modbus TCP, Entry ID 189626. https://support.industry.siemens.com/forum/ww/en/posts/simultaneous-connections-of-modbus-tcp/189626
- Siemens SiePortal forum, How many Modbus TCP IP clients can read simultaneously from S7-1517, Entry ID 261569. https://support.industry.siemens.com/forum/WW/en/posts/how-many-modbus-tcp-ip-client-can-read-simultaneously-in-s7-1517/261569
- Industrial Monitor Direct, Troubleshooting Intermittent Modbus TCP Connections on S7-1500 PLC. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-intermittent-modbus-tcp-connections-on-s7-1500-plc
- PLCtalk forum "S7-1500 modbus tcp speed?". https://www.plctalk.net/forums/threads/s7-1500-modbus-tcp-speed.114046/
- Siemens SiePortal forum, MB_Unit_ID parameter in Modbus TCP, Entry ID 156635. https://support.industry.siemens.com/forum/WW/en/posts/mb-unit-id-parameter-in-modbus-tcp/156635