Skip to main content

Protocol Bridging: Translating Between EtherNet/IP, Modbus, and MQTT at the Edge [2026]

· 14 min read

Every manufacturing plant is multilingual. One production line speaks EtherNet/IP to Allen-Bradley PLCs. The next line uses Modbus TCP to communicate with temperature controllers. A legacy packaging machine only understands Modbus RTU over RS-485. And the cloud platform that needs to ingest all of this data speaks MQTT.

The edge gateway that bridges these protocols isn't just a translator — it's an architect of data quality. A poor bridge produces garbled timestamps, mistyped values, and silent data gaps. A well-designed bridge normalizes disparate protocols into a unified, timestamped data stream that cloud analytics can consume without post-processing.

This guide covers the engineering patterns that make protocol bridging work reliably at scale.

The Protocol Translation Problem

Let's be specific about what needs translation. Consider three devices on the same plant floor:

Device A — Allen-Bradley Micro820 (EtherNet/IP)

  • Tags: BlenderStatus_INT, ProcessTemp, BatchCount
  • Data types: Named, typed, self-describing
  • Access: TCP port 44818, CIP explicit messaging
  • Resolution: 32-bit float, 16-bit integer, 8-bit boolean

Device B — Temperature Controller (Modbus TCP)

  • Registers: 300800 (device type), 400520 (temp setpoint), 301039 (serial month)
  • Data types: Raw 16-bit registers, meaning depends on documentation
  • Access: TCP port 502, function codes 03/04
  • Resolution: 16-bit registers, sometimes paired for 32-bit values

Device C — RTU Sensor (Modbus RTU)

  • Same register structure as Modbus TCP
  • Access: RS-485 serial, 9600 baud, 8N1
  • Timing: 50ms byte timeout, 400ms response timeout
  • Addressing: Slave ID required

Cloud Destination — Azure IoT Hub (MQTT 3.1.1)

  • Transport: TCP port 8883 (TLS)
  • Payload: JSON or binary, max ~256KB per message
  • QoS: Level 1 (at least once delivery)
  • Topics: devices/{deviceId}/messages/events/

The bridge must read from all three simultaneously, normalize the data into a common format, and deliver it reliably over MQTT — handling network outages, device failures, and data type mismatches along the way.

Step 1: Protocol-Specific Data Acquisition

EtherNet/IP Acquisition

EtherNet/IP's tag-based model makes acquisition straightforward. The gateway creates tag handles specifying the protocol, PLC address, CPU type, and tag name:

protocol=ab-eip
gateway=192.168.1.50
cpu=micro800
elem_count=1
elem_size=4
name=ProcessTemp

The CIP stack returns typed values directly — a REAL tag gives you an IEEE 754 float, an INT gives you a signed 16-bit integer. No register-to-value conversion needed.

Key engineering decisions:

  • Tag handle lifecycle: Create once, read many. Recreating tag handles on every poll adds 5-20ms overhead. But if a read returns error -32 (no PLC connection), the handle may be stale and should be destroyed and recreated.
  • Element batching: Reading temp_zones[0] through temp_zones[7] as elem_count=8 reduces 8 TCP transactions to 1.
  • CPU type selection: The cpu=micro800 parameter isn't cosmetic — it determines the CIP routing path and message format. Wrong CPU type = silent failures.

Modbus TCP Acquisition

Modbus TCP requires more engineering. The gateway must:

  1. Map function codes from register addresses. The address range determines the function code:
Address RangeFunction CodeModbus Object Type
0–65536FC 01Coils (read/write bits)
100000–165536FC 02Discrete Inputs (read-only bits)
300000–365536FC 04Input Registers (read-only 16-bit)
400000–465536FC 03Holding Registers (read/write 16-bit)

A register at address 300800 maps to FC 04 (Read Input Registers) with a base address of 800. Address 400520 maps to FC 03 (Read Holding Registers) at base 520.

  1. Coalesce consecutive registers into single reads. If tags at addresses 400500, 400501, 400502, and 400503 all need reading, the gateway should issue one FC 03 read for 4 registers starting at 500 — not four separate reads.

The coalescing algorithm groups tags by:

  • Same function code (same address range)
  • Consecutive addresses (no gaps)
  • Same polling interval
  • Total count under the maximum (typically 50 registers per read to keep packets under 100 bytes)

When a gap appears or the register limit is reached, the current group is read and a new group starts.

  1. Handle multi-register values. A 32-bit float or integer typically spans two consecutive 16-bit Modbus registers. The byte order matters enormously:
Register layout for 32-bit value at address 400500:
Register 400500 (low word): 0x4248
Register 400501 (high word): 0x0000

Big-endian (AB CD): float = 50.0
Little-endian (CD AB): float = garbage
Word-swapped (BA DC): also garbage

The gateway must know the byte order convention for each device. Most Allen-Bradley controllers combine registers as (high_word shifted left 16) OR low_word. Many European controllers use the opposite. Some use word-swapped ordering. Getting this wrong produces values that look plausible but are completely incorrect — the most dangerous kind of bug.

For IEEE 754 floats, the libmodbus function modbus_get_float() handles the conversion correctly for standard byte ordering. But when you encounter a non-standard device, you'll need to implement custom byte swapping.

Modbus RTU Acquisition

Modbus RTU adds serial communication complexity on top of the register interpretation:

Serial link parameters:

  • Baud rate: Must match the slave device (9600, 19200, 38400 are common)
  • Parity: None (N), Even (E), or Odd (O) — must match exactly
  • Data bits: Typically 8
  • Stop bits: 1 or 2

Timing is critical:

  • Byte timeout (inter-character): 50ms is a safe default. Too short = corrupted frames. Too long = wasted time.
  • Response timeout: 400ms allows for slow devices. Some legacy sensors need 500ms+.
  • Inter-frame silence: 3.5 character times minimum between frames (at 9600 baud, this is ~4ms).

Flush the buffer. Before each poll cycle, flush the serial port receive buffer. Stale data from partial responses will corrupt the next read.

Connection recovery: Unlike TCP, serial connections don't have a "connected" state. But the Modbus context must be explicitly closed and recreated after certain errors (ETIMEDOUT, ECONNRESET, EPIPE, EBADF). Without this recovery, the serial port can enter a hung state that only a process restart fixes.

The 50ms inter-read delay. After reading a group of Modbus RTU registers, insert a 50ms pause before the next read. This gives the slave device time to release the RS-485 bus and avoids bus contention. Skip this delay and you'll get intermittent CRC errors that are nearly impossible to debug.

Step 2: Type Normalization

The bridge must convert protocol-specific data types into a unified type system. Here's a practical mapping:

Unified TypeEtherNet/IP SourceModbus SourceWire Size
boolplc_tag_get_uint8()FC01/FC02 bit1 byte
int8plc_tag_get_int8()Low byte of FC03/04 register1 byte
uint8plc_tag_get_uint8()Low byte of FC03/04 register1 byte
int16plc_tag_get_int16() (offset × 2)Single FC03/04 register (signed)2 bytes
uint16plc_tag_get_uint16() (offset × 2)Single FC03/04 register (unsigned)2 bytes
int32plc_tag_get_int32() (offset × 4)Two consecutive registers, combined4 bytes
uint32plc_tag_get_uint32() (offset × 4)Two consecutive registers, combined4 bytes
float32plc_tag_get_float32() (offset × 4)Two registers → modbus_get_float()4 bytes

The Offset Trap

When reading typed values from EtherNet/IP tag buffers, the byte offset depends on the element type:

  • 8-bit types: offset = index × 1
  • 16-bit types: offset = index × 2
  • 32-bit types: offset = index × 4

Getting the offset wrong doesn't produce an error — it silently reads the wrong bytes. A get_int16(tag, 3) reads bytes 3-4, but if you meant element index 3 of a 16-bit array, you need get_int16(tag, 6) (index × 2). This bug shows up as "values look wrong but the PLC says they're right."

Calculated Tags: Bit Extraction

Many PLCs pack multiple boolean states into a single integer register (a "status word"). The bridge needs to extract individual bits:

Source: alarm_word (uint16) = 0x0042 = 0000 0000 0100 0010

Extracted tags:
alarm_bit_1 = (alarm_word >> 1) & 0x01 = 1 (active)
alarm_bit_6 = (alarm_word >> 6) & 0x01 = 1 (active)
alarm_bit_0 = (alarm_word >> 0) & 0x01 = 0 (inactive)

Each extracted bit becomes a separate tag in the normalized data stream. The shift count and mask are configured per tag, allowing a single Modbus register to generate 16 individual alarm data points — each with its own change detection and delivery behavior.

Step 3: Intelligent Change Detection

Transmitting every tag value on every poll cycle wastes bandwidth and MQTT broker resources. An effective change detection strategy reduces transmission volume by 60-90% without losing important data.

Value Comparison

For each tag, the gateway maintains the last-read value. On each poll:

  1. Read the current value from the PLC
  2. Compare to the stored previous value (raw binary comparison for speed)
  3. If unchanged AND the tag uses comparison mode → skip delivery
  4. If changed OR this is the first read → deliver to the batch
// Comparison pseudocode
if (tag.do_compare && tag.read_once) {
if (current_value == tag.last_value) {
// Skip — no change
return;
}
}
deliver(tag.id, current_value);
tag.last_value = current_value;

Report-by-Exception with Dependent Tags

The real power emerges when you combine change detection with dependent tag chains. Consider a blender machine:

Primary tag: recipe_number (compare=true, interval=5s)
├── Dependent: recipe_name (only read when recipe_number changes)
├── Dependent: target_weight (only read when recipe_number changes)
├── Dependent: mix_time (only read when recipe_number changes)
└── Dependent: ingredient_ratios[0..7] (only read when recipe_number changes)

In steady-state operation, the gateway reads one tag (recipe_number) every 5 seconds. When the recipe changes — maybe once per hour — it cascades reads of 11 dependent tags. This is 99.5% less traffic than polling all 12 tags continuously.

Hourly Full Refresh

Even with change detection, it's important to periodically force a full read of all tags. Once per hour, the gateway resets the "has been read" flag on every tag, causing the next poll cycle to read and transmit everything — regardless of whether values changed.

This catches edge cases: a value changed and changed back between two polls, a PLC was rebooted and reloaded with different values, or a network glitch caused a lost update.

Step 4: Batching for Efficient MQTT Delivery

Individual tag values are tiny (2-8 bytes), but MQTT overhead per message is significant (topic name, headers, QoS handshake, TLS framing). Batching aggregates multiple tag values into a single MQTT publish.

Binary Batch Format

For bandwidth-constrained deployments (cellular, satellite), a binary format packs data efficiently:

Batch Frame:
[0xF7] # Start marker
[group_count: 4 bytes BE] # Number of time-grouped samples

Per Group:
[timestamp: 4 bytes BE] # Unix epoch seconds
[device_type: 2 bytes BE] # Equipment type code
[serial_num: 4 bytes BE] # Unique device ID
[value_count: 4 bytes BE] # Tags in this group

Per Value:
[tag_id: 2 bytes BE] # Tag numeric identifier
[status: 1 byte] # 0=OK, else error code
[element_count: 1 byte] # Array elements (usually 1)
[element_size: 1 byte] # 1, 2, or 4 bytes
[data: element_count × element_size bytes]

A typical batch with 20 float values occupies ~120 bytes. The equivalent JSON representation would be 400+ bytes. Over a 4G cellular link with per-MB billing, binary batching pays for itself within days.

JSON Batch Format

When human readability matters more than bandwidth:

{
"groups": [
{
"ts": 1709337600,
"device_type": 1018,
"serial_number": 25034752,
"values": [
{ "id": 100, "values": [187.3] },
{ "id": 101, "values": [true] },
{ "id": 102, "values": [42, 17, 3, 89] }
]
}
]
}

Batch Finalization Triggers

A batch should be finalized and transmitted when either:

  1. Size threshold exceeded: Default 4KB. Keeps individual MQTT messages manageable.
  2. Time threshold exceeded: Default 60 seconds. Ensures data reaches the cloud even during quiet periods with few changes.

Whichever trigger fires first wins. During high-activity periods (many tag changes), size triggers dominate. During quiet periods, the time trigger ensures regular heartbeat delivery.

Step 5: Store-and-Forward Reliability

MQTT connections drop. Cellular links go dark. Cloud brokers restart. The gateway must handle all of these without losing data.

Page-Based Circular Buffer

The store-and-forward buffer uses a page-based architecture:

Buffer Memory (2MB):
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ Page 0 │ Page 1 │ Page 2 │ Page 3 │ Page 4 │
│ (free) │ (used) │ (used) │ (work) │ (free) │
└─────────┴─────────┴─────────┴─────────┴─────────┘
↑ ↑
Being sent Currently filling

Page states:

  • Free: Available for new data
  • Work: Currently receiving batched data
  • Used: Contains data waiting for MQTT delivery
  • Sending: Data sent to broker, awaiting QoS 1 ACK

Flow:

  1. Batch data writes into the work page
  2. When the work page fills, it moves to the used queue
  3. The MQTT thread takes the first used page and publishes it
  4. On publish ACK (PUBACK), the page moves back to free
  5. If the send fails, the page stays in used and retries

Overflow handling: If all pages are used (extended outage), the oldest used page is recycled. This is logged as a critical warning — it means data loss. Sizing the buffer correctly for expected outage duration eliminates this in practice. A 2MB buffer with 4KB pages holds 500+ batches — at 60-second intervals, that's over 8 hours of offline storage.

MQTT Connection Watchdog

A separate watchdog monitors data delivery. If no successful MQTT publish ACK is received within 120 seconds:

  1. Destroy the current MQTT client
  2. Clear the "packet sent" flag (don't wait for ACK on a dead connection)
  3. Reinitialize the MQTT library
  4. Reconnect asynchronously (in a separate thread to avoid blocking data collection)
  5. On successful reconnect, begin draining the used page queue

The asynchronous reconnect is critical. If reconnection blocks the main thread, tag polling stops and you lose data from the PLC side too.

TLS Certificate Rotation

Industrial MQTT connections typically use TLS with X.509 certificates. The gateway monitors both the configuration file and the certificate file for modification. If either changes:

  1. Stop the existing MQTT loop
  2. Reload the configuration (hostname, device ID, shared access signature)
  3. Check SAS token expiration against system clock
  4. Reinitialize with new credentials
  5. Reconnect

Token expiration is a silent killer. The SAS token includes an se= timestamp. If the system clock is behind (common on embedded Linux without NTP), the token appears valid locally but the broker rejects it. Always log the token expiration time and the current system time for debugging.

Performance Benchmarks

Based on real-world deployments:

MetricValue
EtherNet/IP tag read (single)5-15ms
Modbus TCP register read (single)3-8ms
Modbus RTU register read (single)15-50ms (serial dependent)
Coalesced Modbus read (50 registers)8-12ms TCP, 30-60ms RTU
Binary batch assembly (20 tags)Under 1ms
MQTT publish + QoS 1 ACK (LAN)5-20ms
MQTT publish + QoS 1 ACK (cellular)50-500ms
Buffer page cycle (write → deliver → free)100-600ms
Maximum sustainable poll rate200-500 tags/second (mixed protocols)

Common Pitfalls

  1. Modbus register addresses off by one. Some devices use 0-based addressing (register 0 = first register), others use 1-based (register 1 = first). Off-by-one errors produce "almost right" values that are difficult to spot.

  2. Float byte order assumption. Never assume. Always verify with a known value. Have the PLC write 123.456 to a register and confirm your gateway reads 123.456, not 3.14e-38.

  3. Exhausting PLC connections. Each EtherNet/IP tag handle consumes a CIP connection. Creating and destroying handles in a tight loop can exhaust the PLC's connection table. Create handles once, reuse them.

  4. Serial port hung state. After a Modbus RTU timeout, always close and reopen the serial port. A partial frame left in the kernel buffer will corrupt every subsequent transaction.

  5. Clock skew. If the gateway's system clock drifts, batch timestamps become unreliable. Use NTP. If NTP isn't available (air-gapped networks), use monotonic clocks for interval timing and only use wall-clock time for batch timestamps.

  6. MQTT topic hierarchy mismatch. Azure IoT Hub uses devices/{deviceId}/messages/events/ with a specific format. AWS IoT Core uses different topic patterns. Make the topic configurable, not hardcoded.

Where machineCDN Fits

machineCDN's edge gateway architecture was purpose-built for multi-protocol bridging. A single gateway instance handles EtherNet/IP and Modbus (TCP + RTU) simultaneously, with automatic device detection, configurable tag maps, binary batch optimization, and a robust store-and-forward buffer. The platform's protocol-aware normalization ensures that a temperature reading from an EtherNet/IP PLC and one from a Modbus RTU sensor arrive in the cloud with identical formatting — making downstream analytics protocol-agnostic.

For plants adding IIoT monitoring incrementally, this means you start with whatever PLCs and controllers you have today. No protocol converters, no middleware, no custom integration code. One edge device, multiple protocols, unified cloud delivery.


Protocol bridging is the unsung hero of industrial IIoT. The protocols themselves are well-documented. The hard part is the engineering in between — type normalization, change detection, batch optimization, and reliable delivery over unreliable networks. Get these patterns right and you have a data pipeline that plant engineers can trust.