feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
//! Permanently allocated copy-in/copy-out storage for DMA backends.
//!
//! This module manages allocation and exclusive CPU access, not hardware.
//! Backends must prove quiescence before calling the unsafe completion method.
use core::{
cell::{Cell, UnsafeCell},
marker::PhantomData,
sync::atomic::{AtomicBool, Ordering, compiler_fence},
};
use crate::{CompletionError, SpiCompletion};
/// One bounded TX/RX pair. Only a static allocation can be claimed, once ever.
pub struct Storage<const N: usize> {
claimed: AtomicBool,
transmit: UnsafeCell<[u8; N]>,
receive: UnsafeCell<[u8; N]>,
}
// SAFETY: The atomic claim creates at most one non-cloneable owner. All access
// to bytes is through that owner with &mut self, and its state excludes CPU
// access during DMA. Neither Drop nor any other helper resets the claim.
unsafe impl<const N: usize> Sync for Storage<N> {}
impl<const N: usize> Default for Storage<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> Storage<N> {
/// Creates unclaimed storage. Place this in DMA-accessible static memory.
pub const fn new() -> Self {
Self {
claimed: AtomicBool::new(false),
transmit: UnsafeCell::new([0; N]),
receive: UnsafeCell::new([0; N]),
}
}
/// Claims the allocation permanently. Dropping/forgetting the owner leaks
/// its claim deliberately: DMA may still hold the static addresses.
pub fn claim(&'static self) -> Option<Transfer<N>> {
self.claimed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.ok()?;
Some(Transfer {
storage: self,
state: State::Idle,
exclusive: PhantomData,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum State {
Idle,
Active(usize),
Quarantined,
}
/// Exclusive, movable owner of a permanent allocation. It is Send: moving it
/// moves only a shared reference to the one-time-claimed static, never bytes.
/// It is not Sync; CPU buffer operations require exclusive ownership.
pub struct Transfer<const N: usize> {
storage: &'static Storage<N>,
state: State,
exclusive: PhantomData<Cell<()>>,
}
/// Rejection before any copy-in or hardware submission.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartError {
InvalidLength,
Busy,
Quarantined,
}
/// Static endpoints, valid even after the owner is moved, dropped or forgotten.
/// Access through these raw pointers remains the backend's unsafe obligation.
pub struct Endpoints {
pub transmit: *const u8,
pub receive: *mut u8,
pub length: usize,
}
impl<const N: usize> Transfer<N> {
/// Checks admission without touching storage or changing ownership.
pub fn check_start(&self, length: usize) -> Result<(), StartError> {
match self.state {
State::Quarantined => Err(StartError::Quarantined),
State::Active(_) => Err(StartError::Busy),
State::Idle if length == 0 || length > N => Err(StartError::InvalidLength),
State::Idle => Ok(()),
}
}
/// Copies in and reserves both slots before returning hardware endpoints.
/// The backend must apply its device/cache handoff barrier before enabling DMA.
pub fn begin(&mut self, transmit: &[u8]) -> Result<Endpoints, StartError> {
self.check_start(transmit.len())?;
// SAFETY: Idle slots cannot be accessed by DMA: only unsafe completion
// can return them to Idle. The one-time owner excludes other CPU access.
unsafe {
(&mut *self.storage.transmit.get())[..transmit.len()].copy_from_slice(transmit);
}
self.state = State::Active(transmit.len());
compiler_fence(Ordering::Release);
Ok(Endpoints {
transmit: self.storage.transmit.get().cast::<u8>(),
receive: self.storage.receive.get().cast::<u8>(),
length: transmit.len(),
})
}
/// Checks completion admission without changing the active transfer.
pub fn check_completion(&self, completion: SpiCompletion) -> Result<(), CompletionError> {
if completion == SpiCompletion::NotPending {
return Err(CompletionError::NotPending);
}
match self.state {
State::Idle => Err(CompletionError::NotActive),
State::Active(_) => Ok(()),
State::Quarantined => Err(CompletionError::Quarantined),
}
}
/// Permanently prevents any subsequent CPU access or transfer admission.
pub fn quarantine(&mut self) {
self.state = State::Quarantined;
}
/// Releases quiescent slots, copying out only a successful full transfer.
/// Errors never modify `receive`; an invalid destination discards the result.
///
/// # Safety
/// Both DMA streams must be observed disabled and the SPI bus finished.
/// No hardware may subsequently access these endpoints until a new `begin`.
/// Perform the platform's device/cache completion barrier before this call.
/// `Complete` additionally requires proof that all submitted bytes arrived
/// without a transfer fault. Notifications alone are insufficient evidence.
pub unsafe fn finish(
&mut self,
completion: SpiCompletion,
receive: &mut [u8],
) -> Result<(), CompletionError> {
self.check_completion(completion)?;
let State::Active(length) = self.state else {
unreachable!();
};
compiler_fence(Ordering::Acquire);
self.state = State::Idle;
if completion == SpiCompletion::TransferFault {
return Err(CompletionError::TransferFault);
}
if receive.len() != length {
return Err(CompletionError::InvalidLength);
}
// SAFETY: The caller proved DMA quiescence and initialized the full
// response. The allocation remains exclusively claimed by this owner.
unsafe {
receive.copy_from_slice(&(&*self.storage.receive.get())[..length]);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
#[test]
fn capacity_above_u16() {
const CAPACITY: usize = u16::MAX as usize + 1;
static STORAGE: Storage<CAPACITY> = Storage::new();
let owner = STORAGE.claim().unwrap();
assert_eq!(owner.check_start(CAPACITY), Ok(()));
assert_eq!(
owner.check_start(CAPACITY + 1),
Err(StartError::InvalidLength)
);
}
#[test]
fn concurrent_claims_create_exactly_one_usable_owner() {
static STORAGE: Storage<4> = Storage::new();
let start = std::sync::Barrier::new(4);
let mut owner = std::thread::scope(|scope| {
let contenders: [_; 4] = core::array::from_fn(|_| {
scope.spawn(|| {
start.wait();
STORAGE.claim()
})
});
let mut winner = None;
for contender in contenders {
if let Some(owner) = contender.join().unwrap() {
assert!(winner.replace(owner).is_none());
}
}
winner.expect("exactly one contender claims the static allocation")
});
assert!(STORAGE.claim().is_none());
let endpoints = owner.begin(&[1, 2, 3, 4]).unwrap();
let mut receive = [0; 4];
// SAFETY: All contenders have joined. These accesses stand in for DMA,
// initialize the full response, and end before accepting completion.
unsafe {
assert_eq!(
core::slice::from_raw_parts(endpoints.transmit, 4),
&[1, 2, 3, 4]
);
endpoints
.receive
.copy_from_nonoverlapping([5, 6, 7, 8].as_ptr(), 4);
owner.finish(SpiCompletion::Complete, &mut receive).unwrap();
}
assert_eq!(receive, [5, 6, 7, 8]);
}
#[test]
fn copy_in_out_survives_caller_mutation_and_owner_move() {
static STORAGE: Storage<4> = Storage::new();
fn assert_send<T: Send>() {}
assert_send::<Transfer<4>>();
let mut owner = STORAGE.claim().unwrap();
let mut caller = [1, 2, 3, 4];
let endpoints = owner.begin(&caller).unwrap();
caller.fill(9);
let mut moved = owner;
let mut result = [7; 4];
assert_eq!(moved.check_start(4), Err(StartError::Busy));
assert_eq!(
moved.check_completion(SpiCompletion::NotPending),
Err(CompletionError::NotPending)
);
assert_eq!(result, [7; 4]);
// SAFETY: No hardware is involved; simulate DMA using the reserved
// endpoints, then stop accessing them before accepting completion.
unsafe {
assert_eq!(
moved.finish(SpiCompletion::NotPending, &mut result),
Err(CompletionError::NotPending)
);
assert_eq!(moved.check_start(4), Err(StartError::Busy));
assert_eq!(
core::slice::from_raw_parts(endpoints.transmit, 4),
&[1, 2, 3, 4]
);
endpoints
.receive
.copy_from_nonoverlapping([5, 6, 7, 8].as_ptr(), 4);
moved.finish(SpiCompletion::Complete, &mut result).unwrap();
}
assert_eq!(result, [5, 6, 7, 8]);
assert_eq!(moved.check_start(4), Ok(()));
}
#[test]
fn invalid_lengths_and_faults_never_copy_stale_bytes() {
static STORAGE: Storage<4> = Storage::new();
let mut owner = STORAGE.claim().unwrap();
assert!(matches!(owner.begin(&[]), Err(StartError::InvalidLength)));
assert!(matches!(
owner.begin(&[0; 5]),
Err(StartError::InvalidLength)
));
let mut receive = [9; 5];
let endpoints = owner.begin(&[1; 4]).unwrap();
// SAFETY: Simulated DMA initializes all bytes and is then quiescent.
unsafe {
endpoints.receive.write_bytes(2, 4);
assert_eq!(
owner.finish(SpiCompletion::Complete, &mut receive),
Err(CompletionError::InvalidLength)
);
}
assert_eq!(receive, [9; 5]);
owner.begin(&[3; 4]).unwrap();
// SAFETY: No hardware was started; cancellation cannot race any access.
unsafe {
assert_eq!(
owner.finish(SpiCompletion::TransferFault, &mut receive[..4]),
Err(CompletionError::TransferFault)
);
}
assert_eq!(receive, [9; 5]);
assert_eq!(
owner.check_completion(SpiCompletion::Complete),
Err(CompletionError::NotActive)
);
}
#[test]
fn quarantine_drop_and_forget_never_reclaim_storage() {
static QUARANTINED: Storage<4> = Storage::new();
static DROPPED: Storage<4> = Storage::new();
static FORGOTTEN: Storage<4> = Storage::new();
let mut owner = QUARANTINED.claim().unwrap();
owner.begin(&[1]).unwrap();
owner.quarantine();
assert_eq!(owner.check_start(1), Err(StartError::Quarantined));
assert_eq!(
owner.check_completion(SpiCompletion::Complete),
Err(CompletionError::Quarantined)
);
assert!(QUARANTINED.claim().is_none());
let endpoints = {
let mut owner = DROPPED.claim().unwrap();
owner.begin(&[3]).unwrap()
};
assert!(DROPPED.claim().is_none());
// SAFETY: Dropping the handle did not free or reuse the static bytes.
unsafe { assert_eq!(*endpoints.transmit, 3) };
let mut owner = FORGOTTEN.claim().unwrap();
let endpoints = owner.begin(&[4]).unwrap();
// Explicitly exercise forget as well as drop, including if a destructor
// is ever added: correctness must not depend on it running.
#[allow(clippy::forget_non_drop)]
core::mem::forget(owner);
assert!(FORGOTTEN.claim().is_none());
// SAFETY: Forgetting the handle also leaves the allocation reserved.
unsafe { assert_eq!(*endpoints.transmit, 4) };
}
}
+57 -20
View File
@@ -1,11 +1,13 @@
//! Non-blocking, chip-select-scoped SPI transfer contract.
//!
//! A selected device owns electrical chip-select behavior. A driver retains its
//! transfer storage until a terminal completion event; implementations submit
//! DMA or interrupt-driven work and never wait for a peripheral flag.
//! A selected device owns electrical chip-select behavior and asynchronous
//! transfer storage. Submission copies in; successful completion copies out.
//! No caller borrow or caller address may survive either method call.
#![no_std]
pub mod dma;
/// Terminal outcome reported by a selected SPI device.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SpiCompletion {
@@ -17,28 +19,63 @@ pub enum SpiCompletion {
TransferFault,
}
/// Failure to accept and copy out a completed transfer. No receive bytes are
/// changed on error.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CompletionError {
/// No terminal event: leave the active transfer entirely untouched.
NotPending,
/// There is no submitted transfer to complete.
NotActive,
/// The destination length differs from the submitted length.
InvalidLength,
/// The transfer failed; no receive bytes are usable.
TransferFault,
/// Quiescence could not be proven. Storage and route are permanently held.
Quarantined,
}
/// Owns one chip-select-scoped, non-blocking SPI route.
///
/// `start_transfer` begins a complete transfer and returns without waiting for
/// a peripheral status flag. The caller owns both buffers until the board
/// reports a terminal `SpiCompletion`; `complete_transfer` releases the route
/// after that terminal outcome.
/// Moving, dropping, or forgetting the device or caller buffers during a
/// transfer must be memory-safe. Hardware accesses only implementation-owned
/// storage whose allocation outlives hardware activity, including cancellation.
pub trait SpiDevice {
/// Failure to submit a transfer.
type StartError;
/// Starts one complete full-duplex transfer.
///
/// Implementations assert chip select before submitting and deassert it on
/// every terminal completion or submission failure. `receive` receives one
/// octet for each `transmit` octet; the two ranges must have equal length.
fn start_transfer(
&mut self,
transmit: &[u8],
receive: &mut [u8],
) -> Result<(), Self::StartError>;
/// Identifies a temporary admission rejection. No transfer was accepted.
/// Drivers may retry the same pending operation within their deadline.
fn is_busy(_error: &Self::StartError) -> bool {
false
}
/// Releases transport ownership after a hardware ISR acknowledged a
/// terminal completion. This must not poll or wait.
fn complete_transfer(&mut self, completion: SpiCompletion);
/// Copies `transmit` into bounded route-owned storage and starts one
/// full-duplex transfer. Empty/oversized submissions must fail safely.
///
/// No pointer into `transmit` is retained. Chip select is asserted before
/// submission; rejecting a concurrent submission must not disturb it.
fn start_transfer(&mut self, transmit: &[u8]) -> Result<(), Self::StartError>;
/// On `Complete`, proves hardware quiescence before copying exactly the
/// submitted number of bytes to an equally sized `receive` and releasing
/// chip select. An interrupt notification alone is not proof of quiescence.
///
/// `NotPending` returns that error without aborting or changing anything.
/// Faults cancel the transfer without copying out. Any bounded hardware
/// shutdown that cannot prove quiescence must quarantine the route and its
/// storage permanently, preventing reuse. No destination pointer is retained.
/// Passing `TransferFault` also requests cancellation without an IRQ. It must
/// perform the same bounded shutdown and return `TransferFault` only after
/// quiescence, or `Quarantined` if that cannot be proved. No bytes are copied.
///
/// Providers must validate current hardware progress, not trust a notification
/// from a previous transfer. Before reuse, drain old hardware flags/data. A
/// delayed notification may fault a new incomplete transfer but must never
/// publish old receive storage as a new result.
fn complete_transfer(
&mut self,
completion: SpiCompletion,
receive: &mut [u8],
) -> Result<(), CompletionError>;
}