คาบ 1 — Recap + Interrupts / Pin.irq

เปิด Developer II — จาก polling สู่ interrupt

คาบเปิดคอร์ส Developer II (ต่อจาก Developer I)

คาถาประจำคาบ: "อย่านั่งเฝ้าถาม ให้ฮาร์ดแวร์สะกิดเราเอง"

MicroPython บนบอร์ด PSoC Edge

เป้าหมายของคาบนี้

ใน Developer I เราอ่านปุ่มแบบ polling คือวน while แล้วถามสถานะปุ่มซ้ำ ๆ ตลอดเวลา ใช้ได้ แต่ต้องนั่งเฝ้าเอง คาบนี้เรายกระดับขึ้นอีกขั้น:

  1. ทบทวน Dev I สั้น ๆ — Pin, gpio.led, ลูป while True
  2. polling vs interrupt — ต่างกันตรงไหน ทำไม interrupt ดีกว่าในหลายงาน
  3. Pin.irq() — ผูก handler กับปุ่ม ให้ฮาร์ดแวร์เรียกเราตอนปุ่มถูกกด (hardware interrupt จริง)
  4. non-blocking timer — กระพริบ LED ด้วย time.ticks_ms() โดยไม่ sleep ยาวจนบล็อกงานอื่น

ปลายทาง: ไฟกระพริบสม่ำเสมอ "พร้อมกัน" กับการนับจำนวนกดปุ่ม โดยทั้งสองอย่างไม่ขวางกัน

ทบทวน Developer I — สิ่งที่เราถือติดมือมา

น้อง ๆ ผ่าน Dev I มาแล้ว เราจึงคุ้นกับของพวกนี้:

  • from machine import Pin — คลาสคุมขา GPIO ของบอร์ด
  • import gpio — โมดูลบอร์ด มี gpio.led(n) คุมไฟ LED ง่าย ๆ
  • import timetime.sleep_ms() หน่วงเวลาเป็นมิลลิวินาที
  • ลูป while True: — หัวใจของโปรแกรมฝังตัว วนทำงานไม่จบ

คาบนี้เราเอาของเดิมพวกนี้มาต่อยอด แค่เปลี่ยน "วิธีฟังปุ่ม" จากเฝ้าถามเอง เป็นให้ฮาร์ดแวร์สะกิด

ไม่มีอะไรที่เรียนมาแล้วทิ้ง ทุกอย่างต่อยอดจากของเดิมทั้งหมด

Polling vs Interrupt — ภาพในหัว

Polling (Dev I)
เราถามเอง ทุกรอบ
while: if btn...
เผลอ sleep ยาว = พลาดจังหวะกด
ยกระดับเป็น
Interrupt (Dev II)
ฮาร์ดแวร์ สะกิดเรา
btn.irq(handler...)
กดเมื่อไหร่ก็ไม่พลาด

Polling = เรานั่งเฝ้าตู้ไปรษณีย์ทั้งวัน · Interrupt = ติดกระดิ่งไว้ มีจดหมายมาค่อยดัง

ทำไม interrupt ตอบไว — คิดเป็นตัวเลข

ตอน polling เราวนถามแล้ว sleep คั่นแต่ละรอบ ถ้าปุ่มถูกกด ตอนที่เรากำลัง sleep อยู่พอดี กว่าจะรู้ก็ต้องรอครบรอบก่อน เวลาที่แย่ที่สุด (worst case) จึงเท่ากับคาบที่เราวนถาม:

tlatency,poll    Tpoll  =  tเช็ค+tsleept_{\text{latency,poll}} \;\le\; T_{\text{poll}} \;=\; t_{\text{เช็ค}} + t_{\text{sleep}}

  • sleep ยิ่งยาว ค่า worst-case latency ยิ่งสูง ตอบสนองยิ่งหน่วง (และเสี่ยงพลาดจังหวะกดสั้น ๆ)
  • ส่วน interrupt ฮาร์ดแวร์สะกิด CPU ทันทีที่ขาเปลี่ยน — latency เหลือระดับไมโครวินาที แทบไม่ขึ้นกับลูปเลย

tlatency,irq    tเข้า handler    Tpollt_{\text{latency,irq}} \;\approx\; t_{\text{เข้า handler}} \;\ll\; T_{\text{poll}}

นี่คือเหตุผลเชิงตัวเลขว่าทำไมงาน real-time ถึงเลือก interrupt — ไม่ใช่แค่ "ดูสวยกว่า" แต่ตอบไวกว่าจริง

machine.Timer — interrupt จาก "เวลา" (ฮาร์ดแวร์)

นอกจาก Pin.irq (interrupt จาก ปุ่ม) บอร์ดเรารองรับ machine.Timer แล้ว — ฮาร์ดแวร์ (TCPWM) นับเวลาเอง แล้วเรียก callback ให้อัตโนมัติ โดย main loop ไม่ต้องหยุดรอ

from machine import Timer
# เด้งทุก 0.5 วินาที — เกมยังวิ่งต่อระหว่างนี้
t  = Timer(0, mode=Timer.PERIODIC, period=500, callback=lambda t: print("tick"))
t2 = Timer(1, mode=Timer.ONE_SHOT, period=3000, callback=boom)  # ยิงครั้งเดียวหลัง 3 วิ
t.deinit()    # หยุด

หมายเหตุ: โค้ด machine.Timer ข้างบนเป็น ตัวอย่างเสริม (enrichment) เพื่อให้เห็นว่าบอร์ดรองรับ hardware timer จริง — ไม่ได้อยู่ในไฟล์ฝึก s01_interrupt.py ซึ่งตั้งใจฝึก software timer (time.ticks_ms()) อย่างเดียว (ดูหมายเหตุใน solution_codes/s01_interrupt.py:19-25)

  • period= มิลลิวินาที · หรือ freq= ครั้ง/วินาที · mode= PERIODIC / ONE_SHOT
  • callback วิ่งจาก scheduler (ปลอดภัยกับ FreeRTOS) เขียน Python ปกติได้
  • จำนวน Timer จำกัดตามฮาร์ดแวร์ของบอร์ด (Eva Game = id 0–3) — เลือก counter ว่างให้อัตโนมัติ

Pin.irq = interrupt จากปุ่ม · machine.Timer = interrupt จากเวลา สองพี่น้อง real-time

Animation ไม่บล็อก — ไฟ "หายใจ" ด้วย Timer

เดิมไฟหายใจต้องวน while + sleep ซึ่ง บล็อก ทั้งโปรแกรม. ใช้ Timer ให้เฟดเองเป็นจังหวะ โดยเกมยังทำงานต่อ — ต่อยอด led.brightness() (Developer I คาบ 8):

from machine import Timer
import gpio
led = gpio.led(0); level = [0]; step = [4]
def breathe(t):
    level[0] += step[0]
    if level[0] >= 100 or level[0] <= 0:
        step[0] = -step[0]                 # ถึงสุด/ต่ำสุด -> กลับทาง
    led.brightness(level[0])
Timer(0, mode=Timer.PERIODIC, period=30, callback=breathe)   # เฟดเองทุก 30 ms
# โค้ดเกมข้างล่างนี้ยังทำงานต่อได้ตามปกติ — ไฟหายใจอยู่เบื้องหลัง

ป้ายกำกับให้ชัด: โค้ด breathe นี้เป็น ตัวอย่างเสริม (enrichment) สาธิตแนวคิด — ไม่ได้อยู่ในไฟล์ s01_interrupt.py ที่เราฝึกจริง (ไฟล์นั้นกระพริบด้วย software timer ticks_ms ล้วน ๆ ไม่ใช้ machine.Timer) ยกมาให้เห็นว่าถ้าอยากได้ animation ที่ไม่บล็อกก็ทำแบบนี้ได้

หลักเดียวกันใช้ได้กับ sprite เปลี่ยนเฟรม / ไฟกะพริบ: ยกงาน "ทำเป็นจังหวะ" ออกจาก main loop ไปไว้ที่ Timer

software timer ก็ยังเป็นเทคนิคพื้นฐานที่ต้องรู้

มี machine.Timer (ฮาร์ดแวร์) แล้ว แต่ software timer ด้วย time.ticks_ms() ยังสำคัญ — มันคือวิธีคุมจังหวะ ภายในลูป ที่ทำงานได้ทุกที่ ไม่กิน counter ฮาร์ดแวร์

  • software timer (ticks_ms): เทียบเวลาเองในลูป — เข้าใจง่าย ไม่จำกัดจำนวน เหมาะกับงานในเกมลูป
  • hardware Timer (machine.Timer): จังหวะนิ่งจากฮาร์ดแวร์ ไม่ดริฟต์ตามภาระเฟรม เด้งคู่ขนานไม่บล็อก

คาบนี้เราฝึก software timer เป็นหลัก (เป็นรากฐาน) — แล้วรู้ไว้ว่าถ้าต้องการจังหวะเป๊ะ ๆ ไม่บล็อก มี machine.Timer ให้ใช้

ตั้งต้น — import และเตรียมอุปกรณ์

นำเข้าโมดูล แล้วเตรียม LED กับปุ่มให้พร้อม:

    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
  • Pin("USER_BUTTON", Pin.IN) ใช้ชื่อขาบนบอร์ดได้เลย ไม่ต้องจำเบอร์ขา
  • press_count เป็นตัวแปรร่วม เดี๋ยว handler จะมาบวกค่าให้
tactile push-button on a PCB
ที่มา: "Tactile switches on a printed circuit board" — Raimond Spekking, CC BY-SA 4.0, Wikimedia Commons
ปุ่ม USER_BUTTON ในโค้ดก็คือ tactile switch ตัวจริงที่บัดกรีบนบอร์ดแบบนี้เลย
บนบอร์ดเกมมันคือ USER BTN1 (ขา P7_0) — กดแล้วขาเปลี่ยนสถานะ
ส่วน gpio.led(0) คือ LED ดวงเล็กข้าง ๆ ที่เราสั่งกระพริบ

หัวใจ #1 — interrupt handler (ต้องสั้นและเร็ว)

นี่คือฟังก์ชันที่ ฮาร์ดแวร์เรียกเอง ตอนปุ่มถูกกด เราไม่ได้เรียกมันเอง:

    def on_button(pin):
        # นี่คือ "interrupt handler" — ฮาร์ดแวร์เรียกฟังก์ชันนี้เองตอนปุ่มถูกกด
        # เราไม่ได้เรียกมันเอง และไม่รู้ล่วงหน้าว่ามันจะถูกเรียกตอนไหน
        # กฎเหล็กของ handler: ทำให้สั้นและเร็วที่สุด ห้ามงานหนัก/หน่วงเวลานาน
        global press_count
        press_count += 1
  • กฎเหล็ก: handler ต้องสั้นและเร็วที่สุด ห้ามทำงานหนักหรือหน่วงเวลานานในนี้
  • ใช้ global เพราะเราจะไปแก้ค่า press_count ที่อยู่นอกฟังก์ชัน
  • งานหนัก ๆ (เช่น print) ปล่อยให้ลูปหลักทำ handler แค่นับเพิ่มพอ

หัวใจ #2 — ผูก handler กับขอบขาลง

ตั้งครั้งเดียวจบ จากนั้นฮาร์ดแวร์เฝ้าปุ่มให้เราเอง:

    # ผูก handler กับ "ขอบขาลง" (IRQ_FALLING) = จังหวะที่ปุ่มเปลี่ยนจากปล่อย -> กด
    # ตั้งครั้งเดียวจบ จากนี้ฮาร์ดแวร์จัดการเฝ้าปุ่มให้เราเอง
    btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING)

    print("กดปุ่ม USER BTN1 ได้เลย — ไฟจะกระพริบไปด้วยโดยไม่สะดุด (Ctrl+C เพื่อหยุด)")
  • trigger=Pin.IRQ_FALLING = สั่งให้สนใจเฉพาะ "ขอบขาลง" คือจังหวะ ปล่อย → กด
  • หลังบรรทัดนี้ เราไม่ต้องเขียนโค้ดอ่านปุ่มในลูปอีกเลย ฮาร์ดแวร์ทำให้แล้ว

ลองเทียบกับ Dev I: เมื่อก่อนต้อง if btn.value()... ทุกรอบลูป ตอนนี้เหลือบรรทัดเดียว

"ขอบขาลง" หน้าตาเป็นยังไง — ดูที่สัญญาณจริง

ปุ่มบนบอร์ดต่อแบบ active-low: ปกติสายถูกดึงให้เป็น HIGH ไว้ พอเรากดปุ่ม สายจะถูกต่อลงกราวด์กลายเป็น LOW จังหวะที่มันเปลี่ยน HIGH → LOW นั่นแหละคือ "falling edge" ที่ IRQ_FALLING รอจับ:

  • เลือก IRQ_FALLING = สนใจเฉพาะตอน กด (ขาลง) — ตอนปล่อย (ขาขึ้น/rising) เราไม่สน
  • ถ้าอยากจับตอนปล่อยด้วย ใช้ Pin.IRQ_RISING หรือรวมสองขอบด้วย Pin.IRQ_FALLING | Pin.IRQ_RISING

เลือกขอบให้ตรงกับ "เหตุการณ์จริง" ที่เราสนใจ — กดคือขาลง ปล่อยคือขาขึ้น

เข้าใจฮาร์ดแวร์ · interrupt — ฮาร์ดแวร์สะกิดเอง

ใน Developer I เราอ่านปุ่มแบบ polling (วนเช็คเองทุกเฟรม) — interrupt คือให้ฮาร์ดแวร์ "สะกิด" CPU เองตอนขาเปลี่ยน

  • btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING) = เรียก handler ทันทีที่ขาเปลี่ยน HIGH → LOW (falling edge = จังหวะกดปุ่ม active-low)
  • CPU ไม่ต้องคอยถาม → ตอบสนองทันที + ประหยัดแรง · แต่ handler ต้อง สั้นและเร็ว อย่าหน่วงนาน

polling = คอยถาม "กดยัง? กดยัง?" · interrupt = "กดเมื่อไหร่ค่อยบอก" — embedded จริงใช้ interrupt ตลอด

interrupt อยู่ตรงไหนในชิปจริง

Pin.irq กับ machine.Timer ที่เราเรียกด้วย Python ไม่ใช่เวทมนตร์ — มันสั่ง บล็อกฮาร์ดแวร์จริง ที่ฝังอยู่ในไมโครคอนโทรลเลอร์ตัวเล็ก ๆ นี้ ทั้งขา GPIO, ตัวจัดคิว interrupt (NVIC ของ Cortex-M), และตัวนับเวลา (timer/TCPWM) ล้วนเป็นวงจรบนซิลิคอนชิ้นนี้

microcontroller chip
ที่มา: "NXP LPC18B1" — Henriok, CC0, Wikimedia Commons
GPIO pin and TCPWM timer feed NVIC which interrupts the CPU core
บล็อกที่มีชื่อในชิป (ซ้าย) ต่อกันตามผังนี้ (ขวา): GPIO / TCPWM → NVIC → CPU core

เชื่อมโยงรากฐาน — เกมเล็ก ๆ คาบนี้แตะรากฐานวิศวกรรมฝังตัวพอดี:
Embedded: GPIO (ขาปุ่ม/LED) · Interrupt (NVIC สะกิด CPU) · Timer (นับเวลาเอง) — สามเสาหลักของ MCU
Python: on_button คือ callback — เราส่ง "ฟังก์ชัน" ไปให้ระบบเก็บไว้เรียกทีหลัง เป็นแนวคิดเดียวกับ event handler ในงานจริง
เข้าใจสามคำนี้แล้ว ต่อไปจะอ่าน datasheet ชิปตัวไหนก็เห็นภาพว่าของพวกนี้อยู่ตรงไหน

เกร็ด: ทำไม embedded สำคัญ — Atari 2600: racing the beam

บน 2600 ทุก CPU cycle ต้องตรงเป๊ะกับจังหวะจอ CRT — พลาด timing นิดเดียว = ภาพเพี้ยนทั้งเฟรม (hard real-time แท้ ๆ)

เชื่อมกับวันนี้: interrupt (Pin.irq) ที่น้องเพิ่งเรียน คือเครื่องมือจัดการ timing แบบ real-time เดียวกัน — แต่สบายกว่าเยอะ

ที่มา: Atari 2600 · ภาพ Evan-Amos (public domain) · en.wikipedia.org/wiki/Atari_2600

หัวใจ #3 — software timer แบบไม่บล็อก (เตรียมตัวแปร)

ก่อนเข้าลูป เตรียมตัวแปรจดเวลาและสถานะไฟ:

    BLINK_MS = 500              # อยากให้ไฟสลับสถานะทุก ๆ กี่มิลลิวินาที
    led_on = False
    last_blink = time.ticks_ms()   # จดเวลาเริ่มต้นไว้เทียบ
    last_reported = -1             # กันพิมพ์ค่าซ้ำ ๆ เมื่อจำนวนกดไม่เปลี่ยน
  • BLINK_MS = ระยะเวลาที่อยากให้ไฟสลับสถานะ (500 มิลลิวินาที = ครึ่งวินาที)
  • last_blink จดเวลา "ครั้งสุดท้ายที่สลับไฟ" ไว้ เดี๋ยวเอามาเทียบว่าครบช่วงหรือยัง
  • last_reported ไว้กันพิมพ์ค่าซ้ำ ถ้าจำนวนกดยังไม่เปลี่ยนก็ไม่ต้องพิมพ์ใหม่

หัวใจ #4 — ลูปหลักแบบไม่บล็อก

หัวใจของ non-blocking: เทียบเวลาเองด้วย ticks_diff แทนการ sleep ยาว

    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
  • time.ticks_diff(now, last_blink) = เวลาที่ผ่านไปตั้งแต่สลับไฟครั้งล่าสุด
  • ครบ BLINK_MS เมื่อไหร่ค่อยสลับไฟ ระหว่างนั้นลูปไม่หยุดรอ จึงไม่ขวางการนับปุ่ม

เส้นน้ำเงินคือ now - last_blink ไต่ขึ้นทุกรอบลูป พอแตะเส้น BLINK_MS ก็สลับไฟแล้วรีเซ็ต last_blink = now — ลูปไม่เคยหยุดรอเลย

หัวใจ #4 (ต่อ) — รายงานผลและพักสั้น ๆ

ส่วนท้ายของลูป: รายงานเมื่อค่าเปลี่ยน แล้วพักสั้น ๆ

        # ปุ่มถูกนับให้แล้วโดย interrupt — ลูปแค่คอยรายงานเมื่อค่าเปลี่ยน
        if press_count != last_reported:
            print("ปุ่มถูกกดแล้ว", press_count, "ครั้ง")
            last_reported = press_count

        time.sleep_ms(5)   # พักสั้น ๆ พอ ไม่ให้ลูป busy เกินจำเป็น (ยังถือว่าไม่บล็อก)
  • ลูปไม่ต้องอ่านปุ่มเอง interrupt นับให้แล้ว ลูปแค่ดูว่าค่าเปลี่ยนไหมแล้วรายงาน
  • sleep_ms(5) พักสั้นมาก แค่กันไม่ให้ลูปกิน CPU เกินจำเป็น (5 ms ถือว่าไม่บล็อก)

สังเกต: ไฟกระพริบทุก 500 ms กับการนับปุ่ม "ทำงานพร้อมกัน" ในลูปเดียว ไม่มีใครรอใคร

เห็นภาพเคลื่อนไหว — ไฟไม่เคยหยุด ปุ่มก็ไม่ตกหล่น

ลองนึกตามภาพนี้: main loop กระพริบไฟตามจังหวะของมันเอง ส่วนตอนกดปุ่ม ฮาร์ดแวร์ "แทรก" เข้ามาเรียก on_button() บวกตัวนับ แล้วคืนคุมให้ลูปทันที — ไฟไม่สะดุดแม้แต่จังหวะเดียว

  • interrupt แทรกเข้ามาเร็วมากจน main loop แทบไม่รู้สึก งานกระพริบจึงเดินต่อเนื่อง
  • ถ้าเป็น polling แล้วเผลอ sleep ยาว ช่วงนั้นปุ่มอาจถูกกดแล้ว "ตกหล่น" ได้ — interrupt ไม่พลาด

ภาพนี้คือหัวใจของคาบ: สองงานเดินขนานกันโดยไม่ขวางกัน (concurrency แบบ non-blocking)

ภาพรวมทั้งโปรแกรม — ลูปหลักไม่บล็อก + interrupt แทรก

timeline ที่ผ่านมาเห็น "สองงานขนานกัน" แต่ยังไม่เห็นว่าโค้ดตัดสินใจยังไงในแต่ละรอบ ผังนี้ไล่ control flow จริง ของ s01_interrupt.py ทั้งไฟล์: ลูป while True มี จุดตัดสินใจ (◇) 2 จุด ต่อรอบ ส่วน interrupt (กล่องส้ม) แทรกเข้ามา นอกลูป เมื่อไหร่ก็ได้ แล้วบวก press_count ให้เงียบ ๆ

ตั้ง irq btn.irq() · L41 now = ticks_ms() while True · L50-51 ครบ BLINK_MS? L55 press_count เปลี่ยน? L61 sleep_ms(5) L65 สลับ LED led.on/off · L56-58 print นับปุ่ม L62-63 on_button(): press_count += 1 hardware IRQ · L32-37 No No Yes Yes async วนทุกรอบลูป — ไม่เคยหยุดรอ (non-blocking)
◻ เทา = เริ่ม/ตั้งค่า  ·  ◻ น้ำเงิน = ประมวลผล  ·  ◇ ม่วง = ตัดสินใจ  ·  ◻ เขียว = อ่าน/วาด (I/O)  ·  ◻ ส้ม = interrupt (ฮาร์ดแวร์แทรก)

จับสังเกต: ทั้งสองจุดตัดสินใจตอบ No บ่อยกว่า Yes มาก — ลูปเลยวิ่งฉิว ไม่ค้างที่ sleep ยาว ส่วน press_count ถูกบวกจากกล่องส้ม (นอกลูป) ลูปแค่ "เห็นค่าเปลี่ยน" แล้วรายงาน นี่คือเหตุผลที่ไฟกระพริบไม่มีวันสะดุดตอนกดปุ่ม

วิธีรันบนบอร์ด

เราไม่รันด้วย exec(open(...)) แต่ใช้วิธีมาตรฐานของ BENTO IDE:

  1. เปิดไฟล์ s01_interrupt.py ใน BENTO IDE
  2. กด Program to Device เพื่อส่งโค้ดลงบอร์ด
  3. กดปุ่ม USER BTN1 บนบอร์ด แล้วดูข้อความ "ปุ่มถูกกดแล้ว N ครั้ง" ใน console
  4. สังเกตว่า ไฟยังกระพริบสม่ำเสมอ ตลอดเวลา ไม่ว่าจะกดปุ่มถี่แค่ไหน
BENTO IDE Program to Device button
ปุ่ม Program to Device (มุมซ้ายบน) ใน BENTO IDE — ที่มา: ภาพหน้าจอ BENTO IDE (สื่อประกอบคอร์ส)
run flow

ที่ไฟไม่สะดุดตอนกดปุ่ม คือผลของ interrupt + non-blocking loop ทำงานคู่กัน นี่คือสิ่งที่เราอยากให้น้อง ๆ เห็นกับตา

สรุปคาบ 1

Polling (Dev I) เฝ้าถามทุกรอบ Pin.irq() ฮาร์ดแวร์สะกิด ticks_ms() timer ไม่บล็อก
  • เข้าใจ polling vs interrupt และผูก Pin.irq(trigger=Pin.IRQ_FALLING) ได้ ถือว่าผ่านคาบนี้
  • จำว่า handler ต้องสั้น · มีทั้ง software timer (ticks_ms) และ hardware machine.Timer ให้เลือกตามงาน
  • 2 เกมของคอร์สนี้คือ Snake กับ Flappy Bird เราจะค่อย ๆ ต่อยอดทักษะไปถึงเกม

ทบทวนเพิ่มเอง — คลิปสั้นเรื่อง polling กับ interrupt

อยากเห็นแนวคิด polling vs interrupt อธิบายอีกมุมแบบเห็นภาพ ลองดูคลิปนี้หลังเลิกเรียน (ภาษาอังกฤษ สั้น ๆ เข้าใจง่าย):

Polling vs. Interrupts - An Introduction To Microcontrollers - PyroEDU — PyroElectro

สิ่งที่คลิปเรียกว่า "interrupt service routine (ISR)" ก็คือ on_button() ของเรานี่แหละ — แค่คนละภาษา หลักการเดียวกัน

ทำเอง 30% — เติมช่องว่างใน practise

ไฟล์ฝึก practise_codes/s01_interrupt.py เว้นช่องไว้ให้เราเติมเอง 3 จุด:

  1. ใน on_button(pin) — เติมบรรทัดนับเพิ่ม: press_count += 1
  2. หลังนิยาม handler — เติมการผูก: btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING)
  3. ในลูป — เติมเงื่อนไขเช็กเวลา: if time.ticks_diff(now, last_blink) >= BLINK_MS:

ส่งงาน (ก่อนเลิก):

  • โชว์ s01_interrupt.py รันบนบอร์ด ไฟกระพริบ + นับปุ่มได้พร้อมกัน
  • ส่งช่อง 30% ที่เติมเอง
  • เขียน 2 บรรทัด: ทำไม handler ต้องสั้น และทำไมเราไม่ใช้ 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-17
  • btn = Pin("USER_BUTTON", Pin.IN) ใช้ชื่อขาบนบอร์ดตรง ๆ ไม่ต้องจำว่าเป็น P7_0 — บนบอร์ดเกมมันคือ USER BTN1 ตัว tactile switch ที่เราดูรูปกันไปแล้ว ตั้งเป็น Pin.IN เพราะปุ่มเป็นขา "อ่านเข้า" :28
  • press_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 จะสร้างตัวแปรใหม่ในเครื่องขึ้นมาแทน แล้วตัวนับข้างนอกจะไม่ขยับเลย — บั๊กเงียบที่หายาก :36
  • ทำไม handler มีแค่บรรทัดเดียว (press_count += 1)? เพราะ กฎเหล็ก ที่ย้ำในสไลด์ "หัวใจ #1": handler ต้องสั้นและเร็วที่สุด งานหนักอย่าง print เราจงใจไม่ใส่ในนี้ ปล่อยให้ลูปหลักทำ ถ้าเผลอหน่วงเวลาในนี้ interrupt ตัวถัดไปจะถูกกั้นไว้ ระบบสะดุดทั้งเครื่อง :33-35
  • บรรทัด btn.irq(handler=on_button, trigger=Pin.IRQ_FALLING) คือการ ส่งฟังก์ชันไปให้ระบบเก็บไว้เรียกทีหลัง ตรงนี้แหละที่สไลด์เรียกว่า callback เราส่งชื่อ on_button เฉย ๆ ไม่ใส่วงเล็บ เพราะไม่ได้เรียกมันตอนนี้ แค่ฝากไว้ :41
  • trigger=Pin.IRQ_FALLING เลือกจับเฉพาะ "ขอบขาลง" คือจังหวะ ปล่อย → กด ของปุ่ม active-low ตรงกับสไลด์ "ขอบขาลงหน้าตาเป็นยังไง" หลังบรรทัดนี้ เราไม่ต้องเขียนโค้ดอ่านปุ่มในลูปอีกเลย ต่างจาก Dev I ที่ต้อง if btn.value() ทุกรอบ :39-41

ประโยคเดียวที่อยากให้จำจากสไลด์นี้: ตั้ง irq ครั้งเดียวจบ จากนั้นฮาร์ดแวร์เฝ้าปุ่มให้เราเอง — เราเอาเวลาไปทำอย่างอื่นได้เต็มที่

เฉลย · ลูปหลักที่ "ไม่เคยหยุดรอ" — software timer คุมจังหวะไฟ

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-58
  • last_reported = -1 ตั้งเป็นค่าที่ press_count (เริ่มที่ 0) ไม่มีวันเท่า จงใจให้รอบแรกที่ยังไม่กดปุ่มก็ไม่พิมพ์ พอค่าเปลี่ยนค่อยรายงานทีเดียว กันจอรกด้วยข้อความซ้ำ :48,61-63
  • time.sleep_ms(5) ต่างจาก sleep ยาวของ Dev I ตรงที่มันสั้นมากจนถือว่าไม่บล็อก แค่กันไม่ให้ลูป busy กิน CPU ฟรี ๆ — ตอบไวพอสำหรับสายตาคน แต่ไม่เผาแรงเครื่องทิ้ง :65
  • ผลลัพธ์คือสิ่งที่สไลด์ "เห็นภาพเคลื่อนไหว" อยากให้เห็น: ไฟกระพริบทุก 500 ms กับการนับปุ่ม ทำงานพร้อมกันในลูปเดียว ไม่มีใครรอใคร นี่คือ concurrency แบบ non-blocking ของจริง

ถ้าจับได้ว่า "ไม่ใช้ 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 หัวใจโปรแกรมฝังตัว
  • ชิ้นที่ 1→2 คือการย้าย "ใครเป็นคนถามปุ่ม" จากตัวเราในลูป (Dev I polling) ไปเป็นฮาร์ดแวร์ (interrupt) — ปุ่มเลยไม่มีวันตกหล่นแม้เรากำลังยุ่งอยู่
  • ชิ้นที่ 2→3 คือการปลดล็อกลูปหลัก พอ interrupt รับหน้าที่นับปุ่มไปแล้ว ลูปเลยว่างพอจะทำ software timer ได้โดยไม่ต้องพะวงเรื่องปุ่ม สองเรื่องนี้จึงอยู่ด้วยกันในไฟล์เดียวอย่างมีเหตุผล ไม่ใช่บังเอิญ
  • ทั้งสามชิ้นรวมกันได้ โครง event + non-blocking loop อันเดียวกับที่ game loop ของ Snake กับ Flappy Bird ใช้: รับ input แบบไม่พลาด แล้ววาดเฟรมต่อไปโดยไม่ค้าง

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

เชื่อมจุด — interrupt ดวงนี้มาจากไหน แล้วจะพาเราไปถึงไหน

หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องไวยากรณ์โค้ด แต่เรื่อง "มองให้ทะลุ" ว่า 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)
ที่มา polling นั่งเฝ้าถามเอง (Dev I) วันนี้ Pin.irq + ticks_ms ไม่บล็อก (ปุ่ม + ไฟพร้อมกัน) ที่ไป game loop รับ input ไม่ค้าง (Snake / Flappy)

ถ้าตอบสามคำถามข้างบนได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด: interrupt กับ non-blocking loop วันนี้ไม่ใช่แบบฝึกหัด มันคือโครงกระดูกของทุกเกมที่เรากำลังจะสร้าง

ใช้จริงที่ไหน — Pin.irq · edge counting · non-blocking loop

เทคนิคสามอย่างของวันนี้ไม่ใช่แค่แบบฝึกหัด มันคือสิ่งที่เฟิร์มแวร์จริงใช้กันทุกวัน:

กริ่ง / คีย์แพด / ปุ่มฉุกเฉิน CPU หลับประหยัดไฟ (idle HIGH) กด → IRQ_FALLING ปลุก ไม่มีใคร poll ปุ่มกริ่งทั้งวันให้เปลืองแบต นับรอบมอเตอร์ / tachometer พัลส์ 1 ลูก = หมุน 1 รอบ 1 2 3 4 5 ผูก interrupt ที่ขอบ → บวก press_count ใน handler rotary encoder / ลูกบิดปรับเสียง หมุน 1 คลิก = 1 ขอบ ระดับเสียง IRQ_RISING/FALLING → เพิ่ม/ลดระดับทันที heartbeat LED · 1 Hz ticks_ms คู่ขนาน main loop (non-blocking) ไฟหยุดเต้น = เฟิร์มแวร์ค้าง (liveness)
  • กริ่งประตู / คีย์แพด / ปุ่มฉุกเฉิน — CPU หลับประหยัดไฟเกือบตลอด ใช้ Pin.irq แบบ IRQ_FALLING ปลุกเฉพาะตอนมีคนกด ตรงกับท่า "ผูก handler กับขอบขาลง" ของเรา — ไม่มีใคร poll ปุ่มกริ่งทั้งวันให้เปลืองแบต
  • นับรอบมอเตอร์ / tachometer — เซนเซอร์ส่งพัลส์หนึ่งลูกต่อรอบ เฟิร์มแวร์ผูก interrupt ที่ขอบสัญญาณแล้ว บวกตัวนับในhandler เหมือน press_count += 1 ของเรา พัลส์มาเร็วแค่ไหนก็ไม่พลาด เพราะ handler สั้นและเร็วตามกฎเหล็ก
  • rotary encoder / ลูกบิดปรับเสียง — หมุนหนึ่งคลิกเกิดหนึ่งขอบสัญญาณ ระบบจับด้วย edge interrupt เดียวกับ IRQ_FALLING/IRQ_RISING ที่เราเรียน แล้วเพิ่ม/ลดระดับเสียงทันทีโดยจอไม่กระตุก
  • heartbeat LED + main loop — เฟิร์มแวร์จริงมักกระพริบ LED 1 Hz บอกว่า "ยังไม่แฮงก์" ด้วย ticks_ms แบบไม่บล็อก คู่ขนานกับงานหลักที่ยังรับ event อื่นได้ตลอด นี่คือ software timer + non-blocking loop ของเราในเครื่องจริง

ทั้งสี่ตัวอย่างนี้ไม่มีอันไหนเป็นของสมมติ ปุ่ม-นับ-กระพริบที่เราเล่นวันนี้ คือหลักการเดียวกับที่เดินอยู่ในเครื่องรอบตัวเราตอนนี้

ต่อยอด — คิดต่อเอง

ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงและเกมที่เรากำลังจะสร้างได้:

แบบ poll — while + time.sleep_ms() เช็คปุ่ม time.sleep_ms(500) — CPU ไม่ฟังปุ่ม เช็คปุ่ม กดตอนนี้ กดช่วง sleep → CPU ไม่รู้ ตกหล่น ไม่ถูกนับ เปลี่ยนวิธีคิด แบบ interrupt — btn.irq(IRQ_FALLING) main loop ไม่บล็อก: กระพริบไฟ + งานอื่นเดินต่อ irq แทรกทันทีที่กด → นับครบทุกครั้ง กดตอนไหนก็รับได้ — ไฟยังกระพริบต่อ ไม่สะดุด
  • ปุ่มเด้ง (button bounce) — กดปุ่มจริงเร็ว ๆ บางทีนับเพิ่มหลายครั้งทั้งที่กดหนเดียว เพราะหน้าสัมผัสโลหะเด้งจริง จะกันการนับซ้ำนี้ยังไงโดย ไม่ผิดกฎ handler ต้องสั้น? (ใบ้: เอา ticks_ms มาจำเวลากดครั้งล่าสุด)
  • สองปุ่ม สองความหมาย — ปุ่มหนึ่งเพิ่มค่า อีกตัวลดค่า จะผูก Pin.irq ให้ใช้ handler คนละตัว หรือ handler เดียวแล้วดูจาก argument pin ว่ามาจากปุ่มไหน แบบไหนสะอาดกว่าเมื่อปุ่มเยอะขึ้น
  • ปรับจังหวะไฟตามเหตุการณ์ — ตอนนี้ BLINK_MS = 500 คงที่ ถ้าอยากให้ไฟกระพริบ เร็วขึ้นเมื่อกดปุ่มถี่ขึ้น (เอา press_count มาคิดเป็นความถี่) จะเปลี่ยนโครง non-blocking loop ตรงไหน โดยยังไม่กลับไป sleep ยาว
  • จากปุ่มสู่ทิศทางงู (บทต่อไป) — คาบหน้าขึ้น Snake ต้องวาดงูเดินเป็นเฟรม ๆ พร้อมรับปุ่มบังคับทิศ ลองคิดล่วงหน้าว่าจะเอาโครง "interrupt เก็บ input ใส่ตัวแปรร่วม + main loop ไม่บล็อกวาดเฟรม" ของวันนี้ ไปทำให้งูเลี้ยวตามปุ่มโดยเกม ไม่ค้างระหว่างรอ ได้ยังไง — สะพานตรงเข้าคาบ Snake #1

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

fit-css

← Roadmap (TOC)