ph-eventing
Stack-allocated ring buffers for no-std embedded targets.
What's in the box
| Type | Use case |
|---|---|
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. |
All three are fixed-size, #![no_std], zero-allocation, and generic over T: Copy.
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 two SPSC types, no data loss that
cannot be observed either — every drop is reported (
SeqRing) or prevented (EventBuf).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: single-owner, lossy SPSC, and backpressure SPSC.
- 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.SeqRingandEventBufrequire 32-bit atomics by default.- 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
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| { ... });
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
Common Traits
All producers implement Sink<T> and all consumers implement Source<T>,
so you can write generic code that works with any combination:
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> |
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 ever returned. See ChoosingN.
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.
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.SeqRingandEventBufare 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()are deprecated since 0.2.0 and will be removed in 0.3.0. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is undefined behavior.T: Copyis required by all types to avoid allocation and return values by copy.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. The copy is never returned and never becomes an invalid value, but the access 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 buffer is shared; the handles are owned.
SeqRing<T, N>andEventBuf<T, N>areSyncwhenT: Send, so&bufcan 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:producer()panics rather than handing out a duplicate;try_producer/try_consumerreturnNoneinstead. - 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 ever returned — 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
SeqRing and EventBuf are lock-free, 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 |
| 69 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; N == 0 rejected at compile time |
| 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.