Reusable TwinCAT 3 framework for decoupled, multi-device Modbus RTU communication via a shared FIFO command queue.
ModbusHandler and ModbusDevice form a two-layer architecture: a background task drives all serial I/O through a state-machine-based command processor, while device driver function blocks compose the handler to queue commands and receive results asynchronously. The framework uses a FIFO ring buffer to decouple multiple device drivers from a single shared serial bus, and union-based type punning for zero-overhead endian conversion between big-endian Modbus registers and little-endian TwinCAT memory.
- Queue-based command dispatch: Multiple device drivers push
ST_ModbusCommandentries into a sharedFifoBuffer; the handler processes them one at a time over the serial bus - Asynchronous result notification: Each command carries a
POINTER TO HRESULT; the handler writes the result on completion so callers are never blocked - Dual-task isolation:
ModbusHandlerruns in a dedicated 1 msBGCommtask; device logic runs at 10 ms inPlcTask, preventing serial I/O from blocking control cycles - Endian conversion built-in:
ModbusRealandModbusDintunions provide zero-copy big-endian ↔ little-endian conversion forREAL,DINT, andUDINTtypes - HMI-ready device template:
ST_ModbusDevice_HMIwithTcHmiSymbol.AddSymbolfor automatic TcHMI symbol binding - Configurable timeout and queue depth:
ModbusTimeoutinput andParam.MAXMODBUSBUFFERconstant control runtime behavior - All standard Modbus functions: Read/write coils, read/write registers, input registers, and diagnostics via
E_ModbusFunction - Template-first design:
ModbusDeviceis explicitly a copy-and-adapt template; register layout, data types, and HMI interface are all customization points
- Cyclic sensor reads: Poll multiple Modbus RTU sensors at independent intervals by assigning different
UpdateTimevalues per device instance - Shared bus, multiple slaves: Address up to 247 slave devices via a single EL6001/EL6022 terminal without additional hardware
- Independent error isolation: Each device instance tracks its own error state; one faulty device does not stall the queue
- Live process values: Expose
ActualProcessValue1,ActualProcessValue2, andSerialNumberto TcHMI via theST_ModbusDevice_HMIstruct - Operator commands:
CmdReinitializeandCmdCalibratebooleans let operators trigger actions from HMI without PLC code changes - Status feedback:
HasError,Initialized, andCalibrationSuccededprovide real-time device state to HMI panels
- Register map adaptation: Modify
_Initialize()to match your device's register layout with minimal boilerplate - Triggered writes: Use rising-edge detection (
R_TRIG) on HMI inputs to fire one-shot calibration or configuration writes - Engineering unit conversion: Use or extend the built-in
_ConvertModbusToReal()/_ConvertRealToModbus()helpers for your data types
Queue-based Modbus RTU master implementation with internal state machine. Accepts ST_ModbusCommand entries from any number of device drivers and dispatches them sequentially over the serial bus.
Features:
- FIFO ring buffer (
Tc3_AnyBuffer.FifoBuffer) holds up toMAXMODBUSBUFFER(200) commands - State machine:
WAITING→EXECUTE→COOLDOWN→ERROR - Configurable
ModbusTimeoutper command (defaultT#500MS) - Results written back via
POINTER TO HRESULT— callers poll asynchronously - Logs error codes via
ADSLOGSTRon transition toERRORstate
Performance:
AddToBuffer: O(1) — single FIFO push, returns immediately- Command dispatch: O(1) per cycle — one state machine transition
- Memory: fixed O(n) where n =
MAXMODBUSBUFFER, allocated at startup
Best for: any application with multiple Modbus RTU devices on a single serial bus, shared EL6001/EL6022 terminal, priority-isolated communication task.
Composition-based sample device driver implementation built on top of ModbusHandler. Demonstrates the full pattern: initialization, cyclic reads, triggered writes, endian conversion, and HMI binding.
Features:
- Cyclic register reads triggered by
TONtimer at configurableUpdateTime(defaultT#1000MS) - One-shot serial number read on initialization
- Calibration write triggered via
R_TRIGonhmi.CmdCalibrate - Endian conversion for
REALandUDINTviaModbusReal/ModbusDintunions ST_ModbusDevice_HMIstruct withTcHmiSymbol.AddSymbolattribute for automatic TcHMI symbol registration
Performance:
_Initialize: O(1) — sets register addresses and pointers once on first scan- Cyclic body: O(1) — timer check, queue push, result poll
- Endian conversion: O(1) — union overlay, no copy or shift operations
Best for: copying as a starting point for a new Modbus RTU device driver; demonstrating the queue/result pattern to developers new to the framework.
| ModbusHandler | ModbusDevice | Best For | |
|---|---|---|---|
| Role | Serial command dispatcher | Device driver template | Handler: bus layer; Device: application layer |
| Task | BGComm (1 ms, priority 4) | PlcTask (10 ms, priority 8) | Always run the handler in a dedicated background task |
| Instances | One per serial bus | One per slave device | All device instances share the same handler |
| Extensible | No — use as-is | Yes — copy and adapt | Device: new Modbus slave types |
Declare the shared handler in the background communication program:
// BG_Communication.TcPOU — runs in BGComm task (1 ms, priority 4)
PROGRAM BG_Communication
VAR
modbushandler : ModbusHandler;
END_VAR
modbushandler();
Declare device instances in the main program:
// MAIN.TcPOU — runs in PlcTask (10 ms, priority 8)
PROGRAM MAIN
VAR
ModbusDevice1 : ModbusDevice;
ModbusDevice2 : ModbusDevice;
ModbusDevice3 : ModbusDevice;
END_VAR
ModbusDevice1(
Param := (MBSAdress := 20, UpdateTime := T#1000MS),
ModbusHandler := BG_Communication.modbushandler
);
ModbusDevice2(
Param := (MBSAdress := 30, UpdateTime := T#1000MS),
ModbusHandler := BG_Communication.modbushandler
);
ModbusDevice3(
Param := (MBSAdress := 40, UpdateTime := T#500MS),
ModbusHandler := BG_Communication.modbushandler
);
Submit a command manually from a custom device driver:
VAR
_cmd : ST_ModbusCommand;
_result : HRESULT := S_Pending;
END_VAR
_cmd.ModbusFunction := E_ModbusFunction.ReadRegs;
_cmd.UnitID := 30;
_cmd.MBAddr := 4;
_cmd.Quantity := 2;
_cmd.pMemoryAddr := ADR(_rawBuffer);
_cmd.cbLength := SIZEOF(_rawBuffer);
_cmd.Result := ADR(_result);
ModbusHandler.AddToBuffer(_cmd);
// Later — poll for completion:
IF _result = S_OK THEN
// process _rawBuffer
ELSIF _result = E_FAIL THEN
// handle error
END_IF
Adapt _Initialize() when copying ModbusDevice for a new device:
METHOD _Initialize : BOOL
// Map your device register layout here
_readProcessValue1.UnitID := Param.MBSAdress;
_readProcessValue1.MBAddr := 4; // <-- your register address
_readProcessValue1.Quantity := 2;
_readProcessValue1.pMemoryAddr := ADR(_rawValueProcessValue1);
_readProcessValue1.cbLength := SIZEOF(_rawValueProcessValue1);
_readProcessValue1.Result := ADR(_readProcessValue1Result);
_readProcessValue1.ModbusFunction := E_ModbusFunction.ReadRegs;
classDiagram
class BG_Communication {
+modbushandler : ModbusHandler
}
class MAIN {
+ModbusDevice1 : ModbusDevice
+ModbusDevice2 : ModbusDevice
+ModbusDevice3 : ModbusDevice
+ModbusDevice4 : ModbusDevice
}
class ModbusHandler {
+ModbusTimeout : TIME
+Busy : BOOL
+Error : BOOL
+ErrorId : UDINT
+AddToBuffer(command : ST_ModbusCommand)
}
class ModbusDevice {
+Param : ST_ModbusDeviceParam
+ModbusHandler : REF TO ModbusHandler
+Error : BOOL
+PVProcessValue1 : REAL
+PVProcessValue2 : REAL
-_Initialize()
-_HandleResults()
-_HMIUpdate()
-_ConvertModbusToReal()
-_ConvertModbusToUDint()
-_ConvertRealToModbus()
-_ConvertUdintToModbus()
}
class ST_ModbusCommand {
<<struct>>
+ModbusFunction : E_ModbusFunction
+UnitID : BYTE
+MBAddr : WORD
+Quantity : WORD
+pMemoryAddr : POINTER TO BYTE
+cbLength : UINT
+Result : POINTER TO HRESULT
}
class E_ModbusFunction {
<<enumeration>>
ReadCoils
ReadInputStatus
ReadRegs
ReadInputRegs
WriteSingleCoil
WriteSingleRegister
WriteMultipleCoils
WriteRegs
Diagnostics
}
class ST_ModbusDeviceParam {
<<struct>>
+MBSAdress : BYTE
+UpdateTime : TIME
}
class ST_ModbusDevice_HMI {
<<struct>>
+SerialNumber : UDINT
+ActualProcessValue1 : REAL
+ActualProcessValue2 : REAL
+CalibrationValue : REAL
+HasError : BOOL
+Initialized : BOOL
+CalibrationSucceded : BOOL
+CmdReinitialize : BOOL
+CmdCalibrate : BOOL
}
class ModbusReal {
<<union>>
+rawdata : ARRAY OF WORD
+value : REAL
}
class ModbusDint {
<<union>>
+rawdata : ARRAY OF WORD
+value : DINT
}
BG_Communication *-- ModbusHandler
MAIN *-- ModbusDevice
ModbusDevice o-- ModbusHandler
ModbusDevice *-- ST_ModbusDeviceParam
ModbusDevice *-- ST_ModbusDevice_HMI
ModbusDevice --> ModbusReal
ModbusDevice --> ModbusDint
ModbusHandler --> ST_ModbusCommand
ST_ModbusCommand --> E_ModbusFunction
- Copy the
Components/ModbusHandler/folder into your PLC project - Install
Tc3_AnyBufferfrom the KimRo library repository and add it as a library reference - Add
Tc2_ModbusRTU,Tc2_Standard,Tc2_System, andTc3_Modulelibrary references (Beckhoff Automation) - Create a dedicated background task (e.g., 1 ms, priority ≤ 5) and add a
BG_Communicationprogram to it - Declare a
ModbusHandlerinstance inBG_Communicationand call it once per cycle - Copy
ModbusDeviceSample/ModbusDevice.TcPOUas a template for each new device driver - Configure your EL6001 or EL6022 hardware terminal and link
ModbusRtuMasterV2_KL6x22BI/O variables
- Clone the repository
- Open
Source/Solution/ModbusRTUHandler.slnin TwinCAT XAE (minimum version 3.1.4026.18) - Install
Tc3_AnyBufferby KimRo and resolve all library references - Activate the configuration and connect to target hardware with EL6001 or EL6022
- Build and download —
MAIN.TcPOUinstantiates four devices (addresses 20, 30, 40, 50); device 4 is intentionally misconfigured (invalid handler reference) to demonstrate error handling behavior
- Single Responsibility:
ModbusHandlerowns only serial dispatch;ModbusDeviceowns only device-specific register logic — neither does both - Open/Closed: Add new device drivers by copying and extending
ModbusDevicewithout modifyingModbusHandler - Liskov Substitution: All device drivers use the same
REFERENCE TO ModbusHandlerinput — any compliant handler instance can be substituted - Interface Segregation:
ST_ModbusCommandcarries only what the handler needs; device state and HMI data are isolated inST_ModbusDevice_HMI - Dependency Inversion:
ModbusDevicedepends on theModbusHandlerabstraction passed by reference, not a concrete instance it constructs internally
- State machine:
ModbusHandlercycles throughWAITING→EXECUTE→COOLDOWN→ERROR, enabling non-blocking serial dispatch without recursion or busy-wait - Command queue (FIFO): Device drivers fire-and-forget via
AddToBuffer(); the handler serializes execution independently of caller cycles - Pointer-to-result: Each
ST_ModbusCommandstores aPOINTER TO HRESULT; the handler writes the outcome asynchronously, eliminating polling at the bus level - Composition over inheritance:
ModbusDeviceholds a reference toModbusHandlerrather than extending it, keeping both independently reusable - Union-based type punning:
ModbusRealandModbusDintoverlayWORDarrays withREAL/DINTto convert endianness withoutMEMCPY - Dual-task producer/consumer: The 10 ms
PlcTaskproduces commands; the 1 msBGCommtask consumes them — the FIFO is the only handoff point between tasks
- Always run
ModbusHandlerin a dedicated task with higher priority than your device logic tasks — sharing a task causes slower serial I/O to block PLC cycle execution - Keep
ModbusTimeout(defaultT#500MS) shorter than your application's watchdog or alarm scan interval so Modbus errors surface before higher-level logic times out - Poll the
HRESULTresult pointer only after at least oneBGCommtask cycle — it remainsS_Pendinguntil the handler completes the command - Do not increase
MAXMODBUSBUFFERbeyond the number of commands that can realistically be processed per application cycle; a perpetually full queue silently drops new entries - Use
_ConvertModbusToReal()/_ConvertRealToModbus()as reference implementations when adding support for new data types — theModbusRealunion approach avoids manual pointer arithmetic
The FIFO buffer provided by Tc3_AnyBuffer.FifoBuffer is the synchronization boundary between the 10 ms PlcTask (producers) and the 1 ms BGComm task (consumer). Each AddToBuffer() call is an atomic push into the ring buffer; the handler pops one entry per cycle. Because TwinCAT tasks run on a single-core real-time scheduler with non-preemptive switching at task boundaries, no additional locking is required — provided ModbusHandler() is called exclusively from one task and AddToBuffer() is called exclusively from the other. Do not call ModbusHandler() and AddToBuffer() from the same task.
When a command fails due to a Modbus timeout or device error, ModbusHandler writes E_FAIL to the command's POINTER TO HRESULT, sets its Error output, captures the raw error code in ErrorId, and logs the event via ADSLOGSTR before transitioning through COOLDOWN and resuming. Device drivers detect failure by polling their stored HRESULT and propagate the Error output accordingly.
// Success path
IF _readProcessValue1Result = S_OK THEN
_realConverter.w[0] := _rawValueProcessValue1[1];
_realConverter.w[1] := _rawValueProcessValue1[0];
PVProcessValue1 := _realConverter.r;
END_IF
// Failure path
IF _readProcessValue1Result = E_FAIL THEN
Error := TRUE;
END_IF
Tc2_ModbusRTU: Beckhoff Automation — providesModbusRtuMasterV2_KL6x22B, the hardware-level Modbus RTU master function blockTc2_Standard: Beckhoff Automation — standard IEC 61131-3 blocks (TON,R_TRIG)Tc2_System: Beckhoff Automation — system utilities (MEMCPY,ADSLOGSTR)Tc3_Module: Beckhoff Automation — TwinCAT 3 module supportTc3_AnyBuffer: KimRo —FifoBufferring buffer used as the command queue; third-party, must be installed separately
This project is a Beckhoff internal sample and template. Contributions that improve the clarity of the template pattern, fix correctness issues, or extend hardware compatibility are welcome.
- New hardware support: Tested configurations for additional serial terminals (e.g., EL6002, KL6001)
- Additional data type converters: New union types for
LREAL,INT,LINT, following theModbusReal/ModbusDintpattern - Documentation improvements: Corrections or additions to inline code comments and this README
- Error handling enhancements: Additional error classification in
ModbusHandlerwithout breaking the existing state machine contract
BSD Zero Clause License (0BSD) — use, copy, and modify freely with or without attribution.
- Beckhoff Information System: consult the Beckhoff TwinCAT documentation for TwinCAT runtime, EtherCAT, and library questions
- GitHub Issues: open an issue on this repository for bugs or questions specific to this sample