The Modbus Serial Line Protocol specification has exactly one hard timing requirement: section 1.4, the 3.5-character silent interval between frames. It is the rule that separates a reliable Modbus implementation from one that works in the lab and fails in the field. And it is the rule that almost every open-source Modbus library ignores.
oms-modbus enforces it. Here is why that matters, what happens when you skip it, and how we implemented spec-compliant timing without sacrificing throughput.
What the Spec Actually Says
From the Modbus over Serial Line Protocol Specification (V1.02):
A silent interval of at least 3.5 character times must separate Modbus messages.
A "character time" is the time required to transmit one byte (11 bits in standard Modbus RTU: 1 start bit, 8 data bits, 1 parity bit, 1 stop bit). So the minimum silent interval in seconds is:
T = 3.5 × 11 / baud_rate
At common baud rates, this works out to:
| Baud Rate | 3.5 Character Times |
|---|---|
| 9,600 | 3.65 ms |
| 19,200 | 1.83 ms |
| 38,400 | 0.91 ms |
| 57,600 | 0.61 ms |
| 115,200 | 0.28 ms (275 µs) |
Above 19,200 baud, the spec says to use a fixed 1.75 ms interval. The logic is simple: at higher speeds, the character time shrinks to the point where a 3.5-character gap is hard to detect reliably on a UART, so a fixed floor prevents false frame detection.
What Happens When You Violate It
The 3.5T rule is not an optimization hint. It is the framing mechanism for the entire protocol. Modbus RTU has no start-of-frame delimiter, no length prefix, and no end-of-frame marker. The only way a receiver knows where one frame ends and the next begins is the silent interval.
If a transmitter sends two frames with less than 3.5T between them, the receiver merges them into a single frame. It reads the first frame's bytes, then immediately reads the second frame's bytes as a continuation of the same message. The CRC at the end of the merged blob does not match, so the receiver discards both frames. Two valid messages are lost — not because of noise, not because of a bad cable, but because of a timing violation at the transmitter.
On a lightly loaded bus with one sensor, you might never notice. The operating system's serial buffer and driver scheduling usually insert enough delay between application-level writes that the 3.5T rule is satisfied by accident. But load the bus with 30 sensors and a polling cycle of 100 ms, and the accidental delays disappear. The OS serial buffer fills up, the driver flushes frames back-to-back, and suddenly 20% of your readings are CRC errors.
The False Positive Mirror: Inter-Frame Gap vs. Intra-Frame Gap
The 3.5T rule also governs the intra-frame gap. If a transmitter pauses for more than 1.5 character times within a single frame, the receiver interprets that pause as the end of the frame, then immediately receives the remaining bytes as the start of a new (garbage) frame. Both are discarded.
This is why Modbus RTU over half-duplex RS-485 is harder than it looks. You must:
- Transmit the entire frame without pausing for more than 1.5T between bytes
- Then wait at least 3.5T before starting the next frame
A UART in FIFO mode handles (1) automatically — the hardware shifts bytes out back-to-back. But (2) is the application's responsibility. Most Modbus libraries leave it to the operating system, which means it works on Linux (whose tty layer has a configurable inter-character timeout), might work on Windows (depending on the USB-to-serial driver quality), and definitely does not work predictably on embedded RTOS targets.
How oms-modbus Does It
oms-modbus treats the 3.5T rule as a protocol-level contract, not an OS-level convenience. After writing each frame to the serial port, the library computes the required silent interval and blocks until it has elapsed:
fn enforce_timing(
transport: &impl Transport,
baud_rate: u32,
) -> io::Result<()> {
let char_time_ns = 11_000_000_000u64 / baud_rate as u64; // 11 bits / baud
let silence_ns = if baud_rate > 19_200 {
1_750_000u64 // Fixed 1.75ms floor per spec §1.4
} else {
(char_time_ns * 7 / 2) as u64 // 3.5 × char_time, integer-safe
};
let deadline = Instant::now() + Duration::from_nanos(silence_ns);
while Instant::now() < deadline {
std::hint::spin_loop();
}
Ok(())
}
Three design decisions worth noting:
1. Busy-wait, not sleep
thread::sleep() has millisecond granularity and can overshoot by 1–2 ms. At 115,200 baud, where the required silence is 275 µs, a 1 ms oversleep is a 4× overrun that halves your throughput. Busy-spinning with spin_loop() keeps the CPU core occupied for the exact number of nanoseconds required. This is wasteful on a general-purpose server, but on a dedicated diagnostic tool or edge gateway, the CPU core is idle otherwise — burning it for 275 µs costs nothing.
2. Integer arithmetic, no floating point
The spec formula is 3.5 × 11 / baud_rate, which produces a floating-point result. Floating-point arithmetic in timing code is dangerous — rounding errors at nanosecond scales produce silent interval jitter that is invisible in unit tests but adds up over millions of frames. The integer-based calculation char_time_ns * 7 / 2 (equivalent to char_time_ns × 3.5) avoids all rounding. The division by 2 is exact because we multiply first.
3. Configurable, not hard-coded
Some Modbus devices — particularly older Chinese PLCs — require longer silent intervals than the spec mandates. oms-modbus exposes silence_multiplier as a configuration option on every transport builder. Set it to 2.0 and the library enforces a 7.0T gap. Set it to 0.5 and you can stress-test what happens when timing is deliberately violated.
The Throughput Question
The obvious objection: "If you wait 1.75 ms after every frame, how fast can you poll?"
At 115,200 baud, a typical Read Holding Registers request is 8 bytes. The response is 5 + 2×N bytes (N = register count). For a single register read:
- Request: 8 bytes × 11 bits ÷ 115,200 = 764 µs
- Response: 7 bytes × 11 bits ÷ 115,200 = 668 µs
- Silent intervals (2): 2 × 275 µs = 550 µs
- Total per transaction: ~1.98 ms
That is about 500 register reads per second on a dedicated bus. For a 30-sensor weather station polling 10 registers each every second — a typical IIoT scenario — the bus is at 60% utilization. The 3.5T rule is not the bottleneck. Bad CRC handling and retry storms are.
Testing Against the Spec
oms-modbus's timing enforcement is tested in the 440+ test suite with a dedicated timing harness. The harness uses Instant::now() to measure the actual gap between consecutive transmitted frames and asserts that:
- The gap is never less than 3.5T for baud rates ≤ 19,200
- The gap is never less than 1.75 ms for baud rates > 19,200
- The gap is never more than the requested
silence_multipliertimes the spec minimum
Tests run on Windows, Linux, and macOS CI runners. Timing-dependent tests are inherently flaky on shared CI hardware, so we run them with relaxed upper bounds and rely on dedicated bench hardware for precision timing validation.
What This Means for Your Bus
If you are integrating Modbus sensors into a system — whether using our OHTS-series environmental sensors or any other vendor's devices — check your Modbus library's timing behavior. Ask three questions:
- Does it enforce the 3.5T rule? If it relies on the OS serial driver to insert delays, it will fail on a loaded bus.
- Does it detect framing errors? A library that silently discards CRC-mismatched frames without logging is hiding bus problems.
- Can you measure the actual inter-frame gap? Without timestamped frame logs (see WireTap), you cannot verify that the library's timing is correct.
oms-modbus answers yes to all three. If your current Modbus library does not, consider whether the bus errors you are chasing are actually software bugs masquerading as hardware problems.
Read the Modbus Serial Line Protocol Specification V1.02 for the complete timing requirements. The 3.5T rule is in section 1.4, and it is worth reading the original text — most implementations get the baud-rate threshold wrong.