如何用 3KB 不到的 JavaScript 實現微機模擬器

不知道有多少同窗小時候玩太小霸王、GBA 之類遊戲主機的模擬器呢?模擬器不只僅是上面的遊戲好玩,編寫它的過程也是頗有意思的。下面咱們會介紹怎樣拿 JavaScript 從頭作一個帶 CPU、內存、輸入輸出、能玩老遊戲,體積還不到 3KB 的模擬器前端

模擬器開發入門

若是你以爲下面的理論有些枯燥,不妨直接打開玩玩咱們最後實現的成果:Merry8 模擬器。它用 2.5KB 的 JavaScript 代碼,支持了一門上世紀 70 年代的彙編語言,可以讓你在 Canvas 上體驗當年用這門語言編寫的 PONG 遊戲(是的,就是那個來回彈跳的乒乓球),還支持經過 NPM 來安裝並使用它,以爲有意思的話請點個 Star 再走哦😀git

前戲事後就是正題啦。可能對於絕大多數同窗來講,模擬器都是一個陌生的概念,那麼它大概是個什麼樣的東西呢?程序員

寬泛地說,從 Hello World 到 Alpha Go,全部的軟件都不過是對【輸入】給出【輸出】的代碼邏輯而已。那麼,模擬器也是軟件,它的輸入和輸出又是什麼呢?想一想你是怎麼玩超級瑪麗的吧:github

  1. 你須要用模擬器打開超級瑪麗的 ROM,這是模擬器的輸入
  2. 你須要在遊戲過程當中按鍵,這也是模擬器的輸入
  3. 模擬器有畫面和聲音,這是模擬器的輸出

因此,只要你的代碼實現了打開並運行 ROM,對用戶輸入作出響應,就是個能用的模擬器了!npm

這樣一來,咱們須要思考的問題就進一步細化成了這幾個:編程

  1. 遊戲 ROM 是什麼格式,怎樣打開它呢?
  2. 怎樣運行遊戲 ROM 裏的代碼呢?
  3. ROM 運行時,怎樣接收用戶輸入,並把結果輸出呢?

是否是和 如何把大象裝進冰箱 的三部曲同樣,很是簡單而清晰呢?下面,咱們就來逐一回答這三個問題:設計模式

把冰箱門打開:遊戲 ROM 是什麼格式?

Windows 的可執行文件是 .exe 格式,Linux 的可執行文件是 .elf 格式,而遊戲主機的可執行文件就是 ROM 了。不一樣平臺的遊戲機,支持的 ROM 格式都有所不一樣。不過總的來講,它們都是由機器碼所構成的二進制格式。一些前端同窗可能熟悉 ArrayBuffer 這種數據結構,它很是適合表達這樣的內容。因此,咱們打開 ROM 時作的事情很是簡單,只須要這兩步:數組

  1. 發一個 Ajax 請求,得到 ROM 的靜態文件。
  2. 把 ROM 文件轉換成 JS 的 ArrayBuffer 數組。

這步結束後,咱們得到的 ArrayBuffer 數組,每項都是一個大小在 0x000xFF 之間的數字。熟悉 CSS 顏色值的同窗們笑了,這不就是十六進制下的 0~255 嘛!不過,這裏的取值大小和顏色深淺可沒什麼關係,而是實打實的機器碼。怎樣破譯這串數字的含義呢?瀏覽器

把大象裝進去:如何運行 ROM 的代碼?

提到【機器碼】和【彙編語言】,可能很多同窗首先想到的都是當年被微機原理支配的恐懼……不過請放心,這並無多難(當年我好像只考了 70 多分😅)。這一步看似麻煩,但也能夠分爲兩個很是容易解釋清楚的小步驟:sass

  1. 0xF0 這樣的機器碼,翻譯成可讀性更好的彙編碼
  2. 根據彙編碼的意義來執行它。

從小霸王到 GBA,從 Apple II 到 80x86,各類曾經是主流的硬件平臺,其硬件都有很是完善的開發者文檔。文檔裏會告訴你形如這樣的信息:

8xy3 - XOR Vx, Vy
Set Vx = Vx XOR Vy.複製代碼

這是什麼意思呢?大意就是:數值知足 8xy3 的機器碼,對應的彙編指令叫作 XOR。這條指令的功能,是把 Vx 寄存器的值設置爲 VxVy 作異或操做後的值(這裏的 x 和 y 相似通配符,匹配出如今相應位置上的一位數值)。

因此,咱們就能夠把全部數值知足 8xy3 的機器碼,翻譯成爲 XOR 彙編指令了。若是用函數來表達,這個函數大體形如:

function convert (num) {
  if (num[0] === 8 && num[3] === 3) {
    return 'XOR'
  }
}複製代碼

上面的判斷條件顯然是錯誤的(進制和下標都是瞎寫的),不過它的思路和真正可用的版本已經很接近了:輸入機器碼數值,根據文檔判斷出它是什麼指令,只要寫一大堆扁平的 else if 就足夠了,不難吧?

把機器碼數值轉換爲彙編碼以後,咱們須要作的就是最核心的內容了:根據彙編碼的意義來執行它。這須要一種很是高端、精妙、富有智慧而通用的設計模式——

兵來將擋,水來土掩模式

這種模式的背後,是一種很是強大的編程思想,強調在代碼中需求缺什麼,就補什麼。在編寫模擬器時,這種模式指導咱們:

  1. 見到有些彙編碼會跳轉地址,咱們就補一個 count 地址變量,模擬出地址信息讓它改。
  2. 見到有些彙編碼會改寄存器,咱們就補幾個 Vx 變量,模擬出寄存器讓它改。
  3. 見到有些彙編碼會讀寫內存,咱們就補一個 memory 數組,模擬出內存讓它改。
  4. 見到有些彙編碼會改堆棧數組,咱們就補一個 stack 數組和一個 pointer 變量,模擬出一個能進能出的棧讓它進進出出。
  5. ……

不少人誤用了這種模式,在每次趕上小改動就瑣碎地修修補補。在這裏,咱們的本意其實是在通讀文檔後,找出全部指令會修改的東西,用變量來模擬它。若是用僞代碼表示,咱們模擬出的一條彙編指令大體形如:

function ADD (a, b) {
  return a + b
}複製代碼

咱們先定義一個 ADD 函數,在函數內部處理好 ADD 彙編指令所修改的內容,這樣咱們就模擬出一條彙編指令了!實際的代碼牽扯到一些位運算,但整體來講,你大能夠把每條指令都當作一個單純的函數。

在實現了這一堆彙編指令的功能之後,最後的關鍵問題就是該怎麼樣運行它呢?咱們知道,每一個 CPU 都有特定的運行頻率,一旦運行就會按照這個頻率不停地執行指令。因此,咱們能夠把 CPU 的這種運行方式,模擬爲一個死循環:

while (true) {
  // 讀取下一條指令。
  const ins = nextIns()
  // 將指令餵給 CPU 執行。
  cpu.run(ins)
}複製代碼

好了!到此爲止,咱們知道了用 JavaScript 寫模擬器的話,只須要:

  • 用函數模擬指令功能。
  • 用變量模擬寄存器、內存和堆棧。
  • 用循環模擬 CPU 運行。

這樣是否是就足以讓模擬器運行起來了呢?事情並無這麼單純…再堅持一下就夠了!

把冰箱門關上:如何處理輸入輸出?

對函數式編程有所瞭解的同窗們,應該都瞭解【反作用】的概念。反作用表明着全部計算以外,【不純粹】的東西,最典型的反作用就是【輸入】和【輸出】了。

若是沒有反作用,那麼模擬器就會毫無疑問地陷入死循環(好比用戶打開遊戲不按鍵,那麼界面會卡在一個 Press Start to Continue 之類的標題畫面不動)。因此,咱們須要在上一步的基礎上,實現某種機制,來合適地處理輸入和輸出。

在 JavaScript 的語義中,咱們有 setTimeout 的概念。經過定時器,咱們可以把同步的代碼轉爲異步執行。對 CPU 不停進行計算的模擬會阻塞咱們的主線程,因此對於一個真實世界的模擬器,咱們須要使用一些異步的小技巧來爲輸入輸出騰出空間。這個過程能夠簡化爲:

  1. 把原來 while 不停執行指令的同步死循環,變成每隔一段時間執行若干條指令的異步循環。
  2. 設置事件監聽器,在按下特定按鈕的時候,更改模擬器的狀態(這時候 CPU 的循環被定時器暫停了)。
  3. 每次觸發 CPU 的異步循環,執行到一些判斷輸入狀態的指令時,就能夠獲取到被輸入事件修改過的狀態了。

這樣,咱們就解決了輸入的問題了!輸出問題則簡單得多:在 CPU 執行輸出指令時,渲染 Canvas 便可。或者,你也能夠另開一個定時器來不停地渲染屏幕狀態。

到此爲止,咱們已經介紹了對模擬器而言,這幾個最核心功能點的實現方式:

  • 如何讀取 ROM 文件。
  • 如何模擬運行機器指令。
  • 如何處理輸入輸出。

理論水平已經足夠了,下面就是實戰啦😀

Chip-8 簡介

咱們的 Merry8 模擬器實現的是 Chip-8 彙編語言。這是一種上世紀 70 年代的中古語言。和小霸王 NES 使用的 6502 彙編不一樣的是,Chip-8 並無一種官方的硬件實現,只要按照它的規範實現了完整的指令集,就能夠運行兼容的 ROM 了。因爲其結構的簡單,它很是適合做爲模擬器開發的入門語言

符合 Chip-8 規範的解釋器(或者虛擬機、模擬器…)可以使用的資源包括:

  • 4KB 大小的內存
  • V0 到 V15 共 16 個 16 位寄存器
  • 一個 PC 計數器
  • 一個 I 索引寄存器
  • 一個 SP 棧指針
  • 一個延遲定時器
  • 一個 16 鍵的鍵盤
  • 一個音效寄存器
  • 一個 64x32 的黑白屏幕

基於上面的背景介紹,咱們能夠很是天然地把這些資源抽象成 JavaScript:

  • 內存和堆棧:存放連續數據的 ArrayBuffer 數組
  • 寄存器和計數器:表示相應值的 number 變量
  • 鍵盤:表示各按鍵是否按下的 boolean 數組
  • 黑白屏幕:表達顏色的 boolean 二維數組

對指令而言,基礎的 Chip8 規範共有 35 條指令,雖然每條指令長度都固定在 2 個字節,但不一樣指令中的參數格式是不一樣的。例如讀取到的指令機器碼爲 60 12 時,整個處理流程就是:

  1. 匹配出該指令是 6xkk 指令,這是 LD(Load)指令,第一個操做數爲 0 且第二個操做數爲 12。
  2. 根據文檔,將 0x12 這個操做數寫入 V0 寄存器。

在明白了這條指令的含義後,咱們就能夠模擬出它的指令邏輯,來操縱模擬的硬件資源了。把這 35 條指令覆蓋一遍後,咱們就能實現整個模擬器啦。

對每條指令的實現細節,在 Chip-8 文檔模擬器 CPU 源碼 裏都有相應的信息,在此就再也不贅述啦。

Merry8 模擬器架構

Merry8 模擬器是筆者在 去年的聖誕節 花了一個週末實現的。這也是 Merry 命名的由來。不過鑑於當時只有不到半年的前端經驗,它在一些工程細節上並不優雅,總體更接近於一個應用而非類庫,把它寫完丟到 Github 上之後也是疏於打理。值此白色相簿的季節,在優化了一些代碼結構後,如今它已是一個有着可用 API 且具有清晰模塊結構的輪子了,主要的模塊包括:

  • disassembler 模塊,負責將機器碼反彙編爲可讀的格式。
  • ops 模塊,封裝了 Chip-8 的 CPU 指令邏輯。
  • view 模塊,負責渲染狀態到 Canvas 上。

整個模擬器的運做方式基本和上文中的描述一致,用一句話說清楚,就是在異步循環中處理指令邏輯

在最近的 v0.3.0 更新中,它基於 OO 的基本方式,理清了幾個模塊之間的關係:你能夠經過 new Merry8() 新建一個模擬器實例,每一個模擬器實例都有着本身的虛擬 CPU、內存、堆棧指針、寄存器和 Canvas 上下文。這樣,你能夠很輕鬆地在一個頁面裏實例化多個模擬器,加載不一樣的 ROM 並進行不一樣的控制。

在前端的工程化方面,這個玩具也有些靠譜的實踐:

  • Rollup 構建。
  • StandardJS 風格 Lint。
  • 對若干反彙編函數和 CPU 指令,實現了單元測試。

目前,Merry8 還處於 Beta 狀態,它的遊戲兼容性還很不理想,測試覆蓋也很欠缺,但若是你有興趣來參與完善它,很是歡迎你提出 PR!

總結

毫無疑問,Merry8 就算再完善,也不過是一個玩具而已。那麼,爲何我還願意花這麼多精力來正經地實現、維護並介紹它呢?我能想到幾個理由:

  • 開發模擬器,是一個瞭解計算機基礎知識如何工做的好方式。它不光有着容易展現出成果的樂趣,還可讓你藉此瞭解到指令如何運做、內存如何分配、堆棧如何增減的基礎知識。
  • 編寫模擬器比起其它瞭解底層技術的方式而言,思惟負擔更輕。好比,你並不須要學習如何使用 C 或 C++ 之類底層的編程語言。注意,開發模擬器並不須要會寫彙編語言,我到如今也不會用 Chip-8 彙編寫遊戲,只知道每條指令作什麼就足夠了。
  • 它能給你真正意義上根據文檔來思考模塊結構的機會。不一樣於平常根據接口文檔編寫的【入參格式、出參格式】膠水代碼,你要實現的東西是一份技術規範。別忘了,多少人啃過的 ECMA-262 一樣也只是一份技術規範。
  • 它可以鍛鍊你調試與單元測試的能力。在一個每秒執行成千上萬次的循環裏,一條指令的細微偏移就會讓整個模擬器失效。因此,你須要用單元測試來保證每條指令的正確性,並在出現問題時用比 console.log 更靠譜的調試技術來定位並解決。
  • 它可以培養你診斷性能瓶頸並優化的思考方式。好比,在第一個版本里,模擬器的 CPU 佔用一直是滿的。我覺得問題出在定時邏輯上,但 Profiling 後發現問題出在渲染層:當時使用 DOM 繪圖,對 64X32 的上千個 DOM 節點,60fps 的全量更新已經使得瀏覽器不堪重負。在遷移到 Canvas 後,CPU 負載問題就順利解決了。

最後不得不提的是,在調試模擬器 ROM 的時候,會讓你對技術和歷史有更多的敬畏。不要以爲 3KB 內實現一個模擬器有多麼了不得,要知道它所模擬的 PONG 遊戲只有 246 字節!天知道它的做者是怎麼在 200 多個字節的空間裏實現碰撞、計分和 IO 交互的,也許這就是上古時期程序員的神級操做吧

若是你以爲本文的主題有些意思,在個人 Github 還有一些相似的玩具,旨在用最簡單的邏輯來實現編譯器、解釋器、前端框架等輪子的基礎。歡迎關注哦😀

參考

相關文章
相關標籤/搜索