不知道有多少同窗小時候玩太小霸王、GBA 之類遊戲主機的模擬器呢?模擬器不只僅是上面的遊戲好玩,編寫它的過程也是頗有意思的。下面咱們會介紹怎樣拿 JavaScript 從頭作一個帶 CPU、內存、輸入輸出、能玩老遊戲,體積還不到 3KB 的模擬器。前端
若是你以爲下面的理論有些枯燥,不妨直接打開玩玩咱們最後實現的成果:Merry8 模擬器。它用 2.5KB 的 JavaScript 代碼,支持了一門上世紀 70 年代的彙編語言,可以讓你在 Canvas 上體驗當年用這門語言編寫的 PONG 遊戲(是的,就是那個來回彈跳的乒乓球),還支持經過 NPM 來安裝並使用它,以爲有意思的話請點個 Star 再走哦😀git
前戲事後就是正題啦。可能對於絕大多數同窗來講,模擬器都是一個陌生的概念,那麼它大概是個什麼樣的東西呢?程序員
寬泛地說,從 Hello World 到 Alpha Go,全部的軟件都不過是對【輸入】給出【輸出】的代碼邏輯而已。那麼,模擬器也是軟件,它的輸入和輸出又是什麼呢?想一想你是怎麼玩超級瑪麗的吧:github
因此,只要你的代碼實現了打開並運行 ROM,對用戶輸入作出響應,就是個能用的模擬器了!npm
這樣一來,咱們須要思考的問題就進一步細化成了這幾個:編程
是否是和 如何把大象裝進冰箱
的三部曲同樣,很是簡單而清晰呢?下面,咱們就來逐一回答這三個問題:設計模式
Windows 的可執行文件是 .exe
格式,Linux 的可執行文件是 .elf
格式,而遊戲主機的可執行文件就是 ROM 了。不一樣平臺的遊戲機,支持的 ROM 格式都有所不一樣。不過總的來講,它們都是由機器碼所構成的二進制格式。一些前端同窗可能熟悉 ArrayBuffer
這種數據結構,它很是適合表達這樣的內容。因此,咱們打開 ROM 時作的事情很是簡單,只須要這兩步:數組
這步結束後,咱們得到的 ArrayBuffer 數組,每項都是一個大小在 0x00
到 0xFF
之間的數字。熟悉 CSS 顏色值的同窗們笑了,這不就是十六進制下的 0~255
嘛!不過,這裏的取值大小和顏色深淺可沒什麼關係,而是實打實的機器碼。怎樣破譯這串數字的含義呢?瀏覽器
提到【機器碼】和【彙編語言】,可能很多同窗首先想到的都是當年被微機原理支配的恐懼……不過請放心,這並無多難(當年我好像只考了 70 多分😅)。這一步看似麻煩,但也能夠分爲兩個很是容易解釋清楚的小步驟:sass
0xF0
這樣的機器碼,翻譯成可讀性更好的彙編碼。從小霸王到 GBA,從 Apple II 到 80x86,各類曾經是主流的硬件平臺,其硬件都有很是完善的開發者文檔。文檔裏會告訴你形如這樣的信息:
8xy3 - XOR Vx, Vy
Set Vx = Vx XOR Vy.複製代碼
這是什麼意思呢?大意就是:數值知足 8xy3
的機器碼,對應的彙編指令叫作 XOR
。這條指令的功能,是把 Vx
寄存器的值設置爲 Vx
和 Vy
作異或操做後的值(這裏的 x 和 y 相似通配符,匹配出如今相應位置上的一位數值)。
因此,咱們就能夠把全部數值知足 8xy3
的機器碼,翻譯成爲 XOR
彙編指令了。若是用函數來表達,這個函數大體形如:
function convert (num) {
if (num[0] === 8 && num[3] === 3) {
return 'XOR'
}
}複製代碼
上面的判斷條件顯然是錯誤的(進制和下標都是瞎寫的),不過它的思路和真正可用的版本已經很接近了:輸入機器碼數值,根據文檔判斷出它是什麼指令,只要寫一大堆扁平的 else if
就足夠了,不難吧?
把機器碼數值轉換爲彙編碼以後,咱們須要作的就是最核心的內容了:根據彙編碼的意義來執行它。這須要一種很是高端、精妙、富有智慧而通用的設計模式——
兵來將擋,水來土掩模式
這種模式的背後,是一種很是強大的編程思想,強調在代碼中需求缺什麼,就補什麼。在編寫模擬器時,這種模式指導咱們:
count
地址變量,模擬出地址信息讓它改。Vx
變量,模擬出寄存器讓它改。memory
數組,模擬出內存讓它改。stack
數組和一個 pointer
變量,模擬出一個能進能出的棧讓它進進出出。不少人誤用了這種模式,在每次趕上小改動就瑣碎地修修補補。在這裏,咱們的本意其實是在通讀文檔後,找出全部指令會修改的東西,用變量來模擬它。若是用僞代碼表示,咱們模擬出的一條彙編指令大體形如:
function ADD (a, b) {
return a + b
}複製代碼
咱們先定義一個 ADD
函數,在函數內部處理好 ADD
彙編指令所修改的內容,這樣咱們就模擬出一條彙編指令了!實際的代碼牽扯到一些位運算,但整體來講,你大能夠把每條指令都當作一個單純的函數。
在實現了這一堆彙編指令的功能之後,最後的關鍵問題就是該怎麼樣運行它呢?咱們知道,每一個 CPU 都有特定的運行頻率,一旦運行就會按照這個頻率不停地執行指令。因此,咱們能夠把 CPU 的這種運行方式,模擬爲一個死循環:
while (true) {
// 讀取下一條指令。
const ins = nextIns()
// 將指令餵給 CPU 執行。
cpu.run(ins)
}複製代碼
好了!到此爲止,咱們知道了用 JavaScript 寫模擬器的話,只須要:
這樣是否是就足以讓模擬器運行起來了呢?事情並無這麼單純…再堅持一下就夠了!
對函數式編程有所瞭解的同窗們,應該都瞭解【反作用】的概念。反作用表明着全部計算以外,【不純粹】的東西,最典型的反作用就是【輸入】和【輸出】了。
若是沒有反作用,那麼模擬器就會毫無疑問地陷入死循環(好比用戶打開遊戲不按鍵,那麼界面會卡在一個 Press Start to Continue
之類的標題畫面不動)。因此,咱們須要在上一步的基礎上,實現某種機制,來合適地處理輸入和輸出。
在 JavaScript 的語義中,咱們有 setTimeout
的概念。經過定時器,咱們可以把同步的代碼轉爲異步執行。對 CPU 不停進行計算的模擬會阻塞咱們的主線程,因此對於一個真實世界的模擬器,咱們須要使用一些異步的小技巧來爲輸入輸出騰出空間。這個過程能夠簡化爲:
while
不停執行指令的同步死循環,變成每隔一段時間執行若干條指令的異步循環。這樣,咱們就解決了輸入的問題了!輸出問題則簡單得多:在 CPU 執行輸出指令時,渲染 Canvas 便可。或者,你也能夠另開一個定時器來不停地渲染屏幕狀態。
到此爲止,咱們已經介紹了對模擬器而言,這幾個最核心功能點的實現方式:
理論水平已經足夠了,下面就是實戰啦😀
咱們的 Merry8 模擬器實現的是 Chip-8 彙編語言。這是一種上世紀 70 年代的中古語言。和小霸王 NES 使用的 6502 彙編不一樣的是,Chip-8 並無一種官方的硬件實現,只要按照它的規範實現了完整的指令集,就能夠運行兼容的 ROM 了。因爲其結構的簡單,它很是適合做爲模擬器開發的入門語言。
符合 Chip-8 規範的解釋器(或者虛擬機、模擬器…)可以使用的資源包括:
基於上面的背景介紹,咱們能夠很是天然地把這些資源抽象成 JavaScript:
對指令而言,基礎的 Chip8 規範共有 35 條指令,雖然每條指令長度都固定在 2 個字節,但不一樣指令中的參數格式是不一樣的。例如讀取到的指令機器碼爲 60 12
時,整個處理流程就是:
6xkk
指令,這是 LD
(Load)指令,第一個操做數爲 0 且第二個操做數爲 12。0x12
這個操做數寫入 V0 寄存器。在明白了這條指令的含義後,咱們就能夠模擬出它的指令邏輯,來操縱模擬的硬件資源了。把這 35 條指令覆蓋一遍後,咱們就能實現整個模擬器啦。
對每條指令的實現細節,在 Chip-8 文檔 和 模擬器 CPU 源碼 裏都有相應的信息,在此就再也不贅述啦。
Merry8 模擬器是筆者在 去年的聖誕節 花了一個週末實現的。這也是 Merry
命名的由來。不過鑑於當時只有不到半年的前端經驗,它在一些工程細節上並不優雅,總體更接近於一個應用而非類庫,把它寫完丟到 Github 上之後也是疏於打理。值此白色相簿的季節,在優化了一些代碼結構後,如今它已是一個有着可用 API 且具有清晰模塊結構的輪子了,主要的模塊包括:
disassembler
模塊,負責將機器碼反彙編爲可讀的格式。ops
模塊,封裝了 Chip-8 的 CPU 指令邏輯。view
模塊,負責渲染狀態到 Canvas 上。整個模擬器的運做方式基本和上文中的描述一致,用一句話說清楚,就是在異步循環中處理指令邏輯。
在最近的 v0.3.0
更新中,它基於 OO 的基本方式,理清了幾個模塊之間的關係:你能夠經過 new Merry8()
新建一個模擬器實例,每一個模擬器實例都有着本身的虛擬 CPU、內存、堆棧指針、寄存器和 Canvas 上下文。這樣,你能夠很輕鬆地在一個頁面裏實例化多個模擬器,加載不一樣的 ROM 並進行不一樣的控制。
在前端的工程化方面,這個玩具也有些靠譜的實踐:
目前,Merry8 還處於 Beta 狀態,它的遊戲兼容性還很不理想,測試覆蓋也很欠缺,但若是你有興趣來參與完善它,很是歡迎你提出 PR!
毫無疑問,Merry8 就算再完善,也不過是一個玩具而已。那麼,爲何我還願意花這麼多精力來正經地實現、維護並介紹它呢?我能想到幾個理由:
console.log
更靠譜的調試技術來定位並解決。最後不得不提的是,在調試模擬器 ROM 的時候,會讓你對技術和歷史有更多的敬畏。不要以爲 3KB 內實現一個模擬器有多麼了不得,要知道它所模擬的 PONG 遊戲只有 246 字節!天知道它的做者是怎麼在 200 多個字節的空間裏實現碰撞、計分和 IO 交互的,也許這就是上古時期程序員的神級操做吧。
若是你以爲本文的主題有些意思,在個人 Github 還有一些相似的玩具,旨在用最簡單的邏輯來實現編譯器、解釋器、前端框架等輪子的基礎。歡迎關注哦😀