umsh_bsp_nrf52840/
panic_persist.rs

1//! Persist panic messages across resets.
2//!
3//! On nRF52840 a region of RAM can be marked as `noinit` so its
4//! contents survive a soft reset (warm boot, watchdog, panic-driven
5//! `SCB::sys_reset()`). This module provides the framing layer for
6//! that region — it lives here in `umsh-bsp-nrf52840` because every
7//! nRF52840-based board (T1000-E, T-Echo, …) shares this mechanism.
8//!
9//! The [`PanicSlot`] API operates on a borrowed `&'static mut [u8]`,
10//! so the framing is purely portable and unit-testable on the host.
11//! The caller is responsible for declaring the `.noinit` static and
12//! passing a mutable slice of it; see `umsh-bsp-nrf52840::gpregret`
13//! and the board BSPs for the typical integration pattern.
14//!
15//! # On-region layout
16//!
17//! ```text
18//!   offset  field      bytes  notes
19//!   ------  --------   -----  -----------------------------------------
20//!     0     magic        4    PANIC_MAGIC; distinguishes a captured
21//!                             record from uninitialized RAM noise.
22//!     4     length       2    payload length in bytes (truncated if
23//!                             larger than the region).
24//!     6     checksum     2    Fletcher-16 of magic ++ length ++ payload.
25//!     8     payload      N    UTF-8 panic message, `length` bytes.
26//! ```
27//!
28//! Total header = 8 bytes. Maximum payload = `region.len() - 8`.
29
30/// A `Sync` wrapper around `UnsafeCell<MaybeUninit<T>>` for use with
31/// `#[link_section = ".uninit"]` statics.
32///
33/// Rust 2024 made `&mut static_mut` a hard error (`static_mut_refs` lint).
34/// `UnsafeCell` is the correct escape hatch: it opts into interior
35/// mutability, so a raw pointer obtained via `UnsafeCell::get()` is not a
36/// reference to a mutable static. The `unsafe impl Sync` is sound on a
37/// single-core target where no thread can race on the cell.
38///
39/// # Soundness
40///
41/// [`as_bytes_mut`](Self::as_bytes_mut) forms a `&mut [u8]` over memory
42/// the compiler believes is uninitialized, which is formally UB: uninit
43/// is not a valid `u8`. A retained region always holds real bits after a
44/// reset, but the abstract machine cannot express that — it has no
45/// notion of the previous boot, so bytes written then read as never
46/// written. Making this formally sound needs a `freeze` operation stable
47/// Rust does not expose, so no construction available here is sound;
48/// the choice is only about which shape to prefer.
49///
50/// The reads that touch never-written bytes are the record validators —
51/// [`PanicSlot::read`]'s magic compare, and the equivalent checks in the
52/// firmware's breadcrumb and capture regions — on a cold boot. They are
53/// already written to treat the region as arbitrary: magic, length bound
54/// and Fletcher-16 all have to pass before any byte is believed.
55///
56/// The failure mode that would matter is LLVM folding a load from the
57/// `undef`-initialized static. It does not: the static is both loaded
58/// and stored across several functions and its address escapes through
59/// `UnsafeCell::get()`, so there is nothing to fold against. This has
60/// been the shape of retained-panic crates for years without incident.
61///
62/// Two things not to do:
63///
64/// - **Do not give the cell a concrete initializer** (`UnsafeCell::new([0;
65///   N])`) to silence the uninit question. `.uninit` is a `NOLOAD`
66///   section, so the initializer never reaches RAM — but the compiler
67///   now believes the region is zero at startup and may constant-fold
68///   the magic comparison to `false`, silently killing the feature.
69/// - **Do not swap the return type for `&mut [MaybeUninit<u8>]`** as a
70///   fix. It removes the UB at acquisition and on the write path, but
71///   every validator read then needs `assume_init` on the same
72///   never-written bytes. The UB relocates to the read path — the one
73///   that runs at boot on every device — and buys nothing.
74pub struct SyncNoinit<T>(pub core::cell::UnsafeCell<core::mem::MaybeUninit<T>>);
75
76// Safety: nRF52840 is a single-core device; concurrent access is impossible.
77unsafe impl<T> Sync for SyncNoinit<T> {}
78
79impl<T> SyncNoinit<T> {
80    pub const fn uninit() -> Self {
81        Self(core::cell::UnsafeCell::new(core::mem::MaybeUninit::uninit()))
82    }
83
84    /// Return a `&'static mut [u8]` slice over the inner value.
85    ///
86    /// # Safety
87    /// The caller must ensure no other live reference to this cell
88    /// exists. Nothing here enforces that: the returned lifetime is
89    /// unrelated to `&self`, so every call mints another independent
90    /// `&'static mut` over the same bytes, and two live at once alias.
91    ///
92    /// The discipline the firmwares rely on, in case a new caller needs
93    /// to fit into it:
94    ///
95    /// - Boot-time readers run at the top of `main`, before the
96    ///   interrupts whose handlers touch a region are unmasked.
97    /// - Thread-mode callers keep the borrow inside one function body
98    ///   and never hold it across an `.await`, so the single-threaded
99    ///   executor cannot interleave two.
100    /// - Handlers that run from an interrupt touch only a region no
101    ///   thread-mode code borrows after boot.
102    ///
103    /// The panic and `HardFault` handlers are the deliberate exception:
104    /// either can be entered while a boot-time borrow is still live, and
105    /// will alias it. Both end in a reset without returning, so the
106    /// aliased borrow is never used again.
107    pub unsafe fn as_bytes_mut(&self) -> &'static mut [u8] {
108        let ptr: *mut u8 = self.0.get().cast::<u8>();
109        // SAFETY: caller ensures no other live reference; ptr is non-null and aligned.
110        unsafe { core::slice::from_raw_parts_mut(ptr, core::mem::size_of::<T>()) }
111    }
112}
113
114/// A `core::fmt::Write` sink over a fixed-size byte slice.
115///
116/// Used to format a `core::panic::PanicInfo` into a stack buffer without
117/// heap allocation. Truncates silently when the buffer fills — acceptable
118/// for a best-effort panic message capture.
119pub struct SliceWriter<'a> {
120    pub buf: &'a mut [u8],
121    pub pos: usize,
122}
123
124impl<'a> core::fmt::Write for SliceWriter<'a> {
125    fn write_str(&mut self, s: &str) -> core::fmt::Result {
126        let avail = self.buf.len().saturating_sub(self.pos);
127        let n = s.len().min(avail);
128        self.buf[self.pos..self.pos + n].copy_from_slice(&s.as_bytes()[..n]);
129        self.pos += n;
130        Ok(())
131    }
132}
133
134const PANIC_MAGIC: u32 = 0x554D_5350; // "UMSP" — UMSH Panic
135const HEADER_LEN: usize = 8;
136
137/// Framing wrapper around a borrowed RAM region.
138///
139/// One instance is constructed at boot from the BSP-provided
140/// `&'static mut [u8]`. Pass the same region (the literal same RAM,
141/// not a fresh copy) every reset so the previous boot's panic can be
142/// recovered.
143#[derive(Debug)]
144pub struct PanicSlot<'a> {
145    region: &'a mut [u8],
146}
147
148impl<'a> PanicSlot<'a> {
149    /// Wrap a RAM region. Region must be at least
150    /// [`HEADER_LEN`] + 1 byte; smaller regions panic in debug and
151    /// silently truncate writes to zero payload bytes in release.
152    pub fn new(region: &'a mut [u8]) -> Self {
153        debug_assert!(
154            region.len() > HEADER_LEN,
155            "panic slot region must hold at least the 8-byte header plus 1 payload byte"
156        );
157        Self { region }
158    }
159
160    /// Capacity available for payload bytes.
161    pub fn payload_capacity(&self) -> usize {
162        self.region.len().saturating_sub(HEADER_LEN)
163    }
164
165    /// Write a panic message. Truncates if `msg` is larger than the
166    /// payload capacity. Returns the number of payload bytes actually
167    /// stored. Designed to be callable from a panic handler, so it
168    /// allocates nothing and uses only direct slice writes.
169    pub fn capture(&mut self, msg: &[u8]) -> usize {
170        if self.region.len() <= HEADER_LEN {
171            return 0;
172        }
173        let cap = self.payload_capacity();
174        let len = msg.len().min(cap);
175
176        self.region[0..4].copy_from_slice(&PANIC_MAGIC.to_le_bytes());
177        self.region[4..6].copy_from_slice(&(len as u16).to_le_bytes());
178        self.region[8..8 + len].copy_from_slice(&msg[..len]);
179
180        let cksum = checksum(&self.region[0..6], &self.region[8..8 + len]);
181        self.region[6..8].copy_from_slice(&cksum.to_le_bytes());
182        len
183    }
184
185    /// Borrow the previously-captured payload, if a valid record is
186    /// present. Returns `None` for an uninitialized region, a stale /
187    /// foreign record (wrong magic), a corrupt record (bad checksum),
188    /// or an out-of-bounds length.
189    pub fn read(&self) -> Option<&[u8]> {
190        if self.region.len() <= HEADER_LEN {
191            return None;
192        }
193
194        let magic = u32::from_le_bytes(self.region[0..4].try_into().ok()?);
195        if magic != PANIC_MAGIC {
196            return None;
197        }
198
199        let len = u16::from_le_bytes(self.region[4..6].try_into().ok()?) as usize;
200        if len > self.payload_capacity() {
201            return None;
202        }
203
204        let stored_cksum = u16::from_le_bytes(self.region[6..8].try_into().ok()?);
205        let computed = checksum(&self.region[0..6], &self.region[8..8 + len]);
206        if stored_cksum != computed {
207            return None;
208        }
209
210        Some(&self.region[8..8 + len])
211    }
212
213    /// Invalidate the record. Subsequent [`read`](Self::read) calls
214    /// return `None` until another [`capture`](Self::capture) writes a
215    /// new one. Cheap: zeroes only the 4-byte magic.
216    pub fn clear(&mut self) {
217        if self.region.len() >= 4 {
218            self.region[0..4].fill(0);
219        }
220    }
221}
222
223/// Fletcher-16 over two byte slices (header front + payload). The
224/// header's checksum field itself is skipped by construction — callers
225/// pass `header[0..6]` (magic + length, omitting bytes 6..8 where the
226/// checksum will be stored).
227fn checksum(header_front: &[u8], payload: &[u8]) -> u16 {
228    let mut s1: u16 = 0;
229    let mut s2: u16 = 0;
230    for &b in header_front.iter().chain(payload.iter()) {
231        s1 = (s1.wrapping_add(b as u16)) % 255;
232        s2 = (s2.wrapping_add(s1)) % 255;
233    }
234    (s2 << 8) | s1
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn slot(region: &mut [u8]) -> PanicSlot<'_> {
242        PanicSlot::new(region)
243    }
244
245    #[test]
246    fn empty_region_returns_none() {
247        let mut buf = [0u8; 64];
248        let s = slot(&mut buf);
249        assert_eq!(s.read(), None);
250    }
251
252    #[test]
253    fn round_trip_basic_message() {
254        let mut buf = [0u8; 64];
255        let mut s = slot(&mut buf);
256        assert_eq!(s.capture(b"oops"), 4);
257        assert_eq!(s.read(), Some(&b"oops"[..]));
258    }
259
260    #[test]
261    fn round_trip_full_capacity() {
262        let mut buf = [0u8; 64];
263        let cap = buf.len() - HEADER_LEN;
264        let payload = [b'x'; 56]; // 64 - HEADER_LEN
265        assert_eq!(payload.len(), cap);
266        let mut s = slot(&mut buf);
267        assert_eq!(s.capture(&payload), cap);
268        assert_eq!(s.read(), Some(&payload[..]));
269    }
270
271    #[test]
272    fn truncates_when_payload_exceeds_capacity() {
273        let mut buf = [0u8; 16]; // capacity = 8
274        let mut s = slot(&mut buf);
275        assert_eq!(s.capture(b"this is way too long"), 8);
276        assert_eq!(s.read(), Some(&b"this is "[..]));
277    }
278
279    #[test]
280    fn random_uninitialized_bytes_do_not_read_as_valid() {
281        let mut buf: [u8; 64] = [0xA5; 64]; // not the magic
282        let s = slot(&mut buf);
283        assert_eq!(s.read(), None);
284    }
285
286    #[test]
287    fn clear_invalidates_record() {
288        let mut buf = [0u8; 64];
289        let mut s = slot(&mut buf);
290        s.capture(b"oops");
291        assert!(s.read().is_some());
292        s.clear();
293        assert_eq!(s.read(), None);
294    }
295
296    #[test]
297    fn corrupted_payload_is_rejected() {
298        let mut buf = [0u8; 64];
299        {
300            let mut s = slot(&mut buf);
301            s.capture(b"hello world");
302        }
303        // Flip a byte inside the payload.
304        buf[10] ^= 0xFF;
305        let s = slot(&mut buf);
306        assert_eq!(s.read(), None);
307    }
308
309    #[test]
310    fn corrupted_length_is_rejected() {
311        let mut buf = [0u8; 64];
312        {
313            let mut s = slot(&mut buf);
314            s.capture(b"hello");
315        }
316        // Bump length to claim more bytes than the payload contains.
317        buf[4] = 200;
318        let s = slot(&mut buf);
319        assert_eq!(s.read(), None);
320    }
321
322    #[test]
323    fn length_beyond_region_is_rejected() {
324        let mut buf = [0u8; 64];
325        {
326            let mut s = slot(&mut buf);
327            s.capture(b"x");
328        }
329        // Length larger than the region's payload capacity.
330        let bogus_len: u16 = 1024;
331        buf[4..6].copy_from_slice(&bogus_len.to_le_bytes());
332        let s = slot(&mut buf);
333        assert_eq!(s.read(), None);
334    }
335
336    #[test]
337    fn second_capture_overwrites_first() {
338        let mut buf = [0u8; 64];
339        let mut s = slot(&mut buf);
340        s.capture(b"first");
341        s.capture(b"second");
342        assert_eq!(s.read(), Some(&b"second"[..]));
343    }
344
345    #[test]
346    fn read_does_not_mutate_region() {
347        let mut buf = [0u8; 64];
348        {
349            let mut s = slot(&mut buf);
350            s.capture(b"persisting");
351        }
352        let snapshot = buf;
353        {
354            let s = slot(&mut buf);
355            let _ = s.read();
356        }
357        assert_eq!(snapshot, buf);
358    }
359
360    #[test]
361    fn payload_capacity_excludes_header() {
362        let mut buf = [0u8; 100];
363        let s = slot(&mut buf);
364        assert_eq!(s.payload_capacity(), 100 - HEADER_LEN);
365    }
366
367    #[test]
368    fn empty_payload_is_valid() {
369        let mut buf = [0u8; 64];
370        let mut s = slot(&mut buf);
371        assert_eq!(s.capture(b""), 0);
372        assert_eq!(s.read(), Some(&b""[..]));
373    }
374}