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:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user