未經授權請勿散播此文件. 請鏈結此網頁,禁止拷貝
在我收到許多電子郵件,他們詢問如何設計電腦上的模擬器, 從何處開始? 於是我寫下這篇文件.以下的建議與忠告是我自己的想法,不保證絕對正確. 這份文件只要涵蓋了所謂的"解譯式"模擬器,而非"編譯式",因為我並沒有很多的經驗在"重新編譯"的技術. 有一兩個地方你可以找到有關這類技術的資訊.
如果你認為這份文件遺漏了什麼或者是需要修正的地方,請 E-mail 告知我你的建議. 雖然我不會回答狂熱份子與白癡對於ROM方面的請求. 很不幸的我遺失了一些重要的 FTP/WWW的網站位址在這份文件的最後,所以如果你知道有價值的相關資料的話,請告訴我.其他像 FAQ 的常見問題集也不包含在文件中.
這份文件的英文原版在此, 日文版 由 Bero翻譯, 中文版(BIG-5)由麥克熊翻譯
所以啦,你決定要寫一個軟體模擬器嗎?很好,這份文件對你可能有幫助. 它包含了一些常用的技術問題關於模擬器的實作.它也提供模擬器內部的藍圖,你可以一步一步的學習.
基本上,任何有微處理器的都可以.當然,只有具備有較佳彈性或較差彈性的程式設備模擬起來才較為有趣.它們包含了:
必須注意的是你可以模擬任何電腦系統,甚至是非常複雜的(例如 Commodore Amiga電腦). 但是模擬的效率會非常的慢.
模擬是企圖去仿製設備的內部設計.模仿是仿製設備的功能. 例如,一個程式模擬小精靈的硬體並且直接用小精靈的ROM去跑,這就是模擬器. 而一個為你的電腦設計的小精靈遊戲,外表看起來像是小精靈遊戲,這就是模仿.
雖然這是在"灰色"地帶的東西,表示模擬有版權的硬體是合法的,只要還沒有任何資訊說它是不合法的.你應該小心散播有版權的系統ROM(唯讀記憶體或是BIOS等等)在一個模擬器中是不合法的.
一個模擬器有三個基本的方案可以使用.並可以為了最佳效果來合併使用.
while(CPUIsRunning) //當CPU在執行的話
{
Fetch OpCode //讀取運算碼
Interpret OpCode //解譯運算碼
}
這種方法的優點是容易除錯,可攜性高,同步性佳(你可以輕易的計算時間週期並且將模擬器聯繫在時間週期上).
一個最大,最明顯的缺點就是執行效率.解譯式會花費許多的CPU時間, 所以你可能需要很快的電腦來執行你的程式碼,以達到還不錯的速度.
為了寫一個模擬器,你必須有良好的基本知識有關電腦程式設計與數位電子學.有組合語言的程式設計經驗將會很快上手.
最常見的就是 C 和組合語言. 這裡列出兩種語言的優缺點:
優 通常會產生執行較快的程式碼. 優 模擬 CPU 的暫存器可以直接存放於被模擬 CPU 的暫存器中. 優 許多執行碼可以模擬的類似模擬 CPU 的執行碼. 缺 程式碼不具可攜性,例如: 無法在不同架構的電腦上執行 缺 程式碼很難除錯及維護
優 程式碼具有可攜性,所以在不同的電腦和作業系統下都可以執行. 優 它的程式碼較容易除錯和維護. 優 真實硬體工作的各種假設狀況可以快速地被測試. 缺 C 通常比純組合語言執行得慢一些.
對於寫一個可用的模擬器而言, 一定得對你選用的程式語言有充分的了解, 這是相當複雜的計劃, 你的程式碼應該被最佳化, 執行起來才會更快更有效率. 電腦模擬器很明顯的並不是你學習一個程式語言的計劃之一.
你可以參考下列的位址:
comp.sys.msx MSX/MSX2/MSX2+/TurboR computers comp.sys.sinclair Sinclair ZX80/ZX81/ZXSpectrum/QL comp.sys.apple2 Apple ][ etc.
公告問題時請查詢適當的常見問題集 FAQ.
Console
and Game Programming site in Oulu, Finland
Arcade
Videogame Hardware archive at ftp.spies.com
Computer
History and Emulation archive at KOMKON
comp.emulators.misc
FAQ
My
Homepage
Arcade
Emulation Programming Repository
Emulation
Programmer's Resource
首先, 如果你只需要模擬一個標準的 Z80 或 6502 CPU, 你可以使用其中一個 我寫的 CPU 模擬器. 有確定狀態的用法.
對於想自己寫 CPU 模擬器核心的人或是有興趣想知道它是如何運作的話, 我提供一個典型的 CPU 模擬器的骨架, 用 C 寫成的. 你自己可能想省略一部份並增加另一部份.
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 Counter 的初始值, 以及 PC 的程式計數器:
Counter=InterruptPeriod; PC=InitialPC;
這個 Counter
包含了預測下一次中斷 CPU 週期的剩餘值.
注意當計數器截止後則中斷就不需要產生了:
你可以它來達成一些其他的目的,
例如同步計時器, 或是更新畫面的掃描線.
以後再介紹. PC 包含了被模擬 CPU
將要讀取的下一個運算碼的記憶體位址.
給定初始值後, 我們開始執行主迴圈:
for(;;)
{
注意這個迴圈也可以寫成
while(CPUIsRunning)
{
這裡的 CPUIsRunning 是一個布林變數.
這樣有一個明顯的好處, 就是你可以隨時中止這個迴圈,
只要把 CPUIsRunning 設定為即可.
很不幸的, 每次迴圈都要檢查這個變數會花掉一些
CPU 的時間, 所以應該盡可能的避免. 但也不要將這個迴圈寫成如下所示:
while(1)
{
因為這個例子中, 有一些程式編譯器會產生程式碼去檢查 1 是否為 true(真). 你一定不會想讓程式編譯器在每個迴圈都去做這個沒意義的事吧.
現在當我們在迴圈中, 第一件要做的就是讀進下一個運算碼, 並且修改程式計數器:
OpCode=Memory[PC++];
要注意的是, 儘管這是最簡單也最快速的方法去讀取模擬記憶體, 但這並不總是可行的. 本文件的後面會提到另一個較為普遍的使用記憶體的方法.
當讀取運算碼後, 就將 CPU 週期計數器減去這個運算碼所需的週期值:
Counter-=Cycles[OpCode];
Cycles[]
表中應包含有每個紀錄運算碼所需的CPU週期值.
注意有某些運算碼 (例如條件式跳躍或副程式呼叫)
在不同的參數下可能會有不同的週期值.
因此之後要再調整了.
現在要來解釋運算碼並執行它了:
switch(OpCode)
{
switch() 常被誤認為是沒有效率的,
當它被編譯成一連串的 if() ... else if()
... 陳述語法. 在只有少量的條件式下確實是如此,
不過在大量的條件式下 (100-200 或更多)
它就會被編譯成一個跳躍查詢表,
那就十分有效率了.
有兩個方法來解譯運算碼. 第一種方法是建一個功能函式表,
去呼叫適當的一個. 這個方法比 switch() 沒有效率,
要經常透過呼叫功能函式.
第二種方法是建一個標記表, 並使用 goto
語法. 雖然這個方法比 switch() 要快一點,
不過只有在有提供 "預先編譯標記"
功能的程式編譯器才有用.
其他的程式編譯器並不允許你建立一個標記位址的陣列表.
在成功的解譯並且執行一個運算碼之後, 就要檢查我們是否要產生中斷. 這時, 你就可以執行任何在需要和系統時脈同步的事情上:
if(Counter<=0) { /* Check for interrupts and do other hardware emulation here */ ... Counter+="InterruptPeriod;" if(ExitRequired) break; }
這些週期性的工作會在接下來的文件中提到.
注意我們並不是簡單的設定 Counter=InterruptPeriod,
而是讓 Counter+=InterruptPeriod:
這樣可以讓週期計數器更精確, 使 Counter
值也可以是負數.
再看看這一行
if(ExitRequired) break;
每個迴圈都去檢查是否該結束也太浪費了些,
我們只要在 Counter 逾期才結束:
這樣只要設定 ExitRequired=1 就可以離開模擬器,
也不會花費太多的 CPU 時間.
操作模擬器記憶體最簡單的方法就是把它當成位元組陣列 (字組陣列等等). 使用它是很平凡的:
Data=Memory[Address1]; /* Read from Address1 */ Memory[Address2]=Data; /* Write to Address2 */
雖然這種簡單的記憶體操作在下列的例子中並非都是可行的:
要處理這樣的問題, 在此介紹兩個功能函式:
Data=ReadMemory(Address1); /* Read from Address1 由位址1讀取*/ WriteMemory(Address2,Data); /* Write to Address2 寫入位址2*/
所有特別的程序如分頁操作, 映射, 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) 再最佳化時會將小型的靜態函式直接內建入執行碼中.
也要注意一下大多數的時候, ReadMemory()
被頻繁呼叫的次數常是 WriteMemory()
的好幾倍. 因此將較多的程式碼寫在 WriteMemory()
中, 以讓 ReadMemory() 函式盡量的愈短愈簡潔,
這樣是值得的.
ReadMemory()
中處理, 不過通常是不會這樣做的,
因為 ReadMemory()被呼叫的頻率遠高於
WriteMemory(), 所以比較有效率的方法是將映射記憶體的功能寫在
WriteMemory() 函式中.週期性的工作應該是會定期發生在模擬器的機器上的, 如下:
為了模擬這樣的工作, 你應該將它們聯繫在適當的 CPU 週期上. 例如, 如果 CPU 週期在 2.5Mhz 上執行, 而顯示器的更新率是 50Hz(標準的 PAL 影像), 那麼垂直空白的中斷就應該每 50000 CPU 週期中斷一次
2500000/50 = 50000 CPU cycles
現在, 如果假設整個畫面(包含垂直空白) 有 256 條掃描線高, 其中實際顯示在畫面上的有 212 條(其他 44條是垂直空白), 那你的模擬器必須每 195個 CPU 週期更新一條掃瞄線
50000/256 ~= 195 CPU cyles
之後, 你應該產生一次垂直空白中斷, 然後等待直到做完了垂直空白, 也就是休息 8594 個 CPU 週期
(256-212)*50000/256 = 44*50000/256 ~= 8594 CPU cycles
小心計算每個工作所需的 CPU 週期數,
接著使用最大公倍數作為
InterruptPeriod, 並所有其他的工作聯繫於此的(每次計數器的終結時則不必執行).
首先, 許多額外有效率編碼可以藉由正確的選擇編譯器最佳化的選項來達成. 在我個人的經驗上, 以下列旗標的組合會給你最佳的執行速度:
Watcom C++ -oneatx -zp4 -5r -fp3 GNU C++ -O3 -fomit-frame-pointer Borland C++
如果你發現有這些編譯器更好的選項設定或是不同的編譯器, 請告訴我.
將 C 程式碼本身最佳化相對於選用編譯器的選項而言, 是一些小的技巧, 一般而言通常還要依不同的 CPU 去做最佳化. 幾個一般的法則適用於所有類型的 CPU. 雖然沒有絕對正確的, 當你所需要的利益是多變的:
GPROF
是馬上想到的) 可能會顯示出很棒的事情,
那是你以前從來不會懷疑的. 你會發現似乎微小的碼確執行的非常頻繁要的,
使的整個程式慢了下來.
將這些的程式碼最佳化或者是用組合語言重寫,
就可以加速效率了.int 而不是用 short 或rlong.
這樣會減少編譯器再產生碼時轉換不同長度的整數.
也可能減少記憶體操作的時間, 一些
CPU 工作是最快的, 當讀寫資料的基準容量的邊界與記憶體的邊界對齊時.register
(雖然大多數新的編譯器可以自動地將變數放入暫存器中).
這樣便能了解到具有許多一般功能暫存器的
CPU (Power PC) 的好處, 相對於只有一些可宣告暫存器的
CPU (Intel x86).J/128==J>>7).
在大多數的 CPU 執行起來會快一些.
另外使用位元運算 (AND) 在這類的例子中(J%128==J&0x7F).
所有的 CPU 一般分為幾個等級, 基於它們如何將資料存放於記憶體中. 雖然會有一些少見的樣本, 而大多的 CPU 可以分為這兩類:
0x12345678 存放於這類的
CPU, 那記憶體中看起來就向下面所示:
0 1 2 3
+--+--+--+--+
|12|34|56|78|
+--+--+--+--+
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, 等等.
當你寫一個模擬器時, 你應該同時注意到模擬和被模擬的 CPU 的結尾法. 如果你想要模擬一個 Z80 CPU, 它是用低位元結尾法, 它存放 16 位元的字組以低位元組先放. 如果你用的是低位元結尾的 CPU (例如, Intel 80x86) 來模擬它的話, 每件事都很自然的處理. 如果你用的是高位元結尾的 CPU(PowerPC), 在將 16 位元的 Z80 資料放如記憶體中就忽然有問題了. 更糟的是, 如果你的程式必須同時在這兩種架構中運作, 那你就需要一些方法去偵測結尾的方式.
以下列出一個處理結尾問題的方法:
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 來存取.
每次你模擬時需要分別用個別的位元組來存取,
你就用 B.l 和
B.h 來存放順序.
如果你的程式將被編譯在不同的工作平台上, 你可能想要測試正確的結尾法的旗標, 在你執行任何重要的事之前. 這兒有一個方法去做這樣的測試:
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"); // 這部機器是低位元結尾法
以後再寫.
大多數的電腦系統是由幾個大的晶片所組成的, 每個晶片執行系統功能的特定部分. 如此一來, 就有 中央處理器CPU, 顯示控制器, 聲音產生器, 等等. 某些晶片有它們自己的記憶體, 其他的硬體就跟它們相連接.
一個典型的模擬器應該要重複原本的系統設計, 藉著實作每一個子系統的功能函式在個別的模組中. 首先, 這會讓除錯容易些, 所有的臭蟲就會在局部的模組中. 另外, 模組化的架構允許你重複使用模組程式在別的模擬器中. 電腦硬體是相當標準化的: 你可以預期會在許多不同的電腦基版中找到相同的中央處理器或是顯示晶片. 這樣就較容易些, 只要模擬過這樣的晶片一次就好了, 而不需再使用這個晶片的每種電腦上一次又一次的實作撰寫了.
©1997-1999 Copyright by Marat Fayzullin [fms@cs.umd.edu]
回到首頁 last updated Feb 26,1999