Decoding Modbus Frames by Hand — What Every Byte Tells You About the Bus

Byte-by-byte breakdown of Modbus RTU, TCP, and ASCII frames. Learn to identify function codes, spot CRC errors, and read raw captures the way WireTap sees them.

OrangeHorse Engineering Team 11 min read

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

LayerRTUTCP (MBAP)ASCII
FramingSilent interval (≥3.5T)TCP stream (MBAP header)Start : / End \r\n
Address1 byte (1–247)1 byte in MBAP header2 hex chars
Error checkCRC-16 (2 bytes, little-endian)TCP checksum (no Modbus CRC)LRC (1 byte, hex-encoded)
Min frame4 bytes8 bytes (7 header + 1 FC)9 chars (:01 03 00 00 00 01 FA\r\n)
Max ADU256 bytes260 bytes515 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:

CodeNameRequest PDUResponse PDU
0x01Read Coilsaddr(2) + qty(2)byte_count(1) + bits(N)
0x02Read Discrete Inputsaddr(2) + qty(2)byte_count(1) + bits(N)
0x03Read Holding Registersaddr(2) + qty(2)byte_count(1) + regs(2×N)
0x04Read Input Registersaddr(2) + qty(2)byte_count(1) + regs(2×N)
0x05Write Single Coiladdr(2) + val(2, 0xFF00/0x0000)echo request
0x06Write Single Registeraddr(2) + val(2)echo request
0x08Diagnosticsub-func(2) + data(2)echo
0x0FWrite Multiple Coilsaddr(2) + qty(2) + bc(1) + bits(N)addr(2) + qty(2)
0x10Write Multiple Registersaddr(2) + qty(2) + bc(1) + regs(2×N)addr(2) + qty(2)
0x16Mask Write Registeraddr(2) + and(2) + or(2)echo
0x17Read/Write Multiple Regsaddr_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)ValueMeaning
001Slave address — same as request
103Function code — echoed from request
206Byte count — 6 bytes of register data follow
3–400 64Register 0 = 100 (0x0064)
5–600 C8Register 1 = 200 (0x00C8)
7–801 2CRegister 2 = 300 (0x012C)
9–10D1 0ECRC-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)FieldThis FrameMeaning
0–1Transaction ID00 01Auto-incrementing request ID for matching responses
2–3Protocol ID00 00Always 0 for Modbus
4–5Length00 05Bytes remaining (UID + PDU = 1 + 4 = 5)
6Unit ID01Slave address (called "Unit ID" in TCP spec)
7Function code03Read Holding Registers — same PDU as RTU
8–9Start addr00 00Register address 0
10–11Quantity00 03Read 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
SegmentValueMeaning
:Start delimiter
01Slave address (2 hex chars)
03Function code
0000Starting address
0003Quantity
F9LRC (Longitudinal Redundancy Check)
\r\nEnd 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]
ByteValueMeaning
001Slave address
183Function code + 0x80 → 0x03 with exception bit set
202Exception code: Illegal Data Address
3–491 2ECRC

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:

CodeNameTypical Cause
1Illegal FunctionDevice does not support FC 0x10 (Write Multiple)
2Illegal Data AddressReading register 60000 when the sensor has only 128 registers
3Illegal Data ValueWriting 0xFFFF to a register that only accepts 0–10000
4Server Device FailureInternal sensor fault — reboot the device
6Server Device BusyPLC 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:

  1. Is the frame long enough? Minimum: 4 bytes for RTU (slave + FC + 2 CRC), 8 for TCP (7 MBAP + FC), 9 chars for ASCII.
  2. 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.
  3. What is the function code? Byte 1 in RTU/ASCII, byte 7 in TCP. Bit 7 set = exception.
  4. 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.
  5. Is the byte count consistent? For read responses: byte_count should equal register_count × 2 and be even. An odd byte count is a protocol error.
  6. 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.

modbus modbus-rtu modbus-tcp wire-protocol frame-decode rust IIoT