Ring Buffers as Contracts: Why State Machines Matter More Than Buffer Size
When your camera produces data faster than your bus can consume it, buffer sizing alone won't save you. The real problem is state ownership, and the bars cutting through our images proved it.
The problem: when fast producers meet slow consumers
In multi-stage embedded pipelines, timing mismatches are inevitable. A camera sensor might capture a full frame in under 200ms, while offloading that data to flash storage takes nearly a second. During that second, what happens to the next frame? And the one after that?
The naive answer is "use a bigger buffer." The real answer is you need a contract.
At Hoomanely, our SoM-based imaging devices face this exact challenge. When an EverBowl captures thermal and visual data for pet behavior inference, that data flows through multiple stages: DMA capture, PSRAM staging, flash storage, and eventually CAN FD transmission to an edge gateway. Each stage runs at a different speed. The camera doesn't wait for flash. Flash doesn't wait for CAN. And yet the data somehow has to stay coherent.
The symptom we saw was subtle but devastating: garbage bars cutting through otherwise perfect images. Not corruption from a bad sensor, not noise from electrical interference. Buffer reuse while data was still in flight.
The camera was writing new frames into the same memory region the offload process was still reading from. A classic race condition. But why did a ring buffer, designed specifically to solve this problem, fail to prevent it?
Why it matters: contracts vs. capacity
Most engineers think of ring buffers as a capacity problem: how many buffers do I need to avoid blocking the producer? But capacity is only half the story. The real question is when it's safe to reuse a buffer.
Consider a simple imaging pipeline. DMA writes frame data into Buffer A over 200ms. Firmware copies Buffer A into PSRAM over 50ms. Firmware offloads PSRAM into Flash over 700ms. CAN transmits Flash to the gateway over some number of seconds.
If you release Buffer A after step 2, you've opened a 700ms window where the camera might overwrite data flash is still reading. If you wait until step 4, you've serialized the entire pipeline, no parallelism, terrible throughput.
The key insight: buffer release isn't a timing problem, it's a state ownership problem. Each stage needs to explicitly declare "I'm done with this data, the next stage can proceed."
That's why ring buffers in real systems need state machines, not just indices and sizes, but lifecycle contracts that make ownership transitions explicit and auditable.

Architecture: state as a contract
Our solution treats each buffer as having an explicit lifecycle enforced by a state machine: EMPTY, then ALLOCATED, then COPYING, then QUEUED, then RELEASED, back to EMPTY.
Each transition is a contract between stages. EMPTY to ALLOCATED means DMA owns the buffer and the camera can write. ALLOCATED to COPYING means DMA finished and firmware is copying to PSRAM. COPYING to QUEUED means the PSRAM copy is done and the offload process can read. QUEUED to RELEASED means the flash write completed and the buffer is safe to reuse. RELEASED to EMPTY means cleanup finished and the buffer returns to the pool.
The critical decision was when to transition QUEUED to RELEASED. Initially we released after the PSRAM copy in step 2. That created the race condition. The fix was releasing only after the flash write completed in step 3, which meant offload had to call back into the ring buffer when done:
// Offload completion handler
void on_flash_write_complete(uint32_t sequence_id) {
dma_ring_buffer_release(sequence_id); // NOW safe to reuse
}Why does this work? Because flash storage is the last synchronous consumer of the camera data. CAN transmission happens from flash storage, not from the ring buffer. Once data hits flash, the DMA buffer has served its purpose.
The key principle: release buffers when the fastest stage that needs them finishes, not when the slowest downstream consumer finishes.

Implementation: making states auditable
State machines are only useful if they're observable. When you're debugging a race condition, you need to know which buffer was in which state when the corruption happened.
We made state transitions logged and indexed:
typedef enum {
BUFFER_STATE_EMPTY = 0,
BUFFER_STATE_ALLOCATED,
BUFFER_STATE_COPYING,
BUFFER_STATE_QUEUED,
BUFFER_STATE_RELEASED
} buffer_state_t;
typedef struct {
buffer_state_t state;
uint32_t sequence_id; // Links to capture event
uint32_t allocated_tick; // When allocated
uint32_t released_tick; // When released
} dma_buffer_slot_t;Every state change logs which buffer changed state, what sequence ID it was serving, and when the transition occurred, in tick counts. That means when you see garbage bars, you can grep the logs for that sequence ID and reconstruct the entire buffer lifecycle. Did it get released too early? Was it ever marked COPYING? Was there a double allocation? Debuggability is the contract's audit trail.
Another important detail was ring size. We settled on 12 buffers, not because of some theoretical maximum calculated from producer and consumer rates, but because burst mode requires it. When the system captures several frames back to back, say a motion-triggered event, the camera might fill several buffers before the first offload completes. With only 3 or 4 buffers you'd stall the camera waiting for offload. With 12, you can queue an entire burst while background offload catches up. Ring sizing is about burst capacity, not just steady-state throughput.
Real-world usage: multi-device coordination
In a multi-device ecosystem like ours, where an EverBowl captures visual data, an EverHub aggregates it, and a Tracker provides motion context, buffer ownership gets even more complex.
Consider what happens when the EverBowl transmits image data over CAN FD to the EverHub. The EverBowl's ring buffer isn't just feeding local flash storage, it's also feeding a network transmitter with its own buffering and flow control. Does the EverBowl wait for CAN transmission to finish before releasing buffers? No, that would serialize local storage and network transmission and destroy throughput. Instead we use layered contracts.
The ring buffer contract releases when the flash write completes locally. The flash storage contract holds data until CAN confirms transmission over the network. The CAN transmission contract marks flash entries as "sent" after ACK, remotely. Each layer owns a different resource, DRAM, flash, network, with a different release condition. The ring buffer doesn't care about CAN. Flash storage doesn't care about DMA. Each contract stays isolated and composable.
This is how you build systems that scale, not by building one giant state machine that knows about every dependency, but by layering contracts that each solve one ownership problem cleanly.
There's also error handling to think about. What if the flash write fails? The ring buffer is already released and the data is lost, if you're not careful. Our fix is conditional release: if offload fails, mark the buffer QUEUED rather than RELEASED and retry, only transitioning to RELEASED on success. Buffer pressure builds during flash errors, eventually stalling the camera, but data integrity is preserved. Explicit backpressure beats silent data loss.
Takeaways: state machines as ownership contracts
If there's one thing to take from this, it's that ring buffers without state machines are just circular arrays that make race conditions harder to debug.
Release timing defines correctness, not just performance. Releasing too early causes corruption, releasing too late causes stalls, and the contract has to specify exactly when resources change ownership.
Make state transitions observable. Log every transition with timestamps and sequence IDs, because when you're debugging you need to reconstruct what happened, not guess.
Size for bursts, not just steady state. Your ring needs enough capacity to absorb temporary spikes in producer rate while slower consumers catch up.
Layer contracts, don't centralize them. Each stage, DMA, PSRAM, flash, CAN, should have its own ownership rules that you compose rather than merge into one monolithic state machine.
Explicit backpressure beats silent corruption. If downstream stages can't keep up, stall the producer. Don't drop data silently or overwrite in-flight buffers.
In embedded systems, contracts matter more than code. Your state machine is the contract. Your ring buffer is just the data structure that enforces it.