Expand description
Deterministic handoff primitives for no-std embedded targets.
§Primitives
| Type | When to reach for it |
|---|---|
Block / BlockBuilder | Complete contiguous sample windows; compose with a transport. |
RingBuf | Single-owner ring — simple, no atomics, &mut access. |
SeqRing | Lock-free SPSC ring that overwrites old entries (lossy, high-throughput). |
EventBuf | Lock-free SPSC ring with backpressure — rejects pushes when full. |
CountedSignal | Saturating SPSC count for identical, payload-free events. |
EventFlags | Coalesced SPSC condition set — one bit per condition, one atomic operation per hot path. |
LatestBuf | Freshness-first SPSC snapshot — retains one newest unread value. |
All are fixed-size and zero-allocation. The buffer types are generic
over T: Copy; CountedSignal carries no payload and EventFlags
provides exactly 32 payload-free conditions.
§Common traits
| Trait | Role | Implementors |
|---|---|---|
Sink<T> | Accept events | RingBuf, seq_ring::Producer, event_buf::Producer |
Source<T> | Yield events | seq_ring::Consumer, event_buf::Consumer |
Link<In,Out> | Both | Blanket impl for any Sink<In> + Source<Out> |
LatestSink<T> | Publish a newest value | latest_buf::Producer |
LatestSource<T> | Take the newest value with loss evidence | latest_buf::Consumer |
forward bridges the stream pair only; LatestBuf
deliberately stands outside it (decision D2), and the signal types
implement neither family.
The traits::forward function transfers items from any Source to any
Sink, making it easy to bridge different buffer types.
§Static bring-up
static_spsc! declares a static buffer together with
named handle types, so a signature need not spell out
event_buf::Producer<'static, T, N>:
ph_eventing::static_spsc! {
pub mod telemetry: EventBuf<u32, 64>;
}
fn on_sample(tx: &telemetry::Tx, v: u32) { let _ = tx.push(v); }
let (tx, rx) = telemetry::take().expect("first take");
on_sample(&tx, 1);
assert_eq!(rx.pop(), Some(1));§Quick start — RingBuf
use ph_eventing::RingBuf;
let mut ring = RingBuf::<u32, 4>::new();
ring.push(1);
ring.push(2);
ring.push(3);
assert_eq!(ring.latest(), Some(3));§Quick start — SeqRing
use ph_eventing::SeqRing;
let ring = SeqRing::<u32, 64>::new();
let producer = ring.try_producer().expect("producer");
let mut consumer = ring.try_consumer().expect("consumer");
producer.push(42);
// Assert the delivery flag, not just the hook body: an empty ring would
// skip the hook and the assertions inside it would pass vacuously.
let delivered = consumer.poll_one(|seq, v| {
assert_eq!(seq, 1);
assert_eq!(*v, 42);
});
assert!(delivered);§Quick start — EventBuf
use ph_eventing::EventBuf;
let buf = EventBuf::<u32, 4>::new();
let producer = buf.try_producer().expect("producer");
let consumer = buf.try_consumer().expect("consumer");
assert!(producer.push(1).is_ok());
assert!(producer.push(2).is_ok());
assert_eq!(consumer.pop(), Some(1));§Quick start — forward
use ph_eventing::{SeqRing, EventBuf};
use ph_eventing::traits::{Source, Sink, forward};
let seq = SeqRing::<u32, 8>::new();
let sp = seq.try_producer().expect("producer");
let mut sc = seq.try_consumer().expect("consumer");
sp.push(1); sp.push(2);
let eb = EventBuf::<u32, 8>::new();
let mut ep = eb.try_producer().expect("producer");
let (n, err) = forward(&mut sc, &mut ep, 10);
assert_eq!(n, 2);
assert!(err.is_none());§No-std
The crate is #![no_std] by default. Tests require std.
§Targets without atomics
Every concurrent primitive — SeqRing, EventBuf, EventFlags,
CountedSignal, and LatestBuf — requires 32-bit atomics. For targets that lack them
(for example thumbv6m-none-eabi), enable
portable-atomic-unsafe-assume-single-core or portable-atomic-critical-section.
The crate always compiles those modules, so no-atomic targets need one of
those features even when only RingBuf is used. RingBuf itself uses no
atomics.
§Safety and concurrency
-
RingBufhas no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots asMaybeUninit<T>and reads only live entries, so it does containunsafe. -
SeqRing,EventBuf, andEventFlagsare SPSC by design: exactly one producer and one consumer must be active. Handle acquisition istry_producer()/try_consumer(), which returnNonerather than panicking — on a microcontroller a panic is a reset. (The panickingproducer()/consumer(), deprecated since 0.2.0, were removed in 0.3.0.) Using unsafe to bypass these constraints is undefined behavior.The examples here use
.expect(...)for brevity, which is a panic. That is fine in a doctest on a host; in firmware, branch on theNone:
let Some(tx) = buf.try_producer() else {
return; // already claimed -- report it, do not reset the device
};EventBufis race-free by construction — its producer and consumer never touch the same slot — and passes Miri with the data-race detector enabled.SeqRingis a seqlock and carries a known formal data race. A raced copy is discarded and never becomes an invalid value — within the whole-span bound: the discard comparesu32sequences, so a consumer stalled mid-read for a full2^32 - 1publications can pass both checks against a rewritten slot (theseq_ring“whole-span sequence aliasing” section carries the reachability arithmetic and escape hatches). The access itself is undefined behaviour by the letter of the memory model. Practical consequence: running Miri over a test that drives this ring from two threads reports UB inside this crate — that is the deviation, not a new bug. It is a deliberate trade of formal soundness for accepting anyT: Copy; theseq_ringmodule docs give the alternatives and why each was rejected.EventBufhas no such caveat, but applies backpressure rather than overwriting, so it is not a drop-in replacement.
§Using it across contexts
The typical embedded shape is a producer in an interrupt handler and a consumer in a task loop.
SeqRingandEventBufareSyncwhenT: Send, andEventFlagsisSync, so a shared reference can be handed to both contexts. TheProducerandConsumerhandles areSend + !Sync: move each into the context that owns it, never share one.- The handles borrow the buffer, so the buffer must outlive them.
new()is aconst fnon the normal build, sostatic BUF: EventBuf<u32, 64> = EventBuf::new();works. (Under--cfg loomit is non-const because Loom’s atomics are not const-constructible.) Handles still borrow the buffer, so an ISR / task split typically pairs thestaticwith aStaticCellor similar for the handles themselves.
N is fixed at compile time and the buffer lives inline —
N * size_of::<T>() bytes, no allocation. For EventBuf it is the
backpressure threshold; for SeqRing it is how far the consumer may lag
before entries are lost. It need not be a power of two.
§SeqRing semantics
- Sequence numbers are monotonically increasing
u32values;0is reserved for “empty”. poll_one/poll_up_todrain in-order and returnPollStats;poll_one_valuereturns(seq, T)without a hook.latest/latest_valueread the newest value without advancing the consumer cursor.- If the consumer lags by more than
N, it skips ahead and reports drops viaPollStats. - Once every
2^32 - 1pushes the sequence counter wraps, and a few extra entries are dropped there becausepushskips the reserved sequence0: exactly one for a power-of-twoN, none ifNdivides2^32 - 1, up toN - 1otherwise. Reported as ordinary drops, never a stale or torn value within the whole-span bound stated in theseq_ringmodule docs. Prefer a power of two forN. Consumer::droppedsaturates rather than wrapping;usizeis 32 bits on the targets this crate ships to, so a long-lived lagging consumer can reach the top of the range.
§EventBuf semantics
pushreturnsErr(val)when the buffer is full — no data is silently lost.popreturns the oldest item, orNonewhen empty.peekcopies the oldest item without advancing the consumer cursor.drain(max, hook)consumes up tomaxitems through a callback.
§EventFlags semantics
event_flags::Producer::raiseunions a mask into the pending set.event_flags::Consumer::take_allatomically returns and clears that set.- Duplicate raises may coalesce; ordering and multiplicity are not retained.
- A take that observes a raise also observes memory actions sequenced before it.
Re-exports§
pub use block::Block;pub use block::BlockBuilder;pub use block::FillError;pub use counted_signal::CountSnapshot;pub use counted_signal::CountedSignal;pub use event_buf::EventBuf;pub use event_flags::EventFlags;pub use event_flags::EventMask;pub use latest_buf::LatestBuf;pub use latest_buf::LatestItem;pub use latest_buf::PublishReport;pub use ring::RingBuf;pub use seq_ring::PollStats;pub use seq_ring::SeqRing;pub use traits::LatestSink;pub use traits::LatestSource;pub use traits::Link;pub use traits::Sink;pub use traits::Source;
Modules§
- block
- Complete, contiguous sample blocks and a fill-side builder.
- counted_
signal - A saturating count for payload-free events.
- event_
buf - Bounded SPSC event buffer with backpressure — no heap, no alloc.
- event_
flags - Coalesced condition notification for an ISR-to-task handoff.
- latest_
buf - Freshness-first SPSC snapshot channel.
- ring
- Fixed-size, stack-allocated ring buffer — no heap, no alloc, no atomics.
- seq_
ring - Lock-free SPSC overwrite ring for high-rate telemetry in no-std contexts.
- traits
- Common traits for event producers and consumers.
Macros§
- static_
spsc - Declare a
staticSPSC buffer with its handle types and a paired take.