Raspberry Pi Pico - Multicore Adventures

Introduction

While I was writing my first impression post, I was thinking about how to utilize both cores. I grew up on Arduino, which is single core, and wasn’t really messing around that much with ESP, nor big Pi’s. That was, until now. For some reason, the first thing I thought when browsing throught C++ SDK Reference was “how can I use both of these cores? Is it as painful as on desktop?” Well, I have answers for you, with some code.

Projects

So, the first thing you do when learning something new, you are going for the simplest stuff that you can think of. And for me, the simplest thing that I can think of that could be done on microcontroller is blinking LEDs. So naturally, I wanted to know, if I can utilize both cores to blink two LEDs with independent timing. But to explain stuff in better detail, let’s firt see, how you would approach this on single core board.

Timer Approach

On Arduino boards, there is this little function called millis(), that lot of people get confused about. I don’t understand why, as it simply uses one of AtMega8’s timers to count, how many milliseconds elapsed since the processor booted up. You can compare this value with whatever you want, and do math with it. And if you know what modulo is, you probably now know, how to blink 2 LEDs with different speed. You just check at different values, and if they are true, you blink. Simple.

1
2
3
4
5
6
7
8
// get the state of the counter; how many ms has elapsed
uint64_t time = millis();
// check if they are divisible by 2000ms (2s) or 3000ms (3s), and toggle appropriate LED
if(time % 2000 == 0) {
toggleLED();
} else if (time % 3000 == 0) {
toggleAnotherLED();
}

This would be some simplified code for blinking two LEDs with different speeds on Arduino. But how about Pico.

Just use two cores, duh

If you dig into the documentation, you’ll find out that the lovely devs left us with quite a few methods to access the multicore API. What we are interested in is multicore_launch_core1() function. It takes just one parameter, and that is the name of the function that shall be executed on second core. That is it. So, let’s write a simple program that blinks the on-board LED with 333ms delays, and LED on GPIO 0 with 500ms delays.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// import libraries
#include <stdio.h>
#include "pico/stdlib.h"

#include "hardware/adc.h"
#include "hardware/timer.h"

// define on which pins are LEDs
#define LEDG 25
#define LEDB 0

//this function will execute on core1
void core1_blink() {
//initialize pin, and set to output
gpio_init(LEDG);
gpio_set_dir(LEDG, GPIO_OUT);

// create infinite loop, which will toggle the green on-board LED with 333ms delays
while(1) {
gpio_put(LEDG, 1);
sleep_ms(333);
gpio_put(LEDG, 0);
sleep_ms(333);
}
}

// this function runs on core0 by default, and is basically the same as the one above, with exception of different pin and delay times
void main() {
// start the function on core1
multicore_laucnhe_core1(core1_blink);

//initialize pin, and set to output
gpio_init(LEDB);
gpio_set_dir(LEDB, GPIO_OUT);

// create infinite loop, which will toggle the blue LED on pin0 with 500ms delays
while(1) {
gpio_put(LEDB, 1);ä
sleep_ms(500);
gpio_put(LEDB, 0);
sleep_ms(500);
}
}

It is really that simple. I’ll attach the CMakeLists.txt, so you can compile and try for yourself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cmake_minimum_required(VERSION 3.13)

include(pico_sdk_import.cmake)

project(multicore_blink)

pico_sdk_init()

add_executable(multicore_blink
main.c
)

pico_add_extra_outputs(multicore_blink)

target_link_libraries(multicore_blink pico_stdlib)
target_link_libraries(multicore_blink pico_multicore)

Reading ADC on one core, printing on the other

As it’s well known, handling data between cores/threads is not an easy task. On desktop particuraly, it is really, really complex and you can run into difficult situations. Fortunately for us, everything on Pico is very well documented and set up, so you don’t need anything really special to pass data between cores.

There are two FIFO (First In, First Out registers, each 32-bits wide and 8 entries deep. One can be only used to write from core0, and the other only to write from core1. They both can be read from both cores. That means you can essentially have full duplex communication between cores. But that’s not important. There are few, really self explanatory methods that utilizes these registers. And one very important note, every time you push something onto the FIFO, a corresponding IRQ will be set. Since those operations are not asynchronous, you HAVE to use the interrupts, and handle them accordingly. So, let’s dive into the program.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// import libraries 
#include <stdio.h>
#include "pico/stdlib.h"

#include "pico/multicore.h"
#include "hardware/adc.h"
#include "hardware/irq.h"

// this method will handle interrupt; execute stuff while the interrupt is set
void core1_irq_handler() {
// check if there is data in FIFO
while(multicore_fifo_rvalid()) {
// pop the data from FIFO stack
uint16_t raw = multicore_fifo_pop_blocking();
// this is from datasheet, it is used to calculate the value of ADC in Volts
const float conversion_factor = 3.3f / (1 << 12);
// and print to stdout
printf("ADC Value - %f V\n", raw*conversion_factor);
}

//at the end of handling the interrupt, we need to clear it
multicore_fifo_clear_irq();
}

// this method runs on core1. In the infinite loop, you can do essentially whatever you want. We're just using the built-in loop which does nothing. Literally. It's nop instruction afiak.
void core1_entry() {
// this will run only at the beggining of execution. We clear the interrupt flag, if it got set by a chance
multicore_fifo_clear_irq();
// set the SIO_IRQ_PROC1 (FIFO register set interrupt) ownership to only one core. Opposite to irq_set_shared_handler() function
// We pass it the name of function that shall be executed when interrupt occurs
irq_set_exclusive_handler(SIO_IRQ_PROC1, core1_irq_handler);
// enable interrupt
irq_set_enabled(SIO_IRQ_PROC1, true);

// do whatever
while(1) {
tight_loop_contents();
}
}

// main function, running on core0
void main() {
// initialize stdio to COM output
stdio_init_all();
// start our routine on core1
multicore_launch_core1(core1_entry);
// initialize ADC, set pin and which ADC to use
adc_init();
adc_gpio_init(26);
adc_select_input(0);

// classic infinite loop
while(1) {
// read the ADC
uint16_t raw_value = adc_read();
// push the read value onto the FIFO stack
multicore_fifo_push_blocking(raw_value);
// sleep for some time
sleep_ms(1000);
}
}

As we can see, it is nothing really difficult. It took me some time to figure out which interrupt flags correspond to FIFO registers, but eventually I managed to get it to work. How does it works then? Well, it’s commented, go and read the comments.

But for you lazy people, here’s TLDR:
We set up a handler, that prints stuff from FIFO stack. Then, we wait for the interrupt on the core1, while doing some important stuff. On the core0, we take measurement from one of the ADCs each second, and push the value onto the FIFO. When this happens, interrupt request occurs that is handled on core1. Core0 can meanwhile still take measurements and push them onto the stack.

Aaaand I almsot forgot, here’s the CMakeLists

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cmake_minimum_required(VERSION 3.13)

include(pico_sdk_import.cmake)

project(multicore_adc)

pico_sdk_init()

add_executable(multicore_adc
main.c
)

pico_add_extra_outputs(multicore_adc)

target_link_libraries(multicore_adc pico_stdlib)
target_link_libraries(multicore_adc pico_multicore)
target_link_libraries(multicore_adc hardware_adc)

pico_enable_stdio_usb(multicore_adc 1)

Conclusion

That will be it for program showcase, I think you should get a pretty good idea of how this thing works. It is really simple, and the idea behind it is extremely clever - using the FIFO registers solved so much hassle that would otherwise had to be solved by shennanigans regarding protected memory and heaps. It is well thought system, and after a bit of getting used to, it is no big deal.