มัลติทาสกิ้งบนไมโครคอนโทรลเลอร์

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
วิกินี้เป็นส่วนหนึ่งของรายวิชา 01204223

โปรแกรมควบคุมที่ใช้ในคอมพิวเตอร์แบบฝังตัวนั้นมักต้องการให้มีการทำงานหลายส่วนขนานกันไป เรียกว่าเป็นการทำงานแบบ มัลติทาสกิ้ง (multitasking) อาทิเช่นการตรวจสอบสถานะของแสงเพื่อเปิดปิดไฟในขณะที่ต้องตรวจสอบสถานะการกดปุ่มสวิตช์ไปด้วยในเวลาเดียวกัน หรือการทำไฟกระพริบเป็นจังหวะเพื่อแสดงให้เห็นว่าอุปกรณ์กำลังทำงานในขณะที่ต้องคอยวนตรวจสอบข้อมูลที่มาจากพอร์ท USB เป็นต้น ในสถานการณ์เหล่านี้แม้ว่าแต่ละงานย่อยจะมีการทำงานที่ตรงไปตรงมา แต่การทำงานย่อยหลาย ๆ งานให้เสมือนว่าพร้อมกันบนไมโครคอนโทรลเลอร์ที่มีหน่วยประมวลผลเดียวโดยไม่มีระบบปฏิบัติการคอยช่วยเหลือเป็นเรื่องที่ค่อนข้างซับซ้อน

ตัวอย่างแอพลิเคชัน

พิจารณาตัวอย่างโปรแกรมควบคุม LED สองดวงให้กระพริบเป็นอิสระต่อกันดังนี้

ตัวอย่าง
เขียนเฟิร์มแวร์ที่ทำให้ LED สีเขียวบนบอร์ดพ่วงติด 1 วินาทีและดับ 0.5 วินาทีสลับกันไป ในขณะเดียวกันทำให้ LED สีแดงติด 0.7 วินาทีและดับ 0.3 วินาทีสลับกันไป

จะเห็นว่างานทั้งหมดประกอบด้วยงานย่อยสองงาน ที่ผ่านมานั้นหากใช้เฟรมเวิร์กของ Arduino การทำให้เพียง LED สีเขียวกระพริบตามที่กำหนดทำได้โดยการเขียนโค้ดในฟังก์ชัน loop ลักษณะนี้

// LED สีเขียวติด 1 วินาที ดับ 0.5 วินาที
void loop()
{
  digitalWrite(PIN_PC2, HIGH);
  delay(1000);
  digitalWrite(PIN_PC2, LOW);
  delay(500);
}

ในขณะที่การทำให้ LED สีแดงกระพริบจะใช้โค้ดดังนี้

// LED สีแดงติด 0.7 วินาที ดับ 0.3 วินาที
void loop()
{
  digitalWrite(PIN_PC0, HIGH);
  delay(700);
  digitalWrite(PIN_PC0, LOW);
  delay(300);
}

อย่างไรก็ตาม การให้ LED ทั้งสองดวงกระพริบตามจังหวะของตัวเองขนานกันไปนั้นไม่อาจทำได้โดยการรวมงานทั้งคู่เข้าด้วยกันอย่างตรงไปตรงมาเช่นนี้ได้

// *** โค้ดด้านล่างไม่ได้ทำงานตามที่พึงประสงค์ ***

void taskGreen()
{
  digitalWrite(PIN_PC2, HIGH);
  delay(1000);
  digitalWrite(PIN_PC2, LOW);
  delay(500);
}

void taskRed()
{
  digitalWrite(PIN_PC0, HIGH);
  delay(700);
  digitalWrite(PIN_PC0, LOW);
  delay(300);
}

void loop()
{
  taskGreen();
  taskRed();
}

ทั้งนี้เนื่องจากว่าฟังก์ชัน taskRed() ไม่มีการถูกเรียกจนกว่าฟังก์ชัน taskGreen() จะทำงานเสร็จในหนึ่งรอบซึ่งใช้เวลาทั้งสิ้น 1.5 วินาที ในทำนองเดียวกัน ระหว่างที่ฟังก์ชัน taskRed() ทำงานอยู่นั้นฟังก์ชัน taskGreen() ก็ไม่มีโอกาสได้ทำงานเช่นกัน

เครื่องจักรสถานะ

เวลาที่สูญเสียไปกับฟังก์ชันทั้งคู่ตามตัวอย่างข้างต้นนั้นเกือบทั้งหมดเกิดจากการใช้คำสั่ง delay ซึ่งเป็นการหยุดรอโดยไม่ทำอะไรทั้งสิ้น จึงส่งผลกระทบให้งานอื่นที่ต้องการการประมวลผลหยุดชะงักลงด้วย เราจึงต้องออกแบบทั้งสองฟังก์ชันใหม่เพื่อให้ทำงานเสร็จสิ้นในการเรียกแต่ละครั้งให้เร็วที่สุดเพื่อให้งานอื่นมีโอกาสได้ประมวลผล แนวคิดที่ใช้กันอย่างแพร่หลายคือมองงานแต่ละงานในรูป เครื่องจักรสถานะจำกัด (Finite State Machine) หรือเรียกสั้น ๆ ว่าเครื่องจักรสถานะ ผังภาพด้านล่างแสดงเครื่องจักรสถานะของงานควบคุม LED สีเขียว

ผังภาพเครื่องจักรสถานะของการทำไฟสีเขียวติด 1 วินาที ดับครึ่งวินาที

กลไกการทำงานของงาน (หรือเครื่องจักร) ข้างต้นเป็นดังนี้

  1. เริ่มทำงานโดยเข้าสู่สถานะ ON ซึ่งมีการสั่งให้ LED สีเขียวติด และบันทึกเวลาปัจจุบันเป็นมิลลิวินาทีจากฟังก์ชัน millis() ไว้ในตัวแปร ts (ย่อมาจาก timestamp)
    (หมายเหตุ: ฟังก์ชัน millis() เป็นฟังก์ชันที่เฟรมเวิร์ก Arduino มีให้ใช้สำหรับตรวจสอบว่าไมโครคอนโทรลเลอร์ได้ทำงานมาเป็นระยะเวลานานกี่มิลลิวินาที)
  2. ตรวจสอบเวลาที่อยู่ในสถานะนี้โดยคำนวณค่า millis()-ts เกินค่า 1000 มิลลิวินาที
    • หากยังไม่เกินให้อยู่ในสถานะเดิม
    • หากเกินแล้ว ให้เข้าสู่สถานะ OFF โดยบันทึกค่า ts เป็นเวลาที่เข้าสู่สถานะใหม่ใน และดับ LED สีเขียว
  3. ดำเนินการในลักษณะเดียวกันเมื่ออยู่ในสถานะใหม่

โค้ดเครื่องจักรสถานะ สำหรับ LED สีเขียว

เรานำเครื่องจักรสถานะที่ออกแบบไว้ข้างต้นมาเขียนเป็นโค้ดภาษาซี/Arduino ได้ดังนี้

#include <Practicum.h>

enum State { ON, OFF };

State taskGreen_state;
 
//////////////////////////////////////////////////
void taskGreen()
{
  static uint32_t ts = 0;

  if (taskGreen_state == ON)
  {
    if (millis() - ts >= 1000)
    {
      ts = millis();
      digitalWrite(PIN_PC2, LOW);
      taskGreen_state = OFF;
    }
  }

  else if (taskGreen_state == OFF)
  {
    if (millis() - ts >= 500)
    {
      ts = millis();
      digitalWrite(PIN_PC2, HIGH);
      taskGreen_state = ON;
    }
  }
}

//////////////////////////////////////////////////
void setup()
{
  // ตั้งค่าอินพุท/เอาท์พุทของขาให้เหมาะสม
  pinMode(PIN_PC0, OUTPUT);
  pinMode(PIN_PC1, OUTPUT);
  pinMode(PIN_PC2, OUTPUT);
  pinMode(PIN_PC3, INPUT_PULLUP);
  pinMode(PIN_PC4, INPUT);
  pinMode(PIN_PD3, OUTPUT);

  // กำหนดสถานะเริ่มต้น
  taskGreen_state = ON;
  digitalWrite(PIN_PC2, HIGH);
}

//////////////////////////////////////////////////
void loop()
{
  taskGreen();
}

โปรแกรมข้างต้นสามารถคอมไพล์และรันได้จริง แต่จะมีเพียงงานของการกระพริบ LED สีเขียวเท่านั้น อย่างไรก็ตามโปรแกรมนี้ถูกเขียนในรูปแบบแตกต่างจากโปรแกรมไฟกระพริบที่ผ่าน ๆ มา และมีจุดที่น่าสังเกตหลายจุดดังนี้

  • ฟังก์ชัน taskGreen นั้นทำงานตามที่ได้ออกแบบไว้ในเครื่องจักรสถานะทุกประการ ซึ่งจะเห็นว่าไม่มีการใช้งานคำสั่ง delay จึงทำให้ทำงานเสร็จแทบจะทันทีที่ถูกเรียกในแต่ละครั้ง อันเป็นพฤติกรรมที่เราต้องการในการทำงานแบบมัลติทาสกิ้ง
  • คำสั่ง enum เป็นการกำหนดชนิดข้อมูลแบบใหม่ชื่อ State เพื่อเอาไว้สร้างตัวแปรเก็บสถานะปัจจุบันของเครื่องจักร โดยมีค่าที่เป็นไปได้คือ ON และ OFF
  • ตัวแปร ts ถูกประกาศให้เป็นตัวแปรแบบโลคัล แต่ต้องมีการคงค่าเดิมไว้แม้การทำงานจะออกจากฟังก์ชัน taskGreen() ไปแล้ว จึงต้องมีการระบุคีย์เวิร์ด static เอาไว้

โค้ดที่สมบูรณ์: LED สองสีกระพริบอิสระ

โค้ดข้างต้นนำมาเพิ่มเครื่องจักรสถานะสำหรับ LED สีแดงเข้าไปได้อย่างตรงไปตรงมา ทำให้ได้โค้ดที่สมบูรณ์ดังนี้

#include <Practicum.h>

enum State { ON, OFF };

State taskGreen_state;
State taskRed_state;
 
////////////////////////////////////
// เครื่องจักรสถานะสำหรับ LED สีเขียว
////////////////////////////////////
void taskGreen()
{
  static uint32_t ts = 0;

  if (taskGreen_state == ON)
  {
    if (millis() - ts >= 1000)
    {
      ts = millis();
      digitalWrite(PIN_PC2, LOW);
      taskGreen_state = OFF;
    }
  }

  else if (taskGreen_state == OFF)
  {
    if (millis() - ts >= 500)
    {
      ts = millis();
      digitalWrite(PIN_PC2, HIGH);
      taskGreen_state = ON;
    }
  }
}

////////////////////////////////////
// เครื่องจักรสถานะสำหรับ LED สีแดง
////////////////////////////////////
void taskRed()
{
  static uint32_t ts = 0;

  if (taskRed_state == ON)
  {
    if (millis() - ts >= 700)
    {
      ts = millis();
      digitalWrite(PIN_PC0, LOW);
      taskRed_state = OFF;
    }
  }

  else if (taskRed_state == OFF)
  {
    if (millis() - ts >= 300)
    {
      ts = millis();
      digitalWrite(PIN_PC0, HIGH);
      taskRed_state = ON;
    }
  }
}

//////////////////////////////////////////////
void setup()
{
  // ตั้งค่าอินพุท/เอาท์พุทของขาให้เหมาะสม
  pinMode(PIN_PC0, OUTPUT);
  pinMode(PIN_PC1, OUTPUT);
  pinMode(PIN_PC2, OUTPUT);
  pinMode(PIN_PC3, INPUT_PULLUP);
  pinMode(PIN_PC4, INPUT);
  pinMode(PIN_PD3, OUTPUT);

  // กำหนดสถานะเริ่มต้นให้กับเครื่องจักรสถานะทั้งคู่
  taskGreen_state = ON;
  digitalWrite(PIN_PC2, HIGH);
  taskRed_state = ON;
  digitalWrite(PIN_PC0, HIGH);
}

//////////////////////////////////////////////
void loop()
{
  taskGreen();
  taskRed();
}

การอิมพลิเมนต์เครื่องจักรสถานะด้วยคำสั่ง goto

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

โค้ดดั้งเดิม (แบบใช้ delay) โค้ดที่อิมพลิเมนต์ตามเครื่องจักรสถานะ
void taskGreen()
{
  while (1)
  {
    digitalWrite(PIN_PC2, HIGH);
    delay(1000);
    digitalWrite(PIN_PC2, LOW);
    delay(500);
  }
}
void taskGreen()
{
  static uint32_t ts = 0;

  if (taskGreen_state == ON)
  {
    if (millis() - ts >= 1000)
    {
      ts = millis();
      digitalWrite(PIN_PC2, LOW);
      taskGreen_state = OFF;
    }
  }

  else if (taskGreen_state == OFF)
  {
    if (millis() - ts >= 500)
    {
      ts = millis();
      digitalWrite(PIN_PC2, HIGH);
      taskGreen_state = ON;
    }
  }
}

ความต้องการของการเขียนโค้ดตามแบบด้านขวามือนั้นคือการทำให้ taskGreen ประมวลผลและออกจากฟังก์ชันให้เร็วที่สุดในการเรียกแต่ละครั้ง โดยจดจำสถานะปัจจุบันของตนเอาไว้เพื่อดำเนินการต่อในการถูกเรียกประมวลผลครั้งถัดไป โค้ดที่ได้จึงมีลักษณะเป็นคำสั่ง if หลาย ๆ คำสั่งกระจายอยู่ทั่วไปในฟังก์ชัน และบดบังลำดับการทำงานที่ต้องการจนแทบไม่เหลือเค้าเดิม ส่วนโค้ดทางด้านซ้ายนั้นแม้จะสื่อให้เห็นถึงพฤติกรรมการทำงานได้อย่างชัดเจน แต่การวนลูปแบบไม่รู้จบและหยุดรอของคำสั่ง delay นั้นเป็นสิ่งที่ยอมรับไม่ได้ในการทำงานแบบมัลติทาสกิ้ง

ทางเลือกที่ใช้ข้อดีจากทั้งสองฝ่ายคือการหากลไกที่ทดแทนคำสั่ง delay โดยให้โปรแกรมทำงานต่อในบรรทัดถัดไปเมื่อเวลาผ่านไปครบถ้วนแล้ว และบังคับให้ออกจากฟังก์ชันทันทีหากยังไม่ถึงเวลาหน่วงที่กำหนดแต่ให้บันทึกตำแหน่งปัจจุบันเอาไว้ เพื่อที่ว่าการเรียกฟังก์ชันครั้งถัดไปจะได้กระโดดมาทำงาน ณ จุดที่ออกจากฟังก์ชันไปต่อได้ แนวคิดนี้ทำได้โดยอาศัยคำสั่ง goto ซึ่งมีให้อยู่แล้วในภาษาซี

#include <Practicum.h>

void* taskGreen_state;

//////////////////////////////////////////////////
// เครื่องจักรสถานะสำหรับ LED สีเขียว
//////////////////////////////////////////////////
void taskGreen()
{
  static uint32_t ts;

  // กระโดดไปยังตำแหน่งที่บันทึกเอาไว้ก่อนออกจากฟังก์ชันครั้งล่าสุด (ถ้ามี)
  if (taskGreen_state != NULL) goto *taskGreen_state;

  while (1)
  {
    digitalWrite(PIN_PC2, HIGH);

    // สามบรรทัดนี้ทำงานเทียบเท่ากับการใช้ delay(1000) โดยไม่หยุดรอ
    ts = millis();
ON: taskGreen_state = &&ON;  // จดจำบรรทัดปัจจุบัน
    if (millis()-ts < 1000) return;

    digitalWrite(PIN_PC2, LOW);

    // สามบรรทัดนี้ทำงานเทียบเท่ากับการใช้ delay(500) โดยไม่หยุดรอ
    ts = millis();
OFF:taskGreen_state = &&OFF; // จดจำบรรทัดปัจจุบัน
    if (millis()-ts < 500) return;
  }
}

//////////////////////////////////////////////////
void setup()
{
  pinMode(PIN_PC0, OUTPUT);
  pinMode(PIN_PC1, OUTPUT);
  pinMode(PIN_PC2, OUTPUT);
  pinMode(PIN_PC3, INPUT_PULLUP);
  pinMode(PIN_PC4, INPUT);
  pinMode(PIN_PD3, OUTPUT);

  taskGreen_state = NULL;
}

//////////////////////////////////////////////////
void loop()
{
  taskGreen();
}
หมายเหตุ
  • โค้ดด้านบนคอมไพล์และรันได้จริง
  • การใช้คำสั่ง goto ในรูปแบบด้านบนเรียกว่าการทำ computed goto ซึ่งไม่จัดอยู่ในภาษาซีมาตรฐาน แต่รองรับโดยคอมไพเลอร์ส่วนใหญ่เช่น GCC คำสั่งในภาษาซีมาตรฐานที่มีพฤติกรรมใกล้เคียง computed goto มากที่สุดคือคำสั่ง switch..case
  • แม้ฟังก์ชัน taskGreen จะมีลูปอนันต์อยู่ จะมีการออกจากฟังก์ชันเสมอด้วยคำสั่ง return จึงทำให้ทำงานร่วมกับงานอื่นแบบมัลติทาสกิ้งได้
  • เป็นที่ถกเถียงกันเรื่องความเลวร้ายของการใช้คำสั่ง goto แต่การใช้คำสั่งนี้ในสถานการณ์ที่เหมาะสมจะทำให้โปรแกรมสั้นและทำความเข้าใจได้ง่ายขึ้น

ฟังก์ชัน taskGreen ที่เขียนใหม่นั้นยังสามารถถูกมองในรูปของเครื่องจักรสถานะได้ เพียงแต่สถานะต่าง ๆ นั้นถูกฝังลงไปในจุดต่าง ๆ ของโค้ด ณ ตำแหน่งที่มีการกำกับด้วย ON: และ OFF: ลองเปรียบเทียบโค้ดที่ปรับแก้แล้วกับโค้ดดั้งเดิมและโค้ดที่เขียนขึ้นจากเครื่องจักรสถานะตรง ๆ

โค้ดเครื่องจักรสถานะแบบใช้ goto โค้ดดั้งเดิม (แบบใช้ delay) โค้ดเครื่องจักรสถานะแบบใช้ if
void taskGreen()
{
  static uint32_t ts;

  if (taskGreen_state != NULL)
    goto *taskGreen_state;

  while (1)
  {
    digitalWrite(PIN_PC2, HIGH);

    // เทียบเท่า delay(1000)
    ts = millis();
ON: taskGreen_state = &&ON;
    if (millis()-ts < 1000) return;

    digitalWrite(PIN_PC2, LOW);

    // เทียบเท่า delay(500)
    ts = millis();
OFF:taskGreen_state = &&OFF;
    if (millis()-ts < 500) return;
  }
void taskGreen()
{
  while (1)
  {
    digitalWrite(PIN_PC2, HIGH);
    delay(1000);
    digitalWrite(PIN_PC2, LOW);
    delay(500);
  }
}
void taskGreen()
{
  static uint32_t ts = 0;

  if (taskGreen_state == ON)
  {
    if (millis() - ts >= 1000)
    {
      ts = millis();
      digitalWrite(PIN_PC2, LOW);
      taskGreen_state = OFF;
    }
  }

  if (taskGreen_state == OFF)
  {
    if (millis() - ts >= 500)
    {
      ts = millis();
      digitalWrite(PIN_PC2, HIGH);
      taskGreen_state = ON;
    }
  }
}

จะเห็นว่าโค้ดรูปแบบใหม่ทางด้านซ้ายมือแม้ว่าจะยาวกว่าเดิมก็ตาม แต่ก็มีลักษณะที่คล้ายคลึงกับโค้ดดั้งเดิมในช่องกลางมากขึ้น โดยเฉพาะอย่างยิ่งถ้ามีการรวบ 3 คำสั่งที่คอมเม้นต์ไว้ว่าทำงานเทียบเท่าคำสั่ง delay เอาไว้ให้เป็นคำสั่งเดียวได้จะได้โค้ดภายในลูป while ที่เหมือนกันเกือบทุกประการกับโค้ดเดิม จึงเหลือเพียงการทำให้โค้ดดูง่ายขึ้นโดยการรวมคำสั่งหลาย ๆ คำสั่งให้เสมือนเป็นคำสั่งเดียวโดยใช้ระบบมาโครของภาษาซี ทั้งหมดนี้มีโปรแกรมเมอร์หลายคนได้พัฒนาเอาไว้พร้อมใช้งานในรูปไลบรารีเรียบร้อยแล้ว หนึ่งในนั้นคือไลบรารี Protothreads ดูรายละเอียดการใช้งานไลบรารี Protothreads ได้จากวิกิ มัลติทาสกิ้งด้วยไลบรารี Protothreads