Seven Modbus Pitfalls That Break Integrations (and How to Avoid Them)

The seven most common Modbus integration mistakes — 0-based vs 1-based addressing, multi-register float word order, signed vs unsigned values, wire byte order, integer scaling, quantity limits, and blind retries — with concrete frame examples and runnable Rust code.

OrangeHorse Engineering Team 10 min read

Seven Modbus Pitfalls That Break Integrations

Modbus is 46 years old, and on the surface it is one of the simplest protocols in industrial automation: read a register, write a register. But the simplicity is a trap. The protocol spec leaves a surprising amount to convention, and different vendors — different engineers at the same vendor — made different choices. The result is a stack of footguns that have broken more integrations than any electrical noise ever did.

Here are the seven that bite hardest, with concrete frame examples and a runnable demo at the end. The frames below were captured from a running program; every CRC is real.

1. 0-based vs 1-based addressing — the "40001" trap

This is the single most common source of "the sensor returns the wrong register" bugs.

A Modbus register address is a 0-based number: the first holding register is address 0x0000. But the classic PLC convention refers to holding registers with a 4 prefix and a 1-based offset — so the first holding register is "40001", the second is "40002", and so on. 40001 in a PLC datasheet is address 0, not address 40001.

Add to that the two other prefixes — 1 for coils, 3 for input registers — and a 30001 (first input register) is also address 0, just in a different table.

datasheet "40001"  →  holding register, address 0x0000
datasheet "40003"  →  holding register, address 0x0002
datasheet "30002"  →  input register,    address 0x0001

When you read a value that looks "off by one" or "off by one register", the fix is almost always this offset. The library's own protocol guide has a worked example of the four data tables and their addressing.

2. 32-bit values span two registers — and word order is a guess

A Modbus register is 16 bits. Any value wider than that — a 32-bit float, a 32-bit signed integer, a 64-bit timestamp — is split across two or more consecutive registers. How it is split is not specified, and there are two common conventions.

Take a temperature of 25.0 °C. As an IEEE 754 float that is 0x41C80000. In the most common convention — high word first (a.k.a. "big-endian word order") — register 0 holds 0x41C8 and register 1 holds 0x0000:

register[0] = 0x41C8   (high word)
register[1] = 0x0000   (low word)

Reassemble as ((reg[0] as u32) << 16) | reg[1] and you get 25.0. But some vendors use the opposite order — low word first ("word swap") — where register 0 holds 0x0000 and register 1 holds 0x41C8. If you assemble those two words the same way you did before, you get:

(0x0000 << 16) | 0x41C8 = 0x000041C8 = 2.36 × 10⁻⁴¹

25.0 versus 2.36e-41. The difference is not a rounding error; it is a word-order mismatch. The value is garbage in a way that is impossible to "eyeball" — it looks like a valid (if absurdly small) float. The runnable demo at the end of this article shows both assemblies side by side.

The rule: when you meet a multi-register value, read the vendor's register map for the words "word order", "endianness", or "byte/word swap". When the datasheet does not say — which is common — read a known reference value (a sensor sitting at room temperature, a device at a known address) and try both orderings until the number makes sense.

3. Signed vs unsigned — same bits, different number

0xFFFF is either 65535 (unsigned) or -1 (signed), depending on how you interpret it. The wire carries no type information — the same two bytes mean either, and the correct interpretation is documented only in the datasheet.

This matters most for temperature. A soil sensor that reports temperature in signed tenths of a degree will send 0xFE0C for -5.0 °C (that is -50 in two's complement). Read it as unsigned and you get 65036 instead of -5.0 — a value that is not just wrong, it is implausible, which is actually a useful clue. When you see a temperature like 65036 or 65436, you are almost certainly reading a signed value as unsigned.

0xFFFF  →  u16: 65535      i16: -1
0xFE0C  →  u16: 65036      i16: -500   (i.e. -50.0 °C in tenths)

The demo below prints both interpretations of 0xFFFF from the same register.

4. Wire byte order — registers big-endian, CRC little-endian

There are two byte orders in a Modbus RTU frame, and they point in opposite directions:

  • Register values are big-endian (high byte first). The value 0x41C8 goes on the wire as 41 C8.
  • The CRC-16 is little-endian (low byte first). The checksum of a frame is written low byte then high byte.

You can see both in one response frame. This is a read of two registers returning 0x41C8 and 0x0000:

01 03 04 41 C8 00 00 6F F1
│  │  │  └──────┴──────┘  └──┴── CRC (0xF16F, low byte 6F first)
│  │  │        register data (big-endian)
│  │  └─ byte count = 4
│  └─ FC 03
└─ slave 01

The register bytes read left-to-right as the values you expect (41 C8 = 0x41C8), but the CRC bytes are 6F F1 — the low byte 0x6F first, then the high byte 0xF1, so the actual CRC value is 0xF16F. This inversion is the single most common bug in hand-rolled Modbus frame builders, and it is invisible until you test against a real device. If you are writing frames by hand, see the CRC deep-dive for the exact algorithm.

5. Integer scaling — most sensors send no floats

Most Modbus sensors do not transmit IEEE 754 floats at all. They transmit scaled integers: temperature as tenths (×10) or hundredths (×100) of a degree, conductivity as µS/cm, voltage as millivolts. The register holds 250 and the datasheet says "temperature, 0.1 °C, signed" — so the value is 25.0 °C.

The trap is assuming the number is already in the unit you want. 250 raw is not 250 °C; it is 25.0 °C. The scaling factor is a datasheet detail you must look up and apply in software, and it varies between registers on the same device — the same sensor might report temperature in tenths and moisture in hundredths.

register value 250, scale 0.1   →  25.0 °C
register value 250, scale 1.0   →  250  (units)
register value 250, scale 0.01  →  2.50 (some other unit)

6. Quantity limits — one register too many and it fails

Every read and write has a hard quantity ceiling, and exceeding it is an error, not a warning. From the Modbus spec:

  • Read Holding Registers (FC 03) and Read Input Registers (FC 04): maximum 125 registers per request.
  • Write Multiple Registers (FC 16): maximum 123 registers.
  • Read Coils (FC 01) and Read Discrete Inputs (FC 02): maximum 2000 coils.
  • Write Multiple Coils (FC 15): maximum 1968 coils.

The limits exist because a Modbus PDU is capped at 253 bytes. 125 registers × 2 bytes = 250 bytes, plus the function code and byte count, just fits. Ask for 126 and a spec-compliant device returns an Illegal Data Value exception — or, on a sloppier device, silently truncates. Either way, a read loop that assumes "read everything in one shot" breaks the moment a sensor's register map grows past 125 entries. Chunk your reads.

7. Blind retries — read the exception code first

When a Modbus request fails, the failure usually returns a structured reason: an exception code (see the function code reference). But a common integration pattern is to catch any error and blindly retry a few times before giving up.

The two most common exceptions are permanent, not transient:

  • 0x02 Illegal Data Address — the register does not exist. Retrying changes nothing; the address is wrong.
  • 0x03 Illegal Data Value — the value is out of range. Retrying changes nothing; the value is wrong.

Retrying those is at best wasted bus time and at worst — in a gateway with a short poll interval — a retry storm that saturates the 3.5T silent interval and drops every other device's frames. Only 0x05 (Acknowledge) and 0x06 (Server Busy) are worth retrying, and those with a backoff.

The lesson: match on the error. oms-modbus surfaces exception responses as ModbusError::Exception { function, code }, so you can distinguish "address is wrong — fix it" from "busy — try again later".

The demo

This program runs the three most concrete pitfalls — float word order, signed/unsigned, and 0-based addressing — against an in-memory device, and dumps the raw frames so you can see the byte order for yourself.

use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ── A device holding one 32-bit float across two 16-bit registers ──
    // 25.0f32 = 0x41C80000; register 0 holds the high word, register 1 the low.
    let temp_c = 25.0f32;
    let bits = temp_c.to_bits(); // 0x41C8_0000
    let hi = (bits >> 16) as u16; // 0x41C8
    let lo = bits as u16; // 0x0000

    let store = Arc::new(SlaveStore::with_holding_registers(&[
        (0, hi),     // temperature — high word
        (1, lo),     // temperature — low word
        (2, 0xFFFF), // a register that is 65535 or -1, depending on who asks
    ]));

    let (client_stream, server_stream) = tokio::io::duplex(1024);
    let server = rtu::RtuServer::new(server_stream);
    tokio::spawn(async move {
        server.serve_forever(store).await.ok();
    });

    let cap = Arc::new(BusCapture::unbounded());
    let opts = ClientOptions::default()
        .with_timeout(Duration::from_secs(3))
        .with_tap(cap.clone());
    let client = rtu::with_options(client_stream, opts);

    // ── Pitfall 1: a 32-bit float spans TWO registers, high word first ──
    let regs = client.read_holding_registers(1, 0, 2).await?;
    let correct = f32::from_bits(((regs[0] as u32) << 16) | (regs[1] as u32));
    let swapped = f32::from_bits(((regs[1] as u32) << 16) | (regs[0] as u32));
    println!("regs {regs:?} -> high-word-first {correct} °C, word-swapped {swapped:e} °C");

    // ── Pitfall 2: 0xFFFF is -1 (i16) or 65535 (u16) — same bits ───────
    let raw = client.read_holding_registers(1, 2, 1).await?;
    println!("0xFFFF as u16 = {}, as i16 = {}", raw[0], raw[0] as i16);

    // ── Pitfall 3: PLC "40001" is address 0 in a 0-based library ──────
    let first = client.read_holding_registers(1, 0, 1).await?;
    println!("address 0 (a.k.a. PLC \"40001\") = {:#06X}", first[0]);

    println!();
    for pkt in cap.drain() {
        println!("{pkt}");
    }

    Ok(())
}

Output:

regs [16840, 0] -> high-word-first 25 °C, word-swapped 2.3598e-41 °C
0xFFFF as u16 = 65535, as i16 = -1
address 0 (a.k.a. PLC "40001") = 0x41C8

[TX]  8B  [01 03 00 00 00 02 C4 0B]
[RX]  9B  [01 03 04 41 C8 00 00 6F F1]
[TX]  8B  [01 03 00 02 00 01 25 CA]
[RX]  7B  [01 03 02 FF FF B9 F4]
[TX]  8B  [01 03 00 00 00 01 84 0A]
[RX]  7B  [01 03 02 41 C8 89 82]

Read the trace top to bottom and all three pitfalls are visible at once:

  • The first response 41 C8 00 00 is the two words of 25.016840 is 0x41C8 in decimal, and the second word is 0x0000. Assemble them high-word-first and you get 25.0; word-swap them and you get 2.3598e-41.
  • The second response FF FF is 0xFFFF, printed as both 65535 and -1 — identical bytes, opposite meaning.
  • The third read asks for address 0 and gets 0x41C8 back — that is the register a PLC datasheet would call "40001".

A best-practices checklist

Before you ship a Modbus integration, run through these:

  1. Know the addressing. Is the datasheet using 0-based addresses or 1-based 4xxxxx notation? Resolve the offset up front.
  2. Know the word order for every multi-register value. Confirm against a known reading, not the datasheet alone.
  3. Know the signedness of every register. Temperatures especially are frequently signed.
  4. Know the scaling of every register. "Temperature" is almost never in raw degrees.
  5. Chunk your reads to 125 registers (and 123 for writes) or fewer.
  6. Match on the exception code, and only retry Acknowledge and Server Busy — never Illegal Data Address or Illegal Data Value.
  7. Respect the 3.5T silent interval — a library that does not enforce it will fail on a loaded bus.

Every one of these is a data or protocol problem, not a hardware problem. The fastest way to know which one you are hitting is to look at the raw frames — which is exactly what WireTap is for.


This article is part of the oms-modbus tutorial series. oms-modbus is a transport-generic Modbus library in Rust — one API for TCP, RTU, and ASCII — used at OrangeHorse to build diagnostic tools for our Modbus-enabled sensors. MIT/Apache-2.0, on crates.io.

Read next: Modbus Protocol Guide — the data model and addressing model behind these pitfalls. Modbus Function Codes 01–06 — the six codes you will actually use.

modbus modbus pitfalls modbus best practices modbus addressing modbus float modbus endianness modbus signed unsigned rust iiot