การพัฒนาเฟิร์มแวร์สำหรับ RP2040 ด้วยภาษา C/C++ (ตอน 2 dual-core blink)

Supachai Vorapojpisut
5 min readMay 2, 2022

--

บทความตอนแรกได้แนะนำเครื่องมือพัฒนาซอฟต์แวร์สำหรับหน่วยประมวลผล RP2040 โดยใช้ blink เพื่อสาธิตว่าจะเขียนด้วย MicroPython, Arduino และ Mbed OS อย่างไร โค้ดตัวอย่างของ Mbed OS เรียกใช้ RTOS API เพื่อสร้าง thread แยกสำหรับการควบคุมติด/ดับ LED ไปพร้อมๆกับ main loop แต่การเขียนโค้ดแบบ multithread ด้วย Mbed OS จะยังไม่ได้รองรับหน่วยประมวลผลแบบหลายคอร์ ทำให้ยังกระจายงานไปยังแต่ละคอรไม่ได้ บทความนี้จะต่อยอดจากครั้งก่อนโดยอธิบายการเขียนโค้ดสำหรับเลือกคอร์ของหน่วยประมวลผล RP2040 ให้ทำงาน

ฮาร์ดแวร์รองรับ dual core ของ RP2040

สถาปัตยกรรมของหน่วยประมวลผล RP2040

หน่วยประมวลผล RP2040 ใช้สถาปัตยกรรมแบบ symmetric โดยทั้งสองคอร์ (proc0 และ proc1) เป็น ARM Cortex-M0 เหมือนกัน การเข้าถึงองค์ประกอบต่างๆบนชิพหรือนอกชิพ (RP2040 ใช้หน่วยความจำแฟลชภายนอกผ่านบัส QSPI) จะอาศัยช่องทาง bus fabric ที่ AHB-Lite master (proc0, proc1, DMA read/write) จะเข้าถึงข้อมูลของหน่วยความจำและเพอริเฟอรัลต่างๆผ่าน 10 downstream port ในกรณีของการเข้าถึง port ต่างกันจะไม่มี wait state แต่หากมี master มากกว่า 1 ตัวจะเข้าถึง port เดียวกันจะเกิดการรอตาม priority หรือผลัดกันเข้าถึงแบบ round-robin ในกรณีที่มี priority เท่ากัน การไหลของข้อมูลผ่าน bus fabric จะเป็นไปอย่างอัตโนมัติโดยไม่ต้องมีซอฟต์แวร์มาควบคุม

โครงสร้าง bus fabric ของ RP2040

เมื่อเทียบกับการพัฒนาแบบ bare metal (foreground-background execution) หรือใช้ RTOS (concurrent execution) การพัฒนาซอฟต์แวร์ที่ใช้ทั้งสองคอร์ของ RP2040 จัดเป็นการประมวลผลแบบขนาน (parallel processing) ที่มีความท้าทายอย่างมากในการไม่เหยียบเท้ากันเอง (race condition) ทีมวิศวกรที่พัฒนา RP2040 จึงเตรียมกลไกการเข้าจังหวะระหว่าง proc0 และ proc1 ไว้หลายวิธีทั้งการพึ่งพาฮาร์ดแวร์เฉพาะ เช่น บล็อก Single-cycle IO (SIO) และ API ในกลุ่ม mutex ของ C/C++ SDK

บล็อก Single-cycle IO (SIO) เป็นกลุ่มของฮาร์ดแวร์เฉพาะที่เชื่อมต่อโดยตรงกับส่วนคอร์ proc0/proc1 ไม่ผ่านบัส AHB-Lite โดยมีจุดเด่นที่ใช้แค่ 1 clock cycle ในการอ่าน/เขียนค่า การเข้าถึงค่าต่างๆภายในบล็อก SIO จะใช้รหัสคำสั่ง load/store เหมือนการเข้าถึงหน่วยความจำ โดยมีย่านแอดเดรสสำหรับอ่าน/เขียนแบบ word (32 บิต) คือ 0xD0000000~0xD000017C องค์ประกอบภายในบล็อก SIO แบ่งออกตามหน้าที่เป็น 4 ส่วน ได้แก่

  • CPUID ใช้ตรวจสอบส่วนคอร์ที่กำลังประมวลผล
  • FIFO ใช้ส่งผ่านข้อมูลระหว่างส่วนคอร์
  • spin lock ใช้ควบคุมการเข้าถึงทรัพยากร
  • ส่วนคำนวณ เช่น การหารเลขจำนวนเต็ม

ทั้งนี้ C/C++ SDK ของ RP2040 ได้เตรียมไลบรารี pico_sync สำหรับใช้เข้าถึงองค์ประกอบภายในบล็อก SIO ได้ โดยไม่จำเป็นที่จะอ่าน/เขียนรีจิสเตอร์ที่เกี่ยวข้อง

ฮาร์ดแวร์ที่เชื่อมต่อระหว่าง proc0/proc1

FIFO เป็นบัฟเฟอร์ขนาด 8 ค่าสำหรับส่งผ่านข้อมูล 32 บิตระหว่างคอร์ proc0/proc1 โดยแบ่งออกเป็น FIFO_RD และ FIFO_WR เพื่อการสื่อสารแบบ full-duplex การตรวจสอบสถานะ FIFO จะอาศัยรีจิสเตอร์ FIFO_ST ซึ่งประกอบด้วย แฟลก VLD (ได้รับข้อมูลใหม่) RDY (ว่าง) ROE (อ่านทั้งที่ว่าง) และ WOF (เขียนทั้งที่เต็ม) โดยสามารถผูกกับ IRQ #15 (SIO_IRQ_PROC0) และ #16 (SIO_IRQ_PROC1) ได้ เงื่อนไขที่กระตุ้น SIO_IRQ_PROCx เป็นการ OR ระหว่างแฟลก VLD, ROE และ WOF จึงสามารถใช้กระตุ้นส่วนคอร์มารับข้อมูลได้ทันที

spin lock เป็นกลไกระดับฮาร์ดแวร์ในการป้องกันการแย่งกันเข้าถึงทรัพยากรของระบบได้ โดยส่วนคอร์จะต้อง poll สถานะของ lock เพื่อจองการเข้าถึงทรัพยากร หน่วยประมวลผล RP2040 เตรียม spin lock ภายในบล็อก SIO ไว้ 32 ตัว โดยแบ่งตามเงื่อนไขการเข้าถึงดังนี้

  • หมายเลข 0–15 ถูกใช้ใน SDK จึงห้ามนำมาใช้ในส่วนโค้ดของแอพพลิเคชัน
  • หมายเลข 16–23 เป็น spin lock แบบช่วง เมื่อร้องขอจะได้ ID ที่ยังว่าง
  • หมายเลข 24–31 ใช้สำหรับการล็อกแบบเจาะจง

ทั้งนี้การใช้งาน spin lock ควรระวังปัญหา deadlock และ priority inversion ที่อาจเกิดขึ้นเมื่อมี interrupt มาขัดจังหวะโค้ดที่ขอ spin lock ไปแล้ว การร้องขอ spin lock จึงควรเรียกใช้ฟังก์ชันที่สามารถ disable กลไก interrupt ด้วย เพื่อให้ส่วนโค้ดที่ร้องขอ spin lock ไม่ถูกขัดจังหวะหรือที่เรียกว่า atomic operation ได้จริง

การติดตั้ง C/C++ SDK

ในบางสถานการณ์ การเขียนโค้ดอาจต้องกำหนดส่วนคอร์ที่จะประมวลผลได้อย่างเจาะจง เช่น การพัฒนาซอฟต์แวร์ที่แบ่งส่วนโค้ดสำหรับการสื่อสารให้อยู่บนคอร์ proc0 ในขณะที่งานอื่นๆจะประมวลผลบนคอร์ proc1 เพื่อป้องกันไม่ให้เกิดการขัดจังหวะในระหว่างการสื่อสาร การส่งผ่านข้อมูลระหว่างคอร์สามารถใช้เทคนิค shared memory โดยจองหน่วยความจำที่เข้าถึงได้จากทั้งสองคอร์ร่วมกับ spin lock เป็นกลไกป้องกัน

โค้ดตัวอย่างต่อไปนี้จะพัฒนาด้วย C/C++ SDK ของ Raspberry Pi Foundation เพื่อให้เข้าใจถึงกลไกในการเข้าถึงฟีเจอร์ต่างๆ อย่างไรก็ตาม การติดตั้ง C/C++ SDK ค่อนข้างไม่สะดวกสำหรับเครื่อง PC ที่ใช้ Microsoft Windows หากจะทำไปตามขั้นตอนที่อธิบายในเว็บไซต์ ผมจึงขอแนะนำตัวช่วยคือ โครงการ pico-setup-windows บน github ซึ่งจะ automate ขั้นตอนต่างๆในการติดตั้ง

ผลของการใช้ pico-setup-windows

การเรียนรู้ขั้นตอนการ build ของ C/C++ SDK บน VS Code ให้ทดลองด้วยการสร้าง blink ตามขั้นตอนต่อไปนี้

  1. คลิก shortcut “Visual Studio Code for Pico” เพื่อประกาศ environment variable และเรียก VS Code ให้ขึ้นมาทำงาน
  2. ตรวจสอบว่าได้ติดตั้ง CMake extension แล้ว
  3. เรียกเมนู File > Open Folder และ File > New File เพื่อสร้างไฟล์พื้นฐานสำหรับ project ได้แก่ CMakeLists.txt และไฟล์ซอร์สโค้ด เช่น main.c และ main.h
  4. กดปุ่ม F1 เพื่อเลือกฟังก์ชัน CMake: Build

หากมีการเปลี่ยนแปลงเงื่อนไขของไฟล์และไลบรารีแล้วมีปัญหาในการ build ให้ทดลองเรียก CMake: Clean เพื่อล้างไฟล์ต่างๆที่เคยถูกสร้างและคอมไพล์ไปแล้ว

เนื้อหาของไฟล์ CMakeLists.txt จะเป็นการกำหนดขั้นตอนต่างๆในการเตรียมและเรียกใช้ C/C++ SDK เพื่อมา build ไฟล์ .uf2 จากซอร์สโค้ด ตัวอย่างแบบง่ายของไฟล์ CMakeLists.txt สำหรับสร้าง blink.uf2 จากไฟล์ main.c คือ

# specify version of CMake
cmake_minimum_required(VERSION 3.12)
# Pull in SDK (must be before project)
include($ENV{PICO_SDK_PATH}/pico_sdk_init.cmake)
# Set executable name
set(NAME blink)
# Boilerplate
project(${NAME} C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
# Initialize the SDK
pico_sdk_init()
# Add source files
add_executable(${NAME}
main.c
)
# Set compiler options
add_compile_options(-Wall
-Wno-format
-Wno-unused-function
-Wno-maybe-uninitialized
)
# Include required libraries
target_link_libraries(${NAME}
pico_stdlib
)
# create map/bin/hex file etc.
pico_add_extra_outputs(${NAME})

การเขียนโค้ด blink แบบง่ายด้วย C/C++ SDK สามารถเรียก API ของ pico_stdlib เพื่อตั้งค่าและใช้ขา GP0 เป็นเอาต์พุตแบบดิจิตอลและหน่วงเวลา ซึ่งจะเห็นได้ว่าไม่ได้แตกต่างจาก Arduino มากนัก

#include <pico/stdlib.h>int main() {
const uint LED_PIN = 0;
gpio_init(LED_PIN);
gpio_set_dir(LED_PIN, GPIO_OUT);
while (true) {
gpio_put(LED_PIN, 1);
sleep_ms(250);
gpio_put(LED_PIN, 0);
sleep_ms(250);
}
}

เมื่อเรียก CMake: Build แล้ว โปรแกรม CMake จะทำการ build เป็นไฟล์ blink.uf2 ในโฟลเดอร์ย่อย /build ตามที่ได้กำหนดเงื่อนไขใน CMakeLists.txt การโปรแกรมลงบอร์ดให้กดปุ่ม boot แล้วเสียบสาย/เปิดสวิทช์เพื่อเข้าสู่โหมด UF2 bootloader จากนั้นจึงคลิกแล้วลากไฟล์ blink.uf2 เข้าสู่ไดร์ฟจำลอง

หน้าจอย่อย OUTPUT ของ VS Code แสดงสถานะ build สำเร็จ (exit code 0)

โค้ด blink แบบ dual core (ยังไม่ sync)

การบูตของ RP2040 จะใช้ proc0 ประมวลผลไปตามลำดับของการบูต ในขณะที่ proc1 จะเข้าสู่โหมด low-power เพื่อรอการกระตุ้นให้ทำงาน ดังนั้นส่วนโค้ดของโปรแกรมจะทำงานเฉพาะใน proc0 หากไม่กระตุ้น proc1 ให้ประมวลผล การกระตุ้น proc1 สามารถเลือกจาก multicore_launch_core1() ที่จะใช้พื้นที่ stack แบบ default หรือ multicore_launch_core1_with_stack() ที่สามารถระบุขนาดและตำแหน่งของ stack ได้

การปรับโค้ดตัวอย่างให้เป็น blink แบบ dual core จะแบ่งออกเป็น 2 ขั้น โดยเริ่มจากการเพิ่มไลบรารี pico_multicore ในไฟล์ CMakeLists.txt

# Include required libraries
target_link_libraries(${NAME}
pico_stdlib
pico_multicore
)

ส่วนโค้ดสำหรับ proc1 เป็นเพียงฟังก์ชันแบบ void ซึ่งหลังจากถูกกระตุ้นด้วยการเรียกใช้ฟังก์ชัน multicore_launch_core1() จะทำงานขนานไปกับส่วนโค้ด main ที่ทำงานบน proc0 การที่ทั้งสองคอร์มีพื้นที่ stack ที่แยกออกจากกัน ทำให้รูปแบบของการเขียนโค้ดคล้ายกับ multithread ใน RTOS เพียงแต่จะไม่มีการสลับงาน (context switching) เกิดขึ้น

#include <pico/stdlib.h>
#include <pico/multicore.h>
void core1_blink() {
const uint LED1_PIN = 1;
gpio_init(LED1_PIN);
gpio_set_dir(LED1_PIN, GPIO_OUT);
while (true) {
gpio_put(LED1_PIN, 1);
sleep_ms(300);
gpio_put(LED1_PIN, 0);
sleep_ms(300);
}
}
int main() {
multicore_launch_core1(core1_blink);
const uint LED0_PIN = 0;
gpio_init(LED0_PIN);
gpio_set_dir(LED0_PIN, GPIO_OUT);
while (true) {
gpio_put(LED0_PIN, 1);
sleep_ms(250);
gpio_put(LED0_PIN, 0);
sleep_ms(250);
}
}

ข้อควรระวังในการเขียนโค้ดแบบ dual core คือ ฟังก์ชันบางส่วนในไลบรารีมาตรฐานของภาษา C จะไม่เป็น thread safe จึงควรใช้ร่วมกับ API ของไลบรารี pico_sync เช่น กลุ่มฟังก์ชัน mutex_ หรือ fifo_ เพื่อลดความเสี่ยงของปัญหาแย่งใช้ทรัพยากร ทั้งนี้ ไลบรารี pico_multicore ได้เตรียมบางฟังก์ชันที่ใช้กันบ่อยให้เป็น thread safe ได้แก่ printf() malloc() calloc() และ free() อย่างไรก็ตาม การเขียนโค้ด C++ ที่มีการจองหน่วยความจำสำหรับ object จะต้องระวังเป็นพิเศษ เพราะอาจเกิดปัญหา memory violation ได้

โค้ด blink แบบ dual core (sync)

การเขียนโค้ดที่จะต้องประสานการทำงานระหว่างคอร์ จะต้องเรียกใช้กลไกเข้าจังหวะหรือส่งผ่านข้อมูลระหว่าง proc0/proc1 เพื่อป้องกันสถานการณ์ไม่พึงประสงค์ต่างๆ เช่น ส่วนโค้ดหยุดทำงาน (blocking) และข้อมูลถูกเขียนทับ (read-modify-write) รายการของไลบรารีใน C/C++ SDK ที่เกี่ยวข้องกับหน้าที่นี้ได้แก่

  • pico_multicore ประกอบด้วยฟังก์ชันสำหรับใช้งาน FIFO และ lockout ที่ใช้หยุดการทำงานของคอร์อื่น
  • pico_sync ประกอบด้วยฟังก์ชันสำหรับเข้าจังหวะ เช่น การเข้าสถานะ critical section การใช้ mutex เพื่อคุมการเข้าถึงทรัพยากร และการใช้ semaphore เพื่อคุมจังหวะของการประมวลผล
  • pico_util ประกอบด้วยฟังก์ชัน queue สำหรับแลกเปลี่ยนข้อมูล

ทั้งนี้ ไลบรารีข้างต้นจะหุ้มการทำงานของ FIFO และ spin lock เพื่อเป็นการหลีกเลี่ยงการเขียนโค้ดที่เข้าถึงบล็อก SIO โดยตรง ซึ่งอาจทำให้เกิดการขัดแย้งกับส่วนโค้ดของ SDK เอง เอกสาร C/C++ SDK เองจะแนะนำให้ใช้กลุ่มฟังก์ชัน critical section, mutex, semaphore และ queue ซึ่งเป็น API พื้นฐานของ RTOS ดังนั้น การพัฒนาซอฟต์แวร์แบบ dual core บน RP2040 ด้วย C/C++ SDK จึงคล้ายกับการเขียนโค้ดแบบ multithread แบบจำกัดแค่ 2 thread (proc0/proc1) ซึ่งจะไม่มีการสลับงานเกิดขึ้น

การปรับปรุงโค้ดตัวอย่างแบบไม่ sync จะเริ่มจากการเพิ่มไลบรารี pico_sync ในไฟล์ CMakeLists.txt

# Include required libraries
target_link_libraries(${NAME}
pico_stdlib
pico_multicore
pico_sync
)

โค้ดตัวอย่างจะเป็น loop ของ proc0 และ proc1 ที่วนอ่านสถานะของปุ่มกด (ขา GP20) แล้วมาตั้งค่า LED0 (proc0) และ LED1 (proc1) เนื่องจากโค้ดของทั้งสองคอร์จะเข้าถึงขา GP20 เหมือนกัน ฟังก์ชัน gpio_get() จึงถูกป้องกันด้วยการใช้ mutex แบบ blocking ทำให้ส่วนคอร์ที่เข้าถึงช้ากว่าจะหยุดรอจนกว่าอีกคอร์จะปล่อยการจอง mutex หลังจากตั้งค่า LED และหน่วงเวลาแล้ว

#include <pico/stdlib.h>
#include <pico/multicore.h>
#include <pico/sync.h>
const uint LED0_PIN = 0;
const uint LED1_PIN = 1;
const uint BTN_PIN = 20;
mutex_t btn_mtx;
void core1_blink() {
bool pressed;
gpio_init(LED1_PIN); // initialize LED1
gpio_set_dir(LED1_PIN, GPIO_OUT);
while (true) {
mutex_enter_blocking(&btn_mtx);
pressed = !gpio_get(BTN_PIN);
gpio_put(LED1_PIN, pressed);
sleep_ms(1000);
gpio_put(LED1_PIN, 0);
mutex_exit(&btn_mtx);

sleep_ms(300);
}
}
int main() {
bool pressed;
gpio_init(LED0_PIN); // initialize LED0
gpio_set_dir(LED0_PIN, GPIO_OUT);
gpio_init(BTN_PIN); // initialize button
gpio_set_dir(BTN_PIN, GPIO_IN);
mutex_init(&btn_mtx); // initialize mutex
multicore_launch_core1(core1_blink);

while (true) {
mutex_enter_blocking(&btn_mtx);
pressed = !gpio_get(BTN_PIN);
gpio_put(LED0_PIN, pressed);
sleep_ms(1000);
gpio_put(LED0_PIN, 0);
mutex_exit(&btn_mtx);

sleep_ms(500);
}
}

การใช้ mutex แบบ blocking และมีการหน่วงเวลาระหว่างช่วง mutex_enter และ mutex_exit ทำให้เมื่อกดปุ่ม จะมี LED เพียงดวงเดียวที่ติดและค้างไว้ตามการหน่วงเวลา (เป็นไปตามเงื่อนไข mutual exclusion) ทั้งนี้ตำแหน่งของ LED ที่ติดจะไม่แน่นอน ขึ้นอยู่กับส่วนคอร์ไหนที่เข้ามาอ่านสถานะปุ่มกด ณ เวลานั้น

ข้อเสียของการใช้ mutex แบบ blocking ในกรณีของ RP2040 คือ อีกส่วนคอร์จะไม่สามารถประมวลผลงานอื่นได้จนกว่า mutex จะถูกปล่อย แม้ว่าสถานการณ์นี้ไม่ใช่ประเด็นสำคัญในกรณีของหน่วยประมวลผลทั่วไป เนื่องจากมีโค้ดส่วนเดียวเท่านั้นที่จะถูกประมวลผลได้ โค้ดต่อไปนี้จะสาธิตการเปลี่ยนไปใช้ mutex แบบ non-blocking เพื่อให้คอร์ proc1 ยังประมวลผลขนานไปกับ proc0

while (true) {
while (!mutex_try_enter(&btn_mtx, &core_id)) {
gpio_put(LED1_PIN, 1); sleep_ms(50);
gpio_put(LED1_PIN, 0); sleep_ms(50);
}
pressed = !gpio_get(BTN_PIN);
gpio_put(LED1_PIN, pressed);
sleep_ms(1000);
gpio_put(LED1_PIN, 0);
mutex_exit(&btn_mtx);
sleep_ms(300);
}

การเปลี่ยนไปใช้ mutex_try_enter() ทำให้หากไม่สามารถจอง mutex ได้จะคืนสถานะ false กลับมา วงรอบ while จึงจะกระพริบ LED ด้วยความถี่สูงและจะแสดงสถานะติด/ดับค้าง 1 วินาทีหากจอง mutex ได้ การกดปุ่มค้างจะเห็นชัดมากขึ้นว่า proc1 ยังทำงาน (ไฟกระพริบ) แม้ว่า LED0 จะติดที่แสดงว่า mutex ถูกจองด้วย proc0 และยังอยู่ในช่วงหน่วงเวลา

終わりに (ในตอนท้าย)

การที่ C/C++ SDK ของ RP2040 เตรียม API สำหรับเข้าจังหวะ/ส่งผ่านข้อมูลในรูปแบบเดียวกับ RTOS น่าจะทำให้คนที่เคยเขียนโค้ดแบบ multithread ง่ายกับการมาเขียนโค้ดบน dual core บน RP2040 แม้ว่าจะถูกจำกัดที่ไม่สามารถสร้าง thread ได้ตามใจ แต่อีกนัยหนึ่งคือการไม่ต้องเสียหน่วยความจำและเวลาในการสลับงานของ RTOS ซึ่งเป็นจุดที่น่าคิดในแง่การ trade-off ระหว่าง flexibility และ efficiency ทั้งนี้หากอยากเขียนโค้ดแบบหลาย thread สิ่งที่ต้องทำเพิ่มหน่อยคือไปเอา FreeRTOS ที่มี RP2040 port แล้วมา build ร่วมกัน

บอร์ด LILYGO T-PicoC3 อีกสักพักถึงจะมา เลยคิดว่าอาจจะแทรกเนื้อหาของการ build ไลบรารี CMSIS สำหรับ RP2040 ก่อน เพราะจะเป็นการเปิดช่องให้กับ API สายแข็งหลายๆตัวรวมถึง CMSIS-DSP และ CMSIS-NN

--

--

Supachai Vorapojpisut
Supachai Vorapojpisut

Written by Supachai Vorapojpisut

Assistant Professor at Thammasat University

No responses yet