ชมรมโดรน

2. สาย I2C คุยกับ Flight Controller

แอดเดรส 0x33 กับ 0x34, บัฟเฟอร์ 6 ไบต์ที่ส่งคำสั่ง และไลบรารี Wire

Nano ไม่ได้ต่อสายตรงเข้ามอเตอร์ มันคุยกับ Flight Controller (FC) ผ่านสายสองเส้นที่เรียกว่า I2C (อ่านว่า ไอ-สแควร์-ซี) ทุกคำสั่งที่คุณเขียน เช่น take_off() หรือ set_height(120) สุดท้ายกลายเป็นไบต์ไม่กี่ตัววิ่งบนสายคู่นี้

สองเส้น สองหน้าที่

  • SDA สายข้อมูล บิตวิ่งไปทีละตัว
  • SCL สายจังหวะ ทุกครั้งที่สายนี้กระดิก ฝั่งรับอ่านบิตหนึ่งตัว

อุปกรณ์หลายตัวใช้สายคู่เดียวกันได้ จึงต้องมี แอดเดรส (address) เหมือนเลขห้อง ในโค้ดของชมรมมีสองเลข

อุปกรณ์แอดเดรสดูได้ที่
Arduino Nano0x33Wire.begin(0x33) ใน protocol.cpp
Flight Controller0x34const uint8_t FC_ADDR = 0x34; ใน protocol.cpp

เลขขึ้นต้น 0x คือเลขฐานสิบหก 0x33 = 51 และ 0x34 = 52

บัส I2C ระหว่าง Arduino Nano กับ Flight Controllerสองเส้น SDA และ SCL ต่อร่วมกัน Nano มีแอดเดรส 0x33 และ FC มีแอดเดรส 0x34Arduino Nano0x33Wire.begin(0x33)Flight Controller0x34requestFrom(0x34, 8)SDA (ข้อมูล)SCL (จังหวะนาฬิกา)บนบอร์ด Nano ขา SDA = A4, SCL = A5
Nano กับ FC ใช้สาย SDA และ SCL คู่เดียวกัน แยกกันด้วยแอดเดรส

ตัวอย่างชีวิตจริง: ตึกเดียว หลายห้อง

I2C เหมือนโทรศัพท์ภายในของโรงเรียน สายหลักเส้นเดียวต่อทุกห้อง แต่ละห้องมีเลขภายใน ถ้าห้องธุรการต้องการคุยกับห้องการเงิน ก็กดเลขห้องนั้น ห้องอื่นไม่ต้องสนใจ

Nano เป็น "ผู้รับ" ไม่ใช่ "ผู้ถาม"

ใน I2C ฝั่งหนึ่งเป็นผู้คุม (master) อีกฝั่งเป็นผู้ตาม (slave) บนโดรนนี้ FC เป็นผู้คุม Nano ประกาศตัวเป็นผู้ตามที่แอดเดรส 0x33 แล้วรอให้ FC มาเก็บคำสั่ง นี่คือคอนสตรักเตอร์ของคลาส Arduino

// protocol.cpp
Arduino::Arduino() {
  //配置IIC读写
  Wire.begin(0x33);
  Wire.onRequest(requestEvent);  // register event
  Wire.onReceive(receiveEvent);
}
  • Wire.begin(0x33) เข้าร่วมบัสในฐานะผู้ตามที่มีแอดเดรส 0x33
  • Wire.onRequest(...) ลงทะเบียนฟังก์ชันที่จะถูกเรียกเมื่อ FC ขอข้อมูล
  • Wire.onReceive(...) ลงทะเบียนฟังก์ชันที่จะถูกเรียกเมื่อ FC ส่งข้อมูลมา

ฟังก์ชันสองตัวนี้ถูกเรียกเองโดยฮาร์ดแวร์ ไม่ต้องมี loop() ไปเรียก (หลักการนี้คือ interrupt ดูที่ ขั้นสูง)

// protocol.cpp
void Arduino::requestEvent(void) {
  Wire.write(m_i2cData);
}
void Arduino::receiveEvent(int howMany) {
  while (1 < Wire.available()) {  // loop through all but the last
    char c = Wire.read();
  }
  int x = Wire.read();  // ไบต์สุดท้าย
  m_remoteKey = x;
}

receiveEvent อ่านไบต์ที่ FC ส่งมา เก็บตัวสุดท้ายเป็นเลขปุ่มรีโมท (m_remoteKey) นี่คือทางที่ปุ่ม KEY ไปถึงโค้ดของเรา ส่วน requestEvent ส่งบัฟเฟอร์ m_i2cData ให้ FC ทุกครั้งที่ FC ถาม

บัฟเฟอร์ 6 ไบต์

คำสั่งทุกคำสั่งถูกเขียนลงอาร์เรย์ m_i2cData ยาว 6 ไบต์

// protocol.hpp
static char m_i2cData[6];

โครงสร้างเหมือนกันทุกคำสั่ง

ไบต์หน้าที่
[0]ตัวนับ เพิ่มทีละ 1 ทุกครั้งที่สั่งคำสั่งใหม่
[1]รหัสคำสั่ง เช่น F_ARM = 1, F_AUTO_LAND = 3, F_DIRECTION = 15
[2]–[5]ข้อมูลของคำสั่งนั้น

ตัวอย่างจริงคือ take_off() และ set_pos()

// protocol.cpp
void Arduino::take_off(void)  //起飞
{
  m_i2cData[0]++;
  m_i2cData[1] = Arduino::F_ARM;
}

void Arduino::set_pos(int16_t x, int16_t y)  //坐标点设置
{
  m_i2cData[0]++;
  m_i2cData[1] = Arduino::F_POS;
  m_i2cData[2] = (uint16_t)(x & 0xffff);
  m_i2cData[3] = (uint16_t)(x & 0xffff) >> 8;
  m_i2cData[4] = (uint16_t)(y & 0xffff);
  m_i2cData[5] = (uint16_t)(y & 0xffff) >> 8;
}
บัฟเฟอร์ 6 ไบต์ที่ Nano ส่งให้ Flight Controllerไบต์แรกเป็นตัวนับ ไบต์ที่สองเป็นรหัสคำสั่ง ที่เหลือเป็นข้อมูล[0]ตัวนับ[1]รหัสคำสั่ง[2]ข้อมูล 1[3]ข้อมูล 2[4]ข้อมูล 3[5]ข้อมูล 4take_off() → F_ARM = 1n+11————set_pos(300, -50) → F_POS = 14n+1140x2C0x010xCE0xFFx = 300 = 0x012C → ไบต์ต่ำ 0x2C มาก่อน ไบต์สูง 0x01 ตามหลังy = −50 = 0xFFCE → ไบต์ต่ำ 0xCE มาก่อน ไบต์สูง 0xFF ตามหลังตัวนับ [0] เพิ่มทีละ 1 ทุกคำสั่ง FC จึงรู้ว่าเป็นคำสั่งใหม่ ไม่ใช่ค่าเก่า
ตัวอย่างบัฟเฟอร์ของ take_off() และ set_pos(300, -50) ตัวเลข 16 บิตถูกแบ่งเป็นสองไบต์ ไบต์ต่ำมาก่อน

ตัวนับไว้ทำอะไร

คำสั่ง take_off() ไม่ได้ "ส่ง" ทันที มันแค่เขียนลงบัฟเฟอร์ แล้วรอให้ FC มาอ่าน ถ้า FC อ่านบัฟเฟอร์ซ้ำสองรอบ มันจะเห็นรหัส 1 สองครั้ง แล้วไม่รู้ว่าเราสั่งขึ้นบินสองรอบ หรือค่าเก่าค้างอยู่ ตัวนับที่เพิ่มทุกครั้งช่วยแยกเรื่องนี้ ถ้าตัวนับเปลี่ยน แสดงว่าเป็นคำสั่งใหม่ (นี่คือเจตนาของการออกแบบ ซอร์สฝั่ง FC ไม่ได้อยู่ในชุดไฟล์ของชมรม)

ทำไมต้องแบ่งเป็นสองไบต์

ไบต์หนึ่งเก็บได้ 0–255 แต่ระยะ 300 cm เกินนั้น จึงแตก 16 บิตเป็นไบต์ต่ำกับไบต์สูง ตัวอย่าง 300 = 0x012C ไบต์ต่ำ 0x2C (44) ไบต์สูง 0x01 ฝั่งรับประกอบกลับด้วย ต่ำ | (สูง << 8) โค้ดที่อ่านความสูงจาก FC ก็ทำแบบนี้

// protocol.cpp
uint16_t Arduino::get_height_cm() const {
  return (uint16_t)m_externData[0] | ((uint16_t)m_externData[1] << 8);
}

ลำดับ "ต่ำก่อนสูง" เรียกว่า little-endian (เหมือนเขียนตัวเลขกลับด้าน)

อีกทาง: อ่านข้อมูลจาก FC

นอกจากรับปุ่ม Nano ยังขอข้อมูล 8 ไบต์จาก FC (รวมความสูง) ด้วย Wire.requestFrom

// protocol.cpp
static void iicRecv() {
  const uint8_t FC_ADDR = 0x34;
  const uint8_t LEN = 8;

  Wire.requestFrom(FC_ADDR, LEN);
  uint8_t i = 0;
  while (Wire.available() && i < LEN) {
    pArduino->m_externData[i++] = Wire.read();
  }
  while (i < LEN) {
    pArduino->m_externData[i++] = 0;
  }
}

ลำดับคือ ขอ 8 ไบต์ → วนอ่านเท่าที่มี → ช่องที่เหลือเติม 0 เรียกใช้ผ่าน pArduino->poll() ดังนั้นก่อนอ่านความสูงต้อง poll() ก่อนเสมอ ไม่งั้นได้ค่าเก่า

// shooting.ino (reach_height_shoot_stable)
pArduino->poll();
uint16_t h = read_height_cm();
if (h == 0 || h > 300) { ... }   // 0 = เซนเซอร์ไม่ตอบ

ลองทำ

  1. คำนวณว่า set_pos(1000, 0) จะได้ไบต์ [2] และ [3] เป็นเลขอะไร (คำตอบ: 1000 = 0x03E8 → [2]=0xE8, [3]=0x03)
  2. เปิด protocol.hpp หา enum _FUNCTION_SEND แล้วหาว่า F_DIRECTION มีค่าเท่าไร (คำตอบ: นับต่อจาก F_POS = 14 ได้ 15)
  3. เพิ่ม Serial.println ชั่วคราวเพื่อพิมพ์ pArduino->get_height_cm() หลัง poll() แล้วยกโดรนขึ้นลงดูว่าค่าเปลี่ยนไหม

สังเกตเล็กน้อยในโค้ด

ฟังก์ชัน set_height() ใช้รหัสคำสั่งเดียวกับ set_pos() คือ F_POS ต่างกันแค่ข้อมูลที่ใส่ลงไป อ่านที่ ขั้นสูง: คำสั่งเดินทางยังไง เพื่อดูภาพรวมของคำสั่งทั้งหมด

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

  • ความสูงอ่านได้ 0 ตลอด สาย I2C หลวม หรือลืม poll() ก่อนอ่าน
  • Nano ไม่รับปุ่ม ตรวจว่าไม่มีโค้ดอื่นเรียก Wire.begin() ซ้ำแล้วเปลี่ยนแอดเดรส
  • ใส่ delay() ยาวใน loop() บัฟเฟอร์ถูกเขียนทับหลายรอบก่อน FC มาอ่าน คำสั่งบางตัวหายไป ใส่ delay หลังคำสั่งบินพอให้โดรนทำเสร็จ
  • ใช้ขา A4/A5 ทำอย่างอื่น สองขานี้เป็นขา I2C ของ Nano

On this page