Industrial HMI & Vision · 2026-04-20
Modbus TCP in C# .NET 8: A Practical Guide for PLC Communication
A working approach to Modbus TCP from .NET 8: reading and writing holding registers, connection lifecycle, timeouts, and the polling loop that survives a PLC restart.
Modbus TCP is how most .NET upper-computer software talks to PLCs. The protocol is simple; production code around it is not. Here’s the setup that has survived factory floors (Xinje and others), with the failure modes handled explicitly.
Read and write the essentials
A minimal request/response over TCP for holding registers — read:
// Read 10 holding registers starting at address 100 (function code 0x03)
byte[] pdu = [0x03, 0x00, 0x64, 0x00, 0x0A];
// MBAP header: transaction id (2) + protocol id (2) + length (2) + unit id (1)
byte[] frame = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x01, ..pdu];
Write single register (0x06) and write multiple (0x10) follow the same shape: PDU in, MBAP wrapper, parse the echoed response. Don’t roll your own if you can avoid it — libraries like NModbus handle the framing. If you do roll your own, the two fields that matter are length (bytes after the length field itself, i.e. unit id + PDU) and transaction id (must match between request and response).
The connection lifecycle
A PLC is not a web API. Treat the TCP connection as disposable and build a reconnection loop:
- Connect once, keep the socket open.
- Every request goes through a
SemaphoreSlimso concurrent HMI screens don’t interleave frames. - On any IOException/TimeoutException: dispose, back off (500 ms → 1 s → 2 s), reconnect, and re-prime the unit id.
- A disconnected PLC must not hang your UI: cap the read timeout (e.g., 500 ms) and report
comm.faultinstead of blocking.
The polling pattern
Don’t read on demand from the UI thread — poll on a worker and publish snapshots:
while (!ct.IsCancellationRequested)
{
try
{
var registers = await ReadHoldingRegistersAsync(100, 10, ct);
_channel.Writer.TryWrite(new PlcSnapshot(registers, DateTime.UtcNow));
}
catch { await Task.Delay(1000, ct); } // reconnect inside
await Task.Delay(100, ct);
}
The UI binds to the latest snapshot; every consumer sees the same values; a hiccup in the PLC bus degrades to a stale snapshot with a fault flag, never a frozen screen.
The three numbers that matter
- Timeout: shorter than your operator’s patience, longer than the PLC scan time
- Retry delay: back off, or you’ll reboot the PLC by request flood
- Poll interval: read fast enough for the fastest screen you render, not faster
Write the loop once, and every PLC project — Xinje, Modbus RTU bridges, EtherCAT gateways — becomes a parameter change, not a rewrite.