Embedded C++ Tutorial — Circular Buffers
In the embedded world, a specific problem appears repeatedly: a data source continuously generates data, a consumer processes it slowly, and we want to avoid malloc in between. Thus, an ancient but timeless data structure enters the stage—the Circular Buffer (also known as a Ring Buffer).
You can think of it as a warehouse with a fixed size; when it is full, we start over from the beginning. No resizing, no fragmentation, and no "new failed" errors, making it perfect for MCUs, drivers, interrupts, DMA, serial ports, audio streams, and other scenarios.
Why Does the Embedded World Love Circular Buffers?
In the PC world, we can freely use new or std::vector::push_back. However, in embedded systems, these operations sound quite dangerous:
- Heap memory is small and prone to fragmentation.
- We cannot use
mallocwithin an interrupt context. - We do not want unpredictable latency in real-time systems.
The characteristics of a circular buffer are almost tailor-made for embedded systems:
- Fixed size, determined at compile time or initialization.
- O(1) enqueue / dequeue.
- Contiguous memory, cache-friendly.
- No dynamic allocation required.
- Simple implementation, easy to make lock-free / interrupt-safe.
To summarize in one sentence:
It isn't smart, but it is reliable.
The Core Idea of a Circular Buffer (Actually Very Simple)
A circular buffer is essentially:
- A fixed-size array.
- Two indices:
head: The write position.tail: The read position.
When an index reaches the end of the array, it wraps around to the beginning, just like a circle.
[ 0 ][ 1 ][ 2 ][ 3 ][ 4 ][ 5 ]
↑ ↑
tail head2
3
4
Writing data: move head Reading data: move tail
There is only one core problem we need to solve clearly: 👉 How do we distinguish between "full" and "empty"?
How to distinguish between "empty" and "full"? (The Classic Problem)
There are three common approaches:
- Waste one element (most common)
- Maintain an extra
count - Use an extra
fullflag bit
In embedded systems, Approach 1 is the most popular: it is simple, unambiguous, and logically clear. The rules are:
- Buffer size is
N - Can actually store at most
N - 1elements - Condition checks:
- Empty:
head == tail - Full:
(head + 1) % N == tail
- Empty:
Yes, we sacrifice one slot to gain a lifetime of peace of mind.
A Clean C++ Circular Buffer Implementation
Below is a no dynamic memory, templated, embedded-friendly implementation.
Basic Interface Design
Expand (22 lines)Collapse
#pragma once
#include <cstddef>
#include <array>
template<typename T, std::size_t Capacity>
class RingBuffer {
public:
bool push(const T& value);
bool pop(T& out);
bool empty() const;
bool full() const;
std::size_t size() const;
std::size_t capacity() const { return Capacity - 1; }
private:
std::array<T, Capacity> buffer_{};
std::size_t head_ = 0;
std::size_t tail_ = 0;
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Pay attention to a detail: 👉 Capacity actual array size = user-available capacity + 1
Enqueue (push): step forward
template<typename T, std::size_t Capacity>
bool RingBuffer<T, Capacity>::push(const T& value)
{
if (full()) {
return false; // 缓冲区满了
}
buffer_[head_] = value;
head_ = (head_ + 1) % Capacity;
return true;
}2
3
4
5
6
7
8
9
10
11
There is no magic here:
- First, check if the buffer is full.
- Write the data.
- Move the
head. - If it reaches the end, wrap around to the beginning.
O(1), it will never be slow.
Dequeue (pop): The Consumer Takes the Stage
template<typename T, std::size_t Capacity>
bool RingBuffer<T, Capacity>::pop(T& out)
{
if (empty()) {
return false; // 没数据
}
out = buffer_[tail_];
tail_ = (tail_ + 1) % Capacity;
return true;
}2
3
4
5
6
7
8
9
10
11
Just as simple:
- Fail if empty
- Read data
- Move
tail
Status Check Functions
Expand (21 lines)Collapse
template<typename T, std::size_t Capacity>
bool RingBuffer<T, Capacity>::empty() const
{
return head_ == tail_;
}
template<typename T, std::size_t Capacity>
bool RingBuffer<T, Capacity>::full() const
{
return (head_ + 1) % Capacity == tail_;
}
template<typename T, std::size_t Capacity>
std::size_t RingBuffer<T, Capacity>::size() const
{
if (head_ >= tail_) {
return head_ - tail_;
}
return Capacity - (tail_ - head_);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The size() approach is common in embedded systems. It avoids complex branching logic and does not require an additional counter.
A Real-World Embedded Use Case
UART Reception (ISR + Main Loop)
RingBuffer<uint8_t, 128> rx_buffer;
void USART_IRQHandler()
{
uint8_t data = UART_Read();
rx_buffer.push(data); // 中断里只做这件事
}
int main()
{
while (1) {
uint8_t ch;
if (rx_buffer.pop(ch)) {
process_char(ch);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
This approach offers several advantages that are highly specific to embedded systems:
- The logic within the ISR is extremely short.
- It does not use
malloc. - The main loop processes data at its own pace.
- Even if processing is slow, it will not block interrupts.
A Reality Check on Thread Safety / Interrupt Safety
The implementation above features:
- Single producer + single consumer
- One runs in the interrupt, the other in the main loop
On many MCUs, this is naturally safe (as long as index reads and writes are atomic).
However, if you encounter one of the following scenarios:
- Multithreading
- Multiple producers
- SMP (Symmetric Multi-Processing)
- Inter-task communication in an RTOS
Then you will need:
- Interrupt disabling
- Atomic variables
- Or mutex / spinlock
Comparison with std::queue and std::vector
| Approach | Dynamic Allocation | Deterministic | Embedded Friendly |
|---|---|---|---|
| std::vector | Yes | No | ❌ |
| std::queue | Depends on underlying container | No | ❌ |
| Circular Buffer | No | Yes | ✅ |