แพลตฟอร์ม mbed 101 + HO
เนื้อหาตอนที่ 3 นี้จะมาสาธิต API ของ RTOS ที่เป็นจุดเด่นของแพลตฟอร์ม mbed และเป็นคำตอบ (สำหรับผม) ว่าทำไมจึงเลือก mbed มาใช้สอนแทน Arduino ที่ถูกและหาซื้อง่ายกว่า ก่อนจะเข้าส่วนโค้ดตัวอย่างที่สาธิต API คงต้องทำความเข้าใจเกี่ยวกับกลไกการทำงานของหน่วยประมวลผลในเบื้องต้น
การประมวลผลแบบ multithreading
กลไกการประมวลผลรหัสคำสั่งในส่วนโค้ดของ ARM Cortex จะแบ่งออกเป็น 2 โหมดคือ
- โหมด Thread เป็นสถานะที่หน่วยประมวลผลทำงานตามลำดับของซอฟต์แวร์ โดยเริ่มต้นในระดับ privileged (เข้าถึงรีจิสเตอร์ฮาร์ดแวร์ได้หมด) หลังจาก reset และเปลี่ยนเข้าสู่ระดับ non-privileged (เข้าถึงรีจิสเตอร์ได้จำกัด) ด้วยการตั้งค่าในฮาร์ดแวร์ ARM ได้ออกแบบให้แบ่งโหมด Thread ออกเป็น 2 ระดับเพื่อแยกระหว่างชั้นระบบปฏิบัติการ (ระดับ privileged) และแอพพลิเคชัน (ระดับ non-privileged)
- โหมด Handler เป็นสถานะที่หน่วยประมวลผลถูกขัดจังหวะจาก exception (ประมวลผลพิเศษจากรหัสคำสั่ง) หรือ interrupt (กระตุ้นด้วยเงื่อนไขฮาร์ดแวร์) ทำให้เกิดการสลับไปประมวลผลส่วนโค้ด interrupt handler ที่เตรียมไว้
โค้ดที่เขียนบนแพลตฟอร์ม mbed จะรวมไลบรารี mbed-os ไว้ ทำให้การทำงานส่วนแรกหลังจาก reset เป็นส่วนโค้ดของ RTOS ซึ่งจะสลับเป็นระดับ non-privileged ก่อนเข้าสู่ main() การสลับออกจากโหมด Thread ระดับ non-privileged ของ main() สามารถเกิดขึ้นได้ 2 ทางคือ การเปิดใช้งาน interrupt แล้วเกิดการกระตุ้นจากฮาร์ดแวร์ทำให้สลับไปโหมด Handler และการเรียกใช้ API ของ mbed-os ซึ่งจะกระตุ้นให้ส่วน RTOS ให้มาทำงานด้วยกลไก software exception
โค้ดตัวอย่างของปุ่มกดที่สลับสถานะ LED แบ่งออกเป็น 2 ส่วนคือ ส่วนโปรแกรมหลักที่ลงทะเบียน external interrupt ของปุ่มกดแล้ววนรอบทุก 0.5 วินาทีเพื่ออัพเดท LED ซึ่งเป็นโค้ดที่ทำงานในโหมด Thread (non-privileged) ในทุกวงรอบจะมีการเรียกใช้เมธอด ThisThread::sleep_for() ที่เป็น API ของ mbed ทำให้ส่วน RTOS ถูกกระตุ้นขึ้นมาทำงาน หากมีส่วนโค้ดที่เป็น thread อื่นรอทำงานอยู่ ตัว RTOS จะสลับหน่วยประมวลผลไปประมวลผลใน thread นั้นแทน ซึ่งเป็นพฤติกรรมของระบบซอฟต์แวร์แบบ multithreading ส่วนที่สองคือ ฟังก์ชัน btnPressed() ที่ลงทะเบียนเป็น interrupt handler ด้วยเมธอด InterruptIn::fall() หากผู้ใช้กดปุ่ม กลไก external interrupt ของตัวชิพจะส่งสัญญาณ IRQ (interrupt request) ไปขัดจังหวะหน่วยประมวลผลให้มาทำฟังก์ชันนี้
การใช้งานกลไก interrupt เป็นเงื่อนไขพื้นฐานเพื่อให้ไมโครคอนโทรลเลอร์ตอบสนองต่อเหตุการณ์ทางกายภาพได้อย่างมีประสิทธิภาพ แต่ส่วนโค้ดของ interrupt handler ควรทำงานได้เร็วและไม่ติดในวงรอบ ทำให้ไม่เหมาะสมกับซอฟต์แวร์ของอุปกรณ์ที่ต้องเชื่อมต่อกับฮาร์ดแวร์จำนวนมาก ปัญหาที่มักพบในกรณีที่โค้ด interrupt handler ใช้เวลาในการประมวลผลนานเกินไป คือ โปรแกรมหลักจะไม่ทำงานเนื่องจากโหมด Handler อยู่ในชั้นความสำคัญมากกว่าโหมด Thread ส่งผลให้ทำให้อุปกรณ์ทำงานผิดเพี้ยนได้
ไลบรารี mbed-os ได้เตรียม API แบบ RTOS (real-time operating system) สำหรับแยกการประมวลผลในโหมด Thread (non-privileged) ออกเป็นหลายส่วน จากนั้นอาศัยกลไก interrupt และ exception เพื่อกระตุ้นส่วนจัดลำดับงานในโหมด Thread (privileged) มาเรียบเรียงให้การประมวลผลในภาพรวมมีความสอดคล้องกัน ผมขอยกตัวอย่างของ API มาสาธิตการประมวลผลในรูปบบ concurrent และ deferred operation
Thread API
แพลตฟอร์ม mbed เตรียม Thread API สำหรับแยกส่วนโค้ดให้ทำงานเป็นเอกเทศออกจากส่วนโปรแกรมหลัก main() แต่ละ thread จะมีการเตรียมพื้นที่หน่วยความจำ stack แยกออกจากโปรแกรมหลัก ทำให้สถานะการทำงานต่างๆ (คำสั่งล่าสุด ตัวแปร) ของ thread นั้นจะคงค่าไว้ได้ตลอดช่วงการทำงาน ส่วนโค้ดที่เรียกใช้ Thread API ในทางปฏิบัติมักจะอยู่ในรูปแบบดังนี้
- การทำงานแบบคู่ขนาน (concurrent operation) ส่วนโค้ดจะมีวงรอบอนันต์เหมือนกับโปรแกรมหลัก โดยเรียกใช้ API เพื่อคืนการทำงานให้กับส่วนโค้ดอื่น
- การประมวลผลตามเงื่อนไข (on-demand execution) ส่วนโค้ดภายในวงรอบจะเริ่มด้วยการเรียกใช้ API เพื่อรอเงื่อนไขในการทำงาน การทำงานจะเกิดจากส่วนโค้ดอื่นเรียกใช้ API เพื่อกระตุ้น thread นี้
ผมขอใช้ตัวอย่างไฟกระพริบแบบซับซ้อนเพื่อสาธิตการสร้าง thread ให้ทำงานแบบคู่ขนานไปกับโปรแกรมหลัก (main thread) ที่จะกระพริบ LED บนบอร์ด NUCLEO-F401RE ด้วยความถี่ 1 Hz ในขณะที่อีก 3 thread ย่อยจะแยกคุม LED สีแดง (กดติดปล่อยดับ) LED สีเขียว (กดเพื่อกระพริบ) และ LED สีน้ำเงิน (กดเพื่อเริ่ม/หยุดกระพริบ) โค้ดตัวอย่างจะเริ่มจากการประกาศ object ของ thread ซึ่งจะมีการจองหน่วยความจำในระหว่างการสร้าง จากนั้นแต่ละ thread จะเริ่มต้นประมวลผลเมื่อเรียกใช้เมธอด start() เพื่อกำหนดฟังก์ชันที่จะเป็น callback (ส่วนโค้ดที่ทำหน้าที่ประมวลผล) ทั้งนี้แต่ละ thread จะปล่อยการทำงานคืนให้กับส่วนจัดลำดับงานเมื่อเมธอด ThisThread::sleep_for() ถูกเรียก ทำให้ RTOS สามารถจัดสรรงานให้กับ thread อื่นๆได้ ทำให้หน่วยประมวลผลไม่ต้องวนรอแบบไม่ประมวลผลข้อมูล
#include "mbed.h"DigitalOut led1(LED1);
DigitalIn SW1(PA_7), SW2(PA_8);
InterruptIn SW3(PA_9);
DigitalOut RED(PC_10), GREEN(PC_11), BLUE(PC_12);
Thread redThr, greenThr, blueThr;
bool cond = false;void redCallback() {
while (true) { // การทำงานแบบกดติดปล่อยดับ
RED = !SW1;
ThisThread::sleep_for(100);
}
}
void greenCallback() {
while (true) {
if (!SW2) { // การทำงานแบบกดเพื่อกระพริบ
GREEN = !GREEN;
}
ThisThread::sleep_for(200);
}
}
void blueCallback() {
while (true) { // การทำงานแบบกดเพื่อเริ่ม/หยุดกระพริบ
if (cond) {
BLUE = !BLUE;
}
ThisThread::sleep_for(300);
}
}
void btnHandler() {
cond = !cond;
}int main() {
redThr.start(&redCallback);
greenThr.start(&greenCallback);
blueThr.start(&blueCallback);
SW3.fall(&btnHandler);
while (true) { // การทำงานแบบกระพริบไปเรื่อยๆ
led1 = !led1;
ThisThread::sleep_for(500);
}
}
การสร้าง thread ทำงานแบบคู่ขนานมักจะใช้กับส่วนโค้ดที่ต้องวนตรวจสถานะของการเชื่อมต่อฮาร์ดแวร์หรือที่เรียกว่า polling แต่ในกรณีของการทำงานร่วมกับ interrupt handler ควรที่จะสร้าง thread รอเงื่อนไขกระตุ้นมากกว่า การใช้ตัวแปร global เพื่อส่งผ่านสถานะ/ข้อมูล เช่น ตัวแปร cond ดังในโค้ดตัวอย่าง ยังมีข้อไม่เหมาะสมทั้งในแง่ที่ต้องวนมาตรวจสอบรวมถึงอาจเกิดการแย่งเขียน/อ่านค่า (race condition) ได้ ไลบรารี mbed-os ได้เตรียม API ไว้หลายรูปแบบสำหรับใช้เป็นเงื่อนไข เช่น
- กลุ่ม API สำหรับเข้าจังหวะ เช่น Mutex, Semaphore, EventFlags และ ConditionVariable เมื่อ thread เรียกใช้เมธอดเพื่อรอเงื่อนไขจะเป็นการปล่อยการทำงานคืนให้ส่วนจัดลำดับงานของ RTOS หากมีส่วนโค้ด interrupt handler หรือ thread อื่นเรียกเมธอดกระตุ้น จะทำให้เงื่อนไขเป็นจริง ส่วนโค้ดของ thread ที่รอเงื่อนไขจะเริ่มทำงานต่อไป
- กลุ่ม API สำหรับส่งผ่านข้อมูล เช่น Queue และ Mail ใช้เมื่อต้องการให้ interrupt handler หรือ thread ส่งผ่านข้อมูลไปประมวลผลต่อในอีก thread หนึ่ง
นอกจากนี้ object ของ Thread เองสามารถใช้เมธอดในกลุ่ม ThisThread::wait_?() เพื่อรอเงื่อนไข flag (สถานะ 32 บิต) ที่จะถูกกระตุ้นจากส่วนโค้ดอื่นด้วยการเรียกเมธอด flags_set() ของ thread นี้ได้
ผมได้เตรียมโค้ดตัวอย่างที่สาธิตการกระตุ้น thread ที่คุม LED ให้ทำงาน โดยเปลี่ยนจากการรอภายในวงรอบอนันต์ด้วยเมธอด ThisThread::sleep_for() เป็นการรอเงื่อนไขจากส่วนโค้ดอื่น การทำงานของ thread ชื่อ blinkThr จะเริ่มแต่ละวงรอบด้วยการรอการกระตุ้น (เงื่อนไขไหนก็ได้) ด้วยเมธอด ThisThread::flags_wait_any() การประมวลผลของ thread จะเกิดขึ้นเมื่อส่วน callback ของ Ticker (timer IRQ แบบตั้งเวลาต่อเนื่อง) Timeout (timer IRQ แบบหน่วงเวลา) หรือ InterruptIn (external IRQ จากปุ่มกด) กระตุ้นด้วยเมธอด flags_set() จากนั้นจึงเป็นการทำงานของส่วนโค้ดที่ขึ้นอยู่กับการตรวจสอบค่า flag ในระดับบิต การกระตุ้น thread จากภายใน interrupt handler ทำให้สามารถย้ายการประมวลผลมาดำเนินการในโหมด Thread (non-privileged) ซึ่งมีผลกระทบต่อระบบซอฟต์แวร์น้อยกว่า
#include "mbed.h"#define RED_F 0x01
#define GREEN_F 0x02
#define BLUE_F 0x04DigitalOut led1(LED1);
Thread blinkThr;
Ticker trig2Hz;
Timeout alarm5sec;
InterruptIn SW1(PA_7);
DigitalOut RED(PC_10), GREEN(PC_11), BLUE(PC_12);void blinkCallback() {
while (true) {
uint32_t flags = ThisThread::flags_wait_any(RED_F|GREEN_F|BLUE_F); // รอการกระตุ้น
printf(“0x%8X\n”, flags);
if (flags & RED_F) {
RED = !RED;
}
if (flags & GREEN_F) {
GREEN = !GREEN;
}
if (flags & BLUE_F) {
BLUE = !BLUE;
}
}
}void redBlink() {
blinkThr.flags_set(RED_F); // กระตุ้น LED สีแดง
}
void greenOn() {
blinkThr.flags_set(GREEN_F); // กระตุ้น LED สีเขียว
}
void btnHandler() {
blinkThr.flags_set(BLUE_F); // กระตุ้น LED สีน้ำเงิน
}int main() {
blinkThr.start(&blinkCallback);
trig2Hz.attach(&redBlink, 1.0/4); // ตั้งกระตุ้นทุก 0.25 วินาที
alarm5sec.attach(&greenOn, 5.0); // ตั้งกระตุ้นเมื่อผ่านไป 5 วินาที
SW1.fall(&btnHandler); // ตั้งกระตุ้นเมื่อกดปุ่ม SW1 while (true) {
led1 = !led1;
ThisThread::sleep_for(500);
}
}
แม้ว่าการเขียนโค้ดที่เรียกใช้ Thread API จะมีข้อดีหลายอย่าง เช่น แยกการประมวลผลที่ใช้เวลานานออกเป็นหลายส่วน ลดปริมาณของการทำงานภายในโหมด Handler แต่ผู้พัฒนาควรระวังการทำงานที่ผิดพลาดที่เกิดจากการเข้าถึงตัวแปร global หรือฮาร์ดแวร์ จากหลาย thread ในเวลาใกล้เคียงกันหรือที่เรียกว่าปัญหา race condition การเขียนโค้ดจึงควรศึกษาหลักการของการเขียนฟังก์ชันแบบ thread-safe โดยประยุกต์กลไกเข้าจังหวะของ RTOS API เช่น การใช้เมธอด lock() ของคลาส Mutex เพื่อป้องกันการเข้าใช้ตัวแปร/ฮาร์ดแวร์พร้อมกัน
EventQueue API
แพลตฟอร์ม mbed เตรียม EventQueue API ไว้ชดเชยจุดอ่อนของ Thread API ด้วยการสร้าง thread ที่เรียกว่า Event Loop สำหรับรอ Event ที่ถูกกระตุ้นแล้วประมวลผลฟังก์ชันที่เป็น callback ตามลำดับของ Event ที่ได้รับ การทำงานแบบ thread เดี่ยวที่ประมวลผลตามลำดับทำให้กลไก EventQueue มีความปลอดภัย (thread-safe และ ISR-safe) ทั้งจากการเรียกใช้ด้วย thread อื่นหรือจากส่วนโค้ด interrupt handler อย่างไรก็ตาม EventQueue ไม่มีกลไกที่รองรับการให้ความสำคัญกับ Event จึงไม่เหมาะสำหรับส่วนโค้ดจัดการเหตุวิกฤติ รวมทั้งอาจเกิดปัญหา Queue ล้นในกรณีที่มี Event ถูกกระตุ้นมากเกินไปก่อนที่ฟังก์ชัน callback ล่าสุดจะทำงานเสร็จ
ผมเตรียมตัวอย่างที่สาธิตการใช้ EventQueue API ด้วยการแสดงค่าความเร่ง 3 แกนที่สุ่มวัดจากเซ็นเซอร์ ADXL-335 ซึ่งทำงานร่วมกับโค้ดของ LED อีก 2 ส่วนที่จะกระตุ้น Event เข้าสู่ EventQueue เดียวกัน โค้ดตัวอย่างจะสร้าง Event Loop ภายใน thread ที่เตรียมไว้ จากนั้นจึงกำหนดเงื่อนไขการกระตุ้น Event-Callback ในรูปแบบต่างๆ ได้แก่
- เมธอด EventQueue::call_in() เพื่อหน่วงเวลาในการกระตุ้น Event
- เมธอด EventQueue::call_every() เพื่อกระตุ้น Event ซ้ำๆตามช่วงเวลา
- เมธอด EventQueue::event() เพื่อกระตุ้น Event ทันที โดยสามารถกำหนดเป็น callback ให้กับ interrupt handler (ส่งการทำงานจากโหมด Handler ออกมาเป็นโหมด Thread) หรือภายใน callback อื่น (เรียกแบบเป็นทอด)
#include "mbed.h"
#include "TextLCD.h"EventQueue queue(32 * EVENTS_EVENT_SIZE);
Thread eventThr;
DigitalOut led1(LED1), RED(PC_10), GREEN(PC_11), BLUE(PC_12);
DigitalOut rwPin(PB_1), ctrsPin(PB_8);
TextLCD* lcd;
InterruptIn SW1(PA_7);
AnalogIn X(PA_0), Y(PA_1), Z(PA_4);void greenCallback() {
GREEN = !GREEN;
}
void redCallback() {
RED = 0;
queue.call_every(500, &greenCallback); // กระตุ้นไฟเขียวกระพริบ
}
void lcdCallback(float aX, float aY, float aZ) {
lcd->cls();
lcd->printf(“X%.1fY%.1fZ%.1f”, aX, aY, aZ);
}
void buttonCallback() {
float aX = 0, aY = 0, aZ = 0;
const int SAMPLE_SIZE = 4;
const float VREF = 3.0; // ค่าแรงดันอ้างอิงของ MCU
const float GAIN = 0.3; // sensitivity ของ ADXL-335
const float OFFSET = 1.5; // bias ของ ADXL-335 BLUE = 1;
for (int i = 0; i < SAMPLE_SIZE; i++) { // สุ่มวัดแบบเฉลี่ย
aX += 1.0/SAMPLE_SIZE * X.read();
aY += 1.0/SAMPLE_SIZE * Y.read();
aZ += 1.0/SAMPLE_SIZE * Z.read();
ThisThread::sleep_for(1);
}
aX = (VREF*aX — OFFSET)/GAIN; // แปลงเป็นความเร่งหน่วย g
aY = (VREF*aY — OFFSET)/GAIN;
aZ = (VREF*aZ — OFFSET)/GAIN;
BLUE = 0;
queue.call(&lcdCallback, aX, aY, aZ); // กระตุ้นส่วนแสดงผล
}
int main() {
// สร้าง Event Loop ใน thread
eventThr.start(callback(&queue, &EventQueue::dispatch_forever));
RED = 1;
queue.call_in(5000, &redCallback); // หน่วงเวลาดับไฟแดง
SW1.fall(queue.event(&buttonCallback)); // กดปุ่มวัด
rwPin = 0;
ctrsPin = 1;
lcd = new TextLCD(PB_0, PB_2, PB_4, PB_5, PB_6, PB_7, TextLCD::LCD16x2);
lcd->printf(“Press SW1”);
while (true) {
led1 = !led1; // ส่วน main thread ทำไฟกระพริบ
ThisThread::sleep_for(500);
}
}
โค้ดตัวอย่างแสดงการแยกการสุ่มวัดและแสดงผลออกเป็น 2 ช่วง เพื่อเปิดช่องให้งานอื่นสามารถเข้ามาแทรกได้ด้วยการเข้ากระตุ้น Event มารอใน EventQueue ตัวอย่างของสถานการณ์ที่ต้องแยกการประมวลผลออกเป็นหลายช่วง เช่น การประมวลผลข้อมูลขนาดใหญ่ในขณะที่ต้องรับคำสั่งจากแผงควบคุมหรือคำสั่งจากช่องสื่อสาร ซึ่งการแยกส่วนโค้ดประมวลผลข้อมูลออกเป็นหลายฟังก์ชันที่กระตุ้นแบบต่อเนื่อง (มารอต่อท้ายคิว) จะทำให้ทุกหน้าที่สามารถดำเนินไปได้อย่างราบรื่นตามลำดับ ทั้งนี้ EventQueue API จะใช้ทรัพยากร (หน่วยความจำ RAM) น้อยกว่าการสร้าง thread ให้ทำงานแบบคู่ขนาน เนื่องจาก Event Loop ทำงานอยู่บน thread เดี่ยว รวมทั้งสามารถประยุกต์ main thread ให้ทำงานเป็น Event Loop ได้
ทิ้งท้าย
process และ thread เป็นคำศัพท์ที่ใช้เรียกการทำงานของส่วนโค้ดภายใต้การจัดการของระบบปฏิบัติการระดับคอมพิวเตอร์ โดย process จะอ้างถึงการทำงานโปรแกรมภายใต้การปกป้องของหน่วยจัดการหน่วยความจำ (memory management unit, MMU) ทำให้สถานะทั้งหมดอยู่แยกออกจากโปรแกรมอื่น ในขณะที่ thread จะใช้เรียกส่วนทำงานแบบคู่ขนานภายในกรอบโปรแกรมทำให้ยังเข้าถึงสถานะต่างๆของโปรแกรมได้ ในกรณี mbed ที่ไม่มีการใช้หน่วยจัดการหน่วยความจำ จึงมองได้ว่าการทำงานทั้งหมดคือ โปรแกรมเดียวกัน แต่ละส่วนโค้ดที่ทำงานคู่ขนานกัน จะสามารถเข้าถึงสถานะต่างๆ เช่น การเข้าถึงตัวแปรโกลบอล การเรียกใช้ฟังก์ชัน ได้เท่าเทียมกัน สถานการณ์แบบนี้จึงถูกเรียกว่าเป็นการทำงานแบบ multithreading (เดิมมักเรียกว่า multitasking) ซึ่งเป็นหน้าที่ของนักพัฒนาที่จะเข้าใจปัญหาที่อาจเกิดขึ้นและเขียนโค้ดให้เหมาะสม
อ้างอิง
- “แพลตฟอร์ม mbed 101” ดูออนไลน์ https://medium.com/@vsupacha_90388/แพลตฟอร์ม-mbed-101-f62beed36adb
- “แพลตฟอร์ม mbed 101 + H” ดูออนไลน์ https://medium.com/@vsupacha_90388/แพลตฟอร์ม-mbed-101-h-e07845af1e18
- “Thread API” ดูออนไลน์ https://os.mbed.com/docs/mbed-os/v5.15/apis/thread.html
- “EventQueue API” ดูออนไลน์ https://os.mbed.com/docs/mbed-os/v5.15/apis/eventqueue.html