Skip to content

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 timeoutMs for 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