Dual-Core Architecture

Developer guide to the RP2040/RP2350 dual-core design in Bus Pirate firmware: core responsibilities, inter-core communication via lock-free SPSC queues, and memory barrier conventions.


Core Responsibilities

CoreResponsibilities
Core 0UI/command processing, protocol engine, mode handlers, syntax pipeline
Core 1LCD/display updates, USB service, TinyUSB stack

Core 0 runs the main command loop — parsing user input, compiling syntax bytecode, and dispatching protocol operations through the mode function-pointer table. Core 1 services USB interrupts via TinyUSB and drives the LCD refresh loop.


SPSC Queue

Lock-free single-producer single-consumer ring buffer for inter-core communication, defined in src/spsc_queue.h.

typedef struct {
    volatile uint32_t head;      // Write position (producer only)
    volatile uint32_t tail;      // Read position (consumer only)
    uint8_t* buffer;             // Pointer to data buffer
    uint32_t capacity;           // Total buffer size (must be power of 2)
    uint32_t mask;               // capacity - 1, for fast modulo
} spsc_queue_t;
FieldPurpose
headNext write position (modified only by producer)
tailNext read position (modified only by consumer)
bufferCaller-provided data buffer
capacityBuffer size (MUST be power of 2)
maskcapacity - 1 for bitmask modulo

Invariants:

  • Queue empty: head == tail
  • Queue full: (head + 1) & mask == tail

SPSC Queue API

// Init
static inline void spsc_queue_init(spsc_queue_t* q, uint8_t* buffer, uint32_t capacity);

// Producer (one core only)
static inline bool spsc_queue_try_add(spsc_queue_t* q, uint8_t data);
static inline void spsc_queue_add_blocking(spsc_queue_t* q, uint8_t data);

// Consumer (one core only)
static inline bool spsc_queue_try_remove(spsc_queue_t* q, uint8_t* data);
static inline void spsc_queue_remove_blocking(spsc_queue_t* q, uint8_t* data);
static inline bool spsc_queue_try_peek(spsc_queue_t* q, uint8_t* data);
static inline void spsc_queue_peek_blocking(spsc_queue_t* q, uint8_t* data);

// Either core (result may be stale)
static inline uint32_t spsc_queue_level(spsc_queue_t* q);
static inline uint32_t spsc_queue_free(spsc_queue_t* q);
static inline bool spsc_queue_is_empty(spsc_queue_t* q);
static inline bool spsc_queue_is_full(spsc_queue_t* q);

The try_ variants return false immediately when the queue is full (producer) or empty (consumer). The _blocking variants spin until the operation succeeds.


Memory Barriers

From src/spsc_queue.h — uses __dmb() for ARM data memory barrier to ensure correct visibility of data across cores.

Producer side (try_add):

q->buffer[head] = data;
__dmb();  // Release barrier: ensure data write visible before head update
q->head = next_head;

Consumer side (try_remove):

if (tail == q->head) return false;
__dmb();  // Acquire barrier: ensure we see data written before head was updated
*data = q->buffer[tail];
__dmb();  // Release barrier: ensure data read completes before tail update
q->tail = (tail + 1) & q->mask;

The producer writes data first, then issues a release barrier before publishing the new head. The consumer checks head, issues an acquire barrier to read the committed data, then updates tail after a second release barrier.


Queue Instances

From src/usb_rx.h and src/usb_tx.h:

extern spsc_queue_t rx_fifo;      // Terminal input: Core1 → Core0
extern spsc_queue_t bin_rx_fifo;  // Binary mode input: Core1 → Core0
// tx_fifo and bin_tx_fifo: Core0 → Core1
QueueProducerConsumerPurpose
rx_fifoCore 1 (USB/UART input)Core 0 (command processor)Terminal input
tx_fifoCore 0 (printf output)Core 1 (USB transmit)Terminal output
bin_rx_fifoCore 1 (USB input)Core 0 (binary mode)Binary mode input
bin_tx_fifoCore 0 (binary mode)Core 1 (USB transmit)Binary mode output

Data Flow

USB Host ↔ TinyUSB (Core 1) ↔ SPSC Queues ↔ Core 0 ↔ Protocol Engine
                                                      ↔ Command Processor

All bytes between the USB host and the command/protocol layer pass through SPSC queues. Core 1 never directly calls into the command processor or protocol engine; it only enqueues/dequeues bytes.


Usage Example

static uint8_t my_buffer[256];
static spsc_queue_t my_queue;
spsc_queue_init(&my_queue, my_buffer, sizeof(my_buffer));

Buffer size must be a power of 2. The queue is statically allocated — no malloc/free required.


Design Rationale

  • Lock-free: No spinlocks, no priority inversion risk
  • Static allocation: No malloc/free needed on embedded target
  • Power-of-2 sizes: Efficient modulo via bitmask
  • Stale reads are safe: May falsely report empty/full, caller retries