Skip to main content

rivet/
console.rs

1//! Debug console — the board's UART/semihosting/whatever, reached through
2//! [`crate::port::board`]. Replaces the old `rivet::arch::debug_print`;
3//! application code should use this module (or [`crate::print!`] /
4//! [`crate::println!`]) instead of talking to the port directly.
5//!
6//! # Interrupt-driven mode (plan.md Phase 14)
7//!
8//! By default every write is a blocking spin on the board's polling
9//! write, exactly as before — always correct, including from the fault
10//! path (see below for why that matters). A board can opt in to
11//! interrupt-driven TX by registering its own TX-empty IRQ handler
12//! (through [`crate::irq`]) that calls [`tx_irq_next_byte`] and calling
13//! [`enable_irq_tx`] once that's wired up; from then on, [`write_str`]/
14//! [`write_bytes`] push into a ring buffer instead of blocking on
15//! hardware directly, and the registered ISR drains it.
16//!
17//! **Deliberately drop-on-full, not block-on-full** — the same policy
18//! [`crate::log`] uses, and for the same reason, sharpened by a real
19//! constraint here: [`crate::fault::on_fault`] calls `console::write_str`
20//! from *inside* the trap/exception handler on a single-hart kernel,
21//! where no interrupt (including the one TX handler that would ever
22//! drain the ring) can preempt the trap handler that's currently running.
23//! Blocking there would deadlock permanently, not just stall — dropping
24//! and counting is the only safe choice.
25//!
26//! RX is push-only from the board's side ([`on_rx_byte`], called from a
27//! registered RX IRQ handler) and pull-only from the application side
28//! ([`try_read_byte`]) — genuinely additive, doesn't touch the existing
29//! write path at all.
30
31use core::fmt::{self, Write};
32use crate::sync::atomic::{AtomicBool, Ordering};
33
34use crate::sync::{Channel, Once, Receiver, Sender};
35
36const RX_CAPACITY: usize = 64;
37const TX_CAPACITY: usize = 256;
38
39#[cfg(not(loom))]
40static RX_CHANNEL: Channel<u8, RX_CAPACITY> = Channel::new();
41#[cfg(loom)]
42loom::lazy_static! {
43    static ref RX_CHANNEL: Channel<u8, RX_CAPACITY> = Channel::new();
44}
45
46#[cfg(not(loom))]
47static RX_SENDER: Once<Sender<'static, u8, RX_CAPACITY>> = Once::new();
48#[cfg(loom)]
49loom::lazy_static! {
50    static ref RX_SENDER: Once<Sender<'static, u8, RX_CAPACITY>> = Once::new();
51}
52
53#[cfg(not(loom))]
54static RX_RECEIVER: Once<Receiver<'static, u8, RX_CAPACITY>> = Once::new();
55#[cfg(loom)]
56loom::lazy_static! {
57    static ref RX_RECEIVER: Once<Receiver<'static, u8, RX_CAPACITY>> = Once::new();
58}
59
60#[cfg(not(loom))]
61static TX_CHANNEL: Channel<u8, TX_CAPACITY> = Channel::new();
62#[cfg(loom)]
63loom::lazy_static! {
64    static ref TX_CHANNEL: Channel<u8, TX_CAPACITY> = Channel::new();
65}
66
67#[cfg(not(loom))]
68static TX_SENDER: Once<Sender<'static, u8, TX_CAPACITY>> = Once::new();
69#[cfg(loom)]
70loom::lazy_static! {
71    static ref TX_SENDER: Once<Sender<'static, u8, TX_CAPACITY>> = Once::new();
72}
73
74#[cfg(not(loom))]
75static TX_RECEIVER: Once<Receiver<'static, u8, TX_CAPACITY>> = Once::new();
76#[cfg(loom)]
77loom::lazy_static! {
78    static ref TX_RECEIVER: Once<Receiver<'static, u8, TX_CAPACITY>> = Once::new();
79}
80
81static IRQ_TX_ACTIVE: AtomicBool = AtomicBool::new(false);
82
83/// Called once from [`crate::init`], splitting both rings up front so the
84/// first write/read anywhere never pays for it.
85pub(crate) fn init() {
86    if let Some((tx, rx)) = RX_CHANNEL.split() {
87        let _ = RX_SENDER.set(tx);
88        let _ = RX_RECEIVER.set(rx);
89    }
90    if let Some((tx, rx)) = TX_CHANNEL.split() {
91        let _ = TX_SENDER.set(tx);
92        let _ = TX_RECEIVER.set(rx);
93    }
94}
95
96/// Switch [`write_str`]/[`write_bytes`] to interrupt-driven mode. Call
97/// this once the board's TX-empty IRQ handler is registered and enabled
98/// (it must already be able to call [`tx_irq_next_byte`] and re-arm/
99/// disable the hardware interrupt itself — this module has no MMIO
100/// access of its own).
101pub fn enable_irq_tx() {
102    IRQ_TX_ACTIVE.store(true, Ordering::Release);
103}
104
105/// Called from the board's TX-empty ISR: pull the next queued byte, if
106/// any, for the ISR to write to hardware. `None` means the ring is
107/// empty — the ISR should disable the TX interrupt at that point (it
108/// will be re-armed by the next dropped-into-empty-ring write, via
109/// [`crate::port::arch::request_reschedule`]-style "kick" the board's own
110/// IRQ handler is responsible for, matching how it originally armed it).
111pub fn tx_irq_next_byte() -> Option<u8> {
112    TX_RECEIVER.get().and_then(|rx| rx.try_recv())
113}
114
115/// Called from the board's RX ISR with one received byte.
116pub fn on_rx_byte(b: u8) {
117    if let Some(tx) = RX_SENDER.get() {
118        // Drop-on-full: a byte arriving faster than any consumer reads
119        // means there's nobody waiting for it right now anyway.
120        let _ = tx.try_send(b);
121    }
122}
123
124/// Non-blocking read of one received byte (task context). `None` if
125/// nothing is buffered, or interrupt-driven RX was never wired up.
126pub fn try_read_byte() -> Option<u8> {
127    RX_RECEIVER.get().and_then(|rx| rx.try_recv())
128}
129
130fn write_bytes_irq(bytes: &[u8]) -> bool {
131    let Some(tx) = TX_SENDER.get() else {
132        return false;
133    };
134    // The whole call — every byte's push, the prime, and the kick — runs
135    // under one `critical::enter`, not per-byte. Two things depend on
136    // this: (1) multiple concurrent producers (any task, or the fault
137    // path from trap context) pushing into an SPSC channel need
138    // serializing into one logical producer, same as `crate::log`; a
139    // *per-byte* critical section still lets one task's message be
140    // preempted mid-string by another task's, interleaving their text
141    // byte-by-byte on the wire — observed directly, not hypothetical.
142    // (2) the "prime" write below must never race the hardware ISR
143    // pulling from the same SPSC receiver.
144    crate::critical::enter(|| {
145        for &b in bytes {
146            // Order-preserving backpressure, not drop-on-full: since the
147            // whole call runs with the ISR masked, the ring can never
148            // drain *during* this push on its own — so on a full ring,
149            // pull the oldest queued byte out and write it directly
150            // (polling, always completes, can't deadlock) to make room,
151            // then retry. This never loses a byte and never reorders one
152            // relative to the others; it only ever costs a few polling
153            // writes on a message that overruns the ring's capacity.
154            while tx.try_send(b).is_err() {
155                if let Some(old) = tx_irq_next_byte() {
156                    crate::port::board::console_write(&[old]);
157                } else {
158                    break; // ring reported full but is now empty: retry
159                }
160            }
161        }
162        // "Prime the pump": both the NS16550 and PL011 TX-empty condition
163        // are edge-triggered on the *transition* to empty, not
164        // level-sensed — merely re-enabling the interrupt mask in
165        // `console_kick_tx` doesn't recreate that edge if no new byte is
166        // ever written, so a ring that goes idle and is then written to
167        // again would sit queued forever. Writing one byte here directly
168        // guarantees a real transmit-complete event soon, which *does*
169        // re-assert the interrupt for whatever's left.
170        if let Some(b) = tx_irq_next_byte() {
171            crate::port::board::console_write(&[b]);
172        }
173        // Enable the hardware TX interrupt so the primed byte's
174        // completion (and everything queued behind it) keeps draining
175        // without further help from here.
176        crate::port::board::console_kick_tx();
177    });
178    true
179}
180
181pub fn write_str(s: &str) {
182    write_bytes(s.as_bytes());
183}
184
185pub fn write_bytes(bytes: &[u8]) {
186    if IRQ_TX_ACTIVE.load(Ordering::Acquire) && write_bytes_irq(bytes) {
187        return;
188    }
189    // plan.md Phase 29/30, found on real ESP32-S3 dual-core hardware: the
190    // polling fallback below is a direct, unsynchronized hardware
191    // register write on every board that uses it (confirmed for S3:
192    // `rivet-bsp-esp32s3::__rivet_board_console_write` polls
193    // `UART0.status().txfifo_cnt()` and writes `UART0.fifo()` with no
194    // lock at all) — this module's own docs already say the *design*
195    // assumes "on a single-hart kernel" for the fault-path write, and
196    // that assumption silently stopped holding the moment a real second
197    // hart existed: two harts calling this concurrently interleave their
198    // byte writes on the shared UART FIFO, confirmed to produce genuinely
199    // corrupted binary garbage on the wire, not just interleaved-but-
200    // readable text — including fault diagnostics a human needs to
201    // actually read.
202    //
203    // A `critical::enter`-wrapped (unconditionally blocking) version was
204    // tried and reverted: it introduces exactly the failure mode this
205    // module's own docs warn about for the fault path — a lock that
206    // *blocks* until the other hart releases it turns "one hart crashed"
207    // into "both harts silently hang forever" the moment the other hart
208    // is genuinely wedged while holding it. Fault-path output must never
209    // be able to block on another hart's cooperation, full stop.
210    //
211    // The bounded-retry version below was *also* provisionally reverted
212    // once, on the belief it hung `mutex_test`'s QEMU stress phase on
213    // both Cortex-M targets — that belief was wrong. Phase 30 found the
214    // actual cause: `mutex_test`'s 2,000,000-iteration contended-mutex
215    // phase genuinely takes well over the 15-120s capture windows used
216    // to test it (150+ real seconds on STM32 hardware at 16MHz), on
217    // *pristine, unmodified* code too — confirmed by reverting every
218    // session change, including this file, back to the original
219    // unsynchronized write, and reproducing the identical "no output"
220    // symptom with a short capture window. This was never a regression
221    // from the lock below: a bounded-retry try-lock cannot hang
222    // indefinitely by construction — it gives up and writes
223    // unsynchronized after `LOCK_SPIN_LIMIT` iterations, a fixed, small
224    // cost per call, entirely unrelated to how long a *caller's own*
225    // workload takes to reach its next print. Re-verified against the
226    // full `riscv`/`cm3`/`mps2` QEMU suites and real STM32/S3/C6
227    // hardware, with adequate timeouts this time, before being kept.
228    let mut spins: u32 = 0;
229    while CONSOLE_WRITE_LOCK
230        .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
231        .is_err()
232    {
233        spins += 1;
234        if spins >= LOCK_SPIN_LIMIT {
235            crate::port::board::console_write(bytes);
236            return;
237        }
238        core::hint::spin_loop();
239    }
240    crate::port::board::console_write(bytes);
241    CONSOLE_WRITE_LOCK.store(false, Ordering::Release);
242}
243
244/// Bounded-retry lock for [`write_bytes`]'s polling path — see its own
245/// comment for why this is deliberately not `critical::enter` (which
246/// would block unboundedly). Plain `AtomicBool`, not the crate's usual
247/// nesting-aware `critical::enter`: this lock is only ever held for the
248/// duration of one `port::board::console_write` call, never nested.
249static CONSOLE_WRITE_LOCK: AtomicBool = AtomicBool::new(false);
250/// How many spin iterations to wait for [`CONSOLE_WRITE_LOCK`] before
251/// giving up and writing unsynchronized. Not calibrated against any
252/// particular board's clock — large enough that a healthy other hart's
253/// brief, normal-length write (a handful of bytes, one polling loop each)
254/// reliably finishes within it, small enough that a genuinely wedged
255/// other hart doesn't stall this one's own diagnostic output for long.
256const LOCK_SPIN_LIMIT: u32 = 100_000;
257
258/// Synchronously drain any bytes still queued in the TX ring, via the
259/// blocking polling write. No-op if interrupt-driven TX was never
260/// enabled (nothing can be queued there).
261///
262/// Call this before anything that terminates or resets the guest right
263/// after printing diagnostics — [`crate::fault::on_fault`]'s `Panic`
264/// policy, the default panic handler, a watchdog timeout — since all of
265/// them print a final message and then call [`crate::port::board::reset`]
266/// or exit essentially immediately. Without a synchronous flush there,
267/// that message would very likely be lost: it's sitting in the TX ring
268/// waiting for the interrupt-driven ISR to drain it, but the guest halts
269/// before that interrupt ever gets a chance to fire. Diagnostic output a
270/// human needs to actually see must not depend on an interrupt that may
271/// never come.
272pub fn flush_sync() {
273    // The TX ring's receiver end is SPSC — normally consumed only by the
274    // board's hardware TX-empty ISR. Draining it here too, without
275    // excluding that ISR, would be a second concurrent consumer racing
276    // on the same `head` index (observed directly: this caused real
277    // output truncation on Cortex-M, where interrupts stay enabled
278    // through this call unless something masks them). `critical::enter`
279    // makes this genuinely the only consumer for its duration.
280    crate::critical::enter(|| {
281        while let Some(b) = tx_irq_next_byte() {
282            crate::port::board::console_write(&[b]);
283        }
284    });
285}
286
287struct Console;
288
289impl Write for Console {
290    fn write_str(&mut self, s: &str) -> fmt::Result {
291        write_str(s);
292        Ok(())
293    }
294}
295
296#[doc(hidden)]
297pub fn _print(args: fmt::Arguments) {
298    // A formatting error here would mean a `fmt::Write` impl returned
299    // `Err` for a plain UART byte write, which never fails.
300    let _ = Console.write_fmt(args);
301}
302
303/// Write formatted text to the debug console. See [`println!`] for a
304/// version that appends a newline.
305#[macro_export]
306macro_rules! print {
307    ($($arg:tt)*) => {{
308        $crate::console::_print(core::format_args!($($arg)*));
309    }};
310}
311
312/// Write formatted text to the debug console, followed by a newline.
313#[macro_export]
314macro_rules! println {
315    () => { $crate::print!("\n") };
316    ($($arg:tt)*) => {{
317        $crate::console::_print(core::format_args!($($arg)*));
318        $crate::print!("\n");
319    }};
320}