Raspberry Pi Pico Dual-Core Programming with pico_multicore and _thread

Both the RP2040 and the RP2350 have two processor cores, but every program you have written so far used only one. The second core is useful whenever two jobs have different timing needs: one core can sample a sensor at a steady rate while the other handles a display, Wi-Fi or USB without disturbing that timing. In this advanced tutorial you will start code on core 1 with the C/C++ SDK, pass data safely between the cores with the hardware FIFO and SDK queues, and then see the MicroPython equivalent using the _thread module.

How the Two Cores Work Together

After reset, core 0 runs your main() while core 1 sleeps in the boot ROM waiting for instructions. Calling multicore_launch_core1(fn) hands it a function, a stack and a vector table, and core 1 starts executing fn. Both cores share the same memory and peripherals, so any global variable is visible to both. That convenience is also the main danger: if both cores modify the same data at the same time, results become corrupted in ways that are hard to reproduce.

MechanismSDK APIBest for
Inter-core FIFOmulticore_fifo_push_blocking, multicore_fifo_pop_blockingSingle 32-bit messages and start-up handshakes; small hardware FIFO (8 words each way on the RP2040)
Queuequeue_init, queue_try_add, queue_try_removeStructs of any size, many entries, safe from either core
Mutexmutex_init, mutex_enter_blocking, mutex_exitProtecting shared variables or a shared peripheral
Hardware spinlocksspin_lock_blocking, spin_unlockVery short critical sections; the SDK builds queues and mutexes on these

Queues are usually the right choice. Each core owns its own data and the queue copies messages between them, so there is no shared variable to protect.

Before reaching for the second core, consider whether a peripheral can do the job instead. PIO handles cycle-exact pin timing, DMA moves blocks of ADC or SPI data without the CPU, and hardware timers fire callbacks on schedule. The second core earns its place when two pieces of genuine computation, such as filtering and a display or network stack, must both run continuously.

Project: Sampling on Core 0, Filtering and LED on Core 1

Core 0 samples a potentiometer on GP26 every 10 ms and sends each sample through a queue. Core 1 runs a 16-sample moving average, sets the blink period of an LED on GP15 from the result, and returns the filtered value through a second queue for core 0 to print. The cores also use the FIFO once at start-up as a ready handshake. Wire the potentiometer and LED exactly as in the earlier ADC and LED tutorials.

CMakeLists.txt - links the multicore and ADC libraries
cmake_minimum_required(VERSION 3.13)
include(pico_sdk_import.cmake)
project(dualcore C CXX ASM)
set(CMAKE_C_STANDARD 11)
pico_sdk_init()

add_executable(dualcore dualcore.c)
target_link_libraries(dualcore pico_stdlib pico_multicore hardware_adc)
pico_enable_stdio_usb(dualcore 1)
pico_enable_stdio_uart(dualcore 0)
pico_add_extra_outputs(dualcore)
dualcore.c - core 0 samples the ADC, core 1 filters and blinks, queues carry data both ways
// dualcore.c - two-core producer/consumer example for RP2040 and RP2350
#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/multicore.h"
#include "pico/util/queue.h"
#include "hardware/adc.h"

#define LED_PIN      15
#define POT_GPIO     26          // ADC0
#define WINDOW       16          // moving-average length
#define CORE1_READY  0xC0DE0001u // handshake value sent through the FIFO

typedef struct {
    uint16_t raw;                // 12-bit ADC value, 0..4095
    uint32_t t_ms;               // time the sample was taken
} sample_t;

static queue_t sample_q;         // core 0 -> core 1
static queue_t result_q;         // core 1 -> core 0

// ---------------- core 1 ----------------
static void core1_main(void) {
    gpio_init(LED_PIN);
    gpio_set_dir(LED_PIN, GPIO_OUT);

    uint16_t window[WINDOW] = {0};
    uint32_t sum = 0;
    uint idx = 0;
    uint32_t period_ms = 500;
    absolute_time_t next_toggle = get_absolute_time();

    multicore_fifo_push_blocking(CORE1_READY);   // tell core 0 we are running

    while (true) {
        sample_t s;
        // Drain every waiting sample without blocking
        while (queue_try_remove(&sample_q, &s)) {
            sum -= window[idx];
            window[idx] = s.raw;
            sum += s.raw;
            idx = (idx + 1) % WINDOW;

            uint32_t avg = sum / WINDOW;
            period_ms = 50 + avg * 950 / 4095;   // 50 ms .. 1000 ms
            queue_try_add(&result_q, &avg);      // drop if core 0 is slow
        }
        // Toggle the LED when its deadline has passed
        if (absolute_time_diff_us(get_absolute_time(), next_toggle) <= 0) {
            gpio_xor_mask(1u << LED_PIN);
            next_toggle = make_timeout_time_ms(period_ms);
        }
        tight_loop_contents();
    }
}

// ---------------- core 0 ----------------
int main(void) {
    stdio_init_all();

    adc_init();
    adc_gpio_init(POT_GPIO);
    adc_select_input(0);

    queue_init(&sample_q, sizeof(sample_t), 32);
    queue_init(&result_q, sizeof(uint32_t), 32);

    multicore_launch_core1(core1_main);
    uint32_t msg = multicore_fifo_pop_blocking();   // wait for the handshake
    sleep_ms(2000);                                  // time to open the serial port
    printf("core 1 reported 0x%08lx, main running on core %u\n",
           (unsigned long)msg, get_core_num());

    uint32_t latest = 0, dropped = 0;
    uint32_t last_print = 0;

    while (true) {
        uint32_t now = to_ms_since_boot(get_absolute_time());
        sample_t s = { .raw = adc_read(), .t_ms = now };
        if (!queue_try_add(&sample_q, &s)) {
            dropped++;                               // core 1 fell behind
        }
        while (queue_try_remove(&result_q, &latest)) {
            // keep only the newest filtered value
        }
        if (now - last_print >= 500) {
            last_print = now;
            printf("filtered=%4lu  (%.2f V)  dropped=%lu\n",
                   (unsigned long)latest, latest * 3.3f / 4095,
                   (unsigned long)dropped);
        }
        sleep_ms(10);                                // 100 samples per second
    }
}

How the Code Works

  • Queues are initialised before multicore_launch_core1, so core 1 never touches an uninitialised queue.
  • The FIFO handshake guarantees core 1 has finished its own set-up before core 0 starts sending data.
  • queue_try_add and queue_try_remove never block. Core 1 drops a result if core 0 is busy printing, and core 0 counts dropped samples, which tells you if the consumer cannot keep up.
  • The moving average keeps a running sum: subtract the oldest value, add the newest. This costs the same regardless of window size.
  • The LED is driven with a deadline from make_timeout_time_ms rather than sleep_ms, so core 1 keeps processing samples between toggles.
  • get_core_num() returns 0 or 1, handy for confirming which core runs a function while debugging.

The MicroPython Equivalent with _thread

On the rp2 port, _thread.start_new_thread runs a function on core 1. Only one extra thread is supported, and the module is still described as experimental, but it works well for simple splits. Use a lock for any variable both cores modify.

dual_core.py - blink on core 1 while core 0 prints, sharing a counter under a lock
# dual_core.py - MicroPython: second function runs on core 1
import _thread
import time
from machine import Pin

lock = _thread.allocate_lock()
counter = 0
running = True

def core1_task():
    global counter
    led = Pin(15, Pin.OUT)
    while running:
        led.toggle()
        lock.acquire()
        counter += 1          # shared variable, protected by the lock
        lock.release()
        time.sleep_ms(250)
    led.off()

_thread.start_new_thread(core1_task, ())

try:
    while True:
        lock.acquire()
        c = counter
        lock.release()
        print('core 1 toggles so far:', c)
        time.sleep(1)
except KeyboardInterrupt:
    running = False           # ask core 1 to finish cleanly
    time.sleep_ms(500)

Stopping the program in Thonny interrupts only core 0. The running flag lets core 1 exit its loop; without it, the second thread can keep running and the next run may fail until you press the reset button or unplug the board.

Common Mistakes

  • Calling multicore_launch_core1 twice: core 1 is already running. Use multicore_reset_core1() first if you really need to restart it.
  • Sharing a plain global counter between cores without a lock or queue, then wondering why counts are occasionally wrong.
  • Using the FIFO for bulk data: it is tiny and blocks when full. Use a queue.
  • In MicroPython, starting a second _thread while one is already running raises an error on the rp2 port.

Summary

The Pico's second core is started with multicore_launch_core1 in C or _thread.start_new_thread in MicroPython. Keep each core's data private and pass messages through queues, use the FIFO for small signals such as a ready handshake, and protect any truly shared resource with a mutex or lock. With this pattern one core can keep strict sampling timing while the other handles slower work like filtering, displays or networking.