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
0x41C8goes on the wire as41 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:
0x02Illegal Data Address — the register does not exist. Retrying changes nothing; the address is wrong.0x03Illegal 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 00is the two words of25.0—16840is0x41C8in decimal, and the second word is0x0000. Assemble them high-word-first and you get25.0; word-swap them and you get2.3598e-41. - The second response
FF FFis0xFFFF, printed as both65535and-1— identical bytes, opposite meaning. - The third read asks for address
0and gets0x41C8back — that is the register a PLC datasheet would call "40001".
A best-practices checklist
Before you ship a Modbus integration, run through these:
- Know the addressing. Is the datasheet using 0-based addresses or 1-based
4xxxxxnotation? Resolve the offset up front. - Know the word order for every multi-register value. Confirm against a known reading, not the datasheet alone.
- Know the signedness of every register. Temperatures especially are frequently signed.
- Know the scaling of every register. "Temperature" is almost never in raw degrees.
- Chunk your reads to 125 registers (and 123 for writes) or fewer.
- Match on the exception code, and only retry
AcknowledgeandServer Busy— neverIllegal Data AddressorIllegal Data Value. - 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.