[ on ] chu .tech
← Blog

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 SemaphoreSlim so 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.fault instead 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.

✉ Book a call