Overview
When a Siemens S7-1500 CPU must talk to a serial Modbus RTU device, the usual path is the MB_CLIENT instruction shipped in TIA Portal, which speaks Modbus TCP (MBAP header + PDU) over a TCP socket. There are real situations where you cannot use MB_CLIENT and must instead send a raw Modbus RTU frame (address + function code + data + CRC-16) directly into a TCP socket. Typical reasons include:
- The remote device sits behind a transparent serial-to-Ethernet gateway (for example, the Advantech EKI-1521) that forwards the serial bytes inside a TCP payload. The gateway speaks TCP, but the payload is RTU-framed.
- You are using a third-party Modbus-to-Profibus or Modbus-to-fieldbus gateway that exposes a Modbus RTU-style service over TCP.
- You must support a vendor-specific function code that
MB_CLIENTblocks do not cover. - You need sub-millisecond deterministic cycle time on a custom request/response without going through the MB_CLIENT state machine.
This reference shows how to construct, send, receive, and validate Modbus RTU frames directly from S7-1500 user logic using the standard TSEND_C and TRCV_C TIA Portal blocks. It includes a working SCL CRC-16 routine, a complete request/response code sample, gateway wiring details, and a troubleshooting matrix.
Prerequisites
Before you build the first frame, confirm that the following items are available and configured.
Hardware
- Siemens S7-1500 CPU (any firmware from V2.0 onward; tested on CPU 1511-1 PN and CPU 1515-2 PN with firmware V2.9). All Open User Communication blocks are available from the instruction list under Communication → S7 Communication or Communication → Other in TIA Portal V17 and later.
- CPU with an integrated PROFINET interface, or a CP 1543-1 communication module. A second PROFINET port is recommended so that the Modbus traffic can be isolated from the engineering network.
- Serial device or remote Modbus RTU node. For development, a USB-to-RS-485 converter on a laptop running
modpollor a Modbus simulator is ideal. - Advantech EKI-1521 (or compatible) serial-to-Ethernet gateway, set to Server mode, port 502, with the Modbus Gateway operating mode enabled. EKI-1521 firmware V1.60 or later is recommended for stable RTU-over-TCP forwarding.
Software
- TIA Portal V17 (or V16, V18, V19). Open User Communication blocks are documented in the S7-1500/ET 200MP System Manual.
- S7-PLCSIM (V17 or later) if you want to dry-run the logic without hardware.
- Wireshark with the modbus_rtu dissector disabled (or set to "don't decode") so the raw TCP stream is visible while commissioning.
Network Configuration
- The S7-1500 CPU IP address, subnet mask, and router must be set in the device configuration (e.g.
192.168.10.10 / 255.255.255.0). - The EKI-1521 must have a fixed IP in the same subnet (e.g.
192.168.10.20 / 255.255.255.0) with the TCP port set to 502 (default Modbus port) or any free port if 502 is already in use. - Open a
PG/PCfirewall exception for the TCP port used by the gateway if Windows Defender or an IT firewall is active on the engineering station.
Modbus RTU Frame Structure and Function Codes
The Modbus RTU frame used on the wire is a fixed-structure PDU with a 2-byte trailing CRC. Every byte except the start and end delimiters is implicit (the 3.5-character silent interval marks the boundaries on a serial line, but over TCP the framing is taken from the TCP stream itself, so the boundary is irrelevant).
| Field | Length (bytes) | Description | Range / Example |
|---|---|---|---|
| Slave Address | 1 | Receiver address; 0 = broadcast, 1–247 = individual slave, 248–255 = reserved. | 0x01 (slave 1) |
| Function Code | 1 | Service identifier. Bit 7 set in the response means exception. | 0x03 (read holding registers) |
| Data | 0–252 | Function-specific payload (registers, coil states, exception sub-code, etc.). | 0x00 0x00 0x00 0x06 (start addr + quantity for FC03) |
| CRC-16 (Modbus) | 2 | CRC-16 polynomial 0xA001, init 0xFFFF, reflected input/output. Transmitted low byte first. | 0xC4 0x0B |
Common Function Codes
| Code | Name | Direction | Data Field (request) | Data Field (response) |
|---|---|---|---|---|
| 0x01 | Read Coils | Master → Slave | Start addr (2 B) + Quantity (2 B) | Byte count (1 B) + coil bytes (N) |
| 0x02 | Read Discrete Inputs | Master → Slave | Start addr (2 B) + Quantity (2 B) | Byte count (1 B) + input bytes (N) |
| 0x03 | Read Holding Registers | Master → Slave | Start addr (2 B) + Quantity (2 B) | Byte count (1 B) + register words (2·N) |
| 0x04 | Read Input Registers | Master → Slave | Start addr (2 B) + Quantity (2 B) | Byte count (1 B) + register words (2·N) |
| 0x05 | Write Single Coil | Master → Slave | Output addr (2 B) + value (2 B, 0xFF00 = ON, 0x0000 = OFF) | Echo of request |
| 0x06 | Write Single Register | Master → Slave | Register addr (2 B) + value (2 B) | Echo of request |
| 0x0F (15) | Write Multiple Coils | Master → Slave | Start addr (2 B) + Quantity (2 B) + Byte count (1 B) + coil bytes (N) | Start addr (2 B) + Quantity (2 B) |
| 0x10 (16) | Write Multiple Registers | Master → Slave | Start addr (2 B) + Quantity (2 B) + Byte count (1 B) + register words (2·N) | Start addr (2 B) + Quantity (2 B) |
Exception Frame
If the slave cannot fulfil the request, the response function code has the high bit set (0x80 | function_code) and the data field is a single exception code byte.
| Exception code | Name | Typical cause |
|---|---|---|
| 0x01 | Illegal Function | Function code not supported by the slave |
| 0x02 | Illegal Data Address | Register or coil address outside the slave's map |
| 0x03 | Illegal Data Value | Quantity, byte count, or value outside the allowed range |
| 0x04 | Slave Device Failure | Hardware fault on the slave while processing |
| 0x05 | Acknowledge | Long-running command accepted, processing continues |
| 0x06 | Slave Device Busy | Slave processing another command; retry later |
| 0x08 | Memory Parity Error | Parity error in slave memory |
| 0x0A (10) | Gateway Path Unavailable | Gateway cannot reach the downstream device |
| 0x0B (11) | Gateway Target No Response | Downstream device did not reply to the gateway |
The complete Modbus Application Protocol Specification is published by the Modbus Organization; the original Modbus over Serial Line specification that defines the RTU framing and CRC-16 algorithm is available as PI_MBUS_300.pdf.
Why Raw RTU over TCP Instead of MB_CLIENT
The MB_CLIENT instruction in TIA Portal expects a Modbus TCP server, i.e. a peer that prefixes every PDU with an MBAP header. A serial Modbus RTU device cannot speak Modbus TCP natively. There are three ways to bridge the gap, and the choice dictates whether you need raw RTU framing in user logic.
| Bridge | Method | Frame on the TCP socket | Required on the CPU |
|---|---|---|---|
| Modbus TCP → serial converter (acts as Modbus TCP server) | TCP socket + MBAP header removed, RTU frame produced on RS-485 | Modbus TCP (MBAP + PDU) |
MB_CLIENT directly |
| Serial-to-Ethernet gateway in TCP Server (transparent) mode, e.g. EKI-1521 | TCP socket carries the exact bytes that appear on RS-485 | Raw Modbus RTU (no MBAP, CRC-16 present) |
TSEND_C/TRCV_C + custom CRC |
| Modbus-to-Profibus gateway (e.g. Hilscher NT 50) | Serial bytes embedded in a vendor-specific wrapper | Vendor proprietary or raw RTU | Either, depending on the gateway profile |
The second row is the one covered here. The EKI-1521 in TCP Server, Modbus Gateway Mode opens a TCP listener on port 502 (configurable) and forwards every byte received on that socket to the downstream RS-485 master. The CPU must therefore generate a valid Modbus RTU frame — slave address, function code, payload, and a valid CRC-16 — and put those bytes into the TCP send buffer.
CRC-16 (Modbus) Implementation in SCL
The Modbus RTU CRC-16 uses polynomial 0xA001 (bit-reversed 0x8005), an initial value of 0xFFFF, and is processed with reflected input and output. The result is transmitted low byte first. The function block below takes a pointer to the data and a length and returns a 16-bit CRC.
// FB_ModbusCRC16 — Modbus RTU CRC-16, SCL for S7-1500
FUNCTION_BLOCK "FB_ModbusCRC16"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
VAR_INPUT
pData : POINTER TO BYTE; // pointer to first byte of frame
uiLength : UINT; // number of bytes to cover (without CRC)
END_VAR
VAR_OUTPUT
wCRC : WORD; // Modbus CRC, LO/HI ordered for on-wire use
END_VAR
VAR
iCRC : WORD;
i : INT;
j : INT;
bTmp : BYTE;
bIn : BYTE;
END_VAR
BEGIN
iCRC := 16#FFFF;
FOR i := 0 TO UINT_TO_INT(uiLength) - 1 DO
bIn := pData[i];
iCRC := iCRC XOR WORD#16#0000 OR bIn;
FOR j := 0 TO 7 DO
IF (iCRC AND 16#0001) <> 0 THEN
iCRC := SHR(IN := iCRC, N := 1) XOR 16#A001;
ELSE
iCRC := SHR(IN := iCRC, N := 1);
END_IF;
END_FOR;
END_FOR;
// Swap LO/HI for Modbus on-wire order
bTmp := WORD_TO_BYTE(SHR(IN := iCRC, N := 8) AND 16#00FF);
wCRC := SHL(IN := WORD#16#0000 OR WORD_TO_BYTE(iCRC AND 16#00FF), N := 8)
OR WORD#16#0000 OR bTmp;
END_FUNCTION_BLOCK
Stand-alone Test Vector
The standard Modbus test vector 01 03 00 00 00 0A must yield CRC C5CD, on the wire as CD C5. The block above produces wCRC = 16#CDC5, which when written to a byte array as Lo = wCRC.B0 and Hi = wCRC.B1 gives CD C5 on the wire. Verify this once with an HMI tag or a watch table before relying on the routine on a live system.
Configuring TSEND_C and TRCV_C in TIA Portal
TSEND_C and TRCV_C are the Open User Communication blocks used for the TCP stream. TSEND_C establishes the connection, sends, and disconnects in a single block; TRCV_C receives. They are documented in the TIA Portal help under Communication > Open User Communication and in the S7-1500 System Manual.
Connection Parameters
| Parameter | Value (example) | Note |
|---|---|---|
| REQ (TSEND_C) | Rising edge starts a send job | Use a one-shot from the request trigger |
| ID | 1 (must match for TSEND_C and TRCV_C) | Local connection identifier |
| CONNECT |
TCON_IP_V4 data block |
Auto-generated by the TIA Portal wizard |
| DATA | Pointer to the frame DB (e.g. P#DB100.DBX0.0 BYTE 8) |
Length taken from the LEN input, not the data type |
| LEN | Total frame length including CRC, e.g. 8 | 0 = use the full DATA area |
| CONT | TRUE | Keep connection open between requests |
| DONE / BUSY / ERROR | Status outputs | Done = 1 means a single job finished; do not use as connection status |
TCON_IP_V4 (Connection Description)
Use the wizard ("Add new connection" in the program editor) to generate the connection DB. The fields the CPU program needs to verify are:
-
InterfaceId: HW identifier of the PROFINET interface (e.g.64for the first PN port of a CPU 1515-2 PN). -
ID: Connection ID — choose a value that is not used by any other Open User Communication block on the same CPU. -
ConnectionType:16#0Bfor TCP (decimal 11). Do not use16#0C(ISO-on-TCP) or16#0D(UDP); the EKI-1521 is a plain TCP listener. -
ActiveEstablished:TRUE— the S7-1500 is the TCP client and opens the socket to the gateway. -
RemoteAddress:192.168.10.20(EKI-1521). -
RemotePort:502. -
LocalPort:0(any free local port, assigned by the CPU).
Building the Request Frame
The request is constructed in a data block of type Array[0..255] of Byte. The function block FB_BuildRequest below fills the array for a generic read-holding-registers (FC 0x03) request. Adapt the same pattern for any other function code.
// FB_BuildReadHoldingRegs — populates a Modbus RTU request frame
FUNCTION_BLOCK "FB_BuildReadHoldingRegs"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
VAR_INPUT
bSlave : BYTE; // slave address 1..247
wStartAddr : WORD; // first register address
wQuantity : WORD; // number of registers, 1..125
END_VAR
VAR_IN_OUT
aFrame : ARRAY[0..255] OF BYTE;
END_VAR
VAR_OUTPUT
uiFrameLen : UINT; // total length including CRC, ready for TSEND_C
END_VAR
VAR
fbCRC : FB_ModbusCRC16;
END_VAR
BEGIN
aFrame[0] := bSlave; // Slave address
aFrame[1] := 16#03; // Function code 03 = read holding regs
aFrame[2] := WORD_TO_BYTE(SHR(IN := wStartAddr, N := 8) AND 16#00FF); // Start HI
aFrame[3] := WORD_TO_BYTE(wStartAddr AND 16#00FF); // Start LO
aFrame[4] := WORD_TO_BYTE(SHR(IN := wQuantity, N := 8) AND 16#00FF); // Qty HI
aFrame[5] := WORD_TO_BYTE(wQuantity AND 16#00FF); // Qty LO
fbCRC(pData := ADR(aFrame[0]),
uiLength := 6,
wCRC => aFrame[7]);
// CRC bytes occupy indices 6 and 7 in LO/HI order
aFrame[6] := aFrame[7] AND 16#00FF; // CRC LO
aFrame[7] := (SHR(IN := aFrame[7], N := 8) AND 16#00FF); // CRC HI
uiFrameLen := 8;
END_FUNCTION_BLOCK
W#16#0100 register address (256) is sent as bytes 01 00, with the high byte first. The same convention applies to the quantity field. The CRC is the opposite: it is sent low byte first. The code above explicitly splits both halves to make the byte order obvious. Do not rely on WORD_TO_BLOCK_DB for this.Receiving and Parsing the Response
The TCP receive side uses the same connection. The complete response from a successful FC 0x03 read of N registers is 1 + 1 + 1 + 2·N + 2 = 2·N + 5 bytes. An exception response is 5 bytes. Set LEN = 5 on TRCV_C to get the function code and exception sub-code in one shot; if the function code is below 0x80 and the response is not an exception, issue a second TRCV_C call with LEN = (2·N − 1) to fetch the data payload. A simpler approach is to set LEN = 255 and rely on RCV_LEN on TRCV_C to tell you how many bytes arrived.
// FB_ParseResponse — validates CRC and extracts the register payload
FUNCTION_BLOCK "FB_ParseResponse"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
VAR_INPUT
uiBytesReceived : UINT;
END_VAR
VAR_IN_OUT
aRx : ARRAY[0..255] OF BYTE;
END_VAR
VAR_OUTPUT
bOK : BOOL;
bException : BOOL;
bExCode : BYTE;
wValue : ARRAY[0..124] OF WORD;
uiValueCnt : UINT;
END_VAR
VAR_TEMP
i : INT;
wComputed : WORD;
wOnWire : WORD;
END_VAR
BEGIN
bOK := FALSE;
bException := FALSE;
bExCode := 16#00;
uiValueCnt := 0;
IF uiBytesReceived < 5 THEN
RETURN;
END_IF;
// Check for exception: high bit set in function code
IF (aRx[1] AND 16#80) <> 0 THEN
bException := TRUE;
bExCode := aRx[2];
RETURN;
END_IF;
// Recompute CRC over the frame minus the trailing 2 bytes
// and compare to the on-wire CRC.
"FB_ModbusCRC16"(pData := ADR(aRx[0]),
uiLength := uiBytesReceived - 2,
wCRC => wComputed);
wOnWire := SHL(IN := WORD#16#0000 OR aRx[uiBytesReceived - 1], N := 8)
OR WORD#16#0000 OR aRx[uiBytesReceived - 2];
IF wComputed <> wOnWire THEN
bOK := FALSE;
RETURN;
END_IF;
// For FC 0x03 the byte count is aRx[2]; data starts at aRx[3]
uiValueCnt := UINT#0;
FOR i := 0 TO (aRx[2] / 2) - 1 DO
wValue[i] := SHL(IN := WORD#16#0000 OR aRx[3 + 2 * i], N := 8)
OR WORD#16#0000 OR aRx[3 + 2 * i + 1];
uiValueCnt := uiValueCnt + 1;
END_FOR;
bOK := TRUE;
END_FUNCTION_BLOCK
Complete Code Example — Cyclic Read with Timeout
The snippet below ties TSEND_C, TRCV_C, and the two helper function blocks into a single request/response cycle that runs every tCycle ms. Drop it into a cyclic OB (e.g. OB1 or a watchdog OB) and adjust the timer preset to suit your scan rate.
// MainModbusRTU_OB1 — cyclic read of 10 holding regs from slave 1, start 0
DATA_BLOCK "dbModbus" { S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
STRUCT
aTx : ARRAY[0..255] OF BYTE;
aRx : ARRAY[0..255] OF BYTE;
wRegisters : ARRAY[0..124] OF WORD;
uiTxLen : UINT;
uiRxLen : UINT;
bTrigger : BOOL;
bBusy : BOOL;
bDone : BOOL;
bError : BOOL;
wStatus : WORD;
END_STRUCT;
END_DATA_BLOCK
// In OB1:
IF "dbModbus".bTrigger AND NOT "dbModbus".bBusy THEN
"FB_BuildReadHoldingRegs"(bSlave := 16#01,
wStartAddr := 16#0000,
wQuantity := 16#000A,
aFrame := "dbModbus".aTx,
uiFrameLen => "dbModbus".uiTxLen);
"TSEND_C"(REQ := TRUE,
ID := 1,
LEN := "dbModbus".uiTxLen,
DATA := "dbModbus".aTx,
CONT := TRUE,
DONE => "dbModbus".bDone,
BUSY => "dbModbus".bBusy,
ERROR => "dbModbus".bError,
STATUS => "dbModbus".wStatus);
END_IF;
// Fire TRCV_C alongside the SEND so the receive is armed
"TRCV_C"(EN_R := "dbModbus".bTrigger,
ID := 1,
LEN := 255,
DATA := "dbModbus".aRx,
CONT := TRUE,
NDR => , // raise when bytes received
BUSY => ,
ERROR => ,
STATUS => ,
RCV_LEN => "dbModbus".uiRxLen);
IF "dbModbus".uiRxLen > 0 THEN
"FB_ParseResponse"(uiBytesReceived := "dbModbus".uiRxLen,
aRx := "dbModbus".aRx,
bOK => ,
bException => ,
bExCode => ,
wValue := "dbModbus".wRegisters,
uiValueCnt => );
"dbModbus".uiRxLen := 0;
"dbModbus".bTrigger := FALSE;
END_IF;
TRCV_C never sees bytes, it will wait indefinitely. Drive TRCV_C with an IEC_TIMER (TON with PT = 1000 ms typical for serial-over-TCP) and call TRCV_C again with EN_R = FALSE to cancel, or set CONT = FALSE and use TDISCON to close the socket on timeout. The CPU will then re-establish the connection on the next cycle.Connecting Through the EKI-1521 Gateway
The Advantech EKI-1521 presents itself as a TCP server on the LAN side and an RS-232/422/485 master on the field side. The relevant configuration is reachable through the EKI-1521 web interface (default 192.168.10.20, user admin, password admin).
| EKI-1521 setting | Recommended value | Why |
|---|---|---|
| Operation Mode | Modbus Gateway | Translates between Modbus TCP and Modbus RTU on the serial port |
| TCP Port | 502 | Default Modbus port; matches the example in this article |
| Serial Interface | RS-485, 2-wire | Matches the majority of industrial Modbus RTU slaves |
| Baud Rate / Parity / Stop Bits | Match the slave device, e.g. 19200 / Even / 1 | The frame is forwarded byte-for-byte; mismatched settings corrupt the CRC |
| Inter-character Timeout | 3.5 character times (default) | Allows the gateway to detect end-of-frame and forward the response |
| Slave ID Mapping | Transparent (do not remap) | The CPU program already puts the slave address in the RTU frame |
Verification and Testing
Run the verification in this order to catch the most common issues early.
-
Watch-table check. Force
bTriggerin the watch table of the request DB. TheTSEND_Cshould showBUSY = 1for a few scan cycles, thenDONE = 1andSTATUS = 16#0000. -
Wireshark capture. Filter on
tcp.port == 502 && ip.addr == 192.168.10.20and inspect the first eight bytes of the payload. They must match the slave address, function code, start address, quantity, and CRC you expect. If the payload contains an MBAP header (00 01 00 00 00 06 FF), the gateway is in Modbus TCP mode and you should switch toMB_CLIENTinstead. - Slave simulator cross-check. Connect the gateway to a PC running Modbus Poll or QModMaster in slave mode, set the same parameters, and confirm that the simulator sees the same request bytes the CPU is sending.
-
CRC validation in the CPU. In the watch table, observe the computed CRC against the on-wire CRC inside
FB_ParseResponse. A mismatch with theWiresharkcapture almost always points to a byte-order mistake in the request assembly. -
Exception decoding. If the response is consistently
0x83 0x02, the slave is rejecting the start address. Use the slave's register map to correct the offset (some vendors are 0-based, others 1-based; Modbus is conventionally 0-based at the PDU layer, but the device's manual is authoritative). -
Long-run stability. Let the cycle run for at least 10,000 transactions. Watch for
STATUS = 16#80C4(connection terminated by remote), which indicates either a firewall reset on the EKI-1521 or a watchdog timeout on the CPU. Increase the gateway's TCP keep-alive from 30 s to 600 s if it occurs without other symptoms.
Troubleshooting Matrix
| Symptom on the CPU | Likely root cause | Diagnostic step | Fix |
|---|---|---|---|
TSEND_C reports STATUS = 16#80C4 immediately |
TCP port blocked or gateway not in TCP server mode | From the engineering station, Test-NetConnection 192.168.10.20 -Port 502
|
Open the port, enable Modbus Gateway mode on the EKI-1521 |
TSEND_C succeeds, TRCV_C never reports NDR
|
EKI-1521 inter-character timeout too short for the response size | Increase the gateway inter-character timeout to 50 ms | Match the gateway timeout to the slave response time plus 5 ms |
Response received, CRC mismatch in FB_ParseResponse
|
Byte order on the CRC field, or wrong polynomial | Compare the on-wire bytes in Wireshark with the test vector 01 03 00 00 00 0A → CD C5 | Verify the LO-byte-first append in FB_BuildReadHoldingRegs
|
| Exception 0x02 (Illegal Data Address) | Register address offset wrong for the vendor | Read the slave's register map; check whether it is 0-based or 1-based | Adjust wStartAddr by ±1 or the vendor-specified offset |
| Exception 0x04 (Slave Device Failure) | Serial line noise, A/B swapped, or termination missing | Replace terminators, check the A+/B− polarity with a scope | Re-terminate at both ends, fix polarity |
| TCP socket closes after every transaction |
CONT left at default (FALSE) on TSEND_C
|
Watch CONT in the watch table |
Hard-code CONT = TRUE
|
Intermittent STATUS = 16#80A1
|
CPU interface overload or scan time too long | Check OB1 scan time in the diagnostic buffer | Move the Modbus logic to a slower cyclic OB (e.g. OB30 at 100 ms) |
| Slave receives the wrong slave address | Byte 0 of the request overwritten by a stray copy | Inspect the request DB just before TSEND_C fires |
Make sure no other code writes to aTx[0] after build |
FAQ
What is the difference between Modbus RTU over TCP and Modbus TCP?
Modbus TCP adds a 7-byte MBAP header (transaction ID, protocol ID, length, unit ID) and drops the CRC-16. Modbus RTU over TCP keeps the original RTU frame structure (address + function code + data + CRC-16) and transports those raw bytes inside a TCP payload. Use the former with MB_CLIENT; use the latter with TSEND_C/TRCV_C as described in this article.
Can I use the standard MB_CLIENT instruction to talk to a device behind an EKI-1521?
Yes, but only if the EKI-1521 is set to the Modbus TCP server (transparent Modbus) operating mode, in which case it converts MBAP + PDU on the LAN side into RTU on the serial side. If the EKI-1521 is in the raw Modbus Gateway mode and forwards RTU bytes verbatim, you must build the RTU frame yourself and use TSEND_C/TRCV_C.
Why does my slave report exception 0x02 Illegal Data Address even though the address exists?
Many vendors use a 1-based register number in their documentation while Modbus PDU addressing is 0-based. Subtract 1 from the address in the documentation. If that still fails, request a contiguous block one or two registers before and after to find the actual valid range exposed by the slave firmware.
Where can I find a reference Modbus RTU test vector to verify my CRC routine?
The Modbus Organization publishes the Modbus over Serial Line specification (PI_MBUS_300.pdf), which contains a worked example: the request 01 03 00 00 00 0A must yield a CRC of C5CD, transmitted as CD C5 (low byte first). Validate this against your FB_ModbusCRC16 implementation before going live.
How do I get reliable timing on the TCP socket when the slave response is slow?
Drive TRCV_C with an IEC_TIMER (TON) preset to the expected worst-case round trip (e.g. 1000 ms for a 19200 bit/s bus with 10 registers). On timeout, call TRCV_C with EN_R = FALSE to discard the partial buffer and either re-issue the request immediately or escalate the error to the HMI alarm log.