จบชุดบทเรียนนี้เราจะไล่สแตก Edge AI ได้ครบ 3 ชั้น แล้วชี้ต้นทางของทุกค่าที่ edge_ai คืนมาได้:
s_models[] และคู่ index s_active/s_currentedge_ai (links / model / active / result / latency) แล้ว trace ทั้งเส้นปลายทางของวันนี้: กด Trace แล้วอ่าน log สามชั้น (transport → control → result) พร้อมชี้ฟังก์ชันหรือฟิลด์ต้นทางของแต่ละชั้นใน ai_engine.h กับ ipc_model_link_defs.h ของ SDK สาธารณะ
ชุดบทเรียนนี้เราวัดกันที่ "อธิบายได้" ไม่ใช่ "รันผ่าน" — โค้ดสั้น แต่ความเข้าใจต้องลึกถึงระดับข้ามคอร์
17 ชุดบทเรียนก่อนหน้าเราเดินครบ 5 เสาหลัก: DAQ → Processing → Analysis → Training → Apps ตอนนี้เรามีของครบมือแล้ว บทเรียน 7.1–7.2 เปิดสาย Researcher ด้วยการหันกลับไปมองเครื่องมือที่ใช้มาตลอด
s_models[] กับ ROW macro อยู่ตรงไหน ก็เพิ่มไม่ถูกที่Researcher tier ไม่ใช่ "ยากขึ้น" แต่ "ลึกขึ้น" — เราเลิกเป็นผู้ใช้ API แล้วเริ่มเป็นคนที่แก้/ต่อ API ได้
ลองนึกถึงบรรทัดที่เราเขียนมาตั้งแต่บทเรียน 1.1–1.3:
r = edge_ai.result() # r['label'], r['conf'], r['scores'], r['seq'] ...
ดูเหมือนเรียกฟังก์ชันธรรมดา แต่จริงๆ บรรทัดนี้ทำงานข้าม สองคอร์:
result() จึงเป็นการ pull — ส่งคำถามข้ามคอร์ผ่าน IPC แล้วรอ CM55 เติมคำตอบกลับมาlabel/conf/scores/seq) มีต้นทางเป็นฟิลด์ใน struct ฝั่ง C ชื่อ ai_result_t s_resวันนี้เราจะเดินย้อนจาก dict ตัวนี้กลับไปให้ถึงต้นทาง: จาก Python → IPC →
ai_engine→ NPU แล้วกลับมา ทั้งวงในหนึ่งภาพ
ชิปตัวนี้มีสาม Arm core งาน Edge AI กระจายอยู่สองคอร์ แล้วคุยกับ MicroPython บนอีกคอร์:
ทำไมต้องแยก: งานที่กินแรง (NPU, โมเดล, จอ) ไปอยู่คอร์เร็ว ส่วน REPL กับเซนเซอร์อยู่คอร์ควบคุม — แต่ละคอร์ทำสิ่งที่ตัวเองถนัด
หัวใจของทั้งสแตกคือไฟล์ proj_cm55/modules/ai_models/ai_engine.c มันคุม FreeRTOS task ชื่อ ai_task ที่รัน ทีละหนึ่งโมเดล เลือกจากทะเบียน s_models[]
ai_engine คือ แหล่งความจริงเดียว ว่า "ตอนนี้รันโมเดลอะไร และมันตอบว่าอะไร"finalize() โมเดลเพื่อสลับ — เคยทำแล้ว TFLite-Micro interpreter หลุดมือ NPU จน IPC ค้างถาวร ทุกโมเดลจึง resident อยู่ตลอด สลับแค่เปลี่ยน index ที่ถูก feedจำคำนี้ไว้: "สลับโมเดล = เปลี่ยนว่าใครได้กินข้อมูล ไม่ใช่ปิดเปิดโมเดล" — นี่คือบทเรียนที่แลกมาด้วยการ debug หลายชั่วโมง
ai_engine เก็บ index สองตัว ที่คนมักสับสน แต่ต่างกันสำคัญมาก:
edge_ai.active() คืน s_current (โมเดลที่สลับไปแล้วจริง) ไม่ใช่ตัวที่เพิ่งขอactive() ทันทีหลังสั่ง select อาจได้ค่าเก่า เพราะ task ยังตามไม่ทันrequested() (s_active) ไม่ใช่ active() — ไม่งั้นทับ selection ใหม่โดยไม่ตั้งใจนี่คือ "gotcha" อันดับหนึ่งของสแตกนี้ ในสคริปต์ฝึกเราจะเรียก
active()หลังselect()เพื่อ เห็นกับตา ว่ามันสลับจริงหรือยัง
แต่ละแถวใน s_models[] เป็น struct ai_model_desc_t ประกาศด้วย ROW macro ที่มี guard EDGE_AI_MODEL_<name> — โมเดลจะคอมไพล์เข้ามาก็ต่อเมื่อ Makefile ขอ:
#define MOTION_ROW { .name = "Motion Detection", \
.sensor = AI_SENSOR_IMU, .class_count = 3, \
.class_labels = { "idle", "circle", "shaking" }, \
.flash_bytes = 28272u, .period_ms = 200u, \
.init = AIM_MOTION_init, .enqueue = AIM_MOTION_enqueue, \
.dequeue = AIM_MOTION_dequeue, .finalize = AIM_MOTION_finalize }
edge_ai.models() คืนมา (name/sensor/labels) มาจาก ROW นี้ตรงๆinit / enqueue / dequeue / finalizeนี่คือจุดที่ชุดบทเรียนถัดไป (บทเรียน 7.3–7.4) เราจะแตะ — เพิ่มโมเดล = เพิ่ม ROW หนึ่งแถว + define ใน Makefile + ไฟล์โมเดล วันนี้แค่รู้จักหน้าตามันก่อน
ai_task ดูว่าโมเดลปัจจุบันใช้เซนเซอร์อะไร แล้วเรียก feed function ให้ตรงชนิด:
| เซนเซอร์ | feed fn | ทำอะไร |
|---|---|---|
AI_SENSOR_IMU |
feed_imu() |
ดึง snapshot BMI270 ผ่าน IPC, remap แกน (-X,-Y,Z), แปลง counts → g/dps, enqueue() ต่อ sequence ใหม่ |
AI_SENSOR_RADAR |
feed_radar() |
ดูด chirp int16 128 sample จาก ring, cast ADC → float (ไม่ scale), enqueue() ต่อ chirp |
AI_SENSOR_MIC |
feed_audio() |
เริ่ม PDM mic, normalize int16 → [-1,1], วน enqueue+dequeue+publish ต่อ sample |
ถ้าอยากทำ feature เดียวกันซ้ำนอกบอร์ด (เช่นตอน train) ต้องเลียนแบบ feed function นี้ให้เป๊ะ — นี่คือสะพานเชื่อม โมดูล 4 (Analysis) กับโมดูล 5 (Training) ที่เราเดินมา
เมื่อ dequeue() ได้ verdict publish() เขียนลง global ai_result_t s_res โดยไม่ล็อก:
typedef struct {
uint8_t model_index, class_count, top_class, running;
float scores[AI_MAX_CLASSES];
uint32_t inference_us, inference_us_max, inferences, seq;
} ai_result_t;
publish() เสี่ยงหน่วง completion IRQ ของ Ethos-U55seq เพิ่มทุกครั้งที่ publish — เราใช้มันเช็ก "มีผลใหม่ไหม" (นี่คือ r['seq'] ที่เราเช็กในลูปทุกบทเรียน)ทุก key ใน dict ที่
result()คืน map ตรงกับฟิลด์ใน struct นี้ —scores→scores,seq→seq,latency_ms→inference_us/1000,top→top_class
build แบบ combo รวม 6 โมเดลในภาพเดียว สลับได้ตอนรัน:
| โมเดล | เซนเซอร์ | ชนิด | ที่มา |
|---|---|---|---|
| Motion Detection | IMU | int8 | source-gen model_motion.c |
| Baby Cry Detection | MIC | int8 | source-gen model_audio.c |
| Push Detection | RADAR | float32 | source-gen model_radar.c |
| Cough Detection | MIC | int8 | ready-model cough_lib_eval.a |
| Alarm Detection | MIC | int8 | ready-model alarm_lib_eval.a |
| Siren Detection | MIC | float32 | ready-model siren_lib_eval.a |
AIM_<NAME>_*IMAI_* ไม่ prefix → ชนกัน ต้อง objcopy rename เป็น IMAI_<MODEL>_* ให้อยู่ร่วมกันได้สังเกตว่า int8 (Motion/Cough) กับ float32 (Push/Siren) อยู่ในภาพเดียวกันได้ — คำถามคือมันรันร่วมกันได้ยังไง? หน้าถัดไปตอบ
จุดที่คนเข้าใจผิดบ่อย: DEEPCRAFT ไม่ใช่ runtime มันเป็นแค่เปลือกที่ห่อกราฟ .tflite ไว้:
libtensorflow-microlite.a พก kernel ทั้ง int8 และ float32 มาในตัว.tflite) เสียบเข้าได้"DEEPCRAFT = wrapper, TFLite-Micro = runtime" — จำประโยคนี้ไว้ เพราะมันปลดล็อกความคิดว่า "เราเทรนโมเดลเองแล้วเสียบแทนได้"
คำถามค้างจากเมื่อกี้: ทำไม radar/siren (float32) รันในภาพ int8 combo ได้?
COMPONENT_ML_INT8x8 / ML_FLOAT32 แค่ตั้ง typedef ตัวชี้ MTB_ML_DATA_T — ไม่ได้ตัดสินว่ารันด้วย kernel ไหนตอน Trace ในสคริปต์ ลองเทียบ
latency()ของโมเดล int8 กับ float32 — จะเห็นเลยว่า NPU กับ CPU ต่างกันจริง
โมเดล .tflite int8 ไปรันบน NPU ได้ต้องผ่านขั้นคอมไพล์ Vela ก่อน แปลงให้ Ethos-U55 อ่านออก แล้ว weights ต้องหาที่อยู่ในแฟลช:
.cy_socmem_data แต่ในภาพ combo weights 6 โมเดล + FFT table จะล้นกำแพง .fw_identity ที่ 0x60900000CY_ML_MODEL_MEM=.ml_weights ย้าย weights ไปโหลดที่หาง flash ~2.5 MB เหนือกำแพง แล้ว copy ลง SOCMEM ตอน boot (ให้ NPU DMA เอื้อมถึง)จำ address
0x60900000ไว้ — มันคือ "กำแพง" ที่เราเคยเจอในบันทึกโปรเจกต์จริง ตอน combo image เกือบล้น นี่ไม่ใช่ทฤษฎี เป็นของจริงบนบอร์ดนี้
MicroPython บน CM33_NS เรียก ai_engine บน CM55 ตรงๆ ไม่ได้ ทั้งคู่แลกข้อความขนาดคงที่ผ่าน IPC pipe เดียว มี สอง plane:
OP_CTRL) — สั่ง: SELECT(n)=0x90+n, START=0x82, STOP=0x83 แบบยิงแล้วไม่รอOP_QUERY) — อ่านแบบ pull: CM33 วางตัวชี้ struct แล้ว block รอ CM55 เติมข้อจำกัดที่ต้องรู้: control plane เป็น best-effort ใช้ pipe ร่วม ถ้า flood
select()รัวๆ pipe ค้างได้ (กู้ด้วย core reset เท่านั้น) — ใน REPL ให้เว้นการสลับโมเดลเป็นวินาที
การออกแบบที่สำคัญที่สุดของ IPC นี้: อ่านเป็น pull ที่ตอบรับ · สั่งเป็นการยืนยันด้วยการสังเกต
edge_ai.select(n) # 1) ส่ง SELECT(0x90+n) ผ่าน control plane
# 2) poll Q_ACTIVE จนอ่านกลับได้ = n (สูงสุด 25 × 20 ms)
# ถ้าไม่สลับในเวลา -> OSError "select not confirmed"
select() ไม่เชื่อว่า "สั่งแล้วสำเร็จ" แต่รอ เห็น active() เปลี่ยนเป็น n จริงselect() ด้วย try/except OSError เสมอในสคริปต์ฝึก เราจะเรียก
active()ต่อจากselect()ทันที เพื่อ เห็นกับตา ว่ากลไก confirm-by-observation นี้ทำงาน — index ที่ได้ต้องตรงกับที่ขอ
ชุดบทเรียนก่อน ๆ เราเน้นฝั่ง "สั่ง" (select/stop) ชุดบทเรียนนี้เราส่องด้วยฝั่ง "อ่าน" 5 ตัว แต่ละตัว map ไป query sub-plane:
| คำสั่ง | คืนค่า | map ไป |
|---|---|---|
edge_ai.links() |
('ipc',) — link backend |
IPC model link (deepcraft_task.c) |
edge_ai.model(n) |
dict descriptor หนึ่งตัว | Q_MODEL → ai_engine_model(i) |
edge_ai.active() |
index ที่รันอยู่ (-1 ถ้า idle) |
Q_ACTIVE → s_current |
edge_ai.result() |
dict verdict ล่าสุด หรือ None |
Q_RESULT → ai_result_t s_res |
edge_ai.latency() |
เวลาอนุมานล่าสุด (ms) | s_res.inference_us / 1000 |
on_result(cb) เป็นทางเลือกแบบ event-driven — เฟิร์มแวร์เรียก callback ตอนคลาสเปลี่ยน (เราใช้ในฉบับเต็ม)โฟกัสห้าตัว: links → model → active → result → latency ห้าตัวนี้คือทั้งเรื่องราวของสคริปต์ส่องสแตกชุดบทเรียนนี้
รวมทุกอย่างเข้าด้วยกัน นี่คือเส้นทางของ หนึ่งการอนุมาน ตั้งแต่เซนเซอร์จนเป็น dict ใน Python:
ทั้งหมดคือ "sample เซนเซอร์บนคอร์หนึ่ง กลายเป็น Python dict บนอีกคอร์ ผ่านผล lock-free กับ IPC pull" — ที่เหลือในสแตกเป็นแค่รายละเอียดรอบการทำให้เส้นนี้เร็ว สลับได้ ปลอดภัย
สแตกนี้ไม่มีสมการให้ท่อง มันเป็น เส้นทางข้อมูลหกป้าย ที่ไหลจากซ้ายไปขวาหนึ่งรอบต่อหนึ่งการอนุมาน อ่านป้ายให้ออก แล้วคุณ trace ได้ทั้งเส้น:
อ่านแต่ละป้ายเป็นภาษาคน แล้วโยงว่าทำไมมันสำคัญกับชุดบทเรียนนี้:
| ป้าย | อ่านว่าอะไร | ทำไมสำคัญกับชุดบทเรียนนี้ |
|---|---|---|
sensor |
เซนเซอร์ดิบบน CM33_NS (IMU/MIC/RADAR) | ต้นทางของทุกค่า — sample แรกก่อนข้ามคอร์ |
IPC |
สะพานข้ามคอร์ CM33_NS ↔ CM55 | ชั้น transport ที่ links() ชี้ให้เห็น |
feed_*() |
แปลง raw → feature ที่กราฟกิน | ตอบคำถาม "โมเดลเห็นอะไรจริง ๆ" |
enqueue → dequeue |
ป้อนเข้าคิว แล้วดึงออกให้ NPU | จุดที่ "สลับโมเดล = เปลี่ยนว่าใครได้ feed" |
NPU |
Ethos-U55 อนุมาน int8 (float ไป CPU) | ที่มาของ latency() int8 vs float32 |
publish() |
เขียนผลลง s_res แบบ lock-free + seq++ |
ปลายทางของ result() ทุก key |
ไม่มีสูตรให้จำ มีแค่หกป้าย — trace เก่งคือ "เห็นค่าปุ๊บ บอกได้ทันทีว่ามันอยู่ป้ายไหนของเส้นนี้"