我們來動手編寫計算機模擬器

pythontab發表於2012-12-28
你決定要寫一個軟體模擬器嗎?好極了,這篇文件可能對你有幫助,它涵蓋了人們在寫模擬器時會提出的一些常見技術問題,同時它也給你了一張模擬器內部的“藍圖”,對你寫模擬器多少會有幫助。

你可以模擬什麼東西?
基本上只要有微處理器的東西都可以被模擬,當然了,只有那些用來執行軟體的裝置才值得去模擬,包括:

計算機
計算器
電子遊戲機
街機
等等
有必要指出你可以模擬任何計算機系統,即使它很複雜(例如 Commodore Amiga 計算機 *),不過模擬的效能會非常差。(* 譯者注:Commodore Aimga 一款功能強大的遊戲機,其效能趕上甚至超過了同時代的 PC。 )

什麼是“模擬”?它和“模擬”有什麼區別?
模擬(emulate)的目標是模仿裝置的內部設計,而模擬(simulate)旨在模擬裝置的功能。舉個例子,在一個模擬 Pacman 街機硬體的程式上跑 Pacman ROM * 就是一個模擬器,而仿照街機的畫面在計算機上寫出來的 Pacman 遊戲就是模擬器。(* 譯者注:ROM 是指儲存硬體“系統”程式碼的只讀儲存器。)

模擬有專利的硬體是否合法?
這個問題的答案模稜兩可,但只要你透過合法途徑獲取資訊,那麼即使模擬有專利的硬體也是合法的。但要注意,若你在釋出模擬器的同時釋出了有版權保護的系統 ROM(BIOS 等)是非法的。

什麼是“解釋型”模擬器?它和“重新編譯型”模擬器有什麼區別?
你可以使用三種基本模式來實現模擬器,為了得到最佳效果,你可以組合使用這三種模式。

解釋型
模擬器從儲存器逐個位元組讀取模擬器的程式碼,解碼,然後對模擬暫存器、儲存器、輸入輸出裝置執行相應的命令。這一類模擬器的通用演算法如下:

while(CPUIsRunning) {
 Fetch OpCode
 Interpret OpCode
 }

這個模型的優點在於便於除錯、移植性好、好同步(很容易就能數出過了多少個時鐘週期,然後用它來控制模擬器。)

但它有一個很明顯弱點,效能差。解釋需要佔用很多 CPU 時間,為了讓程式碼跑出像樣的速度,你可能需要一臺很好的計算機。

靜態重新編譯型
利用這種技術,你可以把模擬程式翻譯成計算機的彙編程式碼,最後你將得到一個可執行檔案,你可以在你的計算機上執行,無需任何特殊工具。雖然靜態重新編譯聽起來不錯,但它並非總是可行,例如,如果一個會修改自己的程式,你就沒有辦法靜態重新編譯,因為你不知道它會變成什麼樣,除非你執行它。為了防止這種情況,你可以把靜態重新編譯器和直譯器或動態編譯器合在一起使用。

動態重新編譯行
動態和靜態重新編譯本質上是一回事,只是它發生在程式執行時。它並不會一次編譯全部的程式碼,而是在遇到 CALL 或 JUMP 指令時動態地編譯程式碼。為了提高速度,這種技術可以和靜態重新編譯合在一起用。關於動態重新編譯的更多內容,你可以閱讀 Ardi 的白皮書,Macintosh 的重新編譯型模擬器就是它發明的。

如果我想寫模擬器,應該從哪裡入手?
為了寫模擬器,你必須掌握計算機程式設計和數位電路。如果你寫過彙編程式,也會非常有幫助。

選擇一種程式語言。
找到和要模擬的硬體相關的一切資訊。
實現一個 CPU 模擬器或找一份現成的程式碼。
寫一些模擬其他硬體的程式碼,至少要寫一部分。
這時,寫一個內建的小偵錯程式會很有幫助,它可以停止模擬然後看到程式在做什麼,你可能還需要一個目標系統組合語言的反彙編器,如果沒有就自己寫一個。
試著在你的模擬器上執行程式。
使用反彙編器和偵錯程式觀察程式如何使用硬體,合理調整程式碼。
應該用什麼程式語言?
C 和彙編是首選,它們各自的利弊如下:

組合語言
+ 生成程式碼的速度通常比較快。
+ 模擬 CPU 的暫存器可以直接儲存在執行模擬器的 CPU 的暫存器中。
+ 很多操作碼可以用執行模擬器的 CPU 的操作碼來模擬。
- 程式碼不可移植,不能在不同的體系結構上執行。
- 程式碼難以除錯和維護。

C
+ 程式碼可以移植,能在不同的計算機和作業系統上執行。
+ 除錯和維護相對比較容易。
+ 對硬體的工作行為的假設比較容易檢驗。
-  C 通常比純彙編程式碼慢。

上哪弄要模擬的硬體的資訊?
下面列出了一些你可能想要

新聞組
comp.emulators.misc
這個新聞組專門用來討論有關計算機模擬的問題,很多模擬器作者都讀它,雖然它有點吵。發言前請先閱讀它的 FAQ。
comp.emulators.game-consoles
和 comp.emulators.misc 很像,但針對電子遊戲裝置的模擬,發言前閱讀 comp.emulators.misc 的 FAQ。
comp.sys./emulated-system/
comp.sys.* 下是特定型號的計算機,閱讀這個新聞組,你可能會得到很多有用的技術資訊。在這些討論組發言之前請先閱讀相關的 FAQ。
alt.folklore.computers
rec.games.video.classic
FTP
Console and Game Programming
Arcade Videogame Hardware
Computer History and Emulation
WWW
我的主頁
Arcade Emulation Programming Repository
Emulation Programmer's Resource
如何模擬 CPU?
首先,如果你只需要模擬一個標準的 Z80 或 6502 CPU,你可以用我寫的 CPU 模擬器。不過它們的適用範圍有限。

如果你想自己寫一個 CPU 模擬器核心,或對它們如何工作感興趣,下面我給出了一個典型的 CPU 模擬器框架。在實際的模擬器中,你可能跳過其中某些部分或新增一些自己的程式碼。

Counter = InterruptPeriod;
PC = InitialPC;
 for (;;) {
     OpCode = Memory[Pc];
     Counter -= Cycles[OpCode];
     switch (OpCode) {
     case OpCode1: case OpCode2: ...
 }
     if (Counter <= 0) {
         /* 檢查中斷或做其它事 */
 /* 這裡是迴圈任務 */
         ...
        Counter += InterruptPeriod;
        if (ExitRequired) break;
     }
 }

首先我們為 CPU 時鐘週期計數器(Counter)和程式計數器(PC)賦初值:


Counter = InterruptPeriod; PC = InitialPC;

Counter 中儲存了距離下一次中斷還有多少個 CPU 時鐘週期,注意當 Counter 減為 0 中斷並不一定發生:利用這點你可以很多其他事,例如同步計時器,更新螢幕的掃描行,稍候我會細講。PC 中儲存了一個儲存器地址,模擬的 CPU 將從該地址中讀出下一個操作碼(opcode)。


賦完初始值,我們開始主迴圈:

for (;;) {
你也可以這樣實現迴圈:

while (CPUIsRunning) {

這裡的 CPUIsRunning 是一個布林型變數,這麼做的好處是你可以隨時把 CPUIsRunning 設為 0 來結束迴圈。不幸的是,每次都在迴圈中檢查這個變數都會佔用很多的 CPU 時間,如果可以最好別這樣做。另外,不要這樣實現迴圈:

while (1) {

因為在這個例子中,有的編譯器會生成“檢查 1 是否為真”的程式碼,你可不希望編譯器每次迴圈時都做無用功。

現在,我們進入了迴圈,第一件事就是讀取下一個操作碼,然後修改程式計數器:

OpCode = Memory[Pc];

注意,儘管這是模擬器“從儲存器讀取資料”最簡單、也是最快速的方法,但它不總是可行,關於訪問儲存器更通用的方法,我稍後會提。


取出操作碼後,我們將 CPU 時鐘週期計數器減去這個操作碼所需時間:

Counter-=Cycles[OpCode];

Cycles[] 表中儲存了所有操作碼所需花費的 CPU 時鐘週期數。小心,某些操作碼(例如條件轉移或子例程呼叫)花費的時鐘週期數會根據引數的變化而變化,不過這可以在後續的程式碼中調整。

接著是解釋操作碼並執行:

switch (OpCode) {

一個常見的誤區是認為 switch() 結構的效率低,因為它會編譯成一連串的 if() .. else if().. 語句。沒錯,當條件數很少時,switch() 結構的確會編譯為一連串的 if 語句,但如果超過 100 個,就會編譯成一個跳轉表,這樣效率就會很高。

解釋操作碼的方法有兩種。一種是建一張函式表,根據它呼叫相應函式,這種方法的效率比 switch() 低,因為函式呼叫會帶來額外開銷(overhead)。另一種是建一張標籤(label)表,然後用 goto 語句跳到相應標籤,雖然這種方法比 switch() 更快,但只有那些提供了“預計算標籤”的編譯器才能使用,其他編譯器不允許你建立標籤地址的陣列。

當我們解釋完操作碼並執行相應操作之後,是時候檢查一下是否需要中斷了。這時你還可以執行那些需要和系統時鐘同步的任務:

if (Counter <= 0) { /* 檢查中斷,進行一些其他的硬體模擬 */
     ...
 Counter += InterruptPeriod;
     if (ExitRequired) break; }

迴圈任務一會兒再說。

注意,我們不僅僅令 Counter = InterrputPeriod,而是用 Counter += InterruptPeriod:這樣時鐘週期計數才精確,因為 Counter 中儲存的時鐘週期數可能是負的。

再來看這行:

if (ExitRequired) break;

因為在每遍迴圈中檢查退出的代價實在太大,所以我們只在 Counter 減到 0 以下時檢查: 當你把 ExitRequired 設為 1 時不但能退出模擬,而且不會花費太多的 CPU 時間。

如何處理對模擬儲存器的訪問?
訪問模擬儲存器最簡單的方式就是把儲存器當作一個位元組(或字,等等)陣列來處理,這樣訪問起來就容易了:

Data = Memory[Address1]; /* 從 Address1 讀取資料 */
Memory[Address2] = Data; /* 把資料寫到 Address2  */

這樣雖然很簡單,但並不總是成立,原因有以下幾個:

頁式儲存器
儲存器的地址空間可能劃分為了可交換頁(也叫塊),這麼做通常是為了在地址空間很小(64 KB)時增加儲存器空間。
映象儲存器
同一塊儲存器區域可能可以在多個不同的地址訪問到,例如你寫到 $4000 號儲存器單元的資料可能出現在 $6000 和 $8000 號單元。如果使用了不完全地址解碼 *,ROM 也有可能有映象。(譯者注:不完全地址解碼)
ROM 保護
一些卡帶式軟體(例如 MSX遊戲)會往自己的 ROM 中寫資料,如果成功,它就會停止工作,這麼做通常是為了保護版權。為了讓這樣的軟體能在你的模擬器上工作,你必須遮蔽往 ROM 寫資料的功能。
儲存器對映輸入/輸出
系統中可能有儲存器對映輸入/輸出裝置,訪問這樣的儲存器單元會產生“特殊效果”,因此需要追蹤這樣的讀寫操作。(譯者注:儲存器對映輸入/輸出是CPU 和外設通訊的一種渠道)
為了解決這些問題,我們引入兩個函式:

Data = ReadMemory(Address1); /* 從 Address1 讀取資料 */
WriteMemory(Address2,Data); /* 往 Address2 寫資料 */

所有特殊的處理,例如對頁的訪問、映象、I/O 處理等等都放到這些函式中去完成。

ReadMemory() 和 WriteMemory() 通常會給模擬器帶來大量額外開銷,加上它們的呼叫頻率很高,所以必須足夠高效才行。下面這個例子中的函式訪問了分頁的地址空間:

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;
}

注意 inline 關鍵字,它告訴編譯器:把函式嵌入程式碼中,而不是呼叫它們,如果你的編譯器不支援 inline 或 _inline,可以用 static 關鍵字:有的編譯器(例如 WatcomC)會最佳化 static,把函式嵌入程式碼。

同時記住,在絕大多數情況中呼叫 ReadMemory() 的頻率要比呼叫 WriteMemory() 高出幾倍,因此不妨把更多的程式碼放到 WriteMemory() 中去實現,而讓 ReadMemory() 儘可能簡短。

註釋:儲存器映象
正如前面所說,很多計算機都有映象 RAM,寫到某個儲存器單元的值可能出現在其他單元中。雖然這種情況可以在 ReadMemory() 中處理,但最好別這麼做,因為 ReadMemory() 的呼叫頻率比 WriteMemory() 高, 在 WriteMemory() 函式中實現儲存器映象更高效。

什麼是迴圈任務?
迴圈任務(cyclic task)是模擬機中定期發生的事件,例如:

螢幕重新整理
VBlank 和 HBlank 中斷 (譯者注:VBlank:水平回掃;HBlank:垂直回掃)
更新計時器
更新聲音引數
更新鍵盤/手柄狀態
等等
為了模擬這些任務,你必須讓它們每過一個固定的 CPU 時鐘週期就發生一次,比如假設 CPU 的主頻為 2.5MHz 而顯示器的重新整理率為 50HZ(PAL 影片標準),那麼 VBLANK 中斷就應該每隔 2500000/50 = 50000 個 CPU 時鐘週期發生一次。

現在假設整個螢幕(VBlank)有 256 行高,實際顯示了其中的 212 行(剩下的 44 行需要垂直回掃),那麼我們就可以得到模擬器必須每隔 50000/256 ~= 195 個 CPU 時鐘週期內重新整理一行。

這樣,你就應該在 (256-212)*50000/256 = 44*50000/256 ~= 8594 個 CPU 時鐘週期內產生一個 VBlank 中斷,並等待 VBlank 完成工作,這期間什麼都不做。

仔細計算每項任務所需的 CPU 時鐘週期數,然後用它們的最大公約數作為 InterruptPeriod,,所有的迴圈任務每隔這段時間就會進行檢查(但不一定會在每次 Counter 減為 0 時都執行)。

如何最佳化 C 程式碼?
首先使用正確的編譯最佳化選項可以大大提高程式碼的效能,根據我的經驗,下面這些標誌組合的執行速度是最快的:

Watcom C++      -oneatx -zp4 -5r -fp3
GNU C++         -O3 -fomit-frame-pointer
Borland C++
如果你找到了更好的設定方法,請讓我知道。

迴圈展開的註釋
開啟編譯器的“迴圈展開”選項可能會起作用,這個選擇試圖把短的迴圈展開成線性的程式碼,但根據我的經驗,這個選項的效果並沒有傳說中的那麼好,在一些特殊情況下反而會降低程式碼的效能。

最佳化 C 程式碼本身比設定選項要難一些,而且通常依賴於目標 CPU,下面幾條規則適用於所有 CPU,但不要把它們視作絕對真理,因為你遇到的情況可能不同:

使用效能分析工具!
用一個好的效能分析工具(我首先想到的就是 GPROF)來分析你的程式可能會帶來意想不到的效果,你可能會找到那些執行頻率最高、降低了整個程式速度的程式碼,最佳化這些程式碼或者用匯編語言重寫會大大提升程度的效能。
不要用 C++
不要使用 C++ 編譯器支援而純 C 編譯器不支援的結構,因為 C++ 編譯器生成的程式碼通常會有額外開銷。
整數大小
使用 CPU 提供的基本整數型別,比如 int 而不是 short 或 long,因為這樣不但能減少編譯器生成程式碼(用來轉換整型長度)的數量,而且能減少訪存次數,因為如果資料的大小和 CPU 讀/寫儲存器資料的大小一致,就可以一次完成讀寫。
暫存器分配
在每段程式碼中少用一些變數,並把使用頻率最高的變數宣告為暫存器變數(絕大多數新版編譯器都會自動把變數放到暫存器中)。這對那些有很多通用暫存器(PowerPC)的 CPU 很有效,但對那些僅有少量暫存器的 CPU(Intel 80x86)就不一定了。
展開小迴圈
如果程式碼中有一個小迴圈執行了很多次,最好把它手動展開,見“自動迴圈展開”的註釋。
移位代替乘除
當你需要乘或除 2 的 n 次方時使用移位操作(J/128==j>>7)。它們在絕大多數的 CPU 上都很快/另外,使用 AND 位操作來實現求模(J%128==J&0x7F)。
什麼是位元組序?
CPU 可以根據在儲存器中儲存資料的方式分類,除了一些特殊情況,絕大多數 CPU 可分為以下兩類:

大端序
大端序(High-endian)的 CPU 在儲存器中儲存資料時字(word)的低位位元組會先出現。例如,如果你把 0x12345678 儲存在大端序的 CPU 時,儲存器看起來像這樣:

0  1  2  3
+--+--+--+--+
|12|34|56|78|
+--+--+--+--+

小端序
小端序(Low-endian)的 CPU 在儲存器中儲存資料時字的高位位元組會先出現。相同的例子在小端序 CPU 中會看起來大不相同:

0  1  2  3
+--+--+--+--+
|78|56|34|12|
+--+--+--+--+
大端序 CPU 典型的例子是 6809、Motorola 680x0 系列、PowerPC 和 Sun SPARC。小端 CPU 包括 6502 和它的後代 65816、Zilog Z80、絕大多數 Intel 晶片(包括 8080 和 80x86)、DEC Alpha 等等。

typedef union {
  short W; /* 按字訪問 */
 struct /* 按訪問... */ {
#ifdef LOW_ENDIAN
     byte l,h; /* ...在小端序結構中 */
#else
 byte h,l; /* ...在大端序結構中 */
#endif
 } B;
} word;

正如你所見,你可以用 w 來訪問整個字。儘管模擬器每次需要以獨立的位元組訪問字,但你心裡只要想 B.I 和 B.h 就行了。

如果你需要在不同的平臺上編譯程式,可能需要在模擬前測試一下它的位元組序,然後設定相應的編譯標誌。以下方法可以完成測試:

  int *T;

  T=(int *)"";
  if(*T==1) printf("This machine is high-endian.n");
  else      printf("This machine is low-endian.n");

怎樣提高程式的移植性?
待續。

為什麼要模組化?
絕大多數計算機系統是由多塊晶片組成的,它們各自完成了系統的部分功能。因此有 CPU、影片控制器、聲音發生器,等等,有的晶片可能有自己的儲存器和相連的硬體。

典型的模擬器應該在獨立的模組中實現各個子系統的功能,從而還原系統的設計。首先,這樣除錯起來更簡單,因為所有的錯誤都鎖定在了一個模組中。其次,如果把體系結構模組化,就可以在其他模擬器中重用模組。計算機硬體的標準有規範:你完全可以在不同的計算機模型中找到相同的 CPU 或影片晶片,與其為每臺計算機都實現一次晶片,不如只模擬一次,這樣會輕鬆很多。

相關文章