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.
38pub struct SyncNoinit<T>(pub core::cell::UnsafeCell<core::mem::MaybeUninit<T>>);
39
40// Safety: nRF52840 is a single-core device; concurrent access is impossible.
41unsafe impl<T> Sync for SyncNoinit<T> {}
42
43impl<T> SyncNoinit<T> {
44    pub const fn uninit() -> Self {
45        Self(core::cell::UnsafeCell::new(core::mem::MaybeUninit::uninit()))
46    }
47
48    /// Return a `&'static mut [u8]` slice over the inner value.
49    ///
50    /// # Safety
51    /// The caller must ensure no other live reference to this cell exists.
52    pub unsafe fn as_bytes_mut(&self) -> &'static mut [u8] {
53        let ptr: *mut u8 = self.0.get().cast::<u8>();
54        // SAFETY: caller ensures no other live reference; ptr is non-null and aligned.
55        unsafe { core::slice::from_raw_parts_mut(ptr, core::mem::size_of::<T>()) }
56    }
57}
58
59/// A `core::fmt::Write` sink over a fixed-size byte slice.
60///
61/// Used to format a `core::panic::PanicInfo` into a stack buffer without
62/// heap allocation. Truncates silently when the buffer fills — acceptable
63/// for a best-effort panic message capture.
64pub struct SliceWriter<'a> {
65    pub buf: &'a mut [u8],
66    pub pos: usize,
67}
68
69impl<'a> core::fmt::Write for SliceWriter<'a> {
70    fn write_str(&mut self, s: &str) -> core::fmt::Result {
71        let avail = self.buf.len().saturating_sub(self.pos);
72        let n = s.len().min(avail);
73        self.buf[self.pos..self.pos + n].copy_from_slice(&s.as_bytes()[..n]);
74        self.pos += n;
75        Ok(())
76    }
77}
78
79const PANIC_MAGIC: u32 = 0x554D_5350; // "UMSP" — UMSH Panic
80const HEADER_LEN: usize = 8;
81
82/// Framing wrapper around a borrowed RAM region.
83///
84/// One instance is constructed at boot from the BSP-provided
85/// `&'static mut [u8]`. Pass the same region (the literal same RAM,
86/// not a fresh copy) every reset so the previous boot's panic can be
87/// recovered.
88#[derive(Debug)]
89pub struct PanicSlot<'a> {
90    region: &'a mut [u8],
91}
92
93impl<'a> PanicSlot<'a> {
94    /// Wrap a RAM region. Region must be at least
95    /// [`HEADER_LEN`] + 1 byte; smaller regions panic in debug and
96    /// silently truncate writes to zero payload bytes in release.
97    pub fn new(region: &'a mut [u8]) -> Self {
98        debug_assert!(
99            region.len() > HEADER_LEN,
100            "panic slot region must hold at least the 8-byte header plus 1 payload byte"
101        );
102        Self { region }
103    }
104
105    /// Capacity available for payload bytes.
106    pub fn payload_capacity(&self) -> usize {
107        self.region.len().saturating_sub(HEADER_LEN)
108    }
109
110    /// Write a panic message. Truncates if `msg` is larger than the
111    /// payload capacity. Returns the number of payload bytes actually
112    /// stored. Designed to be callable from a panic handler, so it
113    /// allocates nothing and uses only direct slice writes.
114    pub fn capture(&mut self, msg: &[u8]) -> usize {
115        if self.region.len() <= HEADER_LEN {
116            return 0;
117        }
118        let cap = self.payload_capacity();
119        let len = msg.len().min(cap);
120
121        self.region[0..4].copy_from_slice(&PANIC_MAGIC.to_le_bytes());
122        self.region[4..6].copy_from_slice(&(len as u16).to_le_bytes());
123        self.region[8..8 + len].copy_from_slice(&msg[..len]);
124
125        let cksum = checksum(&self.region[0..6], &self.region[8..8 + len]);
126        self.region[6..8].copy_from_slice(&cksum.to_le_bytes());
127        len
128    }
129
130    /// Borrow the previously-captured payload, if a valid record is
131    /// present. Returns `None` for an uninitialized region, a stale /
132    /// foreign record (wrong magic), a corrupt record (bad checksum),
133    /// or an out-of-bounds length.
134    pub fn read(&self) -> Option<&[u8]> {
135        if self.region.len() <= HEADER_LEN {
136            return None;
137        }
138
139        let magic = u32::from_le_bytes(self.region[0..4].try_into().ok()?);
140        if magic != PANIC_MAGIC {
141            return None;
142        }
143
144        let len = u16::from_le_bytes(self.region[4..6].try_into().ok()?) as usize;
145        if len > self.payload_capacity() {
146            return None;
147        }
148
149        let stored_cksum = u16::from_le_bytes(self.region[6..8].try_into().ok()?);
150        let computed = checksum(&self.region[0..6], &self.region[8..8 + len]);
151        if stored_cksum != computed {
152            return None;
153        }
154
155        Some(&self.region[8..8 + len])
156    }
157
158    /// Invalidate the record. Subsequent [`read`](Self::read) calls
159    /// return `None` until another [`capture`](Self::capture) writes a
160    /// new one. Cheap: zeroes only the 4-byte magic.
161    pub fn clear(&mut self) {
162        if self.region.len() >= 4 {
163            self.region[0..4].fill(0);
164        }
165    }
166}
167
168/// Fletcher-16 over two byte slices (header front + payload). The
169/// header's checksum field itself is skipped by construction — callers
170/// pass `header[0..6]` (magic + length, omitting bytes 6..8 where the
171/// checksum will be stored).
172fn checksum(header_front: &[u8], payload: &[u8]) -> u16 {
173    let mut s1: u16 = 0;
174    let mut s2: u16 = 0;
175    for &b in header_front.iter().chain(payload.iter()) {
176        s1 = (s1.wrapping_add(b as u16)) % 255;
177        s2 = (s2.wrapping_add(s1)) % 255;
178    }
179    (s2 << 8) | s1
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn slot(region: &mut [u8]) -> PanicSlot<'_> {
187        PanicSlot::new(region)
188    }
189
190    #[test]
191    fn empty_region_returns_none() {
192        let mut buf = [0u8; 64];
193        let s = slot(&mut buf);
194        assert_eq!(s.read(), None);
195    }
196
197    #[test]
198    fn round_trip_basic_message() {
199        let mut buf = [0u8; 64];
200        let mut s = slot(&mut buf);
201        assert_eq!(s.capture(b"oops"), 4);
202        assert_eq!(s.read(), Some(&b"oops"[..]));
203    }
204
205    #[test]
206    fn round_trip_full_capacity() {
207        let mut buf = [0u8; 64];
208        let cap = buf.len() - HEADER_LEN;
209        let payload = [b'x'; 56]; // 64 - HEADER_LEN
210        assert_eq!(payload.len(), cap);
211        let mut s = slot(&mut buf);
212        assert_eq!(s.capture(&payload), cap);
213        assert_eq!(s.read(), Some(&payload[..]));
214    }
215
216    #[test]
217    fn truncates_when_payload_exceeds_capacity() {
218        let mut buf = [0u8; 16]; // capacity = 8
219        let mut s = slot(&mut buf);
220        assert_eq!(s.capture(b"this is way too long"), 8);
221        assert_eq!(s.read(), Some(&b"this is "[..]));
222    }
223
224    #[test]
225    fn random_uninitialized_bytes_do_not_read_as_valid() {
226        let mut buf: [u8; 64] = [0xA5; 64]; // not the magic
227        let s = slot(&mut buf);
228        assert_eq!(s.read(), None);
229    }
230
231    #[test]
232    fn clear_invalidates_record() {
233        let mut buf = [0u8; 64];
234        let mut s = slot(&mut buf);
235        s.capture(b"oops");
236        assert!(s.read().is_some());
237        s.clear();
238        assert_eq!(s.read(), None);
239    }
240
241    #[test]
242    fn corrupted_payload_is_rejected() {
243        let mut buf = [0u8; 64];
244        {
245            let mut s = slot(&mut buf);
246            s.capture(b"hello world");
247        }
248        // Flip a byte inside the payload.
249        buf[10] ^= 0xFF;
250        let s = slot(&mut buf);
251        assert_eq!(s.read(), None);
252    }
253
254    #[test]
255    fn corrupted_length_is_rejected() {
256        let mut buf = [0u8; 64];
257        {
258            let mut s = slot(&mut buf);
259            s.capture(b"hello");
260        }
261        // Bump length to claim more bytes than the payload contains.
262        buf[4] = 200;
263        let s = slot(&mut buf);
264        assert_eq!(s.read(), None);
265    }
266
267    #[test]
268    fn length_beyond_region_is_rejected() {
269        let mut buf = [0u8; 64];
270        {
271            let mut s = slot(&mut buf);
272            s.capture(b"x");
273        }
274        // Length larger than the region's payload capacity.
275        let bogus_len: u16 = 1024;
276        buf[4..6].copy_from_slice(&bogus_len.to_le_bytes());
277        let s = slot(&mut buf);
278        assert_eq!(s.read(), None);
279    }
280
281    #[test]
282    fn second_capture_overwrites_first() {
283        let mut buf = [0u8; 64];
284        let mut s = slot(&mut buf);
285        s.capture(b"first");
286        s.capture(b"second");
287        assert_eq!(s.read(), Some(&b"second"[..]));
288    }
289
290    #[test]
291    fn read_does_not_mutate_region() {
292        let mut buf = [0u8; 64];
293        {
294            let mut s = slot(&mut buf);
295            s.capture(b"persisting");
296        }
297        let snapshot = buf;
298        {
299            let s = slot(&mut buf);
300            let _ = s.read();
301        }
302        assert_eq!(snapshot, buf);
303    }
304
305    #[test]
306    fn payload_capacity_excludes_header() {
307        let mut buf = [0u8; 100];
308        let s = slot(&mut buf);
309        assert_eq!(s.payload_capacity(), 100 - HEADER_LEN);
310    }
311
312    #[test]
313    fn empty_payload_is_valid() {
314        let mut buf = [0u8; 64];
315        let mut s = slot(&mut buf);
316        assert_eq!(s.capture(b""), 0);
317        assert_eq!(s.read(), Some(&b""[..]));
318    }
319}