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.
| Table | Access | Item size | Typical content | Read | Write |
|---|---|---|---|---|---|
| Coils | read/write | 1 bit | relays, digital outputs, flags | FC 01 | FC 05, 15 |
| Discrete Inputs | read-only | 1 bit | limit switches, alarm contacts | FC 02 | — |
| Input Registers | read-only | 16 bit | ADC readings, measured values | FC 04 | — |
| Holding Registers | read/write | 16 bit | setpoints, config, sensor values | FC 03 | FC 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 range | Table | Offset → Reference |
|---|---|---|
00001–09999 | Coils | offset 0 = reference 00001 |
10001–19999 | Discrete Inputs | offset 0 = reference 10001 |
30001–39999 | Input Registers | offset 0 = reference 30001 |
40001–49999 | Holding Registers | offset 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:
| Register | Content | Type | Conversion |
|---|---|---|---|
0x0000 | Soil temperature | signed 16-bit | value / 10 → °C |
0x0001 | Soil moisture (VWC) | unsigned 16-bit | value / 10 → % |
0x0030 | Device address | unsigned 16-bit | write 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.