Modbus TCP/IP Protocol: Everything You Need to Know to Connect Your PLCs to the Cloud
Modbus TCP is the most widely used PLC data collection protocol in French industry. It is used in tens of millions of devices that have been installed over the past 40 years. If you’re incorporating remote connectivity into your projects, you’ll encounter Modbus TCP—so you might as well gain a thorough understanding of it.
This guide covers the essentials for practitioners: history, byte-by-byte frame structure, function codes, a comparison with competing protocols, performance optimization, security, and a complete, working Python code example.
1. From Modbus RTU to Modbus TCP: 45 Years of Evolution
The Birth of Modbus in 1979
Modbus was created in 1979 by Modicon (now part of Schneider Electric) to enable communication between the first Modicon 084 programmable logic controllers. It was a serial (RS-232 and later RS-485) master/slave protocol designed for a single purpose: to read and write numerical values to field devices.
Simplicity was the fundamental design principle:
- No network addressing—just a slave ID from 1 to 247 on the bus
- No authentication—any master can query any slave
- No automatic discovery—the list of slaves is configured manually
- No security mechanism—the protocol assumes a physically secure environment
Modbus RTU (Remote Terminal Unit): a compact binary protocol for RS-485. Data is transmitted in binary format, with a 16-bit CRC for error detection. Still widely used in field instruments (energy meters, pressure sensors, variable-frequency drives).
Modbus ASCII: ASCII-readable version, for RS-232 connections. Less efficient (2 bytes per data byte), obsolete in new installations.
Modbus TCP: Ethernet Without a Protocol Revolution
Around 1996–1999, as Ethernet became widespread in factories, Modbus TCP encapsulated the protocol within TCP/IP frames. The revision was precise:
- Same data model (coils, discrete inputs, input registers, holding registers)
- Same function codes
- Same register addressing
- Removal of the CRC (TCP guarantees integrity)
- Addition of a 7-byte MBAP header for routing and request correlation
- Standard TCP port: 502
The advantage: Any existing equipment compatible with Modbus RTU could be migrated to Modbus TCP with minimal changes. The disadvantage: The original security vulnerabilities remained.
2. Structure of a Modbus TCP Frame — Byte-by-Byte Analysis
Overview of the Framework
┌─────────────────────────────────────────────────────────────────────┐
│ Trame Modbus TCP │
├──────────────────────────────────────────┬──────────────────────────┤
│ MBAP Header (7 octets) │ PDU │
├──────────┬──────────┬──────────┬─────────┼────────────┬─────────────┤
│ Trans.ID │ Proto.ID │ Length │ Unit ID │ Func.Code │ Data │
│ 2 bytes │ 2 bytes │ 2 bytes │ 1 byte │ 1 byte │ n bytes │
└──────────┴──────────┴──────────┴─────────┴────────────┴─────────────┘
Analysis of the MBAP Header
Transaction Identifier (2 bytes): A number freely chosen by the client, which is returned exactly as entered in the response. This allows responses to be correlated with requests in the case of persistent connections with multiple requests in progress. In practice, most clients use a simple counter (00 01, 00 02, 00 03...) or a fixed identifier (00 00) if they operate in sequential request/response mode.
Protocol Identifier (2 bytes):
Always 00 00 for Modbus. This constant exists to allow for a possible extension to other protocols via the same port, but has never been used.
Length (2 bytes):
Number of bytes that follow, in big-endian format. Includes the Unit ID and the PDU, but not the first 4 bytes of the MBAP Header. For an FC=03 request with 10 registers: 6 bytes (1 Unit ID + 1 FC + 2 addresses + 2 counts) → 00 06.
Unit Identifier (1 byte): Modbus slave identifier. On directly connected PLCs (S7-1200 with MB_SERVER, M340 with built-in Modbus server), the value is typically 1 or 255. On Modbus RTU→TCP gateways, this identifier corresponds to the RTU slave’s address on the RS-485 bus.
Analysis of a Complete FC=03 Request
Reading 10 holding registers starting at address 100 (e.g., %MW100 on the Schneider M340):
Requête client (hex) : 00 01 00 00 00 06 FF 03 00 64 00 0A
Décodage byte par byte :
00 01 → Transaction ID = 1
00 00 → Protocol ID = 0 (Modbus standard)
00 06 → Length = 6 (Unit ID + FC + 2 adresse + 2 count)
FF → Unit ID = 255 (0xFF = CPU locale M340)
03 → Function Code = Read Holding Registers
00 64 → Starting Address = 100 (0x0064 en big-endian)
00 0A → Quantity = 10 registres (0x000A)
Réponse automate (hex) : 00 01 00 00 00 17 FF 03 14 [20 octets]
Décodage :
00 01 → Transaction ID = 1 (même que la requête — corrélation)
00 00 → Protocol ID = 0
00 17 → Length = 23 (1 UID + 1 FC + 1 ByteCount + 20 données)
FF → Unit ID = 255
03 → Function Code = 3 (confirme que c'est une réponse normale)
14 → Byte Count = 20 (10 registres × 2 octets = 0x14 hex)
[suivi de 20 octets de données — 10 valeurs uint16 big-endian]
Exception Codes (Error Responses)
When the bot cannot process a request, it returns an exception code:
Réponse exception (hex) : 00 01 00 00 00 03 FF 83 02
83 → FC + 0x80 = 3 + 128 = 131 → indique une réponse d'exception sur FC=3
02 → Exception code : Illegal Data Address
| Code | Name | Meaning | Common Cause |
|---|---|---|---|
| 01 | Illegal Function | Unsupported FC | PLC does not support this FC |
| 02 | Illegal Data Address | Out-of-range address | Register > PLC limit |
| 03 | Illegal Data Value | Invalid data value | Count > 125 (FC=03) |
| 04 | Server Device Failure | Internal error | Modbus server not initialized |
| 05 | Acknowledge | Long-running process | Rare in practice |
| 06 | Server Device Busy | Server busy | Too many simultaneous connections |
| 0A | Gateway Path Unavailable | Gateway path unavailable | RTU slave unreachable |
| 0B | Gateway Target Device Failed | Slave not responding | RTU slave powered off or failed |
3. Function Codes in Detail
FC=01 and FC=02 — Reading Bits
FC=01 : Read Coils → bits de sortie (écriture autorisée)
FC=02 : Read Discrete Inputs → bits d'entrée (lecture seule)
Requête : FC AddrH AddrL QuantH QuantL
01 00 00 00 00 00 10 (lecture 16 coils depuis @0)
Réponse : FC ByteCount Coil_Data
01 02 [2 bytes = 16 bits d'état]
Note : les coils sont packés en bits, LSB first.
Byte1 bit0 = Coil@0, Byte1 bit1 = Coil@1, etc.
FC=03 — Read Holding Registers (most commonly used)
Read/write access to 16-bit registers. Limit: 125 registers maximum per request.
FC=04 — Read Input Registers
Same as FC=03, but for the "input" registers (read-only). On the Schneider M340, this corresponds to the %IW registers. On most PLCs, this is rarely used because the analog measurements are copied to the %MW (Holding Registers).
FC=06 and FC=16 — Writing to Registers
FC=06 : Write Single Register
Requête : FC AddrH AddrL ValueH ValueL
06 00 64 00 64 01 F4 (écriture 500 à l'adresse 100)
FC=16 : Write Multiple Registers (jusqu'à 123 registres par requête)
Requête : FC AddrH AddrL CountH CountL ByteCount Values...
10 00 64 00 05 01 2C [données]
Caution when writing (FC=06, FC=16): Only write to registers if you are certain of the addressing and the impact on the PLC program. Writing to a setpoint register immediately changes the process behavior. Always test on a development PLC or a non-production PLC before deployment.
4. Modbus TCP vs. OPC-UA vs. MQTT: Which Protocol Is Right for Your Architecture?
These three protocols coexist in modern IIoT architectures. They serve different layers and are not in direct competition with one another.
| Criterion | Modbus TCP | OPC-UA | MQTT |
|---|---|---|---|
| Year Introduced | 1979 (RTU), ~1996 (TCP) | 2008 | 1999 |
| Model | Client/Server (polling) | Client/Server + Pub/Sub | Pub/Sub (broker) |
| Layer | Field communication | Systems interoperability | IoT/cloud transport |
| Native security | None | TLS + certificate authentication | TLS + authentication |
| PLC CPU Load | Very low | High | Low to medium |
| Legacy PLCs | Universal (40 years) | Modern PLCs only | Via gateway |
| Automatic Discovery | No | Yes (OPC namespace) | No |
| Structured data | No (flat registers) | Yes (types, structs, arrays) | Yes (JSON/CBOR) |
| Latency | Low (polling) | Very low (pub/sub) | Very low |
How to Choose?
Use Modbus TCP if:
- The PLC supports Modbus TCP (Siemens S7-1200, Schneider M340/M221/M241, Wago, Allen-Bradley, etc.)
- You need to collect simple measurements (16-bit registers)
- Ease of configuration takes precedence over the richness of the data model
Use OPC-UA if:
- The PLC natively supports OPC-UA server (S7-1500, B&R, Beckhoff, recent CODESYS 3.x)
- You need to monitor complex variables (structures, arrays, custom types)
- Interoperability with other SCADA or MES systems is a requirement
Use MQTT for cloud transport if:
- You have a gateway (such as Eziwan) that collects data via Modbus/OPC-UA and publishes it via MQTT
- You need a scalable publish/subscribe architecture for thousands of data points
The target architecture for cloud-based IIoT:
[Automate] ──Modbus TCP / OPC-UA──→ [Gateway] ──MQTT over TLS──→ [Cloud]
The gateway converts and transmits data. It bridges the OT world (Modbus/OPC-UA) and the cloud world (MQTT/REST).
5. Modbus TCP Security: The Problem and Solutions
The Complete Lack of Authentication
Modbus TCP has no native security mechanisms:
- No authentication: anyone on the network can read and write
- No encryption: data is transmitted in plain text (readable by Wireshark)
- No authorization: no concept of read/write permissions per user
Practical implication: If your PLC is accessible from the Internet (port 502 open), it is completely vulnerable. If someone connects to your OT network (physically or through a security breach), they can read and modify all the registers.
Possible Answers
Network segmentation (primary measure): Contain Modbus TCP traffic within an isolated OT network. The Eziwan gateway is located within this OT network; communication with the cloud occurs via a VPN tunnel, not by exposing port 502.
IP Filtering on the PLC (Secondary Measure): Some newer PLCs (Schneider M340 firmware ≥ 2.60, Siemens S7-1200 firmware V4.x with FIREWALL functions) support IP filtering: Only the IP address of the Eziwan gateway is authorized to connect to port 502.
Modbus Security (advanced measure): In 2018, the Modbus Organization released a "Modbus Security" extension (TLS transport for Modbus TCP) on port 802. This extension is not yet widely supported by PLCs on the market, but is available on some newer devices.
6. Optimizing Collection Performance
Request Batching
The golden rule: group reads into contiguous blocks. Reading 100 contiguous registers in a single query is much more efficient than 100 queries, each reading a single register:
Configuration inefficace (100 requêtes) :
Lire %MW0 → 1 requête (2 octets de données)
Lire %MW50 → 1 requête
Lire %MW99 → 1 requête
... 97 requêtes supplémentaires ...
Latence totale : 100 × (aller-retour réseau) ≈ 100 × 5ms = 500ms
Configuration optimisée (1 requête) :
Lire %MW0 à %MW99 → 1 requête (200 octets de données)
Latence totale : 1 × 5ms = 5ms
The Eziwan gateway automatically groups configured variables into contiguous ranges. To maximize this grouping, organize your monitoring variables in adjacent memory areas.
Impact on the PLC cycle
Each Modbus TCP request received is processed by the PLC at the end of the program cycle. On an M340 with a 10-ms cycle time, 10 simultaneous Modbus requests can extend the cycle time by 1 to 3 ms.
Recommendation: Do not exceed 1 Modbus request per PLC cycle for real-time applications. Reduce the polling frequency if the cycle time increases.
7. Complete Python code example using pymodbus
"""
Collecte Modbus TCP industrielle avec gestion d'erreurs robuste
Compatible Schneider M340, Siemens S7-1200, Wago, Allen-Bradley...
Testé avec pymodbus >= 3.0
Installation :
pip install pymodbus loguru
"""
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException, ConnectionException
from loguru import logger
import struct
import time
from dataclasses import dataclass
from typing import Optional
# ─── Configuration ────────────────────────────────────────────────────────────
HOST = "192.168.1.20" # IP automate Schneider M340
PORT = 502 # Port Modbus TCP
UNIT_ID = 255 # 255 pour CPU locale M340, 1 pour S7-1200
TIMEOUT = 3 # Timeout en secondes
RETRIES = 3 # Nombre de tentatives avant abandon
# ─── Structure de données ─────────────────────────────────────────────────────
@dataclass
class VariablesProcess:
temperature_c: float = 0.0
debit_lmin: int = 0
pression_bar: float = 0.0
pompe_1_marche: bool = False
pompe_2_marche: bool = False
code_defaut: int = 0
compteur_production: int = 0
timestamp: float = 0.0
# ─── Client Modbus avec reconnexion automatique ───────────────────────────────
class ModbusCollecteur:
"""Collecteur Modbus TCP avec gestion de reconnexion et retry."""
def __init__(self, host: str, port: int = 502, unit_id: int = 1):
self.host = host
self.port = port
self.unit_id = unit_id
self.client: Optional[ModbusTcpClient] = None
self._connexions_ok = 0
self._connexions_echec = 0
def connecter(self) -> bool:
"""Établit la connexion TCP vers l'automate."""
try:
self.client = ModbusTcpClient(
self.host,
port=self.port,
timeout=TIMEOUT,
retries=1, # Retries gérés par notre code
reconnect_delay=0, # Pas de reconnexion automatique
)
if self.client.connect():
self._connexions_ok += 1
logger.info(f"Connecté à {self.host}:{self.port}")
return True
else:
self._connexions_echec += 1
logger.warning(f"Connexion refusée par {self.host}:{self.port}")
return False
except Exception as e:
logger.error(f"Erreur connexion : {e}")
return False
def deconnecter(self):
if self.client:
self.client.close()
self.client = None
def lire_holding_registers(
self, adresse: int, count: int
) -> Optional[list[int]]:
"""Lit des registres avec retry automatique."""
for tentative in range(1, RETRIES + 1):
try:
if not self.client or not self.client.is_socket_open():
if not self.connecter():
time.sleep(1)
continue
result = self.client.read_holding_registers(
address=adresse, count=count, slave=self.unit_id
)
if result.isError():
exception_code = getattr(result, 'exception_code', '?')
logger.warning(
f"Erreur Modbus addr={adresse} count={count} "
f"exception_code={exception_code}"
)
# Exception 04 (server busy) → attendre avant retry
if exception_code == 6:
time.sleep(0.5)
self.deconnecter()
continue
return result.registers
except ConnectionException:
logger.warning(f"Connexion perdue (tentative {tentative}/{RETRIES})")
self.deconnecter()
time.sleep(0.5)
except Exception as e:
logger.error(f"Erreur inattendue : {e}")
self.deconnecter()
time.sleep(1)
logger.error(f"Échec lecture après {RETRIES} tentatives — addr={adresse}")
return None
@staticmethod
def registres_to_float32_abcd(reg_high: int, reg_low: int) -> float:
"""Décode 2 registres 16-bit en float32 IEEE754 big-endian (ABCD)."""
raw = struct.pack(">HH", reg_high, reg_low)
return struct.unpack(">f", raw)[0]
@staticmethod
def registres_to_int32(reg_high: int, reg_low: int) -> int:
"""Décode 2 registres en entier 32-bit signé big-endian."""
raw = struct.pack(">HH", reg_high, reg_low)
return struct.unpack(">i", raw)[0]
# ─── Lecture des variables process ───────────────────────────────────────────
def lire_variables(collecteur: ModbusCollecteur) -> Optional[VariablesProcess]:
"""
Lecture optimisée : 1 seule requête Modbus pour toutes les variables.
Plan mémoire (M340 EcoStruxure) :
%MW100 : Temperature (int16, ×0.1 → °C)
%MW101 : Débit (int16, L/min)
%MW102-103 : Pression (float32 ABCD, bar)
%MW104 : États machine (bits 0-7)
%MW105 : Code défaut actif
%MW106-107 : Compteur production (int32)
"""
# 1 requête pour %MW100 à %MW107 (8 registres)
registres = collecteur.lire_holding_registers(adresse=100, count=8)
if registres is None:
return None
vars_process = VariablesProcess(
temperature_c = registres[0] * 0.1, # %MW100
debit_lmin = registres[1], # %MW101
pression_bar = ModbusCollecteur.registres_to_float32_abcd( # %MW102+103
registres[2], registres[3]),
pompe_1_marche = bool(registres[4] & 0x0001), # %MW104 bit0
pompe_2_marche = bool(registres[4] & 0x0002), # %MW104 bit1
code_defaut = registres[5], # %MW105
compteur_production = ModbusCollecteur.registres_to_int32( # %MW106+107
registres[6], registres[7]),
timestamp = time.time(),
)
return vars_process
# ─── Boucle principale ────────────────────────────────────────────────────────
def main():
collecteur = ModbusCollecteur(HOST, PORT, UNIT_ID)
INTERVALLE = 5 # secondes entre chaque collecte
logger.info(f"Démarrage collecte Modbus TCP — {HOST}:{PORT} — cycle {INTERVALLE}s")
while True:
try:
vars_process = lire_variables(collecteur)
if vars_process:
logger.info(
f"T={vars_process.temperature_c:.1f}°C "
f"Q={vars_process.debit_lmin}L/min "
f"P={vars_process.pression_bar:.2f}bar "
f"P1={'ON' if vars_process.pompe_1_marche else 'OFF'} "
f"Défaut={vars_process.code_defaut}"
)
# Ici : publier via MQTT, API REST, ou base time-series
# mqtt_client.publish("usine/ligne3/variables", vars_process)
else:
logger.warning("Collecte échouée — attente 10s avant retry")
time.sleep(10)
continue
time.sleep(INTERVALLE)
except KeyboardInterrupt:
logger.info("Arrêt demandé.")
collecteur.deconnecter()
break
if __name__ == "__main__":
main()
This code demonstrates best practices for Modbus TCP data collection in Python: batching reads, exception handling, automatic reconnection, and decoding complex data types. In production, the Eziwan gateway handles all these functions natively—without requiring a Python script on-site.
FAQ — Modbus TCP/IP Protocol
Why is the Modbus TCP port 502 and not a standard port like 80 or 443?
Port 502 was officially assigned to Modbus by the IANA (Internet Assigned Numbers Authority) when Modbus TCP was standardized in the 1990s. At the time, there was no reason to reuse existing IT application ports. It is a registered port (< 1024), so using it as a server requires root privileges on Linux—which industrial firmware handles natively.
What is the limit on the number of registers that can be read in a single FC=03 query?
The limit is 125 registers per FC=03 (Read Holding Registers) request, as defined in the official Modbus specification. This equates to a maximum of 250 bytes of payload data per request. For FC=16 (Write Multiple Registers), the limit is 123 registers per request. The Eziwan gateway automatically splits the request if the requested range exceeds these limits.
Does Modbus TCP support multiple simultaneous client connections?
Yes, but it’s the PLC that sets the limit. The Schneider M340 supports up to 16 simultaneous connections, while the Siemens S7-1200 supports up to 8 (via MB_SERVER). Beyond that, the PLC returns a 06 exception (Server Busy). In practice, a data collection gateway + a diagnostic tool + a local SCADA system can easily reach 3 simultaneous connections—so keep an eye on that.
Can Modbus TCP be encrypted?
The native Modbus TCP standard (port 502) is not encrypted. A "Modbus Security" extension on port 802 using TLS has been available since 2018, but is rarely supported by current PLCs. The practical solution is to confine Modbus TCP to an isolated OT network and route all external communication through an encrypted VPN tunnel (such as Eziwan’s OpenVPN)—encryption is handled by the transport layer, not by Modbus itself.
Is pymodbus the only Python library for Modbus TCP?
No. The main alternatives are minimalmodbus (simpler for Modbus RTU), umodbus (lightweight, small memory footprint), and pyModbusTCP (purely TCP, no dependencies). For industrial projects in Python, pymodbus >= 3.0 remains the most comprehensive choice, offering exception handling, reconnection, and support for both modes (RTU and TCP).
How do you debug a Modbus TCP communication without access to the PLC?
Use Modbus Slave (Windows) as a local PLC simulator, or diagslave (command line, cross-platform). These tools simulate a Modbus TCP server that responds to configurable addresses. Ideal for testing a data collection configuration without risking interference with a PLC in production.
Does your PLC support Modbus TCP, but you're not sure if it's compatible with your firmware version?
Check your PLC's compatibility →
See also: Modbus TCP with a Schneider M340 and the Siemens S7 documentation for specific configurations.