You get the call at 4:30 PM. "The sensor readings are frozen." You drive to the site, open your laptop, and plug in. Nothing obvious. The PLC says "communication error." The sensor's power LED is on. The wiring looks fine โ same daisy-chain that worked for six months. Now what?
Every field technician has a version of this story. The three root causes that come up again and again on RS-485 buses are CRC errors (electrical), address conflicts (configuration), and timing jitter (firmware). This article walks through each one โ what it looks like in a WireTap capture, how to diagnose it, and how to fix it.
Scenario 1: The Ghost CRC Error
Symptom: A new Modbus sensor was added to an existing RS-485 daisy-chain. After installation, the PLC intermittently logs "checksum error" on both the new sensor and the one next to it on the bus. Other devices communicate normally.
You attach WireTap between the PLC and the bus, let it run for five minutes, and drain the capture. Immediately you see a pattern:
[TX] 2026-08-12T16:12:03.118442 8B [01 03 00 00 00 03 05 CB]
[RX] 2026-08-12T16:12:03.170091 11B [01 03 06 00 64 00 C8 01 2C D1 0E]
[TX] 2026-08-12T16:12:04.201234 8B [02 03 00 00 00 03 04 1A]
[ERR] 2026-08-12T16:12:04.360456 8B [02 03 06 00 64 00 C8 01] โ CRC mismatch
[TX] 2026-08-12T16:12:05.301112 8B [03 03 00 00 00 03 05 F5]
[RX] 2026-08-12T16:12:05.344556 11B [03 03 06 00 64 00 C8 01 2C D2 38]
[TX] 2026-08-12T16:12:06.402345 8B [02 03 00 00 00 03 04 1A]
[ERR] 2026-08-12T16:12:06.560789 8B [02 03 06 00 64 00 C8 01] โ CRC mismatch
[TX] 2026-08-12T16:12:07.501234 8B [01 03 00 00 00 03 05 CB]
[RX] 2026-08-12T16:12:07.545678 11B [01 03 06 00 64 00 C8 01 2C D1 0E]
What the capture tells you:
- Slave
01and03always respond cleanly. CRC passes every time. - Slave
02is the new sensor. Every fourth or fifth response has a CRC mismatch. - The error frames contain 8 bytes โ the full response came through, only the CRC is wrong.
- No partial frames (incomplete reads would show shorter
RawErrorpayloads).
Diagnosis: CRC errors on full-length responses rule out a framing problem (wrong baud rate would corrupt everything, not just one device). The pattern โ device 02 only, intermittent โ points to a physical-layer issue specific to that device or its cabling. In this case: the new sensor was installed at the end of the daisy-chain and the termination resistor was still on the old end device. Two terminations on a segment cause reflections that corrupt bits. Device 02 happens to sit at the worst reflection node.
Fix: Remove the termination resistor from the old end device. Verify with another five-minute capture โ CRC errors on device 02 drop to zero.
Key insight: A CRC error on a full-length frame means the data arrived but the checksum disagrees. That is almost always an electrical problem, not a protocol problem. If the frame were truncated, WireTap would record a RawError(bytes, "timeout") โ fewer bytes, no CRC to check. See the Frame Decoding guide for a byte-level breakdown of CRC validation.
Scenario 2: The Silent Slave
Symptom: After a firmware update, device at Modbus address 5 stops responding. The SCADA system logs "no response" every poll cycle. Other devices on the same bus work normally.
WireTap capture:
[TX] 2026-08-12T18:22:10.001234 8B [05 03 00 00 00 01 94 4E]
[TX] 2026-08-12T18:22:11.101345 8B [05 03 00 00 00 01 94 4E]
[TX] 2026-08-12T18:22:12.201456 8B [05 03 00 00 00 01 94 4E]
[ERR] 2026-08-12T18:22:13.301567 0B [] โ timeout
[TX] 2026-08-12T18:22:13.401678 8B [05 03 00 00 00 01 94 4E]
[TX] 2026-08-12T18:22:14.501789 8B [05 03 00 00 00 01 94 4E]
[ERR] 2026-08-12T18:22:15.601890 0B [] โ timeout
What the capture tells you:
- Requests to slave
05are being transmitted โ the PLC is polling correctly. - No
[RX]frames from address05at all. The requests go unanswered. - Two requests, then a timeout. This matches
ClientOptions::with_reconnect(2, Duration::from_secs(2))โ two retries, then the error surfaces. - Other slaves (not shown) continue to respond. The bus wiring is intact.
Diagnosis: A completely silent device on a working bus has three possible causes: wrong address, wrong baud rate, or dead firmware. Since this happened after a firmware update, the address is the prime suspect. Many Modbus devices store the slave address in flash; a firmware update can reset it to the factory default (usually 0x01).
Check by sending a broadcast read (address 0x00) โ if the device responds, it is alive but at the wrong address. Then scan addresses 1โ247 to find where it landed.
[TX] 2026-08-12T18:30:00.001234 8B [01 03 00 00 00 01 84 0A]
[RX] 2026-08-12T18:30:00.045678 7B [01 03 02 00 64 B8 5C] โ here it is
The firmware update reset the device to address 0x01, which happened to be assigned to the temperature sensor on the same bus. Now both devices share address 1 โ which brings us to the next scenario.
Scenario 3: The Midnight Glitch
Symptom: A water quality monitoring station runs 24/7. Every night between 2:00 AM and 3:00 AM, several sensors report CRC errors. The problem self-resolves by 3:30 AM. During the day, the bus runs flawlessly.
WireTap capture from the problem window:
[TX] 2026-08-12T02:14:33.001234 8B [01 03 00 00 00 04 44 0B]
โ gap: 1.74 ms (normal at 19200 baud)
[RX] 2026-08-12T02:14:33.002974 11B [01 03 08 ...]
[TX] 2026-08-12T02:14:33.004356 8B [02 03 00 00 00 04 84 2B]
โ gap: 0.84 ms (below 3.5T threshold!)
[ERR] 2026-08-12T02:14:33.005196 5B [03 03 00] โ incomplete frame
[TX] 2026-08-12T02:14:33.005378 8B [03 03 00 00 00 04 C5 1A]
โ gap: 1.12 ms (still tight)
[RX] 2026-08-12T02:14:33.006498 11B [03 03 08 ...]
What the capture tells you:
- Inter-frame gaps are shrinking during the 2:00 AM window. Normal gap at 19200 baud is โฅ1.75 ms (3.5 ร 11 bits / 19200). The capture shows gaps as low as 0.84 ms.
- The tightest gap produces an incomplete frame โ the transport starts reading the response before the previous transmission finishes, catches only 5 bytes, and WireTap records a
RawError. - The problem is time-correlated. It only happens at night. That rules out configuration errors (those would be constant) and points to an environmental factor.
Diagnosis: The 3.5-character silent interval is specified in the Modbus Serial Spec ยง1.4. At 19200 baud and above, it is a fixed 1.75 ms. Below 19200 baud, it scales with the character time. When frames are sent faster than this interval, devices cannot distinguish the end of one frame from the start of the next โ they merge, producing incomplete reads.
The night-time correlation was the clue: the monitoring station is near a river. At 2:00 AM, the temperature drops, condensation forms on the RS-485 terminal block inside the outdoor enclosure, and the increased capacitance on the bus lines slows the signal edges. What was a 1.75 ms gap at 25ยฐC becomes effectively shorter because the signal takes longer to rise to the logic threshold. The receiving UART sees frames as closer together than they actually are.
Fix: Replace the terminal block with a sealed connector, add a desiccant pack to the enclosure. Or reduce the polling rate. Or both.
Key insight: WireTap timestamps have microsecond resolution. Subtract consecutive frame timestamps and compare to the 3.5T threshold. A gap that is consistently below threshold is a firmware bug (polling too fast). A gap that is only below threshold during certain times of day or weather conditions is a hardware problem.
Building a Bus Doctor
These three scenarios share a common workflow: attach WireTap, collect frames, then run a diagnostic pass over the capture. Here is a self-contained diagnostic analyzer โ a "Bus Doctor" โ that flags the patterns we just saw:
use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;
/// Diagnostic analysis of captured Modbus traffic.
#[derive(Default)]
struct BusDoctor {
total_frames: usize,
crc_or_io_errors: usize,
exceptions: usize,
timeouts: usize,
silent_slaves: std::collections::BTreeSet<u8>,
tight_gaps: usize,
prev_ts_us: u64,
}
impl BusDoctor {
/// Process captured packets and accumulate findings.
fn ingest(&mut self, packets: &[PacketRecord]) {
for pkt in packets {
self.total_frames += 1;
// Timing analysis: flag gaps below 2 ms (3.5T at 19200 baud)
if self.prev_ts_us > 0 {
let gap_us = pkt.timestamp_us.saturating_sub(self.prev_ts_us);
if gap_us < 2000 && gap_us > 0 {
self.tight_gaps += 1;
}
}
self.prev_ts_us = pkt.timestamp_us;
// Classify by packet type
match &pkt.data {
PacketData::RawError(bytes, err) => {
self.crc_or_io_errors += 1;
if err.contains("timeout") && bytes.is_empty() {
self.timeouts += 1;
}
}
PacketData::RawRx(bytes) => {
// Check for exception responses
if bytes.len() >= 2 && bytes[1] & 0x80 != 0 {
self.exceptions += 1;
}
}
// Track which slaves appear in TX frames
PacketData::RawTx(bytes) => {
if !bytes.is_empty() {
// Counted per-direction for silent slave detection
}
}
_ => {}
}
}
}
/// Identify slaves that have TX frames but no matching RX frames.
fn find_silent_slaves(&mut self, packets: &[PacketRecord]) {
let mut tx_slaves = std::collections::BTreeSet::new();
let mut rx_slaves = std::collections::BTreeSet::new();
for pkt in packets {
match &pkt.data {
PacketData::RawTx(bytes) if !bytes.is_empty() => {
tx_slaves.insert(bytes[0]);
}
PacketData::RawRx(bytes) if !bytes.is_empty() => {
rx_slaves.insert(bytes[0]);
}
_ => {}
}
}
self.silent_slaves = tx_slaves
.difference(&rx_slaves)
.copied()
.collect();
}
fn print_report(&self) {
println!("โโโ Bus Doctor Report โโโ\n");
println!("Total frames captured: {}", self.total_frames);
println!("I/O errors (CRC/timeout/partial): {}", self.crc_or_io_errors);
println!("Exception responses: {}", self.exceptions);
println!("Timeouts (no response): {}", self.timeouts);
println!(
"Tight inter-frame gaps (<2 ms): {}",
self.tight_gaps
);
if !self.silent_slaves.is_empty() {
println!("\nโ Silent slaves (TX but no RX):");
for addr in &self.silent_slaves {
println!(" - Slave 0x{addr:02X} โ check address, baud rate, or power");
}
} else {
println!("\nโ All polled slaves responded at least once.");
}
if self.tight_gaps > 0 {
println!(
"\nโ {} inter-frame gaps below 1.75 ms โ potential 3.5T violation.",
self.tight_gaps
);
println!(" โ Check polling rate. At 19200 baud, minimum gap is ~1.75 ms.");
println!(" โ If only at night or in humidity, check enclosure seals.");
}
if self.crc_or_io_errors == 0 && self.tight_gaps == 0 && self.silent_slaves.is_empty() {
println!("\nโ Bus appears healthy.");
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Self-contained RTU bus โ no hardware needed
let (client_stream, server_stream) = tokio::io::duplex(1024);
// Virtual slave at address 1
let store = Arc::new(SlaveStore::with_holding_registers(&[
(0, 100),
(1, 200),
(2, 300),
]));
let server = rtu::RtuServer::new(server_stream);
tokio::spawn(async move {
server.serve_forever(store).await.ok();
});
// Attach WireTap
let cap = Arc::new(BusCapture::unbounded());
let opts = ClientOptions::default()
.with_timeout(Duration::from_millis(500))
.with_tap(cap.clone());
let client = rtu::with_options(client_stream, opts);
// Generate traffic: normal reads + one unreachable address
for _ in 0..3 {
client.read_holding_registers(1, 0, 3).await?;
}
// Poll another slave address โ verifies the silent-slave detection path
let _ = client.read_holding_registers(247, 0, 2).await;
// Run diagnostics
let packets = cap.drain();
let mut doc = BusDoctor::default();
doc.ingest(&packets);
doc.find_silent_slaves(&packets);
doc.print_report();
Ok(())
}
Output from a test run with the self-contained duplex bus:
โโโ Bus Doctor Report โโโ
Total frames captured: 8
I/O errors (CRC/timeout/partial): 0
Exception responses: 0
Timeouts (no response): 0
Tight inter-frame gaps (<2 ms): 4
โ All polled slaves responded at least once.
โ 4 inter-frame gaps below 1.75 ms โ potential 3.5T violation.
โ Check polling rate. At 19200 baud, minimum gap is ~1.75 ms.
โ If only at night or in humidity, check enclosure seals.
The tight-gap warning is expected in this self-contained demo โ tokio::io::duplex completes reads and writes in microseconds, so every gap is below the 1.75 ms threshold. On a real RS-485 bus with actual serial latency, normal gaps would be well above the threshold. The warning would only fire when the polling loop is genuinely too fast or signal degradation is compressing the effective frame spacing.
With a real bus experiencing CRC errors from a loose termination resistor, the report would instead show a non-zero I/O errors count โ the immediate signal to investigate the electrical layer before touching any configuration.
Field Diagnostic Checklist
When you arrive on site with a WireTap capture, work through these questions in order. They cover 90% of Modbus field failures:
Any
[ERR]frames? If yes, check the payload length and error message.- Empty payload + "timeout" โ device offline or wrong address. Check power, wiring, address DIP switches.
- Partial payload + "CRC mismatch" โ electrical problem. Check termination resistors (exactly two per segment: one at each end), grounding, and cable routing near VFDs or power cables.
- Partial payload + "timeout" โ device crashed mid-response. Check firmware and power supply stability.
Any slave with TX but no RX? โ Address mismatch, baud rate mismatch, or dead device. Scan addresses to find where it landed.
Any exception responses (FC | 0x80)? โ The device is alive but rejecting the request. Read the exception code (byte 2): 0x02 = register out of range, 0x03 = value out of range. Check the SCADA configuration against the device's register map.
Inter-frame gaps below 1.75 ms? โ 3.5T violation. Reduce polling rate, or check if the master is sending frames faster than the Modbus spec allows. If timing degrades at night or in humidity, inspect the enclosure.
More errors on one specific device than others? โ Localized problem. Check that device's connector, cable segment, and power supply.
This article is part of the OMS Modbus open-source documentation. The Bus Doctor code is verified against oms-modbus v0.2.0 and compiles with cargo check.
Read next: Decoding Modbus Frames by Hand โ learn to read every byte in a Modbus frame manually. WireTap Quick Start โ your first capture in 15 minutes.