แพลตฟอร์ม mbed 101 + HO

Supachai Vorapojpisut
5 min readMar 17, 2020

--

เนื้อหาตอนที่ 3 นี้จะมาสาธิต API ของ RTOS ที่เป็นจุดเด่นของแพลตฟอร์ม mbed และเป็นคำตอบ (สำหรับผม) ว่าทำไมจึงเลือก mbed มาใช้สอนแทน Arduino ที่ถูกและหาซื้อง่ายกว่า ก่อนจะเข้าส่วนโค้ดตัวอย่างที่สาธิต API คงต้องทำความเข้าใจเกี่ยวกับกลไกการทำงานของหน่วยประมวลผลในเบื้องต้น

การประมวลผลแบบ multithreading

กลไกการประมวลผลรหัสคำสั่งในส่วนโค้ดของ ARM Cortex จะแบ่งออกเป็น 2 โหมดคือ

  1. โหมด Thread เป็นสถานะที่หน่วยประมวลผลทำงานตามลำดับของซอฟต์แวร์ โดยเริ่มต้นในระดับ privileged (เข้าถึงรีจิสเตอร์ฮาร์ดแวร์ได้หมด) หลังจาก reset และเปลี่ยนเข้าสู่ระดับ non-privileged (เข้าถึงรีจิสเตอร์ได้จำกัด) ด้วยการตั้งค่าในฮาร์ดแวร์ ARM ได้ออกแบบให้แบ่งโหมด Thread ออกเป็น 2 ระดับเพื่อแยกระหว่างชั้นระบบปฏิบัติการ (ระดับ privileged) และแอพพลิเคชัน (ระดับ non-privileged)
  2. โหมด 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

โหมดประมวลผลของ ARM Cortex และโค้ด mbed

โค้ดตัวอย่างของปุ่มกดที่สลับสถานะ 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 ในทางปฏิบัติมักจะอยู่ในรูปแบบดังนี้

  1. การทำงานแบบคู่ขนาน (concurrent operation) ส่วนโค้ดจะมีวงรอบอนันต์เหมือนกับโปรแกรมหลัก โดยเรียกใช้ API เพื่อคืนการทำงานให้กับส่วนโค้ดอื่น
  2. การประมวลผลตามเงื่อนไข (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 นี้ได้

API สำหรับเป็นเงื่อนไขการทำงาน

ผมได้เตรียมโค้ดตัวอย่างที่สาธิตการกระตุ้น 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 0x04
DigitalOut 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

กลไก EventQueue ของ mbed

แพลตฟอร์ม 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 ในรูปแบบต่างๆ ได้แก่

  1. เมธอด EventQueue::call_in() เพื่อหน่วงเวลาในการกระตุ้น Event
  2. เมธอด EventQueue::call_every() เพื่อกระตุ้น Event ซ้ำๆตามช่วงเวลา
  3. เมธอด 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) ซึ่งเป็นหน้าที่ของนักพัฒนาที่จะเข้าใจปัญหาที่อาจเกิดขึ้นและเขียนโค้ดให้เหมาะสม

อ้างอิง

  1. “แพลตฟอร์ม mbed 101” ดูออนไลน์ https://medium.com/@vsupacha_90388/แพลตฟอร์ม-mbed-101-f62beed36adb
  2. “แพลตฟอร์ม mbed 101 + H” ดูออนไลน์ https://medium.com/@vsupacha_90388/แพลตฟอร์ม-mbed-101-h-e07845af1e18
  3. “Thread API” ดูออนไลน์ https://os.mbed.com/docs/mbed-os/v5.15/apis/thread.html
  4. “EventQueue API” ดูออนไลน์ https://os.mbed.com/docs/mbed-os/v5.15/apis/eventqueue.html

--

--

Supachai Vorapojpisut
Supachai Vorapojpisut

Written by Supachai Vorapojpisut

Assistant Professor at Thammasat University

No responses yet