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.
| Mechanism | SDK API | Best for |
|---|---|---|
| Inter-core FIFO | multicore_fifo_push_blocking, multicore_fifo_pop_blocking | Single 32-bit messages and start-up handshakes; small hardware FIFO (8 words each way on the RP2040) |
| Queue | queue_init, queue_try_add, queue_try_remove | Structs of any size, many entries, safe from either core |
| Mutex | mutex_init, mutex_enter_blocking, mutex_exit | Protecting shared variables or a shared peripheral |
| Hardware spinlocks | spin_lock_blocking, spin_unlock | Very 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.
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 - 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_addandqueue_try_removenever 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_msrather thansleep_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 - 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_core1twice: core 1 is already running. Usemulticore_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
_threadwhile 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.