Skip to main content

Modbus

The Modbus module reads and writes coils and registers without hand-building function codes, CRC, or MBAP headers.

Three Names to Keep Separate

NameExampleMeaning
Channel namebusZeus name for the communication line: serial, TCP, UDP, or virtual
Device nameovenZeus name for this device
Unit idunitId: 1Modbus slave address / unit identifier

Many setup issues come from mixing these names. bus is not a COM port, oven is not the Modbus unit id, and unitId is not the Zeus device name.

Start with a Virtual Slave

var memory = new ModbusSlaveMemory();
memory.HoldingRegisters[0] = 185;

await using var app = ZeusHost.Create(builder =>
{
builder.AddVirtualChannel("bus", new ModbusSlaveResponder(1, ModbusTransport.Rtu, memory));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
});
});

await app.StartAsync();

var oven = app.Devices.Get<ModbusDevice>("oven");
var values = await oven.ReadHoldingRegistersAsync(0, 1);
var temperature = app.Points.Get<double>("temperature");

Run sample: samples/Zeus.Samples.Console.Modbus.

Field Serial RTU

builder.AddSerialPort("bus", "COM3", 9600);
builder.AddModbusRtu("oven", "bus", unitId: 1);

The second argument of AddModbusRtu is still the Zeus channel name bus, not COM3.

Field TCP

Modbus TCP commonly uses port 502:

builder.AddTcpClient("bus", "192.168.1.10", 502);
builder.AddModbusTcp("oven", "bus", unitId: 1);

unitId can still matter when a TCP gateway routes to an RTU bus.

Read/Write APIs

var oven = app.Devices.Get<ModbusDevice>("oven");

var holding = await oven.ReadHoldingRegistersAsync(address: 0, quantity: 2);
var input = await oven.ReadInputRegistersAsync(address: 0, quantity: 2);
var coils = await oven.ReadCoilsAsync(address: 0, quantity: 8);
var discrete = await oven.ReadDiscreteInputsAsync(address: 0, quantity: 8);

await oven.WriteSingleRegisterAsync(address: 1, value: 200);
await oven.WriteSingleCoilAsync(address: 2, value: true);
await oven.WriteMultipleRegistersAsync(address: 10, values: [100, 200]);
await oven.WriteMultipleCoilsAsync(address: 20, values: [true, false, true]);
await oven.MaskWriteRegisterAsync(address: 30, andMask: 0xFFF0, orMask: 0x0005);

Supported diagnostic and extended operations include exception status 07, diagnostics return query data 08, report server id 11, mask write 16, and read/write multiple registers 17.

Addressing Starts at 0

Zeus uses the wire-protocol address, which starts at 0.

Manual notationUsually in Zeus
40001address: 0 using holding register APIs
40002address: 1
30001address: 0 using input register APIs
00001address: 0 using coil APIs

If a manual explicitly uses 0-based addresses, follow the manual.

Point Acquisition and Write-Back

builder.AddAcquisition(TimeSpan.FromMilliseconds(500));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
map.HoldingRegister("setpoint", 1, 0.1).Writable("setpoint");
map.Coil("heater", 2).Writable("heater");
});

var temperature = app.Points.Get<double>("temperature");
await app.Points.WriteAsync("setpoint", 80.0);
await app.Points.WriteAsync("heater", true);

WriteAsync receives engineering values. With scale: 0.1, writing 80.0 stores raw register value 800.

Common Issues

SymptomLikely causeFix
TimeoutUnit id, serial parameters, RTU/TCP mode, or field wiring is wrongVerify the host with a virtual slave, then check field settings
Illegal data addressAddress base or quantity is wrongCheck whether 40001 should become 0
CRC failureBaud/parity/stop bits mismatch or line noiseCompare with a serial tool trace
TCP transaction id mismatchConcurrent requests on one TCP channelSerialize requests or split channels
Value is 10x too largeMissing scaleUse HoldingRegister(..., 0.1)
Write-back rejectedPoint is not writable or target area is read-onlyMark holding registers/coils with .Writable

Next: Points and Acquisition.