Modbus ASCII Mode: Frame Format, LRC, and Why RTU Won

The human-readable Modbus transmission mode, decoded byte by byte — the colon/CRLF framing, hex encoding, the LRC checksum, and how it compares to RTU's CRC and timing.

OrangeHorse Engineering Team 7 min read

Modbus ASCII Mode: Frame Format, LRC, and Why RTU Won

Most Modbus you will meet is RTU or TCP. But the standard actually defines a third wire format — Modbus ASCII — and it is still out there on legacy PLCs, flow meters, and lab instruments. If you ever wire up a device that stubbornly refuses to talk RTU and asks for "ASCII mode" in its configuration, this is the format it is speaking.

ASCII mode is worth understanding for two reasons beyond mere compatibility. First, it is readable: a frame is printable characters, so you can watch one go past on a serial console and decode it by eye. Second, it teaches the two design choices that separate it from RTU — framing by explicit delimiters instead of timing, and a checksum you can add up by hand instead of a CRC — and understanding those choices makes RTU's design make more sense.

Two transmission modes, one protocol

Modbus over serial has two transmission modes. The data they carry — the address, function code, and PDU — is identical. What differs is how those bytes are framed and checked:

Modbus RTUModbus ASCII
Byte encodingBinary — 1 byte on the wire is 1 byteHex ASCII — 1 byte becomes 2 printable characters
Start / endNone (relies on a ≥3.5-character silent gap): (0x3A) start, CR LF (0x0D 0x0A) end
Error checkCRC-16 (2 bytes, low byte first)LRC (1 byte, as 2 characters)
Serial params8 data bits7 data bits + parity (or 8 + 2 stop bits)
Size~2× (every byte is two characters)

One fact is easy to misread: in the Modbus standard, RTU is required and ASCII is optional. A device may implement RTU only, or both — but never ASCII alone. That is why RTU is the default you see everywhere, and ASCII survives mainly in hardware that predates RTU's dominance or in niches where human-readability matters.

The frame, character by character

A Modbus ASCII frame wraps the exact same bytes RTU would send, but renders each one as two uppercase hex characters, adds a : prefix, computes an LRC, and ends with CR LF. Here is a "read holding register 0, quantity 1, slave 1" request:

: 01 03 00 00 00 01 FB \r \n
│  │  │  └─────────┘  │  │  └─ LF (line feed)
│  │  │       │       │  └──── CR (carriage return)
│  │  │       │       └─────── LRC (2 hex chars)
│  │  │       └─────────────── data (address + quantity)
│  │  └─────────────────────── function code 03
│  └────────────────────────── slave address 01
└───────────────────────────── start-of-frame colon

The underlying binary bytes are 01 03 00 00 00 01. Rendered as hex characters: 01, 03, 00, 00, 00, 01. The whole thing, LRC included, is printable ASCII — you can see it in a terminal.

The LRC: a checksum you can do by hand

ASCII mode's error check is the Longitudinal Redundancy Check, and it is refreshingly simple compared to CRC-16. The LRC is the two's complement of the sum of all the frame bytes (address + function code + data), ignoring any carry:

LRC = −(sum of bytes)  mod 256        (equivalently: 0x100 − sum, or ~sum + 1)

For the request above:

01 + 03 + 00 + 00 + 00 + 01 = 0x05
LRC = 0x100 − 0x05 = 0xFB

The property to remember is that the sum of all bytes including the LRC is zero mod 2560x05 + 0xFB = 0x100 ≡ 0. A receiver re-sums everything (LRC included) and expects zero; a non-zero result means the frame was corrupted. oms-modbus computes it in one line as the wrapping negation of the sum:

let lrc: u8 = data.iter().fold(0u8, |acc, &b| acc.wrapping_add(b)).wrapping_neg();

The trade-off is deliberate. LRC is trivial to implement on a resource-constrained device — just addition — and it catches every single-byte error and most multi-byte errors. But it is weaker than CRC-16, which catches all single-bit and double-bit errors, any odd number of errors, and all bursts up to 16 bits. Some multi-bit error patterns slip past LRC undetected. That weaker protection is part of why RTU (with its CRC) became the default.

Framing without a clock

The colon and CRLF are ASCII mode's answer to a problem RTU solves differently. RTU frames have no markers — a receiver knows a frame ended only because the bus went silent for 3.5 character times. That works, but it demands strict timing on both ends, which is exactly the fragility covered in The 3.5T Rule.

ASCII sidesteps timing entirely. A receiver just waits for a :, reads characters until the CR LF, and has a complete frame. The inter-character gap can be up to a full second with no misparse. The price is efficiency: every byte costs two characters plus the colon and CRLF overhead, so an ASCII frame is more than twice the size of the equivalent RTU frame at the same baud rate. That is the whole reason ASCII lost to RTU — not correctness, but throughput.

Seeing an ASCII frame on the wire

oms-modbus speaks ASCII with the same API as RTU and TCP — an AsciiClient and AsciiServer over any AsyncRead + AsyncWrite transport. Attach a BusCapture tap and the raw frame bytes are yours to inspect:

use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ASCII frames run over any AsyncRead + AsyncWrite byte transport.
    let (client_side, server_side) = tokio::io::duplex(1024);
    let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 42)]));
    let server = ascii::AsciiServer::new(server_side);
    tokio::spawn(async move { server.serve_forever(store).await.ok(); });

    // Attach a tap so the raw ASCII frame bytes are visible.
    let cap = Arc::new(BusCapture::unbounded());
    let opts = ClientOptions::default()
        .with_timeout(Duration::from_secs(3))
        .with_tap(cap.clone());
    let client = ascii::with_options(client_side, opts);

    let regs = client.read_holding_registers(1, 0, 1).await?;
    println!("read {regs:?}");

    for pkt in cap.drain() {
        println!("{pkt}");
    }

    Ok(())
}

The tap prints each frame as its byte values (timestamps differ per run):

read [42]
[TX] 2026-08-24T05:07:12.050056   17B  [3A 30 31 30 33 30 30 30 30 30 30 30 31 46 42 0D 0A]
[RX] 2026-08-24T05:07:12.050155   15B  [3A 30 31 30 33 30 32 30 30 32 41 44 30 0D 0A]

Decode the request byte by byte: 3A is :, then 30 31 = "01" (slave 1), 30 33 = "03" (function code 3), 30 30 30 30 30 30 30 31 = "00000001" (address 0, quantity 1), 46 42 = "FB" (the LRC), and 0D 0A = CR LF. The response is the same shape: 3A · 30 31 ("01") · 30 33 ("03") · 30 32 ("02", byte count) · 30 30 32 41 ("002A", the value 42) · 44 30 ("D0", the LRC) · 0D 0A.

Verify the response LRC by hand: 01 + 03 + 02 + 00 + 2A = 0x30, and 0x100 − 0x30 = 0xD0 — matches the 44 30 on the wire. That is the entire ASCII mode in one worked example.

When you should still care about ASCII

For new integrations, RTU or TCP is almost always the right answer — they are faster, better-protected, and the de facto standard. ASCII earns its keep in three narrow situations:

  1. Legacy devices that only speak ASCII mode.
  2. 7-bit serial links, where binary RTU (which needs 8-bit bytes) is not an option.
  3. Debugging and education, because a frame you can read with cat on a serial port is a frame you can reason about without a logic analyzer.

For everything else, RTU won for a reason.

What to remember

  • Modbus ASCII is the same protocol as RTU, but hex-encoded with : and CR LF delimiters — readable, but roughly twice the size.
  • The LRC is the two's complement of the byte sum; all bytes including the LRC sum to zero mod 256.
  • ASCII frames need no timing — explicit delimiters replace RTU's 3.5-character silent interval.
  • RTU is required and ASCII optional in the standard; prefer RTU/TCP unless a legacy device forces ASCII.

This article is part of the oms-modbus tutorial series. oms-modbus is a transport-generic Modbus library in Rust — TCP, RTU, and ASCII through one API — used at OrangeHorse to build diagnostic tools for our Modbus-enabled sensors. MIT/Apache-2.0, on crates.io.

Read next: One Modbus Client, Any Transport — how the same client runs over serial, TCP, and in-memory channels. Modbus Data Encoding — the byte-order trap when a reading spans two registers.

modbus modbus ascii modbus rtu modbus lrc modbus serial modbus frame rust iiot