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