2 คะแนน โดย GN⁺ 2025-07-06 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • CAMLBOY คือ อีมูเลเตอร์ Game Boy ที่พัฒนาด้วย OCaml และทำงานบนเบราว์เซอร์
  • เป็นโปรเจ็กต์ที่เลือกขึ้นมาเพื่อเรียนรู้ การพัฒนาโปรเจ็กต์ขนาดกลางถึงใหญ่และการใช้งานฟีเจอร์ขั้นสูง ของ OCaml จากของจริง
  • ใช้คุณลักษณะของภาษา OCaml อย่างเป็นประโยชน์จริงหลายด้าน เช่น โครงสร้างพื้นฐาน, abstraction, GADT, functor, การสลับโมดูลขณะรันไทม์
  • ทำงานบนเบราว์เซอร์ที่ 60FPS และแบ่งปันประสบการณ์ด้าน การปรับปรุงประสิทธิภาพ การวิเคราะห์คอขวด และการเพิ่มประสิทธิภาพ
  • สรุปทั้งระบบนิเวศของ OCaml การทำเทสต์อัตโนมัติ และ ผลของการพัฒนาอีมูเลเตอร์ต่อการยกระดับทักษะการทำงานจริง

ภาพรวมโปรเจ็กต์

  • ผู้เขียนทำ โปรเจ็กต์ CAMLBOY มาหลายเดือน และสร้างอีมูเลเตอร์ Game Boy ด้วย OCaml
  • สามารถรันได้ที่ หน้าสาธิต และมี homebrew ROM ให้ลองหลายตัว
  • เปิดเผยซอร์สโค้ดไว้ที่ GitHub

แรงจูงใจในการเรียน OCaml และที่มาของการเลือกโปรเจ็กต์

  • เมื่อเรียนภาษาใหม่ มักรู้สึกว่ามีข้อจำกัดในการทำความเข้าใจ วิธีเขียนโค้ดขนาดกลาง/ใหญ่ และ วิธีใช้ฟีเจอร์ขั้นสูงในงานจริง
  • เพื่อแก้ปัญหานี้ จึงรู้สึกว่าจำเป็นต้องมี ประสบการณ์จากโปรเจ็กต์ที่ใช้งานได้จริง และเลือกพัฒนาอีมูเลเตอร์ Game Boy
  • เหตุผล
    • สเปกชัดเจน ทำให้ขอบเขตการพัฒนากำหนดได้แน่นอน
    • ซับซ้อนพอสมควร แต่ยังอยู่ในขนาดที่ทำเสร็จได้ภายในไม่กี่เดือน
    • มีแรงจูงใจส่วนตัวสูง

เป้าหมายของอีมูเลเตอร์

  • เขียนโค้ดโดยให้ความสำคัญกับ ความอ่านง่ายและการดูแลรักษา
  • คอมไพล์เป็น JavaScript เพื่อรันบนเบราว์เซอร์ ด้วย js_of_ocaml
  • ทำให้ได้ FPS ที่เล่นได้ แม้ในเบราว์เซอร์บนมือถือ
  • ทำเบนช์มาร์กเปรียบเทียบประสิทธิภาพของคอมไพเลอร์แบ็กเอนด์หลายแบบ

เป้าหมายของบทความและประเด็นหลัก

บทความนี้มีจุดประสงค์เพื่อแบ่งปัน เส้นทางการสร้างอีมูเลเตอร์ Game Boy ด้วย OCaml
เนื้อหาที่กล่าวถึง:

  • ภาพรวมสถาปัตยกรรม ของ Game Boy
  • วิธีจัดโครงสร้างโค้ดให้ทดสอบได้และนำกลับมาใช้ซ้ำได้สูง
  • การใช้งานฟีเจอร์ OCaml ขั้นสูงอย่าง functor, GADT, first-class module ในภาคปฏิบัติ
  • ประสบการณ์ในการหาคอขวดด้านประสิทธิภาพ รวมถึง การเพิ่มประสิทธิภาพและการปรับปรุง
  • มุมมองต่อ OCaml โดยรวม

โครงสร้างทั้งหมดและอินเทอร์เฟซหลัก

  • ฮาร์ดแวร์หลักอย่าง CPU, Timer, GPU ทำงานตาม สัญญาณนาฬิกาที่ซิงก์กัน
  • บัส ทำหน้าที่เข้าถึงหรือส่งต่อข้อมูลไปยังโมดูลฮาร์ดแวร์แต่ละตัวตามแอดเดรส
  • โมดูลฮาร์ดแวร์แต่ละตัว implement อินเทอร์เฟซ Addressable_intf.S
  • บัสทั้งระบบเป็นไปตามอินเทอร์เฟซ Word_addressable_intf.S

วิธีการทำงานของเมนลูป

  • เพื่อซิงก์ฮาร์ดแวร์ เมนลูปจะทำขั้นตอนวนซ้ำดังนี้
    1. รันคำสั่ง CPU 1 คำสั่ง และบันทึกจำนวน cycle ที่ใช้
    2. เดินหน้า Timer, GPU ตามจำนวน cycle เท่ากัน
  • วิธีนี้ทำให้จำลองสภาวะการซิงก์ของฮาร์ดแวร์จริงได้
  • มีคำอธิบายพร้อมตัวอย่างโค้ดประกอบ

abstraction สำหรับการอ่าน/เขียนข้อมูล 8 บิตและ 16 บิต

  • หลายโมดูล implement อินเทอร์เฟซรับเข้า/ส่งออกข้อมูล 8 บิต (Addressable_intf.S)
  • การขยายไปสู่ การอ่าน/เขียน 16 บิต ทำผ่าน Word_addressable_intf.S โดยสืบทอดและเพิ่มฟังก์ชัน
  • สร้างชั้น abstraction ด้วย signature และวิธี include โมดูลไทป์ ของ OCaml

การ implement บัส รีจิสเตอร์ และ CPU

  • บัส: ทำหน้าที่ route ตามแอดเดรสไปยังโมดูลฮาร์ดแวร์แต่ละตัว โดยแยกการทำงานตาม memory map
  • รีจิสเตอร์: มีอินเทอร์เฟซสำหรับอ่าน/เขียน รีจิสเตอร์ 8 บิตและ 16 บิต
  • CPU: ในช่วงแรกพึ่งพาบัสอย่างมาก ทำให้ ทดสอบได้ยาก
    • เมื่อใช้ functor จึงสามารถ ทำ abstraction ของ dependency และ inject mock ได้
    • ส่งผลให้การเขียน unit test ง่ายขึ้นมาก

การแสดงชุดคำสั่ง (ใช้ GADT)

  • Game Boy มีทั้งคำสั่ง 8 บิตและ 16 บิต จึงต้องการความปลอดภัยของชนิดข้อมูลในการนิยาม instruction
  • วิธีแบบ variant ธรรมดา ทำให้เกิดปัญหาความขัดแย้งของชนิดค่าที่คืนมาจาก pattern matching ที่ซับซ้อน
  • จึงใช้ GADT (Generalized Algebraic Data Type) เพื่อให้จับคู่ทั้งชนิดอินพุตและเอาต์พุตได้อย่างปลอดภัย
  • เมื่อใช้ GADT จะอนุมานชนิดของอาร์กิวเมนต์และค่าที่คืนมาของแต่ละ instruction ได้อย่างแม่นยำ
  • รองรับแพตเทิร์นคำสั่งและพารามิเตอร์ที่ซับซ้อนได้อย่างปลอดภัย

คาร์ทริดจ์และการเลือกโมดูลขณะรันไทม์

  • คาร์ทริดจ์ Game Boy นอกจาก ROM ธรรมดาแล้ว อาจมีฮาร์ดแวร์เพิ่มเติมอย่าง MBC หรือตัวจับเวลาอยู่ด้วย
  • จำเป็นต้อง implement แยกเป็นแต่ละโมดูลตามชนิด และเลือกโมดูลที่เหมาะสมตอนรันไทม์
  • ใช้ first-class module เพื่อทำการสลับโมดูลขณะรันไทม์และรองรับการขยายในอนาคต

การทดสอบและการพัฒนาเชิงสำรวจ

  • ใช้ test ROM และ ppx_expect
    • มี test ROM แยกตามฟังก์ชัน เช่น การคำนวณเลขคณิต การรองรับ MBC และการตรวจสอบเฉพาะด้านอื่น ๆ
    • เมื่อเกิดความล้มเหลว สามารถวินิจฉัยได้ชัดเจน เช่น แสดงผลบนหน้าจอ
  • integration test ช่วยสร้างความมั่นใจเมื่อต้องรีแฟกเตอร์ครั้งใหญ่หรือเพิ่มฟีเจอร์ใหม่
  • ใช้ แนวทางพัฒนาเชิงสำรวจ โดยวนทำ implementation และตรวจสอบซ้ำด้วย test ROM

UI บนเบราว์เซอร์และการปรับแต่งประสิทธิภาพ

  • สร้างบิลด์ JS ได้ อย่างง่ายดาย ด้วย js_of_ocaml
  • ใช้ไลบรารี Brr เพื่อเข้าถึง Javascript DOM API แบบปลอดภัยในสไตล์ OCaml
  • ประสิทธิภาพเริ่มต้น (20FPS) ยังต่ำ แต่ใช้ Chrome profiler วิเคราะห์คอขวดใน GPU, timer, Bigstringaf ฯลฯ
  • ทำคอมมิตปรับแต่งในแต่ละโมดูล และปิด inline ที่ไม่มีประสิทธิภาพในบิลด์ JS จน บรรลุ 60FPS สุดท้าย (ทั้ง PC/มือถือ)
  • ในบิลด์เนทีฟทำประสิทธิภาพได้สูงถึง 1000FPS

เบนช์มาร์กและการเปรียบเทียบฮาร์ดแวร์

  • implement โหมดเบนช์มาร์กแบบ headless เพื่อวัด FPS ในแต่ละสภาพแวดล้อมได้

การพัฒนาอีมูเลเตอร์กับทักษะการทำงานจริง

  • คล้ายกับ competitive programming ตรงที่ต้องวนลูปตีความสเปก → implement → ตรวจสอบ อย่างชัดเจน
  • เป็นประสบการณ์ที่ช่วยงานพัฒนาและการทดสอบที่อิงสเปกได้จริงอย่างมาก

พัฒนาการของระบบนิเวศและเครื่องมือ OCaml ยุคใหม่

  • dune มอบประสบการณ์ ระบบบิลด์ที่ใช้งานง่าย
  • Merlin, OCamlformat ฯลฯ ช่วยให้ autocomplete, การนำทางในโค้ด, การจัดรูปแบบ ทำได้สะดวก
  • setup-ocaml ก็ใช้งานกับ Github Actions ได้อย่างง่ายดาย

ข้อคิดเกี่ยวกับภาษาฟังก์ชัน

  • ผู้เขียนตั้งข้อสงสัยต่อคำอธิบายที่ว่าภาษาฟังก์ชันคือการ ลด side effect ให้เหลือน้อยที่สุด
  • ในความเป็นจริงมีการใช้ mutable state ที่ซ่อนอยู่ภายใต้ abstraction อย่างจริงจังเพื่อประสิทธิภาพ
  • ผู้เขียนชื่นชอบ static type, pattern matching, module system, type inference

ความไม่สะดวกและต้นทุนของการพึ่งพา abstraction

  • การจัดการ dependency ยังซับซ้อนและมีคำอธิบายมาตรฐานไม่เพียงพอ (เช่น opam)
  • เมื่อใส่ abstraction ด้วย โครงสร้าง module-functor ก็จำเป็นต้องแก้โครงสร้างลำดับชั้น dependency ทั้งชุด
  • ต่างจาก OOP เพราะเมื่อเพิ่ม abstraction แล้ว ยังต้อง เปลี่ยนวิธีเขียนโมดูลที่พึ่งพาในชั้นบนด้วย

แหล่งเรียนรู้ที่แนะนำ

  • Learn OCaml Workshop: สำหรับผู้เริ่มต้น โดยเรียนผ่านโค้ดจริงและการทดสอบ
  • Real World OCaml: เรียนรู้สไตล์ OCaml ที่ใช้จริงผ่านตัวอย่างงานจริง
  • The Ultimate Game Boy Talk: วิดีโอภาพรวมสถาปัตยกรรม
  • gbops, Game Boy CPU Manual, Pandocs, Imran Nazar’s blog: แหล่งอ้างอิงเกี่ยวกับ instruction และฮาร์ดแวร์ของ Game Boy

บทสรุป

  • ผ่านโปรเจ็กต์ CAMLBOY ผู้เขียนได้สัมผัส ฟีเจอร์ขั้นสูงของ OCaml รวมถึงการทดสอบ abstraction และความเข้ากันได้กับเบราว์เซอร์ ในเชิงปฏิบัติ
  • มองเห็นได้ชัดทั้งข้อดีและข้อจำกัดที่ได้จากพัฒนาการของระบบนิเวศและประสบการณ์พัฒนาจริง
  • การพัฒนาอีมูเลเตอร์ช่วยยกระดับฝีมือของนักพัฒนาระดับกลางขึ้นไปได้อย่างเป็นรูปธรรม

1 ความคิดเห็น

 
GN⁺ 2025-07-06
ความคิดเห็นใน Hacker News
  • สงสัยว่ามีใครที่กล้าพูดอย่างมั่นใจไหมว่ามีภาษาโปรแกรมบางภาษาที่เหมาะกับการเขียน emulator, virtual machine, bytecode interpreter มากกว่าเป็นพิเศษ โดยคำว่า "ดีกว่า" ในที่นี้ไม่ได้หมายถึงประสิทธิภาพหรือการลดบั๊กในการพัฒนา แต่หมายถึงความรู้สึกว่าตอนลงมือสร้างและสำรวจเองแล้วมันเข้าใจง่ายกว่า ได้เรียนรู้อะไรมากกว่า และประสบการณ์ในการลงมือทำเองนั้นทั้งคุ้มค่าและสนุก ตัวอย่างเช่น Erlang มีเป้าหมายที่ชัดเจนในโลกของ distributed systems และทั้งความรู้เชิงโดเมนกับการออกแบบภาษาก็สอดคล้องกันดี พอได้ลองใช้ก็จะเข้าใจทั้ง distributed systems และตัว Erlang เองลึกขึ้น เลยสงสัยว่าจะมีภาษาที่เป้าหมายคือ "การถ่ายทอดการทำงานของเครื่องจักรออกมาเป็นโค้ด" ในลักษณะนี้หรือไม่

    • ส่วนตัวอยากเน้นว่า C, C++, Rust, Zig ซึ่งเป็นภาษาสำหรับ system programming คือทางเลือกที่ "ให้ความพึงพอใจสูง" ที่สุด ชนิดข้อมูลของภาษาเหล่านี้อย่างเช่น uint8 แทบจะตรงกับ byte ในหน่วยความจำโดยตรง และโอเปอเรชันอย่าง memcpy ก็แทบจะเหมือนงาน blit ไปเลย แทบไม่ต้องมาปวดหัวกับการฝืนใช้ Number ของ JavaScript ให้กลายเป็น byte สำหรับงาน bit operation ปัญหานี้จะเจอทันทีถ้าลองทำ emulator ด้วย JavaScript แน่นอนว่าถ้าภาษาไหนรองรับการแสดงผลกราฟิกและมีหน่วยความจำพอ ก็ทำงานแนวนี้ได้คล้าย ๆ กันหมด และสุดท้ายถ้าเลือกภาษาที่ตัวเองถนัดที่สุด ก็มักจะได้ความสนุกสูงสุดอยู่ดี

    • Haskell โดดเด่นมากกับงานแปลงข้อมูลที่จำเป็นต่อ DSL และ compiler ส่วน OCaml, Lisp และภาษาสมัยใหม่ที่รองรับ pattern matching กับ ADT ก็เหมาะเหมือนกัน Modern C++ เองก็พอจะพยายามทำอะไรคล้าย ๆ กันได้ด้วย variant type เป็นต้น แต่ไม่ค่อยสวยนัก ถ้าตั้งใจจะรันเกมจริง ๆ บน emulator ทางเลือกมาตรฐานก็ยังเป็น C หรือ C++ ส่วน Rust ก็น่าจะพอได้เหมือนกัน แต่เรื่องการจัดการหน่วยความจำระดับล่างยังไม่แน่ใจ

    • มองว่าไม่ได้มีภาษาที่ดีกว่าเป็นพิเศษสำหรับการทำ emulator, virtual machine, bytecode interpreter ขอแค่มี array ที่เข้าถึงตำแหน่งใดก็ได้ในเวลาเท่าคงที่ และมี bit operation การทำก็ง่ายมากแล้ว ถ้ายังไม่ไปถึงระดับทำ JIT ภาษาสาย functional ก็รองรับ array และ bit operation กันอยู่แล้ว

    • อยากแนะนำ sml โดยเฉพาะสาย MLTon มันมีข้อดีแทบทุกอย่างแบบเดียวกับที่ OCaml มี แต่ส่วนตัวมองว่าเป็นภาษากลุ่ม ML ที่สำเร็จรูปกว่า สิ่งที่คิดถึงจาก OCaml มีแค่ applicative functor ซึ่งจริง ๆ ก็เป็นแค่ความต่างเล็กน้อยของโครงสร้างโมดูล ไม่ได้ต่างกันมาก

    • ถ้าเน้นความสนุกและการทดลองในเบราว์เซอร์ Elm ก็เป็นตัวเลือกที่ดีเหมือนกัน แนะนำให้ดูโปรเจ็กต์คล้ายกันอย่าง elmboy

  • บทความนี้ยอดเยี่ยมมาก ไม่ใช่แค่เรื่อง Ocaml แต่ยังสรุปกระบวนการทำ Game Boy emulator ได้แน่นมากด้วย ขอขอบคุณผู้เขียนไว้ตรงนี้ด้วย อีกอย่างหนึ่งคือผมมีไอเดียมานานแล้วว่า ถ้าทำประสบการณ์พัฒนา Gameboy homebrew ให้ใครก็เข้าถึงได้ง่าย ผ่าน SPA ในเบราว์เซอร์ที่รวมทั้ง assembler editor, assembler/linker/loader ไว้ในที่เดียว น่าจะเหมาะมากสำหรับการสอน embedded development

    • โปรเจ็กต์ rgbds-live คล้ายกับไอเดียนี้มากและมี RGBDS ฝังมาให้เลย: rgbds-live
  • ไม่แน่ใจว่าจะมีคนกำลังมองหา tutorial เรื่องการทำระบบเสียงใน Game Boy emulator หรือเปล่า เพราะ tutorial ส่วนใหญ่ไม่ค่อยอธิบายเรื่องเสียง และตอนพยายามทำเองก็พบว่าต่อให้มีข้อมูลก็ยังทั้งเข้าใจและลงมือทำได้ยาก

    • อันนี้ไม่ใช่ tutorial อย่างเป็นทางการ แต่ขอแชร์สไลด์ 2 หน้า ที่สรุปวิธีที่ผมใช้ตอนลงมือทำเอง: สไลด์ เสียงของ Game Boy มี 4 channel และแต่ละ channel จะปล่อยค่าในช่วง 0 ถึง 15 ทุก tick ตัว emulator ต้องนำมารวมกัน (ค่าเฉลี่ยเลขคณิต) แล้ว scale ไปเป็นช่วง 0 ถึง 255 ก่อนส่งออกไปยัง sound buffer โดยต้องอิงทั้ง tick rate (4.19MHz) และอัตราการส่งออกเสียง (เช่น 22kHz) ดังนั้นจะได้ค่าออกมาหนึ่งค่าประมาณทุก 190 tick ลักษณะเฉพาะของแต่ละ channel สรุปไว้ดีในเอกสารนี้ channel 1 และ 2 เป็น square wave (สลับ 0/15), channel 3 เป็นคลื่นแบบกำหนดเอง (อ่านจากหน่วยความจำ), channel 4 เป็น noise อิง LSFR แนะนำให้ดู โค้ดตัวอย่าง SoundModeX.java

    • เอกสารนี้ ก็ค่อนข้างดีเหมือนกัน

    • วิดีโอ YouTube นี้ ก็น่าดูเช่นกัน

  • ให้ความรู้สึกว่าเป็นทั้งบทความที่ยอดเยี่ยมและโปรเจ็กต์ที่เท่มาก

  • สิ่งที่สังเกตได้ชัดคือเดโมทำงานเร็วเกินไป ช่องทำเครื่องหมาย Throttle แทบไม่เห็นผลอะไรเลย แถมถ้าปิดเครื่องหมายกลับรู้สึกว่าช้าลงอีก เปิด Throttle แล้วได้ 240fps แต่ปิดแล้วได้ 180fps และเมื่อเปิด Throttle เวลา 1 วินาทีในโลกจริงกลับรู้สึกเหมือน 4 วินาทีใน emulator น่าจะเกี่ยวกับการที่จอมี refresh rate 240Hz

    • น่าจะเรียก requestAnimationFrame() อย่างเดียวแต่ลืมคำนวณ deltaTime
  • คิดว่าเป็นบทความที่สวยมาก ขอบคุณที่แชร์อะไรแบบนี้ ทำให้ผมอยากลองสร้าง Game Boy emulator ด้วย Rust เองขึ้นมาจริง ๆ และเพราะโพสต์บล็อกนี้เป็นแรงบันดาลใจมากก็เลยบุ๊กมาร์กเก็บไว้

  • นี่เป็นตัวอย่างการใช้ functor กับ GADT ที่เจ๋งมากจริง ๆ อยากลองเอาไปเทียบกับ emulator ของ CHIP 8 หรือ NES และก็น่าสนใจเหมือนกันถ้าจะพอร์ต CAMLBOY ไปเป็น WASM ด้วย ocaml-wasm

    • มี WASM backend ตัวใหม่ของ js_of_ocaml (wasm_of_ocaml) อยู่แล้ว เพราะงั้นน่าจะรัน CAMLBOY บน WASM ได้อยู่แล้ว