RS485 is half duplex: one pair of wires, one talker at a time. The transceiver has a driver-enable pin, and while it is high this device owns the wire. Raise it, send the request, drop it, and the sensor answers into the silence.
The bug looked electrical. Level readings failed intermittently — but only ever while somebody had the dashboard open. That correlation is what gave it away. It was not the bus. It was the scheduler.
The numbers are completely knowable
At 9600 baud, 8N1, every quantity here is arithmetic:
- The eight-byte request takes 8.3 ms on the wire — 8 bytes x 10 bits / 9600.
- Modbus-RTU then requires 3.5 characters of silence before the device may answer: 3.6 ms.
- The firmware releases the line 100 µs after the last stop bit.
So there are about 3.5 ms of slack between releasing the driver and the first byte coming back. That is the entire budget.
pub fn transaction(bytes: u32, baud: u32) -> Turnaround {
let tx_done_us = DE_LEAD_US + frame_micros(bytes, baud);
Turnaround {
tx_done_us,
release_us: tx_done_us + DE_TAIL_US,
reply_starts_us: tx_done_us + turnaround_micros(baud),
}
}
impl Turnaround {
/// Would releasing DE `late_by_us` behind schedule tread on the reply?
pub fn tramples_reply(&self, late_by_us: u32) -> bool {
self.release_us + late_by_us > self.reply_starts_us
}
}The original code dropped the line with Timer::after_micros(100) following an async flush. Both are cooperative awaits. An executor that is also serving HTTP requests and a WebSocket hub does not resume a task in a hundred microseconds — it resumes it when it gets round to it, which under load is milliseconds.
Four milliseconds late is unremarkable for a busy executor. Four milliseconds late is also more than the entire budget, so the driver is still asserted when the sensor starts talking, and the reply is trampled.
Releasing the driver-enable line
Scheduler-timed - the bug
// Both of these hand control back to the executor.
uart.write_async(&request).await?;
uart.flush_async().await?;
Timer::after_micros(100).await; // "100 us" - eventually
de.set_low();Hardware-timed - the fix
// write_async only fills the 8-byte FIFO and returns immediately.
uart.write_async(&request).await?;
// The BLOCKING flush busy-waits the hardware TX-idle flag.
uart.flush()?;
Delay::new().delay_micros(100); // 100 us, hardware-timed
de.set_low();Same five lines, same intent. The left one asks a scheduler to meet a deadline it never agreed to; the right one blocks the task for 8 ms and meets it every time. Async is not free, and "cooperative" has a price measured in microseconds.
The fix is not a longer timeout. A longer timeout would have made the symptom rarer and the cause permanent.
The fix is refusing to let a cooperative scheduler own a hardware deadline: fill the FIFO asynchronously, then busy-wait on the hardware transmit-idle flag and drop the line microseconds after the last stop bit. The turnaround becomes hardware-timed and independent of what else the executor is doing.
The general lesson
Never let a cooperative scheduler own a hardware deadline. If a deadline is measured in microseconds and your executor is also serving network traffic, the only thing that will meet it is a busy-wait on the hardware itself.
It gets worse on a faster bus
The tail is a fixed 100 µs but the required silence shrinks with baud. At 115200 the whole slack budget is about 200 µs instead of 3.5 ms — so the faster the bus, the less a scheduler can be trusted with it.
Check yourself
Not graded — just to see whether it landed.
1.Why did the failures only appear while the dashboard was open?
2.Why is a longer read timeout the wrong fix?