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:
| Exception | Timeout | |
|---|---|---|
| What the device did | Heard you, decoded your request, refused it | Said nothing |
| On the wire | A response frame: FC + 0x80, then a code byte | Silence |
| What it means | "Your request is wrong" (or "I can't do that") | "The device is absent, busy, or the frame never arrived" |
| Client sees | Exception with a specific code | Timeout |
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.
| Code | Name | Meaning |
|---|---|---|
1 | Illegal Function | The slave does not support this function code (e.g. it only implements reads, and you sent a write). |
2 | Illegal Data Address | The register/coil address is outside the slave's register map. |
3 | Illegal Data Value | The 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). |
4 | Server Device Failure | The slave hit an unrecoverable internal error while processing the request. |
5 | Acknowledge | Accepted, but will take a long time; the client should retry later. |
6 | Server Device Busy | Accepted, but the slave is busy; retry later. |
7 | Negative Acknowledge | The slave cannot perform the requested program function. |
8 | Memory Parity Error | The slave detected a parity error in its extended memory. |
10 | Gateway Path Unavailable | A gateway cannot reach the requested downstream path. |
11 | Gateway Target Device Failed to Respond | A 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
callreturns an exception asOk(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 intoErr(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 rawcallreturnedOk(Response::Exception(3, IllegalDataAddress)): the function code3, the code2, and the exception's own human name. This is the shape a gateway relays verbatim.default call— the same request throughread_holding_registersbecameErr(ModbusError::Exception { function: 3, code: 2 }). Same facts, error-shaped.write— a read-only device rejects a write withIllegalFunction(code 1).wide read— a too-wide read is refused withIllegalDataValue(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,Displayfor 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 | 0x80followed by one code byte; strip the high bit to see which request was refused. - Code
2= bad address, code3= bad value/quantity, code11= a gateway's timeout in exception form. - oms-modbus surfaces exceptions two ways: raw
call→Ok(Response::Exception(..)), default helpers →Err(ModbusError::Exception { function, code }). - The
functionfield is the request's function code, notFC | 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.