Why a ring buffer

A ring buffer (circular buffer) is the standard tool for passing data between a producer and a consumer when:

  • The producer and consumer run at different rates
  • You want no dynamic allocation
  • You need ISR → task communication without a mutex

It’s the hardware primitive in disguise: UARTs, SPI DMAs, and audio codecs all use ring buffers internally. Your firmware should too.


Requirements for an embedded ring buffer

  • No heap — fixed capacity, known at compile time
  • No blockingpush and pop return immediately
  • ISR-safe — safe when one side is a producer ISR and the other a consumer task on a single-core MCU
  • Power-of-two capacity — enables fast modulo via bitwise AND

Implementation

 1// ring_buffer.h
 2#pragma once
 3#include <array>
 4#include <cstddef>
 5#include <optional>
 6
 7template <typename T, size_t Capacity>
 8class RingBuffer {
 9    static_assert((Capacity & (Capacity - 1)) == 0,
10                  "Capacity must be a power of two");
11    static constexpr size_t MASK = Capacity - 1;
12
13public:
14    // Push an item. Returns false if full.
15    bool push(const T& item) {
16        const size_t w = write_;
17        const size_t next = (w + 1) & MASK;
18        if (next == read_) return false;   // full
19        buf_[w] = item;
20        write_ = next;
21        return true;
22    }
23
24    // Pop an item. Returns empty optional if empty.
25    std::optional<T> pop() {
26        const size_t r = read_;
27        if (r == write_) return std::nullopt;  // empty
28        T item = buf_[r];
29        read_ = (r + 1) & MASK;
30        return item;
31    }
32
33    bool empty() const { return read_ == write_; }
34    bool full()  const { return ((write_ + 1) & MASK) == read_; }
35
36    size_t size() const {
37        return (write_ - read_) & MASK;
38    }
39
40    size_t capacity() const { return Capacity - 1; }  // one slot reserved as sentinel
41
42private:
43    std::array<T, Capacity> buf_{};
44    volatile size_t write_ = 0;
45    volatile size_t read_  = 0;
46};

Key design choices

volatile on indices — prevents the compiler from caching the read or write index in a register. On a single-core Cortex-M where one side is an ISR, volatile is sufficient. On multi-core or with optimising compilers that reorder stores, use std::atomic<size_t> with memory_order_relaxed instead (see below).

One slot sentinel — the buffer holds Capacity - 1 items, not Capacity. The “full” condition is (write + 1) % Capacity == read. This avoids the ambiguity between full and empty when read == write. Allocate Capacity one larger than you need: RingBuffer<uint8_t, 32> holds 31 bytes.

Power-of-two(index + 1) & MASK replaces (index + 1) % Capacity. On Cortex-M0 (no hardware divider), division is a software call — tens of cycles. The bitmask is one instruction.


Usage

 1static RingBuffer<uint8_t, 64> uartRxBuf;  // holds 63 bytes
 2
 3// ISR — UART receive
 4void USART1_IRQHandler() {
 5    uint8_t byte = USART1->DR & 0xFF;
 6    uartRxBuf.push(byte);  // returns false if full — byte dropped
 7}
 8
 9// Task / main loop
10void processUart() {
11    while (auto byte = uartRxBuf.pop()) {
12        handle(*byte);
13    }
14}
 1// With structs
 2struct SensorSample { uint32_t tick; float value; };
 3static RingBuffer<SensorSample, 16> sampleBuf;
 4
 5// ISR:
 6sampleBuf.push({ HAL_GetTick(), adcToVoltage(ADC1->DR) });
 7
 8// Task:
 9while (auto s = sampleBuf.pop()) {
10    filter.update(s->value);
11}

Atomic variant for multi-core / strict ordering

Replace volatile with std::atomic for correct behaviour under compiler reordering (always correct; volatile alone is sufficient only on single-core targets where you trust the compiler won’t move loads/stores across a volatile access).

 1#include <atomic>
 2
 3template <typename T, size_t Capacity>
 4class AtomicRingBuffer {
 5    static_assert((Capacity & (Capacity - 1)) == 0, "");
 6    static constexpr size_t MASK = Capacity - 1;
 7
 8public:
 9    bool push(const T& item) {
10        const size_t w = write_.load(std::memory_order_relaxed);
11        const size_t next = (w + 1) & MASK;
12        if (next == read_.load(std::memory_order_acquire)) return false;
13        buf_[w] = item;
14        write_.store(next, std::memory_order_release);
15        return true;
16    }
17
18    std::optional<T> pop() {
19        const size_t r = read_.load(std::memory_order_relaxed);
20        if (r == write_.load(std::memory_order_acquire)) return std::nullopt;
21        T item = buf_[r];
22        read_.store((r + 1) & MASK, std::memory_order_release);
23        return item;
24    }
25
26    bool empty() const {
27        return read_.load(std::memory_order_acquire)
28            == write_.load(std::memory_order_acquire);
29    }
30
31private:
32    std::array<T, Capacity> buf_{};
33    std::atomic<size_t> write_{0};
34    std::atomic<size_t> read_ {0};
35};

This is an SPSC (single-producer, single-consumer) queue — safe when exactly one thread writes and one thread reads. For multiple producers, see lock-free queues.


Complete drop-in header

 1// ring_buffer.h — single header, no dependencies beyond <array> and <optional>
 2#pragma once
 3#include <array>
 4#include <atomic>
 5#include <cstddef>
 6#include <optional>
 7
 8template <typename T, size_t Cap>
 9class RingBuffer {
10    static_assert(Cap && !(Cap & (Cap-1)), "Cap must be power of two");
11    static constexpr size_t M = Cap - 1;
12public:
13    bool push(const T& v) {
14        size_t w = w_.load(std::memory_order_relaxed);
15        size_t n = (w + 1) & M;
16        if (n == r_.load(std::memory_order_acquire)) return false;
17        b_[w] = v;
18        w_.store(n, std::memory_order_release);
19        return true;
20    }
21    std::optional<T> pop() {
22        size_t r = r_.load(std::memory_order_relaxed);
23        if (r == w_.load(std::memory_order_acquire)) return {};
24        T v = b_[r];
25        r_.store((r + 1) & M, std::memory_order_release);
26        return v;
27    }
28    bool empty() const { return r_.load(std::memory_order_acquire) == w_.load(std::memory_order_acquire); }
29    bool full()  const { size_t w = w_.load(std::memory_order_relaxed); return ((w+1)&M) == r_.load(std::memory_order_acquire); }
30private:
31    std::array<T, Cap> b_{};
32    std::atomic<size_t> w_{0}, r_{0};
33};

Quick reference

Parameter Recommendation
Capacity Power of two; one more than max items needed
ISR ↔ task, single-core volatile indices or std::atomic relaxed
ISR ↔ task, multi-core / strict std::atomic acquire/release
Multiple producers Use MPSC queue instead
Item type Trivially copyable — no constructors in ISR context