Protocolo Modbus TCP/IP: todo lo que necesitas saber para conectar tus controladores lógicos programables a la nube
Modbus TCP es el protocolo de recopilación de datos de controladores lógicos programables más extendido en la industria francesa. Se utiliza en decenas de millones de equipos instalados desde hace más de 40 años. Si incorporas la conectividad remota en tus proyectos, te encontrarás con Modbus TCP, así que conviene que lo comprendas a fondo.
Esta guía abarca lo esencial para un profesional: historia, estructura de la trama byte a byte, códigos de función, comparación con los protocolos de la competencia, optimización del rendimiento, seguridad y un ejemplo de código en Python completo y funcional.
1. De Modbus RTU a Modbus TCP: 45 años de evolución
El nacimiento de Modbus en 1979
Modbus fue creado en 1979 por Modicon (hoy absorbida por Schneider Electric) para permitir la comunicación entre los primeros controladores lógicos programables Modicon 084. Se trataba de un protocolo serie (RS-232 y posteriormente RS-485), de tipo maestro/esclavo, diseñado con un único objetivo: leer y escribir valores numéricos en equipos de campo.
La simplicidad era el principio fundamental del diseño:
- Sin direccionamiento de red: solo un identificador de esclavo del 1 al 247 en el bus
- Sin autenticación: cualquier maestro puede consultar a cualquier esclavo
- Sin detección automática: la lista de esclavos se configura manualmente
- Sin mecanismo de seguridad: el protocolo da por supuesto un entorno físicamente seguro
Modbus RTU (Remote Terminal Unit): versión binaria compacta para RS-485. Los datos se transmiten en formato binario, con un CRC de 16 bits para la detección de errores. Sigue siendo muy habitual en los instrumentos de campo (contadores de energía, sensores de presión, variadores de frecuencia).
Modbus ASCII: versión legible en ASCII, para conexiones RS-232. Menos eficiente (2 octetos por byte de datos), obsoleta en las nuevas instalaciones.
Modbus TCP: Ethernet sin una revolución del protocolo
Entre 1996 y 1999, con la generalización de Ethernet en las fábricas, Modbus TCP encapsuló el protocolo en tramas TCP/IP. La revisión fue minuciosa:
- Mismo modelo de datos (bobinas, entradas discretas, registros de entrada, registros de retención)
- Mismos códigos de función
- Mismo direccionamiento de registros
- Eliminación del CRC (TCP garantiza la integridad)
- Incorporación de un encabezado MBAP de 7 bytes para el enrutamiento y la correlación de las solicitudes
- Puerto TCP estándar: 502
La ventaja: cualquier equipo existente compatible con Modbus RTU podía migrar a Modbus TCP con cambios mínimos. El inconveniente: se mantuvieron las deficiencias de seguridad originales.
2. Estructura de una trama Modbus TCP: análisis byte a byte
Descripción general de la trama
┌─────────────────────────────────────────────────────────────────────┐
│ 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 │
└──────────┴──────────┴──────────┴─────────┴────────────┴─────────────┘
Análisis del encabezado MBAP
Identificador de transacción (2 bytes): Número libre elegido por el cliente, que se devuelve tal cual en la respuesta. Permite correlacionar las respuestas con las solicitudes en el caso de conexiones persistentes con varias solicitudes en curso. En la práctica, la mayoría de los clientes utilizan un contador simple (00 01, 00 02, 00 03...) o un identificador fijo (00 00) si funcionan en modo de solicitud/respuesta secuencial.
Identificador de protocolo (2 bytes):
Siempre es 00 00 para Modbus. Esta constante existe para permitir una posible ampliación a otros protocolos a través del mismo puerto, pero nunca se ha utilizado.
Longitud (2 octetos):
Número de octetos que siguen, en formato big-endian. Incluye el ID de unidad y la PDU, pero no los 4 primeros octetos del encabezado MBAP. Para una solicitud FC=03 de 10 registros: 6 octetos (1 ID de unidad + 1 FC + 2 direcciones + 2 recuentos) → 00 06.
Identificador de unidad (1 byte): Identificador del esclavo Modbus. En los controladores conectados directamente (S7-1200 con MB_SERVER, M340 con servidor Modbus integrado), el valor suele ser 1 o 255. En las pasarelas Modbus RTU→TCP, este identificador corresponde a la dirección del esclavo RTU en el bus RS-485.
Análisis de una solicitud FC=03 completa
Lectura de 10 registros de holding a partir de la dirección 100 (por ejemplo: %MW100 en el 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]
Códigos de excepción (respuestas de error)
Cuando el autómata no puede procesar una solicitud, devuelve un código de excepción:
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
| Código | Nombre | Significado | Causa habitual |
|---|---|---|---|
| 01 | Función no válida | FC no compatible | El autómata no admite esta FC |
| 02 | Dirección de datos no válida | Dirección fuera de rango | Registro > límite del autómata |
| 03 | Valor de datos no válido | Valor de datos no válido | Recuento > 125 (FC=03) |
| 04 | Fallo del dispositivo servidor | Error interno | Servidor Modbus no inicializado |
| 05 | Confirmación | Procesamiento prolongado en curso | Poco frecuente en la práctica |
| 06 | Dispositivo servidor ocupado | Demasiadas conexiones simultáneas | |
| 0A | Ruta de la pasarela no disponible | Ruta de la pasarela no disponible | Esclavo RTU inaccesible |
| 0B | Fallo del dispositivo de destino de la pasarela | Esclavo que no responde | Esclavo RTU apagado o averiado |
3. Los códigos de función en detalle
FC=01 y FC=02 — Lectura de 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 — Lectura de los registros de retención (el más utilizado)
Lectura y escritura de registros de 16 bits. Límite: 125 registros como máximo por consulta.
FC=04 — Lectura de registros de entrada
Idéntico a FC=03, pero para los registros de «entrada» (solo lectura). En el Schneider M340, corresponde a los %IW. En la mayoría de los autómatas, se utiliza poco, ya que las mediciones analógicas se copian en los %MW (registros de retención).
FC=06 y FC=16 — Escritura en registros
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]
Precaución al escribir (FC=06, FC=16): solo escriba en los registros si está seguro de la direccionamiento y del impacto en el programa del autómata. Una escritura en un registro de consigna modifica inmediatamente el comportamiento del proceso. Prueba siempre en un autómata de desarrollo o fuera de producción antes de la implementación.
4. Modbus TCP, OPC-UA y MQTT: ¿qué protocolo elegir para tu arquitectura?
Estos tres protocolos coexisten en las arquitecturas modernas de IIoT. Sirven a diferentes capas y no compiten directamente entre sí.
| Criterio | Modbus TCP | OPC-UA | MQTT |
|---|---|---|---|
| Antigüedad | 1979 (RTU), ~1996 (TCP) | 2008 | 1999 |
| Modelo | Cliente/Servidor (sondeo) | Cliente/Servidor + Pub/Sub | Pub/Sub (broker) |
| Capa | Comunicación de campo | Interoperabilidad de sistemas | Transporte IoT/nube |
| Seguridad nativa | Ninguna | TLS + autenticación por certificado | TLS + autenticación |
| Carga de CPU del autómata | Muy baja | Alta | Baja a media |
| Autómatas antiguos | Universal (40 años) | Solo autómatas modernos | A través de una pasarela |
| Detección automática | No | Sí (espacio de nombres OPC) | No |
| Datos estructurados | No (registros planos) | Sí (tipos, estructuras, matrices) | Sí (JSON/CBOR) |
| Latencia | Baja (sondeo) | Muy baja (pub/sub) | Muy baja |
¿Cómo elegir?
Utiliza Modbus TCP si:
- El controlador es compatible con Modbus TCP (Siemens S7-1200, Schneider M340/M221/M241, Wago, Allen-Bradley...)
- Necesita recopilar mediciones sencillas (registros de 16 bits)
- La simplicidad de la configuración prima sobre la riqueza del modelo de datos
Utiliza OPC-UA si:
- El controlador es compatible de forma nativa con el servidor OPC-UA (S7-1500, B&R, Beckhoff, CODESYS 3.x reciente)
- Necesita supervisar variables complejas (estructuras, matrices, tipos personalizados)
- La interoperabilidad con otros sistemas SCADA o MES es un requisito previo
Utiliza MQTT para el transporte en la nube si:
- Tienes una pasarela (como Eziwan) que recoge datos en Modbus/OPC-UA y los publica en MQTT
- Necesitas una arquitectura pub/sub escalable para miles de puntos
La arquitectura de destino para el IIoT en la nube:
[Automate] ──Modbus TCP / OPC-UA──→ [Gateway] ──MQTT over TLS──→ [Cloud]
La pasarela convierte y transmite datos. Actúa como puente entre el mundo OT (Modbus/OPC-UA) y el mundo de la nube (MQTT/REST).
5. Seguridad de Modbus TCP: el problema y las soluciones
La ausencia total de autenticación
Modbus TCP no cuenta con ningún mecanismo de seguridad nativo:
- Sin autenticación: cualquier usuario de la red puede leer y escribir
- Sin cifrado: los datos se transmiten en claro (legibles con Wireshark)
- Sin autorización: no existe el concepto de derechos de lectura/escritura por usuario
Consecuencia práctica: si se puede acceder a tu controlador desde Internet (puerto 502 abierto), es totalmente vulnerable. Si alguien se conecta a tu red OT (físicamente o a través de una brecha de seguridad), puede leer y modificar todos los registros.
Las posibles respuestas
Segmentación de la red (medida primaria): Limitar el tráfico Modbus TCP a una red OT aislada. La pasarela Eziwan se encuentra en esta red OT; la comunicación con la nube se realiza a través de un túnel VPN, no mediante la exposición del puerto 502.
Filtrado de IP en el controlador (medida secundaria): Algunos controladores recientes (Schneider M340 con firmware ≥ 2.60, Siemens S7-1200 con firmware V4.x y funciones FIREWALL) admiten el filtrado de IP: solo la dirección IP de la pasarela Eziwan está autorizada a conectarse al puerto 502.
Seguridad Modbus (medida avanzada): La Modbus Organization publicó en 2018 una extensión denominada «Modbus Security» (transporte TLS para Modbus TCP) en el puerto 802. Esta extensión aún no es ampliamente compatible con los controladores lógicos programables (PLC) del mercado, pero está disponible en algunos equipos recientes.
6. Optimización del rendimiento de la recogida
Agrupación de consultas (batching)
La regla de oro: agrupar las lecturas en bloques contiguos. Leer 100 registros contiguos en una sola consulta es mucho más eficaz que realizar 100 consultas de un registro cada una:
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
La pasarela Eziwan agrupa automáticamente las variables configuradas en rangos contiguos. Para aprovechar al máximo esta agrupación, organiza tus variables de supervisión en zonas de memoria adyacentes.
Repercusión en el ciclo del autómata
El controlador procesa cada solicitud Modbus TCP recibida al final del ciclo de programa. En un M340 con un ciclo de 10 ms, 10 solicitudes Modbus simultáneas pueden alargar el ciclo entre 1 y 3 ms.
Recomendación: no superar una solicitud Modbus por ciclo del autómata en aplicaciones con requisitos de tiempo real. Reducir la frecuencia de sondeo si el ciclo se alarga.
7. Ejemplo de código completo en Python con 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()
Este código ilustra las buenas prácticas para la recopilación de datos Modbus TCP en Python: agrupación de lecturas, gestión de excepciones, reconexión automática y decodificación de tipos complejos. En producción, la pasarela Eziwan gestiona todas estas funciones de forma nativa, sin necesidad de un script de Python in situ.
Preguntas frecuentes — Protocolo Modbus TCP/IP
¿Por qué el puerto Modbus TCP es el 502 y no un puerto estándar como el 80 o el 443?
El puerto 502 fue asignado oficialmente a Modbus por la IANA (Autoridad de Asignación de Números de Internet) durante la estandarización de Modbus TCP en la década de 1990. En aquel momento no había motivos para reutilizar puertos de aplicaciones informáticas ya existentes. Se trata de un puerto registrado (< 1024), por lo que su uso como servidor requiere derechos de root en Linux, algo que los firmwares industriales gestionan de forma nativa.
¿Cuál es el límite de registros que se pueden leer en una sola consulta FC=03?
El límite es de 125 registros por solicitud FC=03 (Read Holding Registers), tal y como se define en la especificación oficial de Modbus. Es decir, un máximo de 250 bytes de datos útiles por solicitud. Para FC=16 (Write Multiple Registers), el límite es de 123 registros por solicitud. La pasarela Eziwan gestiona automáticamente la división si un rango solicitado supera estos límites.
¿Admite Modbus TCP varias conexiones de cliente simultáneas?
Sí, pero es el controlador el que define el límite. El Schneider M340 admite hasta 16 conexiones simultáneas, mientras que el Siemens S7-1200 admite hasta 8 (a través de MB_SERVER). Si se supera este límite, el controlador devuelve una excepción 06 (Server Busy). En la práctica, una pasarela de recopilación + una herramienta de diagnóstico + un SCADA local alcanzan fácilmente las 3 conexiones simultáneas, por lo que hay que estar atento.
¿Se puede cifrar Modbus TCP?
El estándar Modbus TCP nativo (puerto 502) no está cifrado. Desde 2018 existe una extensión denominada «Modbus Security» en el puerto 802 con TLS, pero los controladores lógicos actuales rara vez la admiten. La solución práctica consiste en confinar Modbus TCP a una red OT aislada y hacer pasar toda la comunicación hacia el exterior a través de un túnel VPN cifrado (como OpenVPN de Eziwan); el cifrado lo garantiza la capa de transporte, no el propio Modbus.
¿Es pymodbus la única biblioteca de Python para Modbus TCP?
No. Las principales alternativas son minimalmodbus (más sencillo para Modbus RTU), umodbus (ligero, con bajo consumo de memoria) y pyModbusTCP (puramente TCP, sin dependencias). Para proyectos industriales en Python, pymodbus >= 3.0 sigue siendo la opción más completa, con gestión de excepciones, reconexión y compatibilidad con ambos modos (RTU y TCP).
¿Cómo depurar una comunicación Modbus TCP sin acceso al controlador lógico programable?
Utiliza Modbus Slave (Windows) como simulador de controlador lógico local, o diagslave (línea de comandos, multiplataforma). Estas herramientas simulan un servidor Modbus TCP que responde a direcciones configurables. Es ideal para probar una configuración de recopilación de datos sin correr el riesgo de afectar a un controlador en producción.
¿Tu controlador utiliza Modbus TCP, pero no estás seguro de si es compatible con tu versión de firmware?
Comprueba la compatibilidad de tu controlador →
Véase también: Modbus TCP con un Schneider M340 y la documentación de Siemens S7 para configuraciones específicas.