A Modbus frame is only 8 bytes for the most common operation — reading a holding register. But those 8 bytes encode five distinct pieces of information: who is talking, what they want, where they want it, how much, and whether the message arrived intact. Every field has a story. Misread one byte and you chase a ghost for an afternoon.
This article walks through the three Modbus frame formats — RTU, TCP (MBAP), and ASCII — byte by byte. We build a small frame decoder that takes raw WireTap captures and extracts slave address, function code, data fields, and error flags. By the end you can read a hex dump the way a mechanic reads a spark plug: a quick glance tells you what is normal and what is about to fail.
The Three Frame Formats at a Glance
| Layer | RTU | TCP (MBAP) | ASCII |
|---|---|---|---|
| Framing | Silent interval (≥3.5T) | TCP stream (MBAP header) | Start : / End \r\n |
| Address | 1 byte (1–247) | 1 byte in MBAP header | 2 hex chars |
| Error check | CRC-16 (2 bytes, little-endian) | TCP checksum (no Modbus CRC) | LRC (1 byte, hex-encoded) |
| Min frame | 4 bytes | 8 bytes (7 header + 1 FC) | 9 chars (:01 03 00 00 00 01 FA\r\n) |
| Max ADU | 256 bytes | 260 bytes | 515 chars |
Each format carries the same PDU (Protocol Data Unit) — the function code and its payload. The difference is how the PDU is wrapped for reliable delivery over the physical medium.
RTU Frame: The Workhorse of RS-485
This is the frame you see most often on a half-duplex RS-485 bus. WireTap captures it exactly as it appears on the wire:
[TX] 2026-08-13T10:15:23.118442 8B [01 03 00 00 00 03 05 CB]
Eight bytes. Let us break them open.
Byte 0: Slave Address
01
The master addresses slave 0x01. Valid addresses are 1–247 (0x01–0xF7). Address 0 is reserved for broadcast (write-only, no response). Addresses 248–255 are reserved.
In a WireTap capture, the slave address tells you which device on a multi-drop bus is being polled. If you see [TX] frames with address 0x05 but no matching [RX], device 5 is not responding.
Byte 1: Function Code
03
0x03 = Read Holding Registers. This is the most common function code on any Modbus bus — every sensor, PLC, and energy meter uses it to expose data.
The 11 function codes oms-modbus supports:
| Code | Name | Request PDU | Response PDU |
|---|---|---|---|
| 0x01 | Read Coils | addr(2) + qty(2) | byte_count(1) + bits(N) |
| 0x02 | Read Discrete Inputs | addr(2) + qty(2) | byte_count(1) + bits(N) |
| 0x03 | Read Holding Registers | addr(2) + qty(2) | byte_count(1) + regs(2×N) |
| 0x04 | Read Input Registers | addr(2) + qty(2) | byte_count(1) + regs(2×N) |
| 0x05 | Write Single Coil | addr(2) + val(2, 0xFF00/0x0000) | echo request |
| 0x06 | Write Single Register | addr(2) + val(2) | echo request |
| 0x08 | Diagnostic | sub-func(2) + data(2) | echo |
| 0x0F | Write Multiple Coils | addr(2) + qty(2) + bc(1) + bits(N) | addr(2) + qty(2) |
| 0x10 | Write Multiple Registers | addr(2) + qty(2) + bc(1) + regs(2×N) | addr(2) + qty(2) |
| 0x16 | Mask Write Register | addr(2) + and(2) + or(2) | echo |
| 0x17 | Read/Write Multiple Regs | addr_r(2) + qty_r(2) + addr_w(2) + qty_w(2) + bc(1) + regs(2×N) | bc(1) + regs(2×N) |
You can identify the function code from a single byte in the capture. Given a PacketData::RawTx(bytes), the function code is always bytes[1] for RTU — and bytes[7] for TCP (after the 7-byte MBAP header).
Bytes 2–3: Starting Address
00 00
Big-endian u16 = 0x0000. The master wants to start reading at holding register address 0. Per the Modbus spec, addresses are 0-based on the wire — the "first register" is address 0. Some documentation uses 1-based "register numbers" (e.g. "40001" = holding register 1 = wire address 0). The 0-based convention is what the bytes encode.
Bytes 4–5: Quantity
00 03
Big-endian u16 = 0x0003. Read 3 registers starting at address 0. For function codes 0x01–0x04, quantity is the number of coils/registers. Max is 2000 coils or 125 registers per request per the Modbus spec.
Bytes 6–7: CRC-16
05 CB
The CRC-16 in little-endian byte order — low byte first (0x05), then high byte (0xCB). The actual CRC value is 0xCB05. oms-modbus computes this with polynomial 0xA001 (reflected) using a compile-time-generated 256-entry lookup table — about 8× faster than bit-by-bit computation.
You verify a frame by computing CRC over bytes [0..len-2] and comparing to bytes [len-2..]. WireTap captures the raw bytes including CRC, so you can validate the CRC of any captured frame:
let data = &bytes[..bytes.len() - 2];
let crc_received = u16::from_le_bytes([bytes[bytes.len() - 2], bytes[bytes.len() - 1]]);
let crc_computed = oms_modbus::codec::calculate_crc(data);
assert_eq!(crc_computed, crc_received, "CRC mismatch!");
The Response Frame
After the request, the slave responds:
[RX] 2026-08-13T10:15:23.170091 11B [01 03 06 00 64 00 C8 01 2C D1 0E]
Decoding byte by byte:
| Byte(s) | Value | Meaning |
|---|---|---|
| 0 | 01 | Slave address — same as request |
| 1 | 03 | Function code — echoed from request |
| 2 | 06 | Byte count — 6 bytes of register data follow |
| 3–4 | 00 64 | Register 0 = 100 (0x0064) |
| 5–6 | 00 C8 | Register 1 = 200 (0x00C8) |
| 7–8 | 01 2C | Register 2 = 300 (0x012C) |
| 9–10 | D1 0E | CRC-16 (little-endian) |
The byte count at position 2 tells you how many bytes of register data follow. Since each register is 2 bytes, byte_count = register_count × 2. A byte count that is not even is an immediate red flag — oms-modbus rejects these during PDU decoding.
TCP (MBAP) Frame: Modbus Leaves the Factory Floor
When Modbus travels over Ethernet, it sheds RTU's timing-based framing and adopts a 7-byte header called MBAP (Modbus Application Protocol):
[TX] 2026-08-13T10:15:25.000123 12B [00 01 00 00 00 05 01 03 00 00 00 03]
| Byte(s) | Field | This Frame | Meaning |
|---|---|---|---|
| 0–1 | Transaction ID | 00 01 | Auto-incrementing request ID for matching responses |
| 2–3 | Protocol ID | 00 00 | Always 0 for Modbus |
| 4–5 | Length | 00 05 | Bytes remaining (UID + PDU = 1 + 4 = 5) |
| 6 | Unit ID | 01 | Slave address (called "Unit ID" in TCP spec) |
| 7 | Function code | 03 | Read Holding Registers — same PDU as RTU |
| 8–9 | Start addr | 00 00 | Register address 0 |
| 10–11 | Quantity | 00 03 | Read 3 registers |
TCP has no CRC — the transport layer (TCP) guarantees delivery. The Length field at bytes 4–5 counts the bytes after itself (UID + PDU). Some gateways use a variant where Length counts only the PDU bytes (LengthMode::PduOnly in oms-modbus). Similarly, unit_id_in_body mode repeats the Unit ID as the first PDU byte for gateways that expect it there.
oms-modbus handles all these variants automatically. The TcpConfig builder lets you set tid, unit_id_in_body, and length_mode — but the defaults match the standard for normal use.
ASCII Frame: Human-Readable, Wire-Wasteful
Modbus ASCII encodes the same PDU as printable hex characters. Every byte becomes two ASCII characters, doubling the frame size:
:010300000003F9\r\n
| Segment | Value | Meaning |
|---|---|---|
: | Start delimiter | |
01 | Slave address (2 hex chars) | |
03 | Function code | |
0000 | Starting address | |
0003 | Quantity | |
F9 | LRC (Longitudinal Redundancy Check) | |
\r\n | End delimiter |
The LRC is the two's complement of the sum of all bytes: !(0x01 + 0x03 + 0x00 + 0x00 + 0x00 + 0x03) + 1 = 0xF9. All hex digits are uppercase. oms-modbus computes LRC as data.iter().fold(0u8, |acc, &b| acc.wrapping_add(b)).wrapping_neg().
ASCII is the slowest and least common variant today — it exists primarily for legacy systems where RS-485 runs through 7-bit data paths (the ASCII characters are all printable). You will rarely encounter it on new installations.
Error Frames: When the Slave Says No
Two kinds of errors appear in WireTap captures:
Exception Responses (Protocol-Level Rejection)
When a slave receives a valid request it cannot fulfill — wrong function code, out-of-range address — it responds with an exception:
[RX] 2026-08-13T10:15:30.000456 5B [01 83 02 91 2E]
| Byte | Value | Meaning |
|---|---|---|
| 0 | 01 | Slave address |
| 1 | 83 | Function code + 0x80 → 0x03 with exception bit set |
| 2 | 02 | Exception code: Illegal Data Address |
| 3–4 | 91 2E | CRC |
The exception bit (bit 7 of the function code) is the universal Modbus signal for "request rejected." Strip & 0x7F to recover the original function code. The third byte is the exception code:
| Code | Name | Typical Cause |
|---|---|---|
| 1 | Illegal Function | Device does not support FC 0x10 (Write Multiple) |
| 2 | Illegal Data Address | Reading register 60000 when the sensor has only 128 registers |
| 3 | Illegal Data Value | Writing 0xFFFF to a register that only accepts 0–10000 |
| 4 | Server Device Failure | Internal sensor fault — reboot the device |
| 6 | Server Device Busy | PLC is processing a long command, try again later |
I/O Errors (RawError in WireTap)
When the transport fails mid-read — a timeout, a disconnected cable, a USB serial glitch — WireTap records a PacketData::RawError:
[ERR] 2026-08-13T10:15:50.000789 3B [01 03 00] — timeout
The 3 bytes that arrived before the error are preserved. They are often enough to identify which slave was being polled when the bus failed.
Building a Frame Decoder with WireTap
The code above showed manual decoding. Here is a self-contained program that captures live traffic and prints human-readable frame summaries — function code names, register values, exception details — alongside the raw hex:
use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;
/// Map a function code byte to a human-readable name.
fn function_code_name(fc: u8) -> &'static str {
match fc & 0x7F {
1 => "Read Coils",
2 => "Read Discrete Inputs",
3 => "Read Holding Registers",
4 => "Read Input Registers",
5 => "Write Single Coil",
6 => "Write Single Register",
8 => "Diagnostic",
15 => "Write Multiple Coils",
16 => "Write Multiple Registers",
22 => "Mask Write Register",
23 => "Read/Write Multiple Regs",
_ => "Unknown",
}
}
/// Decode a raw Modbus frame and print a one-line summary.
fn decode_frame(dir: &str, bytes: &[u8]) {
if bytes.len() < 2 {
println!(" {dir} — frame too short ({}B)", bytes.len());
return;
}
let slave = bytes[0];
let fc = bytes[1];
let is_exception = fc & 0x80 != 0;
let fc_name = function_code_name(fc);
let suffix = if is_exception && bytes.len() >= 3 {
format!(" (exception code {})", bytes[2])
} else {
String::new()
};
println!(
" {dir} Slave {} → {} (0x{:02X}){}",
slave, fc_name, fc, suffix
);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Self-contained RTU bus — no hardware needed
let (client_stream, server_stream) = tokio::io::duplex(1024);
// Virtual slave with some registers
let store = Arc::new(SlaveStore::with_holding_registers(&[
(0, 100),
(1, 200),
(2, 300),
]));
let server = rtu::RtuServer::new(server_stream);
tokio::spawn(async move {
server.serve_forever(store).await.ok();
});
// Attach WireTap
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);
// Make several Modbus calls to generate a variety of frames
client.read_holding_registers(1, 0, 3).await?;
client.write_single_register(1, 0, 7777).await?;
client.read_coils(1, 0, 4).await?;
// Trigger an exception — slave at address 247 does not exist
let _ = client.read_holding_registers(247, 0, 1).await;
// Decode captured frames
let packets = cap.drain();
println!("═══ Captured {} frames ═══\n", packets.len());
for pkt in &packets {
match &pkt.data {
PacketData::RawTx(bytes) => decode_frame("[TX]", bytes),
PacketData::RawRx(bytes) => decode_frame("[RX]", bytes),
PacketData::RawError(bytes, err) => {
println!(" [ERR] — {err} ({} partial bytes)", bytes.len());
}
_ => {}
}
// Print the raw hex for reference
println!(" {pkt}\n");
}
println!(
"Stats — Requests: {}, Responses: {}, Errors: {}",
cap.count_requests(),
cap.count_responses(),
cap.count_errors()
);
Ok(())
}
Output from a typical run:
═══ Captured 8 frames ═══
[TX] Slave 1 → Read Holding Registers (0x03)
[TX] 2026-08-12T04:56:21.387379 8B [01 03 00 00 00 03 05 CB]
[RX] Slave 1 → Read Holding Registers (0x03)
[RX] 2026-08-12T04:56:21.387550 11B [01 03 06 00 64 00 C8 01 2C D1 0E]
[TX] Slave 1 → Write Single Register (0x06)
[TX] 2026-08-12T04:56:21.402258 8B [01 06 00 00 1E 61 41 82]
[RX] Slave 1 → Write Single Register (0x06)
[RX] 2026-08-12T04:56:21.402361 8B [01 06 00 00 1E 61 41 82]
[TX] Slave 1 → Read Coils (0x01)
[TX] 2026-08-12T04:56:21.418255 8B [01 01 00 00 00 04 3D C9]
[RX] Slave 1 → Read Coils (0x01)
[RX] 2026-08-12T04:56:21.418352 6B [01 01 01 00 51 88]
[TX] Slave 247 → Read Holding Registers (0x03)
[TX] 2026-08-12T04:56:21.434238 8B [F7 03 00 00 00 01 90 9C]
[RX] Slave 247 → Read Holding Registers (0x03)
[RX] 2026-08-12T04:56:21.434316 7B [F7 03 02 1E 61 B8 19]
Stats — Requests: 4, Responses: 4, Errors: 0
In this self-contained demo the single SlaveStore serves all addresses, so the slave-247 request also succeeds — the previously-written value 7777 (0x1E61) appears in the response. On a real multi-drop RS-485 bus where only device 1 is wired, an unreachable address would generate a PacketData::RawError after the timeout expires.
Frame Decoding Checklist
When staring at a hex dump, work through these questions in order:
- Is the frame long enough? Minimum: 4 bytes for RTU (slave + FC + 2 CRC), 8 for TCP (7 MBAP + FC), 9 chars for ASCII.
- What is the slave address? Byte 0 in RTU/ASCII, byte 6 in TCP. If you see TX frames to an address with no RX, the device is offline.
- What is the function code? Byte 1 in RTU/ASCII, byte 7 in TCP. Bit 7 set = exception.
- Does the CRC match? For RTU: compute CRC over bytes
[0..len-2], compare to bytes[len-2..]in little-endian. A CRC mismatch means corruption on the wire — EMI, ground loop, loose termination. - Is the byte count consistent? For read responses:
byte_countshould equalregister_count × 2and be even. An odd byte count is a protocol error. - What is the inter-frame gap? Subtract consecutive timestamps. The gap should exceed 3.5 character times (~1.75 ms at 19200 baud). Shorter gaps cause frame collisions.
The function_code_name() helper above is the starting point. For a production diagnostic tool you would extend it to decode the data fields: parse register addresses, extract coil values, verify byte counts. That is exactly what WireTap's PacketRecord + Display implementation does — the raw bytes are always available for custom analysis.
This article is part of the OMS Modbus open-source documentation. The frame decoder code is verified against oms-modbus v0.2.0 and runs without hardware — clone the crate and cargo run --example bus_monitor to see live frame capture. For a tutorial on attaching WireTap to your own Modbus client, see the Quick Start guide.