Building a Modbus Device Scanner in Rust: Timeout vs Exception vs Silence

How to discover Modbus devices by sweeping slave addresses — the three-way classification of responses, exception code 11, bounded concurrency, and a runnable Rust scanner built on oms-modbus.

OrangeHorse Engineering Team 8 min read

Building a Modbus Device Scanner in Rust: Timeout vs Exception vs Silence

You plug a new RS-485 sensor into a bus that already has a dozen devices on it, and nothing you read matches the manual. Before you can debug, you need to answer a deceptively simple question: which slave addresses are actually occupied? That question — answered by a device scanner — is where most field troubleshooting actually starts.

A scanner sweeps the slave-address space (1–247 for standard Modbus) and, for each address, sends one small probe and classifies what comes back. The hard part is not sending the probe. The hard part is classifying the answer correctly, because Modbus has three distinct kinds of "no usable data" and they mean completely different things. This article builds a working scanner and walks through that classification.

The three outcomes — plus the one most people get wrong

A probe (a single-register read at address 0) can come back four ways:

OutcomeWhat it meansDevice present?
Data (Ok)A normal register value came back✅ yes
Exception 2/3 (illegal address / illegal value)The device answered, but your request is wrong✅ yes — you probed a register that doesn't exist
Exception 11 (gateway target failed to respond)A gateway answered, but the target slave didn't❌ no — but a gateway is in the way
Timeout / silenceNothing answered at all❌ no — nothing on the wire

The subtle mistake is treating any exception as "device absent". An exception response is, by definition, a response — it proves something heard you and replied. If you read holding register 0 and get back exception code 2 ("Illegal Data Address"), the device is very much present; it just doesn't expose a register at address 0. A scanner that reports that address as "empty" will send you chasing a wiring problem that doesn't exist.

Exception code 11 — "Gateway Target Device Failed to Respond" — is the other trap, and it is the honest answer to the "how do I see an absent device at all?" question. On a real network you rarely talk to slaves directly over TCP. You talk to a gateway (Modbus TCP on one side, RTU on the other), and the gateway fronts many slaves. When you ask that gateway for slave 42 and slave 42 isn't wired up, the gateway cannot time out — it is the responder. So it returns exception 11 on behalf of the missing slave. On an RTU bus with no gateway, the same situation surfaces as pure silence → a timeout on the client side.

That is why a correct scanner has to distinguish all four outcomes, not just "got data / didn't get data".

Why bounded concurrency and a short timeout are the whole ballgame

A naive scanner loops for slave in 1..=247 and awaits each probe. The math kills it:

  • At a 100 ms per-probe timeout, a silent bus costs 247 × 100 ms ≈ 25 seconds — and that's the best case, where every absent address is genuinely absent.
  • On a busy gateway that responds slowly, or a serial bus at 9600 baud, each probe takes longer.

Two levers fix this:

  1. A short timeout. ClientOptions::with_timeout sets the per-request send-and-receive timeout. For scanning you want it short — 50–200 ms — because you are expecting most addresses to be empty.
  2. Bounded concurrency. Instead of awaiting each probe sequentially, fire many probes in flight, capped by a tokio::sync::Semaphore. Eight concurrent probes turn a 25-second sweep into about 3 seconds, without opening 247 simultaneous connections.

A semaphore is the right tool rather than unbounded join_all because you want a ceiling: a gateway can only juggle so many connections, and 247 concurrent TCP sockets is a resource bomb for no benefit.

The scanner, end to end

The demo below is fully self-contained — no hardware, no external gateway. It starts an in-memory TCP server and uses a ServerHook to make that one server behave like a gateway fronting several devices: slave 1, 7, and 23 answer with data, slave 5 exists but rejects a read of register 0 (exception 2), and every other address is reported absent (exception 11). The scanner then sweeps 1–30 with a concurrency cap of 8 and classifies each result.

use oms_modbus::*;
use async_trait::async_trait;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;

// A single TCP server stands in for a Modbus TCP→RTU gateway that fronts
// several devices. The hook gives each slave address its own behaviour.
#[derive(Clone)]
struct GatewayHook {
    present: Vec<u8>,          // devices that answer with data
    missing_register: Vec<u8>, // devices that exist but reject this address
}

#[async_trait]
impl ServerHook for GatewayHook {
    async fn before_call(&self, slave: u8, _request: &Request<'_>) -> Option<Response> {
        if self.present.contains(&slave) {
            None // let the inner store answer normally
        } else if self.missing_register.contains(&slave) {
            Some(Response::Exception(3, Exception::IllegalDataAddress))
        } else {
            // Absent device — the gateway tried, but nothing answered.
            Some(Response::Exception(3, Exception::GatewayTargetDeviceFailedToRespond))
        }
    }
}

#[derive(Debug)]
enum ScanResult {
    Online(Vec<u16>),
    Rejected(String),
    Absent(String),
    Error(String),
}

async fn scan_one<C: ModbusClient>(client: &C, slave: u8) -> ScanResult {
    match client.read_holding_registers(slave, 0, 1).await {
        Ok(regs) => ScanResult::Online(regs),
        Err(ModbusError::Exception { code: 11, .. }) => {
            ScanResult::Absent("gateway target failed to respond".into())
        }
        Err(ModbusError::Exception { code, .. }) => {
            ScanResult::Rejected(format!("exception code {code}"))
        }
        Err(ModbusError::Timeout(_)) => ScanResult::Absent("no response (timeout)".into()),
        Err(e) => ScanResult::Error(e.detail()),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ── Gateway simulator ─────────────────────────────────────────
    let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 1234)]));
    let hook = GatewayHook {
        present: vec![1, 7, 23],
        missing_register: vec![5],
    };
    let service = HookedService::new(store, hook);
    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(service).await.ok(); });

    // ── Scanner sweep, bounded concurrency ────────────────────────
    let opts = ClientOptions::default().with_timeout(Duration::from_millis(100));
    let sem = Arc::new(Semaphore::new(8));
    let mut handles = Vec::new();
    for slave in 1..=30u8 {
        let sem = sem.clone();
        let opts = opts.clone();
        handles.push(tokio::spawn(async move {
            let _permit = sem.acquire_owned().await.unwrap();
            let client = match tcp::with_options(addr, opts).await {
                Ok(c) => c,
                Err(e) => return (slave, ScanResult::Error(e.to_string())),
            };
            (slave, scan_one(&client, slave).await)
        }));
    }
    let mut results = Vec::new();
    for h in handles {
        results.push(h.await.unwrap());
    }
    results.sort_by_key(|(slave, _)| *slave);

    for (slave, r) in results {
        match r {
            ScanResult::Online(regs) => println!("slave {slave:>3}: online   registers={regs:?}"),
            ScanResult::Rejected(m) => println!("slave {slave:>3}: present  ({m})"),
            ScanResult::Absent(m) => println!("slave {slave:>3}: absent   ({m})"),
            ScanResult::Error(m) => println!("slave {slave:>3}: error    ({m})"),
        }
    }
    Ok(())
}

The output (truncated to the interesting lines) shows the three-way split doing its job:

slave   1: online   registers=[1234]
slave   5: present  (exception code 2)
slave   7: online   registers=[1234]
slave  12: absent   (gateway target failed to respond)
slave  23: online   registers=[1234]

Notice the two non-obvious results: slave 5 is correctly reported present even though it returned an exception, and the empty addresses are absent via exception 11 rather than being conflated with a transport failure.

How the demo simulates an absent device

This is worth unpacking, because it is the one thing you cannot get "for free" from the default server. SlaveStore answers every slave address — it is a single device, not a bus. To model a gateway fronting multiple devices with different fates, the demo wraps the store in a HookedService whose before_call hook sees the incoming slave address and short-circuits:

  • present addresses return None, so the request falls through to the inner store, which answers with real data.
  • The missing_register address returns an exception response directly.
  • Everything else returns exception 11, "the gateway tried and the target didn't answer".

This is exactly how you build a deterministic test harness for a Modbus client: a single ServerHook can stand in for an entire mixed bus, so your integration tests don't depend on hardware.

What changes on a real bus

The demo is TCP, but the classification logic is transport-agnostic — scan_one is generic over ModbusClient, so the same function scans an RTU serial bus by swapping the client. On real hardware, three things differ:

  1. RTU has no exception 11. Exception 11 only exists when a gateway is in the path. On a direct RS-485 bus, an absent slave is pure silence — you get the Timeout arm, not exception 11. A scanner for RTU leans on the timeout; a scanner for TCP-with-gateway leans on exception 11. Both arms are in the code above for exactly this reason.
  2. FC03 is a heuristic, not a guarantee. Reading one holding register is the most universally supported probe, but a minority of devices don't implement FC03. A thorough scanner retries a failure with FC04 (read input registers) or FC01 (read coils) before declaring an address empty — the trade-off is a slower sweep.
  3. Don't scan too fast on a live bus. Every probe is traffic on a shared serial line. The semaphore bounds your concurrency, but on a half-duplex RTU bus you should also leave headroom for the other masters and slaves that already live there.

What to remember

  • An exception is a response — it proves a device is there. Only exception 11 (or a timeout) means "no device at this address".
  • The absent case is reported differently on RTU (timeout) vs. TCP-through-a-gateway (exception 11). A correct scanner handles both.
  • A short timeout plus a bounded-concurrency semaphore is what makes a 247-address sweep take seconds instead of half a minute.

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: Rust Modbus Production Engineering — reconnect, retryable errors, backpressure, and observability for the long-running service. Field Troubleshooting with WireTap — what to do after the scanner tells you which device is misbehaving.

modbus modbus scanner modbus device discovery rust iiot modbus exception rs485 modbus tcp