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

doodlewind發表於2019-03-01

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

模擬器開發入門

如果你覺得下面的理論有些枯燥,不妨直接開啟玩玩我們最後實現的成果:Merry8 模擬器。它用 2.5KB 的 JavaScript 程式碼,支援了一門上世紀 70 年代的組合語言,能夠讓你在 Canvas 上體驗當年用這門語言編寫的 PONG 遊戲(是的,就是那個來回彈跳的乒乓球),還支援通過 NPM 來安裝並使用它,覺得有意思的話請點個 Star 再走哦?

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

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

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

所以,只要你的程式碼實現了開啟並執行 ROM,對使用者輸入做出響應,就是個能用的模擬器了!

這樣一來,我們需要思考的問題就進一步細化成了這幾個:

  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 多分?)。這一步看似麻煩,但也可以分為兩個非常容易解釋清楚的小步驟:

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

從小霸王到 GBA,從 Apple II 到 80×86,各種曾經是主流的硬體平臺,其硬體都有非常完善的開發者文件。文件裡會告訴你形如這樣的資訊:

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 鍵的鍵盤
  • 一個音效暫存器
  • 一個 64×32 的黑白螢幕

基於上面的背景介紹,我們可以非常自然地把這些資源抽象成 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 還有一些類似的玩具,旨在用最簡單的邏輯來實現編譯器、直譯器、前端框架等輪子的基礎。歡迎關注哦?

參考

相關文章