nros_node/executor/storage.rs
1//! phase-271 — per-entry [`Executor`](super::spin::Executor) storage (issue 0110).
2//!
3//! The executor's six sized arrays (callback table + arena + scheduling-context
4//! tables) used to be inline fields sized by build-time consts baked into
5//! `nros-node` — one size for every entry sharing a compiled `nros-node`. Here the
6//! ENTRY supplies its own storage, sized to its topology, so a fat native entry
7//! and a lean embedded entry in one workspace each get the right size with no
8//! workspace-global env.
9//!
10//! Per the "C/C++ is a thin wrapper of Rust" principle the PUBLIC API stays
11//! generic-free: the entry hands a raw, 8-aligned `&mut [MaybeUninit<u64>]` backing
12//! (sized via [`executor_storage_u64_len`]); `nros-node` carves it privately into
13//! the typed sub-slices ([`carve`]). The only `unsafe` is that carve, validated
14//! against the `#[repr(C)]` reference [`ExecutorStorage`] layout by unit test.
15
16use core::{
17 alloc::Layout,
18 mem::{MaybeUninit, align_of, size_of},
19};
20
21use super::{
22 arena::CallbackMeta,
23 sched_context::{SchedContext, SchedContextId, SporadicState},
24};
25
26#[cfg(feature = "alloc")]
27type SporadicAtomic = (
28 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
29 super::spin::OpaqueTimerHandle,
30);
31
32/// The typed reference layout the [`carve`] mirrors. `#[repr(C)]` so its field
33/// offsets are the deterministic declaration-order layout the const-fn below
34/// reproduces; a unit test asserts they agree. Only referenced by tests.
35#[cfg(test)]
36#[repr(C)]
37pub(crate) struct ExecutorStorage<const CBS: usize, const SC: usize, const ARENA: usize> {
38 arena: [MaybeUninit<u8>; ARENA],
39 entries: [Option<CallbackMeta>; CBS],
40 sched_contexts: [Option<SchedContext>; SC],
41 sched_context_bindings: [SchedContextId; CBS],
42 sporadic_states: [Option<SporadicState>; SC],
43 #[cfg(feature = "alloc")]
44 sporadic_atomic_states: [Option<SporadicAtomic>; SC],
45}
46
47/// The typed, mutable sub-slices an [`Executor`](super::spin::Executor) borrows
48/// from a carved backing. Element memory is initialised by [`carve`].
49pub(crate) struct ExecutorSlices<'s> {
50 pub(crate) arena: &'s mut [MaybeUninit<u8>],
51 pub(crate) entries: &'s mut [Option<CallbackMeta>],
52 pub(crate) sched_contexts: &'s mut [Option<SchedContext>],
53 pub(crate) sched_context_bindings: &'s mut [SchedContextId],
54 pub(crate) sporadic_states: &'s mut [Option<SporadicState>],
55 #[cfg(feature = "alloc")]
56 pub(crate) sporadic_atomic_states: &'s mut [Option<SporadicAtomic>],
57}
58
59/// Byte offsets of each field within the backing + total size/align. Computed
60/// identically by [`executor_storage_layout`] and [`carve`] (single source of
61/// truth), reproducing `#[repr(C)]` declaration-order layout.
62struct FieldOffsets {
63 arena: usize,
64 entries: usize,
65 sched_contexts: usize,
66 sched_context_bindings: usize,
67 sporadic_states: usize,
68 #[cfg(feature = "alloc")]
69 sporadic_atomic_states: usize,
70 size: usize,
71 align: usize,
72}
73
74const fn align_up(off: usize, align: usize) -> usize {
75 off.div_ceil(align) * align
76}
77
78const fn compute_offsets(cbs: usize, sc: usize, arena: usize) -> FieldOffsets {
79 let mut off = 0usize;
80 let mut max_align = 1usize;
81
82 // arena: [MaybeUninit<u8>; arena] — align 1, at offset 0.
83 let arena_off = 0usize;
84 off += arena;
85
86 macro_rules! place {
87 ($n:expr, $ty:ty) => {{
88 let a = align_of::<$ty>();
89 if a > max_align {
90 max_align = a;
91 }
92 off = align_up(off, a);
93 let at = off;
94 off += $n * size_of::<$ty>();
95 at
96 }};
97 }
98
99 let entries = place!(cbs, Option<CallbackMeta>);
100 let sched_contexts = place!(sc, Option<SchedContext>);
101 let sched_context_bindings = place!(cbs, SchedContextId);
102 let sporadic_states = place!(sc, Option<SporadicState>);
103 #[cfg(feature = "alloc")]
104 let sporadic_atomic_states = place!(sc, Option<SporadicAtomic>);
105
106 let size = align_up(off, max_align);
107 FieldOffsets {
108 arena: arena_off,
109 entries,
110 sched_contexts,
111 sched_context_bindings,
112 sporadic_states,
113 #[cfg(feature = "alloc")]
114 sporadic_atomic_states,
115 size,
116 align: max_align,
117 }
118}
119
120/// Byte [`Layout`] of the backing needed for a `(cbs, sc, arena)`-sized executor.
121/// Public + non-generic so the macro / FFI can size a raw backing.
122pub const fn executor_storage_layout(cbs: usize, sc: usize, arena: usize) -> Layout {
123 let o = compute_offsets(cbs, sc, arena);
124 // SAFETY: `align` is a power of two (a `max` of `align_of` results) and `size`
125 // is rounded up to it; both are non-zero.
126 unsafe { Layout::from_size_align_unchecked(o.size, o.align) }
127}
128
129/// Number of `u64` words a backing must hold for a `(cbs, sc, arena)`-sized
130/// executor. `u64` backing is 8-aligned, which covers every field (all
131/// `align_of ≤ 8`; asserted in tests), so the entry never hand-aligns. The macro
132/// emits `[MaybeUninit<u64>; executor_storage_u64_len(N, SC, A)]`.
133pub const fn executor_storage_u64_len(cbs: usize, sc: usize, arena: usize) -> usize {
134 executor_storage_layout(cbs, sc, arena).size().div_ceil(8)
135}
136
137/// Per-entry executor sizing — the entity counts an [`Executor`](super::spin::Executor)
138/// is built to hold. **Public + non-generic** (the "C/C++ is a thin wrapper"
139/// principle): the entry / macro / FFI supplies these as plain `usize`s rather
140/// than as type/const generics C can't name. Used to size + carve the backing.
141///
142/// `cbs` is capped at 64 by the executor's `u64` ready-set bitmask (asserted in
143/// [`carve`]-time / `open_in`).
144#[derive(Clone, Copy)]
145pub struct ExecutorSizing {
146 /// Callback-table slots (`entries` + per-entry SC bindings). ≤ 64.
147 pub cbs: usize,
148 /// Scheduling-context slots (`sched_contexts` + sporadic state tables).
149 pub sc: usize,
150 /// Bump-allocator arena size in bytes.
151 pub arena: usize,
152}
153
154impl ExecutorSizing {
155 /// The build-time default (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE` consts) — the
156 /// backward-compatible size the `alloc` convenience constructors leak.
157 pub const DEFAULT: Self = Self {
158 cbs: crate::config::MAX_CBS,
159 sc: crate::config::MAX_SC,
160 arena: crate::config::ARENA_SIZE,
161 };
162
163 /// `u64` words a backing must hold for this sizing (see
164 /// [`executor_storage_u64_len`]).
165 pub const fn u64_len(&self) -> usize {
166 executor_storage_u64_len(self.cbs, self.sc, self.arena)
167 }
168}
169
170/// The exact `#[repr(C)]` byte layout the C/C++ FFI's inline executor buffer must
171/// hold: an [`Executor`](super::spin::Executor)`<'static>` header immediately
172/// followed by a default-sized ([`ExecutorSizing::DEFAULT`]) storage backing.
173///
174/// The FFI keeps the executor inline (heap-free — matching the Rust no-alloc
175/// requirement) and carves its per-entry tables from the SAME buffer's
176/// [`backing`](Self::backing) tail. Because that buffer is **pinned** — the C
177/// caller allocates it, it is initialised in place, and it is only ever reached
178/// through a stable `nros_executor_t*` (never moved after init) — the resulting
179/// self-borrow (the header's slices pointing into the same struct's tail) is
180/// sound. The FFI probes `size_of` of THIS type (not bare `Executor`) to size
181/// its `_opaque` array, and reinterprets `_opaque` as `*mut ExecutorInlineStorage`
182/// (the executor stays at offset 0, so existing offset-0 accessors are unchanged).
183#[repr(C)]
184pub struct ExecutorInlineStorage {
185 /// The executor, written in place (offset 0) by `from_session_ptr_in`.
186 pub exec: MaybeUninit<super::spin::Executor<'static>>,
187 /// The carved backing the executor's slices borrow (the buffer's tail).
188 pub backing: [MaybeUninit<u64>; ExecutorSizing::DEFAULT.u64_len()],
189}
190
191/// Carve an 8-aligned `u64` backing into the typed, initialised executor slices.
192///
193/// # Safety
194/// - `backing.len() * 8` must be ≥ `executor_storage_layout(cbs, sc, arena).size()`.
195/// - The returned slices alias `backing`; it must outlive them (the `'s` bound)
196/// and not be otherwise accessed while they live.
197///
198/// Element memory is initialised here (`entries`/SC tables → `None`, bindings →
199/// `SchedContextId(0)`), so the returned `&mut [T]` reference validly-init memory.
200pub(crate) unsafe fn carve<'s>(
201 backing: &'s mut [MaybeUninit<u64>],
202 cbs: usize,
203 sc: usize,
204 arena: usize,
205) -> ExecutorSlices<'s> {
206 let o = compute_offsets(cbs, sc, arena);
207 // Fail-loud on EVERY profile (not `debug_assert!`): embedded release builds
208 // strip debug-assertions, and a backing that is too small is silent memory
209 // corruption — the carved `entries`/`sched_contexts` tables run past the end
210 // of `backing` into whatever .bss follows (e.g. a C carrier's `__nros_c_inst`),
211 // leaving a NULL `drop_fn` that faults in `Executor::drop`. This is exactly
212 // how a STALE config-header mirror (C buffer sized from an out-of-date
213 // `NROS_*_STORAGE_SIZE`) manifested as a `jalr -> 0` on threadx-riscv64 (#131).
214 // Panic here instead, at open, with the two sizes named.
215 assert!(
216 backing.len() * 8 >= o.size,
217 "executor backing too small: {} bytes < {} required — the storage buffer \
218 (NROS_*_STORAGE_SIZE) disagrees with the executor layout; rebuild clean so \
219 the generated config header matches",
220 backing.len() * 8,
221 o.size
222 );
223 let base = backing.as_mut_ptr() as *mut u8;
224
225 unsafe {
226 // arena — no init needed (MaybeUninit).
227 let arena_s =
228 core::slice::from_raw_parts_mut(base.add(o.arena) as *mut MaybeUninit<u8>, arena);
229
230 let entries_p = base.add(o.entries) as *mut Option<CallbackMeta>;
231 let mut i = 0;
232 while i < cbs {
233 entries_p.add(i).write(None);
234 i += 1;
235 }
236 let entries_s = core::slice::from_raw_parts_mut(entries_p, cbs);
237
238 let sc_p = base.add(o.sched_contexts) as *mut Option<SchedContext>;
239 let mut i = 0;
240 while i < sc {
241 sc_p.add(i).write(None);
242 i += 1;
243 }
244 let sched_contexts_s = core::slice::from_raw_parts_mut(sc_p, sc);
245
246 let bind_p = base.add(o.sched_context_bindings) as *mut SchedContextId;
247 let mut i = 0;
248 while i < cbs {
249 bind_p.add(i).write(SchedContextId(0));
250 i += 1;
251 }
252 let bindings_s = core::slice::from_raw_parts_mut(bind_p, cbs);
253
254 let sp_p = base.add(o.sporadic_states) as *mut Option<SporadicState>;
255 let mut i = 0;
256 while i < sc {
257 sp_p.add(i).write(None);
258 i += 1;
259 }
260 let sporadic_s = core::slice::from_raw_parts_mut(sp_p, sc);
261
262 #[cfg(feature = "alloc")]
263 let atomic_s = {
264 let ap = base.add(o.sporadic_atomic_states) as *mut Option<SporadicAtomic>;
265 let mut i = 0;
266 while i < sc {
267 ap.add(i).write(None);
268 i += 1;
269 }
270 core::slice::from_raw_parts_mut(ap, sc)
271 };
272
273 ExecutorSlices {
274 arena: arena_s,
275 entries: entries_s,
276 sched_contexts: sched_contexts_s,
277 sched_context_bindings: bindings_s,
278 sporadic_states: sporadic_s,
279 #[cfg(feature = "alloc")]
280 sporadic_atomic_states: atomic_s,
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 const CBS: usize = crate::config::MAX_CBS;
290 const SC: usize = crate::config::MAX_SC;
291 const ARENA: usize = crate::config::ARENA_SIZE;
292
293 #[test]
294 fn layout_matches_typed_repr_c() {
295 // The manual const-fn layout must equal the compiler's `#[repr(C)]` layout
296 // of the typed storage — proof the carve offsets are the real field offsets.
297 let got = executor_storage_layout(CBS, SC, ARENA);
298 let want = Layout::new::<ExecutorStorage<CBS, SC, ARENA>>();
299 assert_eq!(got.size(), want.size(), "size");
300 assert_eq!(got.align(), want.align(), "align");
301 }
302
303 #[test]
304 fn u64_backing_covers_all_field_aligns() {
305 assert!(align_of::<Option<CallbackMeta>>() <= 8);
306 assert!(align_of::<Option<SchedContext>>() <= 8);
307 assert!(align_of::<SchedContextId>() <= 8);
308 assert!(align_of::<Option<SporadicState>>() <= 8);
309 assert!(executor_storage_layout(CBS, SC, ARENA).align() <= 8);
310 }
311
312 #[test]
313 fn carve_yields_right_lengths_and_inits() {
314 // Heap-allocate: the default test config (MAX_CBS/MAX_SC/ARENA_SIZE from
315 // build.rs) makes this backing array tens of KB, well past
316 // `clippy::large_stack_arrays`'s threshold — and the size here is
317 // incidental (mirrors production config), not the point under test, so
318 // boxing is the right fix rather than an allow.
319 let mut backing =
320 alloc::vec![const { MaybeUninit::<u64>::uninit() }; executor_storage_u64_len(CBS, SC, ARENA)]
321 .into_boxed_slice();
322 let s = unsafe { carve(&mut backing, CBS, SC, ARENA) };
323 assert_eq!(s.arena.len(), ARENA);
324 assert_eq!(s.entries.len(), CBS);
325 assert_eq!(s.sched_contexts.len(), SC);
326 assert_eq!(s.sched_context_bindings.len(), CBS);
327 assert_eq!(s.sporadic_states.len(), SC);
328 assert!(s.entries.iter().all(|e| e.is_none()));
329 assert!(s.sched_context_bindings.iter().all(|b| b.0 == 0));
330 }
331}