Generic Transport in Rust: Modbus RTU over TCP, Serial, and Beyond

How oms-modbus uses Rust's trait system to build a single ModbusClient API across TCP, RTU, and ASCII transports — without virtual dispatch, runtime overhead, or per-protocol code paths.

OrangeHorse Engineering Team 7 min read

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, Zigbee, 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 pluggable transport layer that works with anything that can send and receive bytes.

The Trait That Makes It Work

The entire transport-generic design rests on a single Rust trait:

#[async_trait]
pub trait Transport: Send + Sync {
    /// Send raw bytes to the bus and return the raw response bytes.
    ///
    /// The transport owns framing, timing, and error detection.
    /// It MUST enforce the 3.5T silent interval after transmission.
    async fn transmit(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError>;

    /// Receive raw bytes without sending — for passive WireTap monitoring.
    async fn receive(&mut self) -> Result<Vec<u8>, TransportError>;

    /// The channel type: Serial, Tcp, or Ascii.
    fn channel_type(&self) -> ChannelType;

    /// Baud rate for timing calculations (0 for TCP).
    fn baud_rate(&self) -> u32;
}

Five methods. Four of them are one-liners for most implementations. The single complex method — transmit — encapsulates the entire transport-specific logic: opening a connection, sending bytes, waiting for a response, and enforcing the 3.5T silent interval. Everything above this trait is pure Modbus protocol logic with no knowledge of whether the bytes came from a serial port or a socket.

Three Transports, Zero Runtime Dispatch

oms-modbus ships with three transport implementations:

RTU Transport

The RTU transport wraps a serial port. It opens the port with the configured baud rate, parity, and stop bits, then implements transmit as:

  1. Encode the PDU into an ADU with CRC-16
  2. Write the ADU to the serial port
  3. Enforce the 3.5T silent interval
  4. Read bytes until a valid CRC-terminated response is received or a timeout expires

The CRC insertion and timing enforcement are transport-specific — TCP does not use CRC-16 at the ADU level, and it does not have a baud rate. The RTU transport handles both.

TCP Transport

The TCP transport wraps a tokio::net::TcpStream. The transmit implementation is simpler than RTU because Modbus TCP uses the MBAP header instead of CRC-16 for framing:

  1. Build the MBAP header (transaction ID, protocol ID, length, unit ID)
  2. Prepend it to the PDU
  3. Write the framed message to the socket
  4. Read the MBAP header + PDU response

No CRC. No baud rate. No timing enforcement. The TCP transport knows this. The ModbusClient that calls transport.transmit() does not.

ASCII Transport

Modbus ASCII is the red-headed stepchild of the protocol family — few new devices use it, but industrial equipment installed in the 1990s and still running today speaks nothing else. Instead of binary framing, Modbus ASCII frames begin with a colon (:) and end with a carriage-return line-feed (\r\n). The data bytes are transmitted as hexadecimal ASCII characters — one byte of data becomes two bytes on the wire.

The ASCII transport handles the hex encoding and framing characters. The ModbusClient never knows the difference.

The Compile-Time Advantage

In an object-oriented language like C++ or Java, transport polymorphism typically means virtual dispatch — a vtable lookup on every transmit call, an allocation for the transport object, and runtime branches everywhere the protocol needs to know which transport it is using.

oms-modbus uses Rust's generic type parameters instead:

pub struct ModbusClient<T: Transport> {
    transport: T,
}

Every instantiation of ModbusClient<RtuTransport> is monomorphized at compile time. The compiler statically dispatches every call to transport.transmit(), inlines it into the protocol logic, and eliminates dead code paths for transports that are not used. An application that only needs RTU never compiles the TCP or ASCII code at all — the linker strips it.

The result: zero runtime overhead for transport abstraction. The protocol layer compiles to exactly the same machine code as if you had hard-coded the serial port logic inline.

Beyond the Three Built-In Transports

The Transport trait is public and exported. Anyone can implement it for a custom physical layer:

// Bluetooth SPP transport for wireless Modbus sensors
struct BluetoothTransport {
    stream: BluetoothStream,
}

#[async_trait]
impl Transport for BluetoothTransport {
    async fn transmit(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
        // Modbus RTU framing over Bluetooth serial port profile
        self.stream.write_all(request).await?;
        let mut buf = vec![0u8; 256];
        let n = self.stream.read(&mut buf).await?;
        buf.truncate(n);
        Ok(buf)
    }

    async fn receive(&mut self) -> Result<Vec<u8>, TransportError> {
        let mut buf = vec![0u8; 256];
        let n = self.stream.read(&mut buf).await?;
        buf.truncate(n);
        Ok(buf)
    }

    fn channel_type(&self) -> ChannelType { ChannelType::Serial }
    fn baud_rate(&self) -> u32 { 115_200 }
}

With 18 lines of transport glue, you get a fully functional Modbus client over Bluetooth — complete with all 11 function codes, automatic CRC-16 insertion, and passive bus monitoring. The protocol layer is completely unaware that the bytes are coming over Bluetooth instead of a USB serial adapter.

The Async Decision

oms-modbus uses async/await for all I/O. This was a deliberate choice with trade-offs:

The case for async:

  • A diagnostic IDE needs to poll multiple sensors concurrently while decoding a live WireTap stream and writing to a log file — async makes this trivial with tokio::select!
  • TCP connections benefit from non-blocking I/O — a single async runtime can handle dozens of concurrent Modbus TCP connections without thread-per-connection overhead
  • The async ecosystem (Tokio) provides structured concurrency, timeouts, and cancellation that are harder to implement correctly with raw threads

The case against async:

  • Async adds a dependency on Tokio (or another runtime), which cannot run on bare-metal embedded targets
  • Compile times increase — async state machines add complexity to generics-heavy code
  • The #[async_trait] macro adds a layer of indirection that complicates stack traces

Our compromise: the Transport trait uses #[async_trait] for the public API, but each transport implementation is a thin async wrapper over a synchronous core. The RTU transport's internal functions — CRC computation, ADU framing, byte-stuffing — are all synchronous. Only the actual I/O calls are async. This means the core protocol logic can be extracted and reused in a no_std embedded context with minimal refactoring when we add embedded support in a future release.

Testing Transport Genericity

The transport abstraction is tested with a mock transport that simulates RS-485 line conditions:

struct MockTransport {
    responses: VecDeque<Result<Vec<u8>, TransportError>>,
    latency: Duration,
    fail_every_n: Option<usize>,
    counter: usize,
}

The mock transport can:

  • Return a sequence of predetermined responses (for functional tests)
  • Simulate bus latency (for timing tests)
  • Inject CRC errors on every Nth frame (for error-handling tests)
  • Simulate timeouts (for retry logic tests)

All 440+ tests in the oms-modbus suite use the mock transport. Zero tests open a real serial port or TCP socket. This is a deliberate pattern: the transport abstraction lets you test the complete protocol layer in deterministic conditions, then test the transport implementations against real hardware separately.

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 to an MQTT broker over TCP. With oms-modbus, the gateway has one ModbusClient<RtuTransport> for the serial side and publishes the data via a separate MQTT client. 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 TcpTransport for RtuTransport and re-run the same test suite. 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 bus, 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 LoraTransport is 20 lines of code, not a rewrite.


Read the Complete API documentation for the Transport trait and its implementations. If you need a transport for a physical layer we haven't built yet, open a feature request — or implement the trait yourself and send a pull request.

modbus rust generic-transport traits async architecture embedded