Modbus Protocol Guide: Data Model, Addressing, and Frame Formats

A ground-up guide to the Modbus protocol — the four data tables, 0-based vs 1-based addressing, PDU vs ADU, byte ordering, and RTU/TCP frame formats, with a real soil-sensor example.

OrangeHorse Engineering Team 10 min read

Modbus Protocol Guide: Data Model, Addressing, and Frame Formats

Modbus has been running factories since 1979, and it is still the first protocol an engineer reaches for when a new sensor, meter, or PLC needs to talk to a SCADA system. That longevity has a cost: decades of accumulated convention, three different wire formats, and an addressing scheme that confuses almost everyone the first time they meet it.

This guide is the map I wish I had when I first stared at a hex dump and a register table. It covers the data model, the addressing convention that trips people up, how a request is wrapped for the wire, and — because a protocol is only real when it talks to hardware — a worked example that reads a live soil sensor using the oms-modbus Rust library.

The master–slave model

Modbus is a request–response protocol. Exactly one master (a client) issues requests. Up to 247 slaves (servers) listen on the bus, each with a unique address from 1 to 247. A slave answers only when addressed, and only with a single response.

      ┌─────────┐      request (function code + data)      ┌─────────┐
      │         │ ────────────────────────────────────────▶ │         │
      │ Master  │                                           │ Slave 1 │
      │ (client)│ ◀──────────────────────────────────────── │ (server)│
      └─────────┘      response (echoed FC + data)          └─────────┘
            │
            │  …addressed one at a time…
            ▼
      slaves 2 … 247 wait silently

This simplicity is the protocol's greatest strength. There is no negotiation, no subscription, no discovery handshake — just a function code, some data, and a checksum. A request that fits in eight bytes can read a sensor reading.

The four data tables

Every Modbus device exposes its data through four logical tables. The tables are a model, not a physical layout — a vendor is free to map any table onto any kind of memory, and most sensors use only one or two of them.

TableAccessItem sizeTypical contentReadWrite
Coilsread/write1 bitrelays, digital outputs, flagsFC 01FC 05, 15
Discrete Inputsread-only1 bitlimit switches, alarm contactsFC 02
Input Registersread-only16 bitADC readings, measured valuesFC 04
Holding Registersread/write16 bitsetpoints, config, sensor valuesFC 03FC 06, 16

Two things matter here. First, a bit table returns packed bits, while a register table returns 16-bit words. Second, the table a value lives in is implied by the function code, not by any address prefix — which is exactly where the next section starts confusing people.

Addressing: 0-based, 1-based, and the 40001 habit

The single most common Modbus mistake is mixing up two numbering schemes that describe the same location.

The PDU (what actually travels inside a request) uses 0-based offsets. A request to read holding registers starting at offset 0 asks for the first holding register.

The historical convention — kept alive by HMI screens, SCADA tags, and a million datasheets — uses 1-based reference numbers, prefixed by the table:

Reference rangeTableOffset → Reference
0000109999Coilsoffset 0 = reference 00001
1000119999Discrete Inputsoffset 0 = reference 10001
3000139999Input Registersoffset 0 = reference 30001
4000149999Holding Registersoffset 0 = reference 40001

So 40001 means "holding register number one" — offset 0 in the holding-register table, read or written with function codes 03, 06, or 16. When a configuration screen asks for 40001, the byte you put on the wire is offset 0x0000. That one-off discrepancy has consumed more engineering hours than any other Modbus detail.

A useful mental rule: strip the leading digit, subtract one, and treat the result as the 0-based offset. 30012 → input register offset 11. 40002 → holding register offset 1.

PDU vs ADU: what is actually transmitted

A request has two layers:

  • PDU (Protocol Data Unit) — the transport-independent core: a one-byte function code plus its data.
  • ADU (Application Data Unit) — the PDU wrapped with whatever the specific transport needs to frame it: a slave address, a header, and an error check.
  PDU:  [ function code ][  data  ]
  ADU:  [ transport framing ][ function code ][ data ][ error check ]

This is why oms-modbus can offer one client API for three transports. The PDU — "read two holding registers starting at 0" — is the same bytes regardless of whether it travels over a serial bus or TCP. Only the wrapping changes.

The three frame formats

The same PDU gets three different ADU wrappers.

RTU (binary, over RS-485)

The workhorse of field wiring. Compact binary, delimited by silence rather than by length, protected by a CRC-16.

[ slave addr 1B ][ function code 1B ][ data 0–252B ][ CRC-16 2B ]

The CRC is sent low byte first, a detail that breaks every hand-rolled parser at least once.

ASCII (text, over serial)

Rare, but still found on legacy gear. Every byte is two printable hex characters, framed by a colon and CR/LF, checked with an LRC. About twice as long as RTU for the same payload.

: [ slave 1B ][ FC 1B ][ data ][ LRC 1B ] CR LF

TCP (Modbus TCP, over Ethernet)

Serial Modbus with the slave address replaced by a 7-byte MBAP header. The transaction ID pairs a request with its response; the unit ID stands in for the slave address when the gateway fronts a serial bus.

[ TID 2B ][ PID 2B ][ length 2B ][ unit ID 1B ][ function code 1B ][ data ]

There is no CRC in Modbus TCP — the TCP/IP stack already guarantees integrity.

For a byte-by-byte breakdown of each format, including how to spot an exception response, see Decoding Modbus Frames by Hand.

Byte ordering inside a register

One register is 16 bits, transmitted big-endian: the high byte first. The request 01 03 00 00 00 02 C4 0B reads a starting address of 0x0000 and a quantity of 0x0002 — each 16-bit field is high byte, then low byte.

The trouble starts when a value does not fit in 16 bits. A 32-bit float or integer spans two consecutive registers, and the order of those two words is not specified by the standard. Some devices store the high word first (big-endian, the conventional choice); others store it second — the infamous word swap. When a temperature shows up as 1.9e-38 instead of 23.5, the first thing to check is whether the two registers are swapped.

float 23.5  = 0x41BC0000
  big-endian  word order:  reg[0] = 0x41BC,  reg[1] = 0x0000   ← most vendors
  swapped     word order:  reg[0] = 0x0000,  reg[1] = 0x41BC   ← word swap

Good devices document this. Most don't. When in doubt, read the two registers and try both orderings — one of them will produce a physically plausible value.

Worked example: reading an OHTS1022 soil sensor

Theory is fine, but a protocol guide earns its keep when it reads real hardware. The OHTS1022 is an IP68 soil sensor that measures volumetric water content (VWC) and temperature over RS-485, Modbus-RTU, at 9600 baud, default address 0x01. Its register map is typical of the thousands of Modbus sensors in the field:

RegisterContentTypeConversion
0x0000Soil temperaturesigned 16-bitvalue / 10 → °C
0x0001Soil moisture (VWC)unsigned 16-bitvalue / 10 → %
0x0030Device addressunsigned 16-bitwrite via FC 0x06

Two things to notice before we read it. First, the temperature is signed two's complement — a negative soil temperature arrives as a large unsigned number, and casting it naively to a positive value is a classic bug. Second, both values carry an implicit scale factor of 10: the raw register holds tenths of a unit, so 356 means 35.6 %.

Here is the whole read, written with oms-modbus. The program emulates the sensor in memory — the same code runs unmodified against the real device by swapping the transport for a serial port — then decodes the raw registers exactly as the datasheet specifies.

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ── Emulate an OHTS1022 soil sensor ─────────────────────────────
    // Register map (from the OHTS1022 datasheet, read via FC 0x03):
    //   0x0000  soil temperature  — signed 16-bit, value / 10 (°C)
    //   0x0001  soil moisture     — unsigned 16-bit, value / 10 (% VWC)
    // Datasheet example: 0xFFDD -> -35 -> -3.5 °C, 0x0164 -> 356 -> 35.6 %
    let store = Arc::new(SlaveStore::with_holding_registers(&[
        (0, 0xFFDD),
        (1, 0x0164),
    ]));

    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();
    });

    // ── Attach a passive WireTap so we can see the raw frames ───────
    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);

    // ── Read two holding registers starting at 0x0000 ───────────────
    let regs = client.read_holding_registers(1, 0, 2).await?;
    println!("Raw registers: {regs:?}\n");

    // ── Decode using the OHTS1022 register map ──────────────────────
    let raw_temp = regs[0] as i16; // 0xFFDD -> -35 (signed two's complement)
    let raw_moisture = regs[1]; // 0x0164 -> 356

    let temperature = raw_temp as f32 / 10.0; // -3.5 °C
    let moisture = raw_moisture as f32 / 10.0; // 35.6 %

    println!("Decoded values:");
    println!("  Soil temperature : {temperature:.1} °C");
    println!("  Soil moisture    : {moisture:.1} %\n");

    // ── Show the frames that crossed the bus ────────────────────────
    println!("Captured frames ({}) :", cap.count_requests() + cap.count_responses());
    for pkt in cap.drain() {
        println!("{pkt}");
    }

    Ok(())
}

Running it produces:

Raw registers: [65501, 356]

Decoded values:
  Soil temperature : -3.5 °C
  Soil moisture    : 35.6 %

Captured frames (2) :
[TX] 2026-08-13T10:15:30.004221    8B  [01 03 00 00 00 02 C4 0B]
[RX] 2026-08-13T10:15:30.004885    9B  [01 03 04 FF DD 01 64 5A 66]

Now the whole theory snaps together. The captured TX frame is the RTU ADU we drew earlier:

01          slave address (default 0x01)
03          function code — read holding registers
00 00       starting offset 0x0000 (the temperature register)
00 02       quantity 2 (read temperature + moisture)
C4 0B       CRC-16, low byte first

The RX frame is the response. 03 echoes the function code, 04 is the byte count, then two 16-bit registers, big-endian: FF DD is the temperature and 01 64 the moisture.

01          slave address
03          function code (echoed)
04          byte count — 4 data bytes follow
FF DD       register 0 = 0xFFDD = -35 (signed)  → -3.5 °C
01 64       register 1 = 0x0164 = 356           → 35.6 %
5A 66       CRC-16, low byte first

0xFFDD is the giveaway. As an unsigned u16 it is 65501; as a signed i16 it is -35. The program's regs[0] as i16 performs that reinterpretation, then divides by ten. That one cast — and the division that follows — is the difference between a correct -3.5 °C and a nonsensical 6550.1 °C.

The WireTap capture is optional; it is attached with with_tap() purely so we can inspect the bytes. Remove it and the client is identical to any production Modbus read. To run this against a real sensor, replace the in-memory duplex transport with a serial port opened through tokio-serial, and set the bus timing to match the device — both covered in Monitor a Modbus RTU Bus Without Disrupting It.

The 80/20 rule

You do not need the full Modbus specification to be productive. You need to know the four tables, the 40001-vs-offset-0 conversion, the difference between a PDU and an ADU, and that a negative number arrives as a large unsigned value that needs a sign-extending cast. That is most of the protocol — and every mistake a senior engineer has ever made with Modbus lives somewhere in those four sentences.

The rest is detail you can look up when a specific device needs it: exception codes, diagnostics sub-functions, and the exact timing of the 3.5-character silence on RS-485.


This guide 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 Function Codes 01–06 — the verbs of Modbus, each with request/response byte layouts. Decoding Modbus Frames by Hand — the byte-by-byte frame breakdown this guide sketches.

modbus modbus protocol modbus tutorial modbus rtu frame format modbus addressing holding registers function code rs485 modbus tcp rust tutorial iiot