Modbus TCP I/O Provider Guide¶
Overview¶
PiPLC supports Modbus TCP for industrial I/O communication with both client (master) and server (slave) capabilities:
- Modbus TCP Client Provider: Polls external Modbus devices (remote I/O modules, sensors, drives) and maps their data to PLC memory
- Modbus TCP Server Service: Exposes PLC memory to external SCADA/HMI systems
This enables PiPLC to integrate with standard industrial equipment using the widely-supported Modbus protocol.
Requirements¶
- PiPLC (any platform: Windows, Linux x86, ARM)
- Network connectivity to Modbus devices
- No external libraries required (uses Qt Network)
Modbus Concepts¶
Data Types¶
| Modbus Type | Function Codes | Read/Write | PLC Region |
|---|---|---|---|
| Discrete Inputs | FC02 | Read-only | I: (inputs) |
| Coils | FC01, FC05, FC15 | Read/Write | O: (outputs) |
| Input Registers | FC04 | Read-only | N: (integers) |
| Holding Registers | FC03, FC06, FC16 | Read/Write | N: (integers) |
Addressing¶
Modbus addresses are 16-bit (0–65535). The mapping to PLC addresses is configured in the .ioconfig file.
Client Provider (I/O Provider)¶
The Modbus TCP Client Provider acts as a Modbus master, polling an external Modbus slave device and mapping its data to PLC memory.
Use Cases¶
- Reading sensors from a remote I/O module
- Controlling VFDs or motor drives
- Communicating with PLCs from other vendors
- Interfacing with Modbus-enabled instruments
Configuration¶
Create a .ioconfig file with provider: "modbus_tcp_client":
{
"version": 1,
"provider": "modbus_tcp_client",
"modbusClient": {
"host": "192.168.1.100",
"port": 502,
"unitId": 1,
"timeoutMs": 1000,
"pollIntervalMs": 100,
"discreteInputMappings": [
{ "modbusAddr": 0, "plcWord": 0, "plcBit": 0 },
{ "modbusAddr": 1, "plcWord": 0, "plcBit": 1 }
],
"coilMappings": [
{ "modbusAddr": 0, "plcWord": 0, "plcBit": 0 },
{ "modbusAddr": 1, "plcWord": 0, "plcBit": 1 }
],
"inputRegisterMappings": [
{ "modbusAddr": 0, "plcNWord": 0 },
{ "modbusAddr": 1, "plcNWord": 1 }
],
"holdingRegisterMappings": [
{ "modbusAddr": 0, "plcNWord": 10 },
{ "modbusAddr": 1, "plcNWord": 11 }
]
}
}
Configuration Fields¶
| Field | Description | Default |
|---|---|---|
host | Modbus server hostname or IP address | 127.0.0.1 |
port | Modbus TCP port | 502 |
unitId | Modbus unit/slave ID (1-247) | 1 |
timeoutMs | Response timeout in milliseconds | 1000 |
pollIntervalMs | Polling interval in milliseconds | 100 |
Mapping Fields¶
Discrete Input / Coil Mappings:
| Field | Description |
|---|---|
modbusAddr | Modbus address (0-65535) |
plcWord | PLC word number (I: for inputs, O: for outputs) |
plcBit | Bit within the word (0-15) |
Register Mappings:
| Field | Description |
|---|---|
modbusAddr | Modbus register address (0-65535) |
plcNWord | PLC N: word to map this register to |
Data Flow¶
┌─────────────────────┐ ┌──────────────────────┐
│ External Modbus │ │ PiPLC Engine │
│ Device (Slave) │ │ │
│ │ │ I: ← Discrete Inputs│
│ Discrete Inputs ───┼──FC02────▶│ O: → Coils │
│ Coils ◀───┼──FC05────┤│ N: ← Input Registers│
│ Input Registers ───┼──FC04────▶│ N: ↔ Holding Regs │
│ Holding Registers◀─┼──FC06────┤│ │
└─────────────────────┘ └──────────────────────┘
Example: Reading a Temperature Sensor¶
Assume a Modbus temperature sensor at address 192.168.1.50 with: - Temperature value in holding register 0 (scaled 0-10000 for 0-100.0°C) - Alarm status in discrete input 0
{
"version": 1,
"provider": "modbus_tcp_client",
"modbusClient": {
"host": "192.168.1.50",
"port": 502,
"unitId": 1,
"pollIntervalMs": 500,
"discreteInputMappings": [
{ "modbusAddr": 0, "plcWord": 0, "plcBit": 0 }
],
"inputRegisterMappings": [
{ "modbusAddr": 0, "plcNWord": 0 }
]
}
}
Ladder logic to check temperature and alarm:
Rung 0: |--[ GRT N:0 500 ]--[ OTE O:0/0 ]--| (Temp > 50.0°C → Output 0)
Rung 1: |--[ XIC I:0/0 ]--[ OTE O:0/1 ]--| (Alarm input → Output 1)
Example: Controlling a VFD¶
Assume a VFD at address 192.168.1.60 with: - Run command at coil 0 - Speed setpoint at holding register 0 (0-10000 for 0-100.00 Hz) - Actual speed at input register 0
{
"version": 1,
"provider": "modbus_tcp_client",
"modbusClient": {
"host": "192.168.1.60",
"port": 502,
"unitId": 1,
"pollIntervalMs": 100,
"coilMappings": [
{ "modbusAddr": 0, "plcWord": 0, "plcBit": 0 }
],
"inputRegisterMappings": [
{ "modbusAddr": 0, "plcNWord": 0 }
],
"holdingRegisterMappings": [
{ "modbusAddr": 0, "plcNWord": 10 }
]
}
}
Ladder logic:
Rung 0: |--[ XIC I:0/0 ]--[ OTE O:0/0 ]--| (Start button → Run command)
Rung 1: |--[ XIC I:0/0 ]--[ MOV 5000 N:10 ]--| (Set speed to 50.00 Hz when running)
Rung 2: |--[ XIO I:0/0 ]--[ MOV 0 N:10 ]--| (Clear speed when stopped)
Server Service¶
The Modbus TCP Server Service exposes PLC memory to external Modbus clients (SCADA, HMI, other PLCs).
Use Cases¶
- Providing data to SCADA systems
- Interfacing with operator HMI panels
- Allowing other devices to read PLC status
- Enabling third-party monitoring software
Architecture¶
┌─────────────────────┐ ┌──────────────────────┐
│ External SCADA/HMI │ │ PiPLC Engine │
│ (Modbus Master) │ │ │
│ │ │ I: → Discrete Inputs│
│ Read Inputs ────┼──FC02────▶│ O: ↔ Coils │
│ Read/Write Coils◀──┼──FC01/05─┤│ N: → Input Registers│
│ Read Registers ────┼──FC03/04─▶│ N: ↔ Holding Regs │
│ Write Registers◀───┼──FC06/16─┤│ │
└─────────────────────┘ └──────────────────────┘
Quick Start with piplc-engine¶
The easiest way to run a Modbus TCP server is using the --modbus-server flag with piplc-engine:
# Basic usage (requires root for port 502)
sudo piplc-engine --modbus-server
# Use alternate port (no root required)
piplc-engine --modbus-server --modbus-port 1502
# Full options
piplc-engine --modbus-server --modbus-port 1502 --modbus-unit-id 1
Default Memory Mapping:
| PLC Region | Modbus Type | Modbus Address |
|---|---|---|
I:0/0-15 | Discrete Inputs (FC02) | 0-15 |
O:0/0-15 | Coils (FC01, FC05, FC15) | 0-15 |
N:0-7 | Input Registers (FC04) | 0-7 |
N:10-17 | Holding Registers (FC03, FC06, FC16) | 0-7 |
Command-Line Options¶
| Option | Description | Default |
|---|---|---|
--modbus-server | Enable Modbus TCP server | (disabled) |
--modbus-port <port> | Server listen port | 502 |
--modbus-unit-id <id> | Modbus unit/slave ID (1-247) | 1 |
Programmatic Configuration¶
For advanced customization, the server service can be configured programmatically using ModbusTcpServerService:
#include "ModbusTcpServerService.h"
// Create and configure the service
ModbusTcpServerService service;
ModbusTcpServerConfig config;
config.port = 502;
config.unitId = 1;
config.updateIntervalMs = 50;
// Map I:0 (16 bits) to Modbus discrete inputs 0-15
config.discreteInputStartWord = 0;
config.discreteInputWordCount = 1;
config.discreteInputBaseAddr = 0;
// Map O:0 (16 bits) to Modbus coils 0-15
config.coilStartWord = 0;
config.coilWordCount = 1;
config.coilBaseAddr = 0;
// Map N:0-7 to Modbus input registers 0-7
config.inputRegisterStartWord = 0;
config.inputRegisterCount = 8;
config.inputRegisterBaseAddr = 0;
// Map N:10-17 to Modbus holding registers 0-7
config.holdingRegisterStartWord = 10;
config.holdingRegisterCount = 8;
config.holdingRegisterBaseAddr = 0;
service.setConfig(config);
service.setContext(engineContext);
service.start();
Server Configuration Fields¶
| Field | Description | Default |
|---|---|---|
port | TCP port to listen on | 502 |
unitId | Modbus unit ID to respond to | 1 |
updateIntervalMs | Sync interval (PLC → Modbus) | 50 |
Memory Region Mapping¶
| Field | Description |
|---|---|
discreteInputStartWord | First I: word to expose |
discreteInputWordCount | Number of I: words (×16 bits each) |
discreteInputBaseAddr | Modbus starting address |
coilStartWord | First O: word to expose |
coilWordCount | Number of O: words |
coilBaseAddr | Modbus starting address |
inputRegisterStartWord | First N: word for input registers |
inputRegisterCount | Number of N: words |
inputRegisterBaseAddr | Modbus starting address |
holdingRegisterStartWord | First N: word for holding registers |
holdingRegisterCount | Number of N: words |
holdingRegisterBaseAddr | Modbus starting address |
Signals¶
The service emits signals when external clients write data:
connect(&service, &ModbusTcpServerService::coilWritten,
[](int plcWord, int plcBit, bool value) {
qDebug() << "Coil written: O:" << plcWord << "/" << plcBit << "=" << value;
});
connect(&service, &ModbusTcpServerService::holdingRegisterWritten,
[](int plcNWord, qint32 value) {
qDebug() << "Register written: N:" << plcNWord << "=" << value;
});
Combining Client and Server¶
PiPLC can run both a Modbus client provider (for I/O) and a Modbus server service (for SCADA) simultaneously:
┌─────────────┐ ┌────────────────────────────────────┐ ┌─────────────┐
│ Remote I/O │ │ PiPLC Engine │ │ SCADA/HMI │
│ Module │ │ │ │ System │
│ (Slave) │ │ ┌────────────────────────────┐ │ │ (Master) │
│ │ │ │ ModbusTcpClientProvider │ │ │ │
│ ◀──────────┼─────┼──┤ (polls remote I/O) │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ ModbusTcpServerService │───┼─────┼──▶ │
│ │ │ │ (exposes PLC memory) │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
└─────────────┘ └────────────────────────────────────┘ └─────────────┘
Troubleshooting¶
Connection Failed / Timeout¶
- Verify network connectivity:
ping <host> - Check the Modbus device is listening:
nc -zv <host> <port> - Ensure the correct unit ID is configured
- Increase
timeoutMsfor slow devices or networks
No Data Updates¶
- Verify the Modbus address mappings match the device documentation
- Check the device supports the function codes being used
- Monitor with a Modbus protocol analyzer (e.g., Wireshark with Modbus dissector)
"Exception Response" Errors¶
Modbus exception codes indicate device-side errors:
| Code | Name | Common Cause |
|---|---|---|
| 0x01 | Illegal Function | Device doesn't support this function code |
| 0x02 | Illegal Data Address | Address out of range |
| 0x03 | Illegal Data Value | Value out of valid range |
| 0x04 | Slave Device Failure | Internal device error |
Server Not Accepting Connections¶
- Check if port 502 is already in use:
netstat -an | grep 502 - On Linux, ports below 1024 require root or
CAP_NET_BIND_SERVICE - Try a higher port (e.g., 5020) for testing
Data Appears Swapped or Incorrect¶
- Modbus uses big-endian byte order for registers
- Some devices use little-endian or swap words for 32-bit values
- Verify the device's register ordering in its documentation
Testing Without Hardware¶
The Modbus library includes a built-in server that can be used for testing:
// Create a test server
Modbus::ModbusTcpServer server;
server.setCoilCount(16);
server.setDiscreteInputCount(16);
server.setHoldingRegisterCount(32);
server.setInputRegisterCount(32);
server.listen(5020);
// Set test values
server.setDiscreteInput(0, true);
server.setInputRegister(0, 1234);
// Now connect your client to 127.0.0.1:5020
The test suite uses this approach for self-testing without external dependencies.
See Also¶
- I/O Provider Specification — Provider architecture details
- GPIO Architecture — General I/O architecture
- Memory Regions — PLC address format reference
- Explorer HAT Pro Guide — Alternative I/O provider for Raspberry Pi