Validating Modbus RTU CRC-16 Frames in Python
Serial power meters, RTD bridges, and PLC-fronted instruments almost universally speak Modbus RTU, and every RTU frame carries a two-byte CRC-16/MODBUS trailer that most Python code either ignores or verifies with a subtly wrong byte order. This guide covers end-to-end validation of RTU frames read from a pyserial handle: the CRC-16/MODBUS parameter set, the little-endian trailer, the 3.5-character inter-frame silence that actually delimits a frame, and how to catch a Modbus exception response before it is mistaken for data. It sits under Checksum & CRC Validation.
Unlike the streaming CRC32 reassembler used for high-rate binary telemetry, RTU framing has no sync header — a frame is defined purely by silence on the wire. That single fact drives every decision below, and it is the reason a naive reader that splits on byte count instead of timing will merge two frames and reject both.
RTU Frame Anatomy and the 3.5-Character Silence
A Modbus RTU frame is deliberately minimal: a one-byte slave address, a one-byte function code, a variable-length data field, and a two-byte CRC-16 trailer. There is no start-of-frame delimiter and no length prefix in the request; the master and slave agree on structure by function code alone. What separates one frame from the next is timing — the standard mandates a gap of at least 3.5 character times of idle line before a frame is considered complete, and any inter-byte gap exceeding 1.5 character times inside a frame marks it as malformed.
At a given baud rate a Modbus character is 11 bits (1 start, 8 data, 1 parity, 1 stop), so the silence interval is a direct function of the line speed:
At 9600 baud that is roughly 4.0 ms; above 19200 baud the specification fixes the value at 1.750 ms regardless of the actual character time, because chasing ever-shorter gaps stops being reliable on general-purpose UARTs. In Python you do not measure this gap in userspace with any accuracy — you approximate it by configuring the pyserial inter-byte and read timeouts so that one read() returns exactly one frame, then treat the CRC as the arbiter of whether that framing assumption held. When the silence is lost and two frames merge, the CRC over the combined bytes will not match, which is precisely the signal you want.
Modbus RTU frame layout: the CRC-16 covers address, function, and data, and is appended low byte first; the 3.5-character idle gap on each side is what actually delimits the frame.
CRC-16/MODBUS as a Reflected LFSR
CRC-16/MODBUS is the reflected polynomial 0x8005, which in its bit-reversed processing form is applied as 0xA001. The register initialises to 0xFFFF, there is no final XOR, and both input and output are reflected — so the computation shifts right and conditionally XORs with 0xA001 on a set low bit. For each payload byte b_k, the byte is XORed into the low half of the register, then the register is shifted eight times:
applied for each of the 8 bits of r \leftarrow r \oplus b_k, starting from r_0 = \mathtt{0xFFFF}, with the final 16-bit register taken directly as the CRC (no inversion). The result is what the slave places in the trailer — but it places the low byte first. That little-endian trailer ordering is the single most common source of a validator that computes the right CRC yet rejects every frame: the 16-bit value 0x0A84 is transmitted on the wire as 0x84 0x0A, so the trailer must be read with struct.unpack("<H", ...) (little-endian), not big-endian.
The canonical worked example is the request 01 03 00 00 00 01 — read one holding register from slave 1. The CRC-16/MODBUS over those six bytes is 0x0A84, appended as 84 0A, giving the complete on-wire frame 01 03 00 00 00 01 84 0A. Any validator you write must reproduce that exact trailer, and it makes an ideal fixed vector for a regression test. The choice of 0xA001 over other 16-bit polynomials is a protocol constraint here, but the reasoning behind polynomial selection for a new instrument link is covered in Choosing CRC Polynomials for Instrument Protocols.
Production RTU Frame Validator
The implementation below computes the CRC with a bitwise routine (correct and dependency-free; swap in a 256-entry table for high-throughput polling), reads a candidate frame from a pyserial handle, splits payload from the little-endian trailer, verifies it, and — critically — inspects the function code for the exception bit before returning the payload as data.
from __future__ import annotations
import struct
from dataclasses import dataclass
import serial # pyserial
class RTUFrameError(Exception):
"""Raised when a frame is too short to contain address + function + CRC."""
class RTUCRCError(Exception):
"""Raised when the recomputed CRC-16 does not match the trailing bytes."""
class ModbusExceptionResponse(Exception):
"""Raised when the slave returns a function code with the 0x80 error bit set."""
def __init__(self, address: int, function: int, code: int) -> None:
self.address = address
self.function = function & 0x7F # strip the error flag to recover the original
self.exception_code = code
super().__init__(
f"slave {address}: function 0x{self.function:02X} raised "
f"Modbus exception 0x{code:02X}"
)
def modbus_crc16(frame: bytes) -> int:
"""Compute the CRC-16/MODBUS (reflected 0xA001, init 0xFFFF, no final XOR).
Returns the 16-bit integer; on the wire it is appended low byte first.
"""
crc = 0xFFFF
for byte in frame:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if (crc & 1) else (crc >> 1)
return crc & 0xFFFF
@dataclass(frozen=True)
class RTUFrame:
address: int
function: int
data: bytes
def validate_rtu_frame(raw: bytes) -> RTUFrame:
"""Verify a complete RTU frame and surface exception responses.
Splits the trailing little-endian CRC, recomputes it over the remaining
bytes, and raises on any integrity or protocol fault. A frame whose
function code has bit 7 set is a Modbus exception response, not data.
"""
if len(raw) < 4: # address + function + 2-byte CRC minimum
raise RTUFrameError(f"frame {len(raw)}B shorter than minimum 4B")
payload, trailer = raw[:-2], raw[-2:]
transmitted = struct.unpack("<H", trailer)[0] # low byte first
computed = modbus_crc16(payload)
if computed != transmitted:
raise RTUCRCError(
f"CRC mismatch: computed 0x{computed:04X}, "
f"trailer 0x{transmitted:04X} over {len(payload)}B"
)
address, function = payload[0], payload[1]
if function & 0x80: # exception response: [addr][func|0x80][exception code]
code = payload[2] if len(payload) > 2 else 0
raise ModbusExceptionResponse(address, function, code)
return RTUFrame(address=address, function=function, data=payload[2:])
def read_rtu_response(port: serial.Serial, expected_len: int) -> RTUFrame:
"""Read one RTU frame from an open pyserial handle and validate it.
``expected_len`` is the full frame length the master computes from the
request's function code; the read timeout must exceed the 3.5-char gap so
a single read returns exactly one frame.
"""
raw = port.read(expected_len)
if len(raw) < expected_len:
# Short read: the frame boundary silence was reached early or the
# slave never answered. Owned by the timeout/retry layer, not here.
raise RTUFrameError(
f"read {len(raw)}B of expected {expected_len}B before inter-frame silence"
)
return validate_rtu_frame(raw)
The exception-response check is not optional polish. When a slave cannot service a request — illegal address, illegal data value, device busy — it does not stay silent; it replies with the function code ORed with 0x80 followed by a one-byte exception code. That reply has a perfectly valid CRC, so a validator that stops at the CRC check will hand three bytes of “data” to the parser and report a bogus register value. Trapping the 0x80 bit and raising ModbusExceptionResponse routes the fault into Error Code Categorization instead.
Verifying Against a Known Frame and a Live Meter
The fastest confidence check is the canonical vector — assert that the validator reproduces the 84 0A trailer and round-trips a real response frame.
def build_rtu(payload: bytes) -> bytes:
"""Append the little-endian CRC-16/MODBUS to a payload."""
return payload + struct.pack("<H", modbus_crc16(payload))
# 1. Canonical vector: read-holding-register request → CRC 0x0A84, trailer 84 0A.
req = bytes.fromhex("01 03 00 00 00 01")
assert modbus_crc16(req) == 0x0A84
assert build_rtu(req).hex(" ") == "01 03 00 00 00 01 84 0a"
# 2. A valid response: slave 1, function 3, 2 bytes read = 0x00FB (251).
resp = build_rtu(bytes.fromhex("01 03 02 00 FB"))
frame = validate_rtu_frame(resp)
assert frame.address == 1 and frame.function == 0x03
assert struct.unpack(">H", frame.data[1:])[0] == 251
# 3. An exception response (illegal data address, code 0x02) is trapped, not returned.
exc = build_rtu(bytes.fromhex("01 83 02"))
try:
validate_rtu_frame(exc)
assert False, "exception frame should have raised"
except ModbusExceptionResponse as e:
assert e.function == 0x03 and e.exception_code == 0x02
Against a live power meter, three indicators confirm the framing is sound. First, a poll that returns RTUCRCError only intermittently, correlated with bus load, points at lost inter-frame silence rather than corruption — the physical layer is fine but reads are straddling frame boundaries. Second, a steady stream of ModbusExceptionResponse with code 0x02 means the register map is wrong, not the wiring. Third, if every frame fails the CRC but the computed value looks like the transmitted value with its bytes swapped, the trailer is being read big-endian; that is a one-line fix confirmed by the 84 0A vector above. Payloads that clear both checks flow on to Binary & ASCII Format Parsing for register decoding and scaling.
Failure Modes Specific to RTU CRC Validation
These faults are distinct from the polynomial-selection and endianness catalogue on the parent Checksum & CRC Validation page — they are the ones that surface specifically when validating RTU frames off a serial line.
CRC byte order swapped. The most frequent bug: reading the trailer as big-endian, so the computed 0x0A84 is compared against 0x840A and every frame is rejected. The register math is correct; only the trailer interpretation is wrong. Diagnose by printing both values in hex — if one is the byte-swap of the other, switch the struct.unpack format from ">H" to "<H". RTU is always little-endian in its CRC field.
Frame boundary lost — merged frames. If the read timeout is shorter than the 3.5-character silence, or the reader keeps pulling bytes past one frame, two responses land in a single buffer. The CRC over the concatenation never matches, so both are lost even though each was individually intact. The fix is timing, not CRC logic: size the read to the frame length the function code implies and set the pyserial inter-byte timeout to bracket the 3.5-char gap. A merged-frame symptom is a CRC failure whose buffer length is a clean multiple of a valid frame size.
RS-485 turnaround echo. On a two-wire half-duplex RS-485 segment, the driver’s own transmitted request is often echoed back into the receive buffer before the slave replies. A reader that grabs bytes immediately reads its own request as the “response,” and its CRC — while valid — belongs to the request, not an answer. Flush the input buffer after transmit, or explicitly consume and discard the echoed request bytes, before reading the reply. This is a framing artifact of the transceiver, and it interacts with Timeout Handling & Retry Logic: the echo arrives immediately while the real reply arrives after the slave’s processing delay.
Exception frame misread as data. As above, a slave error reply sets the 0x80 bit on the function code and carries a one-byte exception code with a valid CRC. Skipping the function-code inspection means a three-byte exception frame is decoded as a register payload, yielding a plausible but fabricated reading. Always test function & 0x80 before treating the remaining bytes as data, and translate the exception code through Error Code Categorization so a “device busy” (0x06) triggers a retry while an “illegal data address” (0x02) triggers an operator alert.
Related
- Checksum & CRC Validation — the verification gate and full algorithm reference matrix this RTU validator specialises.
- Choosing CRC Polynomials for Instrument Protocols — why
0xA001and how to reason about polynomial selection for a new link. - PySerial Configuration & Tuning — inter-byte and read timeouts that approximate the 3.5-character silence.
- Timeout Handling & Retry Logic — separating a silent slave and RS-485 echo from a genuine CRC failure.
- Binary & ASCII Format Parsing — decoding and scaling the register values that clear the CRC gate.
← Back to Checksum & CRC Validation
References
- Python
structmodule reference — little-endian (<H) trailer packing and byte-order format codes. - pyserial API reference — read/inter-byte timeout and input-buffer flush semantics for RS-485 framing.