m5stack_core/io/console.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Async buffered serial console for both targets — byte-level ring buffer
3//! with **overwrite-on-full** semantics. The producer (`log!()` / [`send_line`])
4//! is O(1) and NEVER blocks the caller: never spins, never awaits, never
5//! creates back-pressure. On full, the **oldest** bytes are overwritten so the
6//! lines fired *just before* a failure survive (those are the informative ones).
7//!
8//! A single [`drain_task`] pulls contiguous slices from the ring and writes
9//! them via the target's async TX sink (`write_all().await` parks on the
10//! TX-done IRQ when the FIFO is full — no busy-spin). Producers signal the
11//! drain after every write; the drain awaits the signal when the ring is empty.
12//!
13//! Per-target seam (hardware): the sink types + [`setup`] (build + split the
14//! peripheral) + [`imp::boot_panic_write`]. fire27 = UART0 @ 1 Mbaud; cores3 =
15//! USB-Serial-JTAG CDC. `setup` does NOT make the fire27 TX async — `into_async()`
16//! binds the IRQ to the *calling* core, so the binary does it from `main` (PRO).
17//!
18//! The firmware's `alternator_regulator::logger::cat_line` calls [`send_line`]
19//! for the `:cat` dump. Unlike `log!()`, [`send_line`] is **back-pressuring**:
20//! it awaits ring space before writing, so a fast read-back self-paces to the TX
21//! drain rate and is lossless (a plain overwrite-on-full write dropped lines and
22//! made `log_interval`'s read-back show false gaps). alternator-regulator depends
23//! on this crate ONLY for that — optional + esp-hal-gated, so host builds never
24//! pull it.
25
26use core::cell::RefCell;
27use core::fmt::Write as _;
28
29use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
30use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
31use embassy_sync::signal::Signal;
32#[cfg(feature = "console-serial")]
33use embedded_io_async::Write as _;
34use esp_hal::ram;
35use heapless::String;
36
37#[cfg(all(feature = "fire27", feature = "console-serial"))]
38mod imp {
39 use esp_hal::{
40 Async, Blocking,
41 gpio::AnyPin,
42 peripherals::UART0,
43 uart::{Config, Uart, UartRx, UartTx},
44 };
45
46 /// RX half → `serial_cmd`; TX (blocking) → made async by the binary at
47 /// drain-spawn (`into_async` binds the IRQ to the calling = PRO core).
48 pub type ConsoleRx<'d> = UartRx<'d, Blocking>;
49 pub type ConsoleTx<'d> = UartTx<'d, Blocking>;
50 /// The drain task's sink — the TX after the binary's `into_async()`.
51 pub type ConsoleTxAsync<'d> = UartTx<'d, Async>;
52
53 /// Build UART0 @ 1 Mbaud and split. Run early (before radio bring-up); HIL-
54 /// confirmed safe with the async console (see memory fire27-uart-async-corrected).
55 pub fn setup(
56 uart: UART0<'static>,
57 tx_pin: AnyPin<'static>,
58 rx_pin: AnyPin<'static>,
59 ) -> (ConsoleRx<'static>, ConsoleTx<'static>) {
60 Uart::new(uart, Config::default().with_baudrate(1_000_000))
61 .expect("UART0 console init")
62 .with_tx(tx_pin)
63 .with_rx(rx_pin)
64 .split()
65 }
66
67 /// The peripheral bundle [`super::install`] needs to bring the console up:
68 /// UART0 + its TX/RX pins (Fire27 console is UART0 @ 1 Mbaud).
69 pub struct SerialResources {
70 pub uart: UART0<'static>,
71 pub tx_pin: AnyPin<'static>,
72 pub rx_pin: AnyPin<'static>,
73 }
74
75 /// Build the console, returning the RX half (for `serial_cmd`) and the async
76 /// TX sink the drain task writes. `into_async()` binds the TX-done IRQ to the
77 /// calling core, so [`super::install`] must run on the core that owns the
78 /// drain task (main / PRO).
79 pub fn install_serial(
80 res: SerialResources,
81 ) -> (ConsoleRx<'static>, ConsoleTxAsync<'static>) {
82 let (rx, tx) = setup(res.uart, res.tx_pin, res.rx_pin);
83 (rx, tx.into_async())
84 }
85
86 // Raw UART0 TX-FIFO writer (panic only). Spins for FIFO room with a
87 // bounded budget per byte: the panic message MUST get out — a dropped
88 // [PANIC] line turns a clean panic into a silent "wedge" (cost a long
89 // stack-overflow hunt on cores3). The bound keeps a dead/unclocked UART
90 // from hanging the panic loop forever. Used by `on_panic` to synchronously
91 // flush the ring after the async drain is gone (or never started). NEVER
92 // call from steady-state code — `log!()` / `send_line` go through the ring.
93 const UART0_FIFO_REG: *mut u32 = 0x3FF4_0000 as *mut u32;
94 const UART0_STATUS_REG: *const u32 = 0x3FF4_001C as *const u32;
95 const TX_FIFO_DEPTH: u32 = 128;
96 /// ~a few ms at CPU speed — plenty for one byte at 1 Mbaud.
97 const PANIC_SPIN_PER_BYTE: u32 = 1_000_000;
98
99 pub fn boot_panic_write(bytes: &[u8]) {
100 for &b in bytes {
101 let mut budget = PANIC_SPIN_PER_BYTE;
102 while unsafe { (UART0_STATUS_REG.read_volatile() >> 16) & 0xFF } >= TX_FIFO_DEPTH - 2 {
103 budget -= 1;
104 if budget == 0 {
105 return; // UART dead — give up rather than hang the panic
106 }
107 core::hint::spin_loop();
108 }
109 unsafe { UART0_FIFO_REG.write_volatile(b as u32) };
110 }
111 }
112}
113
114#[cfg(all(feature = "cores3", feature = "console-serial"))]
115mod imp {
116 use esp_hal::{
117 Async,
118 peripherals::USB_DEVICE,
119 usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagRx, UsbSerialJtagTx},
120 };
121
122 /// RX half → `serial_cmd` (async poller); TX half → the drain task.
123 pub type ConsoleRx<'d> = UsbSerialJtagRx<'d, Async>;
124 pub type ConsoleTx<'d> = UsbSerialJtagTx<'d, Async>;
125 /// The drain task's sink — the split TX is already async on cores3.
126 pub type ConsoleTxAsync<'d> = UsbSerialJtagTx<'d, Async>;
127
128 /// Build the USB-Serial-JTAG console and split. `into_async()` here binds the
129 /// IRQ to whatever core calls this — the binary calls it from main.
130 pub fn setup(usb: USB_DEVICE<'static>) -> (ConsoleRx<'static>, ConsoleTx<'static>) {
131 UsbSerialJtag::new(usb).into_async().split()
132 }
133
134 /// The peripheral bundle [`super::install`] needs: just the USB-Serial-JTAG
135 /// device (the CoreS3 console is the native CDC port — no probe needed, R7).
136 pub struct SerialResources {
137 pub usb: USB_DEVICE<'static>,
138 }
139
140 /// Build the console, returning the RX half (for `serial_cmd`) and the async
141 /// TX sink the drain task writes. The split TX is already async on CoreS3.
142 pub fn install_serial(
143 res: SerialResources,
144 ) -> (ConsoleRx<'static>, ConsoleTxAsync<'static>) {
145 setup(res.usb)
146 }
147
148 // Raw SERIAL_JTAG EP1 FIFO writer (panic only). Spins for FIFO room with
149 // a bounded budget per fill: the panic message MUST get out — the old
150 // drop-on-full policy lost the [PANIC] line whenever the ring held more
151 // than one 64-byte EP buffer of pre-panic context, turning every panic
152 // into a silent "wedge" (cost a long stack-overflow hunt). The bound
153 // keeps an unplugged USB host from hanging the panic loop forever.
154 const SERIAL_JTAG_FIFO_REG: *mut u32 = 0x6003_8000 as *mut u32;
155 const SERIAL_JTAG_CONF_REG: *mut u32 = 0x6003_8004 as *mut u32;
156 /// ~tens of ms at CPU speed — the host polls the 64-byte EP every USB
157 /// micro-frame, so a live host clears the FIFO well within this.
158 const PANIC_SPIN_PER_FILL: u32 = 5_000_000;
159
160 #[inline]
161 fn fifo_full() -> bool {
162 unsafe { SERIAL_JTAG_CONF_REG.read_volatile() & 0b010 == 0 }
163 }
164
165 pub fn boot_panic_write(bytes: &[u8]) {
166 for &b in bytes {
167 if fifo_full() {
168 // Hand the queued bytes to the host, then wait (bounded) for
169 // room. No host within the budget → give up, don't hang.
170 unsafe { SERIAL_JTAG_CONF_REG.write_volatile(0b001) }; // flush (wr_done)
171 let mut budget = PANIC_SPIN_PER_FILL;
172 while fifo_full() {
173 budget -= 1;
174 if budget == 0 {
175 return;
176 }
177 core::hint::spin_loop();
178 }
179 }
180 unsafe { SERIAL_JTAG_FIFO_REG.write_volatile(b as u32) };
181 }
182 unsafe { SERIAL_JTAG_CONF_REG.write_volatile(0b001) }; // flush (wr_done)
183 }
184}
185
186#[cfg(feature = "console-serial")]
187pub use imp::{ConsoleRx, ConsoleTx, ConsoleTxAsync, SerialResources, setup};
188#[cfg(feature = "console-serial")]
189use imp::{boot_panic_write, install_serial};
190
191/// Serial-free build (R9): the transport is compiled out, so this type is
192/// uninhabited and [`Config::serial`] can only ever be `None`.
193#[cfg(not(feature = "console-serial"))]
194pub enum SerialResources {}
195
196// ---- byte-level ring buffer (target-agnostic) ----
197
198/// Ring capacity. ~50 lines × 80 B = ~4 KB — same memory budget as the prior
199/// `Channel<Line, 12>` (~4.2 KB), and large enough to hold a message-only
200/// panic plus the immediate pre-failure context. Bump if `esp-backtrace` is
201/// ever wired up (a trace would add several KB).
202const RING_SIZE: usize = 4096;
203/// Per-line stack-format buffer. Largest record is the `[hil-cat]` CSV dump
204/// (≈ 320 B + prefix + CRLF).
205const LINE_CAP: usize = 352;
206
207/// Byte ring with overwrite-on-full. Single struct held inside the mutex.
208struct Ring {
209 buf: [u8; RING_SIZE],
210 head: usize, // write position (next byte to be written)
211 tail: usize, // read position (next byte to be read)
212 full: bool, // disambiguates empty vs full when head == tail
213 /// Bytes silently overwritten (oldest discarded) since the last drain read.
214 /// The drain turns this into a VISIBLE `[CONSOLE-DROP …]` marker so a
215 /// ring overrun never looks like a clean gap (which previously made
216 /// `log_interval`'s read-back fail mysteriously). Saturates.
217 dropped: u32,
218}
219
220impl Ring {
221 const fn new() -> Self {
222 Self { buf: [0; RING_SIZE], head: 0, tail: 0, full: false, dropped: 0 }
223 }
224
225 /// Append `bytes`; on full, the **oldest** bytes are overwritten. Always
226 /// succeeds — no return value, no error path, no waiting. Called inside
227 /// the mutex by both the log macros and `send_line`. Each overwritten byte
228 /// bumps `dropped` so the drain can flag the loss.
229 fn write(&mut self, bytes: &[u8]) {
230 for &b in bytes {
231 self.buf[self.head] = b;
232 self.head = (self.head + 1) % RING_SIZE;
233 if self.full {
234 // Overwrote the tail byte — advance tail to track it, and
235 // record the drop so it surfaces downstream.
236 self.tail = (self.tail + 1) % RING_SIZE;
237 self.dropped = self.dropped.saturating_add(1);
238 } else if self.head == self.tail {
239 self.full = true;
240 }
241 }
242 }
243
244 /// Read + reset the overwritten-byte counter (drain-side).
245 #[cfg(feature = "console-serial")]
246 fn take_dropped(&mut self) -> u32 {
247 core::mem::take(&mut self.dropped)
248 }
249
250 #[cfg(feature = "console-serial")]
251 fn is_empty(&self) -> bool {
252 !self.full && self.head == self.tail
253 }
254
255 /// Bytes currently queued (unread).
256 fn used(&self) -> usize {
257 if self.full {
258 RING_SIZE
259 } else if self.head >= self.tail {
260 self.head - self.tail
261 } else {
262 RING_SIZE - self.tail + self.head
263 }
264 }
265
266 /// Free bytes available before overwrite-on-full would discard data.
267 fn free(&self) -> usize {
268 RING_SIZE - self.used()
269 }
270
271 /// Copy up to `dst.len()` readable bytes into `dst` and advance `tail`.
272 /// Bytes are taken from a single contiguous slice — if the ring wraps,
273 /// the caller will get the rest on the next call. Returns bytes copied.
274 #[cfg(feature = "console-serial")]
275 fn read_and_consume(&mut self, dst: &mut [u8]) -> usize {
276 if self.is_empty() {
277 return 0;
278 }
279 // Length of the next contiguous readable slice.
280 let n_contig = if self.head > self.tail {
281 self.head - self.tail
282 } else {
283 RING_SIZE - self.tail
284 };
285 let n = n_contig.min(dst.len());
286 let end = self.tail + n;
287 dst[..n].copy_from_slice(&self.buf[self.tail..end]);
288 self.tail = end % RING_SIZE;
289 if n > 0 {
290 self.full = false;
291 }
292 n
293 }
294}
295
296/// The ring + a CriticalSectionRawMutex so any task on any core can write
297/// safely. Lock scope is per-op (one memcpy + a few indices) — never held
298/// across an `.await`, never spans more than one log line.
299static RING: BlockingMutex<CriticalSectionRawMutex, RefCell<Ring>> =
300 BlockingMutex::new(RefCell::new(Ring::new()));
301
302/// Producer signal — woken after every ring write so the drain can resume
303/// when the ring was empty. Idempotent (multiple signals before a wait =
304/// one wait wakes); the drain reads everything it can per wakeup.
305static DRAIN_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
306
307/// Drain → producer signal — raised after every drain read so a *back-pressuring*
308/// producer ([`send_line`], the HIL `:cat` dump) can wake and retry once the ring
309/// has freed space. The non-blocking hot log path ([`push_line`]) ignores it.
310static SPACE_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
311
312/// Stack-format a line with a `[SSSSS.mmm LEVEL ] msg\r\n` header. Formats
313/// WITHOUT the CRLF first, then guarantees termination so an over-long record is
314/// truncated-but-terminated, never merged into the next (which would corrupt the
315/// `[hil-cat]` CSV dump).
316fn format_line(level: &str, args: core::fmt::Arguments<'_>) -> String<LINE_CAP> {
317 let now = embassy_time::Instant::now();
318 let mut line: String<LINE_CAP> = String::new();
319 let _ = write!(
320 line,
321 "[{:05}.{:03} {:<5}] {}",
322 now.as_secs(),
323 now.as_millis() % 1000,
324 level,
325 args
326 );
327 while line.len() + 2 > LINE_CAP {
328 let _ = line.pop();
329 }
330 let _ = line.push_str("\r\n");
331 line
332}
333
334/// Format + push a line into the ring, signal the drain. O(1) on the caller
335/// side: brief CS for the memcpy + an idempotent signal. **Non-blocking** —
336/// on full the oldest bytes are overwritten (protects time-critical producers).
337fn push_line(level: &str, args: core::fmt::Arguments<'_>) {
338 let line = format_line(level, args);
339 RING.lock(|r| r.borrow_mut().write(line.as_bytes()));
340 DRAIN_SIGNAL.signal(());
341}
342
343struct ConsoleLogger;
344
345impl log::Log for ConsoleLogger {
346 fn enabled(&self, _: &log::Metadata) -> bool {
347 true
348 }
349
350 fn log(&self, record: &log::Record) {
351 // Format level as a string so the column width matches the prior layout
352 // exactly (existing log scrapers depend on it).
353 let lvl = match record.level() {
354 log::Level::Error => "ERROR",
355 log::Level::Warn => "WARN ",
356 log::Level::Info => "INFO ",
357 log::Level::Debug => "DEBUG",
358 log::Level::Trace => "TRACE",
359 };
360 push_line(lvl, *record.args());
361 }
362
363 fn flush(&self) {}
364}
365
366static LOGGER: ConsoleLogger = ConsoleLogger;
367
368/// Register the console as the global `log` backend. Call once, early. The
369/// ring is statically initialised, so producers can write immediately —
370/// pre-[`drain_task`] writes simply accumulate in the ring and are flushed
371/// once the drain runs.
372pub fn init() {
373 init_with_level(log::LevelFilter::Info);
374}
375
376/// Like [`init`] but with an explicit max level (used by [`install`]).
377fn init_with_level(level: log::LevelFilter) {
378 let _ = log::set_logger(&LOGGER);
379 log::set_max_level(level);
380}
381
382/// Stable, greppable log markers that the HIL `detect_crash` contract keys on.
383/// Treat these as part of the public contract — do not reword without updating
384/// the test harness.
385pub mod markers {
386 /// Emitted by [`super::on_panic`] before halt.
387 pub const PANIC: &str = "[PANIC]";
388 /// Prefix of the drain's ring-overrun marker (` <n>B]` follows).
389 pub const CONSOLE_DROP: &str = "[CONSOLE-DROP";
390 /// Logged once at boot when a previous-run panic breadcrumb is read back.
391 pub const PREV_PANIC: &str = "[bc] previous panic";
392 /// Logged once at boot by [`super::install`] (the `app-desc` feature) —
393 /// project name, version (or the `identity`-enforced git mark), and an
394 /// `app_elf_sha256` prefix.
395 pub const IDENTITY: &str = "identity:";
396}
397
398/// Console install configuration. `serial: None` is the production backstop —
399/// the log backend is registered (so `log!` is a cheap no-op into the ring) but
400/// no transport is brought up and no drain task runs, leaving zero serial
401/// surface. `Some(_)` brings up the chip's native transport (UART0 on Fire27,
402/// USB-Serial-JTAG CDC on CoreS3) and spawns the drain.
403pub struct Config {
404 /// The chip's serial peripheral bundle, or `None` for a serial-free build.
405 pub serial: Option<SerialResources>,
406 /// Global `log` max level.
407 pub level: log::LevelFilter,
408}
409
410/// What [`install`] hands back: the console RX half, when a transport was
411/// brought up, to be handed to `serial_cmd` (bidirectional on one port, R7).
412/// In a serial-free build (`console-serial` off, R9) it carries no transport.
413pub struct Console {
414 /// `Some` iff `Config::serial` was `Some`. Hand to the serial-command reader.
415 #[cfg(feature = "console-serial")]
416 pub rx: Option<ConsoleRx<'static>>,
417}
418
419/// One-call console bring-up: register the `log` backend at `cfg.level` and,
420/// when `cfg.serial` is `Some`, build the chip's transport + spawn the single
421/// [`drain_task`]. Replaces the hand-wired `init()` + `setup()` + drain-spawn
422/// sequence in every binary. Call once from main (the core that owns the drain;
423/// Fire27's TX `into_async()` binds the IRQ to the calling core).
424///
425/// Returns a [`Console`] whose `rx` (when present) goes to the serial-command
426/// reader so log TX and command RX share the one port (R7).
427pub fn install(spawner: embassy_executor::Spawner, cfg: Config) -> Console {
428 init_with_level(cfg.level);
429 let console = {
430 #[cfg(feature = "console-serial")]
431 {
432 let rx = cfg.serial.map(|res| {
433 let (rx, tx) = install_serial(res);
434 crate::must_spawn!(spawner, drain_task(tx));
435 rx
436 });
437 Console { rx }
438 }
439 // R9 serial-free: backend registered above; no transport, no drain task.
440 #[cfg(not(feature = "console-serial"))]
441 {
442 let _ = &spawner;
443 match cfg.serial {
444 Some(res) => match res {}, // SerialResources is uninhabited here
445 None => {}
446 }
447 Console {}
448 }
449 };
450 // As early as possible: the first BSP-emitted log line, right after the
451 // backend (and transport, if any) are up. See markers::IDENTITY.
452 #[cfg(feature = "app-desc")]
453 crate::log_boot_identity();
454 console
455}
456
457/// Compatibility no-op. The prior design switched producers from a blocking
458/// writer to an async queue here; with the ring buffer the producer path is
459/// the same in every phase (boot and steady-state both write to the ring),
460/// so there's nothing to switch. Kept so binaries don't have to change in
461/// this commit — can be removed once both binaries stop calling it.
462pub fn enable_async() {}
463
464/// Back-pressuring line sink — the **R10 injection point**. A control crate must
465/// not depend on this BSP directly; instead it defines its own `LineSink`-style
466/// trait and the *binary* injects a ~5-line newtype whose `send_line` forwards
467/// here (the trait stays consumer-side, so neither BSP nor control crate depends
468/// on the other). This is also the bulk-dump path for the HIL `:cat` CSV.
469///
470/// Bulk-dump emit (HIL `:cat` CSV read-back) — **back-pressuring** (lossless).
471/// Unlike the hot log path ([`push_line`], which overwrites-oldest and never
472/// blocks to protect time-critical producers like RWBLE), this AWAITS until the
473/// ring has room for the whole line before writing, then signals the drain. So a
474/// fast `:cat` dump self-paces to the TX drain rate instead of overflowing the
475/// ~4 KB ring and dropping lines (which made `log_interval`'s read-back show
476/// false gaps). Safe because the dump runs on a non-time-critical task and can
477/// tolerate await latency; the `log!()` path does NOT use this.
478pub async fn send_line(args: core::fmt::Arguments<'_>) {
479 let line = format_line("INFO ", args);
480 loop {
481 // Reserve only if the WHOLE line fits — a partial write would still
482 // overwrite-on-full and corrupt the dump. LINE_CAP < RING_SIZE, so it
483 // always fits once the drain has caught up.
484 let wrote = RING.lock(|r| {
485 let mut r = r.borrow_mut();
486 if r.free() >= line.len() {
487 r.write(line.as_bytes());
488 true
489 } else {
490 false
491 }
492 });
493 if wrote {
494 DRAIN_SIGNAL.signal(());
495 return;
496 }
497 // Ring full — wait for the drain to free space, then retry. (No lost
498 // wakeup: SPACE_SIGNAL latches, so a signal between the check above and
499 // this wait still wakes us.)
500 SPACE_SIGNAL.wait().await;
501 }
502}
503
504/// The single console writer: pulls contiguous slices from the ring and
505/// writes them via the target's async TX sink. `write_all().await` parks on
506/// the TX-done IRQ when the FIFO is full — no spin, no interrupts-off, no
507/// cross-core contention. When the ring is empty, awaits [`DRAIN_SIGNAL`]
508/// (woken by every producer write). Spawn once from the binary's main
509/// (fire27: pass `tx.into_async()`; cores3: the split TX is already async).
510#[cfg(feature = "console-serial")]
511#[embassy_executor::task]
512pub async fn drain_task(mut tx: ConsoleTxAsync<'static>) {
513 // Per-iteration scratch. 256 B on the task stack is fine; the loop runs
514 // again immediately to drain whatever didn't fit.
515 let mut scratch = [0u8; 256];
516 // Overflow-marker rate-limit state (see below).
517 let mut drop_accum: u32 = 0;
518 let mut last_drop_report = embassy_time::Instant::now();
519 loop {
520 // Read the next slice AND any overwrite count in one lock.
521 let (n, dropped) = RING.lock(|r| {
522 let mut r = r.borrow_mut();
523 let n = r.read_and_consume(&mut scratch);
524 (n, r.take_dropped())
525 });
526 drop_accum = drop_accum.saturating_add(dropped);
527 // Surface a ring overrun as a LOUD, greppable marker so dropped bytes
528 // never masquerade as a clean gap (the silent loss that made
529 // `log_interval`'s read-back fail; host can grep `[CONSOLE-DROP`).
530 //
531 // RATE-LIMITED + coalesced: the marker is written by the drain straight
532 // to TX (bypassing the ring, so it costs no ring space and can't itself
533 // be overwritten), but it still shares TX bandwidth + drain time with
534 // the data it's preserving. Emitting it every 256-B chunk under a
535 // sustained log storm would steal drain throughput and AMPLIFY the
536 // overrun (more drops → more markers → slower drain). So emit at most
537 // ~4×/s with the accumulated byte count, plus an immediate flush the
538 // moment the ring drains (episode end). Never blocks/back-pressures
539 // producers — it only delays the drain slightly, now bounded.
540 if drop_accum > 0 {
541 let now = embassy_time::Instant::now();
542 if n == 0 || (now - last_drop_report).as_millis() >= 250 {
543 let mut mark: String<48> = String::new();
544 let _ = write!(mark, "\r\n[CONSOLE-DROP {}B]\r\n", drop_accum);
545 let _ = tx.write_all(mark.as_bytes()).await;
546 drop_accum = 0;
547 last_drop_report = now;
548 }
549 }
550 if n == 0 {
551 DRAIN_SIGNAL.wait().await;
552 continue;
553 }
554 // Freed `n` bytes — wake any back-pressuring producer (the :cat dump).
555 SPACE_SIGNAL.signal(());
556 // Write outside the lock — `.await` is NEVER inside the ring CS.
557 let _ = tx.write_all(&scratch[..n]).await;
558 }
559}
560
561// ---- RTC-persistent panic breadcrumb (R8) ----
562
563/// Marks a written crumb, distinguishing a real breadcrumb from uninitialised
564/// RTC RAM after a cold boot / power cycle.
565const CRUMB_MAGIC: u32 = 0x6D35_C0DE;
566
567// Breadcrumb word layout in the RTC-fast persistent array.
568const CRUMB_MAGIC_IDX: usize = 0;
569const CRUMB_REASON_IDX: usize = 1;
570const CRUMB_FILE_PTR_IDX: usize = 2;
571const CRUMB_FILE_LEN_IDX: usize = 3;
572
573/// Breadcrumb in RTC-fast RAM as `[magic, reason, file_ptr, file_len]`.
574/// `#[ram(unstable(rtc_fast, persistent))]` keeps it out of the data-init that
575/// runs on a watchdog/software reset, so it survives the RWDT recovery that
576/// follows a panic — exactly the window R8 needs. A `[u32; 4]` (not a struct)
577/// because esp-hal's persistent section requires `Persistable`, which is
578/// implemented for primitive arrays; `usize` is 32-bit on these chips, so the
579/// `.rodata` file-`&str` pointer + length fit in a `u32` each. The pointer is
580/// valid to reconstruct because the crumb only survives a *warm* reset of the
581/// *same* firmware image.
582#[ram(unstable(rtc_fast, persistent))]
583static mut PANIC_CRUMB: [u32; 4] = [0; 4];
584
585/// A panic breadcrumb recovered from the previous run: `location` is the panic
586/// file, `reason` a 32-bit digest of the panic message.
587pub struct PanicCrumb {
588 pub location: &'static str,
589 pub reason: u32,
590}
591
592/// FNV-1a 32-bit digest of the panic message — the R8 "reason-digest".
593fn reason_digest(s: &str) -> u32 {
594 let mut h: u32 = 0x811c_9dc5;
595 for &b in s.as_bytes() {
596 h ^= b as u32;
597 h = h.wrapping_mul(0x0100_0193);
598 }
599 h
600}
601
602/// Record the panic breadcrumb (file location + message digest) in
603/// RTC-persistent RAM before halting. Called by [`on_panic`].
604fn write_panic_crumb(info: &core::panic::PanicInfo<'_>, msg: &str) {
605 let (file_ptr, file_len) = info
606 .location()
607 .map(|l| (l.file().as_ptr() as usize, l.file().len()))
608 .unwrap_or((0, 0));
609 let mut crumb = [0u32; 4];
610 crumb[CRUMB_MAGIC_IDX] = CRUMB_MAGIC;
611 crumb[CRUMB_REASON_IDX] = reason_digest(msg);
612 crumb[CRUMB_FILE_PTR_IDX] = file_ptr as u32;
613 crumb[CRUMB_FILE_LEN_IDX] = file_len as u32;
614 // SAFETY: panic context is single-threaded and terminal; the only other
615 // access is `take_panic_breadcrumb` at boot before any task runs.
616 unsafe { core::ptr::addr_of_mut!(PANIC_CRUMB).write(crumb) };
617}
618
619/// Read and clear the previous run's panic breadcrumb, if any. Call **once** at
620/// boot, before [`install`], and log the result (the [`markers::PREV_PANIC`]
621/// line) — that read-back is the cross-transport fault contract (R8), identical
622/// on both targets. Returns `None` on a clean boot or once the crumb is taken.
623pub fn take_panic_breadcrumb() -> Option<PanicCrumb> {
624 // SAFETY: called once at boot before any task runs — no concurrent access.
625 unsafe {
626 let ptr = core::ptr::addr_of_mut!(PANIC_CRUMB);
627 let c = ptr.read();
628 if c[CRUMB_MAGIC_IDX] != CRUMB_MAGIC {
629 return None;
630 }
631 (*ptr)[CRUMB_MAGIC_IDX] = 0; // clear in place → reported exactly once
632 let file_ptr = c[CRUMB_FILE_PTR_IDX] as usize;
633 let location = if file_ptr != 0 {
634 core::str::from_utf8_unchecked(core::slice::from_raw_parts(
635 file_ptr as *const u8,
636 c[CRUMB_FILE_LEN_IDX] as usize,
637 ))
638 } else {
639 "?"
640 };
641 Some(PanicCrumb { location, reason: c[CRUMB_REASON_IDX] })
642 }
643}
644
645/// Shared message-only panic handler for both targets. Pushes the panic info
646/// into the ring (alongside the pre-panic context that's already there), then
647/// **synchronously drains the ring** via the raw FIFO poker — the async drain
648/// task is gone (or never started) so it can't service the ring for us. After
649/// the drain, halts so the fault stays visible. No stack walk: message-only
650/// on both targets (deliberate, symmetric), which is why neither `esp-backtrace`
651/// nor `esp-println` is pulled in. The binary's `#[panic_handler]` is a
652/// one-line wrapper around this.
653pub fn on_panic(info: &core::panic::PanicInfo<'_>) -> ! {
654 // Push the panic into the ring. Producer-side is unchanged from any
655 // normal log: brief CS, no await.
656 let mut line: String<256> = String::new();
657 let _ = write!(line, "\r\n[PANIC] {}\r\n", info);
658 // R8: record the breadcrumb (location + message digest) in RTC-persistent
659 // RAM BEFORE any best-effort transport print — the crumb is the contract; a
660 // CDC/UART flush that can't complete post-halt must not gate it.
661 write_panic_crumb(info, line.as_str());
662 RING.lock(|r| r.borrow_mut().write(line.as_bytes()));
663
664 // Best-effort transport print: synchronously drain the ring via
665 // `boot_panic_write` (the async drain task is gone/never-started). Compiled
666 // out in a serial-free build (R9) — the RTC breadcrumb above is the contract;
667 // here we just halt and let the RWDT recover.
668 #[cfg(feature = "console-serial")]
669 loop {
670 let mut chunk = [0u8; 64];
671 let n = RING.lock(|r| r.borrow_mut().read_and_consume(&mut chunk));
672 if n == 0 {
673 break;
674 }
675 boot_panic_write(&chunk[..n]);
676 }
677
678 loop {
679 core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
680 }
681}