如何編寫計(jì)算機(jī)模擬器
早期在 PC 上寫模擬器的牛人,Marat Fayzullin 是其中之一。1997 年,他就已經(jīng)開發(fā)出 fMSX 模擬器,并且以這篇文章《How to write a computer emulator?》分享他的知識。中文翻譯的網(wǎng)頁已經(jīng)不存在了,可惜。
下面是閱讀后的整理:
綱要:
●什麼可以被模擬?
●什麼是 emulation,它跟 simulation 有什麼不同?
●模擬有專利的硬件,是合法的嗎?
●什麼是直譯式的模擬器,跟編譯式的模擬器有何不同?
●我想寫一個(gè)模擬器,我該從何開始?
●我該用哪一種程序語言?
●我從哪裡可以得到想模擬的硬體的資訊?
實(shí)現(xiàn):
●如何模擬一個(gè) CPU?
●如何存取被模擬的內(nèi)存?
●周期性的運(yùn)作有哪些?
程序技巧:
●如何優(yōu)化 C 程序碼?
●什麼是高低字節(jié)順序?
●如何讓程序具可移植性?
●為何我要模組化我的程序?
什麼可以被模擬?
基本上,任何東西有微處理器在裡面,就可模擬。當(dāng)然,只有那些可以跑程序裝置,我們才有興趣模擬。包括:電腦、計(jì)算機(jī)、游樂器、大型電動、其他……
必須特別註明,你可以模擬任何電腦系統(tǒng),即是事非常復(fù)雜的系統(tǒng)(譬如 Amiga 電腦),但是執(zhí)行效率可能很低。
什麼是 Emulation,它跟 Simulation 有什麼不同?
Emulation 模擬裝置內(nèi)部的硬體,Simulation 是模擬裝置內(nèi)部的功能。舉例來說,一個(gè)程序模擬小精靈大型電動的硬體,然后執(zhí)行小精靈的 ROM,就是個(gè) emulator。一個(gè)小精靈的 PC 游戲,就是個(gè) simulator。
模擬有專利的硬體,是合法的嗎?
這是個(gè)灰色地帶,只要你不是透過不合法的管道,拿到硬體的資訊,就應(yīng)該不違法。但是很清楚知道,跟模擬器一起散佈有著作權(quán)的系統(tǒng) ROM(例如 BIOS),是違法的。
什麼是直譯式的模擬器,跟編譯式的模擬器有何不同?
模擬器有三種設(shè)計(jì)的方式,這些設(shè)計(jì)也可以混用,來達(dá)到最好的效果。
直譯式
模擬器一個(gè)位元又一個(gè)位元的,從內(nèi)存讀取代碼,然后解碼,執(zhí)行對應(yīng)的暫存器、內(nèi)存、輸出入的命令。通用的演算法如下:
- while(CPUIsRunning)
- {
- Fetch OpCode
- Interpret OpCode
- }
這種設(shè)計(jì)的好處是,容易除錯,容易移植,容易同步(你只需要計(jì)算過了多少 CPU 周期,然后讓你模擬的其他部份,跟 CPU 同步)。
這種設(shè)計(jì)明顯的弱點(diǎn),就是執(zhí)行效率很差。執(zhí)行直譯會花很多 CPU 時(shí)間,你會需要很快的電腦,才能有不錯的執(zhí)行速度。
靜態(tài)編譯式
這種技術(shù),就是把一支你要模擬的系統(tǒng)的代碼,編譯成你的電腦的的匯編語言。編譯的結(jié)果,通常是一支你的電腦的普通執(zhí)行檔,不需要額外的工具就可以執(zhí)行。靜態(tài)編譯,聽起來很美好,但通常不可行。例如,你就無法靜態(tài)編譯會自我修改的代碼,因?yàn)檫@種代碼只有執(zhí)行時(shí),才會知道內(nèi)容是什麼。為瞭解決上述的問題,或許需要混用直譯器,或是動態(tài)編譯編譯器。
動態(tài)編譯式
動態(tài)編譯基本上跟靜態(tài)編譯一樣,但動態(tài)編譯發(fā)生在程序執(zhí)行時(shí)。動態(tài)編譯是在執(zhí)行到 CALL 或 JUMP 時(shí)才編譯,取代一開始就編譯一整個(gè)程序。為了增加執(zhí)行效率,這種技術(shù)常常結(jié)合靜態(tài)編譯。你可以讀,動態(tài)編譯式麥金塔模擬器的作者 Ardi,的這篇動態(tài)編譯白皮書學(xué)到更多
我想寫一個(gè)模擬器,我該從何開始?
想要寫一個(gè)模擬器,你必須懂程序設(shè)計(jì),以及數(shù)位電子。如果懂得匯編語言,會更好。
1. 選一種程序語言
2. 找到所有被模擬硬體的所有資訊
3. 寫 CPU 模擬,或是選用一個(gè)現(xiàn)成的 CPU 模擬程序
4. 寫個(gè)粗略的其他周邊硬體的模擬,至少要一部分
5. 在這個(gè)時(shí)候,寫個(gè)內(nèi)建除錯器,讓你可以暫停模擬,檢查程序執(zhí)行的結(jié)果。你也會需要一個(gè)被模擬 CPU 的匯編語言反組譯器。如果找不到現(xiàn)成的,就自己寫一個(gè)。
6. 試著用你的模擬器執(zhí)行程序
7. 用除錯程序跟反組譯器,看看程序到底在干麼,然后根據(jù)此修改你的模擬器
我該用哪一種程序語言?
最常被用到是 C 跟匯編語言,各有優(yōu)缺點(diǎn)。
匯編
+ 通用,可以產(chǎn)生速度快的程序碼
+ 可以直接使用暫存器,來映射被模擬的暫存器
+ 很多匯編語言指令,可以對應(yīng)到被模擬的匯編語言指令
- 程序是不可移植的,換句話說,你的模擬器,不能在別種 CPU 上跑
- 很難除錯跟維護(hù)
C 語言
+ 可移植性,所以可以在不同的作業(yè)系統(tǒng)上跑
+ 相對容易除錯跟維護(hù)
+ 對硬體的不同假設(shè),可以很快的測試
- 通常 C 語言的程序比匯編語言的程序慢
要寫模擬器,對所選擇的語言,瞭解得很透徹,是絕對必要的。因?yàn)槟M器的程序很復(fù)雜,你要優(yōu)化你的模擬器,讓它跑得越快越好。電腦模擬器程序,絕對不是你越來學(xué)習(xí)程序語言的專案。
#p#
我從哪裡可以得到想模擬的硬體的資訊?
下列地方,你會想去看一看:
●網(wǎng)路新聞群組
comp.emulators.misc
這個(gè)新聞群組,討論模擬器一般的問題。許多模擬器作者會訂閱,雖然裡面雜音很多。如果要貼問題到這個(gè)新聞群組,記得先看 c.e.m FAQ 常見問題。
comp.emulators.game-consoles
跟 comp.emulators.misc 一樣,不過這個(gè)新聞群組,專攻電視游樂器的模擬器。如果要貼問題到這個(gè)新聞群組,記得先看 c.e.m FAQ 常見問題。
comp.sys./emulated-system/
comp.sys.* 新聞群組階層,專攻特定的電腦系統(tǒng)。你閱讀這些新聞群組,可以得到有用的技術(shù)資料。典型的例子:
- com.sys.msx MSX / MSX2 / MSX2+ / TurboR 電腦
- comp.sys.sinclair Sinclair ZX80/ZX81/ZXSpectrum/QL
- comp.sys.apple2 Apple ][
如果要發(fā)問題到這個(gè)新聞群組,記得先看 FAQ
alt.folklore.computers
rec.games.video.classic
FTP
如何模擬一個(gè) CPU?
首先,如果你需要模擬一個(gè)標(biāo)準(zhǔn)的 Z80 或 6502 CPU,你可以使用 Marat Fayzullin 所寫的 CPU 模擬器 當(dāng)然有些限制。
對那些想要自己寫 CPU 模擬核心,或是對其中的運(yùn)作原理感性趣的人,我提供一個(gè)用 C 寫的范例架構(gòu)如下,在真正的實(shí)做,你或許會考慮略過其中部份,或添加新的部份。
- Counter=InterruptPeriod;
- PC=InitialPC;
- for(;;)
- {
- OpCode=Memory[PC++];
- Counter-=Cycles[OpCode];
- switch(OpCode)
- {
- case OpCode1:
- case OpCode2:
- ...
- }
- if(Counter<=0)
- {
- /* Check for interrupts and do other */
- /* cyclic tasks here */
- ...
- Counter+=InterruptPeriod;
- if(ExitRequired) break;
- }
- }
首先我們指定 CPU 周期記數(shù)器 (Counter),以及指令位址記數(shù)器 (PC)
- Counter=InterruptPeriod;
- PC=InitialPC;
Counter 紀(jì)錄了到下一次系統(tǒng)中斷發(fā)生,還剩多少個(gè) CPU 周期。注意當(dāng) Counter 過其實(shí),系統(tǒng)中斷不必然發(fā)生。你可以利用他來處理其事情:像是時(shí)鐘同步,更新螢?zāi)坏膾呙榫€等。等等,我們會討論這些。PC 則紀(jì)錄了CPU 會從那個(gè)內(nèi)存位址,讀取下次的執(zhí)行的指令。
在我們給這些設(shè)定初始值之后,然后開始進(jìn)入主循環(huán):
- for(;;)
- {
主循環(huán)也可以寫成這樣:
- while(CPUIsRunning)
- {
CPUIsRunning 是個(gè)布林值,這樣寫有個(gè)好處,你可以在任何時(shí)候,設(shè) CPUIsRunning=0,來終止主循環(huán)。然而在每個(gè)循環(huán)檢查這個(gè)變數(shù),會花不少的 CPU,而我們應(yīng)該盡量減少花費(fèi) CPU。同時(shí),不要寫成下面這樣子:
- while(1)
- {
因?yàn)檫@樣寫,編譯器產(chǎn)生代碼,去檢查 1 為 “真” 或 “假”,你不會希望在主循環(huán)的每個(gè)循環(huán),都去執(zhí)行這多餘的動作。
現(xiàn)在我們在主循環(huán)內(nèi),第一件事,就是去讀下一個(gè)執(zhí)行碼,然后修改程序位址記數(shù)器。
- OpCode=Memory[PC++];
注意,這是最簡易的方式,來模擬讀取內(nèi)存,但并非永遠(yuǎn)可行。更通用的方式,來存取內(nèi)存,稍后會提到。
在提取操作碼后,會從 CPU 周期計(jì)數(shù)器,扣掉這個(gè)指令所需的周期數(shù)。
- Counter-=Cycles[OpCode];
Cycles[] 表內(nèi)放的是每個(gè)操作碼,所需要的周期數(shù)。要特別注意,有些指令(例如條件式跳躍,或是呼叫副程序),需要的周期數(shù),是跟操作后面緊接的參數(shù)而變動。這個(gè)可以在執(zhí)行指令碼時(shí)調(diào)整。
現(xiàn)在該是解譯操作碼,然后跟著執(zhí)行的時(shí)候了:
- switch(OpCode)
- {
有一個(gè)錯誤的觀念,認(rèn)為 switch 語句是沒有效率的,因?yàn)闀痪幾g成 if () …… else if () …….. 語句。這只有在 case 數(shù)量很少的 switch 語句,才會被這樣編譯。當(dāng)有 100 到 200 個(gè) case 的時(shí)候,switch 語句通常會被翻譯成 jump 表格,jump 表格,其實(shí)蠻有效率的。
有其他兩種替代方案,可以用來解譯操作碼。第一種方法,是建一個(gè)函式表,然后呼叫對應(yīng)的函式。這種方式,比用 switch() 沒效率,因?yàn)楹艚泻?,有額外的開銷。第二種方式,是建一個(gè)位址的表格,然后使用 goto 語句。這種方式,稍比用 switch() 有效率一點(diǎn),但這種方式,只適合用在編譯器支援未預(yù)定位址表格。其他的編譯器,不會允許你這樣定義表格。
在成功解譯并執(zhí)行一個(gè)操作碼后,這時(shí)候該去檢查有沒有任何系統(tǒng)中斷發(fā)生。這時(shí)候,你也可以執(zhí)行任何需要跟系統(tǒng)時(shí)鐘同步的工作。
- if(Counter<=0)
- {
- /* Check for interrupts and do other hardware emulation here */
- ...
- Counter+=InterruptPeriod;
- if(ExitRequired) break;
- }
有關(guān)周期性的工作,后面會提到。
注意,我們并非直接指定 Counter=InterruptPeriod,而是執(zhí)行 Counter+=InterruptPeriod,這樣會讓周期的計(jì)算更精確,因?yàn)橛袝r(shí)候,Counter 會變成負(fù)數(shù)。
同時(shí),注意這
- if(ExitRequired) break;
這個(gè)語句如果在每個(gè)循環(huán)都執(zhí)行,成本太高,所以只有在中斷發(fā)生時(shí)才檢查。這樣就可以在 ExitRequired=1 時(shí),停止模擬,但又不會花太大的成本。
#p#
如何存取被模擬的內(nèi)存?
模擬內(nèi)存存取最簡單的方式,就是把它當(dāng)成一個(gè)攤平的位元組或字元組陣列。如此,存取內(nèi)存,就是一件微不足道的事情:
- Data=Memory[Address1]; /* Read from Address1 */
- Memory[Address2]=Data; /* Write to Address2 */
這種簡易的作法,并非永遠(yuǎn)可行,原因如下:
●分頁式的內(nèi)存 ?內(nèi)存空間,可能被切成小塊,變成可以切換的頁,就是所謂的 banks。例如常見的,小內(nèi)存位址空間( 64 KB),所使用的擴(kuò)充內(nèi)存。
●映射的內(nèi)存 ?這塊內(nèi)存空間,可以用數(shù)個(gè)不同的位址來存取。例如你寫資料到位址 $4000,然后你在位址$6000,及位址 $8000,你也可以讀到。
●ROM 的讀取保護(hù) ?有些存到卡夾的軟體(例如 MSX 的游戲),就算你寫到 ROM,回傳成功,事實(shí)上 ROM 上的資料也不會改變。這麼做,是為了做軟體保護(hù)。為了讓這樣的軟體,可以在你的模擬器運(yùn)行,你需要把 ROM 設(shè)成唯讀。
●內(nèi)存映射到 I/O ?系統(tǒng)可能有 I/O 裝置,映射到內(nèi)存位址。存取這樣的內(nèi)存位址,會產(chǎn)生特殊效果,所以必須被追蹤。
要成功處理上述問題,我們引進(jìn)幾個(gè)函式:
- Data=ReadMemory(Address1); /* Read from Address1 */
- WriteMemory(Address2,Data); /* Write to Address2 */
所有特殊的處理,包括內(nèi)存分頁,內(nèi)存映射,I/O 的處理,等等,都在函式內(nèi)處理。
ReadMemory() 跟 WriteMemory() 對模擬器造成很大的 CPU 負(fù)擔(dān),因?yàn)樗鼈儓?zhí)行的非常頻繁。因此這些函式必須寫得越有效率越好。這裡有一個(gè)存取分頁式內(nèi)存的例子:
- static inline byte ReadMemory(register word Address)
- {
- return(MemoryPage[Address>>13][Address&0x1FFF]);
- }
- static inline void WriteMemory(register word Address,register byte Value)
- {
- MemoryPage[Address>>13][Address&0x1FFF]=Value;
- }
注意那個(gè) inline 關(guān)鍵字,它會指示編譯器,直接把這些函式碼,直接插入程序中,以取代函式呼叫。如果你的編譯器,不支援 inline 或是 _inline,試著改把這些函式,宣告成 static,有些編譯器(例如 Watcom C)優(yōu)化時(shí),會把短的函式,變成 inline 函式。
同時(shí)要記住,通常 ReadMemory() 的呼叫次數(shù),是 WriteMemory() 的好幾倍。所以盡量把程序碼放到 WriteMemory(),讓 ReadMemory() 保持簡單。
關(guān)於內(nèi)存映射的一個(gè)小註記:
之前說過,被映射的內(nèi)存,寫入一個(gè)位址,可以在其他位址讀取。這個(gè)功能,可以實(shí)做在 ReadMemory(),但是通常我們不這樣做,因?yàn)?ReadMemory() 比 WriteMemory() 更頻繁被呼叫。更有效率的方式,是實(shí)做內(nèi)存映射到 WriteMemory()函式。
周期性的運(yùn)作有哪些?
周期性的運(yùn)作,是被模擬的機(jī)器,固定一段時(shí)間,就會執(zhí)行的工作,例如:
- 屏幕更新
- VBlank 跟 HBlank 系統(tǒng)中斷
- 更新時(shí)鐘
- 更新聲音參數(shù)
- 更新鍵盤跟搖桿狀態(tài)
- 其他
為了要模擬這樣的運(yùn)作,你要替它們綁上固定的周期。例如 CPU 假設(shè)以 2.5 MHz,并且以 50 Hz 更新顯示(PAL 系統(tǒng)),所以 VBlank 系統(tǒng)中斷,就會每 5000 CPU 周期,發(fā)生一次。
2500000/50 = 50000 CPU cycles
現(xiàn)在,假設(shè)整個(gè)螢?zāi)皇牵ò?VBlank)是 256 條掃瞄線,實(shí)際上只有 212 條顯示(44 條在 VBlank),我們得到一條掃瞄線 195 個(gè) CPU 周期,更新一次。
50000/256 ~= 195 CPU cyles
然后,我們應(yīng)該產(chǎn)生一個(gè) VBlank 系統(tǒng)中斷,然后在 VBlank 期間不做任何事情。
(256-212)*50000/256 = 44*50000/256 ~= 8594 CPU cycles
小心計(jì)算每個(gè)周期性運(yùn)作所需的 CPU 周期,然后使用他們的最大公約數(shù),作為中斷檢查的周期,然后綁定給每個(gè)周期性運(yùn)作。
如何優(yōu)化 C 程序?
首先,很多執(zhí)行效率的增進(jìn),只要選對編譯器的編譯選項(xiàng),就有了。根據(jù)我的經(jīng)驗(yàn),下面的編譯選項(xiàng),可以給你的最佳的執(zhí)行速度:
- Watcom C++ -oneatx -zp4 -5r -fp3
- GNU C++ -O3 -fomit-frame-pointer
- Borland C++
如果你發(fā)現(xiàn),這三個(gè)編譯器,更好的優(yōu)化參數(shù),或是其他的編譯器的優(yōu)化參數(shù),請讓我知道。
●一些關(guān)於把循環(huán)攤平的筆記
雖然說,把循環(huán)攤平的這個(gè)優(yōu)化選項(xiàng),看起來是有用的。這個(gè)選項(xiàng),會把短的循環(huán),攤平成線性的語句。但我的經(jīng)驗(yàn)告訴我,開啟這個(gè)選項(xiàng),執(zhí)行效率并不會提升太大,反而在某些情況下,程序反而會出現(xiàn)異常。
優(yōu)化 C 程序碼,比選擇編譯器選項(xiàng),還難搞。跟執(zhí)行你的程序的 CPU 有很大關(guān)系。有一些通用的規(guī)則,可以適用在所有 CPU。但別把它們當(dāng)成真理。
●使用分析程序
用分析工具來執(zhí)行你的程序(第一個(gè)就想到 GPROF),或許可以發(fā)現(xiàn)你從沒懷疑的神奇事情。你會發(fā)現(xiàn)毫不起眼的程序,頻繁的被執(zhí)行,拖慢整個(gè)程序。優(yōu)化這些程序碼,或是用匯編語言改寫,可以讓你的程序執(zhí)行效率飛耀。
●不要用 C++
不要用任何非用 C++ 不可的架構(gòu)。C++ 跟純 C 比起來,額外的開銷比較大。
●整數(shù)的類型
盡量用你的 CPU 支持的整數(shù)類型。舉例 int 對比 short 或 long,這會減少編譯器產(chǎn)生不同整數(shù)行別的轉(zhuǎn)換。
●寄存器配置
盡量減少在程序區(qū)塊配置太多變數(shù),并且宣告他們?yōu)?register (大部分的編譯器已經(jīng)會自動把變數(shù)變成 register)。特別是有很多通用暫存器的 CPU (PowerPC)這個(gè)優(yōu)勢,就比有專屬暫存器(Intel 8086)來的強(qiáng)。
●攤平小循環(huán)Unroll small loops
如果你剛好有小循環(huán)會執(zhí)行好幾次,把小循環(huán)攤平成線性執(zhí)行的程序,是好主意。對照前面提到的編譯器自動攤平選項(xiàng)。
●算術(shù)移位 vs. 乘除法
盡量用算術(shù)移位,如果你乘或除一個(gè)數(shù)是 2 的 n 次方(J/128==J>>7),算術(shù)移位在大多數(shù)的 CPU 都比較快。另外用位元的 & 來求餘數(shù)(J%128==J&0x7F)。
#p#
什麼是高低字節(jié)順序?
所有的 CPU 通常都根據(jù)它們?nèi)绾蝺Υ尜Y料到內(nèi)存,分為幾個(gè)等級。除了非常特殊的種類,絕大多數(shù)的 CPU 分成兩個(gè)等級:
High-endian CPU 先存放 higher byte of word。例如,在這樣的 CPU 你存放 0×12345678,內(nèi)存的內(nèi)容會長像這樣:
- 0 1 2 3
- +--+--+--+--+
- |12|34|56|78|
- +--+--+--+--+
Low-endian CPU 先存放 lower byte of word。上述了例子,內(nèi)存內(nèi)容會看起來完全不一樣。
- 0 1 2 3
- +--+--+--+--+
- |78|56|34|12|
- +--+--+--+--+
典型 High-endian 的 CPU 有 6809,摩羅托拉 680×0 系列,PowerPC,及昇陽的 SPARC。Low-Endian 的 CPU 有 6502,及其后代 65816,及 zilog Z80,絕大多數(shù) Intel CPU (8086,8088),DEC alpha 等。
當(dāng)我們寫模擬器時(shí),必須注意到,你模擬的 CPU,及執(zhí)行你的模擬器的 CPU 的高低字節(jié)。舉例,我們想要模擬 low-endian 的 Z80,Z80 會先存 lower byte of word。如果你用的也是 low-endian 的 CPU,例如 intel 8006,那麼完全不需要特別處理。但是如果你用的是 high-endian 的 CPU,例如 PowerPC,這時(shí)候,要存放 16 bit 的 Z80 資料到內(nèi)存,就會有問題。如果你的程序,必須兩種高低字節(jié)順序的 CPU 都能跑,問題就更復(fù)雜了。
一種解節(jié)高低字節(jié)順序的作法如下:
- typedef union
- {
- short W; /* Word access */
- struct /* Byte access... */
- {
- #ifdef LOW_ENDIAN
- byte l,h; /* ...in low-endian architecture */
- #else
- byte h,l; /* ...in high-endian architecture */
- #endif
- } B;
- } word;
可以看到,可以用 w 存取整個(gè)字節(jié)。而每次如果你需要存取個(gè)別 byte,用 B.l 及 B.w,來對應(yīng)高低位元組。
如果你的程序,要在跨平臺編譯,在程序開始執(zhí)行前,你也許會想要測試,是否編譯有設(shè)定正確的 endian 旗標(biāo)。這裡有如何測試的程序碼。
- int *T;
- T=(int *)"\01\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
- if(*T==1) printf("This machine is high-endian.\n");
- else printf("This machine is low-endian.\n");
如何讓程序具可移植性?
尚未撰寫。
為何我要模組化我的程序?
大多數(shù)的計(jì)算機(jī)系統(tǒng),是由幾塊比較大的芯片所組成,各自執(zhí)行一部分的系統(tǒng)功能。有 CPU、顯示控制器、聲音產(chǎn)生器、及其他。有些芯片,有自己的內(nèi)存,及周邊的硬件。
一個(gè)典型的模擬器,應(yīng)該重現(xiàn)原有的系統(tǒng)設(shè)計(jì),并實(shí)做每個(gè)子系統(tǒng)的功能,在不同的模組。這樣做,首先除錯會比較容易,因?yàn)閱栴}會被獨(dú)立在各自的模組裡。其次模組化,可以讓你在別的模擬器,重復(fù)使用你的模組。電腦的硬體,其實(shí)標(biāo)準(zhǔn)化成度很高,你可以在不同型號的電腦,發(fā)現(xiàn)相同的 CPU,相同的顯示控制器。為某個(gè)芯片,模組化寫一次模擬的程序,會比你每次都重寫來的容易。
譯文鏈接:Shun-Yuan Chou 的博客(譯者是臺灣人,原譯文為繁體,大部分術(shù)語已轉(zhuǎn)換成簡體,可能少數(shù)術(shù)語有遺漏)