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}