ph-eventing
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).RingBufis 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.
EventFlagsfor coalesced ISR-to-task condition notification.- Common
Sink/Source/Linktraits for writing generic event-processing code. forward(src, snk, max)utility to bridge anySource→Sink.- No heap, no dynamic dispatch, no required dependencies.
- Optional
portable-atomicsupport for targets without native 32-bit atomics. - Designed for
#![no_std]environments (std only for tests).
Compatibility
- MSRV: Rust 1.92.0.
SeqRing::new()andEventBuf::new()assertN > 0.SeqRing,EventBuf, andEventFlagsrequire 32-bit atomics by default.LatestBufalso 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-coreportable-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.rsdetects the combination and explains it. - Consequently
--all-featuresdoes not work for this crate and cannot be made to. Check combinations individually;scripts/ci.shenumerates 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 ;
let mut fill = new;
for in
let block = fill.push.expect.expect;
let queue = new;
let producer = queue.try_producer.expect;
let consumer = queue.try_consumer.expect;
// Backpressure is returned, never unwrapped: a full queue hands the
// complete block back through `Err` for the caller's policy.
assert!;
assert_eq!;
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 RingBuf;
let mut ring = new;
ring.push;
ring.push;
ring.push;
assert_eq!;
assert_eq!; // oldest
// iterate oldest → newest
for val in ring.iter
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 SeqRing;
let ring = new;
let producer = ring.try_producer.expect;
let mut consumer = ring.try_consumer.expect;
producer.push;
assert_eq!;
// 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 LatestBuf;
let channel = new;
let producer = channel.try_producer.expect;
let consumer = channel.try_consumer.expect;
let _ = producer.publish;
assert!;
let item = consumer.take_latest.expect;
assert_eq!;
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 EventBuf;
let buf = new;
let producer = buf.try_producer.expect;
let consumer = buf.try_consumer.expect;
assert!;
assert!;
assert_eq!; // full — value returned
assert_eq!; // copy, no advance
assert_eq!;
assert!; // 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 CountedSignal;
let signal = new;
let producer = signal.try_producer.expect;
let consumer = signal.try_consumer.expect;
producer.increment;
producer.increment;
let snapshot = consumer.take_count;
assert_eq!;
assert!;
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 ;
const DATA_READY: EventMask = from_bits;
const OVERFLOW: EventMask = from_bits;
let flags = new;
let producer = flags.try_producer.expect;
let consumer = flags.try_consumer.expect;
producer.raise;
producer.raise; // coalesces
producer.raise;
assert_eq!;
assert!;
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 ;
use ;
// bridge a SeqRing producer → EventBuf consumer
let seq = new;
let sp = seq.try_producer.expect;
let mut sc = seq.try_consumer.expect;
sp.push; sp.push;
let eb = new;
let mut ep = eb.try_producer.expect;
let = forward;
assert_eq!;
assert!;
| 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>:
static_spsc!
let = take.expect;
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 selfto push). get(i)returns thei-th element where0is the oldest.latest()returns the most recently pushed element.iter()yields elements oldest → newest.new()is aconst fn;N == 0fails at compile time.
SeqRing
- Sequence numbers are monotonically increasing
u32values;0is reserved for "empty". - When the producer wraps the ring, old values are overwritten.
poll_oneandpoll_up_todrain in-order and returnPollStats(read,dropped,newest).poll_one_value/latest_valuereturn(seq, T)without a hook.latestreads the newest value without advancing the consumer cursor.skip_to_latestdiscards the backlog so the next poll returns the newest item.- 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 — exactly one for a power-of-twoN, none ifNdivides2^32 - 1, up toN - 1otherwise. They are reported as ordinary drops; no stale or torn value is returned (within the span bound below). See ChoosingN. - Sequence arithmetic is modular over that
2^32 - 1span, 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:
popalways returns the oldest item. peekcopies the oldest item without advancing the cursor.pushreturnsOk(())on success orErr(val)when the buffer is full.drain(max, hook)consumes up tomaxitems 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::MAXare exact; the counter saturates rather than wrapping. take_countatomically clears the counter and reports whether it saturated.- A concurrent increment belongs wholly to the current take or the next one.
- The sole
Send + !Syncproducer 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
incrementat 8 retired instructions on the below-MAXhot path and 9 on the saturated sentinel arm, andtake_countat 9 (rustc 1.92.0, QEMU 10.0.11, measured on the assembled 0.3.0 tree). The third arm — a staleMAXre-read belowMAXafter a take — is the saturated arm plus onefetch_addby 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.
EventMaskis exactly 32 bits;from_indexrejects 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
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,EventFlags,CountedSignal, andLatestBufare SPSC by design: exactly one producer and one consumer may be active. Usetry_producer()/try_consumer(), which returnNonerather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panickingproducer()/consumer(), deprecated since 0.2.0, were removed in 0.3.0. Using unsafe to bypassSeqRing/EventBuf/LatestBufownership can be undefined behavior. Forging or concurrently sharing aCountedSignalproducer breaks its bounded no-wrap contract; the handle is!Syncto prevent that in safe Rust. Forging a secondLatestBufproducer or consumer similarly breaks the three-slot exclusive-ownership exchange.T: Copyis required by all payload-carrying types to avoid allocation and return values by copy.EventFlagshas no unsafe slot access and passes Miri with the race detector enabled.EventBufis race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled.SeqRingis 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 comparesu32sequences, so a consumer stalled mid-read for a full2^32 − 1publications 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 testover a test that drivesSeqRingfrom 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: Copyis what rules that out. Generality was chosen over formal soundness. EventBufhas 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_ringmodule docs.
- This affects your tooling, not just ours: if you run
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>, andLatestBuf<T>areSyncwhenT: Send, andEventFlagsandCountedSignalareSync, so the primitive can be handed to both contexts.ProducerandConsumerareSend + !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 secondProducerwhile one is live:try_producer/try_consumerreturnNonerather 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 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.
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,Nis your backpressure threshold: the point at whichpushstarts returningErr. Size it for the largest burst you are willing to absorb between drains. -
For
SeqRing,Nis how far the consumer may lag before it starts losing entries. Size it for the worst-case gap between polls, not for the average. -
Nneed not be a power of two — no indexing or capacity logic requires it — but forSeqRinga power of two is still the better default.SeqRingaddresses slots by(seq - 1) % Nwhilepushskips the reserved sequence0, so a full cycle is2^32 - 1sequences and the slot walk only lines up across the wrap whenNdivides2^32 - 1. What that costs, once per wrap:NEntries 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 - 1—N = 48drops 15,N = 96drops 33,N = 121drops 58These are reported through
PollStatslike 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 per2^32pushes is beneath the noise floor for anything that already tolerates overwrite, so a power of two is almost always the right call.EventBufhas 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.