Modbus TCP Framing: MBAP, Transaction IDs, and Gateway Mode

The Modbus TCP header, decoded byte by byte — transaction ID correlation, the Length field, unit IDs, and the TcpConfig options that make non-standard gateways interoperate.

OrangeHorse Engineering Team 8 min read

Modbus TCP Framing: MBAP, Transaction IDs, and Gateway Mode

Modbus RTU frames itself with a CRC and a silent interval. Modbus TCP has neither — TCP is a reliable byte stream, so there are no natural frame boundaries. Something has to say "this request ends here and the next begins here." That something is the MBAP header (Modbus Application Protocol header), a fixed 7-byte prefix prepended to every Modbus TCP PDU.

Most people use Modbus TCP for years without looking at those 7 bytes. Then they meet a gateway that answers but misbehaves, or a device that half-works, and suddenly the header matters. This article decodes the MBAP byte by byte and walks through the configuration knobs that most libraries hide.

The 7-byte prefix

A Modbus TCP frame is the MBAP header followed by a PDU. The header:

OffsetFieldSizeMeaning
0Transaction ID2Echoed in the response so a client can match it to a request
2Protocol ID20 for Modbus (reserved for future multi-protocol use)
4Length2Byte count of everything after this field (UID + PDU)
6Unit ID1The slave address, carried over from RTU

The header exists for two reasons. First, framing: Length tells the receiver how many bytes follow, so it can slice a continuous TCP stream into discrete PDUs without a CRC or a silent interval. Second, correlation: Transaction ID lets one client keep multiple requests in flight and match each response to the request that produced it.

Here is the header in front of a real read request (read holding registers, address 0, quantity 2):

00 01   00 00   00 07   01   01 03 00 00 00 02
└─TID─┘ └Proto┘ └Len─┘ └UID┘ └──── PDU ────────┘
  • 00 01 — Transaction ID 1 (the first request of an auto-incrementing client).
  • 00 00 — Protocol ID 0, always Modbus.
  • 00 07 — Length 7: one byte of Unit ID plus six bytes of PDU.
  • 01 — Unit ID 1, the slave address.
  • 01 03 00 00 00 02 — the PDU, where the leading 01 is the repeated unit id (see below) and 03 00 00 00 02 is function code 3 with address 0 and quantity 2.

The Transaction ID: matching requests to responses

Transaction ID (TID) is Modbus TCP's answer to a problem RTU doesn't have. On a half-duplex RTU bus, only one request is in flight at a time, so a response is unambiguously "the answer to what I just sent." TCP is full-duplex: a client can pipeline several requests before the first response arrives. Without a correlation key, a response would be ambiguous.

So the spec says: a server must copy the request's TID into its response. The client keeps a counter, stamps each request, and matches the incoming response's TID back to the original request. oms-modbus exposes this as TidMode:

  • TidMode::Fixed(u16) — always send the same value (the default is Fixed(0), which is what the spec calls for when a client sends one request at a time).
  • TidMode::Auto — auto-increment from 1 on each request, wrapping at u16::MAX. This is what a pipelining client uses so responses stay matchable.

The one-sentence rule: if you send requests sequentially and await each before the next, Fixed(0) is fine and is the interoperable default. If you pipeline, use Auto.

The Length field, and the devices that get it wrong

Length counts the bytes after the Length field — that is, the Unit ID byte plus the PDU. It is what lets the receiver reassemble frames from a stream, so getting it wrong breaks everything: a short Length truncates the PDU, a long Length makes the receiver wait for bytes that never come.

Some non-standard devices compute Length as PDU bytes only, excluding the Unit ID. That is a one-byte discrepancy that manifests as a hung read or a truncated payload. oms-modbus models both as LengthMode:

  • LengthMode::Standard — Length = 1 (Unit ID) + PDU bytes. This is the spec and what virtually all devices use.
  • LengthMode::PduOnly — Length = PDU bytes only, for the outliers.

If a device responds to a standard client with a one-byte-short frame, PduOnly is the first thing to try.

Unit ID in the body: gateway mode

The most common real-world source of MBAP confusion is a TCP-to-RTU gateway. It terminates Modbus TCP, extracts the slave address from the MBAP header, and re-addresses the request onto an RS-485 bus. So far so standard. But a minority of gateways expect the slave address twice: once in the MBAP header and once as the first byte of the PDU body. That is because some legacy gateway firmware simply strips the 7-byte header and forwards everything after it — and "everything after it" needs the address already present to be a valid RTU frame.

oms-modbus exposes this as the unit_id_in_body flag on TcpConfig:

  • unit_id_in_body: false — the Unit ID lives only in the MBAP header (spec standard).
  • unit_id_in_body: true — the Unit ID is also written as the first byte of the PDU body, so a gateway that strips the header still gets a complete RTU frame.

There is a decoding subtlety worth knowing: a body that begins with the unit id is ambiguous. If the client sent unit id 1 and the PDU's first byte is 03 (function code 3), a decoder can't always tell whether byte 0 is a unit id followed by function code 3, or function code 01 followed by more bytes. oms-modbus resolves this by checking whether body byte 0 matches the MBAP Unit ID and whether byte 1 is a valid function code — and when both interpretations parse, it prefers the unit_id_in_body reading. The effect is that a correctly-configured client on either side of the flag still decodes frames.

Tying it together with TcpConfig

All three knobs live on one struct:

pub struct TcpConfig {
    pub tid: TidMode,             // Fixed(u16) or Auto
    pub unit_id_in_body: bool,    // repeat the UID in the PDU body
    pub length_mode: LengthMode,  // Standard or PduOnly
}

Two presets cover the overwhelming majority of deployments:

  • TcpConfig::standard()TidMode::Fixed(0), UID in header only, LengthMode::Standard. The spec default; interoperates with tokio-modbus and almost everything else.
  • TcpConfig::gateway()TidMode::Auto, unit_id_in_body: true, LengthMode::Standard. The common combination for TCP↔RTU gateways.

If a gateway "answers but the data is garbage," try gateway(). If the Length looks one byte off, try LengthMode::PduOnly. If you are pipelining requests, switch to TidMode::Auto.

Seeing the header for yourself

The demo below runs a server and client both in gateway mode and attaches a BusCapture tap, so you can read the actual MBAP bytes off the wire:

use oms_modbus::*;
use oms_modbus::tcp::TcpConfig;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A server in "gateway" mode: unit-id repeated in the body + auto TID.
    let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 1234), (1, 5678)]));
    let server = tcp::TcpServer::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
        .await?
        .with_config(TcpConfig::gateway());
    let addr = server.local_addr()?;
    tokio::spawn(async move { server.serve_forever(store).await.ok(); });

    // A matching client with a tap so we can see the raw MBAP bytes.
    let cap = Arc::new(BusCapture::unbounded());
    let opts = ClientOptions::default()
        .with_timeout(Duration::from_secs(3))
        .with_tap(cap.clone());
    let client = tcp::with_options(addr, opts).await?.with_config(TcpConfig::gateway());

    let regs = client.read_holding_registers(1, 0, 2).await?;
    println!("read {regs:?}");

    for pkt in cap.drain() {
        println!("{pkt}");
    }

    Ok(())
}

The tap dumps the request and response exactly as they crossed the transport boundary (timestamps differ per run):

read [1234, 5678]
[TX] 2026-08-19T02:14:21.123711   13B  [00 01 00 00 00 07 01 01 03 00 00 00 02]
[RX] 2026-08-19T02:14:21.123916   14B  [00 01 00 00 00 08 01 01 03 04 04 D2 16 2E]

Decode the request: 00 01 (TID 1, auto-incremented from 1) · 00 00 (Protocol 0) · 00 07 (Length 7) · 01 (Unit ID 1) · then the body 01 03 00 00 00 02 — the repeated unit id, function code 3, address 0, quantity 2. The response mirrors it: TID 00 01 echoed, Length 00 08, and the body 01 03 04 04 D2 16 2E — unit id, function code 3, byte count 4, then the two registers 0x04D2 (1234) and 0x162E (5678) in big-endian order.

Two details are worth pausing on. First, the TID in the response matches the request — that is the correlation doing its job. Second, the response Length is 8, not 7, because the PDU grew by the byte count and data — the Length field is computed per frame, never hard-coded.

What to remember

  • The MBAP header is 7 bytes: Transaction ID (2), Protocol ID (2, always 0), Length (2), Unit ID (1).
  • Length counts the bytes after itself — UID + PDU — and is how a stream is sliced into frames.
  • The Transaction ID is echoed in the response; use TidMode::Auto only when you pipeline requests.
  • TcpConfig::gateway() (auto TID + unit_id_in_body + standard length) fixes the "gateway answers but data is wrong" class of bugs.
  • If frames look one byte short, the device is likely computing Length as PduOnly.

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: Building a Modbus Gateway — implementing the Service trait to bridge TCP and RTU. One Modbus Client, Any Transport — how the same client runs over serial, TCP, and in-memory channels.

modbus modbus tcp mbap modbus transaction id modbus gateway rust iiot modbus framing