Building a Modbus Server in Rust: SlaveStore, Custom Services, and Live Data
Every Modbus article you read talks about the client: connect, read a register, write a coil. That makes sense — most of what you build on the host side is a client. But the other end of the conversation, the server (the slave, the device), is where the actual data lives, and eventually you need to build one: a simulator for tests, a virtual device that fronts real hardware, or a bridge from Modbus to something that isn't Modbus.
In oms-modbus the server side comes in two patterns with a clean dividing line: static data and dynamic data. This article covers both, with the emphasis on the dynamic one, because that is where the interesting engineering is.
Pattern 1: SlaveStore — the fast path
If your registers hold fixed values — a sensor you are simulating for a test, a canned register map — you don't write any server logic at all. SlaveStore is an in-memory register/coil store that already implements Service. The whole server is three lines: wrap SlaveStore::with_holding_registers(&[(0, 1234), (1, 5678)]) in an Arc, bind a TcpServer, and hand the store to serve_forever. That is a TCP server answering reads and writes against two holding registers. The store also handles coils, discrete inputs, and input registers, and it grows its backing arrays on demand, so you can write to any address without pre-sizing. For the overwhelming majority of test and simulation needs this is the entire job — the quick-start on the series index is exactly this pattern.
SlaveStore's limitation is the clue to when you need Pattern 2: it serves memory. Every value is a u16 sitting in a vector. The moment a register is supposed to be computed — a live temperature, a counter, a value that changes because a motor moved — a static store is the wrong model, and you write the service yourself.
Pattern 2: the Service trait — the real device
A real Modbus slave is not a vector of numbers; it is a small program that answers each request by doing something. oms-modbus models that with the Service trait, whose single method is async fn call(&self, request: Request<'_>) -> Result<Response, Exception>: one request in, one response out, or an Err(Exception) to refuse. The trait is where three things become possible that a static store can't express:
- Dynamic data — the response is computed, not fetched. A register can be the current time, a sine wave, or a reading pulled from hardware, and it changes from one poll to the next.
- Write side effects — a write is no longer just "store a number in memory." Writing a setpoint register can move an actuator, writing an alarm threshold can arm a watchdog. The value goes somewhere.
- Validation — the device can refuse. A gain that must be
0..=10getsIllegalDataValue(code 3) for anything else, which is exactly the "address is fine, value is wrong" case from the exception codes article.
Two mechanics are easy to trip over when you first write one. First, call takes &self, not &mut self — a server serves many connections, so mutable state lives behind interior mutability (Arc<Atomic*> or a Mutex), and the transport loop clones one service instance per connection (which is why serve_forever requires Clone). Second, a service returns Err(Exception) to refuse whole categories of request; you don't reach for that for a missing register if a zero or a computed value would do.
A runnable sensor
The demo is a small sensor with one live register and one writable register. Its measurement (an input register) increments on every poll, so you can see it is live rather than static; its gain (a holding register) is writable but validated, so a gain of 99 is refused:
use oms_modbus::*;
use async_trait::async_trait;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// A sensor: one live measurement register and one writable gain register.
/// The measurement increments on every read, so repeated polls return
/// different values — the signature of a real, live device.
#[derive(Clone)]
struct Sensor {
reads: Arc<AtomicU32>,
gain: Arc<AtomicU32>,
}
impl Sensor {
fn new() -> Self {
Sensor {
reads: Arc::new(AtomicU32::new(0)),
gain: Arc::new(AtomicU32::new(1)),
}
}
}
#[async_trait]
impl Service for Sensor {
async fn call(&self, request: Request<'_>) -> Result<Response, Exception> {
match request {
// Input register 0 = live measurement (FC04, read-only).
Request::ReadInputRegisters(addr, qty) => {
let mut regs = Vec::with_capacity(qty as usize);
for i in 0..qty {
match addr + i {
0 => {
let n = self.reads.fetch_add(1, Ordering::Relaxed);
regs.push(n as u16);
}
_ => regs.push(0),
}
}
Ok(Response::ReadInputRegisters(regs))
}
// Holding register 0 = gain (FC03 read, FC06 write).
Request::ReadHoldingRegisters(addr, qty) => {
let mut regs = Vec::with_capacity(qty as usize);
for i in 0..qty {
match addr + i {
0 => regs.push(self.gain.load(Ordering::Relaxed) as u16),
_ => regs.push(0),
}
}
Ok(Response::ReadHoldingRegisters(regs))
}
Request::WriteSingleRegister(addr, value) => {
if addr == 0 && value <= 10 {
self.gain.store(value as u32, Ordering::Relaxed);
Ok(Response::WriteSingleRegister(addr, value))
} else {
Err(Exception::IllegalDataValue) // gains above 10 are rejected
}
}
_ => Err(Exception::IllegalFunction),
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server = tcp::TcpServer::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).await?;
let addr = server.local_addr()?;
tokio::spawn(async move { server.serve_forever(Sensor::new()).await.ok(); });
let client = tcp::TcpClient::connect_with_timeout(addr, Duration::from_secs(3)).await?;
// Live measurement: three polls return three different values.
let a = client.read_input_registers(1, 0, 1).await?;
let b = client.read_input_registers(1, 0, 1).await?;
let c = client.read_input_registers(1, 0, 1).await?;
println!("live measurement: {a:?} {b:?} {c:?}");
// Read the current gain, then write a new one.
let gain = client.read_holding_registers(1, 0, 1).await?;
println!("gain before: {gain:?}");
client.write_single_register(1, 0, 7).await?;
let gain = client.read_holding_registers(1, 0, 1).await?;
println!("gain after: {gain:?}");
// A rejected write (gain 99) surfaces as an exception.
match client.write_single_register(1, 0, 99).await {
Err(ModbusError::Exception { code, .. }) => println!("write 99: exception code {code}"),
other => println!("write 99: {other:?}"),
}
Ok(())
}
Run it:
live measurement: [0] [1] [2]
gain before: [1]
gain after: [7]
write 99: exception code 3
Three things in the output are the whole point of the custom service:
live measurement: [0] [1] [2]— the same register returns a different value on every poll. That is dynamic data; noSlaveStorecan do it.gain before: [1]→gain after: [7]— a write went somewhere and persisted. That is a write side effect, here just a stored value, but it could equally have been an actuator command.write 99: exception code 3— the device refused an out-of-range value withIllegalDataValue. That is validation, the device's way of saying "that address exists, but 99 is not a gain I accept."
From simulator to real device
The demo runs over an in-memory TCP socket, but the service is transport-agnostic: pass it to RtuServer or AsciiServer and the same Sensor serves the same register map over a serial bus. That is the generic-transport property covered in One Modbus Client, Any Transport, working in the server direction. The Arc<Atomic*> state is what makes it shareable and live.
For a real device, the three blocks of the call method become three calls into your hardware layer: reads pull from the actual sensor, writes push to the actual actuator, and validation enforces the device's real physical limits. The Modbus side — the request/response shape, the framing, the concurrency — is already handled by the library.
What to remember
SlaveStoreserves memory; theServicetrait serves behaviour. Use the store for static data, the trait for anything live or stateful.call(&self, Request) -> Result<Response, Exception>is the whole interface: read → compute → respond, write → side effect, invalid →Err.calltakes&self, so mutable state usesArc<Atomic*>or aMutex, andserve_foreverneeds aCloneservice.- Return
Err(Exception::IllegalDataValue)to refuse an out-of-range write — the device-side counterpart of the client-side error in the exception codes article.
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 Exception Codes — the refusals this article raises, and how to read them on the client side. Building a Modbus Device Scanner — how a client finds a server in the first place.