Two PLCs need to share data. No SCADA in the middle, no OPC server, no vendor licence. Modbus TCP over the existing switch is usually the shortest honest path – and this guide walks the whole route, from register map to watchdog.
- What we are building
- Decide who is client and who is server
- Plan the register map first
- Configure the server side
- Configure the client side
- Prove it from a laptop
- Add a watchdog before you go home
- Troubleshooting
What we are building
A line controller (PLC-A) must hand production counts and a run command to a packaging cell (PLC-B), and read the cell status back. Both sit on the same subnet. We will use Modbus TCP: one PLC acts as the server (holds the registers), the other as the client (reads and writes them).
PLC-A (line controller) PLC-B (packaging cell)
192.168.10.11 192.168.10.21
Modbus TCP CLIENT Modbus TCP SERVER :502
| |
| write HR 100..103 (cmd, target) |
|------------------------------------>|
| read HR 200..205 (status, count)|
|<------------------------------------|
| |
+---------- managed switch -----------+
VLAN 10, no uplink
It is not elegant, but it is universal: every PLC brand speaks it, it needs no licence, and you can debug it from a laptop with a free tool. For deterministic motion or safety data, use PROFINET IRT or a safety protocol instead – Modbus has no timing guarantees.
Prerequisites
| Item | Detail |
|---|---|
| Two PLCs | Any brand with a Modbus TCP block: S7-1200/1500, CompactLogix, CODESYS, TwinCAT |
| Network | Same subnet or routed with TCP/502 allowed. Fixed IPs, never DHCP |
| Laptop | For verification – mbpoll or a Python environment |
| Register plan | A written map. Do this before touching either PLC |
Step by step
Decide who is client and who is server
The server owns the data and waits. The client initiates every exchange. Rule of thumb: the PLC that owns the process state is the server; the one that supervises is the client. Two clients talking to each other is a common beginner mistake – nothing happens, and nothing logs an error.
Plan the register map first
Write it down before configuring anything. A shared map avoids the classic off-by-one hunt caused by Modbus documentation mixing 0-based and 1-based addressing.
Configure the server side (PLC-B)
Enable the Modbus TCP server on port 502, point it at a contiguous data block, and make sure that block is not optimised away by the compiler.
Configure the client side (PLC-A)
Create one connection, then schedule reads and writes on a timer – not every scan. 100 to 250 ms is plenty for supervisory data.
Prove it from a laptop before blaming the PLC
An independent Modbus client tells you instantly whether the server is answering. If the laptop can read it, the problem is in the client PLC, not the network.
Add a watchdog
Modbus TCP will happily deliver stale data forever if the link dies mid-cycle. A heartbeat counter and a timeout in the receiving PLC turns a silent failure into a visible alarm.
The register map
| Address | Type | Direction | Meaning |
|---|---|---|---|
| HR 100 | UINT16 | A writes B | Command word: bit0 run, bit1 stop, bit2 reset |
| HR 101 | UINT16 | A writes B | Target batch size |
| HR 102-103 | UDINT | A writes B | Order number (two registers, high word first) |
| HR 200 | UINT16 | B writes A | Status word: bit0 ready, bit1 running, bit2 fault |
| HR 201-202 | UDINT | B writes A | Produced count |
| HR 205 | UINT16 | B writes A | Heartbeat, increments every 500 ms |
Holding register 40001 in classic Modbus documentation is address 0 on the wire. Most modern PLC blocks want the wire address, most HMIs want the 4xxxx form. If your first read returns the value you expected one register later, this is why.
Server side: Siemens S7-1200/1500 example
Call MB_SERVER cyclically in an OB. The data block holding your registers must have optimised block access disabled, otherwise the Modbus mapping cannot resolve absolute offsets.
// OB1 or a cyclic interrupt OB
"MB_SERVER_DB"(
DISCONNECT := FALSE,
MB_HOLD_REG := "ModbusData".Registers, // ARRAY[0..255] OF WORD
CONNECT := "ModbusConn".TCON_Param,
NDR => "mbNewDataReceived",
DR => "mbDataRead",
ERROR => "mbError",
STATUS => "mbStatus"
);
// Heartbeat - proves to the client that we are alive
IF "clk_500ms" THEN
"ModbusData".Registers[205] := "ModbusData".Registers[205] + 1;
END_IF;
Set InterfaceId to the CPU PROFINET interface, ID to a unique connection number, ConnectionType to 11 (TCP), ActiveEstablishment to FALSE on the server, and LocalPort to 502.
Client side: reads and writes on a timer
// One MB_CLIENT instance, alternating between two requests.
// Never issue a new request while DONE and BUSY are both FALSE after an error.
CASE "step" OF
0: // write command block: HR100..103
"MB_CLIENT_DB"(REQ := TRUE, MB_MODE := 1, MB_DATA_ADDR := 40101,
MB_DATA_LEN := 4, MB_DATA_PTR := "TxData".Cmd,
CONNECT := "ClientConn".TCON_Param);
IF "MB_CLIENT_DB".DONE OR "MB_CLIENT_DB".ERROR THEN "step" := 1; END_IF;
1: // read status block: HR200..205
"MB_CLIENT_DB"(REQ := TRUE, MB_MODE := 0, MB_DATA_ADDR := 40201,
MB_DATA_LEN := 6, MB_DATA_PTR := "RxData".Status,
CONNECT := "ClientConn".TCON_Param);
IF "MB_CLIENT_DB".DONE OR "MB_CLIENT_DB".ERROR THEN "step" := 0; END_IF;
END_CASE;
Prove it from a laptop
Before debugging PLC code, confirm the server actually answers. Two registers, one command:
# read 6 holding registers starting at address 200 from the server
mbpoll -m tcp -a 1 -r 201 -c 6 -t 4 -1 192.168.10.21
# write the run bit into the command word
mbpoll -m tcp -a 1 -r 101 -t 4 192.168.10.21 1
Or in Python, which is easier to turn into a permanent test:
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("192.168.10.21", port=502, timeout=2)
c.connect()
rr = c.read_holding_registers(address=200, count=6, slave=1)
status, hb = rr.registers[0], rr.registers[5]
print(f"ready={bool(status & 1)} running={bool(status & 2)} fault={bool(status & 4)}")
print(f"produced={(rr.registers[1] << 16) | rr.registers[2]} heartbeat={hb}")
c.write_registers(address=100, values=[1, 500], slave=1) # run + batch 500
c.close()
A stray write to a command register can start a machine. Test against a bench PLC or with the drive power isolated, and keep the laptop off the production VLAN until the map is proven.
The watchdog nobody adds until it bites
If the cable is pulled, the client PLC keeps the last received values in its data block. Machines have restarted on stale commands because of this. The fix is three lines:
IF "RxData".Heartbeat <> "lastHeartbeat" THEN
"lastHeartbeat" := "RxData".Heartbeat;
"commsTimer" := 0;
ELSE
"commsTimer" := "commsTimer" + 1; // called every 100 ms
END_IF;
"commsFault" := ("commsTimer" > 30); // 3 s without a new heartbeat
IF "commsFault" THEN
"RxData".Status := 0; // force safe state, do not trust old data
END_IF;
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
| No connection at all | Both sides configured as client | One side must have ActiveEstablishment FALSE |
| Connection drops every few seconds | Duplicate connection ID or two clients on one server slot | Server connection limit, usually 8 |
| Values shifted by one register | 0-based versus 1-based addressing | Compare with mbpoll, which uses 1-based by default |
| 32-bit values are nonsense | Word order mismatch | Swap high and low word on one side |
| Works from laptop, not from PLC | Client requesting while previous request is still busy | Sequence requests with DONE and ERROR |
| Random timeouts under load | Polling every scan | Move to a 100-250 ms cyclic interrupt |
Where to go next
Modbus TCP gets two PLCs talking in an afternoon. Once more than two devices need the same data, polling becomes the bottleneck and a publish-subscribe layer starts paying off – that is the subject of the next guide, where the same production counter is published over MQTT Sparkplug B and consumed by three clients at once.
