Modbus Function Codes 01–06: A Reference with Frame Examples

Every standard Modbus function code from Read Coils (01) to Write Single Register (06), with request/response byte layouts, verified frame examples, exception responses, and runnable Rust code.

OrangeHorse Engineering Team 10 min read

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).

FCNameOperates onAccess
01Read Coils1-bit outputsread
02Read Discrete Inputs1-bit inputsread
03Read Holding Registers16-bit read/write registersread
04Read Input Registers16-bit read-only registersread
05Write Single Coil1-bit outputswrite
06Write Single Register16-bit read/write registerswrite

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

ByteField
0Slave address
1Function code 0x01
2–3Starting coil address
4–5Quantity of coils (1–2000)
6–7CRC-16 (low byte first)

Response

ByteField
0Slave address
1Function code 0x01
2Byte 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

ByteField
0Slave address
1Function code 0x03
2–3Starting register address
4–5Quantity of registers (1–125)
6–7CRC-16 (low byte first)

Response

ByteField
0Slave address
1Function code 0x03
2Byte 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:

RegisterMeasurement
0Soil temperature
1Soil moisture
2Soil salinity
3Electrical conductivity
4Soil pH
5Nitrogen (N)
6Phosphorus (P)
7Potassium (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:

CodeNameMeaning
0x01Illegal FunctionThe device does not implement that function code
0x02Illegal Data AddressThe register/coil address does not exist
0x03Illegal Data ValueThe value is out of range (e.g. writing a non-0xFF00 coil)
0x04Slave Device FailureAn unrecoverable device error while processing
0x05AcknowledgeAccepted but busy — retry later
0x06Slave Device BusyProcessing 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.

modbus modbus function code modbus function code 03 read holding registers write single register read coils modbus rtu modbus exception rust tutorial iiot