Modbus is 46 years old. When it was designed in 1979, there was one physical layer: RS-485 (or RS-232 for point-to-point). Today, Modbus runs over serial ports, TCP sockets, USB virtual COM ports, Bluetooth SPP, and RS-485-to-Ethernet gateways. The protocol is the same; the transport is not.
Most Modbus libraries pick one transport and stick with it — a TCP client that cannot talk serial, or a serial client that cannot talk TCP. oms-modbus was designed from the start to be transport-generic: one API, one set of protocol implementations, and a transport layer that works with anything that can send and receive bytes.
The Trait That Makes It Work
The entire design rests on a single Rust trait — but it is not a Transport trait. It is ModbusClient, the client interface, and it has exactly one required method:
/// Transport-independent Modbus client interface (from src/client.rs).
///
/// Every function code — read_coils, read_holding_registers,
/// write_single_register, and the rest — is a default method that builds
/// a `Request` and delegates to `call`. Transports implement only `call`.
#[async_trait]
pub trait ModbusClient: Send + Sync {
async fn call(&self, slave: u8, request: Request<'_>) -> Result<Response, ModbusError>;
// … 11 function-code methods, all with default implementations …
}
Every higher-level call funnels through call(). A client sends a raw Request to a given slave address and gets back a decoded Response — or a structured ModbusError. The read_holding_registers, write_single_register, and diagnostic methods you actually call in application code are all default implementations built on top of call(). A new transport only has to implement call() once, and it gets all 11 function codes for free.
The single-required-method shape is what makes the design work: the protocol logic (PDU encode/decode, exception handling, register mapping) lives in frame.rs and client.rs, and no transport duplicates it.
Three Clients, Two Generic, One Concrete
The other half of the story is how the transports are typed. RTU and ASCII clients are generic over the transport; TCP is concrete:
// RTU and ASCII clients are generic over ANY byte transport:
pub struct RtuClient<T> where T: AsyncRead + AsyncWrite + Unpin + Send + 'static { /* … */ }
pub struct AsciiClient<T> where T: AsyncRead + AsyncWrite + Unpin + Send + 'static { /* … */ }
// TCP is concrete — it always speaks to a tokio::net::TcpStream:
pub struct TcpClient { /* … */ }
There is no Transport trait with transmit/receive/baud_rate methods to implement. Instead, RtuClient<T> and AsciiClient<T> accept any T: AsyncRead + AsyncWrite. That bound is the entire abstraction. A serial port from tokio-serial, a tokio::net::TcpStream, a Unix socket, a pipe, or an in-memory tokio::io::duplex channel — they all satisfy AsyncRead + AsyncWrite, so they all work as a Modbus transport with zero glue code.
What differs between the three is the framing, not the transport:
| Client | Framing | Checksum | Notes |
|---|---|---|---|
tcp::TcpClient | MBAP header (7 bytes) | none (TCP guarantees delivery) | Concrete — always TcpStream |
rtu::RtuClient<T> | [slave][PDU][CRC lo][CRC hi] | CRC-16 | Generic over any byte stream |
ascii::AsciiClient<T> | :hexdataLRC\r\n | LRC | Generic; 2× wire size, human-readable |
The CRC/LRC is inserted and checked by codec.rs — not by the transport. RtuClient adds a 2-byte CRC and enforces the 3.5T silent interval; AsciiClient hex-encodes every byte and appends an LRC; TcpClient prepends an MBAP header. The transport underneath just moves bytes.
The SniffIo Layer: Where WireTap Attaches
rtu::with_options(transport, opts) and ascii::with_options(transport, opts) don't wrap your transport directly. They wrap it in SniffIo<T>, a transparent AsyncRead + AsyncWrite pass-through that copies every byte read or written to your configured WireTap:
// with_options wraps T in SniffIo<T> so the WireTap sees raw bus traffic.
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(stream, opts); // returns RtuClient<SniffIo<T>>
If no tap is configured, SniffIo is a near-zero-overhead pass-through. The important consequence: the transport abstraction (AsyncRead + AsyncWrite) and the observation abstraction (WireTap) compose — you can watch the raw bytes of any transport without that transport knowing it is being watched, and without writing a separate sniffer for serial vs. TCP.
The Compile-Time Advantage
In an object-oriented language, transport polymorphism usually means virtual dispatch — a vtable lookup on every read and write. oms-modbus uses Rust's generic type parameters instead: RtuClient<T> is monomorphized at compile time for each concrete T.
An application that uses only RtuClient<SerialStream> never compiles the TcpStream code path at all — the linker strips it. The read_holding_registers call is statically dispatched and inlined. The result: zero runtime overhead for the transport abstraction. The framing code compiles to the same machine code it would if you had hard-coded the serial port.
The trade-off is compile time and code size: each concrete transport instantiates a copy of the client. For an industrial library where the number of transports is small (serial, TCP, duplex) and the runtime is long, that is the right trade.
Any Transport, in Practice
The examples/generic_transport.rs example proves the point by running RTU over a TCP stream — RTU framing on a TCP connection, no serial port anywhere in sight — then the same API over an in-memory duplex channel, then ASCII over the same. Here is a trimmed-down version of it, self-contained and runnable:
use oms_modbus::*;
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>> {
// ── 1. RTU over TCP: RTU framing on a real TCP connection ─────────
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let listener = tokio::net::TcpListener::bind(addr).await?;
let bind_addr = listener.local_addr()?;
let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 1234), (1, 5678)]));
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
rtu::RtuServer::new(stream).serve_forever(store).await.ok();
});
tokio::time::sleep(Duration::from_millis(50)).await;
let stream = tokio::net::TcpStream::connect(bind_addr).await?;
let client = rtu::with_options(stream, ClientOptions::default());
let regs = client.read_holding_registers(1, 0, 2).await?;
println!("RTU over TCP: {regs:?}");
// ── 2. RTU over an in-memory duplex stream (no hardware) ──────────
let (client_stream, server_stream) = tokio::io::duplex(1024);
let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 42)]));
let server = rtu::RtuServer::new(server_stream);
tokio::spawn(async move { server.serve_forever(store).await.ok(); });
let client = rtu::RtuClient::with_timeout(client_stream, Duration::from_secs(3));
let regs = client.read_holding_registers(1, 0, 1).await?;
println!("RTU over duplex: {regs:?}");
// ── 3. ASCII over the same duplex stream (same API, LRC framing) ──
let (client_stream, server_stream) = tokio::io::duplex(1024);
let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 99)]));
let server = ascii::AsciiServer::new(server_stream);
tokio::spawn(async move { server.serve_forever(store).await.ok(); });
let client = ascii::with_options(client_stream, ClientOptions::default());
let regs = client.read_holding_registers(1, 0, 1).await?;
println!("ASCII over duplex: {regs:?}");
Ok(())
}
Output:
RTU over TCP: [1234, 5678]
RTU over duplex: [42]
ASCII over duplex: [99]
The three calls use the exact same read_holding_registers method from the ModbusClient trait. The only difference is the client type and the transport you hand it. That is the whole design in one example.
What Generic Transport Enables
A transport-generic Modbus library is more than an API convenience. It enables three things that protocol-specific libraries cannot:
1. Protocol Bridging
An RS-485-to-MQTT gateway needs to read Modbus registers over serial and publish them over TCP. With oms-modbus, the gateway has one RtuClient<SerialStream> on the serial side and a separate MQTT client on the network side. The protocol logic is the same; the transports are different.
2. Diagnostic Transparency
When a sensor works over TCP but fails over RTU, the bug is in the transport — not the protocol. With oms-modbus, you swap the serial stream for a TCP stream (or a duplex stream in a unit test) and re-run the same code. If the tests pass on TCP and fail on RTU, the problem is the serial configuration, the cable, or the sensor's RS-485 implementation. The protocol is not the variable.
3. Future-Proofing
Modbus over TLS, Modbus over CAN, Modbus over LoRa — these are not hypothetical. Industrial IoT is moving toward encrypted, long-range, mesh-topology physical layers, and Modbus (the protocol, not the transport) will run on all of them. A transport-generic library means adding a new transport is a matter of handing the client a new AsyncRead + AsyncWrite value — not a rewrite.
The generic-transport design is demonstrated in the runnable generic_transport.rs example, which runs RTU and ASCII over TCP, duplex streams, and serial ports. Full API documentation is at docs.rs/oms-modbus. If you need a transport for a physical layer we have not covered, the AsyncRead + AsyncWrite bound means you can wire it up yourself — no trait to implement, no PR required.
Read next: WireTap Quick Start — attach passive monitoring to any of these transports in 15 minutes. The Bus Monitor guide — why microsecond timestamps matter for diagnostics.