Modbus TCP Protocol: Complete Guide for Industrial IoT and SCADA Integration
Everything you need to know about Modbus TCP — how the protocol works, register types, function codes, reading data with Python, and integrating with SCADA and IoT platforms. Real-world industrial examples included.
What is Modbus TCP?
Modbus is the oldest industrial communication protocol still in widespread use. Originally developed by Modicon in 1979 for serial communication between PLCs, Modbus TCP is the Ethernet variant — wrapping the same Modbus data model inside standard TCP/IP packets.
Despite being over 45 years old, Modbus TCP is still the dominant protocol for reading data from PLCs, energy meters, VFDs (Variable Frequency Drives), temperature controllers, and countless other industrial devices. If you're doing any kind of industrial IoT or SCADA work, you will encounter Modbus TCP.
I've worked with Modbus TCP across railway signalling systems, factory automation, and energy monitoring projects. This guide documents everything I've learned in practice.
Modbus TCP vs Modbus RTU
Before diving in, understand the difference:
| Feature | Modbus RTU | Modbus TCP | |---------|-----------|-----------| | Transport | Serial (RS-232, RS-485) | Ethernet / TCP/IP | | Port | N/A (serial) | TCP port 502 | | Speed | 9600–115200 baud | 100Mbps+ | | Distance | Up to 1200m (RS-485) | Standard Ethernet | | Error checking | CRC16 | TCP checksum (no separate CRC needed) | | Addressing | Device address in frame | IP address + Unit ID | | Typical use | Legacy field devices | Modern PLCs, meters, gateways |
Most modern devices support Modbus TCP directly. Older RS-485 devices can be connected via a Modbus RTU-to-TCP gateway — a small device that bridges serial Modbus to Ethernet.
How Modbus TCP Works
The Data Model
Every Modbus device organizes data into four tables:
| Table | Type | Access | Data | Address Range | |-------|------|--------|------|---------------| | Coils | 1-bit | Read/Write | Digital outputs (relays, valves ON/OFF) | 00001–09999 | | Discrete Inputs | 1-bit | Read Only | Digital inputs (sensors, switches) | 10001–19999 | | Input Registers | 16-bit | Read Only | Analog inputs (temperature, pressure) | 30001–39999 | | Holding Registers | 16-bit | Read/Write | Setpoints, configuration, counters | 40001–49999 |
The most common table you'll read from is Holding Registers (40001+). This is where PLCs store process values, counters, and configuration.
Function Codes
Modbus uses function codes to define the operation:
| Code | Function | Table | |------|----------|-------| | 01 (0x01) | Read Coils | Coils | | 02 (0x02) | Read Discrete Inputs | Discrete Inputs | | 03 (0x03) | Read Holding Registers | Holding Registers | | 04 (0x04) | Read Input Registers | Input Registers | | 05 (0x05) | Write Single Coil | Coils | | 06 (0x06) | Write Single Register | Holding Registers | | 15 (0x0F) | Write Multiple Coils | Coils | | 16 (0x10) | Write Multiple Registers | Holding Registers |
Function Code 03 (Read Holding Registers) is the one you'll use 90% of the time.
The Modbus TCP Frame
MBAP Header (7 bytes) PDU (variable)
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────┬──────────┐
│Transaction│Transaction│Protocol │ Length │ Unit ID │ FC │ Data │
│ ID Hi │ ID Lo │ ID (0000)│ │ │ │ │
│ 0x00 │ 0x01 │ 0x00 00 │ 0x00 06 │ 0x01 │ 0x03 │ ... │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────┴──────────┘
- Transaction ID: Incremented per request, echoed in response for matching
- Protocol ID: Always
0x0000for Modbus - Unit ID: The Modbus slave address (1–247). For direct TCP devices, usually
1or255. Important for RTU-TCP gateways where one IP has multiple serial devices.
Reading Modbus TCP with Python
Install the Library
pip install pymodbusBasic Read — Holding Registers
from pymodbus.client import ModbusTcpClient
# Connect to device
client = ModbusTcpClient(host="192.168.1.100", port=502)
client.connect()
# Read 10 holding registers starting from address 0 (register 40001)
result = client.read_holding_registers(address=0, count=10, slave=1)
if not result.isError():
print("Register values:", result.registers)
# Output: Register values: [1234, 5678, 0, 100, 200, ...]
else:
print("Error:", result)
client.close()Reading an Energy Meter (Real Example)
This is how I read data from a Schneider Electric PM5000 energy meter — a common device in industrial panels:
from pymodbus.client import ModbusTcpClient
import struct
def read_pm5000(ip: str, slave_id: int = 1) -> dict:
"""Read power measurements from Schneider PM5000 series meter."""
client = ModbusTcpClient(host=ip, port=502, timeout=3)
if not client.connect():
raise ConnectionError(f"Cannot connect to {ip}:502")
try:
# PM5000 register map (from Schneider documentation)
# All 32-bit floats stored as two consecutive 16-bit registers (big-endian)
def read_float32(address: int) -> float:
"""Read a 32-bit IEEE 754 float from two Modbus registers."""
result = client.read_holding_registers(address=address, count=2, slave=slave_id)
if result.isError():
return None
raw = struct.pack(">HH", result.registers[0], result.registers[1])
return struct.unpack(">f", raw)[0]
data = {
"voltage_l1_n": read_float32(3027), # V L1-N
"voltage_l2_n": read_float32(3029), # V L2-N
"voltage_l3_n": read_float32(3031), # V L3-N
"current_l1": read_float32(3000), # A L1
"current_l2": read_float32(3002), # A L2
"current_l3": read_float32(3004), # A L3
"active_power_total": read_float32(3053), # kW total
"power_factor": read_float32(3083), # PF
"frequency": read_float32(3109), # Hz
"energy_import": read_float32(3203), # kWh import
}
return data
finally:
client.close()
# Usage
meter = read_pm5000("192.168.1.100", slave_id=1)
for key, value in meter.items():
if value is not None:
print(f" {key}: {value:.2f}")Writing to a PLC (Setting a Setpoint)
from pymodbus.client import ModbusTcpClient
def set_temperature_setpoint(ip: str, setpoint_celsius: float):
"""Write a temperature setpoint to a PLC holding register."""
client = ModbusTcpClient(host=ip, port=502)
client.connect()
# Convert to integer (e.g. 25.5°C → 255 if device uses 0.1°C resolution)
register_value = int(setpoint_celsius * 10)
result = client.write_register(address=100, value=register_value, slave=1)
if result.isError():
print(f"Write failed: {result}")
else:
print(f"Setpoint set to {setpoint_celsius}°C (register value: {register_value})")
client.close()
set_temperature_setpoint("192.168.1.50", 25.5)Common Data Encoding Patterns
Modbus registers are 16-bit unsigned integers. Manufacturers encode different data types in various ways — always check the device register map.
32-bit Integer (two registers)
def read_int32(registers: list, index: int) -> int:
"""Combine two 16-bit registers into a signed 32-bit integer (big-endian)."""
combined = (registers[index] << 16) | registers[index + 1]
if combined >= 2**31: # handle signed
combined -= 2**32
return combined32-bit Float (IEEE 754)
import struct
def read_float32_be(registers: list, index: int) -> float:
"""Big-endian float from two registers."""
raw = struct.pack(">HH", registers[index], registers[index + 1])
return struct.unpack(">f", raw)[0]
def read_float32_le(registers: list, index: int) -> float:
"""Little-endian float (word-swapped) — common in some meters."""
raw = struct.pack(">HH", registers[index + 1], registers[index])
return struct.unpack(">f", raw)[0]Scaled Integer
Many devices store 25.5°C as 255 (scale factor 0.1) or 2550 (scale factor 0.01). Always check the register map.
temperature = registers[0] * 0.1 # if device uses 0.1°C resolution
pressure = registers[1] * 0.01 # if device uses 0.01 bar resolutionBuilding a Modbus TCP Data Logger
This complete example polls multiple Modbus devices and logs data to CSV:
#!/usr/bin/env python3
"""
Modbus TCP Multi-Device Data Logger
Polls devices every 30s and appends to CSV files.
"""
import csv
import time
import struct
import logging
from datetime import datetime
from pathlib import Path
from pymodbus.client import ModbusTcpClient
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger(__name__)
LOG_DIR = Path("/var/log/modbus")
LOG_DIR.mkdir(parents=True, exist_ok=True)
POLL_INTERVAL = 30 # seconds
DEVICES = [
{
"name": "energy-meter-panel-A",
"ip": "192.168.1.100",
"port": 502,
"slave": 1,
"registers": [
{"name": "voltage", "address": 3027, "count": 2, "type": "float32_be", "scale": 1},
{"name": "current", "address": 3000, "count": 2, "type": "float32_be", "scale": 1},
{"name": "active_power", "address": 3053, "count": 2, "type": "float32_be", "scale": 1},
{"name": "energy_kwh", "address": 3203, "count": 2, "type": "float32_be", "scale": 1},
]
},
{
"name": "vfd-pump-1",
"ip": "192.168.1.101",
"port": 502,
"slave": 1,
"registers": [
{"name": "output_freq_hz", "address": 0, "count": 1, "type": "int16", "scale": 0.1},
{"name": "output_current", "address": 1, "count": 1, "type": "int16", "scale": 0.1},
{"name": "output_voltage", "address": 2, "count": 1, "type": "int16", "scale": 1},
{"name": "motor_rpm", "address": 3, "count": 1, "type": "int16", "scale": 1},
]
},
]
def decode_value(registers: list, reg_def: dict):
dtype = reg_def["type"]
scale = reg_def.get("scale", 1)
if dtype == "int16":
val = registers[0]
if val > 32767:
val -= 65536
return val * scale
elif dtype == "uint16":
return registers[0] * scale
elif dtype == "float32_be":
raw = struct.pack(">HH", registers[0], registers[1])
return struct.unpack(">f", raw)[0] * scale
elif dtype == "int32_be":
combined = (registers[0] << 16) | registers[1]
if combined >= 2**31:
combined -= 2**32
return combined * scale
return None
def poll_device(device: dict) -> dict | None:
client = ModbusTcpClient(host=device["ip"], port=device["port"], timeout=3)
if not client.connect():
log.error(f"Cannot connect to {device['name']} ({device['ip']})")
return None
row = {"timestamp": datetime.now().isoformat()}
try:
for reg in device["registers"]:
result = client.read_holding_registers(
address=reg["address"], count=reg["count"], slave=device["slave"]
)
if result.isError():
log.warning(f" {reg['name']}: read error")
row[reg["name"]] = ""
else:
row[reg["name"]] = decode_value(result.registers, reg)
finally:
client.close()
return row
def write_csv(device_name: str, row: dict):
filepath = LOG_DIR / f"{device_name}.csv"
write_header = not filepath.exists()
with open(filepath, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=row.keys())
if write_header:
writer.writeheader()
writer.writerow(row)
def main():
log.info("Modbus data logger started")
while True:
for device in DEVICES:
log.info(f"Polling {device['name']}")
row = poll_device(device)
if row:
write_csv(device["name"], row)
log.info(f" Logged: {row}")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
main()Modbus TCP Security Considerations
Modbus TCP has no built-in authentication or encryption. Anyone on the network with TCP access to port 502 can read and write registers. This is a serious risk in industrial environments.
Protect Modbus Devices with Network Segmentation
Internet
│
Firewall
│
IT Network (192.168.1.0/24)
│
OT Firewall / DMZ
│
OT Network (10.10.1.0/24) ← Modbus devices live here
├── PLC 1: 10.10.1.10
├── Energy Meter: 10.10.1.20
└── VFD Panel: 10.10.1.30
Rules on the OT firewall:
- Allow port 502 TCP only from your SCADA/monitoring server IPs
- Block ALL other access to port 502
- Log all connections to port 502
Use a Modbus TCP Firewall / Proxy
For critical systems, deploy a Modbus-aware firewall (e.g., Moxa MGate with access control) that can:
- Restrict which function codes are allowed (e.g., block FC06/FC16 write codes from monitoring systems)
- Log all Modbus transactions
- Alert on unauthorized write attempts
Integrating Modbus TCP with SCADA Platforms
ThingsBoard
ThingsBoard has a built-in Modbus connector. In the tb-gateway configuration:
{
"Modbus": {
"server": {
"name": "Modbus Default Server",
"type": "tcp",
"host": "192.168.1.100",
"port": 502,
"timeout": 35,
"devices": [
{
"unitId": 1,
"deviceName": "Energy Meter Panel A",
"attributesPollPeriod": 5000,
"timeseriesPollPeriod": 10000,
"timeseries": [
{
"tag": "voltage",
"type": "float",
"functionCode": 3,
"objectsCount": 2,
"address": 3027
}
]
}
]
}
}
}Node-RED
Install the node-red-contrib-modbus package and use Modbus Read nodes. Configure the server IP and poll interval visually, then wire the output to an InfluxDB or MQTT node.
Frequently Asked Questions
What is the Unit ID in Modbus TCP and when does it matter?
Unit ID (also called Slave ID) identifies the Modbus device within the request. For a device directly connected via TCP, Unit ID is usually 1 or 255 — it doesn't matter much. It becomes critical when using a Modbus TCP-to-RTU gateway that has multiple RS-485 serial devices behind a single IP address. Each serial device has its own Unit ID (1–247), and the gateway routes TCP requests to the right serial device based on this ID.
Why am I getting wrong values from registers?
Most likely a byte-order (endianness) issue. If a 32-bit float reads as garbage, try swapping the word order:
# Try both word orders when debugging
raw_be = struct.pack(">HH", registers[0], registers[1]) # big-endian
raw_le = struct.pack(">HH", registers[1], registers[0]) # word-swapped
print("BE:", struct.unpack(">f", raw_be)[0])
print("LE:", struct.unpack(">f", raw_le)[0])Compare against the device's display panel — one of the two will match.
Can I poll a Modbus device faster than once per second?
Technically yes, but most PLCs and industrial devices recommend 500ms–1s minimum between polls. Polling too fast can overload the device's SNMP/Modbus stack, cause missed responses, and in rare cases crash the device firmware. Always check the device manual for the minimum recommended poll interval.
What's the maximum number of registers I can read in one request?
The Modbus specification allows reading up to 125 holding registers (250 bytes) per request. Reading more requires multiple requests. Most devices support this limit, but some older or cheaper devices cap at 32 or 64 registers — check the manual if you get errors with large reads.
Conclusion
Modbus TCP is not glamorous, but it's the reality of industrial IoT. If you're building a SCADA system, an energy monitoring platform, or integrating with any factory equipment, you'll be reading Modbus registers. Understanding the data model, function codes, and data type encoding will save you hours of debugging.
The Python examples here are production-ready starting points — I've used variations of this code across several real deployments. Start with Function Code 03, get your register values matching the device display panel, and build from there.
Related: IoT Real-Time Data Transfer Systems | SNMP Industrial and Railway Device Monitoring | Zero Trust OT Industrial Network Security