Just as high-speed edge gateways can utilize shared array buffers for
zero-copy message passing, a dual-core microcontroller (like the RP2040) can
use a statically allocated memory block to pass IMU vectors and motor intents
without mutex locking.
On a dual-core MCU, the most efficient approach is the Single-Producer /
Single-Consumer (SPSC) Ring Buffer. Because one core only writes and the other
core only reads, you can completely eliminate hardware spinlocks or RTOS
mutexes, provided you respect memory ordering semantics.
1. Memory Architecture and Layout
The RP2040 has 264KB of SRAM interleaved across multiple banks. By assigning
the shared struct to a dedicated memory section, both cores can access it
continuously.
Unlike heavier ARM Cortex-A processors, the Cortex-M0+ on the RP2040 does not
have a data cache. This means you do not need to worry about cache
invalidation or manual cache line flushing. What you write to RAM is
immediately visible to the other core, subject only to instruction reordering.
2. The SPSC Struct Design
The core of the lock-free architecture relies on decoupling the head (write
index) and the tail (read index).
* Core 0 (Producer - IMU/Comms): Exclusively controls the head.
* Core 1 (Consumer - PID Loop): Exclusively controls the tail.
#include <stdatomic.h>
#include <stdint.h>
#include <stdbool.h>
#define BUFFER_SIZE 256 // Must be a power of 2 for fast modulo math
typedef struct {
float roll;
float pitch;
float yaw;
uint32_t timestamp;
} ImuState;
typedef struct {
ImuState data[BUFFER_SIZE];
atomic_size_t head;
atomic_size_t tail;
} LockFreeRingBuffer;
/ Statically allocate the buffer in RAM
LockFreeRingBuffer imu_buffer = {0};
3. Enforcing Memory Barriers
The only risk in an SPSC queue is the compiler or CPU reordering instructions.
If Core 0 updates the head index before the IMU payload has finished writing
to RAM, Core 1 will read garbage data.
We solve this using C11 atomics and explicit memory ordering constraints
(Acquire/Release semantics), similar to how JavaScript engines handle
SharedArrayBuffer atomics under the hood.
The Producer Write (Core 0)
Core 0 writes the payload, enforces a memory barrier (Release), and then
advances the head.
bool buffer_push(LockFreeRingBuffer* rb, const ImuState* state) {
// Relaxed load because only Core 0 modifies head
size_t current_head = atomic_load_explicit(&rb->head, memory_order_relaxed)
;
size_t next_head = (current_head + 1) & (BUFFER_SIZE - 1);
// Acquire load of tail to ensure we read the latest state from Core 1
if (next_head == atomic_load_explicit(&rb->tail, memory_order_acquire)) {
return false; // Buffer is full (drop or handle backpressure)
}
// Write the payload to the array
rb->data[current_head] = *state;
// Release store ensures the payload write completes BEFORE the head
updates
atomic_store_explicit(&rb->head, next_head, memory_order_release);
return true;
}
The Consumer Read (Core 1)
Core 1 reads the current head, reads the payload, and then advances the tail.
bool buffer_pop(LockFreeRingBuffer* rb, ImuState* out_state) {
// Relaxed load because only Core 1 modifies tail
size_t current_tail = atomic_load_explicit(&rb->tail, memory_order_relaxed)
;
// Acquire load of head ensures we don't read past what Core 0 has
committed
if (current_tail == atomic_load_explicit(&rb->head, memory_order_acquire))
{
return false; // Buffer is empty
}
// Read the payload from the array
*out_state = rb->data[current_tail];
// Release store ensures the payload read completes BEFORE the tail
updates
size_t next_tail = (current_tail + 1) & (BUFFER_SIZE - 1);
atomic_store_explicit(&rb->tail, next_tail, memory_order_release);
return true;
}
Because this approach avoids spinlocks entirely, the execution time for a read
or write is deterministic (a few clock cycles). Core 1 can safely poll
buffer_pop() inside a tight 1000Hz motor-control loop without ever stalling
Core 0's communication with the overarching Android system.