Advisory Schedule a Technical Discovery Call — Book your session today! »

· Eduardo Vieira · Industrial Protocols  · 14 min read

Modbus Function Codes: The Definitive Guide (0x01 to 0x17)

Master Modbus function codes with real-world examples in C (libmodbus) and Python (pymodbus). A complete guide to reading coils, inputs, and registers without errors.

Master Modbus function codes with real-world examples in C (libmodbus) and Python (pymodbus). A complete guide to reading coils, inputs, and registers without errors.

Scope, evidence, and environment

This article owns Modbus application-layer request and response semantics: object addressing, function codes, byte layouts, validation, and controlled writes. It does not certify a PLC map, serial cable, gateway, network, or production procedure. The byte fixtures below were run on 2026-07-21 using CPython 3.12.3 and its standard library only; no socket, serial port, controller, credentials, or third-party client was involved. They are BENCH-ONLY—NOT VALIDATED HERE. Context7 documentation lookup returned Monthly quota exceeded, so the dated official Modbus specifications plus official PyModbus and libmodbus documentation are the source fallback. [M-A-F-005] [M-A-F-006]

The examples use a server unit identifier of 0x11 when an RTU address is needed. A Modbus PDU starts with one function-code byte. An RTU ADU adds the unit address before the PDU and a two-byte CRC after it; a TCP ADU instead adds the MBAP header, covered in the Modbus RTU and TCP guide. If bytes are absent, corrupted, or intermittent, diagnose the physical path with the RS-485 survival guide, rather than treating function-code parsing as a wiring diagnosis.

The data model is not an on-wire address

Modbus defines four logical object classes: coils are read/write bits, discrete inputs are read-only bits, input registers are read-only 16-bit registers, and holding registers are read/write 16-bit registers. Documentation often labels them with 0xxxx, 1xxxx, 3xxxx, and 4xxxx reference numbers. Those leading digits are a human convention; they are not sent in a PDU. A request carries a zero-based 16-bit starting address and a quantity. Whether a manual’s “40001” maps to offset 0, or its “40001” is merely a display label for another offset, is a device-map question that must be resolved before an integration is deployed. [M-A-F-001]

That distinction explains a common Illegal Data Address result. A valid-looking request can still ask for the wrong table, apply a one-based conversion twice, or cross the end of a vendor-defined range. Keep the source notation, normalized zero-based offset, object class, data type, scale, and engineering unit together in the integration map. Do not infer a writable register from a 4xxxx label alone: access can be restricted further by the device manual, mode, password state, or process interlock.

Read requests: 0x01, 0x02, 0x03, and 0x04

0x01 Read Coils and 0x02 Read Discrete Inputs use the same request PDU layout: function | start-hi | start-lo | quantity-hi | quantity-lo. The quantity must be 1 through 2,000. Their normal response is function | byte-count | packed-data..., where byte count is ceil(quantity / 8). The first requested bit is bit 0, the least-significant bit of the first data byte; it is not the most-significant bit. A response 01 02 4D 01 for ten requested coils represents bits 0, 2, 3, 6, and 8 as set. Unused high bits in the final byte are padding and must not be interpreted as additional objects. [M-A-F-002]

0x03 Read Holding Registers and 0x04 Read Input Registers have the same request layout but request 1 through 125 registers. Their normal response is function | byte-count | register-data..., with byte count exactly twice the requested register quantity. Each individual 16-bit register is transmitted high byte first. For example, this PDU asks for two holding registers from offset 100:

03 00 64 00 02

The normal response 03 04 00 2A 00 0B has a four-byte payload and decodes to raw words 42 and 11. It does not establish how two registers form a 32-bit integer, IEEE-754 float, timestamp, or signed engineering value. Modbus fixes byte order inside a 16-bit register; it does not standardize the word order of a multi-register application value. Preserve raw words first, then apply the word order, signedness, scale, and unit specified by the vendor. Swapping bytes because a value “looks wrong” can create a plausible but false measurement. [M-A-F-001]

Read response checks before decoding

A read parser needs context from the request. Confirm the response function matches the requested function, detect an exception before reading a byte count, and require the full declared payload to be present. For bit reads, expected byte count is (quantity + 7) // 8; for register reads it is quantity * 2. A 03 03 00 2A 00 response to the two-register request is malformed: it declares three data bytes rather than four. It must be rejected, not padded, truncated, or decoded as a shifted register.

This is more than defensive style. A permissive parser can turn a damaged response into a measurement that reaches a dashboard or control calculation. Record the raw ADU, transport context, request offset and quantity, and parser decision. A timeout means no complete response was received; an exception is a complete protocol response; a malformed response is neither a successful read nor a trustworthy exception. Keeping those cases separate makes retries and troubleshooting evidence meaningful.

Single-object writes: 0x05 and 0x06

0x05 Write Single Coil uses 05 | address-hi | address-lo | value-hi | value-lo. Its only defined values are FF 00 for ON and 00 00 for OFF. A normal response echoes the complete request PDU. Any other two-byte coil value is an invalid data value and must be rejected locally before transport. 0x06 Write Single Register has the same shape, with a 16-bit register value from 0000 through FFFF; its normal response also echoes address and value. [M-A-F-002]

WARNING: a correct frame is not permission to operate equipment. Never use 0x05 or 0x06 as a connectivity test, and never send them to production merely because a lab fixture passes. Before an authorized write, document the asset owner, maintenance window, exact normalized offset, allowed value range, current value, expected read-back, abort condition, and rollback value. Confirm that the register is not a command edge, reset, mode change, safety-related value, or one half of a multiword object. A write that is syntactically valid can still start motion, clear history, change a setpoint, or leave a process in an unsafe partial state. [M-A-F-004]

For a controlled test only, 05 00 13 FF 00 requests coil offset 19 ON, and the normal receipt has the identical five-byte PDU. 06 00 64 00 2A requests holding-register offset 100 become 42, and the normal receipt is again identical. An echo proves only that a server returned the normal write shape. A production procedure still needs an independently authorized read-back and process-level acceptance criterion.

Multiple writes: 0x0F and 0x10

0x0F Write Multiple Coils extends the request with a byte count and packed coil values:

0F | start-hi | start-lo | quantity-hi | quantity-lo | byte-count | packed values

Its quantity is 1 through 1,968, and byte count must be exactly ceil(quantity / 8). It uses the same least-significant-bit-first packing as a coil read. For ten coils starting at offset 19, 0F 00 13 00 0A 02 4D 01 declares two bytes and can carry the same bit pattern used in the read example. The normal response is shorter: 0F 00 13 00 0A, echoing only the start address and quantity. It does not echo the packed data, so retain the outgoing PDU in the change receipt. [M-A-F-002]

0x10 Write Multiple Registers is 10 | start | quantity | byte-count | register-data. Its quantity is 1 through 123; byte count must equal quantity * 2. A write of two registers at offset 100 has PDU 10 00 64 00 02 04 00 2A 00 0B and normal response 10 00 64 00 02. Validate both the declared byte count and the actual PDU length before any transport call. Do not replace an atomic vendor-defined multi-register value with two 0x06 writes: an observer may see the intermediate state, and some devices apply an update only when the documented multi-register transaction arrives.

Function-code limits and capability are separate

The application specification gives protocol maxima, but a server may support less, omit a function, or impose a smaller contiguous range. The practical request limit is therefore the intersection of the specification limit, the vendor manual, the configured unit, and the authorized operation. 0x01/0x02 allow at most 2,000 bits, 0x03/0x04 at most 125 registers, 0x0F at most 1,968 coils, and 0x10 at most 123 registers. A client must also reject zero quantity and an address-plus-quantity that exceeds the 16-bit address space before it sends bytes. [M-A-F-002]

Read and write are intentionally different decisions. A read can still burden a fragile endpoint or reveal process data, so broad scans and unit enumeration need authorization. A write changes state and needs stronger controls: least-privilege network access, isolated maintenance boundaries, an allowlist of unit/function/address/value combinations, rate limits, an operator-approved change record, read-back when safe, and an explicit rollback plan. Modbus does not provide user authentication or message encryption. Segment it from untrusted networks and restrict any bridge or gateway; do not expose a Modbus endpoint directly to the Internet. [M-A-F-004]

Read/write multiple registers: 0x17

0x17 Read/Write Multiple Registers combines a holding-register read range and a holding-register write range in one request. Its request carries read start, read quantity, write start, write quantity, byte count, and write data; its normal response carries only the read byte count and returned register data. The fixture uses 17 00 64 00 02 00 C8 00 02 04 00 2A 00 0B, validates its two-register read response, and does not treat that byte check as a server transaction. A server may not support 0x17, and support does not authorize the write half. [M-A-F-002]

The following non-live API shapes are documentation examples, not runnable connection code. PyModbus 3.11.0 documents client.readwrite_registers(read_address=100, read_count=2, write_address=200, values=[42, 11]); libmodbus 3.2.0 documents modbus_write_and_read_registers(ctx, 200, 2, src, 100, 2, dest), which writes two source registers at address 200 and reads two registers at address 100 into dest. Treat the returned status, exception, or error code as part of the operation: do not decode returned registers or assume the write happened until the library reports success. Confirm the server’s documented 0x17 limits, address map, and atomicity semantics rather than projecting this illustrative two-register frame onto a different device. Use either only against an explicitly supported, authorized server with checked errors, an approved maintenance procedure, and a rollback value. Neither snippet opens a connection here or validates device behavior. [M-A-F-005] [M-A-F-006]

Normal responses and exception responses

On a normal response, the function byte is the requested function. On an exception response, bit 7 is set and exactly one exception code follows. For example, 83 02 means that a 0x03 request received exception 0x02 Illegal Data Address; the original function is recovered as 0x83 & 0x7F = 0x03. The standard codes most useful for diagnosis are 01 Illegal Function, 02 Illegal Data Address, 03 Illegal Data Value, and 04 Server Device Failure. [M-A-F-003]

An exception is evidence, not a request to retry blindly. Code 01 suggests unsupported capability; code 02 points to table, offset, or range; code 03 points to a malformed or unacceptable value; code 04 indicates a server-side failure whose vendor diagnosis matters. Retry policy should be bounded and specific to the transport and operation. Repeating a rejected write can multiply its operational impact, while relabeling an exception as a timeout discards the best diagnostic clue in the exchange.

Deterministic fixture vectors and receipts

The following exact standard-library command validates all nine focused vectors locally. It deliberately has no file, network, device, subprocess, credential, or third-party import.

python3 - <<'PY'
def crc16(data):
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8): crc = (crc >> 1) ^ (0xA001 if crc & 1 else 0)
    return crc
def read(fc, request, response, bits=False):
    req, rsp = bytes.fromhex(request), bytes.fromhex(response)
    qty = int.from_bytes(req[3:5], 'big')
    expected = (qty + 7) // 8 if bits else qty * 2
    assert req[0] == rsp[0] == fc and rsp[1] == expected == len(rsp[2:])
    return rsp
read(1, '01 00 13 00 0A', '01 02 4D 01', True)
assert read(2, '02 00 13 00 0A', '02 02 4D 01', True)[2:] == bytes.fromhex('4D 01')
for fc in (3, 4):
    rsp = read(fc, f'{fc:02X} 00 64 00 02', f'{fc:02X} 04 00 2A 00 0B')
    assert [int.from_bytes(rsp[i:i+2], 'big') for i in (2, 4)] == [42, 11]
for fc, frame in ((5, '05 00 13 FF 00'), (6, '06 00 64 00 2A')):
    req, rsp = bytes.fromhex(frame), bytes.fromhex(frame)
    assert req[0] == fc and rsp == req
w15, r15 = bytes.fromhex('0F 00 13 00 0A 02 4D 01'), bytes.fromhex('0F 00 13 00 0A')
q15 = int.from_bytes(w15[3:5], 'big')
assert w15[5] == (q15 + 7)//8 == len(w15[6:]) and r15 == w15[:5]
w16, r16 = bytes.fromhex('10 00 64 00 02 04 00 2A 00 0B'), bytes.fromhex('10 00 64 00 02')
q16 = int.from_bytes(w16[3:5], 'big')
assert w16[5] == q16*2 == len(w16[6:]) and r16 == w16[:5]
w17 = bytes.fromhex('17 00 64 00 02 00 C8 00 02 04 00 2A 00 0B')
r17 = bytes.fromhex('17 04 00 2A 00 0B')
rq, wq = int.from_bytes(w17[3:5], 'big'), int.from_bytes(w17[7:9], 'big')
assert w17[0] == r17[0] == 0x17 and w17[9] == wq*2 == len(w17[10:])
assert r17[1] == rq*2 == len(r17[2:])
assert [int.from_bytes(r17[i:i+2], 'big') for i in (2, 4)] == [42, 11]
exc, malformed = bytes.fromhex('83 02'), bytes.fromhex('03 03 00 2A 00')
assert exc[0] & 0x80 and (exc[0] & 0x7F, exc[1]) == (3, 2)
assert malformed[1] != 4 or len(malformed[2:]) != malformed[1]
raw = bytes.fromhex('11 03 00 64 00 02')
good = raw + crc16(raw).to_bytes(2, 'little')
bad = good[:-1] + bytes([good[-1] ^ 1])
assert crc16(raw) == 0x4487 == int.from_bytes(good[-2:], 'little')
assert crc16(bad[:-2]) != int.from_bytes(bad[-2:], 'little')
valid = lambda start, qty, limit: 1 <= qty <= limit and start + qty <= 0x10000
assert valid(0, 125, 125) and not valid(0, 0, 125)
assert not valid(0, 126, 125) and not valid(0xFFFF, 2, 125)
print('V-A-F-001 PASS: FC=01,02,03,04,05,06,0F,10,17; failures=exception,malformed-length,bad-CRC,invalid-range; CRC=0x4487')
PY

Expected and observed receipt on CPython 3.12.3, 2026-07-21: exit status 0 and the literal printed line above. That line is derived from the assertions: 0x01/0x02 check packed bits; 0x03/0x04 decode [42, 11]; 0x05/0x06 check echoes; 0x0F/0x10 check request and reply shapes; and 0x17 checks both ranges, write count, and returned words. Exception, malformed-length, altered-CRC, zero/over-limit quantity, and address-overflow assertions reject their invalid cases. This receipt proves parser and encoder arithmetic only, not a live server response, interoperability, or a safe device write. [V-A-F-001]

Troubleshooting by failure class

Start with the smallest layer that can explain the evidence. If an RTU CRC fails, preserve the raw frame and investigate capture integrity, framing, and the RS-485 path; do not change an address map first. If a TCP response has the wrong transaction or MBAP length, discard it as a response to the current request and inspect connection multiplexing or gateway behavior in the protocol guide. If the function is correct but byte count is wrong, reject the payload and capture both request and response.

For 83 02, verify the object table and normalized offset against the vendor map before changing code. For 83 03, check quantity, write encoding, byte count, and permitted value. For an echoed 0x05/0x06 or a normal 0x0F/0x10 reply, verify that the reply’s address and quantity match the authorized request, then perform only the approved read-back. Never compensate for uncertain mapping with a broad scan or trial writes. Those actions create new state changes while destroying the evidence needed to identify the original error.

Limitations, navigation, and references

The fixtures cover only the nine focused functions 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, and 0x17; optional functions, vendor extensions, diagnostics, and file records remain out of scope. 0x17 is not a substitute for authorization, capability confirmation, or a vendor transaction contract. This article makes no live PLC, drive, gateway, cable, or process claim.

For ADU framing, unit identifiers, transaction handling, and RTU/TCP differences, read Modbus RTU and TCP. For termination, biasing, isolation, measurement safety, and noise symptoms, read RS-485 diagnostics. Safety warning: only qualified personnel following manufacturer procedures, LOTO/PPE requirements, and an approved maintenance plan may perform electrical work or authorized device writes. No production scan, discovery, or write is implied by this article.

Claim and source IDs

Validation receipt: V-A-F-001. Last verified: 2026-07-21.

Back to Blog

Related Posts

View All Posts »