ชมรมโดรน

9. C++ ขั้นสูง: interrupt, PROGMEM, บิต

งานที่ฮาร์ดแวร์ทำเองเบื้องหลัง การเก็บข้อมูลใน Flash แทน RAM และการเปิดปิดทีละบิต

เรื่องขั้นสูง สรุปก่อนอ่าน

  • Interrupt ฮาร์ดแวร์สะกิดให้หยุดงานไปรับเรื่องด่วนแล้วกลับมา เหมือนกริ่งหน้าบ้านดังตอนกำลังทำอาหาร
  • PROGMEM เก็บข้อมูลที่ไม่เปลี่ยนไว้ในที่เก็บโปรแกรม (Flash) เพื่อไม่กิน RAM ที่มีน้อย
  • บิต เปิด-ปิดสวิตช์ทีละดวงในเลข 1 ไบต์
  • ข้ามได้ ไม่ต้องรู้เรื่องพวกนี้ก็สั่งโดรนได้ กลับมาอ่านตอนติดปัญหา RAM หรือจังหวะเวลา

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

1. Interrupt: ให้ฮาร์ดแวร์เรียกเรา

ปกติ loop() ทำทีละบรรทัดตามลำดับ ถ้า FC ส่งปุ่มมาระหว่างที่ loop() กำลัง delay(3000) จะรับทันไหม คำตอบคือรับทัน เพราะรับด้วย interrupt ฮาร์ดแวร์หยุดงานปัจจุบัน ไปรันฟังก์ชันที่ลงทะเบียนไว้ แล้วกลับมาทำต่อจากเดิม

Interrupt หยุดงานหลักไปทำ ISR แล้วกลับมาloop ทำงานต่อเนื่อง เมื่อมีเหตุการณ์เช่น FC ส่งไบต์เข้ามา ฮาร์ดแวร์หยุด loop ไปรัน receiveEvent แล้วกลับloop() ทำงานปกติloop() ทำต่อจากเดิมISRสั้น เร็วเหตุการณ์: FC ส่งปุ่มมาที่ 0x33Wire.onReceive(receiveEvent) ผูกฟังก์ชันไว้ล่วงหน้า
loop() ถูกขัดจังหวะตอนมีเหตุการณ์ ไปรัน ISR สั้นๆ แล้วกลับมาทำต่อจุดเดิม ISR คือ Interrupt Service Routine

โค้ดชมรมใช้อยู่แล้วโดยไม่รู้ตัว

// protocol.cpp
Wire.onRequest(requestEvent);
Wire.onReceive(receiveEvent);

void Arduino::receiveEvent(int howMany) {
  while (1 < Wire.available()) {
    char c = Wire.read();
  }
  int x = Wire.read();
  m_remoteKey = x;
}

receiveEvent ไม่ได้ถูกเรียกจาก loop() ไหนเลย ฮาร์ดแวร์ I2C เรียกเองเมื่อไบต์มาถึง ผลคือ m_remoteKey เปลี่ยนแม้ loop() กำลังรอ แล้ว keyDo ค่อยอ่านค่านั้นตอนวนมาถึง

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

คุณนั่งทำการบ้าน (loop) ไม่ได้เดินไปเปิดประตูทุก 10 วินาที เมื่อกริ่งดัง (interrupt) คุณหยุด ไปรับพัสดุ (ISR) แล้วกลับมาทำการบ้านต่อ

กฎของ ISR

  • สั้นและเร็ว ห้าม delay() ใน ISR ตั้งธงแล้วออก
  • ตัวแปรที่ ISR แก้ ต้องประกาศ volatile เพื่อบอกคอมไพเลอร์ว่าค่าเปลี่ยนได้นอกสายตา

ตัวอย่างปุ่มบนขา 2 (ทดสอบคอมไพล์สำหรับ Nano แล้ว)

volatile bool buttonPressed = false;

void onButtonPress() {
  buttonPressed = true;
}

void setup() {
  Serial.begin(9600);
  pinMode(2, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(2), onButtonPress, FALLING);
}

void loop() {
  if (buttonPressed) {
    buttonPressed = false;
    Serial.println("pressed");
  }
}

Nano มีขา interrupt ภายนอกแค่ขา 2 และ 3 (FALLING = ขาเปลี่ยนจากสูงเป็นต่ำ) หมายเหตุ ขา 3 คือ PORT4 ของโดรนที่ใช้ยิง จึงไม่ควรเอาไปใช้กับปุ่ม

ปิด interrupt ชั่วคราว

พัลส์เซอร์โวต้องการจังหวะแม่น ถ้ามี interrupt แทรกกลางพัลส์ ความกว้างจะยืดและเซอร์โวสั่น ชมรมจึงปิดชั่วขณะ

// shooting.ino
noInterrupts();
digitalWrite(pin, HIGH);
delayMicroseconds(pulse_us);
digitalWrite(pin, LOW);
interrupts();

ปิดนานเกินจะพลาดข้อมูล I2C ที่เข้ามา จึงปิดเฉพาะช่วงพัลส์ที่ยาวไม่เกิน 2.4 ms แล้วเปิดคืนก่อนรอช่วงต่ำ

2. PROGMEM: เก็บข้อมูลใน Flash

Nano มี Flash 32 KB ที่เก็บโปรแกรม (ใช้ได้จริง 30,720 ไบต์ เพราะ bootloader กินส่วนหนึ่ง) แต่ RAM แค่ 2 KB ข้อมูลที่ไม่เปลี่ยนค่า เช่น ตารางเลข หรือข้อความยาว ควรเก็บใน Flash เพื่อไม่ให้ RAM เต็ม

#include <avr/pgmspace.h>

const int pulseTable[] PROGMEM = {600, 1150, 1500, 1900, 2400};

int p = pgm_read_word(&pulseTable[2]);   // ได้ 1500
  • PROGMEM บอกให้ใส่ตารางใน Flash
  • ต้องอ่านกลับด้วย pgm_read_word (สำหรับ 2 ไบต์) ใช้ pulseTable[2] ตรงๆ จะได้ค่ามั่ว เพราะ Flash กับ RAM เป็นคนละที่ที่ชิปอ่านด้วยคำสั่งต่างกัน
  • สำหรับข้อความใช้ F("...") ได้ เช่น Serial.println(F("hello")) ไม่กิน RAM

เมื่อไหร่ควรใช้: ตอนที่ขึ้น Low memory available หรือ Global variables use ... (80%) ตอนนี้โปรเจกต์ชมรมใช้ RAM 389 จาก 2,048 ไบต์ (18%) ยังไม่ต้องใช้ ตารางพัลส์ในโค้ดจริงใช้ const int ธรรมดา

3. บิต: ตัวเลขคือแถวของสวิตช์

ไบต์หนึ่งมี 8 บิต แต่ละบิตเป็น 0 หรือ 1 เหมือนสวิตช์ 8 ตัว ใช้เก็บสถานะหลายอย่างในไบต์เดียว หรือคุมขาของชิปโดยตรง

การเปิด ปิด สลับบิตด้วย OR, AND และ XORreg เริ่มเป็น 00000000 เปิดบิต 3 ได้ 00001000 ปิดคืนได้ 00000000 สลับบิตได้ 0000100076543210เลขบิต →reg00000000reg |= (1<<3)00001000reg &= ~(1<<3)00000000reg ^= (1<<3)000010001<<3 = เลข 1 เลื่อนไป 3 ตำแหน่ง = 00001000 (บิตที่ 3 = เลข 8)
เริ่มจาก 00000000: OR เปิดบิต 3, AND กับ NOT ปิดบิต 3, XOR สลับบิต 3 ช่องแดงคือบิตที่เปลี่ยน
byte reg = 0b00000000;
reg |= (1 << 3);    // เปิดบิต 3  → 00001000
reg &= ~(1 << 3);   // ปิดบิต 3   → 00000000
reg ^= (1 << 3);    // สลับบิต 3  → 00001000
if (reg & (1 << 3)) { /* บิต 3 เปิดอยู่ */ }
  • 1 << 3 เลื่อนเลข 1 ไปทางซ้าย 3 ตำแหน่ง ได้ 00001000 (เท่ากับ 8)
  • |= OR: เปิด
  • &= ~ AND กับค่ากลับบิต: ปิด
  • ^= XOR: สลับ

โค้ดชมรมใช้บิตจริงที่ตำแหน่งนี้ ตอนแตกเลข 16 บิตเป็นสองไบต์ในบัฟเฟอร์ I2C

// protocol.cpp
m_i2cData[4] = (angle >> 8);        // ไบต์สูง = เลื่อนขวา 8 บิต
// protocol.cpp
return (uint16_t)m_externData[0] | ((uint16_t)m_externData[1] << 8);   // ประกอบกลับ

ถ้าชมรมจะเขียนขาโดยตรงแทน digitalWrite ก็ทำได้ เช่น ขา 13 บน Nano คือบิต 5 ของ PORTB (PORTB |= (1 << 5);) เร็วกว่ามาก แต่ความเร็วระดับนั้นยังไม่จำเป็นกับเซอร์โว

ลองทำ

  1. ใส่ 0b00001010 ลงตัวแปร reg แล้วเปิดบิต 0 ด้วย |= ได้ค่าอะไร (คำตอบ: 00001011)
  2. คำนวณว่า 1500 >> 8 ได้เท่าไร และ 1500 & 0xFF ได้เท่าไร (คำตอบ: 5 และ 220, เพราะ 1500 = 5×256 + 220)
  3. ใน loop() เพิ่ม Serial.println(Arduino::m_remoteKey); ชั่วคราว (ตัวแปรนี้เป็น public static) กดปุ่มรีโมทแล้วดูเลขปุ่มที่ไหลมาจาก interrupt

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

  • ISR มี delay() หรือ Serial.print ยาว ค้างหรือพลาดเหตุการณ์ ตั้งธงแล้วออก
  • ลืม volatile loop() ไม่เห็นว่าค่าเปลี่ยน เพราะคอมไพเลอร์เก็บค่าเก่าไว้
  • อ่าน PROGMEM ตรงๆ ได้ค่ามั่ว ต้อง pgm_read_*
  • ปิด interrupt แล้วลืมเปิด millis() หยุดเดิน โปรแกรมค้าง
  • สลับ | กับ || | ทำกับบิต || ทำกับเงื่อนไข ผลต่างกันมาก

On this page