Most Modbus tutorials in .NET end at the same place: install a library, call ReadHoldingRegisters, print a number. That is enough to prove a device is reachable. It is not enough to run anything.
The moment you point that code at more than one device and leave it running, the questions change. What happens when a device stops responding? What happens when one slow device blocks the others? Why is that float reading 3.4e38 instead of 480? Where does the data actually go?
This article covers the layer that answers those questions: the application code you build around a Modbus library. Everything here runs against a simulated device included in the code, so you can execute the whole thing locally with nothing but the .NET SDK. All values and device names are illustrative.
A short Modbus refresher
Modbus TCP is a request/reply protocol. A client opens a TCP connection (port 502 by convention) and asks a device for data. The device answers. There is no subscription and no push, so the client polls on a schedule.
Data lives in four spaces:
| Space | Size | Access |
|---|---|---|
| Coils | 1 bit | read/write |
| Discrete inputs | 1 bit | read |
| Input registers | 16 bit | read |
| Holding registers | 16 bit | read/write |
Function code 3 reads holding registers and is the one you will use most. A request carries a transaction ID, a unit ID, a starting address, and a register count. The response carries the raw 16-bit words back.
Two properties of that design drive most of what follows. Registers are only 16 bits wide, so anything larger spans several of them. And the wire format is big-endian, which will not match your CPU.
Just use a library, then keep going
Do not reimplement Modbus framing for production. NModbus, FluentModbus, and EasyModbusTCP all handle the wire protocol, and FluentModbus ships a server implementation that is useful for testing.
What none of them give you is a client. They give you a read call. The distance between a read call and something you can deploy is:
- connection lifecycle across devices that disappear and come back
- a polling schedule per device that tolerates slow or dead peers
- decoding raw registers into typed values you can reason about
- somewhere for the data to go, decoupled from how it was acquired
- enough visibility to know when a device went quiet
That is the custom part. It is specific to your system, which is exactly why no package solves it for you.
The code below writes the framing directly against System.Net.Sockets so the example has zero dependencies and you can read every byte. In a real build, swap that piece for a library and keep the structure around it.
Component 1: decoding registers into typed values
This is where most first attempts break, so start here.
A 32-bit float occupies two consecutive registers. To reconstruct it you concatenate both words in the order the device sent them and read the result as big-endian. Get the order wrong and you do not get an error, you get a plausible-looking wrong number, or a value like 1.7e38.
Rather than scatter that logic through the codebase, describe the device as data:
enum RegisterType { Float32, UInt16Enum }
record RegisterDefinition(string Name, ushort Address, RegisterType Type, string Unit);
record Reading(string DeviceName, string PointName, object Value, string Unit, DateTime TimestampUtc);
static class RegisterDecoder
{
public static object Decode(RegisterDefinition def, ushort[] regs, int offset)
{
switch (def.Type)
{
case RegisterType.Float32:
Span<byte> buf = stackalloc byte[4];
BinaryPrimitives.WriteUInt16BigEndian(buf.Slice(0, 2), regs[offset]);
BinaryPrimitives.WriteUInt16BigEndian(buf.Slice(2, 2), regs[offset + 1]);
return Math.Round(BinaryPrimitives.ReadSingleBigEndian(buf), 2);
case RegisterType.UInt16Enum:
return regs[offset] switch
{
0 => "Stopped",
1 => "Running",
_ => "Fault"
};
default:
throw new NotSupportedException();
}
}
}
A register map then becomes a list, not code:
var map = new List<RegisterDefinition>
{
new("Voltage", 0, RegisterType.Float32, "V"),
new("Frequency", 2, RegisterType.Float32, "Hz"),
new("Status", 4, RegisterType.UInt16Enum, ""),
};
Two things worth knowing before you fight them. Vendors disagree about word order, so some devices need the two registers swapped before decoding, which is why a WordSwapped flag usually appears in the map eventually. And documentation often numbers registers from 1 while the protocol numbers them from 0, which is a very common source of off-by-one addressing.
Component 2: connection management
BinaryPrimitives handles endianness. The socket handles nothing.
Set explicit timeouts. Without them a silent device leaves you waiting on a read that never returns, and one stalled device can quietly stop your entire pipeline.
public async Task ConnectAsync(CancellationToken ct)
{
_client = new TcpClient { ReceiveTimeout = 1000, SendTimeout = 1000 };
await _client.ConnectAsync(_host, _port, ct);
_stream = _client.GetStream();
}
Also, never assume one ReadAsync returns a whole frame. TCP is a stream, not a message queue. A response can arrive in pieces, and the header tells you how many bytes to expect, so read until you have them all:
private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
{
int read = 0;
while (read < buffer.Length)
{
int n = await _stream!.ReadAsync(buffer.AsMemory(read, buffer.Length - read), ct);
if (n == 0) throw new IOException("Connection closed by device.");
read += n;
}
}
Skipping that check is the classic bug that works perfectly on localhost and fails intermittently on a real network.
Component 3: the polling engine
Each device gets its own loop and its own interval. Independent loops mean a dead device cannot delay a healthy one.
Failures are normal, not exceptional. Devices reboot, networks drop, technicians unplug things. The loop treats a failure as an expected state and backs off exponentially instead of hammering a device that is down:
catch (Exception ex)
{
_failures++;
conn?.Dispose();
conn = null;
var delay = TimeSpan.FromMilliseconds(
Math.Min(200 * Math.Pow(2, _failures - 1), 3000));
Console.WriteLine($"{_cfg.Name}: read failed ({ex.GetType().Name}), " +
$"retry #{_failures} in {delay.TotalMilliseconds:F0}ms");
await Task.Delay(delay, ct);
}
One detail is easy to get wrong, and I got it wrong first while writing this. Reset the failure counter after a successful read, not a successful connect. If you reset on connect, a device that accepts connections but immediately drops them looks healthy every cycle, the counter returns to zero, and the backoff never grows. You reconnect in a tight loop forever. The captured output below shows the corrected behavior.
Component 4: a pluggable sink
Polling and delivery are separate concerns. Keep them apart behind an interface:
interface IReadingSink
{
Task WriteAsync(IReadOnlyList<Reading> readings, CancellationToken ct);
}
The demo uses a console sink. A real deployment might implement the same interface over Azure Event Hubs, MQTT, Kafka, or a time-series database, and a batching decorator that buffers readings and flushes on an interval slots in without the polling loop knowing about it.
The payoff is testability. The polling logic never knows where data lands, so you can verify it against a fake sink and a simulator with no cloud account involved.
Running it
The full program starts two simulated devices, points three pollers at them (the third at a port where nothing is listening), and takes one device offline mid-run. Real captured output:
[18:50:21] device-01: connected
[18:50:21] device-02: connected
[18:50:21] device-03: read failed (SocketException), retry #1 in 200ms
[18:50:21] device-01 Voltage = 478.99 V
[18:50:21] device-01 Frequency = 59.92 Hz
[18:50:21] device-01 Status = Running
[18:50:21] device-02 Voltage = 481.08 V
[18:50:21] device-02 Frequency = 59.98 Hz
[18:50:21] device-02 Status = Stopped
[18:50:21] device-03: read failed (SocketException), retry #2 in 400ms
[18:50:22] device-03: read failed (SocketException), retry #3 in 800ms
[18:50:22] device-01 Voltage = 481.09 V
[18:50:22] device-01 Frequency = 60.03 Hz
[18:50:22] device-01 Status = Running
[18:50:22] device-03: read failed (SocketException), retry #4 in 1600ms
[18:50:23] device-02 Voltage = 481.94 V
[18:50:23] device-02 Frequency = 59.92 Hz
[18:50:23] device-02 Status = Stopped
[18:50:24] device-03: read failed (SocketException), retry #5 in 3000ms
[18:50:25] *** simulating device-02 going offline ***
[18:50:25] device-01 Voltage = 479.28 V
[18:50:25] device-01 Frequency = 60.1 Hz
[18:50:25] device-01 Status = Fault
[18:50:27] device-02: read failed (IOException), retry #1 in 200ms
[18:50:27] device-03: read failed (SocketException), retry #6 in 3000ms
[18:50:27] device-01 Voltage = 480.82 V
[18:50:27] device-01 Frequency = 60.04 Hz
[18:50:27] device-01 Status = Fault
[18:50:27] device-02: connected
[18:50:27] device-02: read failed (IOException), retry #2 in 400ms
[18:50:28] device-02: connected
[18:50:28] device-02: read failed (IOException), retry #3 in 800ms
[18:50:28] device-01 Voltage = 478.37 V
[18:50:28] device-01 Frequency = 59.93 Hz
[18:50:28] device-01 Status = Running
[18:50:28] device-02: connected
[18:50:28] device-02: read failed (IOException), retry #4 in 1600ms
Shutdown complete.
The behavior worth noting: device-01 polls on schedule the entire time, unaffected by two failing neighbors. device-03, which never existed, walks its backoff up to the 3 second ceiling instead of spinning. device-02 degrades gracefully when pulled offline, and its retry counter climbs correctly because it only resets on a successful read.
What this buys you
Failure isolation. One bad device costs you that device, not the fleet.
Testability. A simulator plus a fake sink means the whole pipeline runs in CI.
Decoupled evolution. Protocol handling, decoding, and delivery change independently. Swapping Event Hubs for Kafka touches one class.
Configuration over code. New device model, new register map entry, no redeploy of polling logic.
Operability. Per-device failure counts and timestamps tell you which device went quiet and when.
When not to build this
If you have one device, read it occasionally, and can tolerate a crash and restart, use a library directly in twenty lines and stop reading. The structure here earns its cost when you have a fleet, uneven reliability, and a downstream system that expects a steady stream.
If your devices already speak MQTT or OPC UA, prefer those. Modbus is a 1979 protocol with no security model and no push, and you should not add a polling layer you do not need.
Takeaways
The interesting problems in industrial data collection are not in the protocol. They are in everything wrapped around it: endianness, partial reads, timeouts, backoff, isolation, and a clean seam between acquisition and delivery.
Four rules worth keeping:
- Treat the register map as configuration, not code.
- Assume every read can fail, and back off when it does.
- Reset failure counters on successful reads, not successful connects.
- Keep the sink behind an interface so polling never knows where data goes.
The complete runnable program, simulator included, is roughly 330 lines and requires only the .NET SDK.
Top comments (0)