Modbus Function Codes 01–06: A Reference with Frame Examples
If the Modbus data model is a noun — coils, inputs, registers — then the function code is the verb. It is the single byte in every request that says what the master wants to do. Read the status of a relay? Write a setpoint? Fetch a sensor reading? Each operation has a number, and that number is the function code.
Most Modbus devices implement only a handful of them. In practice, two function codes — 0x03 (Read Holding Registers) and 0x06 (Write Single Register) — carry the overwhelming majority of real-world traffic. This reference walks through the six workhorse codes, 01 through 06, with the exact bytes each one puts on the wire. Every frame shown here was captured from a running program and its CRC independently verified.
How a function code works
A function code is one byte, and it appears in a fixed position: byte 1 in RTU (immediately after the slave address). The master sends a request with the function code; the slave sends a response that echoes the same code back — unless something went wrong, in which case the slave sets bit 7 and sends an exception instead.
request: [ slave ][ function code ][ data ][ CRC ]
response: [ slave ][ function code ][ data ][ CRC ] normal — FC echoed
exception: [ slave ][ function code | 0x80 ][ code ][ CRC ] error — bit 7 set
So a request to read holding registers (0x03) that fails comes back as 0x83. The byte after the modified function code is the exception code (for example, 0x02 = illegal data address). More on exceptions below.
The six workhorse function codes
The six codes covered here split cleanly into two families: bit access (single-bit values) and 16-bit access (whole registers).
| FC | Name | Operates on | Access |
|---|---|---|---|
| 01 | Read Coils | 1-bit outputs | read |
| 02 | Read Discrete Inputs | 1-bit inputs | read |
| 03 | Read Holding Registers | 16-bit read/write registers | read |
| 04 | Read Input Registers | 16-bit read-only registers | read |
| 05 | Write Single Coil | 1-bit outputs | write |
| 06 | Write Single Register | 16-bit read/write registers | write |
Read requests share one shape — address plus quantity — and read responses share another — byte count plus data. Write requests differ only in the value encoding. Each entry below gives the byte layout and one verified example.
FC 01 — Read Coils
Reads the ON/OFF state of digital outputs (relays, valves, flags). Results come back bit-packed, least-significant bit first: the requested coil at the lowest address lands in bit 0 of the first data byte.
Request
| Byte | Field |
|---|---|
| 0 | Slave address |
| 1 | Function code 0x01 |
| 2–3 | Starting coil address |
| 4–5 | Quantity of coils (1–2000) |
| 6–7 | CRC-16 (low byte first) |
Response
| Byte | Field |
|---|---|
| 0 | Slave address |
| 1 | Function code 0x01 |
| 2 | Byte count = ceil(quantity / 8) |
| 3… | Coil data, bit-packed, LSB first |
| … | CRC-16 |
01 01 00 00 00 08 3D CC request — read 8 coils starting at 0
01 01 01 05 91 8B response — byte 0x05 = coils 0 and 2 are ON
FC 02 — Read Discrete Inputs
Identical to FC 01, but for read-only digital inputs: limit switches, alarm contacts, flow switches. The wire format is the same; only the function code byte changes.
01 02 00 00 00 08 79 CC request — read 8 discrete inputs
01 02 01 03 E1 89 response — byte 0x03 = inputs 0 and 1 are ON
FC 03 — Read Holding Registers
The single most important function code in Modbus. It reads 16-bit read/write registers, and it is how you read almost any sensor's measurement. Each register is returned big-endian (high byte first).
Request
| Byte | Field |
|---|---|
| 0 | Slave address |
| 1 | Function code 0x03 |
| 2–3 | Starting register address |
| 4–5 | Quantity of registers (1–125) |
| 6–7 | CRC-16 (low byte first) |
Response
| Byte | Field |
|---|---|
| 0 | Slave address |
| 1 | Function code 0x03 |
| 2 | Byte count = quantity × 2 |
| 3… | Register values, big-endian |
| … | CRC-16 |
01 03 00 00 00 02 C4 0B request — read 2 registers at 0x0000
01 03 04 FF DD 01 64 5A 66 response — 0xFFDD = -35, 0x0164 = 356
FC 04 — Read Input Registers
Reads 16-bit read-only registers — the place the Modbus spec reserves for measured values like an ADC reading. The byte layout is identical to FC 03; only the function code differs. In practice many vendors skip FC 04 and expose their measurements through holding registers instead (see below).
01 04 00 00 00 01 31 CA request — read 1 input register at 0x0000
01 04 02 01 64 B9 4B response — 0x0164 = 356
FC 05 — Write Single Coil
Sets one digital output. The value is not a single bit but a whole 16-bit word, because Modbus RTU cannot frame a lone bit: 0xFF00 means ON and 0x0000 means OFF. Any other value is an error. The response echoes the request — the master knows the write succeeded when it gets the exact request back.
01 05 00 01 FF 00 DD FA request — coil 1 = ON (0xFF00)
01 05 00 01 FF 00 DD FA response — echo
FC 06 — Write Single Register
Sets one 16-bit holding register. This is how you change a setpoint, a baud rate, or — in the example below — a device's own Modbus address. Like FC 05, the response echoes the request.
01 06 00 30 00 02 08 04 request — register 0x0030 = 0x0002
01 06 00 30 00 02 08 04 response — echo
What OHT sensors actually use
Here is a useful, non-obvious fact: across the OrangeHorse sensor line, every device speaks exactly two function codes — FC 03 to read and FC 06 to write. No coils, no discrete inputs, no input registers.
Take the multi-parameter OHTS1020. One FC 03 request reads eight consecutive holding registers starting at 0x0000:
| Register | Measurement |
|---|---|
| 0 | Soil temperature |
| 1 | Soil moisture |
| 2 | Soil salinity |
| 3 | Electrical conductivity |
| 4 | Soil pH |
| 5 | Nitrogen (N) |
| 6 | Phosphorus (P) |
| 7 | Potassium (K) |
And the OHTS1022 reads temperature and moisture the same way — FC 03, two registers at 0x0000. Its FC 06 usage is the classic one: writing 0x0030 to change the device's own address from 0x01 to 0x02. That exact write is the frame above:
01 06 00 30 00 02 08 04
Why do these sensors put read-only measurements in holding registers (FC 03) rather than input registers (FC 04)? Because legacy masters and HMIs overwhelmingly support FC 03 — holding registers are the lowest common denominator. A vendor that wants its sensor to work with any off-the-shelf Modbus master reaches for FC 03. The semantic distinction between "input" and "holding" registers is real but, in the field, frequently ignored.
Exception responses
When a request cannot be satisfied, the slave sets bit 7 of the function code and returns a one-byte exception code:
01 83 02 C0 F1 an FC 03 request rejected: 0x83 = 0x03 | 0x80, code 0x02
The common exception codes you will actually see:
| Code | Name | Meaning |
|---|---|---|
0x01 | Illegal Function | The device does not implement that function code |
0x02 | Illegal Data Address | The register/coil address does not exist |
0x03 | Illegal Data Value | The value is out of range (e.g. writing a non-0xFF00 coil) |
0x04 | Slave Device Failure | An unrecoverable device error while processing |
0x05 | Acknowledge | Accepted but busy — retry later |
0x06 | Slave Device Busy | Processing a long command — retry later |
The two you will meet most often are 0x02 (you guessed the wrong register address) and 0x03 (you wrote a value the device rejects). Whenever a read returns an exception, the fix is almost always "check the register map against the datasheet."
Worked example: all six codes in one program
The program below emulates a small device on an in-memory RTU bus, then exercises all six function codes while a WireTap captures every frame. The register layout mirrors the OHTS1022 (0x0000 temperature, 0x0001 moisture, 0x0030 address). Swap the in-memory transport for a real serial port and the same calls drive a live sensor.
use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── Virtual device with all four data tables populated ───────────
let store = Arc::new(SlaveStore::with_holding_registers(&[
(0, 0xFFDD), // holding[0x0000] — soil temperature (signed)
(1, 0x0164), // holding[0x0001] — soil moisture (VWC)
(0x30, 0x01), // holding[0x0030] — device address
]));
store.write_coil(0, true); // coil 0 — pump relay ON
store.write_coil(1, false); // coil 1 — valve OFF
store.write_coil(2, true); // coil 2 — heater ON
store.write_discrete_input(0, true); // discrete input 0 — alarm contact closed
store.write_discrete_input(1, true); // discrete input 1 — flow switch OK
store.write_input_register(0, 0x0164); // input register 0 — raw VWC
let (client_stream, server_stream) = tokio::io::duplex(1024);
let server = rtu::RtuServer::new(server_stream);
tokio::spawn(async move {
server.serve_forever(store).await.ok();
});
let cap = Arc::new(BusCapture::unbounded());
let opts = ClientOptions::default()
.with_timeout(Duration::from_secs(3))
.with_tap(cap.clone());
let client = rtu::with_options(client_stream, opts);
// ── FC 01 — Read Coils (8 coils starting at 0) ──────────────────
let coils = client.read_coils(1, 0, 8).await?;
println!("FC01 read_coils -> {coils:?}");
// ── FC 02 — Read Discrete Inputs (8 inputs starting at 0) ───────
let inputs = client.read_discrete_inputs(1, 0, 8).await?;
println!("FC02 read_discrete_inputs -> {inputs:?}");
// ── FC 03 — Read Holding Registers (2 regs) ─────────────────────
let regs = client.read_holding_registers(1, 0, 2).await?;
println!("FC03 read_holding_regs -> {regs:?}");
// ── FC 04 — Read Input Registers (1 reg) ────────────────────────
let iregs = client.read_input_registers(1, 0, 1).await?;
println!("FC04 read_input_regs -> {iregs:?}");
// ── FC 05 — Write Single Coil (coil 1 ON) ───────────────────────
client.write_single_coil(1, 1, true).await?;
println!("FC05 write_single_coil -> coil 1 = ON");
// ── FC 06 — Write Single Register (address 0x0030: 0x01 -> 0x02) ─
client.write_single_register(1, 0x30, 0x02).await?;
println!("FC06 write_single_register -> address 0x0030 = 0x02");
// ── Dump the captured frames ─────────────────────────────────────
println!();
for pkt in cap.drain() {
println!("{pkt}");
}
Ok(())
}
Running it prints the decoded values, then the raw frames exactly as they crossed the bus:
FC01 read_coils -> [true, false, true, false, false, false, false, false]
FC02 read_discrete_inputs -> [true, true, false, false, false, false, false, false]
FC03 read_holding_regs -> [65501, 356]
FC04 read_input_regs -> [356]
FC05 write_single_coil -> coil 1 = ON
FC06 write_single_register -> address 0x0030 = 0x02
[TX] 2026-08-13T10:30:00.518895 8B [01 01 00 00 00 08 3D CC]
[RX] 2026-08-13T10:30:00.519004 6B [01 01 01 05 91 8B]
[TX] 2026-08-13T10:30:00.521713 8B [01 02 00 00 00 08 79 CC]
[RX] 2026-08-13T10:30:00.521759 6B [01 02 01 03 E1 89]
[TX] 2026-08-13T10:30:00.524652 8B [01 03 00 00 00 02 C4 0B]
[RX] 2026-08-13T10:30:00.524694 9B [01 03 04 FF DD 01 64 5A 66]
[TX] 2026-08-13T10:30:00.527607 8B [01 04 00 00 00 01 31 CA]
[RX] 2026-08-13T10:30:00.527652 7B [01 04 02 01 64 B9 4B]
[TX] 2026-08-13T10:30:00.530531 8B [01 05 00 01 FF 00 DD FA]
[RX] 2026-08-13T10:30:00.530574 8B [01 05 00 01 FF 00 DD FA]
[TX] 2026-08-13T10:30:00.533455 8B [01 06 00 30 00 02 08 04]
[RX] 2026-08-13T10:30:00.533502 8B [01 06 00 30 00 02 08 04]
Twelve frames, six operations. Two patterns stand out. First, the two writes (FC 05 and FC 06) get an echo back rather than new data — the response is byte-for-byte the request. Second, every read response carries a byte count that tells you how much data follows: 01 for one byte of packed bits, 04 for two registers, 02 for one register.
Note the FC 01 result: read_coils(1, 0, 8) returned eight booleans even though you might only care about the first few. That is bit-packing in action — the response byte 0x05 holds eight coils, and read_coils hands them all back. On a real device, mask off the bits you need.
The takeaway
You can read most of the Modbus devices on the planet with two calls: read_holding_registers and write_single_register. Learn those two cold, and keep the other four in your back pocket for the day you meet a device that actually uses coils and discrete inputs. When a read fails, look at the exception code before anything else — 0x02 means you have the wrong address, and no amount of retrying will fix that.
This reference is part of the oms-modbus tutorial series. oms-modbus is a transport-generic Modbus library in Rust — one API for TCP, RTU, and ASCII — 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 data model and addressing behind these function codes. Decoding Modbus Frames by Hand — how to parse these same frames from a raw hex dump.