ISR-safe, DMA-friendly ring buffer for ARM Cortex-M — LDRH/STRH per field, policy-based IRQ protection, lock-free SPSC, compile-time unit tests.
Most Cortex-M ring buffers use word-sized (size_t / uint32_t) indices with std::atomic or plain volatile, and either require power-of-2 sizes or silently call __aeabi_uidivmod on wrap. This one takes a different set of micro-architectural decisions:
- Lock-free SPSC without
std::atomic— head and tail are packed as twouint16_tfields in a 4-byte-aligned struct. The producerSTRHat offset 0 and the consumerSTRHat offset 2 target different bus addresses — they cannot collide. No libatomic calls, no fences. - Single-LDR state snapshot — both indices read in one 32-bit bus transaction (
readHT()). Other libraries do two separate reads or rely on atomic word-sized indices. - Topology-selectable IRQ protection —
None/SPSC/MPSC/SPMC/MPMCchosen per-instance, with producer and consumer guards independent (MPSC pays no guard onpop, SPMC pays none onpush). - No software division on any path — power-of-2 sizes use
AND, non-power-of-2 use compare-and-subtract. No__aeabi_uidivmodever emitted. - Compile-time unit tests —
static_assertin the constructor runs a full test suite at instantiation. A broken build will not compile.
#include "RingBuffer_PackedState/RingBuffer_PackedState.h"
// Basic use — no IRQ protection (this is the default)
RingBuffer_PackedState<uint8_t, 32> rb;
rb.push(42);
uint8_t val;
if (rb.pop(val)) {
// val == 42
}
// ISR-safe — PRIMASK save/restore, multiple producers/consumers
RingBuffer_PackedState<uint8_t, 32, Topology::MPMC<>> rb_mpmc;
// Lock-free SPSC — e.g. ISR producer + main consumer, or vice versa, no IRQ masking needed
RingBuffer_PackedState<uint8_t, 32, Topology::SPSC<>> rb_spsc;
// Custom lock — e.g. FreeRTOS critical section
RingBuffer_PackedState<uint8_t, 32, Topology::MPMC<FreeRtosLock>> rb_rtos;- Per-field halfword access — head and tail are stored as adjacent
uint16_tfields in a 4-byte-aligned struct. All state reads use a single 32-bitLDRatomic snapshot (readHT()). All state writes use individualSTRHper field — no read-modify-write of the adjacent halfword. - Lock-free SPSC — with
Topology::SPSC<>, the producer writes head (STRH) and the consumer writes tail (STRH) without any IRQ masking. For example, an ISR pushes and main pops — or vice versa. The two stores target different halfword addresses and never collide. - Policy-based IRQ protection — choose
Topology::None,Topology::SPSC,Topology::MPSC,Topology::SPMC, orTopology::MPMCas a template argument. Zero overhead when protection is not needed. - Conditional
volatile—stateandbufferare automaticallyvolatilewhenneeds_volatileis true, ensuring the compiler always generates actual loads and stores across ISR boundaries. - No software division — power-of-2 sizes use
& (Size-1); non-power-of-2 sizes use compare-and-subtract. Both avoid__aeabi_uidivmodon Cortex-M0+. - DMA-friendly contiguous area API — zero-copy access via
get_contiguous_push_area/get_contiguous_pop_areafor direct DMA transfers. - Compile-time unit tests — a
static_assertin the constructor runs a full test suite at compile time. A broken instantiation will not compile. - Header-only — single
.hfile, no dependencies beyond the C++ standard library.
- C++20 or later
- ARM Cortex-M target (GCC
arm-none-eabi) or any C++20 compiler for host-side use __disable_irq(),__get_PRIMASK(), and__set_PRIMASK()available when using the built-inPrimaskLock(standard CMSIS). Custom protection schemes (RTOS, custom critical sections) can be used by supplying a custom lock — see IRQ Protection Policies.
// SPSC: volatile fields (real LDRH/STRH) but no IRQ masking.
// ISR owns head, main owns tail — their STRH writes never collide.
// The roles can be reversed: main as producer, ISR as consumer.
RingBuffer_PackedState<uint8_t, 32, Topology::SPSC<>> rb;
// In ISR (producer):
rb.push(byte_from_peripheral);
// In main (consumer):
uint8_t val;
if (rb.pop(val)) {
// process val
}Two variants are available depending on when the head/tail index should advance.
Note:
get_contiguous_push_areaandget_contiguous_pop_arearead state without a Guard. Only one producer and one consumer may use these functions at a time (SPSC — Single Producer, Single Consumer).
Commit variant — write/read first, then advance index:
RingBuffer_PackedState<uint8_t, 64, Topology::SPSC<>> rb;
// Get pointer and count for DMA write
auto area = rb.get_contiguous_push_area(32);
// Configure DMA: area.ptr, area.count
// ...wait for DMA complete...
rb.commit_push(area.count); // advance head after DMA completes
// Get pointer and count for DMA read
auto out = rb.get_contiguous_pop_area(32);
// Configure DMA: out.ptr, out.count
// Note: if data wraps the buffer boundary, call twice
// ...wait for DMA complete...
rb.commit_pop(out.count); // advance tail after DMA completesReserve variant — advance index immediately, write/read after:
auto area = rb.reserve_push(32); // head advances immediately
// Configure DMA: area.ptr, area.count
// ...wait for DMA complete...
// No commit needed — index already advanced
auto out = rb.reserve_pop(32); // tail advances immediately
// Configure DMA: out.ptr, out.count
// ...wait for DMA complete...
// No commit needed — index already advanced| Parameter | Default | Description |
|---|---|---|
T |
— | Element type |
Size |
— | Total buffer slots. Effective capacity is Size-1. Must be in range [2, 65535]. |
IrqPolicy |
Topology::None<> |
IRQ protection policy. See IRQ Protection Policies below. |
| Function | Description |
|---|---|
push(item) |
Push item. Returns false if full. Guard-protected. |
pop(item) |
Pop item. Returns false if empty. Guard-protected. |
peek(item) |
Read next item without advancing tail. Returns false if empty. No guard needed. |
peek(item, offset) |
Read item at offset from tail (0 = next, 1 = second oldest, …) without advancing tail. Returns false if offset >= getCount(). No guard needed. |
isFull() |
Returns true if buffer is full. |
isEmpty() |
Returns true if buffer is empty. |
getCount() |
Returns number of elements currently in buffer. |
getSpace() |
Returns number of free slots remaining. Producer counterpart to getCount(). |
skip(count) |
Discards up to count elements by advancing tail without reading. Clamped to available data. Alias for commit_pop() — use when intent is discard rather than DMA consume. |
push_n(ptr, count) |
Pushes up to count elements from ptr. Returns number actually pushed. Handles wrap automatically. Uses memcpy for trivially copyable types. ProducerGuard protected. |
pop_n(ptr, count) |
Pops up to count elements into ptr. Returns number actually popped. Handles wrap automatically. Uses memcpy for trivially copyable types. ConsumerGuard protected. |
clear() |
Resets buffer to empty. Always IRQ-protected regardless of topology. Do not call while a DMA transfer is active on this buffer — stop the DMA first. |
| Function | Description |
|---|---|
get_contiguous_push_area(max) |
Returns pointer + count of contiguous writable slots. Call commit_push() after writing. |
commit_push(count) |
Advances head by count after a contiguous push. Clamped to available space. |
get_contiguous_pop_area(max) |
Returns pointer + count of contiguous readable slots. Call commit_pop() after reading. |
commit_pop(count) |
Advances tail by count after a contiguous pop. Clamped to available space. |
reserve_push(max) |
Advances head immediately, returns pointer + count. Write after the call — no commit needed. |
reserve_pop(max) |
Advances tail immediately, returns pointer + count. Read after the call — no commit needed. |
All functions use
readHT()for their initial state read — a single 32-bitLDRatomic snapshot of head and tail, safe across ISR boundaries. Writes back tostate.headorstate.tailremain individualSTRHinstructions.
Note on
peekinSPMC/MPMC:peekdoes not acquire aConsumerGuard. Another consumer may pop the element betweenpeek's state snapshot and the buffer read, returning logically-stale data (or, for multi-byteT, a torn read if the producer wraps and overwrites the slot). For atomic peek-then-act semantics, usepopinto a local variable instead.
All built-in topologies are nested types inside Topology, and all use PrimaskLock by default. Pass a custom lock implementation as a template argument to the chosen topology to swap it out.
| Policy | needs_volatile |
lock_p_needed |
lock_c_needed |
Use case |
|---|---|---|---|---|
Topology::None |
false |
false |
false |
Single context — no concurrency |
Topology::SPSC |
true |
false |
false |
Lock-free SPSC — e.g. ISR owns head, main owns tail (or vice versa) |
Topology::MPSC |
true |
true |
false |
Multiple producers, single consumer — push guarded, pop free |
Topology::SPMC |
true |
false |
true |
Single producer, multiple consumers — pop guarded, push free |
Topology::MPMC |
true |
true |
true |
Multiple producers and consumers — both sides guarded |
lock_p_needed activates a ProducerGuard (PRIMASK save/restore) around push, commit_push, and reserve_push. lock_c_needed does the same for pop, commit_pop, and reserve_pop. This means MPSC pays no guard overhead on the consumer side, and SPMC pays none on the producer side.
Custom lock implementation — define a struct with lock() / unlock(uint32_t) and pass it directly to the chosen topology. Example using FreeRTOS:
struct FreeRtosLock {
static uint32_t lock() { taskENTER_CRITICAL(); return 0; }
static void unlock(uint32_t) { taskEXIT_CRITICAL(); }
};
RingBuffer_PackedState<uint8_t, 32, Topology::MPMC<FreeRtosLock>> rb;push/pop: ~12 instructions, no stack spills, no out-of-line callsTopology::SPSCoverhead: none — no IRQ masking,volatilefields ensure actual LDRH/STRHTopology::MPSC: guard onpushonly (~7–9 cycles);popis freeTopology::SPMC: guard onpoponly (~7–9 cycles);pushis freeTopology::MPMCoverhead:MRS+CPSID+MSRon bothpushandpop(~7–9 extra cycles each)- Power-of-2 size: index wrapping uses a single
ANDinstruction (~2 cycles) - Non-power-of-2 size: index wrapping uses compare-and-subtract (~4 cycles) — no software division
Why adjacent uint16_t fields instead of a packed uint32_t?
Storing head and tail as separate uint16_t fields in a 4-byte-aligned struct means each field is written with a single STRH instruction that touches only its own halfword. On Cortex-M0+, STRH to SRAM is not a read-modify-write — the bus writes exactly the addressed 16-bit location and leaves the adjacent halfword untouched.
This makes SPSC lock-free: the ISR writes state.head (STRH at offset 0) while main writes state.tail (STRH at offset 2) — or vice versa. The two stores target different bus addresses and cannot interfere, so no IRQ masking is needed.
An earlier version packed head and tail into a single uint32_t. The problem: even though push() only changes head and pop() only changes tail, both had to do a full 32-bit read-modify-write (LDR + STR) to preserve the other field — which made lock-free SPSC impossible (a concurrent STR from the ISR could overwrite the stale field read by main).
The current design gets the best of both: writes use individual STRH per field (no RMW, enabling SPSC), while reads use readHT() — a single 32-bit LDR via reinterpret_cast that atomically snapshots both fields in one bus transaction.
Why readHT() for all state reads?
All functions that inspect head and tail — push, pop, peek, isEmpty, isFull, getCount, and the contiguous area functions — call readHT(), which does a single 32-bit LDR from the 4-byte-aligned state struct. This gives a consistent snapshot of both fields from the same moment in time, safe across ISR boundaries. Without it, two separate LDRH reads could be split by an ISR and yield a head/tail pair that never existed simultaneously.
At compile time (constexpr unit tests), std::is_constant_evaluated() switches readHT() to two separate field reads, since reinterpret_cast is not permitted in constant expressions.
Why Topology::SPSC<> instead of Topology::None<> for SPSC?
Topology::SPSC<> sets needs_volatile = true, which makes state.head and state.tail volatile. Without volatile, the compiler is free to cache a field value in a register and never re-read it — ISR writes to state.head would be invisible to main (or vice versa). volatile forces an actual LDRH on every access, at no runtime cost beyond the instruction itself. The Guard is still a no-op, so there is no IRQ masking overhead.
Why waste one slot?
Distinguishing full from empty without a separate count variable. If head == tail the buffer is empty; if nextIndex(head) == tail it is full. Simple, branchless, and correct.
Why compile-time unit tests?
A static_assert in the constructor runs unit_test_ringbuffer::run_test() at compile time against a Size=4 test instance. If any test fails, the translation unit will not compile — no separate test binary required.
Copyright (c) 2026 Erik Nørskov / PxQ Technologies — https://pxq.dk
Dual-licensed: GPLv3 + commercial.
Open Source — GNU General Public License v3.0 (GPLv3): Free to use, modify, and distribute under the terms of the GPLv3. Note that GPLv3 is strong copyleft — derivative works and products that incorporate this software must also be released under GPLv3 (i.e. open-sourced). See LICENSE or https://www.gnu.org/licenses/gpl-3.0.html for full terms.
Commercial License: For use in proprietary or closed-source products (e.g. firmware that will not be open-sourced), a commercial license is available — either as a separate written agreement, or via direct delivery by Erik Nørskov (PxQ Technologies) as part of a paid engagement, in which case the license is granted for that specific project scope only. Each commercial license covers only the version actually delivered by the licensor; later versions require a new engagement or a separate paid license — see LICENSE for the full terms.
Contact: https://pxq.dk
If you find this library useful, please consider giving it a ⭐ on GitHub — it helps others discover it.