เราไม่รันด้วย exec(open(...)) แต่ใช้วิธีมาตรฐานของ BENTO IDE:
s01_interrupt.py ใน BENTO IDE
ที่ไฟไม่สะดุดตอนกดปุ่ม คือผลของ interrupt + non-blocking loop ทำงานคู่กัน นี่คือสิ่งที่เราอยากให้น้อง ๆ เห็นกับตา
Pin.irq(trigger=Pin.IRQ_FALLING) ได้ ถือว่าผ่านคาบนี้ticks_ms) และ hardware machine.Timer ให้เลือกตามงานอยากเห็นแนวคิด polling vs interrupt อธิบายอีกมุมแบบเห็นภาพ ลองดูคลิปนี้หลังเลิกเรียน (ภาษาอังกฤษ สั้น ๆ เข้าใจง่าย):
Polling vs. Interrupts - An Introduction To Microcontrollers - PyroEDU — PyroElectro
สิ่งที่คลิปเรียกว่า "interrupt service routine (ISR)" ก็คือ
on_button()ของเรานี่แหละ — แค่คนละภาษา หลักการเดียวกัน
ไฟล์ฝึก practise_codes/s01_interrupt.py↗ เว้นช่องไว้ให้เราเติมเอง 3 จุด:
on_button(pin) — เติมบรรทัดนับเพิ่ม: press_count += 1btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING)if time.ticks_diff(now, last_blink) >= BLINK_MS:ส่งงาน (ก่อนเลิก):
s01_interrupt.py รันบนบอร์ด ไฟกระพริบ + นับปุ่มได้พร้อมกันsleep ยาวในลูปลองเขียนเองให้สุดก่อนนะ — เดี๋ยวเฉลยพร้อมกันในห้องเรียน
s01_interrupt.py — อ่านให้เข้าใจแล้วปิดไฟล์ พิมพ์เองเฉลยนี้มีไว้ให้ เทียบ ไม่ได้มีไว้ให้ลอก คะแนนของคาบนี้อยู่ที่ใบงานกับคำอธิบายด้วยคำพูดของน้องเอง copy เฉลยส่งไปก็ไม่ได้อะไรติดตัว วิธีใช้ให้คุ้มคือลองเติมช่อง 30% ด้วยตัวเองก่อนอย่างน้อยสิบห้านาที ติดตรงไหนค่อยเปิดเฉลย อ่านให้เข้าใจ แล้วปิด กลับไปพิมพ์ใหม่ด้วยมือ ตอนพิมพ์เองนั่นแหละสมองถึงจะจำ pattern ของ interrupt ได้จริง เราจะแกะเฉลยทีละส่วน เพราะทุกบรรทัดมีเหตุผลของมัน
ส่วนแรก — เตรียมของสามชิ้น: ปุ่ม, ไฟ, ตัวนับร่วม:
from machine import Pin # คลาส Pin มาพร้อม .irq() สำหรับ interrupt
import gpio # โมดูลบอร์ด: gpio.led(n) ใช้คุม LED ง่าย ๆ
import time
led = gpio.led(0) # ไฟดวงแรกไว้กระพริบเป็นจังหวะ
btn = Pin("USER_BUTTON", Pin.IN) # ปุ่ม USER BTN1 = P7_0 บนบอร์ด, ตั้งเป็นขาเข้า
press_count = 0 # ตัวนับจำนวนครั้งที่ปุ่มถูกกด — อัปเดตจากใน interrupt
import สามบรรทัดคือของเดิมจาก Developer I ทั้งหมด (Pin, gpio, time) — คาบนี้ไม่ได้ทิ้งอะไรเลย แค่หยิบ Pin มาใช้ความสามารถใหม่ที่ชื่อ .irq() ตามที่คุยกันในสไลด์ "ทบทวน Developer I" :15-17btn = Pin("USER_BUTTON", Pin.IN) ใช้ชื่อขาบนบอร์ดตรง ๆ ไม่ต้องจำว่าเป็น P7_0 — บนบอร์ดเกมมันคือ USER BTN1 ตัว tactile switch ที่เราดูรูปกันไปแล้ว ตั้งเป็น Pin.IN เพราะปุ่มเป็นขา "อ่านเข้า" :28press_count = 0 คือหัวใจที่เงียบที่สุดในไฟล์นี้ ตัวแปรตัวเดียวนี้เป็น สะพานเชื่อมสองโลก: โลกของ interrupt (ที่มาบวกค่า) กับโลกของ main loop (ที่มาอ่านค่า) ประกาศไว้ตรงนี้ก่อน เดี๋ยว handler จะมาแก้ค่ามัน :30เห็นไหมว่าสามบรรทัดแรกไม่มีอะไรใหม่เลย ของใหม่ทั้งหมดของคาบนี้ซ่อนอยู่ในคำเดียว คือ
.irqที่เรากำลังจะไปเจอ
นี่คือส่วนที่แยก Developer II ออกจาก Developer I ชัดที่สุด ใน Dev I เราเรียกฟังก์ชันเองทุกตัว แต่ on_button ตัวนี้ เราไม่เคยเรียกมันเลยสักครั้ง — ฮาร์ดแวร์เรียกให้:
def on_button(pin):
# นี่คือ "interrupt handler" — ฮาร์ดแวร์เรียกฟังก์ชันนี้เองตอนปุ่มถูกกด
# เราไม่ได้เรียกมันเอง และไม่รู้ล่วงหน้าว่ามันจะถูกเรียกตอนไหน
# กฎเหล็กของ handler: ทำให้สั้นและเร็วที่สุด ห้ามงานหนัก/หน่วงเวลานาน
global press_count
press_count += 1
# ผูก handler กับ "ขอบขาลง" (IRQ_FALLING) = จังหวะที่ปุ่มเปลี่ยนจากปล่อย -> กด
# ตั้งครั้งเดียวจบ จากนี้ฮาร์ดแวร์จัดการเฝ้าปุ่มให้เราเอง
btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING)
global press_count เพราะเราจะไป แก้ค่า ตัวแปรที่อยู่นอกฟังก์ชัน ถ้าลืมบรรทัดนี้ Python จะสร้างตัวแปรใหม่ในเครื่องขึ้นมาแทน แล้วตัวนับข้างนอกจะไม่ขยับเลย — บั๊กเงียบที่หายาก :36press_count += 1)? เพราะ กฎเหล็ก ที่ย้ำในสไลด์ "หัวใจ #1": handler ต้องสั้นและเร็วที่สุด งานหนักอย่าง print เราจงใจไม่ใส่ในนี้ ปล่อยให้ลูปหลักทำ ถ้าเผลอหน่วงเวลาในนี้ interrupt ตัวถัดไปจะถูกกั้นไว้ ระบบสะดุดทั้งเครื่อง :33-35btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING) คือการ ส่งฟังก์ชันไปให้ระบบเก็บไว้เรียกทีหลัง ตรงนี้แหละที่สไลด์เรียกว่า callback เราส่งชื่อ on_button เฉย ๆ ไม่ใส่วงเล็บ เพราะไม่ได้เรียกมันตอนนี้ แค่ฝากไว้ :41trigger=Pin.IRQ_FALLING เลือกจับเฉพาะ "ขอบขาลง" คือจังหวะ ปล่อย → กด ของปุ่ม active-low ตรงกับสไลด์ "ขอบขาลงหน้าตาเป็นยังไง" หลังบรรทัดนี้ เราไม่ต้องเขียนโค้ดอ่านปุ่มในลูปอีกเลย ต่างจาก Dev I ที่ต้อง if btn.value() ทุกรอบ :39-41ประโยคเดียวที่อยากให้จำจากสไลด์นี้: ตั้ง
irqครั้งเดียวจบ จากนั้นฮาร์ดแวร์เฝ้าปุ่มให้เราเอง — เราเอาเวลาไปทำอย่างอื่นได้เต็มที่
interrupt นับปุ่มให้เบื้องหลังแล้ว หน้าที่ของลูปหลักจึงเหลือแค่สองอย่าง: กระพริบไฟตามจังหวะ และรายงานเมื่อค่าเปลี่ยน โดย ห้ามหยุดรอใคร:
BLINK_MS = 500 # อยากให้ไฟสลับสถานะทุก ๆ กี่มิลลิวินาที
led_on = False
last_blink = time.ticks_ms() # จดเวลาเริ่มต้นไว้เทียบ
last_reported = -1 # กันพิมพ์ค่าซ้ำ ๆ เมื่อจำนวนกดไม่เปลี่ยน
while True:
now = time.ticks_ms()
# ซอฟต์แวร์ timer: ถ้าครบช่วง BLINK_MS แล้ว ค่อยสลับไฟ — ไม่ใช้ sleep ยาว
if time.ticks_diff(now, last_blink) >= BLINK_MS:
led_on = not led_on
led.on() if led_on else led.off()
last_blink = now
# ปุ่มถูกนับให้แล้วโดย interrupt — ลูปแค่คอยรายงานเมื่อค่าเปลี่ยน
if press_count != last_reported:
print("ปุ่มถูกกดแล้ว", press_count, "ครั้ง")
last_reported = press_count
time.sleep_ms(5) # พักสั้น ๆ พอ ไม่ให้ลูป busy เกินจำเป็น (ยังถือว่าไม่บล็อก)
sleep(500) ยาว ๆ แบบ Dev I เรา จดเวลาไว้แล้วเทียบเอง ด้วย ticks_diff(now, last_blink) >= BLINK_MS ตามสไลด์ "software timer แบบไม่บล็อก" ระหว่างรอครบ 500 ms ลูปยังวิ่งฉิว ไม่ค้าง :55-58last_reported = -1 ตั้งเป็นค่าที่ press_count (เริ่มที่ 0) ไม่มีวันเท่า จงใจให้รอบแรกที่ยังไม่กดปุ่มก็ไม่พิมพ์ พอค่าเปลี่ยนค่อยรายงานทีเดียว กันจอรกด้วยข้อความซ้ำ :48,61-63time.sleep_ms(5) ต่างจาก sleep ยาวของ Dev I ตรงที่มันสั้นมากจนถือว่าไม่บล็อก แค่กันไม่ให้ลูป busy กิน CPU ฟรี ๆ — ตอบไวพอสำหรับสายตาคน แต่ไม่เผาแรงเครื่องทิ้ง :65ถ้าจับได้ว่า "ไม่ใช้
sleepยาว แต่จดเวลาแล้วเทียบเอง" คือกุญแจของทั้งไฟล์ — น้องเข้าใจคาบนี้แล้วครึ่งทาง อีกครึ่งคือPin.irqที่แกะไปก่อนหน้า
ทั้งไฟล์มีแค่สามชิ้นส่วน แต่ละชิ้นแก้ปัญหาที่ชิ้นก่อนหน้าเปิดค้างไว้ นี่คือวิธีเดียวกับที่เราจะต่อเกมทั้งเครื่องขึ้นมาในคาบถัด ๆ ไป:
| ชิ้นส่วน | บรรทัด | สิ่งที่ยกระดับจาก Dev I | ของเดิมที่เอากลับมาใช้ |
|---|---|---|---|
| เตรียมของ | :27-30 |
เพิ่ม btn เป็น Pin.IN + ตัวนับร่วม press_count |
gpio.led(0), Pin, import เดิม |
| handler | :32-37 |
ฟังก์ชันที่ "ฮาร์ดแวร์เรียกเอง" (callback) + global |
นิยาม def ธรรมดา |
| ผูก irq | :41 |
Pin.irq(trigger=IRQ_FALLING) แทนการเช็กปุ่มในลูป |
เลิกใช้ if btn.value() ทุกรอบ |
| software timer | :47,55 |
ticks_ms + ticks_diff แทน sleep ยาว |
แทน time.sleep_ms ยาว ๆ ของ Dev I |
| ลูปหลัก | :50-65 |
สองงานเดินขนานไม่บล็อกในลูปเดียว | while True หัวใจโปรแกรมฝังตัว |
จับจังหวะการต่อชิ้นนี้ไว้ให้ดี เกมทั้งเกมก็สร้างแบบเดียวกัน คือเริ่มจากชิ้นเล็กที่รันได้ แล้วเติมทีละแนวคิด อย่ากระโดดเขียนทั้งเกมรวดเดียว
หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องไวยากรณ์โค้ด แต่เรื่อง "มองให้ทะลุ" ว่า btn.irq() บรรทัดเดียวที่เพิ่งเขียน จริง ๆ แล้วมันคือชิ้นส่วนของอะไร
ที่มา — interrupt ยืนอยู่บนอะไร ย้อนไปสไลด์ "Polling vs Interrupt — ภาพในหัว" ต้นคาบ เราเทียบ polling เหมือนนั่งเฝ้าตู้ไปรษณีย์ทั้งวัน ส่วน interrupt เหมือนติดกระดิ่งไว้ มีจดหมายมาค่อยดัง แล้วสไลด์ "ทำไม interrupt ตอบไว — คิดเป็นตัวเลข" ก็พิสูจน์ด้วยสมการว่า worst-case latency ของ polling เท่ากับคาบที่เราวนถาม ส่วน interrupt เหลือระดับไมโครวินาที Pin.irq วันนี้จึงไม่ใช่ของใหม่ลอย ๆ มันคือ คำตอบเชิงวิศวกรรมของปัญหาที่ Dev I ทิ้งค้างไว้: จะรับเหตุการณ์ที่มาไม่บอกล่วงหน้าโดยไม่พลาดได้ยังไง
ที่ไป — interrupt ดวงนี้จะโตเป็นอะไร ในสไลด์ "เกร็ด: Atari 2600 racing the beam" อาจารย์บอกว่า interrupt คือเครื่องมือจัดการ timing แบบ real-time เดียวกับที่เกมยุคแรกใช้ ทีนี้คิดต่อ: คาบหน้าเราขึ้น Snake ต้องวาดสนามกับตัวงูเป็นเฟรม ๆ พร้อมกับรับปุ่มบังคับทิศทาง ถ้าเราเผลอ sleep รอปุ่มแบบ Dev I เกมจะค้างทุกครั้งที่รอ โครง "รับ input ไม่พลาด + ลูปไม่หยุดรอ" ที่พิมพ์วันนี้ คือโครงเดียวกับ game loop ของ Snake และ Flappy Bird เป๊ะ ๆ เราแค่ยังไม่ได้ใส่กราฟิกลงไป
ลองตอบคำถามพวกนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:
time.ticks_diff(now, last_blink) >= BLINK_MS ที่คุมจังหวะไฟได้ไหม ถ้าวันหน้าเราเปลี่ยนจาก "สลับไฟทุก 500 ms" เป็น "เลื่อนตัวงูไปข้างหน้าทุก 150 ms" โครงโค้ดจะเปลี่ยนไปมากไหมPin.irq ได้ พรุ่งนี้เราจะรับ "การกดทิศทางบน joystick" มาสั่งงูเลี้ยวโดยไม่ทำให้เกมค้างได้ไหมpress_count ที่ interrupt บวกให้ กับ main loop ที่มาอ่าน มันคือเรื่องเดียวกับ "งูขยับตามตัวแปรทิศทางที่ปุ่มไปแก้" — ตัวแปรร่วมตัวเดียวเชื่อมสองโลกเข้าด้วยกัน (ใบ้: มันคือ pattern ที่ชื่อว่า event flag)ถ้าตอบสามคำถามข้างบนได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด: interrupt กับ non-blocking loop วันนี้ไม่ใช่แบบฝึกหัด มันคือโครงกระดูกของทุกเกมที่เรากำลังจะสร้าง
เทคนิคสามอย่างของวันนี้ไม่ใช่แค่แบบฝึกหัด มันคือสิ่งที่เฟิร์มแวร์จริงใช้กันทุกวัน:
Pin.irq แบบ IRQ_FALLING ปลุกเฉพาะตอนมีคนกด ตรงกับท่า "ผูก handler กับขอบขาลง" ของเรา — ไม่มีใคร poll ปุ่มกริ่งทั้งวันให้เปลืองแบตpress_count += 1 ของเรา พัลส์มาเร็วแค่ไหนก็ไม่พลาด เพราะ handler สั้นและเร็วตามกฎเหล็กIRQ_FALLING/IRQ_RISING ที่เราเรียน แล้วเพิ่ม/ลดระดับเสียงทันทีโดยจอไม่กระตุกticks_ms แบบไม่บล็อก คู่ขนานกับงานหลักที่ยังรับ event อื่นได้ตลอด นี่คือ software timer + non-blocking loop ของเราในเครื่องจริงทั้งสี่ตัวอย่างนี้ไม่มีอันไหนเป็นของสมมติ ปุ่ม-นับ-กระพริบที่เราเล่นวันนี้ คือหลักการเดียวกับที่เดินอยู่ในเครื่องรอบตัวเราตอนนี้
ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงและเกมที่เรากำลังจะสร้างได้:
ticks_ms มาจำเวลากดครั้งล่าสุด)Pin.irq ให้ใช้ handler คนละตัว หรือ handler เดียวแล้วดูจาก argument pin ว่ามาจากปุ่มไหน แบบไหนสะอาดกว่าเมื่อปุ่มเยอะขึ้นBLINK_MS = 500 คงที่ ถ้าอยากให้ไฟกระพริบ เร็วขึ้นเมื่อกดปุ่มถี่ขึ้น (เอา press_count มาคิดเป็นความถี่) จะเปลี่ยนโครง non-blocking loop ตรงไหน โดยยังไม่กลับไป sleep ยาวเลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูกตายตัว ขอแค่คิดต่อจากโค้ดที่พิมพ์เองวันนี้ ตรงนั้นแหละคือจุดที่น้องเริ่มเป็นวิศวกร ไม่ใช่แค่คนพิมพ์ตามเฉลย
fit-css