การเขียนโปรแกรมแบบ Multi-Tasking: TinyGo Goroutines vs. FreeRTOS#

Keywords: Go / TinyGo, Microcontroller Programming, Raspberry Pico, RP2040, FreeRTOS, Arduino Core for RP2040


▷ การเขียนโปรแกรมแบบ Multi-tasking#

บทความนี้นำเสนอตัวอย่างการเขียนโค้ดแบบ "มัลติทาสก์" (Multi-Tasking) โดยใช้ Goroutines และ Channels ในภาษา Go และคอมไพล์โค้ดด้วย TinyGo Compiler (ใช้เวอร์ชัน v0.35.0) เพื่อนำมาเปรียบเทียบกับการเขียนโค้ด Arduino Sketch โดยใช้ FreeRTOS สำหรับชิปไมโครคอนโทรลเลอร์ RP2040

เราจะใช้รูปแบบการทำงานของโปรแกรมสำหรับไมโครคอนโทรลเลอร์ โดยแบ่งออกเป็น 2 งานย่อย หรือที่เรียกว่า ทาสก์ (Tasks) ซึ่งมีความสำคัญเท่ากัน ทั้งสองทาสก์มีหน้าที่สลับสถานะลอจิกของขา GPIO ที่ทำหน้าที่เป็นเอาต์พุตของชิป RP2040 โดยไม่มีการหน่วงเวลา อุปกรณ์ออสซิลโลสโคปแบบดิจิทัลจะถูกนำมาใช้เพื่อวัดสัญญาณเอาต์พุตที่ขา GPIO และใช้ในการวิเคราะห์พฤติกรรมการทำงานของโปรแกรมบนฮาร์ดแวร์จริง

การทำงานแบบ Multi-tasking สำหรับภาษา Go ใช้ฟังก์ชันประเภทที่เรียกว่า Goroutines อย่างไรก็ตาม หากเขียนโค้ดด้วย Arduino Core for RP2040 (ทดลองใช้เวอร์ชัน v4.3.0) ก็อาจใช้ FreeRTOS port for RP2040 ซึ่งเป็นระบบปฏิบัติการเวลาจริง (RTOS: Real-Time Operating System) หรือที่เรียกว่า Real-Time Kernel โดยนำมาสร้างและใช้งานสิ่งที่เรียกว่า FreeRTOS Tasks

รูป: โมเดลการทำงานแบบมัลติทาสก์สำหรับการทดสอบโดยใช้ TinyGo Goroutines + Go Channels

รูป: โมเดลการทำงานแบบมัลติทาสก์สำหรับการทดสอบโดยใช้ FreeRTOS Tasks + Binary Semaphores


▷ การเขียนโปรแกรมสำหรับ TinyGo#

เริ่มต้นด้วยโค้ดตัวอย่างสำหรับ TinyGo โดยเลือกใช้ขา GPIO15 และ GPIO14 เป็นขาเอาต์พุต และอ้างอิงโดยใช้ชื่อตัวแปรเป็น A และ B ตามลำดับ มีการสร้างฟังก์ชัน toggleLED(...) เพื่อใช้เป็นฟังก์ชัน Goroutine

func toggleLED(led machine.Pin, chanIn, chanOut chan bool)

  • led มีชนิดข้อมูลเป็น machine.Pin และเป็นขา GPIO ที่จะใช้เป็นเอาต์พุต
  • chanIn และ chanOut เป็น Go Channel ใช้เป็นช่องทางสำหรับอ่าน (ช่องสัญญาณเข้า) และช่องทางสำหรับเขียน (ข่องสัญญาณออก) ข้อมูลแบบ bool ตามลำดับ

ในฟังก์ชัน toggleLED() มีการใช้คำสั่ง for { ... } ภายในมีการใช้คำสั่งแรก <-chanIn เพื่อรออ่านค่าแบบ bool จากช่อง chanIn ถ้ายังไม่มีข้อมูล การฟังก์ชันนี้จะถูกหยุดไว้ชั่วคราวจนกว่าจะได้ระบข้อมูลเข้ามา แต่ถ้าได้รับข้อมูลแล้วจึงสลับสถานะของลอจิกที่ขาเอาต์พุต และส่งข้อมูล true ไปออกทาง chanOut การส่งข้อมูลออกทาง chanOut จะทำให้ฟังก์ชัน Goroutine ถัดไปที่รอรับข้อมูลอยู่ เปลี่ยนสถานะเป็นพร้อมทำงาน

  for {
      <-chanIn            // Wait for the next token
      led.Set(!led.Get()) // Toggle the LED output
      chanOut <- true     // Send a token
  }

การทำงานของฟังก์ชัน toggleLED() จะต้องอ่านข้อมูลจาก chanIn ถ้าไม่มีข้อมูลเข้ามา จะต้องรอ และเมื่ออ่านข้อมูลได้หนึ่งครั้งแล้ว จึงจะสลับสถานะลอจิกของขา led หนึ่งครั้ง แล้วจึงเขียนข้อมูลไปยัง chanOut

คำสั่งต่อไปนี้เป็นการสร้างช่องสัญญาณ Go Channel จำนวน 2 ช่อง ดังนี้

  chan1 := make(chan bool)
  chan2 := make(chan bool)

การเรียกฟังก์ชัน toggleLED() เพื่อใช้งานแบบ Goroutine จะมีคำว่า go นำหน้า ดังนี้

  go toggleLED(A, chan1, chan2)
  go toggleLED(B, chan2, chan1)

ฟังก์ชันแรกใช้กับขา A อ่านข้อมูลจาก chan1 และเขียนข้อมูลไปยัง chan2 ในขณะที่ ฟังก์ชันที่สองใช้กับขา B อ่านข้อมูลจาก chan2 และเขียนข้อมูลไปยัง chan1

เริ่มต้นจะมีการเขียนข้อมูลไปยัง chan1 ดังนั้นฟังก์ชัน Goroutine หมายเลข 1 สำหรับขา A จะเริ่มทำงานก่อน

package main

import (
    "machine"
    "time"
)

var (
    A = machine.Pin(15) // LED 1 on GPIO 15
    B = machine.Pin(14) // LED 2 on GPIO 14
)

func toggleLED(led machine.Pin, chanIn, chanOut chan bool) {
    for {
        <-chanIn            // Wait for the next token
        led.Set(!led.Get()) // Toggle the LED output
        chanOut <- true     // Send a token
    }
}

func main() {
    println("TinyGo RP2040 Demo...")
    time.Sleep(time.Second)
    println("CPU freq. (MHz):", machine.CPUFrequency()/1e6) // 125MHz

    // Initialize the LED pins
    A.Configure(machine.PinConfig{Mode: machine.PinOutput})
    B.Configure(machine.PinConfig{Mode: machine.PinOutput})

    // Create two channels for goroutine synchronization
    chan1 := make(chan bool) 
    chan2 := make(chan bool)

    // Start the goroutines to toggle LEDs
    go toggleLED(A, chan1, chan2)  // Goroutine (1) for pin A
    go toggleLED(B, chan2, chan1) // Goroutine (2) for pin B

    // Initial communication to start the first LED
    chan1 <- true // Send the first token
    select {}     // Wait forever
}

รูป: ตัวอย่างการเขียนโค้ดโดยใช้ VS Code IDE + TinyGo Compiler

ตัวอย่างรูปคลื่นสัญญาณ 2 ช่อง จากขา GPIO15 และ GPIO14 ที่วัดได้ด้วยออสซิลโลสโคป เป็นดังนี้

รูป: สัญญาณเอาต์พุตที่ได้จากการทำงานของ Goroutines

จากรูปจะเห็นได้ว่า เมื่อสัญญาณแรกมีการเปลี่ยนสถานะลอจิกหนึ่งครั้ง ถัดไปสัญญาณที่สองเกิดการเปลี่ยนสถานะลอจิกหนึ่งครั้ง โดยมีระยะเวลาห่างกันประมาณ 4 ไมโครวินาที การทำงานของ TinyGo Runtime จะเป็นตัวจัดลำดับการทำงานของ Goroutines ให้สลับการทำงาน


▷ การเขียนโปรแกรม FreeRTOS: Binary Semaphores#

ตัวอย่างถัดไปเป็นการเขียนโค้ดด้วย Arduino Core for RP2040 ซึ่งจะมีการสร้าง FreeRTOS Tasks จำนวน 2 ทาสก์ ที่มีความสำคัญเท่ากัน และการสื่อสารกันระหว่างทาสก์ จะใช้วิธีที่เรียกว่า Binary Semaphores และจะใช้เพื่อสื่อสารจากทากส์หนึ่งไปยังอีกทาสก์หนึ่งในแต่ละทิศทาง และมีทาสก์แรกที่ถูกสร้างก่อน เป็นฝ่ายเริ่มต้นทำงานก่อน

#include <Arduino.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

// LED Pins
#define LED1_PIN 15
#define LED2_PIN 14

// FreeRTOS Semaphore Handles for synchronization
SemaphoreHandle_t semaphore1, semaphore2;

// Struct to hold LED pin and semaphore handles
struct TaskParams {
  int ledPin;
  SemaphoreHandle_t semIn, semOut;
};

// Task to toggle an LED
void toggleLED(void *pvParameters) {
  // Cast the parameter back to a pointer to TaskParams
  TaskParams *p = (TaskParams *)pvParameters;
  int ledPin = p->ledPin;
  for (;;) {
    // Wait for the semaphore
    if (xSemaphoreTake(p->semIn, portMAX_DELAY) == pdTRUE) {
      digitalWrite(ledPin, !digitalRead(ledPin)); // Toggle the LED
      xSemaphoreGive(p->semOut); // Give the semaphore to the next task
    }
  }
}

void setup() {
  // Initialize LED pins
  pinMode(LED1_PIN, OUTPUT);
  pinMode(LED2_PIN, OUTPUT);

  // Create the binary semaphores
  semaphore1 = xSemaphoreCreateBinary();
  semaphore2 = xSemaphoreCreateBinary();

  // Define struct instances for task parameters
  TaskParams params1 = {LED1_PIN, semaphore1, semaphore2};
  TaskParams params2 = {LED2_PIN, semaphore2, semaphore1};

  // Create FreeRTOS tasks with struct as the argument
  xTaskCreate(toggleLED, "Toggle LED1", 256, (void *)&params1, 1, NULL);
  xTaskCreate(toggleLED, "Toggle LED2", 256, (void *)&params2, 1, NULL);
  xSemaphoreGive(semaphore1);

  vTaskStartScheduler(); // Start the FreeRTOS scheduler
}

void loop() {
}

รูป: การเขียนโค้ดด้วย Arduino IDE และตัวอย่างโค้ดที่ใช้ FreeRTOS Binary Semaphores

ตัวอย่างรูปคลื่นสัญญาณ 2 ช่อง จากขา GPIO15 และ GPIO14 ที่วัดได้ด้วยออสซิลโลสโคป เป็นดังนี้

รูป: สัญญาณเอาต์พุตที่ได้จากการทำงานของ FreeRTOS Tasks

จะเห็นได้ว่า รูปคลื่นสัญญาณทั้งสองคล้ายกับกรณีของ TinyGo กล่าวคือ เมื่อสัญญาณแรกมีการเปลี่ยนสถานะลอจิกหนึ่งครั้ง ก็จะสลับไปเกิดการเปลี่ยนสถานะลอจิกหนึ่งครั้งในสัญญาณที่สอง แต่ให้สังเกตว่า ระยะเวลาจากขอบขึ้นของสัญญาณแรกไปยังขอบขาขึ้นของสัญญาณที่สอง จะใช้เวลามากกว่ากรณีของ TinyGo

 


▷ การเขียนโปรแกรม FreeRTOS: Queues#

ตัวอย่างถัดไปเป็นการเปลี่ยนจาก Binary Semaphores มาเป็น FreeRTOS Queues เพื่อใช้เป็นช่องทางการอ่านและเขียนข้อมูล คล้ายกับกรณีของ TinyGo ที่มีการใช้ Go Channels อ่านข้อมูลเข้าและส่งข้อมูลออกแยกกัน

#include <Arduino.h>
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h" 

// LED Pins
#define LED1_PIN 15
#define LED2_PIN 14

// FreeRTOS Queue Handles for communication between tasks
QueueHandle_t queue1, queue2;

// Struct to hold LED pin and queue handles
struct TaskParams {
  int ledPin;
  QueueHandle_t queueIn, queueOut;
};

// Task to toggle an LED
void toggleLED(void *pvParameters) {
  // Cast the parameter back to a pointer to TaskParams
  TaskParams *p = (TaskParams *)pvParameters;
  bool token;
  int ledPin = p->ledPin;
  for (;;) {
    if (xQueueReceive(p->queueIn, &token, portMAX_DELAY)==pdTRUE) {
#if 0
      digitalWrite(ledPin, !digitalRead(ledPin)); // Toggle the LED 
#else
      gpio_put(ledPin,!gpio_get(ledPin)); // Toggle the LED
#endif
      xQueueSend(p->queueOut, &token, portMAX_DELAY);
    }
  }
}

void setup() {
  // Initialize LED pins
#if 0  
  pinMode(LED1_PIN, OUTPUT);
  pinMode(LED2_PIN, OUTPUT);
#else
  gpio_init(LED1_PIN);
  gpio_set_dir(LED1_PIN, GPIO_OUT);
  gpio_init(LED2_PIN);
  gpio_set_dir(LED2_PIN, GPIO_OUT);
#endif

  // Create the queues for inter-task communication
  queue1 = xQueueCreate(1, sizeof(bool));
  queue2 = xQueueCreate(1, sizeof(bool));

  // Define struct instances for task parameters
  TaskParams params1 = {LED1_PIN, queue1, queue2};
  TaskParams params2 = {LED2_PIN, queue2, queue1};

  // Create FreeRTOS tasks with struct as the argument
  xTaskCreate(toggleLED, "Toggle LED1", 256, (void *)&params1, 1, NULL);
  xTaskCreate(toggleLED, "Toggle LED2", 256, (void *)&params2, 1, NULL);

  bool startToken = true;
  xQueueSend(queue1, &startToken, portMAX_DELAY); // Send the start token

  vTaskStartScheduler(); // Start the FreeRTOS scheduler
}

void loop() {
}

รูป: การเขียนโค้ดด้วย Arduino IDE และตัวอย่างโค้ดที่ใช้ FreeRTOS Queues

ตัวอย่างรูปคลื่นสัญญาณ 2 ช่อง จากขา GPIO15 และ GPIO14 ที่วัดได้ด้วยออสซิลโลสโคป เป็นดังนี้

รูป: สัญญาณเอาต์พุตที่ได้จากการทำงานของ FreeRTOS Tasks


▷ การเขียนโปรแกรม FreeRTOS: Task Notification#

ตัวอย่างถัดไปเป็นการใช้วิธีที่เรียกว่า Task Notification ที่รองรับการใช้งานโดย FreeRTOS Kernel สำหรับการสื่อสารกันระหว่างทาสก์ และมีตัวอย่างการเขียนโค้ดดังนี้

#include <Arduino.h>
#include "FreeRTOS.h"
#include "task.h"

// LED Pins
#define LED1_PIN 15
#define LED2_PIN 14

// Struct to hold LED pin and task handle
struct TaskParams {
  int ledPin;
  TaskHandle_t nextTask; // Handle of the next task to notify
};

// Task handles
TaskHandle_t task1Handle, task2Handle;

// Task to toggle an LED
void toggleLED(void *pvParameters) {
  TaskParams *p = (TaskParams *)pvParameters;
  int ledPin = p->ledPin;
  for (;;) {
    ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // Wait for a notification
    digitalWrite(ledPin, !digitalRead(ledPin)); // Toggle the LED
    xTaskNotifyGive(p->nextTask); // Notify the next task
  }
}

void setup() {
  // Initialize LED pins
  pinMode(LED1_PIN, OUTPUT);
  pinMode(LED2_PIN, OUTPUT);

  // Define struct instances for task parameters
  TaskParams params1 = {LED1_PIN, NULL};
  TaskParams params2 = {LED2_PIN, NULL};

  // Create FreeRTOS tasks with struct as the argument
  xTaskCreate(toggleLED, "Toggle LED1", 256, (void *)&params1, 1, &task1Handle);
  xTaskCreate(toggleLED, "Toggle LED2", 256, (void *)&params2, 1, &task2Handle);

  // Set the `nextTask` handles in the parameters
  params1.nextTask = task2Handle;
  params2.nextTask = task1Handle;

  xTaskNotifyGive(task1Handle); // Notify the first task

  vTaskStartScheduler(); // Start FreeRTOS scheduler
}

void loop() {
}

รูป: การเขียนโค้ดด้วย Arduino IDE และตัวอย่างโค้ดที่ใช้ FreeRTOS Task Notification

ตัวอย่างรูปคลื่นสัญญาณ 2 ช่อง จากขา GPIO15 และ GPIO14 ที่วัดได้ด้วยออสซิลโลสโคป เป็นดังนี้

รูป: สัญญาณเอาต์พุตที่ได้จากการทำงานของ FreeRTOS Tasks


กล่าวสรุป#

บทความนี้ได้นำเสนอตัวอย่างการเขียนโค้ด TinyGo เปรียบเทียบกับการเขียนโค้ดด้วย Arduino Sketch โดยใช้ Arduino Core for RP2040 + FreeRTOS Kernel เพื่อสาธิตการสร้างและทำงานแบบมัลติทาสก์ (Multi-tasking)

Goroutines ในภาษา Go สามารถนำมาใช้ในการเขียนโค้ดแบบ Multi-tasking ได้สำหรับไมโครคอนโทรลเลอร์ แต่ก็มีข้อดีและข้อเสียแตกต่างจากการใช้ FreeRTOS เช่น การทำงานของ Goroutine จะทำต่อเนื่องจนกว่าจะถึงคำสั่งที่จำเป็นต้องหยุดรอเงื่อนไข หรือ รอเวลา เช่น time.Sleep() จึงจะมีการเปลี่ยนการทำงานไปยัง Goroutine ในลำดับถัดไปที่พร้อมจะทำงานต่อ แต่ละฟังก์ชัน Goroutine ทำงานเป็นหนึ่งทาสก์ และทาสก์เหล่านี้มีระดับความสำคัญเท่ากัน ซึ่งจะแตกต่างจากการทำงานของระบบที่ใช้ Preemptive Scheduling ในระบบประเภทนี้ ทาสก์สามารถมีระดับความสำคัญแตกต่างกันได้ ทาสก์ที่มีความสำคัญสูงกว่าและพร้อมที่จะทำงาน จะแทรกแซงการทำงานของทาสก์ที่กำลังทำงานในขณะนั้นและมีความสำคัญต่ำกว่าได้

จากโค้ดตัวอย่างและการวัดสัญญาณเอาต์พุตจริง จะเห็นได้ว่า โค้ดภาษา Go ที่ใช้ Goroutines จะสร้างสัญญาณเอาต์พุตที่มีความถี่ได้สูงกว่า การเขียนโค้ดด้วย Arduino Sketch และใช้ FreeRTOS Tasks


This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

Created: 2024-12-23 | Last Updated: 2024-12-24