Connect Two PLCs Over Modbus TCP: A Practical Guide

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.

On this page
  1. What we are building
  2. Decide who is client and who is server
  3. Plan the register map first
  4. Configure the server side
  5. Configure the client side
  6. Prove it from a laptop
  7. Add a watchdog before you go home
  8. 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
Why Modbus TCP

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

ItemDetail
Two PLCsAny brand with a Modbus TCP block: S7-1200/1500, CompactLogix, CODESYS, TwinCAT
NetworkSame subnet or routed with TCP/502 allowed. Fixed IPs, never DHCP
LaptopFor verification – mbpoll or a Python environment
Register planA 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

AddressTypeDirectionMeaning
HR 100UINT16A writes BCommand word: bit0 run, bit1 stop, bit2 reset
HR 101UINT16A writes BTarget batch size
HR 102-103UDINTA writes BOrder number (two registers, high word first)
HR 200UINT16B writes AStatus word: bit0 ready, bit1 running, bit2 fault
HR 201-202UDINTB writes AProduced count
HR 205UINT16B writes AHeartbeat, increments every 500 ms
The off-by-one trap

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;
Connection parameters

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()
Do not test on a live line

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

SymptomLikely causeCheck
No connection at allBoth sides configured as clientOne side must have ActiveEstablishment FALSE
Connection drops every few secondsDuplicate connection ID or two clients on one server slotServer connection limit, usually 8
Values shifted by one register0-based versus 1-based addressingCompare with mbpoll, which uses 1-based by default
32-bit values are nonsenseWord order mismatchSwap high and low word on one side
Works from laptop, not from PLCClient requesting while previous request is still busySequence requests with DONE and ERROR
Random timeouts under loadPolling every scanMove 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.

More from the knowledge base