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 defines four data types, each with different read/write permissions from the external client's perspective:

Modbus Type Function Codes Client Permission
Discrete Inputs FC02 Read-only
Coils FC01, FC05, FC15 Read/Write
Input Registers FC04 Read-only
Holding Registers FC03, FC06, FC16 Read/Write

Mapping Flexibility — HMI Perspective

The mapping between Modbus data types and PLC memory regions is fully configurable and not restricted to a conventional I/O match. The most important case to understand is when Modbus TCP connects to an HMI or SCADA system.

A common misconception is that Coils always map to O: (outputs) and Discrete Inputs always map to I: (inputs). In practice, the right mapping depends on what the external client needs to do, not what the name implies:

HMI needs to... Modbus type PLC region Example
Send a command to PLC Coil (R/W) I: Pushbutton, start/stop
See a status from PLC Discrete Input (R) I: Motor running indicator
Send a numeric value Holding Register (R/W) N: Setpoint, recipe value
See a numeric value Input Register (R) N: Temperature, counter

The Modbus read/write distinction reflects the HMI's permissions, not the PLC's I/O direction:

  • Coils → I: — The HMI writes operator commands (button presses, enable signals) that become PLC inputs. The PLC program reads them from I: to make decisions.
  • Discrete Inputs → I: — The HMI reads status bits (motor running, alarm active) that the PLC program writes. The HMI can only observe them, not change them.
  • Holding Registers → N: — The HMI writes numeric values (setpoints, recipe parameters) into PLC integer memory.
  • Input Registers → N: — The HMI reads numeric values (sensor readings, counters) computed by the PLC program.

Both Coils and Discrete Inputs can map to I: — but through different function codes that control whether the HMI can write them or only read them.

Addressing

Modbus addresses are 16-bit (0–65535). Each bit or register mapping is individually configured, so any Modbus address can map to any PLC memory location.


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;
});

Example: HMI Panel via Modbus TCP Server

This example shows the correct mapping for an HMI panel that connects to PiPLC as a Modbus master. The HMI has two pushbuttons (Start, Stop) and displays two indicators (Running, Fault).

Design:

HMI element Direction Modbus type PLC address
Start button HMI → PLC Coil 0 (writable) I:0/0
Stop button HMI → PLC Coil 1 (writable) I:0/1
Running indicator PLC → HMI Discrete Input 0 (read-only) I:0/2 (set by ladder)
Fault indicator PLC → HMI Discrete Input 1 (read-only) I:0/3 (set by ladder)

The HMI can write Coils 0 and 1 (operator commands into I:), and can only read Discrete Inputs 0 and 1 (status bits the PLC writes).

This requires programmatic server configuration to use non-default mappings:

ModbusTcpServerConfig config;
config.port = 502;

// Coils 0-1 → I:0 bits 0-1 (HMI sends commands to PLC)
config.coilStartWord = 0;
config.coilWordCount = 1;
config.coilBaseAddr = 0;

// Discrete Inputs 0-1 → I:0 bits 2-3 (HMI reads PLC status)
config.discreteInputStartWord = 0;
config.discreteInputWordCount = 1;
config.discreteInputBaseAddr = 2;   // start at bit 2

The ladder program reads I:0/0 (Start) and I:0/1 (Stop) as normal inputs, and writes I:0/2 (Running) and I:0/3 (Fault) using OTE instructions — which the server then exposes as read-only Discrete Inputs to the HMI.


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