Modbus Register Addressing: The 40001 Puzzle, 0-Based vs 1-Based, and Data-Table Prefixes
A sensor's register map says "soil temperature is at 40001." You tell your Modbus library to read address 40001. It either errors out or returns garbage from some register you didn't mean to read. What went wrong?
Nothing is broken — you have just met the single most common naming confusion in Modbus. The manual is speaking a convenience dialect invented for humans; the protocol speaks a different, terse dialect for machines. 40001 is the human dialect. The machine dialect for the same location is 0. This article is the full translation table between the two.
Two languages for the same address
Modbus has two address spaces that refer to the same physical data, and they are easy to conflate:
- The PDU address (protocol level) — what actually goes on the wire. It is 0-based, so the first register is
0, the second is1, and so on. It is a plain 16-bit number from0to65535. - The data-model address (documentation level) — what a register map prints for people. It is 1-based, so the first register is
1, and it carries a leading digit that names the data table, giving the familiar40001.
The protocol itself knows nothing about 40001. That string is a Modicon convention, inherited from the first Modbus PLCs and kept alive by decades of documentation, because it packs three facts into one number: which table, which register, and count from one.
The four data tables, and their prefixes
Modbus organises data into four tables, each with a leading digit and a fixed access rule:
| Prefix | Table | Width | Access | Read with | Write with |
|---|---|---|---|---|---|
0x | Coil | 1 bit | read/write | FC01 | FC05, FC15 |
1x | Discrete Input | 1 bit | read-only | FC02 | — |
3x | Input Register | 16 bit | read-only | FC04 | — |
4x | Holding Register | 16 bit | read/write | FC03 | FC06, FC16, FC23 |
(The absent 2x prefix was historically used for discrete inputs by some vendors but never entered the standard; the standard assigns discrete inputs to 1x.)
The prefix does real work: it encodes both the width and the access direction. A register map that says 40001 is telling you "holding register, first one, readable and writable via FC03/FC06/FC16." A map that says 30001 is telling you "input register, first one, read-only via FC04" — typically a live sensor reading you can't overwrite. That distinction between input registers (measurements the device produces) and holding registers (configuration and setpoints you can change) is one a driver must honour, and the prefix is what signals it.
Decoding 40001
The conversion from the human dialect to the machine dialect is two steps:
- The leading digit names the table.
4→ holding register,3→ input register,1→ discrete input,0→ coil. - The rest is 1-based; subtract one.
0001means "register number 1", and the 0-based PDU offset is1 − 1 = 0.
So 40001 → holding register at PDU offset 0. 40013 → holding register at PDU offset 12. The off-by-one is the entire trap: a 1-based 1 is a 0-based 0.
| Human dialect | Machine dialect |
|---|---|
40001 | holding register, offset 0 (FC03/06/16) |
40013 | holding register, offset 12 |
30001 | input register, offset 0 (FC04) |
10001 | discrete input, offset 0 (FC02) |
00001 | coil, offset 0 (FC01/05/15) |
The many ways a manual might print the same thing
The confusion is compounded because vendors render the same address several different ways. The register-map conventions you will meet in the wild:
40001— the classic 5-digit Modicon form (leading table digit + 4-digit 1-based number).400001— the 6-digit form some vendors use to extend the range;400001is still holding register 1 at offset0.Holding Register 1— plain 1-based number, table stated in words.4x0001/%MW1— Schneider/IEC-style table-prefix and memory-word notations.0x0000— the raw 0-based PDU offset in hex, which is exactly what the protocol sends.
The rule that resolves all of them: find the table, find the 1-based number, subtract one. Everything else is presentation.
The off-by-one trap, concretely
The demo below decodes Modicon-style addresses into their data table and PDU offset. It is pure standard-library Rust because the conversion is arithmetic that lives above the protocol:
/// The four Modbus data tables, identified by their conventional leading digit.
#[derive(Debug, Clone, Copy)]
enum Table {
Coil, // 0x — read/write, 1 bit
DiscreteInput, // 1x — read-only, 1 bit
InputRegister, // 3x — read-only, 16 bit
HoldingRegister, // 4x — read/write, 16 bit
}
impl Table {
fn leading_digit(self) -> u16 {
match self {
Table::Coil => 0,
Table::DiscreteInput => 1,
Table::InputRegister => 3,
Table::HoldingRegister => 4,
}
}
fn name(self) -> &'static str {
match self {
Table::Coil => "coil",
Table::DiscreteInput => "discrete input",
Table::InputRegister => "input register",
Table::HoldingRegister => "holding register",
}
}
fn width(self) -> &'static str {
match self {
Table::Coil | Table::DiscreteInput => "1 bit",
Table::InputRegister | Table::HoldingRegister => "16 bit",
}
}
}
/// Decode a Modicon-style address like 40001 into (table, 0-based PDU offset).
/// The leading digit selects the table; the rest is a 1-based register number.
fn decode_modicon(address: u16) -> Option<(Table, u16)> {
let leading = address / 10000;
let number = address % 10000;
let table = match leading {
0 => Table::Coil,
1 => Table::DiscreteInput,
3 => Table::InputRegister,
4 => Table::HoldingRegister,
_ => return None,
};
if number == 0 {
return None; // 1-based: there is no register 0
}
Some((table, number - 1))
}
fn main() {
// (Modicon address, what a manual would call it)
let examples: [(u16, &str); 5] = [
(40001, "holding register 1"),
(40013, "holding register 13"),
(30001, "input register 1"),
(10001, "discrete input 1"),
(1, "coil 1 (written 00001)"),
];
println!("Modicon address -> data table + 0-based PDU offset");
for (addr, label) in examples {
match decode_modicon(addr) {
Some((table, pdu)) => {
println!(
"{addr:>6} -> {:>3}x {:16} ({}) at PDU offset {pdu}",
table.leading_digit(),
table.name(),
table.width(),
);
}
None => println!("{addr:>6} -> invalid ({label})"),
}
}
// The off-by-one trap: a raw Modicon number is NOT the PDU offset.
println!();
println!("the off-by-one trap:");
println!(" 40001 means PDU offset 0, not 1 and not 40001");
println!(" reading offset 1 returns the SECOND register, not the first");
}
Run it:
Modicon address -> data table + 0-based PDU offset
40001 -> 4x holding register (16 bit) at PDU offset 0
40013 -> 4x holding register (16 bit) at PDU offset 12
30001 -> 3x input register (16 bit) at PDU offset 0
10001 -> 1x discrete input (1 bit) at PDU offset 0
1 -> 0x coil (1 bit) at PDU offset 0
the off-by-one trap:
40001 means PDU offset 0, not 1 and not 40001
reading offset 1 returns the SECOND register, not the first
The last two lines are the practical takeaway. If a register map says 40001 and your library or gateway expects a raw PDU offset, you send 0 — never 1 and never 40001. Sending 1 reads the second register, silently shifting every subsequent reading by one position. This is the classic symptom of an addressing mismatch: a register map that looks "one row off," where the temperature shows up in the humidity slot and everything is plausible but wrong.
Choosing the right table
A final point the prefix carries for free: use the table that matches the data's access rule. Sensor measurements that the device produces are input registers (3x) — read them with FC04 and don't try to write them. Configuration, setpoints, and coefficients you are meant to change are holding registers (4x) — read them with FC03 and write them with FC06/FC16. Mapping a read-only measurement into a holding register works in practice but wastes the semantic that tells a future maintainer (and the device itself) what is safe to write.
What to remember
40001is the Modicon human convention; the protocol sends the 0-based offset0.- The leading digit names the table (
0=coil,1=discrete input,3=input register,4=holding register); subtract one from the rest for the PDU offset. - A 1-based
1is a 0-based0; feeding the raw number as an offset shifts every reading by one position. - Input registers are read-only measurements; holding registers are read/write configuration — match the table to the data's access rule.
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 Protocol Guide — the ground-up guide that introduces the four data tables. Modbus Data Encoding — what those 16-bit registers actually contain.