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 RTU | Modbus ASCII | |
|---|---|---|
| Byte encoding | Binary — 1 byte on the wire is 1 byte | Hex ASCII — 1 byte becomes 2 printable characters |
| Start / end | None (relies on a ≥3.5-character silent gap) | : (0x3A) start, CR LF (0x0D 0x0A) end |
| Error check | CRC-16 (2 bytes, low byte first) | LRC (1 byte, as 2 characters) |
| Serial params | 8 data bits | 7 data bits + parity (or 8 + 2 stop bits) |
| Size | 1× | ~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 256 — 0x05 + 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:
- Legacy devices that only speak ASCII mode.
- 7-bit serial links, where binary RTU (which needs 8-bit bytes) is not an option.
- Debugging and education, because a frame you can read with
caton 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
:andCR LFdelimiters — 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.