Modbus Data Encoding: 32-Bit Values, Floats, and the Byte-Order Trap

How a temperature reading becomes two 16-bit registers — IEEE 754 float layout, the four word/byte orders (ABCD/CDAB/BADC/DCBA), signed vs unsigned, and scale factors, with a runnable decoder.

OrangeHorse Engineering Team 9 min read

Modbus Data Encoding: 32-Bit Values, Floats, and the Byte-Order Trap

You read two holding registers from a soil sensor and get 0x4248 and 0x0000. The manual says register 0 is "soil temperature, float." What temperature is it?

If you answer "50.0 °C," you have decoded it as a big-endian IEEE 754 float. If you answer "something astronomically small," you decoded the same two registers with the wrong byte order. Both answers come from the same two registers. That gap — between the raw 16-bit words on the wire and the physical value they represent — is where the majority of Modbus integration bugs live, and it is not obvious until you have seen the four possible arrangements side by side.

Registers are 16-bit; reality is not

The Modbus data model is built on 16-bit registers. Every function code that moves data moves it in units of one or more 16-bit words. A coil is 1 bit, a register is 16 bits, and that is the entire vocabulary of the protocol. The specification says nothing about how a 32-bit number, a float, a signed value, or a scaled engineering value is represented, because those are application-layer concerns that Modbus deliberately leaves to the device vendor.

That is the source of the trouble. The Modbus Application Protocol does specify one thing clearly: within a single 16-bit register, the high-order byte is transmitted first (big-endian, the same "network byte order" used by TCP/IP). So one register is unambiguous. The ambiguity begins the moment a value needs two registers, because the spec does not standardize which register holds the high-order word and which holds the low-order word. Vendors chose, and they chose differently.

Two independent orderings, four combinations

A 32-bit value spans two consecutive 16-bit registers. Two questions must be answered:

  1. Word order — does the first (lower-address) register hold the most-significant word or the least-significant word?
  2. Byte order — within each word, are the two bytes natural (big-endian) or swapped?

Two binary choices give four arrangements. For a 32-bit value whose big-endian byte sequence is A B C D, the four field conventions are:

ConventionFirst registerSecond registerWire bytesCommon name
ABCDA BC DAA BB CC DDbig-endian / "high word first" — Modbus TCP default
CDABC DA BCC DD AA BB"word swap" — Modicon 984 convention
BADCB AD CBB AA DD CC"byte swap"
DCBAD CB ADD CC BB AA"word + byte swap" (full reversal)

The naming is deliberately positional: the letters spell out the byte order you get when you read the two registers as a stream. ABCD is the natural big-endian layout that matches struct.pack(">f", x) in Python or f32::to_be_bytes() in Rust. CDAB is the single most common non-standard variant, introduced by the Modicon 984 PLC family and cloned by decades of compatible hardware — so much so that many devices with other defaults expose a "Modicon mode" switch to force it.

The practical rule: ABCD is the default assumption, but only the device's register map is authoritative. If the documentation mentions "MSRF" (most-significant register first), "float32_swap", "byte order", or "Modicon mode", it is telling you which of the four to use.

What a float actually looks like in 32 bits

Before you can decode, it helps to know what you are decoding. A single-precision float (IEEE 754 binary32) is 32 bits arranged as:

bit 31           30 ——— 23          22 ——— 0
   sign (1)    exponent (8 bits)    mantissa (23 bits)

The reconstructed value is (-1)^sign × (1 + mantissa/2^23) × 2^(exponent − 127). For 50.0, the raw pattern is 0x42480000:

  • 0x4248 = 0100 0010 0100 1000 — sign 0, exponent 100 0010 0 = 132, so 2^(132−127) = 2^5 = 32; mantissa bits give 1.5625; 32 × 1.5625 = 50.0.

Two special patterns deserve a name-drop because they show up as sensor fault indicators: 0x7FC00000 is NaN ("not a number"), which many devices emit when a probe is disconnected, and 0x00000000 / 0x80000000 are +0.0 / −0.0. If your decoded float is NaN or a denormal like 2.4e-41, the more likely cause than a broken sensor is a wrong byte order — a valid 16-bit pair is being interpreted through the wrong lens.

The four ways, side by side

The demo below is pure standard-library Rust — no Modbus library required, because byte order is an application concern that lives above the protocol. It packs 50.0 into two registers using the big-endian convention, then decodes those same two registers with all four orders:

/// The four word/byte orders seen in real Modbus devices.
#[derive(Clone, Copy, Debug)]
enum Order {
    Abcd, // high word first, natural byte order (Modbus TCP default)
    Cdab, // low word first ("word swap", Modicon 984 convention)
    Badc, // byte swap within each 16-bit word
    Dcba, // word swap + byte swap (full reversal)
}

/// Pack an f32 into two 16-bit registers using the given order.
fn encode_f32(v: f32, order: Order) -> (u16, u16) {
    let raw = v.to_bits().to_be_bytes(); // [b0, b1, b2, b3]
    match order {
        Order::Abcd => (
            u16::from_be_bytes([raw[0], raw[1]]),
            u16::from_be_bytes([raw[2], raw[3]]),
        ),
        Order::Cdab => (
            u16::from_be_bytes([raw[2], raw[3]]),
            u16::from_be_bytes([raw[0], raw[1]]),
        ),
        Order::Badc => (
            u16::from_be_bytes([raw[1], raw[0]]),
            u16::from_be_bytes([raw[3], raw[2]]),
        ),
        Order::Dcba => (
            u16::from_be_bytes([raw[3], raw[2]]),
            u16::from_be_bytes([raw[1], raw[0]]),
        ),
    }
}

/// Unpack two 16-bit registers (low address first) back into an f32.
fn decode_f32(reg_n: u16, reg_n1: u16, order: Order) -> f32 {
    let w0 = reg_n.to_be_bytes(); // [hi, lo]
    let w1 = reg_n1.to_be_bytes(); // [hi, lo]
    let raw: [u8; 4] = match order {
        Order::Abcd => [w0[0], w0[1], w1[0], w1[1]],
        Order::Cdab => [w1[0], w1[1], w0[0], w0[1]],
        Order::Badc => [w0[1], w0[0], w1[1], w1[0]],
        Order::Dcba => [w1[1], w1[0], w0[1], w0[0]],
    };
    f32::from_bits(u32::from_be_bytes(raw))
}

fn show(name: &str, v: f32) {
    // Round values print naturally; denormal garbage prints in scientific form.
    if v.fract() == 0.0 && v.abs() < 1_000_000.0 {
        println!("{name:<10} = {v:.0}");
    } else {
        println!("{name:<10} = {v:e}");
    }
}

fn main() {
    // ── 1. The same two registers, decoded four ways ──────────────────
    // 50.0f32 encodes to 0x4248_0000 in IEEE 754 big-endian.
    let (reg_n, reg_n1) = encode_f32(50.0, Order::Abcd);
    println!("50.0 as ABCD registers = {reg_n:#06X}, {reg_n1:#06X}");
    println!("decoding those two registers with each order:");
    show("Abcd", decode_f32(reg_n, reg_n1, Order::Abcd));
    show("Cdab", decode_f32(reg_n, reg_n1, Order::Cdab));
    show("Badc", decode_f32(reg_n, reg_n1, Order::Badc));
    show("Dcba", decode_f32(reg_n, reg_n1, Order::Dcba));

    // ── 2. Signed vs unsigned 16-bit ───────────────────────────────────
    println!();
    let raw: u16 = 0xFFFF;
    println!("0xFFFF as u16 = {} (unsigned)", raw);
    println!("0xFFFF as i16 = {} (signed, two's complement)", raw as i16);
    println!("0xFF9C as i16 = {} (e.g. temperature -100 in 0.1 °C)", 0xFF9Cu16 as i16);

    // ── 3. Scale factor ─────────────────────────────────────────────────
    println!();
    let raw: u16 = 1234;
    let value = raw as f32 * 0.1;
    println!("register 1234 with scale 0.1 = {value} (e.g. 123.4 °C)");

    // ── 4. Round-trip: every order recovers the value if encode+decode agree ──
    println!();
    for order in [Order::Abcd, Order::Cdab, Order::Badc, Order::Dcba] {
        let (a, b) = encode_f32(50.0, order);
        let back = decode_f32(a, b, order);
        println!("round-trip {order:?} -> {back}");
    }
}

Run it:

50.0 as ABCD registers = 0x4248, 0x0000
decoding those two registers with each order:
Abcd       = 50
Cdab       = 2.3777e-41
Badc       = 198656
Dcba       = 2.5921e-41

0xFFFF as u16 = 65535 (unsigned)
0xFFFF as i16 = -1 (signed, two's complement)
0xFF9C as i16 = -100 (e.g. temperature -100 in 0.1 °C)

register 1234 with scale 0.1 = 123.4 (e.g. 123.4 °C)

round-trip Abcd -> 50
round-trip Cdab -> 50
round-trip Badc -> 50
round-trip Dcba -> 50

The first block is the whole lesson. The same two registers 0x4248, 0x0000 decode to four completely different numbers — 50, 2.4e-41, 198656, and 2.6e-41 — depending only on the order you pick. The last block is the one piece of good news: if the writer and reader agree on an order, any of the four round-trips cleanly. The bug is never "the data is wrong"; it is always "the two ends picked different orders."

Signed vs unsigned, and why 65535 is also −1

Byte order is one trap; the interpretation of a single 16-bit word is a second, smaller one. 0xFFFF is 65535 as an unsigned integer and −1 as a two's-complement signed integer — same bits, different meaning. Temperature, pressure, and many physical quantities are negative as often as positive, so a device will either declare a register "signed 16-bit" (i16) or "unsigned 16-bit" (u16). Decoding a signed value as unsigned (or vice versa) inverts the reading around the zero crossing: −1 becomes 65535, and a perfectly reasonable −100 (stored as 0xFF9C) becomes a nonsensical 65436.

Scale factors: when a register isn't the reading

The third and most sensor-specific convention is the scale factor. A register is an integer, but a temperature of 123.4 °C is not. Rather than store a float (two registers, byte-order pitfalls, and no fractional convenience), most industrial devices store an integer and tell you to multiply by a scale — 1234 with a scale of 0.1 is 123.4. Common scales are powers of ten (×0.1, ×0.01, ×10), and a device's register map will state it explicitly ("temperature, °C, ×0.1") or bury it in a "resolution" column. Combine this with the signed/unsigned choice and you get the full decode pipeline: read the raw word → interpret it as signed or unsigned → apply the scale → now it is an engineering value.

The diagnostic procedure when the manual is ambiguous

When a device's register map is unclear, the fastest way to resolve the byte order is to force a known value and read it back:

  1. Pick a value with a distinctive byte pattern. 50.00x42480000 is ideal because 0x42 and 0x48 are unmistakable and it survives any order.
  2. Read the two raw registers.
  3. Reassemble under all four orders (the demo's decode_f32 does exactly this) and see which yields 50.0.

That one test pins down both the word order and the byte order permanently, and it is cheaper than a support ticket. This is also why the bus monitor exists: reading the raw registers off the wire removes the "is it my decoder or the device?" ambiguity in one look.

What to remember

  • Modbus registers are 16-bit; the spec fixes byte order within a register but leaves word order between registers to the vendor.
  • Four conventions cover the field: ABCD (default), CDAB (Modicon word-swap), BADC, DCBA. Only the device's register map is authoritative.
  • A 32-bit float is sign + 8-bit exponent + 23-bit mantissa; a decoded NaN or denormal usually means a wrong byte order, not a broken sensor.
  • Signed vs unsigned (−1 vs 65535) and the scale factor (1234 × 0.1 = 123.4) complete the decode; get all three right and the value is meaningful.

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

Read next: Seven Modbus Pitfalls — the mistakes that break integrations, including multi-register float word order and wire byte order. Modbus ASCII Mode — the human-readable frame format and its LRC checksum.

modbus modbus float modbus byte order modbus endianness modbus 32-bit ieee 754 modbus scaling modbus signed rust iiot