Modbus Write Function Codes 15, 16, 22, 23: Multiple Registers, Masked Writes, and Atomic Read/Write

The advanced Modbus function codes beyond read/write-single: Write Multiple Coils (15), Write Multiple Registers (16), Mask Write Register (22), Read/Write Multiple Registers (23), and Diagnostic (08) — with byte layouts, verified frame examples, the full exception code table, and runnable Rust code.

OrangeHorse Engineering Team 13 min read

Modbus Write Function Codes 15, 16, 22, 23

The six workhorse function codes — Read Coils through Write Single Register — cover the common case: read a value, write a value. But they have a hard limit baked into their name. Write Single Register writes one register per request. Write a setpoint table of 100 registers with FC 06 and you issue 100 round-trips, each one paying the full RS-485 turnaround latency and the 3.5-character silent interval.

Modbus was designed in 1979 for exactly this problem, and the answer is the multiple-write function codes: write many registers in one frame, or modify a register atomically without a read-modify-write race. This article covers the four write codes beyond the singles — FC 15, 16, 22, and 23 — plus the often-overlooked FC 08 Diagnostic, and closes with the complete exception-code table.

Every frame shown here was captured from a running program; the CRC is computed by the library and verified against the Modbus spec.

The advanced codes at a glance

FCNameWhat it doesResponse
08DiagnosticSub-function control: echo data, clear countersEcho (varies by sub-function)
15Write Multiple CoilsWrite 1–1968 coils in one frameAddress + quantity
16Write Multiple RegistersWrite 1–123 registers in one frameAddress + quantity
22Mask Write RegisterAtomic AND/OR on one registerEcho of request
23Read/Write Multiple RegistersRead and write in one transactionRead-back data

The two workhorses are 16 and 23. FC 16 is the bulk-write; FC 23 is the atomic read-modify-write. FC 22 is the surgical alternative when you need to flip specific bits of a register without touching the rest. FC 15 is FC 16's little sibling for coils.

FC 15 — Write Multiple Coils

Writes a run of digital outputs in one frame. The coil values are bit-packed into the request: eight coils per byte, least-significant bit first, exactly like the FC 01 response format.

Request

ByteField
0Slave address
1Function code 0x0F
2–3Starting coil address
4–5Quantity of coils (1–1968)
6Byte count = ceil(quantity / 8)
7…Coil data, bit-packed, LSB first
CRC-16 (low byte first)

Response — echoes the address and quantity; no data.

01 0F 00 00 00 04 01 03 7E 97     request  — write 4 coils at 0: ON, ON, OFF, OFF
01 0F 00 00 00 04 54 08           response — echo: address 0, quantity 4

The data byte 03 is 0b00000011 — coils 0 and 1 ON, coils 2 and 3 OFF. The response does not echo the data, only 00 00 (address) and 00 04 (quantity), which is how the master confirms the write landed.

FC 16 — Write Multiple Registers

The bulk-write code: write up to 123 registers in a single request. This is the code you use to push a calibration table, a schedule, or a block of setpoints in one round-trip instead of 123.

Request

ByteField
0Slave address
1Function code 0x10
2–3Starting register address
4–5Quantity of registers (1–123)
6Byte count = quantity × 2
7…Register values, big-endian (high byte first)
CRC-16

Response — echoes address and quantity.

01 10 00 00 00 03 06 00 6F 00 DE 01 4D 93 16     request  — write 3 registers: 111, 222, 333
01 10 00 00 00 03 80 08                           response — echo: address 0, quantity 3

The three values are 00 6F = 111, 00 DE = 222, 01 4D = 333, each big-endian. The quantity limit of 123 is not arbitrary: 123 registers × 2 bytes = 246 bytes of values, plus 5 bytes of address/quantity/byte-count overhead = 251 bytes of PDU data, plus the 1-byte function code = a 252-byte PDU — just under the Modbus spec's 253-byte PDU maximum. One more register would overflow it.

FC 22 — Mask Write Register

The atomic bit-level write. Given an AND-mask and an OR-mask, the slave computes:

result = (current AND and_mask) OR (or_mask AND (NOT and_mask))

The AND-mask clears the bits you want to set (because and_mask has 0 there), and the OR-mask sets them. This is the classic way to flip individual flags in a status/control register without reading it first — no read-modify-write window for another master (or an interrupt) to slip a write in between.

Request

ByteField
0Slave address
1Function code 0x16
2–3Register address
4–5AND-mask
6–7OR-mask
8–9CRC-16

Response — echoes the full request.

01 16 00 0A 00 FF FF 00 1F C7     request  — register 10: AND=0x00FF, OR=0xFF00
01 16 00 0A 00 FF FF 00 1F C7     response — echo

With register 10 holding 0x01F4 (500) before the write:

(0x01F4 & 0x00FF) | (0xFF00 & ~0x00FF) = 0x00F4 | 0xFF00 = 0xFFF4

The low byte is preserved (F4), the high byte is set to FF. A read back confirms 0xFFF4. The mask-write is atomic within the slave: oms-modbus's SlaveStore takes a single write lock for the whole AND/OR operation, so no other request can observe the intermediate state.

FC 23 — Read/Write Multiple Registers

The compound code: read one register range and write another in a single transaction. The write is applied first, then the read range is returned — which is what makes the classic "swap a value and get the old one back" pattern work.

Request

ByteField
0Slave address
1Function code 0x17
2–3Read start address
4–5Read quantity (1–125)
6–7Write start address
8–9Write quantity (1–121)
10Write byte count = write quantity × 2
11…Write data, big-endian
CRC-16

Response — byte count + read-back data only.

01 17 00 14 00 04 00 14 00 03 06 00 64 00 C8 01 2C 1D EB     request  — read 4 @ 20, write 3 @ 20
01 17 08 03 E8 07 D0 0B B8 00 00 BF E8                       response — old values 1000, 2000, 3000, 0

Here read address and write address are both 0x0014 (20). The request reads four registers starting at 20, and writes three registers (100, 200, 300) to the same address. The response returns the pre-write contents: 03 E8 = 1000, 07 D0 = 2000, 0B B8 = 3000, 00 00 = 0.

Two things to know about FC 23:

  1. The write happens before the read. The returned data is the state before the write, which is how you implement an atomic "give me the old value and install this new one."
  2. Atomicity is not guaranteed by the spec. The Modbus spec does not require FC 23 to be atomic, and oms-modbus's SlaveStore documents this explicitly: the read lock is released before the write lock is acquired. On a single-master RS-485 bus this is fine; on a shared bus with multiple masters, a concurrent write could land between the read and the write. If you need true atomicity on a multi-master bus, use FC 22 (which is atomic inside the slave) instead.

FC 08 — Diagnostic

A meta-code: it does not touch coils or registers, it exercises the device's diagnostic sub-system. The two bytes after the function code are the sub-function code, and the two after that are the data field.

Sub-functionNameResponse
0x0000Return Query DataEchoes the request — the simplest "is the device alive?" test
0x000AClear CountersClears the diagnostic counters
0x000B0x000EReturn Bus Message CountReturns the communication counters

The most useful is 0x0000: the device echoes your request back verbatim, proving the frame made it there and back intact.

01 08 00 00 12 34 ED 7C     request  — Return Query Data with 0x1234
01 08 00 00 12 34 ED 7C     response — echo

It is the Modbus equivalent of ping — no register map needed, works on any device that implements FC 08.

The complete exception code table

When any of these writes fail, the slave sets bit 7 of the function code and returns a one-byte exception code. Here is the full standard set (from the Modbus Application Protocol spec), as modeled by oms-modbus's Exception type:

CodeNameMeaning
0x01Illegal FunctionThe device does not implement that function code
0x02Illegal Data AddressThe address (or address + quantity) is out of range
0x03Illegal Data ValueA value is out of range or otherwise invalid
0x04Server Device FailureUnrecoverable error while executing
0x05AcknowledgeAccepted but still processing — retry later
0x06Server Device BusyBusy with a long command — retry later
0x07Negative AcknowledgeThe requested operation cannot be performed
0x08Memory Parity ErrorThe device detected a parity error in memory
0x0AGateway Path UnavailableThe gateway cannot reach the target
0x0BGateway Target Device Failed to RespondThe gateway's target is not answering

Two of these deserve emphasis. 0x02 Illegal Data Address is the single most common error in the field: a write to 0x0030 fails because that sensor's address register is actually 0x0020 — the datasheet was wrong, or you mixed up 0-based and 1-based addressing (see the pitfalls article). 0x03 Illegal Data Value is what you get when you write a value the device's range check rejects — for example, a coil value that is neither 0xFF00 nor 0x0000.

All four writes in one program

The program below emulates a small device on an in-memory RTU bus and exercises FC 15, 16, 22, 23, and 08 in turn, while a BusCapture records every frame. Swap the duplex 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 holding registers and coils pre-populated ──
    let store = Arc::new(SlaveStore::with_holding_registers(&[
        (0, 100),   // holding[0x0000]
        (1, 200),   // holding[0x0001]
        (10, 500),  // holding[0x000A] — mask-write target
        (20, 1000), // holding[0x0014] — read/write target
        (21, 2000),
        (22, 3000),
    ]));
    store.write_coil(0, true);
    store.write_coil(1, false);
    store.write_coil(2, true);

    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 15 — Write Multiple Coils ───────────────────────────────────
    client
        .write_multiple_coils(1, 0, &[true, true, false, false])
        .await?;
    let coils = client.read_coils(1, 0, 4).await?;
    println!("FC15 write_multiple_coils      -> {coils:?}");

    // ── FC 16 — Write Multiple Registers ───────────────────────────────
    client.write_multiple_registers(1, 0, &[111, 222, 333]).await?;
    let regs = client.read_holding_registers(1, 0, 3).await?;
    println!("FC16 write_multiple_registers  -> {regs:?}");

    // ── FC 22 — Mask Write Register (AND=0x00FF, OR=0xFF00) ────────────
    client.mask_write_register(1, 10, 0x00FF, 0xFF00).await?;
    let masked = client.read_holding_registers(1, 10, 1).await?;
    println!("FC22 mask_write_register       -> {:#06X}", masked[0]);

    // ── FC 23 — Read/Write Multiple Registers (atomic swap) ────────────
    let old = client
        .read_write_multiple_registers(1, 20, 4, 20, &[100, 200, 300])
        .await?;
    let new = client.read_holding_registers(1, 20, 4).await?;
    println!("FC23 read/write: old {old:?} -> new {new:?}");

    // ── FC 08 — Diagnostic (0x0000 = Return Query Data) ────────────────
    let echo = client.diagnostic(1, 0x0000, 0x1234).await?;
    println!("FC08 diagnostic                -> {echo:?}");

    // ── Dump the captured frames ───────────────────────────────────────
    println!();
    for pkt in cap.drain() {
        println!("{pkt}");
    }

    Ok(())
}

Output (timestamps vary by run):

FC15 write_multiple_coils      -> [true, true, false, false, false, false, false, false]
FC16 write_multiple_registers  -> [111, 222, 333]
FC22 mask_write_register       -> 0xFFF4
FC23 read/write: old [1000, 2000, 3000, 0] -> new [100, 200, 300, 0]
FC08 diagnostic                -> (0, 4660)

[TX] 10B  [01 0F 00 00 00 04 01 03 7E 97]
[RX]  8B  [01 0F 00 00 00 04 54 08]
[TX]  8B  [01 01 00 00 00 04 3D C9]
[RX]  6B  [01 01 01 03 11 89]
[TX] 15B  [01 10 00 00 00 03 06 00 6F 00 DE 01 4D 93 16]
[RX]  8B  [01 10 00 00 00 03 80 08]
[TX]  8B  [01 03 00 00 00 03 05 CB]
[RX] 11B  [01 03 06 00 6F 00 DE 01 4D 54 E3]
[TX] 10B  [01 16 00 0A 00 FF FF 00 1F C7]
[RX] 10B  [01 16 00 0A 00 FF FF 00 1F C7]
[TX]  8B  [01 03 00 0A 00 01 A4 08]
[RX]  7B  [01 03 02 FF F4 F8 33]
[TX] 19B  [01 17 00 14 00 04 00 14 00 03 06 00 64 00 C8 01 2C 1D EB]
[RX] 13B  [01 17 08 03 E8 07 D0 0B B8 00 00 BF E8]
[TX]  8B  [01 03 00 14 00 04 04 0D]
[RX] 13B  [01 03 08 00 64 00 C8 01 2C 00 00 91 F4]
[TX]  8B  [01 08 00 00 12 34 ED 7C]
[RX]  8B  [01 08 00 00 12 34 ED 7C]

A few patterns in the trace worth reading closely:

  • FC 15 and FC 16 responses are short — just address + quantity. The master trusts that a non-exception response with the matching address means the write landed.
  • FC 22 and FC 08 responses are full echoes — the mask write echoes the entire request (address + both masks), and the diagnostic echoes the sub-function + data. You can diff the two sides byte-for-byte.
  • The FC 15 read-back returned eight booleans even though the program asked for four — read_coils returns a whole byte (8 bits) because the response is bit-packed; the first four are the coils you wrote. The mask-write read-back confirms 0xFFF4, and the FC 23 read-back confirms the swap (1000→100).

What about FC 43 (Read Device ID)?

One code you will see in datasheets but not in this article is FC 43, Read Device Identification — the standard way to read a device's vendor name, product code, and firmware revision as ASCII strings. And FC 17, Report Server ID, which returns a similar identification blob.

oms-modbus v0.2.0 implements the ten codes that cover the overwhelming majority of real-world traffic — FC 01–06, 08, 15, 16, 22, and 23. FC 43 and FC 17 are on the roadmap but not yet in the release. That is a deliberate scoping decision: the library's Request/Response model is an exhaustive enum with one variant per function code, and each variant has a fully validated encoder/decoder. Adding FC 43 means adding a variant plus its encode/decode paths and tests — the kind of change that should land with the same verification bar as everything else, not as a half-tested append.

If you need to identify a device today, most sensors expose the same information through ordinary holding registers — the OHTS1022, for example, reports its model and firmware through a register block you can read with a plain FC 03.

The takeaway

  • Write multiple registers (FC 16) when you need to push more than one register at a time — one round-trip instead of N.
  • Mask write (FC 22) when you need to flip bits in a register atomically without a read-modify-write race.
  • Read/write multiple (FC 23) when you need to read and write in one transaction — the write lands first, the old value comes back.
  • Diagnostic (FC 08, sub-function 0x0000) as a hardware-independent ping.
  • When a write fails, read the exception code before retrying — 0x02 means wrong address, and no amount of retrying fixes a wrong address.

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 Function Codes 01–06 — the six single-read/single-write codes. Modbus Pitfalls & Best Practices — endianness, addressing, and float encoding, the mistakes that bite every integration.

modbus modbus function code modbus function code 16 write multiple registers mask write register read write multiple registers modbus diagnostic modbus exception codes rust iiot