nros_node/boot_report.rs
1//! A fixed RAM record the image writes about itself, for targets where no log
2//! sink reaches a human.
3//!
4//! # Why this exists
5//!
6//! phase-412 derived six pool counts and the executor arena for the
7//! mr-canhubk344 island, and then could not tell whether the derived image was
8//! correct. The only available signal was the ROS graph's node count, and one
9//! unchanged configuration produced 4, 0, 0, 4, 4 across five runs. Every other
10//! channel was already disqualified for that board:
11//!
12//! * The console is on `lpuart0`, which is not wired on the MR-CANHUBK344.
13//! `lpuart2` is the zenoh serial transport and cannot carry a second protocol.
14//! * `nros_log` therefore reaches nothing. Both arena diagnostics (issue 0900)
15//! go through it, so the two messages written specifically to explain an
16//! arena failure are invisible on the one board that needed them.
17//! * SEGGER RTT was tried and could not discriminate: a working image and a
18//! derived image both emitted only the Zephyr banner, and a deliberate
19//! positive control produced nothing at all.
20//! * Semihosting halts the core until a probe answers and FAULTS with no probe
21//! attached, so an image carrying it cannot run standalone.
22//!
23//! What all of those share is that they are STREAMS: they need the board to
24//! still be running, and they need somebody attached at the moment the
25//! interesting thing happens. The failure this campaign is trying to see is the
26//! opposite shape. An under-sized arena halts DURING entity creation, before
27//! the first spin, so issue 0900's advisory never prints -- the failure cannot
28//! report itself through any stream.
29//!
30//! So this is not a stream. It is a fixed-size record in RAM that the image
31//! keeps up to date as it boots, read out AFTERWARDS by halting the core and
32//! dumping memory. It survives the halt because it does not depend on anything
33//! still running, and a PARTIAL record is the useful case rather than a lost
34//! one: the last stage reached and the allocation that did not fit are exactly
35//! what names the knob to change.
36//!
37//! # Reading it
38//!
39//! The record is a `#[no_mangle]` static, so it has a symbol in the ELF and a
40//! debugger can find it without the address being wired in anywhere:
41//!
42//! ```text
43//! pyocd commander -t s32k344 -c "halt" -c "savemem <addr> <len> report.bin"
44//! python3 scripts/read-boot-report.py <elf> report.bin
45//! ```
46//!
47//! [`MAGIC`] distinguishes a written record from uninitialised RAM, and
48//! [`BootReport::struct_size`] lets a reader refuse a layout it does not know
49//! rather than decode it wrongly.
50//!
51//! # Cost, and why it is opt-in
52//!
53//! Enabled by setting `NROS_BOOT_REPORT=1` at build time (Zephyr:
54//! `CONFIG_NROS_BOOT_REPORT=y`), which makes `nros-node`'s build script emit
55//! `cfg(nros_boot_report)`. With the cfg absent every function here is an empty
56//! `#[inline(always)]` body and the static does not exist, so an image that
57//! does not opt in is byte-identical to one built before this module -- the
58//! same rule issue 0900's arena knob and phase-403's `rx_buffer_from_type()`
59//! both keep.
60//!
61//! Enabled, it costs [`BootReport::struct_size`] bytes of `.bss` (60 on a
62//! 32-bit target) and a handful of relaxed atomic stores on paths that run once
63//! per entity at registration. Nothing here is on the spin path.
64
65#![allow(clippy::module_name_repetitions)]
66
67/// `"NRSR"` -- nano-ros self report. Written LAST, so a reader that finds it
68/// knows every field before it is already valid.
69pub const MAGIC: u32 = 0x4e52_5352;
70
71/// Layout version. Bump on any field change; a reader refuses what it does not
72/// know rather than decoding a record it would misread.
73pub const VERSION: u32 = 3;
74
75/// How far boot got. Monotonic, and the single most useful field: an arena
76/// failure halts during entity creation, so the stage that was NOT reached
77/// names the phase to look at.
78///
79/// Numbers follow EXECUTION ORDER, because `checkpoint` keeps the maximum
80/// and a stage that runs earlier but numbers higher would make the record
81/// claim less progress than was made. Inserting one therefore renumbers the
82/// rest and bumps [`VERSION`]; the decoder refuses a version it does not
83/// know rather than misreading it, which is what makes that safe.
84#[derive(Clone, Copy, PartialEq, Eq, Debug)]
85#[repr(u32)]
86pub enum Stage {
87 /// RAM as the loader left it. Never stored; a reader seeing this with a
88 /// valid magic has found a record that was reset but not re-entered.
89 Untouched = 0,
90 /// The record itself is initialised and the compile-time knobs are in it.
91 ///
92 /// Stamped at the TOP of the C++ entry point, before any argument is
93 /// validated, so "the image never entered nano-ros" is distinguishable
94 /// from "it entered and died before the executor". Version 1 stamped this
95 /// inside the executor constructor instead, which made those two cases
96 /// identical -- both read magic 0 -- and cost a disassembly walk to tell
97 /// apart on the first board run.
98 ReportReady = 1,
99 /// The boot config resolved: node name, namespace, locator and domain id
100 /// all parsed. Everything between here and [`Stage::ReportReady`] is
101 /// argument validation, and [`BootReport::cpp_init_ret`] says which check
102 /// rejected it.
103 BootConfigResolved = 2,
104 /// An `Executor` has bound its arena, so [`BootReport::arena_capacity`]
105 /// is the real slice length rather than the compiled constant.
106 ExecutorReady = 3,
107 /// Entity registration has begun. The interval between this and
108 /// [`Stage::EntitiesReady`] is where an under-sized arena halts.
109 RegisteringEntities = 4,
110 /// Every entity the image declares was registered successfully.
111 EntitiesReady = 5,
112 /// The first `spin_once` was entered, which is where issue 0900's
113 /// headroom advisory would have printed had a sink existed.
114 FirstSpin = 6,
115}
116
117#[cfg(nros_boot_report)]
118pub use enabled::*;
119
120#[cfg(nros_boot_report)]
121mod enabled {
122 use super::{MAGIC, Stage, VERSION};
123 use portable_atomic::{AtomicU32, Ordering};
124
125 /// The record. One per image, in `.bss`.
126 ///
127 /// `#[repr(C)]` with every field an `AtomicU32` -- which is
128 /// `repr(transparent)` over `u32` -- so the layout is exactly the sequence
129 /// of 32-bit words the reader script decodes, on every target this crate
130 /// builds for.
131 ///
132 /// Atomics rather than a `static mut` because the record is written from
133 /// registration paths that an application may reach from more than one
134 /// thread. `Relaxed` throughout: there is no ordering relationship to
135 /// establish with any other data, and the reader is a debugger that has
136 /// already halted the core.
137 #[repr(C)]
138 pub struct BootReport {
139 magic: AtomicU32,
140 version: AtomicU32,
141 struct_size: AtomicU32,
142 stage: AtomicU32,
143
144 // Compile-time, so that comparing these against what the build system
145 // BELIEVES it delivered turns a "derived value did not arrive" defect
146 // into a measurement. `scripts/check-knob-delivery.py` asserts the same
147 // identity one step earlier, at `build.ninja`; this is the same
148 // assertion made by the silicon.
149 arena_size: AtomicU32,
150 max_cbs: AtomicU32,
151 max_sc: AtomicU32,
152 max_nodes: AtomicU32,
153 default_rx_buf_size: AtomicU32,
154
155 // Runtime.
156 arena_capacity: AtomicU32,
157 arena_used: AtomicU32,
158 alloc_count: AtomicU32,
159 last_alloc_size: AtomicU32,
160 /// Bytes the allocation that FAILED asked for, or 0 if none has.
161 failed_alloc_size: AtomicU32,
162 /// Bytes by which that allocation overran the arena. This is the
163 /// number to add to `NROS_EXECUTOR_ARENA_SIZE`, which is why it is
164 /// stored rather than left to be recomputed from the two above.
165 failed_alloc_shortfall: AtomicU32,
166 /// `nros_cpp_init`'s return code, as the two's-complement bits of an
167 /// `i32`, or 0 (`NROS_CPP_RET_OK`) if it has not returned yet.
168 ///
169 /// The stage says HOW FAR init got; this says why it stopped. Without
170 /// it, every early return in that function -- a null argument, a
171 /// non-UTF-8 name, a bad domain id, a backend that refused to open --
172 /// is one indistinguishable "did not reach the executor".
173 cpp_init_ret: AtomicU32,
174 /// The LAST `NodeError` that crossed the C++ FFI, as a stable code.
175 ///
176 /// Stable here means assigned by `nros-cpp`'s exhaustive mapper, not
177 /// taken from the Rust discriminant -- a discriminant shifts whenever a
178 /// variant is inserted, and a dump decoded against the wrong numbering
179 /// names the wrong error, confidently.
180 err_class: AtomicU32,
181 /// For [`Self::err_class`] == Transport, which `TransportError`.
182 ///
183 /// The C++ ABI collapses eight distinct transport variants onto the
184 /// single code -100, which is what made an island subscription failure
185 /// undiagnosable: the return code said "transport" and nothing said
186 /// which. This is the field that separates them.
187 err_transport: AtomicU32,
188 /// Address of the `Backend(&'static str)` message, or 0.
189 ///
190 /// The pointer rather than the text: the message is a static string
191 /// already in the image, so copying it would cost the record a buffer
192 /// to hold something the reader can fetch. Paired with
193 /// [`Self::err_backend_len`].
194 err_backend_ptr: AtomicU32,
195 /// Length of the message at [`Self::err_backend_ptr`], or 0.
196 err_backend_len: AtomicU32,
197 }
198
199 impl BootReport {
200 const fn new() -> Self {
201 Self {
202 magic: AtomicU32::new(0),
203 version: AtomicU32::new(0),
204 struct_size: AtomicU32::new(0),
205 stage: AtomicU32::new(0),
206 arena_size: AtomicU32::new(0),
207 max_cbs: AtomicU32::new(0),
208 max_sc: AtomicU32::new(0),
209 max_nodes: AtomicU32::new(0),
210 default_rx_buf_size: AtomicU32::new(0),
211 arena_capacity: AtomicU32::new(0),
212 arena_used: AtomicU32::new(0),
213 alloc_count: AtomicU32::new(0),
214 last_alloc_size: AtomicU32::new(0),
215 failed_alloc_size: AtomicU32::new(0),
216 failed_alloc_shortfall: AtomicU32::new(0),
217 cpp_init_ret: AtomicU32::new(0),
218 err_class: AtomicU32::new(0),
219 err_transport: AtomicU32::new(0),
220 err_backend_ptr: AtomicU32::new(0),
221 err_backend_len: AtomicU32::new(0),
222 }
223 }
224
225 /// Size of the record in bytes, as the reader must expect it.
226 ///
227 /// ASKED OF THE COMPILER, not counted by hand. A hand-written word
228 /// count is a second statement of the field list that drifts the first
229 /// time a field is added, and it would drift SILENTLY -- the reader
230 /// would accept the record and decode one field short. This whole
231 /// campaign is about not hand-picking numbers the build already knows.
232 #[must_use]
233 pub const fn struct_size() -> u32 {
234 // The record is all `AtomicU32`, so this is exact on every target
235 // and there is no padding for the cast to lose.
236 core::mem::size_of::<Self>() as u32
237 }
238 }
239
240 /// A plain-value copy of the record.
241 ///
242 /// Field-for-field with [`BootReport`] and in the SAME ORDER, because
243 /// `scripts/read-boot-report.py` decodes that order out of a memory dump.
244 /// A test that reads through this therefore exercises the same layout the
245 /// script assumes, which is the only thing keeping the two in step.
246 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
247 pub struct Snapshot {
248 pub magic: u32,
249 pub version: u32,
250 pub struct_size: u32,
251 pub stage: u32,
252 pub arena_size: u32,
253 pub max_cbs: u32,
254 pub max_sc: u32,
255 pub max_nodes: u32,
256 pub default_rx_buf_size: u32,
257 pub arena_capacity: u32,
258 pub arena_used: u32,
259 pub alloc_count: u32,
260 pub last_alloc_size: u32,
261 pub failed_alloc_size: u32,
262 pub failed_alloc_shortfall: u32,
263 pub cpp_init_ret: u32,
264 pub err_class: u32,
265 pub err_transport: u32,
266 pub err_backend_ptr: u32,
267 pub err_backend_len: u32,
268 }
269
270 /// Read the record.
271 #[must_use]
272 pub fn snapshot() -> Snapshot {
273 let r = &NROS_BOOT_REPORT;
274 let g = |f: &AtomicU32| f.load(Ordering::Relaxed);
275 Snapshot {
276 magic: g(&r.magic),
277 version: g(&r.version),
278 struct_size: g(&r.struct_size),
279 stage: g(&r.stage),
280 arena_size: g(&r.arena_size),
281 max_cbs: g(&r.max_cbs),
282 max_sc: g(&r.max_sc),
283 max_nodes: g(&r.max_nodes),
284 default_rx_buf_size: g(&r.default_rx_buf_size),
285 arena_capacity: g(&r.arena_capacity),
286 arena_used: g(&r.arena_used),
287 alloc_count: g(&r.alloc_count),
288 last_alloc_size: g(&r.last_alloc_size),
289 failed_alloc_size: g(&r.failed_alloc_size),
290 failed_alloc_shortfall: g(&r.failed_alloc_shortfall),
291 cpp_init_ret: g(&r.cpp_init_ret),
292 err_class: g(&r.err_class),
293 err_transport: g(&r.err_transport),
294 err_backend_ptr: g(&r.err_backend_ptr),
295 err_backend_len: g(&r.err_backend_len),
296 }
297 }
298
299 /// The record, findable by symbol name from a debugger.
300 ///
301 /// `#[used]` because nothing in a minimal image necessarily reads it, and a
302 /// static whose only writes are through this module's functions is exactly
303 /// what a linker is entitled to discard.
304 #[unsafe(no_mangle)]
305 #[used]
306 pub static NROS_BOOT_REPORT: BootReport = BootReport::new();
307
308 /// Stamp the header and the compile-time knobs.
309 ///
310 /// Idempotent, and safe to call from more than one place -- an image with
311 /// two executors should not have to decide which one owns the record.
312 /// MAGIC is stored LAST so a reader that finds it knows the rest is there.
313 pub fn init() {
314 let r = &NROS_BOOT_REPORT;
315 r.version.store(VERSION, Ordering::Relaxed);
316 r.struct_size
317 .store(BootReport::struct_size(), Ordering::Relaxed);
318 r.arena_size
319 .store(saturate(crate::config::ARENA_SIZE), Ordering::Relaxed);
320 r.max_cbs
321 .store(saturate(crate::config::MAX_CBS), Ordering::Relaxed);
322 r.max_sc
323 .store(saturate(crate::config::MAX_SC), Ordering::Relaxed);
324 r.max_nodes
325 .store(saturate(crate::config::MAX_NODES), Ordering::Relaxed);
326 r.default_rx_buf_size.store(
327 saturate(crate::config::DEFAULT_RX_BUF_SIZE),
328 Ordering::Relaxed,
329 );
330 r.magic.store(MAGIC, Ordering::Relaxed);
331 checkpoint(Stage::ReportReady);
332 }
333
334 /// Record that boot reached `stage`.
335 ///
336 /// MONOTONIC: a lower stage never overwrites a higher one, so a late call
337 /// on a re-entered path cannot make the record claim less progress than was
338 /// actually made. That matters because the field's whole purpose is to be
339 /// believed about a boot that did not finish.
340 pub fn checkpoint(stage: Stage) {
341 let want = stage as u32;
342 let r = &NROS_BOOT_REPORT;
343 let mut cur = r.stage.load(Ordering::Relaxed);
344 while want > cur {
345 match r
346 .stage
347 .compare_exchange_weak(cur, want, Ordering::Relaxed, Ordering::Relaxed)
348 {
349 Ok(_) => return,
350 Err(actual) => cur = actual,
351 }
352 }
353 }
354
355 /// Record the arena slice an `Executor` actually bound.
356 ///
357 /// Not the same number as `ARENA_SIZE`, and the difference is a finding
358 /// rather than noise: the arena's placement is the caller's choice
359 /// (issue 0900), so an image can compile one size and hand the executor
360 /// another. Both are in the record so a dump can say which happened.
361 pub fn note_arena_capacity(capacity: usize) {
362 NROS_BOOT_REPORT
363 .arena_capacity
364 .store(saturate(capacity), Ordering::Relaxed);
365 }
366
367 /// Record a successful arena allocation.
368 pub fn note_alloc(size: usize, used_after: usize) {
369 let r = &NROS_BOOT_REPORT;
370 r.alloc_count.fetch_add(1, Ordering::Relaxed);
371 r.last_alloc_size.store(saturate(size), Ordering::Relaxed);
372 r.arena_used.store(saturate(used_after), Ordering::Relaxed);
373 }
374
375 /// Record the arena allocation that did not fit.
376 ///
377 /// FIRST writer wins, on the same reasoning as [`checkpoint`]: the first
378 /// failure is the one that explains the boot, and any later one is a
379 /// consequence of it.
380 pub fn note_alloc_failed(size: usize, shortfall: usize) {
381 let r = &NROS_BOOT_REPORT;
382 if r.failed_alloc_size
383 .compare_exchange(0, saturate(size), Ordering::Relaxed, Ordering::Relaxed)
384 .is_ok()
385 {
386 r.failed_alloc_shortfall
387 .store(saturate(shortfall), Ordering::Relaxed);
388 }
389 }
390
391 /// Record `nros_cpp_init`'s return code.
392 ///
393 /// LAST writer wins, unlike [`note_alloc_failed`]: an image may call
394 /// `nros_cpp_init` more than once (per component, per tier), and the
395 /// interesting one is the call that did not get through, which is the one
396 /// that leaves the stage where it stopped.
397 pub fn note_cpp_init_ret(ret: i32) {
398 NROS_BOOT_REPORT
399 .cpp_init_ret
400 .store(ret as u32, Ordering::Relaxed);
401 }
402
403 /// Record an error that crossed the FFI.
404 ///
405 /// Takes CODES, not the error type: `nros-node` must not need to know how
406 /// `nros-cpp` numbers its variants, and the numbering has to be assigned by
407 /// an exhaustive match that a new variant breaks at compile time. The caller
408 /// owns both.
409 ///
410 /// LAST writer wins. An image that fails one entity and carries on would
411 /// otherwise keep the first stumble instead of the one that stopped it, and
412 /// the failure that stops setup is the one that explains the boot.
413 pub fn note_error(class: u32, transport: u32, backend_ptr: u32, backend_len: u32) {
414 let r = &NROS_BOOT_REPORT;
415 r.err_class.store(class, Ordering::Relaxed);
416 r.err_transport.store(transport, Ordering::Relaxed);
417 r.err_backend_ptr.store(backend_ptr, Ordering::Relaxed);
418 r.err_backend_len.store(backend_len, Ordering::Relaxed);
419 }
420
421 /// `usize` -> `u32`, saturating.
422 ///
423 /// Every field is a `u32` so the record's layout does not change between a
424 /// 32-bit board and a 64-bit host running the same tests. Saturating rather
425 /// than truncating because a value too large to represent should read as
426 /// "enormous", not as its low half -- a truncated 4 GiB reads as 0, which
427 /// is the one wrong answer that looks like a normal one.
428 fn saturate(v: usize) -> u32 {
429 u32::try_from(v).unwrap_or(u32::MAX)
430 }
431}
432
433#[cfg(not(nros_boot_report))]
434pub use disabled::*;
435
436/// No-op stubs, so call sites need no `cfg` of their own and an image that does
437/// not opt in is byte-identical.
438#[cfg(not(nros_boot_report))]
439mod disabled {
440 use super::Stage;
441
442 #[inline(always)]
443 pub fn init() {}
444
445 #[inline(always)]
446 pub fn checkpoint(_stage: Stage) {}
447
448 #[inline(always)]
449 pub fn note_arena_capacity(_capacity: usize) {}
450
451 #[inline(always)]
452 pub fn note_alloc(_size: usize, _used_after: usize) {}
453
454 #[inline(always)]
455 pub fn note_alloc_failed(_size: usize, _shortfall: usize) {}
456
457 #[inline(always)]
458 pub fn note_cpp_init_ret(_ret: i32) {}
459
460 #[inline(always)]
461 pub fn note_error(_class: u32, _transport: u32, _ptr: u32, _len: u32) {}
462}
463
464#[cfg(all(test, nros_boot_report))]
465mod tests {
466 use super::*;
467
468 /// The reader decodes twenty u32s positionally, so the record must be
469 /// exactly that and nothing else -- no padding, no reordering.
470 ///
471 /// `size_of` on the TARGET, which is the half `check-boot-report-layout.py`
472 /// cannot see: that gate compares two source files, and this compares the
473 /// source against what the compiler actually laid out.
474 #[test]
475 fn the_record_is_twenty_packed_u32s() {
476 assert_eq!(BootReport::struct_size(), 20 * 4);
477 assert_eq!(
478 core::mem::size_of::<BootReport>(),
479 20 * core::mem::size_of::<u32>(),
480 "the record grew padding; the reader decodes positionally"
481 );
482 assert_eq!(core::mem::align_of::<BootReport>(), 4);
483 }
484
485 /// The magic is written LAST, so finding it means the rest is valid.
486 ///
487 /// The reader leans on this to tell "the image died before it had an
488 /// executor" apart from "the dump is at the wrong address", and it can
489 /// only do so if the ordering actually holds.
490 #[test]
491 fn init_stamps_the_header_and_the_compiled_knobs() {
492 init();
493 let s = snapshot();
494 assert_eq!(s.magic, MAGIC);
495 assert_eq!(s.version, VERSION);
496 assert_eq!(s.struct_size, BootReport::struct_size());
497 assert_eq!(s.arena_size, crate::config::ARENA_SIZE as u32);
498 assert_eq!(s.max_cbs, crate::config::MAX_CBS as u32);
499 assert_eq!(s.max_nodes, crate::config::MAX_NODES as u32);
500 assert!(s.stage >= Stage::ReportReady as u32);
501 }
502
503 /// A late call on a re-entered path must not make the record claim LESS
504 /// progress than was actually made. The field's whole purpose is to be
505 /// believed about a boot that did not finish.
506 #[test]
507 fn a_checkpoint_never_goes_backwards() {
508 init();
509 checkpoint(Stage::FirstSpin);
510 assert_eq!(snapshot().stage, Stage::FirstSpin as u32);
511 checkpoint(Stage::ExecutorReady);
512 assert_eq!(
513 snapshot().stage,
514 Stage::FirstSpin as u32,
515 "an earlier stage overwrote a later one"
516 );
517 }
518
519 /// The FIRST failure is the one that explains the boot; a later one is a
520 /// consequence of it and must not overwrite the cause.
521 #[test]
522 fn the_first_alloc_failure_wins() {
523 init();
524 note_alloc_failed(100, 8);
525 note_alloc_failed(999, 512);
526 let s = snapshot();
527 assert_eq!(s.failed_alloc_size, 100);
528 assert_eq!(s.failed_alloc_shortfall, 8);
529 }
530
531 /// A value too large for the field must read as enormous, not as its low
532 /// half. A truncated 4 GiB reads as 0, which is the one wrong answer that
533 /// looks like a normal one.
534 #[test]
535 fn an_unrepresentable_size_saturates_rather_than_truncating() {
536 init();
537 note_arena_capacity(usize::MAX);
538 assert_eq!(snapshot().arena_capacity, u32::MAX);
539 }
540}