2 điểm bởi GN⁺ 2025-07-06 | 1 bình luận | Chia sẻ qua WhatsApp
  • CAMLBOY là một trình giả lập Game Boy được phát triển bằng OCaml và chạy trong trình duyệt
  • Đây là dự án được chọn để học thực tế về phát triển dự án quy mô vừa đến lớn và cách dùng các tính năng nâng cao của OCaml
  • Dự án tận dụng thực tiễn nhiều đặc tính ngôn ngữ OCaml như cấu trúc cơ bản, trừu tượng hóa, GADT, functor, thay thế module lúc chạy
  • Chạy ở 60FPS trong trình duyệt, đồng thời chia sẻ trải nghiệm về quá trình cải thiện hiệu năng, phân tích điểm nghẽn và tối ưu hóa
  • Tổng kết về hệ sinh thái OCaml, tự động hóa kiểm thử, và tác động của việc phát triển trình giả lập đối với việc nâng cao năng lực làm việc thực tế

Tổng quan dự án

  • Trong vài tháng, tác giả đã thực hiện dự án CAMLBOY để tạo một trình giả lập Game Boy bằng OCaml
  • Có thể chạy tại trang demo, bao gồm nhiều homebrew ROM
  • Kho mã nguồn được công khai trên GitHub

Động lực học OCaml và bối cảnh chọn dự án

  • Khi học một ngôn ngữ mới, tác giả cảm thấy có giới hạn trong việc học cách viết mã cho các codebase cỡ vừa/lớncách áp dụng thực tế các tính năng nâng cao
  • Để giải quyết vấn đề này, tác giả thấy cần có trải nghiệm dự án thực tế, nên đã chọn phát triển trình giả lập Game Boy
  • Lý do
    • Đặc tả rõ ràng nên phạm vi hiện thực được xác định sẵn
    • Đủ phức tạp nhưng vẫn ở quy mô có thể hoàn thành trong vài tháng
    • Có động lực cá nhân lớn

Mục tiêu của trình giả lập

  • Viết mã ưu tiên tính dễ đọc và dễ bảo trì
  • Dùng js_of_ocaml để biên dịch sang JavaScript và chạy trong trình duyệt
  • Đạt được mức FPS có thể chơi được cả trên trình duyệt di động
  • Xây dựng benchmark hiệu năng cho nhiều backend trình biên dịch khác nhau

Mục tiêu bài viết và nội dung chính

Mục đích của bài viết này là chia sẻ hành trình tạo trình giả lập Game Boy bằng OCaml
Nội dung đề cập:

  • Tổng quan về kiến trúc Game Boy
  • Cách tổ chức mã có thể kiểm thử và tái sử dụng cao
  • Ứng dụng thực tế các tính năng OCaml nâng cao như functor, GADT, module hạng nhất
  • Kinh nghiệm tìm điểm nghẽn hiệu năng, tối ưu hóa và cải thiện
  • Những suy nghĩ chung về OCaml

Cấu trúc tổng thể và các giao diện chính

  • Các phần cứng chính như CPU, Timer, GPU hoạt động theo xung nhịp được đồng bộ hóa
  • Bus đảm nhiệm việc truy cập/truyền dữ liệu đến từng module phần cứng theo địa chỉ
  • Mỗi module phần cứng triển khai giao diện Addressable_intf.S
  • Toàn bộ bus tuân theo giao diện Word_addressable_intf.S

Cách hoạt động của vòng lặp chính

  • Để đồng bộ phần cứng, vòng lặp chính thực hiện các bước lặp sau
    1. Thực thi 1 lệnh CPU và ghi lại số chu kỳ đã tiêu thụ
    2. Cho Timer, GPU tiến thêm đúng số chu kỳ đó
  • Cách này mô phỏng trạng thái đồng bộ của phần cứng thực tế
  • Bài viết cũng giải thích kèm ví dụ mã triển khai

Trừu tượng hóa đọc/ghi dữ liệu 8 bit, 16 bit

  • Nhiều module triển khai giao diện nhập/xuất dữ liệu 8 bit (Addressable_intf.S)
  • Phần mở rộng đọc/ghi 16 bit được kế thừa và bổ sung chức năng qua Word_addressable_intf.S
  • Tầng trừu tượng được xây dựng bằng signature và cách include kiểu module của OCaml

Triển khai bus, thanh ghi và CPU

  • Bus: phụ trách định tuyến theo địa chỉ tới từng module phần cứng, phân nhánh dựa trên memory map
  • Thanh ghi: cung cấp giao diện đọc/ghi cho thanh ghi 8 bit và 16 bit
  • CPU: ban đầu phụ thuộc mạnh vào bus nên khó kiểm thử
    • Áp dụng functor để trừu tượng hóa phụ thuộc và có thể chèn mock
    • Nhờ đó, việc viết unit test trở nên dễ dàng hơn nhiều

Biểu diễn tập lệnh (dùng GADT)

  • Game Boy có cả lệnh 8 bit và 16 bit, nên cần tính an toàn kiểu cho định nghĩa instruction
  • Cách dùng variant đơn giản gây ra vấn đề xung đột kiểu trả về trong pattern matching phức tạp
  • Áp dụng GADT (Generalized Algebraic Data Type) để ánh xạ an toàn cả kiểu đầu vào lẫn đầu ra
  • Khi dùng GADT, kiểu đối số và kiểu trả về của từng instruction đều có thể được suy luận chính xác
  • Có thể xử lý an toàn các mẫu lệnh và tham số phức tạp

Cartridge và chọn module lúc chạy

  • Cartridge Game Boy ngoài ROM đơn thuần còn có thể chứa phần cứng bổ sung (MBC, timer, v.v.)
  • Cần triển khai module riêng cho từng loại và chọn đúng module khi chạy
  • Dùng module hạng nhất để chuyển đổi module lúc chạy và đảm bảo khả năng mở rộng

Kiểm thử và phát triển khám phá

  • Sử dụng test ROMppx_expect
    • Test ROM theo từng chức năng: kiểm chứng các vùng cụ thể như phép toán số học, hỗ trợ MBC, v.v.
    • Khi thất bại có thể chẩn đoán rõ ràng qua đầu ra màn hình
  • Kiểm thử tích hợp giúp đảm bảo độ tin cậy khi refactor lớn hoặc thêm tính năng mới
  • Áp dụng cách phát triển mang tính khám phá: lặp đi lặp lại việc hiện thực và kiểm chứng bằng test ROM

UI trình duyệt và tối ưu hiệu năng

  • Dùng js_of_ocaml để build sang JS một cách dễ dàng
  • Thư viện Brr cho phép truy cập an toàn DOM API của Javascript theo phong cách OCaml
  • Hiệu năng ban đầu thấp (20FPS), nhưng tác giả đã dùng Chrome profiler để phân tích điểm nghẽn ở GPU, timer, Bigstringaf
  • Tiến hành tối ưu theo từng module, đồng thời tắt inline kém hiệu quả trong bản build JS để đạt 60FPS cuối cùng (PC/di động)
  • Ở bản build native, hiệu năng đạt tới 1000FPS

Benchmark và so sánh phần cứng

  • Hiện thực chế độ benchmark headless để đo FPS theo từng môi trường

Phát triển trình giả lập và năng lực làm việc thực tế

  • Tương tự lập trình thi đấu, quy trình lặp lại là: diễn giải đặc tả rõ ràng → hiện thực → kiểm chứng
  • Đây là trải nghiệm thực sự hữu ích cho việc phát triển và kiểm thử dựa trên đặc tả

Hệ sinh thái và công cụ OCaml hiện đại

  • dune mang lại trải nghiệm hệ thống build đơn giản
  • Merlin, OCamlformat giúp tự động hoàn thành, điều hướng mã, định dạng thuận tiện
  • setup-ocaml cũng có thể áp dụng dễ dàng vào GitHub Actions

Suy ngẫm về ngôn ngữ hàm

  • Tác giả đặt câu hỏi với cách giải thích rằng ngôn ngữ hàm là để giảm thiểu side effect
  • Trạng thái mutable được che giấu dưới lớp trừu tượng vẫn được sử dụng tích cực vì hiệu năng
  • Tác giả ưa thích kiểu tĩnh, pattern matching, hệ thống module, suy luận kiểu

Những bất tiện và chi phí phụ thuộc của trừu tượng hóa

  • Việc chuẩn hóa quản lý phụ thuộc vẫn còn phức tạp và thiếu giải thích đầy đủ (như opam)
  • Khi thêm trừu tượng hóa bằng cấu trúc module-functor, có thể cần chỉnh sửa cả cấu trúc tầng phụ thuộc
  • Khác với OOP, khi đưa vào trừu tượng hóa thì cách viết các module phụ thuộc ở tầng trên cũng phải thay đổi

Tài liệu học được khuyến nghị

Kết luận

  • Thông qua dự án CAMLBOY, tác giả đã có trải nghiệm thực tiễn với các tính năng nâng cao của OCaml, kiểm thử, trừu tượng hóa, khả năng tương thích trình duyệt, v.v.
  • Tác giả nhận thức rõ cả ưu điểm lẫn giới hạn từ sự phát triển của hệ sinh thái và trải nghiệm phát triển thực tế
  • Phát triển trình giả lập thực sự giúp nâng cao năng lực của lập trình viên từ mức trung cấp trở lên

1 bình luận

 
GN⁺ 2025-07-06
Ý kiến trên Hacker News
  • Tò mò không biết có ai có thể tự tin nói rằng một ngôn ngữ lập trình nào đó đặc biệt phù hợp hơn để viết emulator, máy ảo, hay trình thông dịch bytecode hay không. Ở đây tiêu chí "tốt hơn" không phải là hiệu năng hay giảm lỗi triển khai, mà là mức độ trực quan hơn khi tự tay hiện thực và khám phá, học được nhiều hơn, và bản thân trải nghiệm hiện thực mang lại cảm giác đáng giá, thú vị hơn. Ví dụ, Erlang có mục tiêu rất rõ ràng trong lĩnh vực hệ thống phân tán, và tri thức miền cùng thiết kế ngôn ngữ khớp chặt với lĩnh vực đó, nên khi dùng sẽ có được hiểu biết sâu sắc cả về hệ thống phân tán lẫn chính Erlang. Theo kiểu đó, tôi tự hỏi liệu có ngôn ngữ nào mà mục tiêu là "biểu đạt hoạt động của máy móc bằng mã" hay không

    • Cá nhân tôi muốn nhấn mạnh rằng các ngôn ngữ lập trình hệ thống như C, C++, Rust, Zig là lựa chọn "thỏa mãn" nhất. Những ngôn ngữ này có kiểu dữ liệu (ví dụ: uint8) ánh xạ gần như trực tiếp với byte trong bộ nhớ, và các thao tác như memcpy cũng gần như tương đương ngay với công việc blit. Hầu như không phải vật lộn như trong các ngôn ngữ kiểu JavaScript, nơi phải bẻ kiểu Number thành byte để làm toán tử bit. Khi làm emulator bằng JavaScript thì đụng ngay vấn đề này. Tất nhiên, ngôn ngữ nào cũng có thể chạy khá giống nhau miễn là hỗ trợ hiển thị đồ họa và đủ bộ nhớ, và cuối cùng niềm vui lớn nhất vẫn đến từ việc chọn ngôn ngữ mà bản thân thấy thoải mái nhất

    • Haskell thể hiện rất tốt ở các phép biến đổi dữ liệu cần cho DSL và compiler. OCaml, Lisp, cùng các ngôn ngữ hiện đại hỗ trợ pattern matching và ADT cũng đều phù hợp. Modern C++ cũng có thể thử những thứ tương tự bằng variant type các kiểu, nhưng không được gọn gàng lắm. Nếu thực sự định chạy game trong emulator thì C hoặc C++ vẫn là lựa chọn tiêu chuẩn. Rust có lẽ cũng tạm ổn, nhưng tôi không rõ lắm về thao tác bộ nhớ mức thấp của nó

    • Tôi thuộc phe cho rằng không có ngôn ngữ nào đặc biệt tốt hơn để làm emulator, máy ảo, hay trình thông dịch bytecode. Chỉ cần có mảng (truy cập thời gian hằng số tại chỉ số bất kỳ) và toán tử bit là việc hiện thực đã cực kỳ dễ. Ở mức chưa tính đến JIT thì các ngôn ngữ hàm cũng hỗ trợ mảng và toán tử bit

    • Tôi muốn giới thiệu sml, cụ thể là phương ngữ MLTon. Nó chia sẻ gần như mọi lý do khiến OCaml trở nên tốt, nhưng cá nhân tôi đánh giá đây là một lựa chọn hoàn thiện hơn trong họ ngôn ngữ ML. Thứ tôi nhớ ở OCaml chỉ là applicative functor, nhưng đó cũng chỉ là khác biệt nhỏ trong cấu trúc module chứ không phải điều gì quá lớn

    • Nếu ưu tiên vui vẻ và thử nghiệm ngay trong trình duyệt thì Elm cũng là một lựa chọn tốt. Có thể tham khảo dự án tương tự là elmboy

  • Bài này không chỉ nói về Ocaml mà còn tổng hợp rất chắc tay quá trình hiện thực emulator Game Boy, thực sự là một tài liệu tuyệt vời. Xin gửi lời cảm ơn đến tác giả. Ngoài ra, từ lâu tôi đã có ý tưởng rằng nếu làm một SPA trong trình duyệt kết hợp editor assembler với cả assembler/linker/loader để ai cũng dễ dàng trải nghiệm phát triển homebrew cho Gameboy, thì nó sẽ rất hữu ích cho việc dạy học phát triển embedded

    • Dự án rgbds-live khá giống ý tưởng này và có tích hợp sẵn RGBDS. rgbds-live
  • Không biết liệu có ai đang tìm tutorial về hiện thực âm thanh trong emulator Game Boy hay không. Phần lớn tutorial đều không giải thích về âm thanh, và ngay cả khi tự thử hiện thực thì cũng rất khó hiểu và khó làm chỉ dựa trên tài liệu

    • Đây không phải tutorial chính thức, nhưng tôi chia sẻ bộ slide 2 trang tóm tắt cách tôi đã tự hiện thực: Bộ slide Âm thanh của Game Boy có 4 kênh, và mỗi kênh xuất ra giá trị từ 0 đến 15 ở mỗi tick. Emulator cần cộng chúng lại (trung bình cộng), scale sang khoảng 0~255, rồi đưa vào sound buffer. Theo tick rate (4.19MHz) và đầu ra âm thanh (22kHz chẳng hạn), cứ khoảng 190 tick thì xuất một giá trị. Đặc điểm từng kênh được tổng hợp khá tốt trong tài liệu này. Kênh 1 và 2 là sóng vuông (lặp 0/15), kênh 3 là dạng sóng tùy ý (đọc từ bộ nhớ), kênh 4 là nhiễu, dựa trên LSFR. Có thể tham khảo mã ví dụ SoundModeX.java

    • Tài liệu này cũng khá ổn

    • Video YouTube này cũng đáng tham khảo

  • Ấn tượng là đây là một bài viết rất hay và một dự án rất ngầu

  • Tôi nhận thấy bản demo chạy quá nhanh. Checkbox Throttle gần như không có tác dụng. Thậm chí khi bỏ chọn còn có cảm giác chậm hơn. Bật Throttle thì 240fps, tắt thì 180fps. Khi bật Throttle, 1 giây trong thực tế lại thành khoảng 4 giây trong emulator. Có lẽ điều này liên quan đến việc màn hình có tần số quét 240Hz

    • Có lẽ chỉ đang gọi requestAnimationFrame() mà quên tính deltaTime
  • Tôi nghĩ đây là một bài viết thực sự đẹp. Cảm ơn vì đã chia sẻ tài liệu như thế này. Nó khiến tôi muốn tự tay làm một emulator Game Boy bằng Rust, và vì bài blog đã truyền cảm hứng lớn nên tôi đã bookmark lại

  • Đây là một ví dụ dùng functor và GADT cực kỳ đẹp. Tôi muốn so sánh với emulator CHIP 8 hay NES, và cũng thấy sẽ rất thú vị nếu port CAMLBOY sang WASM bằng ocaml-wasm

    • Có backend WASM mới của js_of_ocaml là wasm_of_ocaml, nên có lẽ CAMLBOY đã có thể chạy trên WASM rồi