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}