Skip to content

Repository files navigation

ph-eventing

Crates.io docs.rs CI License: MIT MSRV no_std

Deterministic zero-allocation handoff primitives for no-std embedded targets.

What's in the box

Type Use case
Block<T, N> / BlockBuilder<T, N> Build complete contiguous sample windows, then compose them with a transport.
RingBuf<T, N> Single-owner ring buffer — simple, no atomics, &mut access.
SeqRing<T, N> Lock-free SPSC ring that overwrites old entries (lossy, high-throughput).
EventBuf<T, N> Lock-free SPSC ring with backpressure — rejects pushes when full.
CountedSignal Saturating SPSC count for identical, payload-free events.
EventFlags Coalesced SPSC condition set — 32 payload-free conditions, one atomic hot-path operation.
LatestBuf<T> Freshness-first SPSC snapshot — retains one newest unread value.

All types are fixed-size, #![no_std], and zero-allocation. The buffers are generic over T: Copy; CountedSignal carries no payload and EventFlags carries an EventMask(u32).

What this optimises for

no_std and no-alloc are the entry fee. What this crate offers past that is behaviour you can predict and cost you can measure:

  • Predictability first. No unbounded loops, no hidden allocation, and no panic reachable from a hot path. For the concurrent types, no data loss that cannot be observed either — every drop is reported (SeqRing, exact while the consumer's resume cursor stays within one sequence span of the newest entry; see its section) or prevented (EventBuf), or explicitly coalesced by contract (EventFlags). RingBuf is the deliberate exception: it is a single-owner window that overwrites silently, with no drop counter and no backpressure. Reach for it when losing the oldest entry is the point, not when delivery matters.
  • Cost measured on every target, not one. scripts/codesize.sh (added alongside this release) reports the flash cost of each API shape across 11 targets and 4 ISA families, because a design that wins on Cortex-M4 can cost 40–60% more on Cortex-M0+ or ESP32-S2, where every atomic becomes an interrupt-disable critical section.
  • Guarantees pinned by tooling. Loom proves the orderings exhaustively at the modelled size, Miri checks UB on 32-bit and big-endian, and a code-size row keeps a cheap API from quietly becoming expensive.

Ergonomics is ranked last, deliberately. If an API here feels more awkward than an equivalent std type, that is usually a cost being made visible rather than hidden. Where the awkwardness is not load-bearing, the fix is compile-time tooling that costs nothing at runtime — not a friendlier API that allocates, panics, or hides a cost.

Features

  • Three ring buffer flavours plus a freshness-first SPSC snapshot channel.
  • Complete contiguous sample blocks with an explicit fill-side builder.
  • EventFlags for coalesced ISR-to-task condition notification.
  • Common Sink/Source/Link traits for writing generic event-processing code.
  • forward(src, snk, max) utility to bridge any SourceSink.
  • No heap, no dynamic dispatch, no required dependencies.
  • Optional portable-atomic support for targets without native 32-bit atomics.
  • Designed for #![no_std] environments (std only for tests).

Compatibility

  • MSRV: Rust 1.92.0.
  • SeqRing::new() and EventBuf::new() assert N > 0.
  • SeqRing, EventBuf, and EventFlags require 32-bit atomics by default.
  • LatestBuf also requires 32-bit atomics and stores exactly three payload slots.
  • For thumbv6m-none-eabi (and other no-atomic targets), enable one of:
    • portable-atomic-unsafe-assume-single-core
    • portable-atomic-critical-section (requires a critical-section implementation in the binary)
  • Those two are mutually exclusive — they select different portable-atomic backends, and enabling both fails inside portable-atomic. Cargo features are additive, so this cannot be expressed in the manifest; build.rs detects the combination and explains it.
  • Consequently --all-features does not work for this crate and cannot be made to. Check combinations individually; scripts/ci.sh enumerates the supported set.

Usage

Complete blocks

BlockBuilder<T, N> privately accumulates sequenced samples and yields a Block<T, N> only when all N contiguous samples are present. A gap is returned to the caller without changing the partial block, and clearing or dropping a partial builder publishes nothing. Timestamping is payload policy: use a timestamped type for T when required.

Block is deliberately not another queue. Compose it with the overload policy you need: EventBuf<Block<T, N>, Q> queues complete blocks and rejects the newest when full; LatestBuf<Block<T, N>> retains only the latest complete block.

Budget the composition before choosing it. Publication copies the complete block, so cost scales with block bytes (150–8,651 reference instructions across the measured 2/8/16-byte × N = 8/32/128 grid), a rejected push costs nearly as much as an accepted one (the complete block is preserved and returned, within 2–25 instructions), and RAM is multiple complete blocks — Q slots plus the private builder. Small windows can invert the economics (per-sample publication beats blocks at the 8/16-byte N = 8 corners), and DMA integrations currently cannot avoid the double copy in ISR context — the builder's storage is deliberately private, so either budget both copies or publish from task context. The block module docs carry the full measured disclosure.

use ph_eventing::{BlockBuilder, EventBuf};

let mut fill = BlockBuilder::<i16, 4>::new();
for (sequence, sample) in [(10, 1), (11, 2), (12, 3)] {
    assert!(fill.push(sequence, sample).expect("contiguous").is_none());
}
let block = fill.push(13, 4).expect("contiguous").expect("complete");

let queue = EventBuf::<_, 2>::new();
let producer = queue.try_producer().expect("no producer taken yet");
let consumer = queue.try_consumer().expect("no consumer taken yet");
// Backpressure is returned, never unwrapped: a full queue hands the
// complete block back through `Err` for the caller's policy.
assert!(producer.push(block).is_ok());
assert_eq!(consumer.pop().expect("one block queued").samples(), &[1, 2, 3, 4]);

RingBuf

A straightforward, single-owner ring buffer for collecting values when you don't need cross-thread access. When full, new pushes silently overwrite the oldest entry. Requires only T: Copy — no Default.

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));
assert_eq!(ring.get(0),  Some(1)); // oldest

// iterate oldest → newest
for val in ring.iter() {
    // 1, 2, 3
}

SeqRing

A lock-free SPSC ring for high-rate telemetry. The producer never blocks; the consumer reports drops when it lags behind by more than N.

use ph_eventing::SeqRing;

let ring = SeqRing::<u32, 64>::new();
let producer = ring.try_producer().expect("no producer taken yet");
let mut consumer = ring.try_consumer().expect("no consumer taken yet");

producer.push(123);
assert_eq!(consumer.poll_one_value(), Some((1, 123)));
// hook form still available:
// consumer.poll_one(|seq, v| { ... });

LatestBuf

A three-slot SPSC snapshot channel for state where freshness dominates FIFO delivery. Publishing never rejects; it reports whether an unread value was replaced. Taking returns the newest complete value with generation and skipped counts. Exact skipped counts are guaranteed within one non-zero u32 wrap span; beyond it the count under-counts, and a gap of exactly one or more whole cycles reports skipped = 0 — silence there is not evidence that nothing was lost. The boundary is a rate × take-interval property (~49.7 days between takes at 1 kHz publishing, ~72 minutes at 1 MHz); if the count itself is your requirement, carry a wider producer-assigned sequence in T, and if consumer liveness is, use a watchdog — the LatestItem::skipped docs carry the full disclosure. The consumer intentionally implements LatestSource, not Source, so gap evidence is not silently discarded. T may be one sample or a complete block. Empty polls use an Acquire load rather than an atomic RMW; pending polls transfer ownership with one AcqRel swap. The all-zero initial representation keeps a const-initialized channel in .bss with no payload-proportional flash or startup-copy cost.

use ph_eventing::LatestBuf;

let channel = LatestBuf::<u32>::new();
let producer = channel.try_producer().expect("producer");
let consumer = channel.try_consumer().expect("consumer");

let _ = producer.publish(10);
assert!(producer.publish(20).replaced_unread);
let item = consumer.take_latest().expect("latest");
assert_eq!((item.value, item.generation, item.skipped), (20, 2, 1));

EventBuf

A bounded SPSC queue with backpressure. When the buffer is full, push returns Err(val) so the producer can decide what to do — no data is silently lost.

use ph_eventing::EventBuf;

let buf = EventBuf::<u32, 2>::new();
let producer = buf.try_producer().expect("no producer taken yet");
let consumer = buf.try_consumer().expect("no consumer taken yet");

assert!(producer.push(1).is_ok());
assert!(producer.push(2).is_ok());
assert_eq!(producer.push(3), Err(3)); // full — value returned

assert_eq!(consumer.peek(), Some(1));  // copy, no advance
assert_eq!(consumer.pop(), Some(1));
assert!(producer.push(3).is_ok());     // space freed

CountedSignal

A saturating count for repeated events whose payload and ordering do not matter. The sole producer is load-bearing: it permits exact saturation with a fixed source-level sequence that treats observed u32::MAX as maybe-stale and confirms it through a no-op RMW re-read — an RMW observes the latest value in modification order, so there is no compare-exchange and no algorithmic retry; the contract discloses how each single RMW is realised per ISA.

use ph_eventing::CountedSignal;

let signal = CountedSignal::new();
let producer = signal.try_producer().expect("no producer taken yet");
let consumer = signal.try_consumer().expect("no consumer taken yet");

producer.increment();
producer.increment();
let snapshot = consumer.take_count();
assert_eq!(snapshot.count(), 2);
assert!(!snapshot.is_saturated());

EventFlags

A coalesced condition set for ISR-to-task notification. Repeated raises of one condition may merge; a take returns and clears every condition that occurred at least once since the preceding take.

use ph_eventing::{EventFlags, EventMask};

const DATA_READY: EventMask = EventMask::from_bits(1 << 0);
const OVERFLOW: EventMask = EventMask::from_bits(1 << 1);

let flags = EventFlags::new();
let producer = flags.try_producer().expect("no producer taken yet");
let consumer = flags.try_consumer().expect("no consumer taken yet");

producer.raise(DATA_READY);
producer.raise(DATA_READY); // coalesces
producer.raise(OVERFLOW);

assert_eq!(consumer.take_all(), DATA_READY | OVERFLOW);
assert!(consumer.take_all().is_empty());

EventFlags deliberately does not implement the stream traits below: a coalesced condition set is not a sequence of items, and destructive take plus a rejecting downstream sink could silently lose the mask.

Common Traits

The ring-buffer producers implement Sink<T> and their consumers implement Source<T>, so generic code works with any combination of the listed handles. Signal types such as CountedSignal are outside that stream vocabulary (no T payload), and EventFlags is condition signalling, not a payload stream — its handles deliberately implement neither (see its section above). LatestBuf deliberately stands outside it as well: its consumer implements LatestSource<T> (and its producer LatestSink<T>), because try_pop cannot report the displacement that is this channel's designed overload behaviour — a generic Source bound will not compile against it, by decision D2:

use ph_eventing::{SeqRing, EventBuf};
use ph_eventing::traits::{Source, Sink, forward};

// bridge a SeqRing producer → EventBuf consumer
let seq = SeqRing::<u32, 8>::new();
let sp = seq.try_producer().expect("no producer taken yet");
let mut sc = seq.try_consumer().expect("no consumer taken yet");

sp.push(1); sp.push(2);

let eb = EventBuf::<u32, 8>::new();
let mut ep = eb.try_producer().expect("no producer taken yet");

let (n, err) = forward(&mut sc, &mut ep, 10);
assert_eq!(n, 2);
assert!(err.is_none());
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 Sink<In> + Source<Out>
LatestSink<T> Publish latest latest_buf::Producer (reports replacement)
LatestSource<T> Take latest latest_buf::Consumer (reports generation + skipped)

Declarative static bring-up

static_spsc! names the handle types for you, so a signature does not have to spell out ph_eventing::event_buf::Producer<'static, u32, 64>:

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");

It expands to a static, two type aliases, and an all-or-nothing take() — no allocation, no indirection, and no instruction that would not be there written by hand. SeqRing works the same way. Note the handles are Send + !Sync by design, so they cannot themselves live in a static; taking them is a runtime step and always will be.

Semantics

RingBuf

  • Single-owner (&mut self to push).
  • get(i) returns the i-th element where 0 is the oldest.
  • latest() returns the most recently pushed element.
  • iter() yields elements oldest → newest.
  • new() is a const fn; N == 0 fails at compile time.

SeqRing

  • Sequence numbers are monotonically increasing u32 values; 0 is reserved for "empty".
  • When the producer wraps the ring, old values are overwritten.
  • poll_one and poll_up_to drain in-order and return PollStats (read, dropped, newest).
  • poll_one_value / latest_value return (seq, T) without a hook.
  • latest reads the newest value without advancing the consumer cursor.
  • skip_to_latest discards the backlog so the next poll returns the newest item.
  • If the consumer lags by more than N, it skips ahead and reports drops via PollStats.
  • Once every 2^32 - 1 pushes the sequence counter wraps and a few extra entries are dropped — exactly one for a power-of-two N, none if N divides 2^32 - 1, up to N - 1 otherwise. They are reported as ordinary drops; no stale or torn value is returned (within the span bound below). See Choosing N.
  • Sequence arithmetic is modular over that 2^32 - 1 span, and both headline guarantees carry its bound: a whole-span gap from the consumer's resume cursor aliases to "nothing new" and reports zero drops, and the torn-copy re-check shares the same counter-width ABA limit for a consumer stalled mid-read. Reachability arithmetic and the structural escape hatches are in the rustdoc ("Known limitation: whole-span sequence aliasing").

EventBuf

  • FIFO order: pop always returns the oldest item.
  • peek copies the oldest item without advancing the cursor.
  • push returns Ok(()) on success or Err(val) when the buffer is full.
  • drain(max, hook) consumes up to max items through a callback and returns the count.
  • No data is silently lost — the producer always knows when the buffer cannot accept more.

CountedSignal

  • Counts below u32::MAX are exact; the counter saturates rather than wrapping.
  • take_count atomically clears the counter and reports whether it saturated.
  • A concurrent increment belongs wholly to the current take or the next one.
  • The sole Send + !Sync producer handle is part of the correctness contract.
  • Count operations do not publish unrelated application memory; payload data needs a separate synchronization mechanism.
  • The reference Cortex-M3 probe measures increment at 8 retired instructions on the below-MAX hot path and 9 on the saturated sentinel arm, and take_count at 9 (rustc 1.92.0, QEMU 10.0.11, measured on the assembled 0.3.0 tree). The third arm — a stale MAX re-read below MAX after a take — is the saturated arm plus one fetch_add by construction; all rows are uncontended single-pass counts (the contract discloses the per-ISA RMW realisation).

EventFlags

  • raise(mask) unions conditions into the pending set; duplicate bits may coalesce.
  • take_all() atomically returns and clears every pending condition.
  • Conditions are unordered and carry no payload or multiplicity.
  • EventMask is exactly 32 bits; from_index rejects out-of-range indices without panicking.
  • A take that observes a raise also observes memory writes sequenced before it.
  • There is no non-clearing peek and no stream/signal trait implementation in the initial surface.

Safety and Concurrency

  • RingBuf has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as MaybeUninit<T> and reads only live entries, so it does contain unsafe.
  • SeqRing, EventBuf, EventFlags, CountedSignal, and LatestBuf are SPSC by design: exactly one producer and one consumer may be active. Use try_producer()/try_consumer(), which return None rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panicking producer()/consumer(), deprecated since 0.2.0, were removed in 0.3.0. Using unsafe to bypass SeqRing/EventBuf/LatestBuf ownership can be undefined behavior. Forging or concurrently sharing a CountedSignal producer breaks its bounded no-wrap contract; the handle is !Sync to prevent that in safe Rust. Forging a second LatestBuf producer or consumer similarly breaks the three-slot exclusive-ownership exchange.
  • T: Copy is required by all payload-carrying types to avoid allocation and return values by copy.
  • EventFlags has no unsafe slot access and passes Miri with the race detector enabled.
  • EventBuf is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled.
  • SeqRing is a seqlock and carries a known formal data race — the consumer may copy a slot the producer is overwriting, then discard the copy when the sequence re-check fails. A raced copy is discarded and never becomes an invalid value within the whole-span bound (the re-check compares u32 sequences, so a consumer stalled mid-read for a full 2^32 − 1 publications can pass both checks against a rewritten slot; see the SeqRing section above and the rustdoc). The access itself is undefined behaviour by the letter of the memory model.
    • This affects your tooling, not just ours: if you run cargo miri test over a test that drives SeqRing from two threads, Miri will report UB pointing into this crate. That is the known deviation, not a new bug.
    • It is a deliberate trade. A ring restricted to a word-sized payload could store it in an atomic and be fully race-free; accepting any T: Copy is what rules that out. Generality was chosen over formal soundness.
    • EventBuf has no such caveat and passes Miri with the detector on — but it is not a drop-in, since it applies backpressure instead of overwriting.
    • Full reasoning, including the alternatives and why each was rejected, is in the seq_ring module docs.

Using it across contexts

The typical embedded shape is a producer in an interrupt handler and a consumer in a task loop. That works, with three things to know:

  • The primitive is shared; the handles are owned. SeqRing<T, N>, EventBuf<T, N>, and LatestBuf<T> are Sync when T: Send, and EventFlags and CountedSignal are Sync, so the primitive can be handed to both contexts. Producer and Consumer are Send + !Sync — move each one into the context that owns it, and never share a single handle between contexts. There is no way to get a second Producer while one is live: try_producer / try_consumer return None rather than handing out a duplicate.
  • The buffer must outlive both handles. The handles borrow it, so the usual answer is to own the buffer where it lives longest.
  • new() is a const fn on the normal build, so static BUF: EventBuf<u32, 64> = EventBuf::new(); works. (Under --cfg loom it is non-const because Loom's atomics are not const-constructible.) Handles still borrow the buffer, so an ISR / task split typically pairs the static with a StaticCell or similar for the handles themselves.

Choosing N

N is the slot count, fixed at compile time, and the whole buffer lives inline — N * size_of::<T>() bytes of stack or static, with no allocation.

  • For EventBuf, N is your backpressure threshold: the point at which push starts returning Err. Size it for the largest burst you are willing to absorb between drains.

  • For SeqRing, N is how far the consumer may lag before it starts losing entries. Size it for the worst-case gap between polls, not for the average.

  • N need not be a power of two — no indexing or capacity logic requires it — but for SeqRing a power of two is still the better default. SeqRing addresses slots by (seq - 1) % N while push skips the reserved sequence 0, so a full cycle is 2^32 - 1 sequences and the slot walk only lines up across the wrap when N divides 2^32 - 1. What that costs, once per wrap:

    N Entries dropped at the wrap
    A power of two Exactly 1
    A divisor of 2^32 - 1 (3, 5, 15, 17, 51, 85, 255, 257, 65537, …) 0
    Anything else Up to N - 1N = 48 drops 15, N = 96 drops 33, N = 121 drops 58

    These are reported through PollStats like any other drop, and no stale or torn value is returned (within the whole-span bound stated in the SeqRing section and rustdoc) — it is a data-loss bound, not a correctness one. One lost entry per 2^32 pushes is beneath the noise floor for anything that already tolerates overwrite, so a power of two is almost always the right call. EventBuf has no wrap boundary of this kind.

Quality and verification

The concurrent primitives are atomic, so a green test run on x86 is weak evidence — a strongly-ordered host cannot exhibit the ordering bugs that appear on ARM and RISC-V. What backs this crate, in descending order of strength:

Evidence What it establishes
Loom models Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size
Miri UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets
103 unit + 13 doctests + 11 compile-fail Behaviour, including threaded stress tests for all SPSC types; N == 0 rejected at compile time on the three buffers and BlockBuilder; LatestBuf's absent Source impl and handle !Sync pinned (D2/H2); CountedSignal and EventFlags handle !Sync pinned
3 embedded targets thumbv6m / thumbv7em / riscv32imac compile checks
Code-size baseline Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails
QEMU instruction counts Hot-path cost is constant w.r.t. occupancy, measured per instruction

All of it is reproducible: ./scripts/verify.sh runs the full matrix inside one pinned Docker environment (scripts/verify/Dockerfile), so the numbers above can be checked rather than believed.

One known deviation. SeqRing is a seqlock and carries a formal data race — see Safety and Concurrency above. EventBuf is race-free by construction and passes Miri with the detector enabled.

Coverage is around 94% of lines, though it is a weak signal here: what matters is ordering and interleaving, which line coverage cannot see.

Contributors: CONTRIBUTING.md has the commands for running all of the above locally. CI runs on every PR, but it covers only part of that list — coverage, Miri, and Loom are local-only, so a green check is not a clean matrix.

License

MIT. See LICENSE.

About

Deterministic zero-allocation handoff primitives for no_std embedded Rust — ring buffers, latest-value snapshots, condition flags, saturating counters, and complete sample blocks. Bounded behaviour, measured cost, Loom-verified orderings.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages