ชมรมโดรน

10. เวลา: delay กับ millis

ทำไม delay() ทำให้โปรแกรมหยุดนิ่ง และใช้ millis() จับเวลาโดยไม่บล็อก พร้อมตัวอย่างจากโค้ดโดรน

เวลา สรุปก่อนอ่าน

  • delay() หยุดรอ ง่ายแต่ระหว่างรอโปรแกรมทำอย่างอื่นไม่ได้ เหมือนยืนจ้องหม้อต้มน้ำจนเดือด
  • millis() ดูนาฬิกาว่าผ่านไปกี่มิลลิวินาที ไม่หยุดรอ เหมือนตั้งนาฬิกาปลุกแล้วไปทำอย่างอื่นได้
  • ในโค้ดโดรน ภารกิจส่วนใหญ่ใช้ delay เพราะเป็นลำดับตายตัว ใช้ millis() เมื่อต้องทำสองอย่างพร้อมกัน เช่น รอความสูงนิ่งไปพร้อมอ่านปุ่ม

โค้ดโดรนเต็มไปด้วยเรื่องเวลา "บินหน้า 1.5 วินาที" "รอความสูงนิ่ง 0.5 วินาที" "ถือเซอร์โว 1.5 วินาที" Arduino มีเครื่องมือสามอย่างสำหรับเรื่องนี้

ฟังก์ชันหน่วยทำอะไร
delay(ms)มิลลิวินาทีหยุดรอ ทำอย่างอื่นไม่ได้
delayMicroseconds(us)ไมโครวินาทีหยุดรอสั้นๆ (แม่นถึงราว 16,383 µs)
millis()มิลลิวินาทีบอกเวลาที่ผ่านไปตั้งแต่เปิดบอร์ด ไม่หยุดโปรแกรม
micros()ไมโครวินาทีเหมือน millis() แต่ละเอียดกว่า

1. delay(): ง่ายแต่หยุดทุกอย่าง

drone.takeOff();
delay(3000);          // รอ 3 วินาที
drone.setHeight(120);

โค้ดภารกิจของชมรมเกือบทั้งหมดเป็นแบบนี้ และใช้ได้ดี เพราะภารกิจเป็นลำดับตายตัว "ทำอย่างนี้ รอ ทำอย่างนั้น" ตัวอย่างจริง move_forward_cm เองก็คือ delay

// protocol.cpp
void Arduino::move_forward_cm(uint16_t distance_cm, uint8_t speed) {
  set_direction(1, speed);  // 1 = forward
  delay(ms_round(distance_cm * MS_PER_CM_FWD));
  poshold(speed);
}

ระหว่างนั้น (100 cm × 15.46 ms ≈ 1,546 ms) loop() ไม่ทำอะไรอื่น ปุ่มที่กดเข้ามาถูก interrupt เก็บไว้ในตัวแปร แต่ loop() ยังไม่ได้ไปอ่าน

ตัวอย่างชีวิตจริง: ต้มบะหมี่

ตั้งนาฬิกาปลุก 3 นาที (millis) แล้วไปทำอย่างอื่นได้ ต่างจากยืนจ้องหม้อ 3 นาทีโดยไม่ทำอะไร (delay) ผลคือบะหมี่สุกเท่ากัน แต่ถ้ามีโทรศัพท์เข้า คนยืนจ้องหม้อรับสายไม่ได้

2. millis(): จดเวลาแล้วเช็กเอา

หลักการคือ จำเวลาตอนเริ่ม แล้ววนถามว่า "ผ่านไปเท่าไรแล้ว"

เปรียบเทียบ delay กับ millisdelay หยุดทุกอย่างระหว่างรอ millis ให้ loop วนทำงานอื่นแล้วเช็กเวลาไปเรื่อยๆdelay(1000)รอเฉยๆ 1,000 ms · อ่านปุ่มไม่ได้ · เช็กความสูงไม่ได้millis()ทำงานทำงานทำงานทำงานทำงานทำงานทำงานทำงานครบ!ในแต่ละช่องมีสามอย่าง: อ่านปุ่ม · ทำงานอื่น · เช็ก millis() − t0 ≥ 1000เวลา 0 → 1,000 msทั้งสองแบบรอ 1 วินาทีเท่ากัน ต่างกันที่ระหว่างรอทำอะไรอื่นได้ไหม
delay() รอเฉยๆ ตลอด millis() ให้ loop วนทำงานอื่นแล้วเช็กนาฬิกาทุกรอบ ทั้งคู่ครบ 1 วินาทีเท่ากัน
uint32_t t0 = millis();
while (millis() - t0 < 1000) {
  // ทำงานอื่นตรงนี้ได้
}

โค้ดชมรมใช้รูปแบบนี้ทั่วไป ตัวอย่างอยู่ใน DroneEasy::servoHold

// DroneEasy.cpp
void DroneEasy::servoHold(int pin, int pulse_us, uint16_t hold_ms) const {
  const uint32_t t0 = millis();
  while (millis() - t0 < hold_ms) servoPulse50Hz(pin, pulse_us);
}

ถือพัลส์เซอร์โวนาน hold_ms ที่กำหนด และ reach_height_shoot_stable() ใช้ millis() กันค้าง (หมดเวลา 3,500 ms) ดูผังที่หน้า เงื่อนไขและลูป

เขียน millis() - t0 ไม่ใช่ millis() > t0 + 1000

millis() นับต่อเนื่อง 32 บิต เต็มที่ราว 49.7 วัน แล้ววนกลับ 0 การลบกันด้วยชนิด unsigned ให้ผลถูกแม้วนข้ามขอบ ส่วนการบวกแล้วเทียบ อาจผิดที่ขอบนั้น ใช้แบบลบเสมอ

3. กระพริบ LED แบบไม่หยุด

เทียบกับ Blink ในหน้า สเก็ตช์แรก เวอร์ชันนี้กระพริบเท่าเดิมแต่ loop() ว่างทำอย่างอื่นได้ (ทดสอบคอมไพล์สำหรับ Nano แล้ว ใช้ 848 ไบต์)

const unsigned long BLINK_MS = 500;
unsigned long lastToggle = 0;
bool ledOn = false;

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  if (millis() - lastToggle >= BLINK_MS) {
    lastToggle = millis();
    ledOn = !ledOn;
    digitalWrite(LED_BUILTIN, ledOn);
  }
  // ตรงนี้ทำงานอื่นต่อได้ ไม่ต้องรอ
}
  • lastToggle จำเวลาที่สลับครั้งล่าสุด
  • ทุกรอบ loop() เช็กว่าผ่านไป 500 ms หรือยัง ถ้ายังก็ข้ามไป
  • !ledOn คือกลับค่า จริงเป็นเท็จ เท็จเป็นจริง

ในโค้ดชมรม ถ้าอยากให้ loop() เช็กปุ่มทุกรอบแล้วค่อยบินเมื่อถึงเวลา ก็ใช้โครงสร้างแบบนี้ได้ แต่ตอนนี้ภารกิจของชมรมเป็นลำดับตายตัว delay พอแล้ว

4. จะใช้อะไรเมื่อไร

สถานการณ์ใช้
ภารกิจเป็นลำดับ ไม่ต้องทำอย่างอื่นระหว่างรอdelay()
รอให้โดรนทำท่าเสร็จก่อนสั่งต่อdelay()
ต้องอ่านเซนเซอร์หรือปุ่มระหว่างรอmillis()
ต้องมีหมดเวลา กันค้างmillis()
จังหวะละเอียดระดับ µs (พัลส์เซอร์โว)delayMicroseconds()

ลองทำ

  1. อัปโหลดเวอร์ชัน millis() ข้างบน แล้วเพิ่มบรรทัดใน loop() ที่ Serial.println(millis()) สังเกตว่าข้อความพิมพ์ถี่ (ถูกจำกัดด้วยความเร็ว 9600 baud ราว 80 บรรทัดต่อวินาที) ขณะที่ LED ยังกระพริบ 1 Hz
  2. คำนวณว่า move_forward_cm(250) ใช้เวลากี่ ms (คำตอบ: 250 × 15.46 = 3,865 ms)
  3. เขียน LED กระพริบสองดวงที่จังหวะ 300 กับ 700 ms พร้อมกัน โดยไม่ใช้ delay()

พังตรงไหนบ่อย

  • เก็บเวลาใน int ล้นที่ 32,767 ใช้ unsigned long หรือ uint32_t
  • เขียน millis() > t0 + 1000 ผิดตอนวนข้ามขอบ ใช้ millis() - t0 >= 1000
  • ใส่ delay() ใหญ่ในโค้ดที่ต้องตอบสนองเร็ว ปุ่มดูเหมือนไม่ตอบ
  • ลืมอัปเดต lastToggle เงื่อนไขจริงทุกรอบ LED สลับถี่จนมองไม่ทัน
  • delayMicroseconds เกิน 16,383 ไม่แม่น โค้ดชมรมใช้ 20,000 − พัลส์ (ราว 17,500–19,500) ในเซอร์โว ซึ่งเกินเลยขีดนั้น ถ้าต้องการความแม่นให้แยกเป็น delay() + delayMicroseconds() (ดูโค้ดที่คอมเมนต์ไว้ใน Servo.ino)

On this page