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.
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.
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.producer;
let mut consumer = ring.consumer;
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.producer;
let consumer = buf.consumer;
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.producer;
let mut sc = seq.consumer;
sp.push; sp.push;
let eb = new;
let mut ep = eb.producer;
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> |
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.
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
RingBufis a plain struct with no interior mutability — standard Rust borrow rules apply.SeqRingandEventBufare SPSC by design: exactly one producer and one consumer may be active.producer()/consumer()will panic if called while another handle of the same kind is active. Using unsafe to bypass these constraints (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 not aconst fn, so you cannot writestatic BUF: EventBuf<u32, 64> = EventBuf::new();directly. Use aStaticCell, aOnceCell, or a binding inmainthat outlives the tasks borrowing it. This is the one ergonomic wrinkle on embedded targets and it is worth knowing before you design around it.
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 |
| 58 unit + 7 doctests | Behaviour, including threaded stress tests for both SPSC types |
| 3 embedded targets | thumbv6m / thumbv7em / riscv32imac compile checks |
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 93% 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.