Building a Modbus Gateway in Rust: TCP Front-End, RTU Back-End
The most common Modbus deployment on real plant floors is not a single client talking to a single device. It is a gateway: Modbus TCP on the "upstream" side, Modbus RTU over RS-485 on the "downstream" side, with a dozen sensors and meters hanging off one twisted pair. The gateway is what lets a SCADA system, a cloud ingester, or a phone app reach serial devices that were never designed to speak IP.
A gateway is not just a socket relay. It has a real, subtle job: it must translate between two different failure models. On the TCP side, a client can time out waiting for a slave that isn't there. But a gateway cannot time out on behalf of a missing slave — it is the thing that answered. The Modbus spec gave gateways their own vocabulary for this, and this article builds a working gateway that speaks it correctly.
Two transports, two ways of saying "not there"
Before writing any code, pin down what a gateway has to convert:
| Side | Transport | How an absent slave surfaces |
|---|---|---|
| Upstream (SCADA → gateway) | Modbus TCP | Exception 11 — "Gateway Target Device Failed to Respond" |
| Downstream (gateway → devices) | Modbus RTU | Silence → a timeout on the gateway's own client |
The translation rule is simple and spec-correct:
- If the downstream times out (the slave is silent), the gateway answers upstream with exception 11 — "I tried, the target didn't answer."
- If the downstream is unreachable (the path itself is broken — a wrong serial port, a dead intermediate bridge), the gateway answers upstream with exception 10 — "Gateway Path Unavailable."
- If the downstream returns data or an exception, the gateway relays it verbatim, so the upstream client sees the real exception code the device produced, not one the gateway invented.
That last point trips people up. If a device rejects a read with exception 2 ("Illegal Data Address"), the gateway must not swallow it and substitute exception 11. Exception 2 is meaningful — it tells the upstream client "your request is wrong," not "the device is missing."
The one thing Service doesn't give you
oms-modbus splits the world cleanly: a client implements ModbusClient, a server implements Service. A gateway is a server on one side and a client on the other, so its core is a Service whose call method forwards to a downstream ModbusClient:
impl Service for Gateway {
async fn call(&self, request: Request<'_>) -> Result<Response, Exception> { ... }
}
Notice what's missing: the slave address. Service::call takes only a Request — no slave parameter. That is deliberate, because a normal server (one device, one address) doesn't need it. But a gateway fronts many addresses, so it must know which slave the incoming request was addressed to.
The answer is a task-local. The transport loop scopes the incoming slave id before calling your service, and you recover it the same way the library's own HookedService does:
let slave = oms_modbus::server::context::SLAVE_ID
.try_with(|&id| id)
.unwrap_or(0xFF);
try_with returns Option, because a service might be invoked outside a transport loop (for example, in a unit test that calls call directly). unwrap_or(0xFF) mirrors the library's own fallback — the broadcast address — and keeps the panic-free promise intact.
The gateway, end to end
The demo below is fully self-contained. It builds an in-memory RS-485 bus out of tokio::io::duplex, stands up an RTU server on one end with three distinct slave behaviours, wires an RTU client to the other end, and exposes that client through a TCP server. A plain Modbus TCP client then exercises the whole path: a normal read, a write, a rejected read, and an absent slave.
use oms_modbus::*;
use async_trait::async_trait;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
/// A gateway `Service`: forwards every request to a downstream `ModbusClient`
/// (the RTU client), translating transport failures into gateway exceptions.
#[derive(Clone)]
struct Gateway {
downstream: Arc<dyn ModbusClient>,
}
#[async_trait]
impl Service for Gateway {
async fn call(&self, request: Request<'_>) -> Result<Response, Exception> {
// `Service::call` has no slave parameter. The transport loop scopes
// the incoming slave id as a task-local before calling us, so a
// forwarding service can recover it the same way HookedService does.
let slave = oms_modbus::server::context::SLAVE_ID
.try_with(|&id| id)
.unwrap_or(0xFF);
match self.downstream.call(slave, request).await {
// Data *and* exception responses come back as `Ok` from a raw
// `call` — relay both verbatim so the client sees the real code.
Ok(rsp) => Ok(rsp),
// A silent downstream slave surfaces as a timeout — the
// spec-correct way for a gateway to say "target didn't answer".
Err(ModbusError::Timeout(_)) => Err(Exception::GatewayTargetDeviceFailedToRespond),
// Any other transport failure means the gateway can't reach the path.
Err(_) => Err(Exception::GatewayPathUnavailable),
}
}
}
/// Models the RTU bus behind the gateway: slave 5 exists but rejects a read,
/// slave 42 is reported absent (the bus itself answers "target failed").
#[derive(Clone)]
struct RtuBusHook {
reject: Vec<u8>,
absent: Vec<u8>,
}
#[async_trait]
impl ServerHook for RtuBusHook {
async fn before_call(&self, slave: u8, _request: &Request<'_>) -> Option<Response> {
if self.reject.contains(&slave) {
Some(Response::Exception(3, Exception::IllegalDataAddress))
} else if self.absent.contains(&slave) {
Some(Response::Exception(3, Exception::GatewayTargetDeviceFailedToRespond))
} else {
None // let the inner store answer normally
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── 1. RTU side: a mock RS-485 bus over an in-memory duplex channel ──
let (rtu_client_side, rtu_server_side) = tokio::io::duplex(1024);
let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 1234), (1, 5678)]));
let service = HookedService::new(
store,
RtuBusHook { reject: vec![5], absent: vec![42] },
);
let rtu_server = rtu::RtuServer::new(rtu_server_side);
tokio::spawn(async move { rtu_server.serve_forever(service).await.ok(); });
// ── 2. Gateway: TCP front-end + RTU downstream client ────────────────
// The downstream timeout must be *shorter* than the upstream one: an
// RTU exception response is only 5 bytes, so the RTU client waits its
// full timeout before relaying it. Give the gateway time to translate a
// silent slave into a gateway exception before the TCP master gives up.
let downstream = rtu::RtuClient::with_timeout(rtu_client_side, Duration::from_secs(1));
let gateway = Gateway { downstream: Arc::new(downstream) };
let tcp_server =
tcp::TcpServer::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).await?;
let gateway_addr = tcp_server.local_addr()?;
tokio::spawn(async move { tcp_server.serve_forever(gateway).await.ok(); });
// ── 3. A plain Modbus TCP client talks to the gateway ────────────────
let client = tcp::TcpClient::connect_with_timeout(gateway_addr, Duration::from_secs(3)).await?;
let regs = client.read_holding_registers(1, 0, 2).await?;
println!("slave 1 -> {regs:?}");
client.write_single_register(1, 1, 4242).await?;
let regs = client.read_holding_registers(1, 1, 1).await?;
println!("slave 1 -> {regs:?} (after write)");
match client.read_holding_registers(5, 0, 1).await {
Err(ModbusError::Exception { code, .. }) => println!("slave 5 -> exception code {code}"),
other => println!("slave 5 -> {other:?}"),
}
match client.read_holding_registers(42, 0, 1).await {
Err(ModbusError::Exception { code, .. }) => println!("slave 42 -> exception code {code}"),
other => println!("slave 42 -> {other:?}"),
}
Ok(())
}
Run it and the four requests map cleanly onto the four behaviours:
slave 1 -> [1234, 5678]
slave 1 -> [4242] (after write)
slave 5 -> exception code 2
slave 42 -> exception code 11
The two bottom lines are the whole point. Slave 5 exists — it rejected the read, and the gateway relays that exception 2 unchanged. Slave 42 is absent — the gateway's RTU client timed out, and the gateway translated that silence into exception 11. The upstream client now distinguishes "your request is wrong" from "that device isn't there" without ever touching the serial bus.
Why the downstream timeout must be shorter
Look at the comment in the demo: the downstream RTU client uses a 1-second timeout while the upstream TCP client gets 3 seconds. That is not an arbitrary choice — it is a layering rule.
The trap is an RTU exception response. A normal read of one register is 7 bytes, but an exception response is only 5 bytes (slave, function code with the high bit set, exception code, two CRC bytes). The RTU client has to read the expected frame length, so when it receives a short exception frame it waits out the remainder of its timeout before it can parse and relay it. In other words, a downstream exception costs you a full downstream timeout before it reaches the upstream client.
If both timeouts are 3 seconds, that exception races the TCP master's own deadline: the gateway is still busy decoding the short frame when the master gives up and moves on, and the late response desynchronises the next request. Giving the downstream a shorter timeout means the gateway always has time to translate silence into exception 11 and answer before the upstream master times out. A downstream timeout of roughly a third of the upstream timeout is a sane default.
The verbatim-relay rule, on both sides
There are two "relay verbatim" decisions in the code, and they are easy to get backwards:
Upstream, forwarding the downstream result: a raw
ModbusClient::callreturns exception responses asOk(Response::Exception(...)), not asErr. The default helper methods (read_holding_registers, and so on) convert those intoErr(ModbusError::Exception { ... }), but the rawcallleaves them asOk. A forwarding service should use the rawcalland match onOk(rsp) => Ok(rsp), so a device's genuine exception 2 is preserved rather than re-wrapped by the gateway's own error mapping.Downstream, modelling the bus:
SlaveStoreanswers every address — it is one device, not a bus. To stand in for a mixed RS-485 bus with per-slave fates, the demo wraps it in aHookedServicewhosebefore_callhook short-circuits on specific addresses. This is the same deterministic-harness trick from the scanner article, and it is the clean way to unit-test a gateway against a whole bus without hardware.
What changes with a real RS-485 bus
The demo runs the RTU side over an in-memory duplex channel, so everything is deterministic and repeatable. On real hardware, four things change:
- A real serial port.
RtuClient::with_timeoutaccepts anyAsyncRead + AsyncWrite + Unpin + Sendtransport, so you swap the duplex channel for atokio-serialport and the rest of the code is unchanged — the same generic-transport property covered in One Modbus Client, Any Transport. - Bus timing. RS-485 is half-duplex; frames must be separated by the 3.5-character silent interval or they collide. The RTU server and client enforce this when configured with bus timing — see The 3.5T Rule.
- The absent case is already silence. On a direct RTU bus there is no gateway to emit exception 11, so an absent slave is a plain timeout. The gateway in this article is creating exception 11 precisely because it sits between the TCP master and that silence.
- Concurrency. A real gateway fronts many masters. Because
GatewayholdsArc<dyn ModbusClient>, the single RTU client is shared, and its internal mutex serialises the downstream traffic — you get correctness for free, at the cost of one serial request at a time.
What to remember
- A gateway translates between two failure models: RTU silence becomes TCP exception 11, and an unreachable downstream path becomes exception 10.
Service::callhas no slave parameter — recover the address fromoms_modbus::server::context::SLAVE_ID, the same task-localHookedServiceuses.- Relay exception responses verbatim (via the raw
call, which returns them asOk), or you will replace a device's real error code with a generic gateway error. - Give the downstream a shorter timeout than the upstream, because a short RTU exception frame costs a full downstream timeout before it can be relayed.
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: Modbus TCP Framing Quirks — MBAP headers, transaction IDs, and gateway mode. Building a Modbus Device Scanner — the three-way classification of timeout vs exception vs silence.