Skip to main content

29 posts tagged with "Modbus"

Modbus TCP and RTU protocol for industrial automation

View All Tags

Industrial Data Normalization: Byte Ordering, Register Formats, and Scaling Factors for IIoT [2026]

· 15 min read

Every IIoT engineer eventually hits the same wall: the PLC says the temperature is 16,742, the HMI shows 167.42°C, and your cloud dashboard displays -8.2×10⁻³⁹. Same data, three different interpretations. The problem isn't the network, the database, or the visualization layer — it's data normalization at the edge.

Getting raw register values from industrial devices into correctly typed, properly scaled, human-readable data points is arguably the most underappreciated challenge in IIoT. This guide covers the byte-level mechanics that trip up engineers daily: endianness, register encoding schemes, floating-point reconstruction, and the scaling math that transforms a raw uint16 into a meaningful process variable.

Data normalization and byte ordering in industrial systems

Why This Is Harder Than It Looks

Modern IT systems have standardized on little-endian byte ordering (x86, ARM in LE mode), IEEE 754 floating point, and UTF-8 strings. Industrial devices come from a different world:

  • Modbus uses big-endian (network byte order) for 16-bit registers, but the ordering of registers within a 32-bit value varies by manufacturer
  • EtherNet/IP uses little-endian internally (Allen-Bradley heritage), but CIP encapsulation follows specific rules per data type
  • PROFINET uses big-endian for I/O data
  • OPC-UA handles byte ordering transparently — one of its few genuinely nice features

When your edge gateway reads data from a Modbus device and publishes it via MQTT to a cloud platform, you're potentially crossing three byte-ordering boundaries. Get any one of them wrong and your data is silently corrupt.

The Modbus Register Map Problem

Modbus organizes data into four register types, each accessed by a different function code:

Address RangeRegister TypeFunction CodeData DirectionAccess
0–65,535Coils (discrete outputs)FC 01Read1-bit
100,000–165,535Discrete InputsFC 02Read1-bit
300,000–365,535Input RegistersFC 04Read-only16-bit
400,000–465,535Holding RegistersFC 03Read/Write16-bit

The address ranges are a convention, not a protocol requirement. Your gateway needs to map addresses to function codes:

  • Addresses 0–65,535 → FC 01 (Read Coils)
  • Addresses 100,000–165,535 → FC 02 (Read Discrete Inputs)
  • Addresses 300,000–365,535 → FC 04 (Read Input Registers)
  • Addresses 400,000–465,535 → FC 03 (Read Holding Registers)

The actual register address sent in the Modbus PDU is the offset within the range. So address 400,100 becomes register 100 using function code 03.

Why this matters for normalization: A tag configured with address 300,800 means "read input register 800 using FC 04." A tag at address 400,520 means "read holding register 520 using FC 03." If your gateway mixes these up, it reads the wrong register type entirely — and the PLC happily returns whatever lives at that address, with no type error.

Reading Coils vs Registers: Type Coercion

When reading coils (FC 01/02), the response contains bit-packed data — each coil is a single bit. When reading registers (FC 03/04), each register is a 16-bit word.

The tricky part is mapping these raw responses to typed tag values. Consider a tag configured as uint16 that's being read from a coil address. The raw response is a single bit (0 or 1), but the tag expects a 16-bit value. Your gateway must handle this coercion:

Coil response → bool tag:     bit value directly
Coil response → uint8 tag: cast to uint8
Coil response → uint16 tag: cast to uint16
Coil response → int32 tag: cast to int32 (effectively 0 or 1)

For register responses, the mapping depends on the element count — how many consecutive registers are combined to form the value:

1 register (elem_count=1):
→ uint16: direct value
→ int16: interpret as signed
→ uint8: mask with 0xFF (lower byte)
→ bool: mask with 0xFF, then boolean

2 registers (elem_count=2):
→ uint32: combine two 16-bit registers
→ int32: interpret combined value as signed
→ float: interpret combined value as IEEE 754

The 32-Bit Register Combination Problem

Here's where manufacturers diverge and data corruption begins. A 32-bit value (integer or float) spans two consecutive 16-bit Modbus registers. But which register contains the high word?

Word Order Variants

Big-endian word order (AB CD): Register N contains the high word, register N+1 contains the low word.

Register[N]   = 0x4248    (high word)
Register[N+1] = 0x0000 (low word)
Combined = 0x42480000
As float = 50.0

Little-endian word order (CD AB): Register N contains the low word, register N+1 contains the high word.

Register[N]   = 0x0000    (low word)
Register[N+1] = 0x4248 (high word)
Combined = 0x42480000
As float = 50.0

Byte-swapped big-endian (BA DC): Each register's bytes are swapped, then combined in big-endian order.

Register[N]   = 0x4842    (swapped high)
Register[N+1] = 0x0000 (swapped low)
Combined = 0x42480000
As float = 50.0

Byte-swapped little-endian (DC BA): Each register's bytes are swapped, then combined in little-endian order.

Register[N]   = 0x0000    (swapped low)
Register[N+1] = 0x4842 (swapped high)
Combined = 0x42480000
As float = 50.0

All four combinations are found in the wild. Schneider PLCs typically use big-endian word order. Some Siemens devices use byte-swapped variants. Many Chinese-manufactured VFDs (variable frequency drives) use little-endian word order. There is no way to detect the word order automatically — you must know it from the device documentation or determine it empirically.

Practical Detection Technique

When commissioning a new device and the word order isn't documented:

  1. Find a register that should contain a known float value (like a temperature reading you can verify with a handheld thermometer)
  2. Read two consecutive registers and try all four combinations
  3. The one that produces a physically reasonable value is your word order

For example, if the device reads temperature and the registers contain 0x4220 and 0x0000:

  • AB CD: 0x42200000 = 40.0 ← probably correct if room temp
  • CD AB: 0x00004220 = 5.9×10⁻⁴¹ ← nonsense
  • BA DC: 0x20420000 = 1.6×10⁻¹⁹ ← nonsense
  • DC BA: 0x00002042 = 1.1×10⁻⁴¹ ← nonsense

IEEE 754 Floating-Point Reconstruction

Reading a float from Modbus registers requires careful reconstruction. The standard approach:

Given: Register[N] = high_word, Register[N+1] = low_word (big-endian word order)

Step 1: Combine into 32 bits
uint32 combined = (high_word << 16) | low_word

Step 2: Reinterpret as IEEE 754 float
float value = *(float*)&combined // C-style type punning
// Or use modbus_get_float() from libmodbus

The critical detail: do not cast the integer to float — that performs a numeric conversion. You need to reinterpret the same bit pattern as a float. This is the difference between getting 50.0 (correct) and getting 1110441984.0 (the integer 0x42480000 converted to float).

Common Float Pitfalls

NaN and Infinity: IEEE 754 reserves certain bit patterns for special values. If your combined registers produce 0x7FC00000, that's NaN. If you see 0x7F800000, that's positive infinity. These often appear when:

  • The sensor is disconnected (NaN)
  • The measurement is out of range (Infinity)
  • The registers are being read during a PLC scan update (race condition producing a half-updated value)

Denormalized numbers: Very small float values (< 1.175×10⁻³⁸) are "denormalized" and may lose precision. In industrial contexts, if you're seeing numbers this small, something is wrong with your byte ordering.

Zero detection: A float value of exactly 0.0 is 0x00000000. But 0x80000000 is negative zero (-0.0). Both compare equal in standard float comparison, but the bit patterns are different. If you're doing bitwise comparison for change detection, be aware of this edge case.

Scaling Factors: From Raw to Engineering Units

Many industrial devices don't transmit floating-point values. Instead, they send raw integers that must be scaled to engineering units. This is especially common with:

  • Temperature transmitters (raw: 0–4000 → scaled: 0–100°C)
  • Pressure sensors (raw: 0–65535 → scaled: 0–250 PSI)
  • Flow meters (raw: counts/second → scaled: gallons/minute)

Linear Scaling

The most common pattern is linear scaling with two coefficients:

engineering_value = (raw_value × k1) / k2

Where k1 and k2 are integer scaling coefficients defined in the tag configuration. This avoids floating-point math on resource-constrained edge devices.

Examples:

  • Temperature: k1=1, k2=10 → raw 1675 becomes 167.5°C
  • Pressure: k1=250, k2=65535 → raw 32768 becomes 125.0 PSI
  • RPM: k1=1, k2=1 → raw value is direct (no scaling)

Important: k2 must never be zero. Always validate configuration before applying scaling — a division-by-zero in an edge gateway's main loop crashes the entire data acquisition pipeline.

Bit Extraction (Calculated Tags)

Some devices pack multiple boolean values into a single register. A 16-bit "status word" might contain:

Bit 0: Motor Running
Bit 1: Fault Active
Bit 2: High Temperature
Bit 3: Low Pressure
Bits 4-7: Operating Mode
Bits 8-15: Reserved

Extracting individual values requires bitwise operations:

motor_running = (status_word >> 0) & 0x01    // shift=0, mask=1
fault_active = (status_word >> 1) & 0x01 // shift=1, mask=1
op_mode = (status_word >> 4) & 0x0F // shift=4, mask=15

In a well-designed edge gateway, these "calculated tags" are defined as children of the parent register tag. When the parent register value changes, the gateway automatically recalculates all child tags and delivers their values. This eliminates redundant register reads — you read the status word once and derive multiple data points.

Dependent Tag Chains

Beyond simple bit extraction, production systems use dependent tag chains: when tag A changes, immediately read tags B, C, and D regardless of their normal polling interval.

Example: When machine_state transitions from 0 (IDLE) to 1 (RUNNING), immediately read:

  • Current speed setpoint
  • Actual motor RPM
  • Material temperature
  • Batch counter

This captures the complete state snapshot at the moment of transition, which is far more valuable than catching each value at their independent polling intervals (where you might see the new speed 5 seconds after the state change).

The key architectural insight: tag dependencies form a directed acyclic graph. The edge gateway must traverse this graph depth-first on each parent change, reading and delivering dependent tags within the same batch timestamp for temporal coherence.

Binary Serialization for Bandwidth Efficiency

Once values are normalized, they need to be serialized for transport. Two common formats:

JSON (Human-Readable)

{
"groups": [{
"ts": 1709510400,
"device_type": 1011,
"serial_number": 12345,
"values": [
{"id": 1, "values": [167.5]},
{"id": 2, "values": [true]},
{"id": 3, "values": [1250, 1248, 1251, 1249, 1250, 1252]}
]
}]
}

Binary (Bandwidth-Optimized)

A compact binary format packs the same data into roughly 20–30% of the JSON size:

Byte 0:     0xF7 (frame identifier)
Bytes 1-4: Number of groups (uint32, big-endian)

Per group:
4 bytes: Timestamp (uint32)
2 bytes: Device type (uint16)
4 bytes: Serial number (uint32)
4 bytes: Number of values (uint32)

Per value:
2 bytes: Tag ID (uint16)
1 byte: Status (0x00 = OK, else error code)
If status == OK:
1 byte: Array size (number of elements)
1 byte: Element size (1, 2, or 4 bytes)
N bytes: Packed values, each big-endian

Value packing examples:

bool:   true  → 0x01           (1 byte)
bool: false → 0x00 (1 byte)
int16: 55 → 0x00 0x37 (2 bytes, big-endian)
int16: -55 → 0xFF 0xC9 (2 bytes, two's complement)
uint16: 32768 → 0x80 0x00
int32: 55 → 0x00 0x00 0x00 0x37
float: 1.55 → 0x3F 0xC6 0x66 0x66 (IEEE 754)
float: -1.55 → 0xBF 0xC6 0x66 0x66

Note the byte ordering in the serialization format: values are packed big-endian (MSB first) regardless of the source device's native byte ordering. The edge gateway normalizes byte order during serialization, so the cloud consumer never needs to worry about endianness.

Register Grouping and Read Optimization

Modbus allows reading up to 125 consecutive registers in a single request (FC 03/04). A naive implementation sends one request per tag — reading 50 tags requires 50 round trips, each with its own Modbus frame overhead and inter-frame delay.

A well-optimized gateway groups tags by:

  1. Same function code — Tags addressed at 400,100 and 300,100 cannot be grouped (different FC)
  2. Contiguous addresses — Tags at addresses 400,100 and 400,101 can be read in one request
  3. Same polling interval — Tags with different intervals should be in separate groups to avoid reading slow-interval tags too frequently
  4. Maximum register count — Cap at ~50 registers per request to stay well within Modbus limits and avoid timeout issues with slower devices

The algorithm: sort tags by address, then walk the sorted list. Start a new group when:

  • The function code changes
  • The address is not contiguous with the previous tag
  • The polling interval differs
  • The accumulated register count exceeds the maximum

After each group read, insert a brief pause (50ms is typical) before the next read. This prevents overwhelming slow Modbus devices that need time between transactions to process their internal scan.

Change Detection and Comparison

For bandwidth-constrained deployments (cellular, satellite, LoRaWAN backhaul), sending every value on every read cycle is wasteful. Implement value comparison:

On each tag read:
if (tag.compare_enabled):
if (new_value == last_value) AND (status unchanged):
skip delivery
else:
deliver value
update last_value
else:
always deliver

The comparison must be type-aware:

  • Integer types: Direct bitwise comparison (uint_value != last_uint_value)
  • Float types: Bitwise comparison, NOT approximate comparison. In industrial contexts, if the bits didn't change, the value didn't change. Using epsilon-based comparison would miss relevant changes while potentially false-triggering on noise.
  • Boolean types: Direct comparison

Periodic forced delivery: Even with comparison enabled, force-deliver all tag values once per hour. This ensures the cloud state eventually converges with reality, even if a value change was missed during a brief network outage.

Handling Modbus RTU vs TCP

The normalization logic is identical for Modbus RTU (serial) and Modbus TCP (Ethernet). The differences are all in the transport layer:

ParameterModbus RTUModbus TCP
PhysicalRS-485 serialEthernet
ConnectionSerial port openTCP socket connect
AddressingSlave address (1-247)IP:port (default 502)
FramingCRC-16MBAP header
TimingInter-character timeout mattersTCP handles retransmission
Baud rate9600–115200 typicalN/A (Ethernet speed)
Response timeout400ms typicalShorter (network dependent)

RTU-Specific Configuration

For Modbus RTU, the serial link parameters must match the device exactly:

Baud rate:       9600 (most common) or 19200, 38400, 115200
Parity: None, Even, or Odd
Data bits: 8 (almost always)
Stop bits: 1 or 2
Slave address: 1-247
Byte timeout: 50ms (time between bytes in a frame)
Response timeout: 400ms (time to wait for a response)

Critical RTU detail: Always flush the serial buffer before starting a new transaction. Stale bytes in the receive buffer from a previous timed-out response will corrupt the current response parsing. This is the number one cause of intermittent "bad CRC" errors on Modbus RTU links.

Error Handling That Matters

When a Modbus read fails, the error code tells you what went wrong:

errnoMeaningRecovery Action
ETIMEDOUTDevice didn't respondRetry 2x, then mark link DOWN
ECONNRESETConnection droppedClose + reconnect
ECONNREFUSEDDevice rejected connectionCheck IP/port, wait before retry
EPIPEBroken pipeClose + reconnect
EBADFBad file descriptorSocket is dead, full reinit

On any of these errors, the correct response is: flush the connection, close it, mark the device link state as DOWN, and attempt reconnection on the next cycle. Don't try to send more data on a dead connection — it will fail faster than you can log it.

Deliver error status alongside the tag. When a tag read fails, don't silently drop the data point. Deliver the tag ID with a non-zero status code and no value data. This lets the cloud platform distinguish between "the sensor reads 0" and "we couldn't reach the sensor." They're very different situations.

How machineCDN Handles Data Normalization

machineCDN's edge runtime performs all normalization at the device boundary — byte order conversion, type coercion, bit extraction, scaling, and comparison — before data touches the network. The binary serialization format described above is the actual wire format used between edge gateways and the machineCDN cloud, achieving typical compression ratios of 3–5x versus JSON while maintaining full type fidelity.

For plant engineers, this means you configure tags with their register addresses, data types, and scaling factors. The platform handles the byte-level mechanics — you never need to manually swap words, reconstruct floats, or debug endianness issues. Tag values arrive in the cloud as properly typed, correctly scaled engineering units, ready for dashboards, analytics, and alerting.

Checklist: Commissioning a New Device

When connecting a new Modbus device to your IIoT platform:

  1. Identify the register map — Get the manufacturer's documentation. Don't guess addresses.
  2. Determine the word order — Read a known float value and try all four combinations.
  3. Verify function codes — Confirm which registers use FC 03 vs FC 04.
  4. Check the slave address — RTU only; confirm via device configuration panel.
  5. Set appropriate timeouts — 50ms byte timeout, 400ms response timeout for RTU; 2000ms for TCP.
  6. Read one tag at a time first — Validate each tag independently before grouping.
  7. Compare with HMI values — Cross-reference your gateway's readings against the device's local display.
  8. Enable comparison selectively — For status bits and slow-changing values only. Disable for process variables during commissioning.
  9. Monitor for -32 / timeout errors — Persistent errors indicate wiring, addressing, or timing issues.
  10. Document everything — Future you will not remember why tag 0x1A uses elem_count=2 with k1=10 and k2=100.

Conclusion

Data normalization is the unglamorous foundation of every working IIoT system. When it works, nobody notices. When it fails, your dashboards show nonsense and operators lose trust in the platform.

The key principles:

  • Know your byte order — and document it per device
  • Match element size to data type — a 4-byte read on a 2-byte register reads adjacent memory
  • Use bitwise comparison for floats — not epsilon
  • Batch and serialize efficiently — binary beats JSON for bandwidth-constrained links
  • Group contiguous registers — reduce Modbus round trips by 5–10x
  • Always deliver error status — silent data drops are worse than explicit failures

Get these right at the edge, and every layer above — time-series databases, dashboards, ML models, alerting — inherits clean, trustworthy data. Get them wrong, and no amount of cloud processing can fix values that were corrupted before they left the factory floor.

Modbus TCP Gateway Failover: Building Redundant PLC Communication for Manufacturing [2026]

· 14 min read

Modbus TCP gateway failover architecture

Modbus TCP remains the most widely deployed industrial protocol in manufacturing. Despite being a 1979 design extended to Ethernet in 1999, its simplicity — request/response over TCP, 16-bit registers, four function codes that cover 90% of use cases — makes it the lowest common denominator that virtually every PLC, VFD, and sensor hub supports.

But simplicity has a cost: Modbus TCP has zero built-in redundancy. No heartbeats. No automatic reconnection. No session recovery. When the TCP connection drops — and in a factory environment with electrical noise, cable vibrations, and switch reboots, it will drop — your data collection goes dark until someone manually restarts the gateway or the application logic handles recovery.

This guide covers the architecture patterns for building resilient Modbus TCP gateways that maintain data continuity through link failures, PLC reboots, and network partitions.

Understanding Why Modbus TCP Connections Fail

Before designing failover, you need to understand the failure modes. In a year of operating Modbus TCP gateways across manufacturing floors, you'll encounter all of these:

Failure Mode 1: TCP Connection Reset (ECONNRESET)

The PLC or an intermediate switch drops the TCP connection. Common causes:

  • PLC firmware update or watchdog reboot
  • Switch port flap (cable vibration, loose connector)
  • PLC connection limit exceeded (most support 6-16 simultaneous TCP connections)
  • Network switch spanning tree reconvergence (can take 30-50 seconds on older managed switches)

Detection time: Immediate — the next modbus_read_registers() call returns ECONNRESET.

Failure Mode 2: Connection Timeout (ETIMEDOUT)

The PLC stops responding but doesn't close the connection. The TCP socket remains open, but reads time out. Common causes:

  • PLC CPU overloaded (complex ladder logic consuming all scan cycles)
  • Network congestion (broadcast storms, misconfigured VLANs)
  • IP conflict (another device grabbed the PLC's address)
  • PLC in STOP mode (program halted, communication stack still partially active)

Detection time: Your configured response timeout (typically 500ms-2s) per read operation. For a 100-tag poll cycle, a full timeout can mean 50-200 seconds of dead time before you confirm the link is down.

Failure Mode 3: Connection Refused (ECONNREFUSED)

The PLC's TCP stack is active but Modbus is not. Common causes:

  • PLC in bootloader mode after firmware flash
  • Modbus TCP server disabled in PLC configuration
  • Firewall rule change on managed switch blocking port 502

Detection time: Immediate on the next connection attempt.

Failure Mode 4: Silent Failure (EPIPE/EBADF)

The connection appears open from the gateway's perspective, but the PLC has already closed it. The first write or read on a stale socket triggers EPIPE or EBADF. This happens when:

  • PLC reboots cleanly but the gateway missed the FIN packet (common with UDP-accelerated switches)
  • OS socket cleanup runs asynchronously

Detection time: Only on the next read/write attempt — could be seconds to minutes if polling intervals are long.

The Connection Recovery State Machine

A resilient Modbus TCP gateway implements a state machine with five states:

                    ┌─────────────┐
│ CONNECTING │
│ (backoff) │
└──────┬──────┘
│ modbus_connect() success
┌──────▼──────┐
┌─────│ CONNECTED │─────┐
│ │ (polling) │ │
│ └──────┬──────┘ │
│ │ │
timeout/error link_state=1 read error
│ │ │
┌────────▼───┐ ┌─────▼─────┐ ┌──▼──────────┐
│ RECONNECT │ │ READING │ │ LINK_DOWN │
│ (flush + │ │ (normal) │ │ (notify + │
│ close) │ │ │ │ reconnect) │
└────────┬───┘ └───────────┘ └──┬──────────┘
│ │
└────────────────────────┘
close + backoff

Key Implementation Details

1. Always close before reconnecting. A stale Modbus context will leak file descriptors and eventually exhaust the OS socket table. When any error occurs in the ETIMEDOUT/ECONNRESET/EPIPE/EBADF family, the correct sequence is:

modbus_flush(context)   → drain pending data
modbus_close(context) → close the TCP socket
sleep(backoff_ms) → prevent reconnection storms
modbus_connect(context) → establish new connection

Never call modbus_connect() on a context that hasn't been closed first. The libmodbus library doesn't handle this gracefully — you'll get zombie sockets.

2. Implement exponential backoff with a ceiling. After a connection failure, don't retry immediately — the PLC may be rebooting and needs time. A practical backoff schedule:

AttemptDelayCumulative Time
11 second1s
22 seconds3s
34 seconds7s
48 seconds15s
5+10 seconds (ceiling)25s+

The 10-second ceiling is important — you don't want the backoff growing to minutes. PLC reboots typically complete in 15-45 seconds. A 10-second retry interval means you'll reconnect within one retry cycle after the PLC comes back.

3. Flush serial buffers for Modbus RTU. If your gateway also supports Modbus RTU (serial), always call modbus_flush() before reading after a reconnection. Serial buffers can contain stale response fragments from before the disconnection, and these will corrupt the first read's response parsing.

4. Track link state as a first-class data point. Don't just log connection status — deliver it to the cloud alongside your tag data. A special "link state" tag (boolean: 0 = disconnected, 1 = connected) transmitted immediately (not batched) gives operators real-time visibility into gateway health. When the link transitions from 1→0, send a notification. When it transitions from 0→1, force-read all tags to establish current values.

Register Grouping: Minimizing Round Trips

Modbus TCP's request/response model means each read operation incurs a full TCP round trip (~0.5-5ms on a local network, 50-200ms over cellular). Reading 100 individual registers one at a time takes 100 round trips — potentially 500ms on a good day.

The optimization is contiguous register grouping — instead of reading registers one at a time, read blocks of contiguous registers in a single request.

The Grouping Algorithm

Given a sorted list of register addresses to read, the gateway walks through them and groups contiguous registers that meet three criteria:

  1. Same function code — you can't mix input registers (FC 4, 3xxxxx) with holding registers (FC 3, 4xxxxx) in one request
  2. Contiguous addresses — register N+1 immediately follows register N (with appropriate gaps filled)
  3. Same polling interval — don't group a 1-second alarm tag with a 60-second temperature tag
  4. Maximum register count ≤ 50 — while Modbus allows up to 125 registers per read, keeping requests under 50 registers (~100 bytes) prevents fragmentation issues on constrained networks and limits the blast radius of a single failed read

Example: Optimized vs Naive Polling

Consider a chiller with 10 compressor circuits, each reporting 16 process variables:

Naive approach: 160 individual reads = 160 round trips

Read register 300003 → 1 register  (CQT1 Condenser Inlet Temp)
Read register 300004 → 1 register (CQT1 Approach Temp)
Read register 300005 → 1 register (CQT1 Chill In Temp)
...
Read register 300016 → 1 register (CQT1 Superheat Temp)

Grouped approach: Registers 300003-300018 are contiguous, same function code (FC 4), same interval (60s)

Read registers 300003 → 16 registers (all CQT1 process data in ONE request)
Read registers 300350 → 16 registers (all CQT2 process data in ONE request)
...

Result: 160 round trips → 10 round trips. On a 2ms RTT network, that's 320ms → 20ms.

Handling Non-Contiguous Gaps

Real PLC register maps aren't perfectly contiguous. The chiller above has CQT1 data at registers 300003-300018 and CQT2 data starting at 300350 — a gap of 332 registers. Don't try to read 300003-300695 in one request to "fill the gap" — you'll read hundreds of irrelevant registers and waste bandwidth.

Instead, break at non-contiguous boundaries:

Group 1: 300003-300018  (16 registers, CQT1 process data)
Group 2: 300022-300023 (2 registers, CQT1 alarm bits)
Group 3: 300038-300043 (6 registers, CQT1 expansion + version)
Group 4: 300193-300194 (2 registers, CQT1 status words)
Group 5: 300260-300278 (19 registers, CQT2-10 alarm bits)
Group 6: 300350-300366 (17 registers, CQT2-3 temperatures)
...

The 50ms Inter-Read Delay

Between consecutive Modbus read requests, insert a 50ms delay. This sounds counterintuitive — why slow down? — but it serves two purposes:

  1. PLC scan cycle breathing room. Many PLCs process Modbus requests in their communication interrupt, which competes with the main scan cycle. Rapid-fire requests can extend the scan cycle, triggering watchdog timeouts on safety-critical programs.

  2. TCP congestion avoidance. On constrained networks (especially cellular gateways), bursting 50 reads in 100ms can overflow buffers. The 50ms spacing distributes the load evenly.

Dual-Path Failover Architecture

For mission-critical data collection (pharmaceutical batch records, automotive quality traceability), a single gateway represents a single point of failure. The dual-path architecture uses two independent gateways polling the same PLC:

Architecture

                    ┌──────────┐
│ PLC │
│ (Modbus) │
└──┬───┬──┘
│ │
Port 502 │ │ Port 502
│ │
┌────────▼┐ ┌▼────────┐
│Gateway A│ │Gateway B│
│(Primary)│ │(Standby)│
└────┬────┘ └────┬────┘
│ │
▼ ▼
┌────────────────────┐
│ MQTT Broker │
│ (cloud/edge) │
└────────────────────┘

Active/Standby vs Active/Active

Active/Standby: Gateway A polls the PLC. Gateway B monitors A's heartbeat (via MQTT LWT or a shared health topic). If A goes silent for >30 seconds, B starts polling. When A recovers, it checks B's status and either resumes as primary or remains standby.

  • Pro: Only one gateway reads from the PLC, respecting the PLC's connection limit
  • Con: 30-second failover gap

Active/Active: Both gateways poll the PLC simultaneously. The cloud platform deduplicates data based on timestamps and device serial numbers. If one gateway fails, the other's data is already flowing.

  • Pro: Zero-downtime failover, no coordination needed
  • Con: Doubles PLC connection count and network traffic. Most PLCs support this (6-16 connections), but verify.

Recommendation: Active/Active with cloud-side deduplication. The PLC connection overhead is negligible compared to the operational cost of a 30-second data gap. Cloud-side deduplication is trivial — tag ID + timestamp + device serial number provides a natural composite key.

Store-and-Forward: Surviving Cloud Disconnections

Gateway-to-PLC failover handles half the problem. The other half is cloud connectivity — cellular links drop, VPN tunnels restart, and MQTT brokers undergo maintenance. During these outages, the gateway must buffer data locally and forward it when connectivity returns.

The Paged Ring Buffer

A production-grade store-and-forward buffer uses a paged ring buffer — pre-allocated memory divided into fixed-size pages, with separate write and read pointers:

┌──────────┐
│ Page 0 │ ← read_pointer (next to transmit)
│ [data] │
├──────────┤
│ Page 1 │
│ [data] │
├──────────┤
│ Page 2 │ ← write_pointer (next to fill)
│ [empty] │
├──────────┤
│ Page 3 │
│ [empty] │
└──────────┘

When the MQTT connection is healthy:

  1. Tag data is written to the current work page
  2. When the page fills, it moves to the "used" queue
  3. The buffer transmits the oldest used page to MQTT (QoS 1 for delivery confirmation)
  4. On publish acknowledgment, the page moves to the "free" queue

When the MQTT connection drops:

  1. Tag data continues writing to pages (the PLC doesn't stop producing data)
  2. Used pages accumulate in the queue
  3. If the queue fills, the oldest used page is recycled as a work page — accepting data loss of the oldest data to preserve the newest

This design guarantees:

  • Constant memory usage — no dynamic allocation on an embedded device
  • Graceful degradation — oldest data is sacrificed first
  • Thread safety — mutex-protected page transitions prevent race conditions between the reading thread (PLC poller) and writing thread (MQTT publisher)

Sizing the Buffer

Buffer size depends on your data rate and expected maximum outage duration:

buffer_size = data_rate_bytes_per_second × max_outage_seconds × 1.2 (overhead)

For a typical deployment:

  • 100 tags × 4 bytes/value = 400 bytes per poll cycle
  • 1 poll per second = 400 bytes/second
  • Binary encoding with batch overhead: ~500 bytes/second
  • Target 4 hours of offline buffering: 500 × 14,400 = 7.2MB

With 512KB pages, that's ~14 pages. Allocate 16 pages (minimum 3 needed for operation: one writing, one transmitting, one free) for an 8MB buffer.

Binary vs JSON Encoding for Buffered Data

JSON is wasteful for buffered data. The same 100-tag reading:

  • JSON: {"groups":[{"ts":1709500800,"device_type":1018,"serial_number":23456,"values":[{"id":1,"values":[245]},{"id":2,"values":[312]},...]}]} → ~2KB
  • Binary: Header (0xF7 + group count + timestamp + device info) + packed tag values → ~500 bytes

Binary encoding uses a compact format:

[0xF7] [num_groups:4] [timestamp:4] [device_type:2] [serial_num:4] 
[num_values:4] [tag_id:2] [status:1] [value_count:1] [value_size:1] [values...]

Over a cellular connection billing at $5/GB, the 4× bandwidth savings of binary encoding pays for itself within days on a busy gateway.

Alarm Tag Priority: Batched vs Immediate Delivery

Not all tags are created equal. A temperature reading that's 0.1°C different from the last poll can wait for the next batch. An alarm bit that just flipped from 0 to 1 cannot.

The gateway should support two delivery modes per tag:

Batched Delivery (Default)

Tags are accumulated in the batch buffer and delivered on the batch timeout (typically 5-30 seconds) or batch size limit (typically 10-500KB). This is efficient for process variables that change slowly.

Configuration:

{
"name": "Tank Temperature",
"id": 1,
"addr": 300202,
"type": "int16",
"interval": 60,
"compare": false
}

Immediate Delivery (do_not_batch)

Tags bypass the batch buffer entirely. When the value changes, a single-value batch is created, serialized, and pushed to the output buffer immediately. This is essential for:

  • Alarm words — operators need sub-second alarm notification
  • Machine state transitions — running/stopped/faulted changes trigger downstream actions
  • Safety interlocks — any safety-relevant state change must be delivered without batching delay

Configuration:

{
"name": "CQT 1 Alarm Bits 1",
"id": 163,
"addr": 300022,
"type": "uint16",
"interval": 1,
"compare": true,
"do_not_batch": true
}

The compare: true flag is critical for immediate-delivery tags — without it, the gateway would transmit on every read cycle (every 1 second), flooding the network. With comparison enabled, the gateway only transmits when the alarm word actually changes — zero bandwidth during normal operation, instant delivery when an alarm fires.

Calculated Tags: Extracting Bit-Level Alarms from PLC Words

Many PLCs pack multiple alarm states into a single 16-bit register. Bit 0 might indicate "high temperature," bit 1 "low flow," bit 2 "compressor fault," etc. Rather than requiring the cloud platform to perform bitwise decoding, a production gateway extracts individual bits and delivers them as separate boolean tags.

The extraction uses shift-and-mask arithmetic:

alarm_word = 0xA5 = 10100101 in binary

bit_0 = (alarm_word >> 0) & 0x01 = 1 → "High Temperature" = TRUE
bit_1 = (alarm_word >> 1) & 0x01 = 0 → "Low Flow" = FALSE
bit_2 = (alarm_word >> 2) & 0x01 = 1 → "Compressor Fault" = TRUE
...

These calculated tags are defined as children of the parent alarm word. When the parent tag changes value (detected by the compare flag), all child calculated tags are re-evaluated and delivered. If the parent doesn't change, no child processing occurs — zero CPU overhead during steady state.

This architecture keeps the PLC configuration simple (one alarm word per circuit) while giving cloud consumers individual, addressable alarm signals.

Putting It All Together: A Production Gateway Checklist

Before deploying a Modbus TCP gateway to production, verify:

  • Connection recovery handles all five error codes (ETIMEDOUT, ECONNRESET, ECONNREFUSED, EPIPE, EBADF)
  • Exponential backoff with 10-second ceiling prevents reconnection storms
  • Link state is delivered as a first-class tag (not just logged)
  • Register grouping batches contiguous same-function-code registers (max 50 per read)
  • 50ms inter-read delay protects PLC scan cycle integrity
  • Store-and-forward buffer sized for target offline duration
  • Binary encoding used for buffered data (not JSON)
  • Alarm tags configured with compare: true and immediate delivery
  • Calculated tags extract individual bits from alarm words
  • Force-read on reconnection ensures fresh values after any link recovery
  • Hourly full re-read resets all "read once" flags to catch any drift

machineCDN and Modbus TCP

machineCDN's edge gateway implements these patterns natively — connection state management, contiguous register grouping, binary batch encoding, paged ring buffers, and calculated alarm tags — so that plant engineers can focus on which tags to monitor rather than how to keep the data flowing. The gateway's JSON-based tag configuration maps directly to the PLC's register map, and the dual-format delivery system (binary for efficiency, JSON for interoperability) adapts to whatever network path is available.

For manufacturing teams running Modbus TCP equipment — from chillers and dryers to injection molding machines and conveying systems — getting the gateway layer right is the difference between a monitoring system that works in the lab and one that survives a year on the factory floor.


Building a Modbus TCP monitoring system? machineCDN handles protocol translation, buffering, and cloud delivery for manufacturing equipment — so your data keeps flowing even when your network doesn't.

Best PLC Data Collection Software 2026: 10 Platforms for Extracting Value from Your Controllers

· 10 min read
MachineCDN Team
Industrial IoT Experts

Your PLCs already know everything about your manufacturing operation — cycle times, temperatures, pressures, motor speeds, part counts, alarm states. The problem isn't data. It's getting that data out of the PLC and into a place where humans and AI can actually use it. PLC data collection software bridges that gap, and choosing the right platform determines whether you get actionable intelligence or just another data silo.

Edge Gateway Lifecycle Architecture: From Boot to Steady-State Telemetry in Industrial IoT [2026]

· 14 min read

Most IIoT content treats the edge gateway as a black box: PLC data goes in, cloud data comes out. That's fine for a sales deck. It's useless for the engineer who needs to understand why their gateway loses data during a network flap, or why configuration changes require a full restart, or why it takes 90 seconds after boot before the first telemetry packet reaches the cloud.

This article breaks down the complete lifecycle of a production industrial edge gateway — from the moment it powers on to steady-state telemetry delivery, including every decision point, failure mode, and recovery mechanism in between. These patterns are drawn from real-world gateways running on resource-constrained hardware (64MB RAM, MIPS processors) in plastics manufacturing plants, monitoring TCUs, chillers, blenders, and dryers 24/7.

Phase 1: Boot and Configuration Load

When a gateway boots (or restarts after a configuration change), the first task is loading its configuration. In production deployments, there are typically two configuration layers:

The Daemon Configuration

This is the central configuration that defines what equipment to talk to:

{
"plc": {
"ip": "192.168.5.5",
"modbus_tcp_port": 502
},
"serial_device": {
"port": "/dev/rs232",
"baud": 9600,
"parity": "none",
"data_bits": 8,
"stop_bits": 1,
"byte_timeout_ms": 4,
"response_timeout_ms": 100
},
"batch_size": 4000,
"batch_timeout_sec": 60,
"startup_delay_sec": 30
}

The startup delay is a critical design choice. When a gateway boots simultaneously with the PLCs it monitors (common after a power outage), the PLCs may need 10-30 seconds to initialize their communication stacks. If the gateway immediately tries to connect, it fails, marks the PLC as unreachable, and enters a slow retry loop. A 30-second startup delay avoids this race condition.

The serial link parameters (baud, parity, data bits, stop bits) must match the PLC exactly. A mismatch here produces zero error feedback — you just get silence. The byte timeout (time between consecutive bytes) and response timeout (time to wait for a complete response) are tuned per equipment type. TCUs with slower processors may need 100ms+ response timeouts; modern PLCs respond in 10-20ms.

The Device Configuration Files

Each equipment type gets its own configuration file that defines which registers to read, what data types to expect, and how often to poll. These files are loaded dynamically based on the device type detected during the discovery phase.

A real device configuration for a batch blender might define 40+ tags, each with:

  • A unique tag ID (1-32767)
  • The Modbus register address or EtherNet/IP tag name
  • Data type (bool, int8, uint8, int16, uint16, int32, uint32, float)
  • Element count (1 for scalars, 2+ for arrays or multi-register values)
  • Poll interval in seconds
  • Whether to compare with previous value (change-based delivery)
  • Whether to send immediately or batch with other values

Hot-reload capability is essential for production systems. The gateway should monitor configuration file timestamps and automatically detect changes. When a configuration file is modified (pushed via MQTT from the cloud, or copied via SSH during maintenance), the gateway reloads it without requiring a full restart. This means configuration updates can be deployed remotely to gateways in the field without disrupting data collection.

Phase 2: Device Detection

After configuration loads successfully, the gateway enters the device detection phase. This is where protocol-level intelligence matters.

Multi-Protocol Discovery

A well-designed gateway doesn't assume which protocol the PLC speaks. Instead, it tries multiple protocols in order of preference:

Step 1: Try EtherNet/IP

The gateway sends a CIP (Common Industrial Protocol) request to the configured IP address, attempting to read a device_type tag. EtherNet/IP uses the ab-eip protocol with a micro800 CPU profile (for Allen-Bradley Micro8xx series). If the PLC responds with a valid device type, the gateway knows this is an EtherNet/IP device.

Connection path: protocol=ab-eip, gateway=192.168.5.5, cpu=micro800
Target tag: device_type (uint16)
Timeout: 2000ms

Step 2: Fall back to Modbus TCP

If EtherNet/IP fails (error code -32 = "no connection"), the gateway tries Modbus TCP on port 502. It reads input register 800 (address 300800) which, by convention, stores the device type identifier.

Function code: 4 (Read Input Registers)
Register: 800
Count: 1
Expected: uint16 device type code

Step 3: Serial detection for Modbus RTU

If TCP protocols fail, the gateway probes the serial port for Modbus RTU devices. RTU detection is trickier because there's no auto-discovery mechanism — you must know the slave address. Production gateways typically configure a default address (slave ID 1) and attempt a read.

Serial Number Extraction

After identifying the device type, the gateway reads the equipment's serial number. This is critical for fleet management — each physical machine needs a unique identifier for cloud-side tracking.

Different equipment types store serial numbers in different registers:

Equipment TypeProtocolMonth RegisterYear RegisterUnit Register
Portable ChillerModbus TCPInput 22Input 23Input 24
Central ChillerModbus TCPHolding 520Holding 510Holding 500
TCUModbus RTUEtherNet/IPEtherNet/IPEtherNet/IP
Batch BlenderEtherNet/IPCIP tagCIP tagCIP tag

The serial number is packed into a 32-bit value:

Byte 3: Year  (0x40=2010, 0x41=2011, ...)
Byte 2: Month (0x00=Jan, 0x01=Feb, ...)
Bytes 0-1: Unit number (sequential)

Example: 0x002A0050 = January 2010, unit #80

Fallback serial generation: If the PLC doesn't have a programmed serial number (common with newly installed equipment), the gateway generates one using the router's serial number as a seed, with a prefix byte distinguishing PLCs (0x7F) from TCUs (0x7E). This ensures every device in the fleet has a unique identifier even before the serial number is programmed.

Configuration Loading by Device Type

Once the device type is known, the gateway searches for a matching configuration file. If type 1010 is detected, it loads the batch blender configuration. If type 5000, it loads the TCU configuration. If no matching configuration exists, the gateway logs an error and continues monitoring other ports.

This pattern — detect → identify → configure — means a single gateway binary handles dozens of equipment types. Adding support for a new machine is a configuration file change, not a firmware update.

With devices detected and configured, the gateway establishes its cloud connection via MQTT.

Connection Architecture

Production IIoT gateways use MQTT 3.1.1 over TLS (port 8883) for cloud connectivity. The connection setup involves:

  1. Certificate verification — the gateway validates the cloud broker's certificate against a CA root cert stored locally
  2. SAS token authentication — using a device-specific Shared Access Signature that encodes the hostname, device ID, and expiration timestamp
  3. Topic subscription — after connecting, the gateway subscribes to its command topic for receiving configuration updates and control commands from the cloud
Publish topic:  devices/{deviceId}/messages/events/
Subscribe topic: devices/{deviceId}/messages/devicebound/#
QoS: 1 (at least once delivery)

QoS 1 is the standard choice for industrial telemetry — it guarantees message delivery while avoiding the overhead and complexity of QoS 2 (exactly once). Since the data pipeline is designed to handle duplicates (via timestamp deduplication at the cloud layer), QoS 1 provides the right balance of reliability and performance.

The Async Connection Thread

MQTT connection can take 5-30 seconds depending on network conditions, DNS resolution, and TLS handshake time. A naive implementation blocks the main loop during connection, which means no PLC data is read during this time.

The solution: run mosquitto_connect_async() in a separate thread. The main loop continues reading PLC tags and buffering data while the MQTT connection establishes in the background. Once the connection callback fires, buffered data starts flowing to the cloud.

This is implemented using a semaphore-based producer-consumer pattern:

  1. Main thread prepares connection parameters and posts to a semaphore
  2. Connection thread wakes up, calls connect_async(), and signals completion
  3. Main thread checks semaphore state before attempting reconnection (prevents double-connect)

Connection Watchdog

Network connections fail. Cell modems lose signal. Cloud brokers restart. A production gateway needs a watchdog that detects stale connections and forces reconnection.

The watchdog pattern:

Every 120 seconds:
1. Check: have we received ANY confirmation from the broker?
(delivery ACK, PUBACK, SUBACK — anything)
2. If yes → connection is healthy, reset watchdog timer
3. If no → connection is stale. Destroy MQTT client and reinitiate.

The 120-second timeout is tuned for cellular networks where intermittent connectivity is expected. On wired Ethernet, you could reduce this to 30-60 seconds. The key insight: don't just check "is the TCP socket open?" — check "has the broker confirmed any data delivery recently?" A half-open socket can persist for hours without either side knowing.

Phase 4: Steady-State Tag Reading

Once PLC connections and MQTT are established, the gateway enters its main polling loop. This is where it spends 99.9% of its runtime.

The Main Loop (1-second resolution)

The core loop runs every second and performs three operations:

  1. Configuration check — detect if any configuration file has been modified (via file stat monitoring)
  2. Tag read cycle — iterate through all configured tags and read those whose polling interval has elapsed
  3. Command processing — check the incoming command queue for cloud-side instructions (config updates, manual reads, interval changes)

Interval-Based Polling

Each tag has a polling interval in seconds. The gateway maintains a monotonic clock timestamp of the last read for each tag. On each loop iteration:

for each tag in device.tags:
elapsed = now - tag.last_read_time
if elapsed >= tag.interval_sec:
read_tag(tag)
tag.last_read_time = now

Typical intervals by data category:

Data TypeIntervalRationale
Temperatures, pressures60sSlow-changing process values
Alarm states (booleans)1sImmediate awareness needed
Machine state (running/idle)1sOEE calculation accuracy
Batch counts1sProduction tracking
Version, serial number3600sStatic values, verify hourly

Compare Mode: Change-Based Delivery

For many tags, sending the same value every second is wasteful. If a chiller alarm bit is false for 8 hours straight, that's 28,800 redundant messages.

Compare mode solves this: the gateway stores the last-read value and only delivers to the cloud when the value changes. This is configured per tag:

{
"name": "Compressor Fault Alarm",
"type": "bool",
"interval": 1,
"compare": true,
"do_not_batch": true
}

This tag is read every second, but only transmitted when it changes. The do_not_batch flag means changes are sent immediately rather than waiting for the next batch finalization — critical for alarm states where latency matters.

Hourly Full Refresh

There's a subtle problem with pure change-based delivery: if a value changes while the MQTT connection is down, the cloud never learns about the transition. And if a value stays constant for days, the cloud has no heartbeat confirming the sensor is still alive.

The solution: every hour (on the hour change), the gateway resets all "read once" flags, forcing a complete re-read and re-delivery of all tags. This guarantees the cloud has fresh values at least hourly, regardless of change activity.

Phase 5: Data Batching and Delivery

Raw tag values don't get sent individually (except high-priority alarms). Instead, they're collected into batches for efficient delivery.

Binary Encoding

Production gateways use binary encoding rather than JSON to minimize bandwidth. The binary format packs values tightly:

Header:        1 byte  (0xF7 = tag values)
Group count: 4 bytes (number of timestamp groups)

Per group:
Timestamp: 4 bytes
Device type: 2 bytes
Serial num: 4 bytes
Value count: 4 bytes

Per value:
Tag ID: 2 bytes
Status: 1 byte (0x00=OK, else error code)
Array size: 1 byte (if status=OK)
Elem size: 1 byte (1, 2, or 4 bytes per element)
Data: size × count bytes

A batch containing 20 float values uses about 200 bytes in binary vs. ~2,000 bytes in JSON — a 10× bandwidth reduction that matters on cellular connections billed per megabyte.

Batch Finalization Triggers

A batch is finalized (sent to MQTT) when either:

  1. Size threshold — the batch reaches the configured maximum size (default: 4,000 bytes)
  2. Time threshold — the batch has been collecting for longer than batch_timeout_sec (default: 60 seconds)

This ensures data reaches the cloud within 60 seconds even during low-activity periods, while maximizing batch efficiency during high-activity periods (like a blender running a batch cycle that triggers many dependent tag reads).

The Paged Ring Buffer

Between the batching layer and the MQTT publish layer sits a paged ring buffer. This is the gateway's resilience layer against network outages.

The buffer divides available memory into fixed-size pages. Each page holds one or more complete MQTT messages. The buffer operates as a queue:

  • Write side: Finalized batches are written to the current work page. When a page fills up, it moves to the "used" queue.
  • Read side: When MQTT is connected, the gateway publishes the oldest used page. Upon receiving a PUBACK (delivery confirmation), the page moves to the "free" pool.
  • Overflow: If all pages are used (network down too long), the gateway overwrites the oldest used page — losing the oldest data to preserve the newest.

This design means the gateway can buffer 15-60 minutes of telemetry data during a network outage (depending on available memory and data density), then drain the buffer once connectivity restores.

Disconnect Recovery

When the MQTT connection drops:

  1. The buffer's "connected" flag is cleared
  2. All pending publish operations are halted
  3. Incoming PLC data continues to be read, batched, and buffered
  4. The MQTT async thread begins reconnection
  5. On reconnection, the buffer's "connected" flag is set, and data delivery resumes from the oldest undelivered page

This means zero data loss during short outages (up to the buffer capacity), and newest-data-preserved during long outages (the overflow policy drops oldest data first).

Phase 6: Remote Configuration and Control

A production gateway accepts commands from the cloud over its MQTT subscription topic. This enables remote management without SSH access.

Supported Command Types

CommandDirectionDescription
daemon_configCloud → DeviceUpdate central configuration (IP addresses, serial params)
device_configCloud → DeviceUpdate device-specific tag configuration
get_statusCloud → DeviceRequest current daemon/PLC/TCU status report
get_status_extCloud → DeviceRequest extended status with last tag values
read_now_plcCloud → DeviceForce immediate read of a specific tag
tag_updateCloud → DeviceChange a tag's polling interval remotely

Remote Interval Adjustment

This is a powerful production feature: the cloud can remotely change how often specific tags are polled. During a quality investigation, an engineer might temporarily increase temperature polling from 60s to 5s to capture rapid transients. After the investigation, they reset to 60s via another command.

The gateway applies interval changes immediately and persists them to the configuration file, so they survive a restart. The modified_intervals flag in status reports tells the cloud that intervals have been manually adjusted.

Designing for Constrained Hardware

These gateways often run on embedded Linux routers with severely constrained resources:

  • RAM: 64-128MB (of which 30-40MB is available after OS)
  • CPU: MIPS or ARM, 500-800 MHz, single core
  • Storage: 16-32MB flash (no disk)
  • Network: Cellular (LTE Cat 4/Cat M1) or Ethernet

Design constraints this imposes:

  1. Fixed memory allocation — allocate all buffers at startup, never malloc() during runtime. A memory fragmentation crash at 3 AM in a factory with no IT staff is unrecoverable.

  2. No floating-point unit — older MIPS processors do software float emulation. Keep float operations to a minimum; do heavy math in the cloud.

  3. Flash wear — don't write configuration changes to flash more than necessary. Batch writes, use write-ahead logging if needed.

  4. Watchdog timer — use the hardware watchdog timer. If the main loop hangs, the hardware reboots the gateway automatically.

How machineCDN Implements These Patterns

machineCDN's ACS (Auxiliary Communication System) gateway embodies all of these lifecycle patterns in a production-hardened implementation that's been running on thousands of plastics manufacturing machines for years.

The gateway runs on Teltonika RUT9XX industrial cellular routers, providing cellular connectivity for machines in facilities without available Ethernet. It supports EtherNet/IP and Modbus (both TCP and RTU) simultaneously, auto-detecting device types at boot and loading the appropriate configuration from a library of pre-built equipment profiles.

For manufacturers deploying machineCDN, the complexity described in this article — protocol detection, configuration management, MQTT buffering, recovery — is entirely handled by the platform. The result is that plant engineers get reliable, continuous telemetry from their equipment without needing to understand (or debug) the edge gateway's internal lifecycle.


Understanding how edge gateways actually work — not just what they do, but how they manage their lifecycle — is essential for building reliable IIoT infrastructure. The patterns described here (startup sequencing, multi-protocol detection, buffered delivery, watchdog recovery) separate toy deployments from production systems that run for years without intervention.

IEEE 754 Floating-Point Edge Cases in Industrial Data Pipelines: A Practical Guide [2026]

· 12 min read

If you've ever seen a temperature reading of 3.4028235 × 10³⁸ flash across your monitoring dashboard at 2 AM, you've met IEEE 754's ugly side. Floating-point representation is the lingua franca of analog process data in industrial automation — and it's riddled with traps that can silently corrupt your data pipeline if you don't handle them at the edge.

This guide covers the real-world edge cases that matter when reading float registers from PLCs over Modbus, EtherNet/IP, and other industrial protocols — and how to catch them before they poison your analytics, trigger false alarms, or crash your trending charts.

IEEE 754 floating point data flowing through an industrial data pipeline

Why Floating-Point Matters More in Industrial IoT

In enterprise software, a floating-point rounding error means your bank balance is off by a fraction of a cent. In industrial IoT, a misinterpreted float register can mean:

  • A temperature sensor reading infinity instead of 450°F, triggering an emergency shutdown
  • An OEE calculation returning NaN, breaking every downstream dashboard
  • A pressure reading of -0.0 confusing threshold comparison logic
  • Two 16-bit registers assembled in the wrong byte order, turning 72.5 PSI into 1.6 × 10⁻³⁸

These aren't theoretical problems. They happen on real factory floors, every day, because the gap between PLC register formats and cloud-native data types is wider than most engineers realize.

The Anatomy of a PLC Float

Most modern PLCs store floating-point values as IEEE 754 single-precision (32-bit) numbers. The 32 bits break down as:

┌─────┬──────────┬───────────────────────┐
│Sign │ Exponent │ Mantissa │
│1 bit│ 8 bits │ 23 bits │
└─────┴──────────┴───────────────────────┘
Bit 31 Bits 30-23 Bits 22-0

This gives you a range of roughly ±1.18 × 10⁻³⁸ to ±3.40 × 10³⁸, with about 7 decimal digits of precision. That's plenty for most process variables — but the encoding introduces special values and edge cases that PLC programmers rarely think about.

The Five Dangerous Values

PatternValueWhat Causes It
0x7F800000+InfinityDivision by zero, sensor overflow
0xFF800000-InfinityNegative division by zero
0x7FC00000Quiet NaNUninitialized register, invalid operation
0x7FA00000Signaling NaNHardware fault flags in some PLCs
0x00000000 / 0x80000000+0.0 / -0.0Legitimate zero, but -0.0 can trip comparisons

Why PLCs Generate These Values

PLC ladder logic and structured text don't always guard against special float values. Common scenarios include:

Uninitialized registers: When a PLC program is downloaded but a tag hasn't been written to yet, many PLCs leave the register at 0x00000000 (zero) — but some leave it at 0xFFFFFFFF (NaN). There's no universal standard here.

Sensor faults: When an analog input card detects a broken wire or over-range condition, some PLCs write a sentinel value (often max positive float or NaN) to the associated tag. Others set a separate status bit and leave the value register frozen at the last good reading.

Division by zero: If your PLC program calculates a rate (e.g., throughput per hour) and the divisor drops to zero during a machine stop, you get infinity. Not every PLC programmer wraps division in a zero-check.

Scaling arithmetic: Converting raw 12-bit ADC counts (0–4095) to engineering units involves multiplication and offset. If the scaling coefficients are misconfigured, you can get results outside the normal range that are still technically valid IEEE 754 floats.

The Byte-Ordering Minefield

Here's where industrial protocols diverge from IT conventions in ways that cause the most data corruption.

Modbus Register Ordering

Modbus transmits data in 16-bit registers. A 32-bit float occupies two consecutive registers. The question is: which register holds the high word?

The Modbus specification says big-endian (high word first), but many PLC vendors violate this:

Standard Modbus (Big-Endian / "ABCD"):
Register N = High word (bytes A, B)
Register N+1 = Low word (bytes C, D)

Swapped (Little-Endian / "CDAB"):
Register N = Low word (bytes C, D)
Register N+1 = High word (bytes A, B)

Byte-Swapped ("BADC"):
Register N = Byte-swapped high word (B, A)
Register N+1 = Byte-swapped low word (D, C)

Full Reverse ("DCBA"):
Register N = (D, C)
Register N+1 = (B, A)

Real-world example: A process temperature of 72.5°F is 0x42910000 in IEEE 754. Here's what you'd read over Modbus depending on the byte order:

OrderRegister NRegister N+1Decoded Value
ABCD0x42910x000072.5 ✅
CDAB0x00000x42911.598 × 10⁻⁴¹ ❌
BADC0x91420x0000-6.01 × 10⁻²⁸ ❌
DCBA0x00000x9142Garbage ❌

The only reliable way to determine byte ordering is to read a known value from the PLC — like a setpoint you can verify — and compare the decoded result against all four orderings.

EtherNet/IP Tag Ordering

EtherNet/IP (CIP) is generally more predictable because it transmits structured data with typed access. When you read a REAL tag from an Allen-Bradley Micro800 or CompactLogix, the CIP layer handles byte ordering transparently. The value arrives in the host's native format through the client library.

However, watch out for array access. When reading a float array starting at a specific index, the start index and element count must match the PLC's memory layout exactly. Requesting tag_name[1] with elem_count=6 reads elements 1 through 6 — the zero-indexed first element is skipped. Getting this wrong doesn't produce an error; it silently gives you shifted values.

Practical Validation Strategies

Layer 1: Raw Register Validation

Before you even try to decode a float, validate the raw bytes:

import struct
import math

def validate_float_register(high_word: int, low_word: int,
byte_order: str = "ABCD") -> tuple[float, str]:
"""
Decode and validate a 32-bit float from two Modbus registers.
Returns (value, status) where status is 'ok', 'nan', 'inf', or 'denorm'.
"""
# Assemble bytes based on ordering
if byte_order == "ABCD":
raw = struct.pack('>HH', high_word, low_word)
elif byte_order == "CDAB":
raw = struct.pack('>HH', low_word, high_word)
elif byte_order == "BADC":
raw = struct.pack('>HH',
((high_word & 0xFF) << 8) | (high_word >> 8),
((low_word & 0xFF) << 8) | (low_word >> 8))
elif byte_order == "DCBA":
raw = struct.pack('<HH', high_word, low_word)
else:
raise ValueError(f"Unknown byte order: {byte_order}")

value = struct.unpack('>f', raw)[0]

# Check special values
if math.isnan(value):
return value, "nan"
if math.isinf(value):
return value, "inf"

# Check denormalized (subnormal) — often indicates garbage data
raw_int = struct.unpack('>I', raw)[0]
exponent = (raw_int >> 23) & 0xFF
if exponent == 0 and (raw_int & 0x7FFFFF) != 0:
return value, "denorm"

return value, "ok"

Layer 2: Engineering-Range Clamping

Every process variable has a physically meaningful range. A mold temperature can't be -40,000°F. A flow rate can't be 10 billion GPM. Enforce these ranges at the edge:

RANGE_LIMITS = {
"mold_temperature_f": (-50.0, 900.0),
"barrel_pressure_psi": (0.0, 40000.0),
"screw_rpm": (0.0, 500.0),
"coolant_flow_gpm": (0.0, 200.0),
}

def clamp_to_range(tag_name: str, value: float) -> tuple[float, bool]:
"""Clamp a value to its engineering range. Returns (clamped_value, was_clamped)."""
if tag_name not in RANGE_LIMITS:
return value, False
low, high = RANGE_LIMITS[tag_name]
if value < low:
return low, True
if value > high:
return high, True
return value, False

Layer 3: Rate-of-Change Filtering

A legitimate temperature can't jump from 200°F to 800°F in one polling cycle (typically 1–60 seconds). Rate-of-change filtering catches sensor glitches and transient read errors:

MAX_RATE_OF_CHANGE = {
"mold_temperature_f": 50.0, # Max °F per polling cycle
"barrel_pressure_psi": 2000.0, # Max PSI per cycle
"screw_rpm": 100.0, # Max RPM per cycle
}

def rate_check(tag_name: str, new_value: float,
last_value: float) -> bool:
"""Returns True if the change rate is within acceptable limits."""
if tag_name not in MAX_RATE_OF_CHANGE:
return True
max_delta = MAX_RATE_OF_CHANGE[tag_name]
return abs(new_value - last_value) <= max_delta

The 32-Bit Float Reassembly Problem

When your edge gateway reads two 16-bit Modbus registers and needs to assemble them into a 32-bit float, the implementation must handle several non-obvious cases.

Two-Register Float Assembly

The most common approach reads two registers and combines them. But there's a critical subtlety: the function code determines how you interpret the raw words.

For holding registers (function code 3) and input registers (function code 4), each register is a 16-bit unsigned integer. To assemble a float:

Step 1: Read register N → uint16 word_high
Step 2: Read register N+1 → uint16 word_low
Step 3: Combine → uint32 raw = (word_high << 16) | word_low
Step 4: Reinterpret raw as IEEE 754 float

But here's the trap: some Modbus libraries automatically apply byte swapping at the protocol layer (converting from Modbus big-endian to host little-endian), which means your "high word" might already be byte-swapped before you assemble it.

A robust implementation uses the library's native float-extraction function (like modbus_get_float() in libmodbus) rather than manual assembly when possible. When you must assemble manually, test against a known value first.

Handling Mixed-Endian Devices

In real factories, you'll often have devices from multiple vendors on the same Modbus network — each with their own byte-ordering conventions. Your edge gateway must support per-device (or even per-register) byte-order configuration:

devices:
- name: "Injection_Molding_Press_1"
protocol: modbus-tcp
address: "192.168.1.10"
byte_order: ABCD
tags:
- name: barrel_temp_zone1
register: 40001
type: float32
# Inherits device byte_order

- name: "Chiller_Unit_3"
protocol: modbus-tcp
address: "192.168.1.20"
byte_order: CDAB # This vendor swaps words
tags:
- name: coolant_supply_temp
register: 30000
type: float32

Change Detection with Floating-Point Values

One of the most powerful bandwidth optimizations in IIoT edge gateways is change-of-value (COV) detection — only transmitting a value when it actually changes. But floating-point comparison is inherently tricky.

The Naive Approach (Broken)

// DON'T DO THIS
if (new_value != old_value) {
send(new_value);
}

This fails because:

  • Sensor noise causes sub-LSB fluctuations that produce different float representations
  • NaN ≠ NaN by IEEE 754 rules, so you'd send NaN every single cycle
  • -0.0 == +0.0 by IEEE 754, so you'd miss sign changes that might matter

The Practical Approach

Compare at the raw register level (integer comparison), not the float level. If the uint32 representation of two registers hasn't changed, the float is identical bit-for-bit — no ambiguity:

uint32_t new_raw = (word_high << 16) | word_low;
uint32_t old_raw = stored_raw_value;

if (new_raw != old_raw) {
// Value actually changed — decode and transmit
stored_raw_value = new_raw;
transmit(decode_float(new_raw));
}

This approach is used in production edge gateways and avoids all the floating-point comparison pitfalls. It's also faster — integer comparison is a single CPU instruction, while float comparison requires FPU operations and NaN handling.

Batching and Precision Preservation

When batching multiple tag values for transmission, format choice matters for float precision.

JSON Serialization Pitfalls

JSON doesn't distinguish between integers and floats, and most JSON serializers will round-trip a float through a decimal representation, potentially losing precision:

Original float: 72.5 (exact in IEEE 754: 0x42910000)
JSON: "72.5" → Deserialized: 72.5 ✅

Original float: 72.3 (NOT exact: 0x4290999A)
JSON: "72.30000305175781" → Deserialized: 72.30000305175781
Or: "72.3" → Deserialized: 72.30000305175781 (different!)

For telemetry where exact bit-level reproduction matters (e.g., comparing dashboard values against PLC HMI values), use binary encoding. A well-designed binary telemetry format encodes the tag ID, status, value type, and raw bytes — preserving perfect fidelity with less bandwidth.

A typical binary batch frame looks like:

┌──────────┬────────────┬──────────┬──────────┬────────────────┐
│ Batch │ Group │ Device │ Serial │ Values │
│ Header │ Timestamp │ Type │ Number │ Array │
│ (1 byte) │ (4 bytes) │ (2 bytes)│ (4 bytes)│ (variable) │
└──────────┴────────────┴──────────┴──────────┴────────────────┘

Each value entry:
┌──────────┬────────┬──────────┬──────────┬────────────────┐
│ Tag ID │ Status │ Count │ Elem │ Raw Values │
│ (2 bytes)│(1 byte)│ (1 byte) │ Size │ (count × size) │
│ │ │ │ (1 byte) │ │
└──────────┴────────┴──────────┴──────────┴────────────────┘

This format reduces a typical 100-tag batch from ~5 KB (JSON) to ~600 bytes (binary) — an 8× bandwidth reduction with zero precision loss.

Edge Gateway Best Practices

Based on years of deploying edge gateways in plastics, metals, and packaging manufacturing, here are the practices that prevent float-related data quality issues:

1. Validate at the Source

Don't wait until data reaches the cloud to check for NaN and infinity. By then, you've wasted bandwidth transmitting garbage and may have corrupted aggregations. Validate immediately after the register read.

2. Separate Value and Status

Every tag read should produce two outputs: the decoded value AND a status code. Status codes distinguish between "value is zero because the sensor reads zero" and "value is zero because the read failed." Most Modbus libraries return error codes — propagate them alongside the values.

3. Configure Byte Order Per Device

Don't hardcode byte ordering. Every industrial device you connect might have different conventions. Your tag configuration should support per-device or per-tag byte-order specification.

If your edge gateway communicates over cellular (4G/5G) or satellite, binary encoding pays for itself immediately. The bandwidth savings compound with polling frequency — a gateway polling 200 tags every second generates 17 GB/month in JSON but only 2 GB/month in binary.

5. Hourly Full Reads

Even with change-of-value filtering, perform a full read of all tags at least once per hour. This catches situations where a value changed but the change was lost due to a transient error, and ensures your cloud platform always has a recent snapshot of every tag — even slowly-changing ones.

How machineCDN Handles Float Data

machineCDN's edge infrastructure handles these float challenges at the protocol driver level. The platform supports automatic byte-order detection during device onboarding, validates every register read against configurable engineering ranges, and uses binary telemetry encoding to minimize bandwidth while preserving perfect float fidelity.

For plants running mixed-vendor equipment — which is nearly every plant — machineCDN normalizes all float data into a consistent format before it reaches your dashboards, ensuring that a temperature from a Modbus chiller and a temperature from an EtherNet/IP blender are directly comparable.

Key Takeaways

  1. IEEE 754 special values (NaN, infinity, denormals) appear regularly in PLC data — don't assume every register read produces a valid number
  2. Byte ordering varies by vendor, not by protocol — always verify against a known value
  3. Compare at the raw register level for change detection — never use float equality
  4. Binary encoding preserves precision and saves 8× bandwidth over JSON for telemetry
  5. Validate at the edge, not in the cloud — garbage data should never leave the factory

Getting floating-point handling right at the edge gateway is one of those unglamorous engineering fundamentals that separates reliable IIoT platforms from brittle ones. Your trending charts, alarm logic, and analytics all depend on it.


Want to see how machineCDN handles multi-protocol float data normalization in production? Request a demo to explore the platform with real factory data.

JSON-Based PLC Tag Configuration: Building Maintainable IIoT Device Templates [2026]

· 12 min read

If you've ever stared at a spreadsheet of 200 PLC register addresses trying to figure out which ones your SCADA system is actually polling, you know the pain. Traditional tag configuration — hardcoded in ladder logic comments, scattered across HMI screens, buried in proprietary configuration tools — doesn't scale.

The solution that's gaining traction in modern IIoT deployments is declarative, JSON-based tag configuration. Instead of configuring your data collection logic in opaque proprietary formats, you define your device's entire tag map as a structured JSON document. This approach brings version control, template reuse, and automated validation to the industrial data layer.

In this guide, we'll walk through the architecture of a production-grade JSON tag configuration system, drawing from real patterns used in industrial edge gateways connecting to Allen-Bradley Micro800 PLCs via EtherNet/IP and to various devices via Modbus RTU and TCP.

JSON-based PLC tag configuration for IIoT

Why JSON for PLC Tag Configuration?

The traditional approach to configuring PLC data collection involves vendor-specific tools: RSLinx for Allen-Bradley, TIA Portal for Siemens, or proprietary gateway configurators. These tools work, but they create several problems at scale:

  • No version control. You can't git diff a proprietary binary config file.
  • No templating. When you deploy the same machine type across 50 sites, you're manually recreating the same configuration 50 times.
  • No validation. Typos in register addresses don't surface until runtime.
  • No automation. You can't script the generation of configurations from a master device database.

JSON solves all of these. A tag configuration becomes a text file that can be:

  • Stored in Git with full change history
  • Templated per device type (one JSON per machine model)
  • Validated against a schema before deployment
  • Generated programmatically from engineering databases

Anatomy of a Tag Configuration Document

A well-structured PLC tag configuration document needs to capture several layers of information:

Device-Level Metadata

Every configuration file should identify the device type it applies to, carry a version string for change tracking, and specify the protocol:

{
"device_type": 1010,
"version": "a3f7b2c",
"name": "Continuous Blender Model X",
"protocol": "ethernet-ip",
"plctags": [ ... ]
}

The device_type field is a numeric identifier that maps to a specific machine model. When an edge gateway auto-detects a PLC (by reading a known register), it uses this type ID to look up the correct configuration file. The version field — ideally a short Git hash — lets you track which configuration version is running on each gateway in the field.

For Modbus devices, you'd also include protocol-specific parameters:

{
"device_type": 5000,
"version": "b8e1d4a",
"name": "Temperature Control Unit",
"protocol": "modbus-rtu",
"base_addr": 48,
"baud": 9600,
"parity": "even",
"data_bits": 8,
"stop_bits": 1,
"byte_timeout": 4,
"resp_timeout": 100,
"plctags": [ ... ]
}

Notice the serial link parameters are part of the same document. This is deliberate — you want a single source of truth for "how to talk to this device and what to read from it."

Tag Definitions: The Core Data Model

Each tag in the configuration represents a single data point you want to collect from the PLC. A complete tag definition captures:

{
"name": "barrel_zone1_temp",
"id": 42,
"type": "float",
"ecount": 2,
"sindex": 0,
"interval": 5,
"compare": true,
"do_not_batch": false
}

Let's break down each field:

name — A human-readable identifier for the tag. For EtherNet/IP (CIP) devices, this is the actual PLC tag name. For Modbus, it's a descriptive label since Modbus uses numeric addresses.

id — A numeric identifier used in the wire protocol when transmitting data to the cloud. Using compact integer IDs instead of string names dramatically reduces payload sizes — critical when you're sending telemetry over cellular connections.

type — The data type of the register value. Common types include:

TypeSizeRangeUse Case
bool1 byte0 or 1Alarm states, run/stop status
int81 byte-128 to 127Small counters, mode selectors
uint81 byte0 to 255Status codes, alarm bytes
int162 bytes-32,768 to 32,767Temperature (×10), pressure
uint162 bytes0 to 65,535RPM, flow rate, raw ADC values
int324 bytes±2.1 billionProduction counters, energy
uint324 bytes0 to 4.2 billionLifetime counters, timestamps
float4 bytesIEEE 754Temperature, weight, setpoints

ecount (element count) — How many consecutive elements to read. For a single register, this is 1. For a 32-bit float stored across two Modbus registers, this is 2. For an array of 10 temperature readings, this is 10.

sindex (start index) — The starting element index for array reads. Combined with ecount, this lets you read slices of PLC arrays without pulling the entire array.

interval — How often (in seconds) to poll this tag. This is where you make intelligent decisions about bandwidth:

  • 1 second: Critical alarms, emergency stops, safety interlocks
  • 5 seconds: Process temperatures, pressures, flows
  • 30 seconds: Setpoints, mode selectors (change infrequently)
  • 300 seconds: Configuration parameters, serial numbers

compare — When true, the gateway compares each new reading against the previous value and only transmits if the value changed. This is the single most impactful optimization for reducing bandwidth and cloud ingestion costs.

do_not_batch — When true, the value is transmitted immediately rather than being accumulated into a batch payload. Use this for critical alarms that need sub-second cloud visibility.

Modbus Address Conventions

For Modbus devices, each tag also carries an addr field that encodes both the register address and the function code:

{
"name": "process_temp",
"id": 10,
"addr": 400100,
"type": "float",
"ecount": 2,
"interval": 5,
"compare": true
}

The address convention follows a well-established pattern:

Address RangeModbus Function CodeRegister Type
0 – 65,536FC 01Coils (read/write)
100,000 – 165,536FC 02Discrete Inputs (read)
300,000 – 365,536FC 04Input Registers (read)
400,000 – 465,536FC 03Holding Registers (R/W)

So addr: 400100 means "holding register at address 100, read via function code 3." This convention eliminates ambiguity about which Modbus function to use — the address itself encodes it.

Why this matters: A common source of bugs in Modbus deployments is using the wrong function code. Someone configures a tag to read address 100 with FC 03 when the device exposes it as an input register (FC 04). With the address convention above, the function code is implicit and unambiguous.

Advanced Patterns: Calculated and Dependent Tags

Simple register reads cover 80% of use cases. But industrial devices often pack multiple boolean values into a single 16-bit alarm word, or have tags whose values only matter when a parent tag changes.

Calculated Tags: Extracting Bits from Alarm Words

Many PLCs pack 16 individual alarm flags into a single uint16 register. Rather than reading 16 separate coils, you read one register and extract the bits:

{
"name": "alarm_word_1",
"id": 50,
"addr": 400200,
"type": "uint16",
"ecount": 1,
"interval": 1,
"compare": true,
"calculated": [
{
"name": "high_temp_alarm",
"id": 51,
"type": "bool",
"shift": 0,
"mask": 1
},
{
"name": "low_pressure_alarm",
"id": 52,
"type": "bool",
"shift": 1,
"mask": 1
},
{
"name": "motor_overload",
"id": 53,
"type": "bool",
"shift": 2,
"mask": 1
}
]
}

When alarm_word_1 is read, the gateway automatically:

  1. Reads the raw uint16 value
  2. For each calculated tag, applies the right-shift and mask to extract the bit
  3. Compares the extracted boolean against its previous value
  4. Only transmits if the bit actually changed

This is vastly more efficient than polling 16 individual coils — one Modbus read instead of 16, with identical semantic output.

Dependent Tags: Event-Driven Secondary Reads

Some tags only need to be read when a related tag changes. For example, you might have a machine_state register that changes between IDLE, RUNNING, and FAULT. When it changes, you want to immediately read a block of diagnostic registers — but you don't want to poll those diagnostics every cycle when the machine state is stable.

{
"name": "machine_state",
"id": 100,
"addr": 400001,
"type": "uint16",
"ecount": 1,
"interval": 1,
"compare": true,
"dependents": [
{
"name": "fault_code",
"id": 101,
"addr": 400010,
"type": "uint16",
"ecount": 1,
"interval": 60
},
{
"name": "fault_timestamp",
"id": 102,
"addr": 400011,
"type": "uint32",
"ecount": 2,
"interval": 60
}
]
}

When machine_state changes, the gateway forces an immediate read of all dependent tags, regardless of their normal polling interval. This gives you:

  • Low latency on state transitions — fault diagnostics arrive within 1 second of the fault occurring
  • Low bandwidth during steady state — diagnostic registers are only polled every 60 seconds when nothing is happening

Contiguous Register Optimization

One of the most impactful optimizations in Modbus data collection is contiguous register grouping. Instead of making separate Modbus read requests for each tag, the gateway sorts tags by address and groups adjacent registers into single bulk reads.

Consider these tags:

[
{ "name": "temp_1", "addr": 400100, "ecount": 1 },
{ "name": "temp_2", "addr": 400101, "ecount": 1 },
{ "name": "temp_3", "addr": 400102, "ecount": 1 },
{ "name": "pressure", "addr": 400103, "ecount": 2 }
]

A naive implementation makes four separate Modbus requests. An optimized one makes one request: read 5 registers starting at address 400100. The response contains all four values, which are dispatched to the correct tag definitions.

For this optimization to work, the configuration system must:

  1. Sort tags by address at load time, not at runtime
  2. Validate that function codes match — you can't group a coil read (FC 01) with a holding register read (FC 03)
  3. Respect maximum packet sizes — Modbus TCP allows up to 125 registers per read; some devices are more restrictive
  4. Respect polling intervals — only group tags that share the same polling interval

The performance difference is dramatic. A typical PLC with 50 Modbus tags might require 50 individual reads (50 × ~10ms = 500ms per cycle) or 5 grouped reads (5 × ~10ms = 50ms per cycle). That's a 10× improvement in polling speed.

IEEE 754 Float Handling: The Register Order Problem

Reading 32-bit floating-point values over Modbus is notoriously tricky because the Modbus specification doesn't define register byte ordering for multi-register values. A float spans two 16-bit registers, and different PLCs may store them in different orders:

  • Big-endian (AB CD): Register N contains the high word, N+1 the low word
  • Little-endian (CD AB): Register N contains the low word, N+1 the high word
  • Mid-endian (BA DC or DC BA): Each word's bytes are swapped

Your tag configuration should support specifying the byte order, or at least document which convention your gateway assumes. Most libraries (libmodbus, for example) provide helper functions like modbus_get_float() that assume big-endian by default — but always verify against your specific PLC.

Pro tip: When commissioning a new device, read a register where you know the expected value (e.g., a temperature setpoint showing 72.0°F on the HMI). If the gateway reads 72.0, your byte order is correct. If it reads 2.388e-38 or 1.23e+12, you have a byte-order mismatch.

Binary vs. JSON Telemetry Encoding

Once you've collected your tag values, you need to transmit them. Your configuration should support both JSON and binary encoding, with the choice driven by bandwidth constraints:

JSON encoding is human-readable and debuggable:

{
"groups": [{
"ts": 1709500800,
"device_type": 1010,
"serial_number": 85432,
"values": [
{ "id": 42, "values": [72.3] },
{ "id": 43, "values": [true] }
]
}]
}

Binary encoding is 3-5× smaller. A typical binary frame packs:

  • 1-byte header marker
  • 4-byte group count
  • Per group: 4-byte timestamp, 2-byte device type, 4-byte serial number, 4-byte value count
  • Per value: 2-byte tag ID, 1-byte status, 1-byte value count, 1-byte value size, then raw value bytes

A batch that's 2,000 bytes in JSON might be 400 bytes in binary. Over a cellular connection billed per megabyte, that savings compounds fast.

Putting It All Together: Configuration Lifecycle

A production deployment follows this lifecycle:

  1. Template creation: For each machine model, create a JSON tag configuration. Store it in Git.
  2. Deployment: Push configurations to edge gateways via your device management platform. The gateway monitors the config file and reloads automatically when it changes.
  3. Auto-detection: When the gateway starts, it queries the PLC for its device type (a known register). It then matches the type to the correct configuration file.
  4. Validation: At load time, validate register addresses (no duplicates, valid ranges), data types, and interval values. Reject invalid configs before they cause runtime errors.
  5. Runtime: The gateway polls tags according to their configured intervals, applies change detection, groups contiguous registers, and batches values for transmission.

How machineCDN Handles Tag Configuration

machineCDN's edge gateway uses this exact pattern — JSON-based device templates that are automatically selected based on PLC auto-detection. Each machine type in a plastics manufacturing facility (blenders, dryers, granulators, chillers, TCUs) has its own configuration template with pre-mapped tags, optimized polling intervals, and calculated alarm decomposition.

When a new machine is connected, the gateway detects the PLC type, loads the matching template, and starts collecting data — typically in under 30 seconds with zero manual configuration. For plants running 20+ machines across 5 different models, this eliminates weeks of commissioning time.

Common Pitfalls

1. Overlapping addresses. Two tags pointing to the same register with different IDs will cause confusion in your data pipeline. Validate for uniqueness at load time.

2. Wrong element count for floats. A 32-bit float on Modbus requires ecount: 2 (two 16-bit registers). Setting ecount: 1 gives you garbage data.

3. Polling too fast on serial links. Modbus RTU over RS-485 at 9600 baud can handle roughly 10-15 register reads per second. If you configure 50 tags at 1-second intervals, you'll never keep up. Budget your polling rate against your link speed.

4. Missing change detection on high-volume tags. Without compare: true, every reading gets transmitted. For a tag polled every second, that's 86,400 data points per day — even if the value never changed.

5. Batch timeout too long. If your batch timeout is 60 seconds but an alarm fires, it won't reach the cloud for up to a minute unless that alarm tag has do_not_batch: true.

Conclusion

JSON-based tag configuration isn't just a nice-to-have — it's a fundamental enabler for scaling IIoT deployments. It brings software engineering best practices (version control, templating, validation, automation) to a domain that has traditionally relied on manual, vendor-specific tooling.

The key design principles are:

  • One file per device type with version tracking
  • Rich tag metadata covering data types, intervals, and delivery modes
  • Hierarchical relationships for calculated and dependent tags
  • Protocol-aware addressing that encodes function codes implicitly
  • Contiguous register grouping for optimal Modbus performance

Get this foundation right, and you'll spend your time analyzing machine data instead of debugging data collection.

Modbus Float Encoding: How to Correctly Read IEEE 754 Values from Industrial PLCs [2026]

· 11 min read

If you've spent any time integrating PLCs with an IIoT platform, you've encountered the moment: you read a temperature register that should show 72.5°F, but instead you get 1,118,044,160. Or worse — NaN. Or a negative number that makes zero physical sense.

Welcome to the Modbus float encoding problem. It's the #1 source of confusion in industrial data integration, and it trips up experienced engineers just as often as beginners.

This guide goes deep on how 32-bit floating-point values are actually stored and transmitted over Modbus — covering register pairing, word-swap variants, byte ordering, and the practical techniques that production IIoT systems use to get correct readings from heterogeneous equipment fleets.

Why Modbus and Floats Don't Play Nicely Together

The original Modbus specification (1979) defined only 16-bit registers. Each holding register (4xxxx) or input register (3xxxx) stores exactly one unsigned 16-bit word — values from 0 to 65,535.

But modern PLCs need to represent temperatures like 215.7°F, flow rates like 3.847 GPM, and pressures like 127.42 PSI. A 16-bit integer can't hold these values with the precision operators need.

The solution: pack an IEEE 754 single-precision float (32 bits) across two consecutive Modbus registers. Simple enough in theory. In practice, it's a minefield.

The IEEE 754 Layout

A 32-bit float uses this bit structure:

Bit:  31  30..23   22..0
S EEEEEEEE MMMMMMMMMMMMMMMMMMMMMMM
│ │ └── Mantissa (23 bits)
│ └── Exponent (8 bits, biased by 127)
└── Sign (1 bit: 0=positive, 1=negative)

The float value 72.5 encodes as 0x42910000:

  • Sign: 0 (positive)
  • Exponent: 10000101 (133 - 127 = 6)
  • Mantissa: 00100010000000000000000

That 32-bit value needs to be split across two 16-bit registers. Here's where the problems start.

The Four Word-Order Variants

Different PLC manufacturers split 32-bit floats into register pairs using different byte and word ordering. There are four possible arrangements, and encountering all four in a single plant is common:

Variant 1: Big-Endian (AB CD) — "Network Order"

The most intuitive layout. The high word occupies the lower register address.

Register N  :  0x4291  (bytes A, B)
Register N+1: 0x0000 (bytes C, D)

Reconstruct: (Register_N << 16) | Register_N+10x42910000 → 72.5

Used by: Many Allen-Bradley/Rockwell PLCs, Schneider Modicon M340/M580, some Siemens devices.

Variant 2: Little-Endian Word Swap (CD AB)

The low word comes first. This is surprisingly common.

Register N  :  0x0000  (bytes C, D)
Register N+1: 0x4291 (bytes A, B)

Reconstruct: (Register_N+1 << 16) | Register_N0x42910000 → 72.5

Used by: Many Modbus TCP devices, Conch controls, various Asian-manufactured PLCs.

Variant 3: Byte-Swapped Big-Endian (BA DC)

Each 16-bit word has its bytes reversed, but word order is normal.

Register N  :  0x9142  (bytes B, A)
Register N+1: 0x0000 (bytes D, C)

This requires swapping bytes within each word before combining.

Used by: Some older Emerson/Fisher devices, certain Yokogawa controllers.

Variant 4: Byte-Swapped Little-Endian (DC BA)

The least intuitive: both word order and byte order are reversed.

Register N  :  0x0000  (bytes D, C)
Register N+1: 0x9142 (bytes B, A)

Used by: Rare, but you'll find it in some legacy Fuji and Honeywell equipment.

How Production IIoT Systems Handle This

In a real manufacturing environment, you don't get to choose which word order your equipment uses. A single plant might have:

  • TCU (Temperature Control Units) using Modbus RTU at 9600 baud, storing floats in registers 404000-404056 with big-endian word order
  • Portable chillers on Modbus TCP port 502, using 16-bit integers (no float encoding needed)
  • Batch blenders speaking EtherNet/IP natively, where float handling is built into the CIP protocol
  • Dryers with Modbus TCP and CD-AB word swapping

A well-designed edge gateway handles this with per-device configuration. The key insight: float decoding is a device-level property, not a global setting. Each equipment type gets its own configuration that specifies:

  1. Protocol (Modbus RTU, Modbus TCP, or EtherNet/IP)
  2. Register address (which pair of registers holds the float)
  3. Element count — set to 2 for a 32-bit float spanning two registers
  4. Data type — explicitly declared as float vs. int16 vs. uint32

Here's a generic configuration example for a temperature control unit reading float values over Modbus RTU:

{
"protocol": "modbus-rtu",
"tags": [
{
"name": "Delivery Temperature",
"register": 4002,
"type": "float",
"element_count": 2,
"poll_interval_sec": 60
},
{
"name": "Mold Temperature",
"register": 4004,
"type": "float",
"element_count": 2,
"poll_interval_sec": 60
},
{
"name": "Flow Rate",
"register": 4008,
"type": "float",
"element_count": 2,
"poll_interval_sec": 60
}
]
}

Notice the element_count: 2. This tells the gateway: "read two consecutive registers starting at this address, then combine them into a single 32-bit float." Getting this wrong is the most common source of incorrect readings.

The modbus_get_float() Trap

If you're using libmodbus (the most common C library for Modbus), you'll encounter modbus_get_float() and its variants:

  • modbus_get_float_abcd() — big-endian (most standard)
  • modbus_get_float_dcba() — fully reversed
  • modbus_get_float_badc() — byte-swapped, word-normal
  • modbus_get_float_cdab() — word-swapped, byte-normal

The default modbus_get_float() function uses CDAB ordering (word-swapped). This catches many engineers off guard — they read two registers, call modbus_get_float(), and get garbage because their PLC uses ABCD ordering.

Rule of thumb: Always test with a known value. Write 72.5 to a register pair in your PLC, read both registers as raw uint16 values, and observe which bytes are where. Then select the appropriate decode function.

Practical Decoding in C

Here's how you'd manually decode a float from two Modbus registers, handling the common big-endian case:

// Big-endian (ABCD): high word in register[0], low word in register[1]
float decode_float_be(uint16_t reg_high, uint16_t reg_low) {
uint32_t combined = ((uint32_t)reg_high << 16) | (uint32_t)reg_low;
float result;
memcpy(&result, &combined, sizeof(float));
return result;
}

// Word-swapped (CDAB): low word in register[0], high word in register[1]
float decode_float_ws(uint16_t reg_low, uint16_t reg_high) {
uint32_t combined = ((uint32_t)reg_high << 16) | (uint32_t)reg_low;
float result;
memcpy(&result, &combined, sizeof(float));
return result;
}

Never use pointer casting (*(float*)&combined). It violates strict aliasing rules and can produce incorrect results on optimizing compilers. Always use memcpy.

Element Count and Register Math

One subtle but critical detail: when you configure a tag to read a float, the element count tells the gateway how many 16-bit registers to request in a single Modbus transaction.

For a single float:

  • Element count = 2 (two 16-bit registers = 32 bits)
  • Read function code 3 (holding registers) or 4 (input registers)
  • The response contains 4 bytes of data

For an array of 8 floats (e.g., reading recipe values from a batch blender):

  • Element count = 16 (8 floats × 2 registers each)
  • Single Modbus read request for 16 consecutive registers
  • Far more efficient than 8 separate read requests

This is where contiguous register optimization matters. If you have tags at registers 4000, 4002, 4004, 4006, 4008 — all 2-element floats — a smart gateway combines them into a single Modbus read of 10 registers instead of 5 separate reads. This reduces bus traffic by 60-80% on RTU networks where every transaction costs 5-20ms of serial turnaround time.

Modbus RTU vs TCP: Float Handling Differences

RTU (Serial)

Serial Modbus has strict timing requirements. The inter-frame gap (3.5 character times of silence) separates messages. At 9600 baud with 8N1 encoding:

  • 1 character = 11 bits (start + 8 data + parity + stop)
  • 1 character time = 11/9600 = 1.146ms
  • 3.5 character silence = ~4ms

When reading float values over RTU, response timeout configuration matters. A typical setup:

Baud:             9600
Parity: None
Data bits: 8
Stop bits: 1
Byte timeout: 4ms (gap between consecutive bytes)
Response timeout: 100ms (total time to receive response)

If your byte timeout is too tight, the response may be split into two frames, and the second register of your float pair gets dropped. If you're seeing correct first-register values but garbage in the combined float, increase byte timeout to 5-8ms.

TCP (Ethernet)

Modbus TCP eliminates timing issues but introduces transaction ID management. Each request gets a transaction ID that the slave echoes back. For float reads, the process is identical — request 2 registers, get 4 bytes back — but the framing is handled by TCP, so there's no byte-timeout concern.

The default Modbus TCP port is 502. Some devices use non-standard ports; always verify with the equipment manual.

Common Pitfalls and Troubleshooting

1. Reading Zero Where You Expect a Float

Symptom: Register pair returns 0x0000 0x0000 → 0.0

Likely cause: Wrong register address. Remember the Modbus address convention:

  • Addresses 400001-465536 use function code 3 (read holding registers)
  • Addresses 300001-365536 use function code 4 (read input registers)
  • The actual register number = address - 400001 (for holding) or address - 300001 (for input)

A tag configured at address 404000 maps to holding register 4000 (function code 3). If you accidentally use function code 4, you're reading input register 4000 instead — a completely different value.

2. Reading Extreme Values

Symptom: You get values like 4.5e+28 or -3.2e-15

Likely cause: Wrong word order. You're combining registers in the wrong sequence. Try swapping the two registers and recomputing.

3. Getting NaN or Inf

Symptom: NaN (0x7FC00000) or Inf (0x7F800000)

Likely causes:

  • Word-order mismatch producing an exponent field of all 1s
  • Reading a register that doesn't actually contain a float (it's a raw integer)
  • Sensor disconnected — some PLCs write NaN to indicate a failed sensor

4. Values That Are Close But Off By a Factor

Symptom: You read 7250.0 instead of 72.5

Likely cause: The PLC stores values as scaled integers, not floats. Many older PLCs store temperature as an integer × 100 (so 72.5°F = 7250). Check the PLC documentation for scaling factors. This is especially common with Modbus devices that use single registers (element count = 1) for process values.

5. Intermittent Corrupt Readings

Symptom: 99% of readings are correct, but occasionally you get wild values.

Likely cause: On Modbus RTU, this is usually CRC errors that weren't caught, or electrical noise on the RS-485 bus. Add retry logic — read the registers, if the float value is outside physical bounds (e.g., temperature > 500°F for a plastics process), retry up to 3 times before logging an error.

Real-World Benchmarks

In production IIoT deployments monitoring plastics manufacturing equipment, typical float-read performance:

ProtocolFloat Read TimeRegisters per RequestEffective Throughput
Modbus RTU @ 960015-25ms2 (single float)~40 floats/sec
Modbus RTU @ 960030-45ms50 (contiguous block)~1,000 values/sec
Modbus TCP2-5ms2 (single float)~200 floats/sec
Modbus TCP3-8ms125 (max block)~15,000 values/sec
EtherNet/IP1-3msN/A (native types)~5,000+ tags/sec

The lesson: Modbus RTU float reads are slow individually but scale well with contiguous reads. If you have 30 float tags spread across non-contiguous addresses, it's 30 × 20ms = 600ms per polling cycle. Group your tags by contiguous address blocks to minimize transactions.

Best Practices for Production Systems

  1. Declare types explicitly in configuration. Never auto-detect float vs. integer — always specify the data type per tag.

  2. Use element count = 2 for floats. This is the most common source of misconfiguration. A float is 2 registers, always.

  3. Test with known values during commissioning. Before going live, write a known float (like 123.456) to the PLC and verify the IIoT platform reads it correctly.

  4. Document word order per device type. Build a device-specific configuration library. A TrueTemp TCU uses ABCD, a GP Chiller uses raw int16 — capture this per equipment model.

  5. Implement bounds checking. If a temperature reading suddenly shows 10,000°F, that's not a process event — it's a decode error. Log it, don't alert on it.

  6. Add retry logic for RTU reads. Serial networks are noisy. Retry failed reads up to 3 times before reporting an error status.

  7. Batch contiguous registers. Instead of reading registers 4000-4001, then 4002-4003, then 4004-4005 as three separate transactions, read 4000-4005 as a single 6-register request.

How machineCDN Handles Float Encoding

machineCDN's edge gateway is built to handle the float encoding problem across heterogeneous equipment fleets. Each device type gets a configuration profile that explicitly declares register addresses, data types, element counts, and polling intervals — eliminating the guesswork that causes most float decoding failures.

The platform supports Modbus RTU, Modbus TCP, and EtherNet/IP natively, with automatic protocol detection during initial device discovery. When a new PLC is connected, the gateway attempts EtherNet/IP first (reading the device type tag directly), then falls back to Modbus TCP on port 502. This dual-protocol detection means a single gateway can service mixed equipment floors without manual protocol configuration.

For plastics manufacturers running TCUs, chillers, blenders, dryers, and conveying systems, machineCDN provides pre-built device profiles that include correct register maps, data types, and word-order settings — so the float encoding problem is solved before commissioning begins.


Getting float encoding right is the foundation of trustworthy IIoT data. Every OEE calculation, every alarm threshold, every predictive maintenance model depends on correct readings from the plant floor. Invest the time to verify your decoding — the downstream value is enormous.

Calculated Tags in Industrial IoT: Deriving Boolean Alarms from Raw PLC Registers [2026]

· 9 min read

If you've ever tried to monitor 32 individual alarm conditions from a PLC, you've probably discovered an uncomfortable truth: polling each one as a separate tag creates a nightmarish amount of bus traffic. The solution — calculated tags — is one of the most powerful yet underexplained patterns in industrial data acquisition.

This guide breaks down exactly how calculated tags work, why they matter for alarm systems, and how to implement them efficiently at the edge.

Dependent Tag Architectures: Building Event-Driven Data Hierarchies in Industrial IoT [2026]

· 10 min read

Most IIoT platforms treat every data point as equal. They poll each tag on a fixed schedule, blast everything to the cloud, and let someone else figure out what matters. That approach works fine when you have ten tags. It collapses when you have ten thousand.

Production-grade edge systems take a fundamentally different approach: they model relationships between tags — parent-child dependencies, calculated values derived from raw registers, and event-driven reads that fire only when upstream conditions change. The result is dramatically less bus traffic, lower latency on the signals that matter, and a data architecture that mirrors how the physical process actually works.

This article is a deep technical guide to building these hierarchical tag architectures from the ground up.

Dependent tag architecture for IIoT

The Problem with Flat Polling

In a traditional SCADA or IIoT setup, the edge gateway maintains a flat list of tags. Each tag has an address and a polling interval:

Tag: Barrel_Temperature    Address: 40001    Interval: 1s
Tag: Screw_Speed Address: 40002 Interval: 1s
Tag: Mold_Pressure Address: 40003 Interval: 1s
Tag: Machine_State Address: 40010 Interval: 1s
Tag: Alarm_Word_1 Address: 40020 Interval: 1s
Tag: Alarm_Word_2 Address: 40021 Interval: 1s

Every second, the gateway reads every tag — regardless of whether anything changed. This creates three problems:

  1. Bus saturation on serial links. A Modbus RTU link at 9600 baud can handle roughly 10–15 register reads per second. With 200 tags at 1-second intervals, you're mathematically guaranteed to fall behind.

  2. Wasted bandwidth to the cloud. If barrel temperature hasn't changed in 30 seconds, you're uploading the same value 30 times. At $0.005 per MQTT message on most cloud IoT services, that adds up.

  3. Missing the events that matter. When everything polls at the same rate, a critical alarm state change gets the same priority as a temperature reading that hasn't moved in an hour.

Introducing Tag Hierarchies

A dependent tag architecture introduces three concepts:

1. Parent-Child Dependencies

A dependent tag is one that only gets read when its parent tag's value changes. Consider a machine status word. When the status word changes from "Running" to "Fault," you want to immediately read all the associated diagnostic registers. When the status word hasn't changed, those diagnostic registers are irrelevant.

# Conceptual configuration
parent_tag:
name: machine_status_word
address: 40010
interval: 1s
compare: true
dependent_tags:
- name: fault_code
address: 40011
- name: fault_timestamp
address: 40012-40013
- name: last_setpoint
address: 40014

When machine_status_word changes, the edge daemon immediately performs a forced read of all three dependent tags and delivers them in the same telemetry group — with the same timestamp. This guarantees temporal coherence: the fault code, timestamp, and last setpoint all share the exact timestamp of the state change that triggered them.

2. Calculated Tags

A calculated tag is a virtual data point derived from a parent tag's raw value through bitwise operations. The most common use case: decoding packed alarm words.

Industrial PLCs frequently pack 16 boolean alarms into a single 16-bit register. Rather than polling 16 separate coil addresses (which requires 16 Modbus transactions), you read one holding register and extract each bit:

Alarm_Word_1 (uint16 at 40020):
Bit 0 → High Temperature Alarm
Bit 1 → Low Pressure Alarm
Bit 2 → Motor Overload
Bit 3 → Emergency Stop Active
...
Bit 15 → Communication Fault

A well-designed edge gateway handles this decomposition at the edge:

parent_tag:
name: alarm_word_1
address: 40020
type: uint16
interval: 1s
compare: true # Only process when value changes
do_not_batch: true # Deliver immediately — don't wait for batch timeout
calculated_tags:
- name: high_temp_alarm
type: bool
shift: 0
mask: 0x01
- name: low_pressure_alarm
type: bool
shift: 1
mask: 0x01
- name: motor_overload
type: bool
shift: 2
mask: 0x01
- name: estop_active
type: bool
shift: 3
mask: 0x01

The beauty of this approach:

  • One Modbus read instead of sixteen
  • Zero cloud processing — the edge already decomposed the alarm word into named boolean tags
  • Change-driven delivery — if the alarm word hasn't changed, nothing gets sent. When bit 2 flips from 0 to 1, only the changed calculated tags get delivered.

3. Comparison-Based Delivery

The compare flag on a tag definition tells the edge daemon to track the last-known value and suppress delivery when the new value matches. This is distinct from a polling interval — the tag still gets read on schedule, but the value only gets delivered when it changes.

This is particularly powerful for:

  • Status words and mode registers that change infrequently
  • Alarm bits where you care about transitions, not steady state
  • Setpoint registers that only change when an operator makes an adjustment

A well-implemented comparison handles type-aware equality. Comparing two float values with bitwise equality is fine for PLC registers (they're IEEE 754 representations read directly from memory — no floating-point arithmetic involved). Comparing two uint16 values is straightforward. The edge daemon should store the raw bytes, not a converted representation.

Register Grouping: The Foundation

Before dependent tags can work efficiently, the underlying polling engine needs contiguous register grouping. This is the practice of combining multiple tags into a single Modbus read request when their addresses are adjacent.

Consider these five tags:

Tag A: addr 40001, type uint16  (1 register)
Tag B: addr 40002, type uint16 (1 register)
Tag C: addr 40003, type float (2 registers)
Tag D: addr 40005, type uint16 (1 register)
Tag E: addr 40010, type uint16 (1 register) ← gap

An intelligent polling engine groups A through D into a single Read Holding Registers call: start address 40001, quantity 5. Tag E starts a new group because there's a 5-register gap.

The grouping rules are:

  1. Same function code. You can't combine holding registers (FC03) with input registers (FC04) in one read.
  2. Contiguous addresses. Any gap breaks the group.
  3. Same polling interval. A tag polling at 1s and a tag polling at 60s shouldn't be in the same group.
  4. Maximum group size. The Modbus spec limits a single read to 125 registers (some devices impose lower limits — 50 is a safe practical maximum).

After the bulk read returns, the edge daemon dispatches individual register values to each tag definition, handling type conversion per tag (uint16, int16, float from two consecutive registers, etc.).

The 32-Bit Float Problem

When a tag spans two Modbus registers (common for 32-bit integers and IEEE 754 floats), the edge daemon must handle word ordering. Some PLCs store the high word first (big-endian), others store the low word first (little-endian). A typical edge system stores the raw register pair and then calls the appropriate conversion:

  • Big-endian (AB CD): value = (register[0] << 16) | register[1]
  • Little-endian (CD AB): value = (register[1] << 16) | register[0]

For IEEE 754 floats, the 32-bit integer is reinterpreted as a floating-point value. Getting this wrong produces garbage data — a common source of "the numbers look random" support tickets.

Architecture: Tying It Together

Here's how a production edge system processes a single polling cycle with dependent tags:

1. Start timestamp group (T = now)
2. For each tag in the poll list:
a. Check if interval has elapsed since last read
b. If not due, skip (but check if it's part of a contiguous group)
c. Read tag (or group of tags) from PLC
d. If compare=true and value unchanged: skip delivery
e. If compare=true and value changed:
i. Deliver value (batched or immediate)
ii. If tag has calculated_tags: compute each one, deliver
iii. If tag has dependent_tags:
- Finalize current batch group
- Force-read all dependent tags (recursive)
- Start new batch group
f. Update last-known value and last-read timestamp
3. Finalize timestamp group

The critical detail is step (e)(iii): when a parent tag triggers a dependent read, the current batch group gets finalized and the dependent tags are read in a forced mode (ignoring their individual interval timers). This ensures the dependent values reflect the state at the moment of the parent's change, not some future polling cycle.

Practical Considerations

On Modbus RTU, the 3.5-character silent interval between frames is mandatory. At 9600 baud with 8N1 encoding, one character takes ~1.04ms, so the minimum inter-frame gap is ~3.64ms. With a typical request frame of 8 bytes and a response frame of 5 + 2*N bytes (for N registers), a single read of 10 registers takes approximately:

Request:    8 bytes × 1.04ms = 8.3ms
Turnaround: ~3.5ms (device processing)
Response: (5 + 20) bytes × 1.04ms = 26ms
Gap: 3.64ms
Total: ~41.4ms per read

This means you can fit roughly 24 read operations per second on a 9600-baud link. If you're polling 150 tags with 1-second intervals, grouping is not optional — it's survival.

Alarm Tag Design

For alarm words, always configure:

  • compare: true — only deliver when an alarm state changes
  • do_not_batch: true — bypass the batch timeout and deliver immediately
  • interval: 1 (1 second) — poll frequently to catch transient alarms

Process variables like temperatures and pressures can safely use longer intervals (30–60 seconds) with compare: false since trending data benefits from regular samples.

Avoiding Circular Dependencies

If Tag A is dependent on Tag B, and Tag B is dependent on Tag A, you'll create an infinite recursion in the read loop. Production systems guard against this by either:

  • Limiting dependency depth (typically 1–2 levels)
  • Tracking a "reading" flag to prevent re-entry
  • Flattening the graph at configuration parse time

Hourly Full-Refresh

Even with change-driven delivery, it's good practice to force-read and deliver all tags at least once per hour. This catches any edge cases where a value changed but the change was missed (e.g., a brief network hiccup that caused a read failure during the exact moment of change). A simple approach: track the hour boundary and reset the "already read" flag on all tags when the hour rolls over.

How machineCDN Handles Tag Hierarchies

machineCDN's edge infrastructure supports all three relationship types natively. When you configure a device in the platform, you define parent-child dependencies, calculated alarm bits, and comparison-based delivery in the device configuration — no custom scripting required.

The platform's edge daemon handles contiguous register grouping automatically, supports both EtherNet/IP and Modbus (TCP and RTU) from the same configuration model, and provides dual-format batch delivery (JSON for debugging, binary for bandwidth efficiency). Alarm tags are delivered immediately outside the batch cycle, ensuring sub-second alert latency even when the batch timeout is set to 30 seconds.

For teams managing fleets of machines across multiple plants, this means the tag architecture you define once gets deployed consistently to every edge gateway — whether it's monitoring a chiller system with 160+ process variables or a simple TCU with 20 tags.

Key Takeaways

  1. Model relationships, not just addresses. Tags have dependencies that mirror the physical process. Your data architecture should reflect that.
  2. Use comparison to suppress noise. A status word that hasn't changed in 6 hours doesn't need 21,600 duplicate deliveries.
  3. Calculated tags eliminate cloud processing. Decompose packed alarm words at the edge — one Modbus read becomes 16 named boolean signals.
  4. Dependent reads guarantee temporal coherence. When a parent changes, all children are read with the same timestamp.
  5. Group contiguous registers ruthlessly. On serial links, the difference between grouped and ungrouped reads is the difference between working and not working.

The flat-list polling model was fine for SCADA systems monitoring 50 tags on a single HMI. For IIoT platforms handling thousands of data points across fleets of machines, hierarchical tag architectures aren't an optimization — they're the foundation.

Modbus Address Conventions and Function Codes: The Practical Guide Every IIoT Engineer Needs [2026]

· 11 min read

If you've ever stared at a PLC register map wondering why address 300001 means something completely different from 400001, or why your edge gateway reads all zeros from a register that should contain temperature data — this guide is for you.

Modbus has been the lingua franca of industrial automation for nearly five decades. Its longevity comes from simplicity, but that simplicity hides a handful of conventions that trip up even experienced engineers. The addressing scheme and its relationship to function codes is the single most important concept to nail before you write a single line of polling logic.

Let's break it apart.