Modbus Exception Codes: The Complete Reference (and How to Read Them in Rust)

Every Modbus exception code from 1 to 11, the exception PDU format, why an exception is not a timeout, and the two ways oms-modbus surfaces them — plus a runnable demo that triggers each one.

OrangeHorse Engineering Team 8 min read

Modbus Exception Codes: The Complete Reference (and How to Read Them in Rust)

A Modbus client asks for register 40001 and gets back an answer — but the answer is not the data. It is two bytes that say "no." If you have spent an afternoon staring at a field device that reads everything as zero or -1, you have almost certainly met a Modbus exception and not recognised it.

This article is the full reference: every exception code from 1 to 11, what each one means, the two-byte wire format, and — the part most people get wrong — the difference between an exception and a timeout. It ends with a runnable demo that triggers each common exception and shows exactly how oms-modbus surfaces them.

An exception is not a timeout

Before any code, pin down the single most important diagnostic distinction in Modbus:

ExceptionTimeout
What the device didHeard you, decoded your request, refused itSaid nothing
On the wireA response frame: FC + 0x80, then a code byteSilence
What it means"Your request is wrong" (or "I can't do that")"The device is absent, busy, or the frame never arrived"
Client seesException with a specific codeTimeout

Conflating the two is the classic debugging mistake. An exception is a definitive answer — the device is alive and telling you why it refused. A timeout is no answer at all — the device may be powered off, unplugged, or wired to the wrong pins. If you read a timeout as "the device doesn't support my request" you will chase a software bug that is really a broken wire, and vice versa.

The single most useful skill in Modbus debugging is reading that exception code and knowing which of those two worlds you are in.

The exception PDU, byte by byte

An exception response is the smallest frame Modbus can send. It is the function code with the high bit set, followed by a single exception code byte:

FC | 0x80,  code

So a request 03 00 00 00 01 (read holding registers, address 0, quantity 1) that a device refuses with "Illegal Data Address" comes back as:

83 02
└─┘ └─┘
 │   └── exception code 2 (Illegal Data Address)
 └────── function code 3 + 0x80 = 0x83

The high bit is the signal. Function codes 1–127 are requests; the same byte with bit 7 set (128–255) is "this request produced an exception." Strip the high bit and you know which request was refused; the code byte tells you why.

The complete table

Modbus defines ten standard exception codes. Code 9 is reserved and never appears on the wire; anything else is a vendor extension.

CodeNameMeaning
1Illegal FunctionThe slave does not support this function code (e.g. it only implements reads, and you sent a write).
2Illegal Data AddressThe register/coil address is outside the slave's register map.
3Illegal Data ValueThe address is valid but the value or quantity is not (e.g. a value outside the allowed range, or a read wider than the map).
4Server Device FailureThe slave hit an unrecoverable internal error while processing the request.
5AcknowledgeAccepted, but will take a long time; the client should retry later.
6Server Device BusyAccepted, but the slave is busy; retry later.
7Negative AcknowledgeThe slave cannot perform the requested program function.
8Memory Parity ErrorThe slave detected a parity error in its extended memory.
10Gateway Path UnavailableA gateway cannot reach the requested downstream path.
11Gateway Target Device Failed to RespondA gateway reached the path, but the target device did not answer.

Two of these deserve a close look, because they are the ones you will actually meet:

Code 2 (Illegal Data Address) is the "off-by-one in disguise" error. A register map says 40001 and you tell the library to read raw address 40001, or you ask for address 12 when only 0..=11 exist. Either way the device refuses the address. It is almost always a mapping error — see the register addressing article for the full story on 40001 vs offset 0.

Code 3 (Illegal Data Value) is subtler, because the address is fine — the value or quantity is not. The two common triggers are writing a value outside a permitted range, and reading a quantity wider than the device allows. A frequent real-world case: reading a 32-bit float that spans two registers but asking for quantity = 1; some devices answer with code 3 rather than splitting a float in half.

Codes 10 and 11 are gateway-only — they never come from an end device, only from the bridge in front of it. If you are debugging a Modbus gateway, code 11 ("the target didn't answer") is your timeout in exception form: the gateway is translating downstream silence into a definitive answer for the upstream client.

Two ways oms-modbus surfaces the same exception

Here is the part that trips people up when they move from "calling the library" to "writing one." oms-modbus exposes exceptions through two different shapes, and they are both correct:

  • The raw call returns an exception as Ok(Response::Exception(fc, ex)) — a successful result carrying an exception value, because at the transport level an exception response is a valid, well-formed frame.
  • The default helpers (read_holding_registers, write_single_register, …) convert that into Err(ModbusError::Exception { function, code }) — an error, because the caller asked for data and got a refusal.

The distinction matters when you write forwarding code. A gateway or proxy must use the raw call and relay Ok(Response::Exception(..)) verbatim — otherwise it swallows the device's real exception and substitutes its own. The default methods exist for the common case where you just want "give me the registers or give me an error."

Notice the function field: it is the request's function code (for a refused read, 3, not 0x83). That tells you which operation failed, independent of the code byte telling you why.

A runnable demo

The demo stands up a deliberately picky device — ten holding registers, reads of at most five at a time, and no writes — then triggers each refusal and shows how it surfaces:

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

/// A minimal device that enforces its register map: ten holding registers,
/// reads of at most five at a time, and no writes.
#[derive(Clone)]
struct PickyDevice;

#[async_trait]
impl Service for PickyDevice {
    async fn call(&self, request: Request<'_>) -> Result<Response, Exception> {
        match request {
            Request::ReadHoldingRegisters(addr, qty) => {
                if addr >= 10 {
                    Err(Exception::IllegalDataAddress) // only registers 0..=9 exist
                } else if qty > 5 {
                    Err(Exception::IllegalDataValue) // refuse reads wider than 5
                } else {
                    Ok(Response::ReadHoldingRegisters(vec![42; qty as usize]))
                }
            }
            Request::WriteSingleRegister(..) => Err(Exception::IllegalFunction), // read-only
            _ => 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(PickyDevice).await.ok(); });

    let client = tcp::TcpClient::connect_with_timeout(addr, Duration::from_secs(3)).await?;

    // A valid read succeeds.
    let regs = client.read_holding_registers(1, 0, 2).await?;
    println!("ok read:      {regs:?}");

    // The raw `call` surfaces an exception as Ok(Response::Exception(..)).
    match client.call(1, Request::ReadHoldingRegisters(10, 1)).await {
        Ok(Response::Exception(fc, ex)) => {
            println!("raw call:     exception fc={fc} code={} ({ex})", u8::from(ex));
        }
        other => println!("raw call:     {other:?}"),
    }

    // The default helpers surface the same thing as Err(ModbusError::Exception).
    match client.read_holding_registers(1, 10, 1).await {
        Err(ModbusError::Exception { function, code }) => {
            println!("default call: exception function={function} code={code}");
        }
        other => println!("default call: {other:?}"),
    }

    // A rejected write and a too-wide read round out the map.
    match client.write_single_register(1, 0, 1).await {
        Err(ModbusError::Exception { code, .. }) => println!("write:        code={code} (IllegalFunction)"),
        other => println!("write:        {other:?}"),
    }
    match client.read_holding_registers(1, 0, 6).await {
        Err(ModbusError::Exception { code, .. }) => println!("wide read:    code={code} (IllegalDataValue)"),
        other => println!("wide read:    {other:?}"),
    }

    // Three ways to render the same error.
    let err = client.read_holding_registers(1, 10, 1).await.unwrap_err();
    println!("label:        {}", err.label());
    println!("display:      {err}");
    println!("detail:       {}", err.detail());

    Ok(())
}

Run it:

ok read:      [42, 42]
raw call:     exception fc=3 code=2 (Illegal data address)
default call: exception function=3 code=2
write:        code=1 (IllegalFunction)
wide read:    code=3 (IllegalDataValue)
label:        MODBUS EXCEPTION
display:      Illegal Data Address (code=2)
detail:       Illegal Data Address (FC=3, code=2)

Read the output line by line and the whole model falls out:

  • ok read — a valid request returns data, no exception.
  • raw call — the raw call returned Ok(Response::Exception(3, IllegalDataAddress)): the function code 3, the code 2, and the exception's own human name. This is the shape a gateway relays verbatim.
  • default call — the same request through read_holding_registers became Err(ModbusError::Exception { function: 3, code: 2 }). Same facts, error-shaped.
  • write — a read-only device rejects a write with IllegalFunction (code 1).
  • wide read — a too-wide read is refused with IllegalDataValue (code 3), the "address is fine, quantity is not" case.
  • The last three lines are the same error rendered three ways: label() for a status bar, Display for a short message, detail() for a log line.

What to remember

  • An exception is a definitive "no" with a reason; a timeout is silence. Read the code before you reach for the multimeter — or the other way around.
  • The exception PDU is FC | 0x80 followed by one code byte; strip the high bit to see which request was refused.
  • Code 2 = bad address, code 3 = bad value/quantity, code 11 = a gateway's timeout in exception form.
  • oms-modbus surfaces exceptions two ways: raw callOk(Response::Exception(..)), default helpers → Err(ModbusError::Exception { function, code }).
  • The function field is the request's function code, not FC | 0x80.

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: Building a Modbus Server — the other side of the conversation, where those exceptions are raised. Building a Modbus Gateway — how a gateway translates RTU silence into exception 11.

modbus modbus exception codes modbus illegal data address modbus illegal function modbus error codes modbus exception code 2 modbus exception code 11 rust iiot