CRC-16 in 668 Nanoseconds: Inside oms-modbus's Modbus CRC Implementation

How oms-modbus computes the Modbus CRC-16 checksum — from the bit-by-bit textbook algorithm to a compile-time 256-entry lookup table — and why it validates a full 256-byte frame in under a microsecond.

OrangeHorse Engineering Team 7 min read

Every Modbus RTU frame ends with two CRC-16 bytes. The polynomial is 0x8005 (reflected as 0xA001), the algorithm is CRC-16/MODBUS — also called CRC-16-IBM or CRC-16-ANSI — and the reference implementation has been in the public domain since 1979. Billions of Modbus transactions run this checksum every day across the world's industrial infrastructure. If you can make it faster, you make every RS-485 device that runs it faster.

oms-modbus validates the CRC-16 of a 256-byte frame in 668 nanoseconds — about 2.6 nanoseconds per byte. Here is how the implementation works, and why a forty-year-old checksum still matters for industrial data integrity.

What the Modbus CRC-16 Actually Checks

The Modbus CRC-16 is an error-detection code, not a cryptographic hash. It protects against the failure modes of an RS-485 bus: bit flips from electrical noise, dropped bytes from buffer overruns, and merged frames from timing violations. The polynomial 0x8005 was chosen because, as a 16-bit CRC, it detects:

  • All single-bit errors
  • All double-bit errors
  • All odd numbers of bit errors
  • All burst errors up to 16 bits long
  • 99.998% of longer burst errors

For a protocol that runs on 2-kilometer RS-485 cables through factory floors, this is exactly the right set of guarantees: strong enough to catch every plausible physical-layer error, cheap enough to compute on a 1970s microcontroller.

The CRC is computed over the whole frame — slave address, function code, and data — then transmitted as two bytes with the low byte first. That byte order trips people up constantly: the raw CRC for the request [01 03 00 00 00 01] is 0x0A84, but it goes on the wire as 84 0A. oms-modbus returns the raw value and writes it little-endian via put_u16_le, matching the spec.

The Naive Algorithm: Bit by Bit

The textbook algorithm processes one bit at a time. For each byte, XOR it into the low byte of the CRC, then shift eight times, conditionally XOR-ing the polynomial whenever the bit shifted out was a 1:

/// Bit-by-bit CRC-16 (polynomial 0xA001, reflected).
fn crc16_slow(data: &[u8]) -> u16 {
    let mut crc: u16 = 0xFFFF;
    for &byte in data {
        crc ^= byte as u16;
        for _ in 0..8 {
            if crc & 0x0001 != 0 {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    crc
}

This is correct, and it is what most "how to compute Modbus CRC in C/Python/Java" tutorials show. But the inner loop runs eight times per byte, each iteration a branch that a modern CPU cannot predict well. It works, but it is the slowest correct implementation.

The Table-Driven Approach: Byte by Byte

The standard optimization replaces the inner bit loop with a 256-entry lookup table. The key insight: the eight shift-and-XOR steps for a byte can be precomputed. You XOR the next data byte into the low byte of the CRC to form a table index, then replace the CRC with a single lookup:

/// Compile-time generated 256-entry lookup table (polynomial 0xA001, reflected).
const CRC_TABLE: [u16; 256] = {
    let mut table = [0u16; 256];
    let mut i = 0;
    while i < 256 {
        let mut crc = i as u16;
        let mut j = 0;
        while j < 8 {
            if crc & 0x0001 != 0 {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
            j += 1;
        }
        table[i] = crc;
        i += 1;
    }
    table
};

/// Table-driven CRC-16 — one byte per iteration.
fn crc16_table(data: &[u8]) -> u16 {
    let mut crc = 0xFFFFu16;
    for &byte in data {
        let idx = ((crc as u8) ^ byte) as usize;
        crc = (crc >> 8) ^ CRC_TABLE[idx];
    }
    crc
}

One byte per iteration, one XOR, one shift, one table lookup — no inner branch. This is the standard fast CRC, and it is exactly what oms-modbus ships. The const CRC_TABLE block runs at compile time: the 256-entry table is baked into the binary, so there is no runtime initialization cost and no heap allocation.

Two details are worth noting, because they are the two most common mistakes in hand-rolled Modbus CRC code:

  1. The index is (crc as u8) ^ byte — only the low byte of the running CRC participates, cast to u8. Getting this wrong (using the full u16) produces a plausible-looking but incorrect table.
  2. The table is generated from the reflected polynomial 0xA001, not the unreflected 0x8005. The bit-reversal is baked into the table values, so the per-byte loop stays a clean (crc >> 8) ^ table[idx].

What oms-modbus Does Differently

Two design choices separate this implementation from a naive one:

Compile-time table generation. The const block above is the generator — it is the exact code in src/codec.rs. The same loop you would run once at startup to fill a [u16; 256] is instead evaluated by rustc. The result is a read-only constant in .rodata, shared across every CRC call, with zero initialization cost.

No unsafe, no SIMD, no hardware CRC. The crate declares #![forbid(unsafe_code)] at the top of lib.rs. That rules out hand-written SIMD intrinsics and inline assembly outright. It also keeps the code portable: a single 256-entry table works identically on x86-64, ARM, RISC-V, and every other target Rust supports, including no_std embedded contexts. For a checksum that must run on a microcontroller doing sensor polling, predictability and portability beat an extra 2× from target-specific intrinsics.

The honest trade-off: an AVX2 SIMD CRC-16 could go faster on a workstation. But it would need unsafe, nightly-only or target_feature-gated code, and per-architecture paths — none of which buy anything for a Modbus device whose bottleneck is the serial line, not the CPU. The 256-entry table is fast enough everywhere, and that is the point.

The Benchmark

The canonical numbers, from the project's published benchmark (Intel Core i7-6800K @ 3.40 GHz, Rust 1.97, release build):

PayloadTimePer byte
6-byte RTU request16 ns~2.7 ns
256-byte frame (RTU max)668 ns~2.6 ns

The 256-byte number is the interesting one: it is the largest legal Modbus RTU ADU (1-byte slave address + 253-byte PDU + 2-byte CRC). Validating the biggest possible frame takes under a microsecond — three orders of magnitude below the time it takes to transmit those 256 bytes at 9600 baud (roughly 267 milliseconds).

The benchmark itself lives in benches/crc_bench.rs and compares calculate_crc against the crc crate's CRC_16_MODBUS at three sizes — 6, 64, and 256 bytes — as a sanity check that the implementation is not just fast but correct against an independent reference. Run it with:

cargo bench --bench crc_bench

CRC in a Diagnostic Context

A CRC error in Modbus is not just "the data is wrong." It is a signal. CRC errors on a production bus indicate:

  • Noise — electromagnetic interference coupling into the RS-485 cable
  • Ground loops — potential differences between nodes corrupting the signal
  • Timing violations — frames merged because the 3.5T rule was violated
  • Buffer overruns — the receiver dropped bytes because the application could not keep up

oms-modbus's WireTap records every CRC mismatch with a microsecond timestamp and the raw bytes that caused it. A diagnostic engineer can replay the capture and see exactly which sensor, on which register read, produced which bad CRC — and whether the noise correlates to a nearby motor start, a lightning strike, or a firmware timing bug in the sensor itself.


The CRC-16 implementation in oms-modbus is a compile-time-generated 256-entry lookup table (polynomial 0xA001, reflected, init 0xFFFF), verified against the Modbus Protocol Reference Guide (PI-MBUS-300 Rev. J) test vectors and cross-checked against an independent implementation in the crc crate. Source on GitHub.

Read next: Decoding Modbus Frames by Hand — see where the CRC sits in a real frame and how to verify it byte by byte. Field Troubleshooting with WireTap — what a CRC error looks like on a live bus.

modbus CRC-16 performance rust optimization checksum benchmark