A lightweight Modbus RTU master library for C# applications targeting .NET Framework 4.8. ModBusLIB builds and validates RTU frames, communicates through System.IO.Ports.SerialPort, dispatches typed response events, and provides configurable request timeouts and retries without external NuGet dependencies.
- Modbus RTU master communication over a serial port
- Function codes
0x01,0x02,0x03,0x04,0x05,0x06,0x0F, and0x10 - CRC16/MODBUS generation and validation
- Slave IDs from 1 to 247
- RTU frame boundary detection using the Modbus
t3.5silent interval - One outstanding request at a time with response matching
- Configurable timeout and retry handling
- Standard 5-byte Modbus exception response support
- Decoded
bool[]values for coil/discrete-input reads - Decoded
ushort[]values for register reads - Protocol quantity and address-range validation
- Automated regression tests on GitHub Actions
| Method | Function | Operation | Maximum quantity |
|---|---|---|---|
ReadCoils |
0x01 |
Read Coils | 2000 bits |
ReadDiscreteInputs |
0x02 |
Read Discrete Inputs | 2000 bits |
ReadHoldingRegisters |
0x03 |
Read Holding Registers | 125 registers |
ReadInputRegisters |
0x04 |
Read Input Registers | 125 registers |
WriteSingleCoil |
0x05 |
Write Single Coil | 1 coil |
WriteSingleRegister |
0x06 |
Write Single Register | 1 register |
WriteMultipleCoils |
0x0F |
Write Multiple Coils | 1968 bits |
WriteMultipleRegisters |
0x10 |
Write Multiple Registers | 123 registers |
Addresses are written directly to the 16-bit Modbus protocol address field. Human-readable reference notation such as 40001 is not converted automatically.
- Windows
- .NET Framework 4.8
- A serial interface supported by
System.IO.Ports.SerialPort - Visual Studio with the .NET Framework 4.8 targeting pack, or an equivalent MSBuild environment
The library has no external NuGet dependencies.
Open ModBusLIB.sln in Visual Studio and build the solution using Debug | Any CPU or Release | Any CPU.
The resulting assembly is generated under:
ModBusLIB/bin/Debug/ModBusLIB.dll
or:
ModBusLIB/bin/Release/ModBusLIB.dll
Add the DLL as a reference to your .NET Framework application.
Initialize the library before sending requests:
var bus = new ModBus();
bus.Initialize("COM3", 115200, Parity.Even);The serial format uses 8 data bits. Even or odd parity uses one stop bit. Parity.None uses two stop bits.
For baud rates above 19200, RTU timing uses fixed values of 750 µs for t1.5 and 1750 µs for t3.5. At lower baud rates the timing values are calculated from the baud rate.
Call Close() when communication is finished.
using System;
using System.IO.Ports;
using ModBusLIB;
class Program
{
static void Main()
{
var bus = new ModBus
{
ResponseTimeoutMs = 1000,
MaxRetries = 1
};
bus.ReadHoldingRegistersResponseHandler += (sender, e) =>
{
if (e.IsException)
{
Console.WriteLine($"Modbus exception: 0x{e.ExceptionCode:X2}");
return;
}
foreach (ushort value in e.Registers)
Console.WriteLine(value);
};
bus.RequestTimeoutHandler += (sender, e) =>
{
Console.WriteLine(
$"Timeout: slave={e.SlaveId}, function=0x{e.Function:X2}, retries={e.Retries}");
};
bus.Initialize("COM3", 115200, Parity.Even);
try
{
bus.ReadHoldingRegisters(1, 0, 2);
Console.ReadLine();
}
finally
{
bus.Close();
}
}
}Write one register with function 0x06:
bus.WriteSingleRegister(1, 10, 1234);Write multiple registers with function 0x10:
ushort[] values = { 100, 200, 300 };
bus.WriteMultipleRegisters(1, 10, (ushort)values.Length, values);Only one request may be outstanding at a time. Sending another request before the current request completes or times out throws InvalidOperationException.
For a matching response, response validation, request metadata capture, and completion of the outstanding request are performed atomically under the request-state lock. This prevents lifecycle or concurrent state changes from separating validation from request completion.
A single coil is written with a Boolean value:
bus.WriteSingleCoil(1, 5, true);For WriteMultipleCoils, coil values are supplied as packed bytes in Modbus bit order:
byte[] packedCoils = { 0b00000101 }; // coils 0 and 2 are ON
bus.WriteMultipleCoils(1, 0, 8, packedCoils);Each supported function code has a corresponding response event:
| Event | Function |
|---|---|
ReadCoilsResponseHandler |
0x01 |
ReadDiscreteInputsResponseHandler |
0x02 |
ReadHoldingRegistersResponseHandler |
0x03 |
ReadInputRegistersResponseHandler |
0x04 |
WriteSingleCoilResponseHandler |
0x05 |
WriteSingleRegisterResponseHandler |
0x06 |
WriteMultipleCoilsResponseHandler |
0x0F |
WriteMultipleRegistersResponseHandler |
0x10 |
ReadResponseArgs contains:
| Property | Description |
|---|---|
Frame |
Complete received RTU frame, including slave address and CRC |
CrcOk |
CRC validation result for a dispatched response |
SlaveId |
Slave address from the response |
Function |
Original Modbus function code |
StartAddress |
Start address captured from the matching request |
RequestedQuantity |
Requested bit/register quantity; 1 for FC05/FC06 |
IsException |
true for a Modbus exception response |
ExceptionCode |
Modbus exception code |
Bits |
Decoded bit values for FC01/FC02 responses |
Registers |
Decoded 16-bit values for FC03/FC04 responses |
Frames with an invalid CRC or frames that do not match the outstanding request are discarded rather than dispatched as normal responses.
The default timeout is 1000 ms and retries are disabled by default:
bus.ResponseTimeoutMs = 1000;
bus.MaxRetries = 0;To retry a request up to two times:
bus.MaxRetries = 2;If all attempts expire, RequestTimeoutHandler is raised with the slave ID, function code, and retry count. Once the timeout is completed, the request slot becomes available for the next request.
If an RTU response has started arriving when the request deadline is reached, timeout processing is deferred while bytes are still arriving. The deferral is bounded by the RTU frame boundary: once no new byte has arrived for t3.5, normal packet processing runs before timeout handling.
The public request methods reject invalid Modbus parameters before serialization, including:
- slave IDs outside 1–247
- zero or excessive quantities
- address ranges extending beyond the 16-bit Modbus address space
- insufficient packed coil data
- insufficient register data
The implementation supports full-size FC0F and FC10 request frames up to the Modbus RTU 256-byte ADU limit; the maximum requests generated by these functions are 255 bytes.
This section covers every public member exposed by ModBus.
Object state and configuration
var bus = new ModBus();
// Configurable before requests are sent.
bus.ResponseTimeoutMs = 1000;
bus.MaxRetries = 1;
bus.Initialize("COM3", 115200, Parity.Even);
SerialPort serialPort = bus.Port; // Read-only reference to the active SerialPort.
int crcErrors = bus.CrcFailCount;| Member | Type | Purpose |
|---|---|---|
IsOpen |
bool |
Whether the internal serial transport is open. |
PortName |
string |
Active serial port name, or null when closed. |
CrcFailCount |
int |
Number of received frames rejected because their CRC was invalid. |
ResponseTimeoutMs |
int |
Time allowed for a request attempt before retry/timeout processing. Default: 1000 ms. |
MaxRetries |
int |
Number of retransmissions after the initial attempt. Default: 0. |
Initialize
bus.Initialize("COM3"); // 115200, Even parity
bus.Initialize("COM3", 9600, Parity.Even);
bus.Initialize("COM3", 19200, Parity.None);Signature:
void Initialize(string portName, int baudRate = 115200, Parity parity = Parity.Even)The library opens the port during this call. The underlying SerialPort is intentionally kept internal; use IsOpen and PortName to inspect connection state. It uses 8 data bits; Even/Odd parity uses one stop bit and Parity.None uses two stop bits.
Close
bus.Close();Stops the receive worker cooperatively, resets receive state, stops the internal timer, and closes the serial port.
FC01 — ReadCoils
bus.ReadCoilsResponseHandler += (sender, e) =>
{
if (e.IsException)
{
Console.WriteLine($"Exception 0x{e.ExceptionCode:X2}");
return;
}
// bits[0] corresponds to the first requested coil.
for (int i = 0; i < e.Bits.Length; i++)
Console.WriteLine($"Bit {i}: {e.Bits[i]}");
};
bus.ReadCoils(1, 0, 8);Signature: void ReadCoils(byte slaveId, ushort start, ushort count). Quantity: 1–2000 coils. Coil data is decoded LSB-first into ReadResponseArgs.Bits. Bits.Length exactly matches the requested quantity; padding bits in the final RTU data byte are not exposed.
FC02 — ReadDiscreteInputs
bus.ReadDiscreteInputsResponseHandler += (sender, e) =>
{
if (!e.IsException)
{
bool input0 = e.Bits[0];
Console.WriteLine($"Input 0: {input0}");
}
};
bus.ReadDiscreteInputs(1, 0, 8);Signature: void ReadDiscreteInputs(byte slaveId, ushort start, ushort count). Quantity: 1–2000 inputs. Decoded values are returned in Bits.
FC03 — ReadHoldingRegisters
bus.ReadHoldingRegistersResponseHandler += (sender, e) =>
{
if (!e.IsException)
{
for (int i = 0; i < e.Registers.Length; i++)
Console.WriteLine($"Holding register {i}: {e.Registers[i]}");
}
};
bus.ReadHoldingRegisters(1, 0, 2);Signature: void ReadHoldingRegisters(byte slaveId, ushort start, ushort count). Quantity: 1–125 registers. Register bytes are decoded big-endian into ushort[] Registers.
FC04 — ReadInputRegisters
bus.ReadInputRegistersResponseHandler += (sender, e) =>
{
if (!e.IsException)
Console.WriteLine($"Input register: {e.Registers[0]}");
};
bus.ReadInputRegisters(1, 0, 1);Signature: void ReadInputRegisters(byte slaveId, ushort start, ushort count). Quantity: 1–125 registers. Decoded values are returned in Registers.
FC05 — WriteSingleCoil
bus.WriteSingleCoilResponseHandler += (sender, e) =>
{
if (e.IsException)
Console.WriteLine($"Write failed: 0x{e.ExceptionCode:X2}");
else
Console.WriteLine("Coil write acknowledged.");
};
bus.WriteSingleCoil(1, 5, true);Signature: void WriteSingleCoil(byte slaveId, ushort address, bool coilValue). true is serialized as FF 00; false as 00 00. A normal FC05 response must echo the request fields and is validated against the outstanding request.
FC06 — WriteSingleRegister
bus.WriteSingleRegisterResponseHandler += (sender, e) =>
{
if (!e.IsException)
Console.WriteLine("Register write acknowledged.");
};
int frameLength = bus.WriteSingleRegister(1, 10, 1234);
Console.WriteLine($"TX frame length: {frameLength}");Signature: int WriteSingleRegister(byte slaveId, ushort address, ushort value). A normal FC06 response is validated as an echo of the request. The return value is the serialized request-frame length.
FC0F — WriteMultipleCoils
bus.WriteMultipleCoilsResponseHandler += (sender, e) =>
{
if (!e.IsException)
Console.WriteLine("Multiple-coil write acknowledged.");
};
// LSB-first: bit 0 and bit 2 are ON.
byte[] coilData = { 0b00000101 };
int frameLength = bus.WriteMultipleCoils(1, 0, 8, coilData);Signature: int WriteMultipleCoils(byte slaveId, ushort start, ushort count, byte[] data). Quantity: 1–1968 coils. data must contain at least ceil(count / 8) bytes. The response's start address and quantity are checked against the request. The method returns the serialized request-frame length.
FC10 — WriteMultipleRegisters
bus.WriteMultipleRegistersResponseHandler += (sender, e) =>
{
if (!e.IsException)
Console.WriteLine("Multiple-register write acknowledged.");
};
ushort[] values = { 100, 200, 300 };
int frameLength = bus.WriteMultipleRegisters(
1, 10, (ushort)values.Length, values);Signature: int WriteMultipleRegisters(byte slaveId, ushort start, ushort count, ushort[] data). Quantity: 1–123 registers. data must contain at least count elements. Values are serialized high byte first. The response's start address and quantity are checked against the request. The method returns the serialized request-frame length.
ReadResponseArgs
All eight function-specific response events use ReadResponseArgs:
bus.ReadHoldingRegistersResponseHandler += (sender, e) =>
{
Console.WriteLine($"Slave: {e.SlaveId}");
Console.WriteLine($"CRC valid: {e.CrcOk}");
if (e.IsException)
{
Console.WriteLine($"Exception code: 0x{e.ExceptionCode:X2}");
return;
}
byte[] rawFrame = e.Frame;
ushort[] decodedRegisters = e.Registers; // FC03 / FC04
bool[] bits = e.Bits; // FC01 / FC02
};Despite its historical name, Frame contains the complete received RTU frame, including slave address and CRC. Each Frame access returns a defensive copy, so consumer mutations cannot alter the response snapshot held by the library. Function, StartAddress, and RequestedQuantity correlate the response with the completed request. Invalid-CRC frames are discarded before an event is raised, so normally dispatched responses have CrcOk == true. Bits is populated only for successful FC01/FC02 reads, and Registers only for successful FC03/FC04 reads.
Modbus exception responses
A Modbus exception response is delivered through the event belonging to the original function:
bus.ReadHoldingRegistersResponseHandler += (sender, e) =>
{
if (e.IsException)
{
Console.WriteLine($"Slave {e.SlaveId} returned exception 0x{e.ExceptionCode:X2}");
return;
}
// Process e.Registers here.
};The library accepts standard 5-byte RTU exception frames and exposes the exception code through ExceptionCode.
RequestTimeoutHandler and RequestTimeoutArgs
bus.ResponseTimeoutMs = 500;
bus.MaxRetries = 2;
bus.RequestTimeoutHandler += (sender, e) =>
{
Console.WriteLine($"Slave: {e.SlaveId}");
Console.WriteLine($"Function: 0x{e.Function:X2}");
Console.WriteLine($"Retries performed: {e.Retries}");
};RequestTimeoutArgs exposes SlaveId, Function, and Retries. The event is raised only after the initial attempt and all configured retries have expired.
CallbackExceptionHandler and CallbackExceptionArgs
Response and timeout callbacks are isolated from the Modbus worker. If application callback code throws, the worker continues running and the failure is reported through CallbackExceptionHandler:
bus.CallbackExceptionHandler += (sender, e) =>
{
Console.WriteLine($"Callback {e.CallbackName} failed: {e.Exception}");
};CallbackExceptionArgs exposes CallbackName and Exception. Exceptions thrown by a diagnostic callback are also contained so they cannot terminate the worker thread.
One outstanding request rule
Every public Modbus request method participates in the same request-tracking mechanism:
bus.ReadHoldingRegisters(1, 0, 1);
// Do not send another request until the response event or final timeout.
// This throws InvalidOperationException while the first request is pending:
// bus.ReadInputRegisters(1, 0, 1);A response is accepted only when it matches the active request's slave ID and function. Read responses are additionally checked for the expected byte count, while write responses are checked against the expected echo/address/quantity fields.
Complete lifecycle example
var bus = new ModBus
{
ResponseTimeoutMs = 1000,
MaxRetries = 1
};
bus.ReadHoldingRegistersResponseHandler += (sender, e) =>
{
if (e.IsException)
Console.WriteLine($"Exception: 0x{e.ExceptionCode:X2}");
else
Console.WriteLine($"Value: {e.Registers[0]}");
};
bus.RequestTimeoutHandler += (sender, e) =>
{
Console.WriteLine($"No response from slave {e.SlaveId}");
};
bus.Initialize("COM3", 9600, Parity.Even);
try
{
bus.ReadHoldingRegisters(1, 0, 1);
Console.ReadLine();
}
finally
{
bus.Close();
}Serial receive processing and RTU frame completion run on background threads. Shared receive state is synchronized internally.
Response and timeout event handlers are therefore not guaranteed to execute on a UI thread. Windows Forms or WPF applications should marshal UI updates to their UI thread.
Close() stops the worker thread cooperatively, clears internal request/RX state, stops the internal timer, and closes/disposes the transport. If transport close or dispose fails, library state cleanup is still completed first and the transport exception is then propagated to the caller.
ModBusLIB.Tests is a dependency-free regression test executable. The test suite covers:
- known CRC16/MODBUS vectors
- request serialization
- maximum FC0F and FC10 frame sizes
- slave and function matching
- read byte-count validation
- standard 5-byte exception responses
- write-response echo validation
- register and coil decoding
- Modbus quantity and address limits
- transport initialization/close/dispose failure cleanup
GitHub Actions builds the .NET Framework 4.8 library and test executable on Windows and runs the regression suite on pushes to master.
The automated suite validates protocol logic without requiring physical Modbus hardware. Serial-driver behavior, RS-485 direction control, electrical-layer behavior, and device-specific timing still require hardware testing.
ModBusLIB.sln
├── ModBusLIB/
│ ├── ModBus.cs
│ ├── ModBusLIB.csproj
│ └── Properties/
├── ModBusLIB.Tests/
│ ├── ModBusLIB.Tests.csproj
│ └── Program.cs
└── .github/workflows/
├── modbus-tests.yml
├── release.yml
└── sign-commits.yml
TivaC-MODBUS is a Windows Forms application that uses ModBusLIB for Modbus RTU communication.
This project is licensed under the MIT License.