這是一篇轉載的翻譯,放在這里僅為了保持系列的完成性,也是一篇個人感覺比較好的模擬器編寫入門文章
如何編寫模擬器
我在寫這篇文章之前收到很多人的郵件,他們希望編寫一個模擬器卻不知從何下手。文章中提到的任何觀點和建議都來自我個人,切勿將其當成絕對真理。我的文章主要討論“解釋型”模擬器,而不是“編譯型”模擬器,這是因為我對重編譯技術沒有太多經驗。我在文章中列出一兩個鏈接,讀者可以查找這些技術的相關信息。
如果你覺得這篇文章還有不足或是有所指正,請你通過郵件發給我。我不回復那些來爭吵的,問無聊問題的,還有來要ROM映像的郵件。我在這篇文章的資源列表中丟失了幾個重要的FTP和WWW網址,如果你知道任何有價值的網址也可以告訴我。另外FAQ中如果有問題也可以給我發郵件。
這篇文章由Bero翻譯為
日文
,由Jean-Yuan Chen翻譯為
www.ppxbbs.com/SiliconValley/Vista/8177/howemu.htm
中文
,由Maxime Vernier翻譯為
法文
,另一個較老的
法文
版本由Guillaume Tuloup翻譯(鏈接可能已經失效了),HOWTO部分的
西班牙版本
由Santiago Romero翻譯,另外,Mauro Vilani將其翻譯為
意大利語
,
巴西葡萄牙語
版本由Leandro翻譯。
目錄
你決定編寫一個軟件模擬器了嗎?很好,這篇文章也許可以給你一些幫助,它包含編寫模擬器時常見的技術問題,提供了模擬器內部的“設計藍圖”,一定程度上可供你借鑒。
常見問題
· 可以模擬什么?
· “模擬”的概念,與“simulation”的區別。
· 對專有的硬件模擬是否合法?
· “解釋型”模擬器的概念,與“編譯型”模擬器的區別。
· 編寫模擬器從何入手?
· 使用何種編程語言?
· 如何獲取所模擬硬件的信息?
實現
· 如何模擬CPU?
· 如何實現對模擬內存的訪問?
· 輪轉任務的概念。
編程技術
· 如何優化C代碼?
· 什么是大小端?
· 如何讓程序可移植?
· 如何讓程序模塊化?
· 更多
可以模擬什么?
基本上只要是微處理器內部的任何部件都可以模擬。當然只有那些運行或多或少程序的設備才有模擬的需要,包括:
· 計算機
· 計算器
· 電子游戲機
· 街機
· 其它
有必要知道你可以模擬任何一種計算機系統,不論它有多復雜(例如Commordore的Amiga計算機),只不過這種模擬的性能可能非常低。
模擬的概念,與“simulation”的區別?
模擬試圖去模擬一個設備的內部設計,而“simulation”則模擬設備的功能。例如,一個程序能模擬Pacman(即“吃豆先生”,譯者注)街機的硬件,並能運行Pacman的ROM,就可以稱之為模擬器。而為你機器所編寫的Pacman游戲,只是使用了和真實街機相同的圖像,就稱之為simulator。
對專有硬件的模擬是否合法?
盡管這個問題處於一種“灰色”地帶,似乎只要模擬器包含的信息沒有非法手段得到,對專有硬件的模擬應該是合法的。但要注意的是,如果發布受版權保護的系統ROM(如BIOS等)就是非法行為。
“解釋”模擬器的概念,與“編譯型”模擬器的區別。
模擬器可以采用三種基本方案,加以組合可以取得更好的效果。
· 解釋型
模擬器將所模擬的代碼按字節從存儲器中讀入,譯碼並執行對所模擬的寄存器、存儲器和I/O的操作。這種模擬器的大致算法如下:
while(CPUIsRunning)
{
Fetch OpCode
Interpret OpCode
}
這種模擬器的優點在於:便於調試、移植和同步(可以方便地計算出時鍾周期,並將模擬操作綁定到時鍾周期上)。
而明顯的缺點就是性能低,解釋過程占用了大量的CPU時間,要獲得比較良好的速度就需要在相對較快的機器上運行。
· 靜態重編譯
這種技術是將所模擬的程序翻譯為本機的匯編代碼,生成一個可執行文件,可以在本機運行而無需任何工具。雖然靜態重編譯看上去不錯,但並不總是可以實現的。例如,無法對自修改代碼進行靜態重編譯,因為除非去運行這些代碼,否則無法知道它們最終變成什么形態。為了避免這種情況,可以將靜態重編譯器與解釋器、動態重編譯器結合使用。
· 動態重編譯
動態重編譯與靜態重編譯原理上是相同的,但出現在程序的執行過程中。與一次重編譯所有代碼不同,在執行過程中遇到CALL或JUMP指令時才進行重編譯,可以與靜態重編譯結合來提高速度。你可以從
Ardi的白皮書
中獲得動態重編譯技術的更多內容,他是Macintosh重編譯型模擬器的作者。
編寫模擬器從何入手?
要編寫一個模擬器,必須掌握計算機編程和數字電路的一般知識,另外有匯編編程經驗會更加方便。
1 選擇一種編程語言。
2 查找所模擬硬件的所有信息。
3 編寫CPU模擬代碼或得到已有的CPU模擬代碼。
4 編寫所模擬硬件其它部分的設計代碼,至少完成一部分。
5 編寫一個內置調試器非常有用,可以中止模擬並觀察程序執行的狀態。另外需要一個所模擬系統的反匯編器,如果沒有可以自己寫一個。
6 在模擬器上運行運行程序。
7 用反匯編器和調試器來觀察程序使用硬件的情況,適時地調整自己的代碼。
使用何種編程語言?
使用最多的兩種就是C和匯編,下面是它們各自的優缺點:
· 匯編語言
+ 一般可以生成高速代碼。
+ 可以將所模擬CPU的寄存器直接存放到宿主機CPU的寄存器上。
+ 模擬指令的大部分操作碼與宿主機操作碼相似。
- 代碼不能移植,無法運行在不同架構的機器上。
- 調試和維護代碼比較困難
· C
+ 代碼可以移植到不同的機器和操作系統中。
+ 調試和維護代碼相對容易。
+ 對真實硬件工作的種種假設可以快速地進行測試。
- C生成的代碼通常比匯編代碼慢。
掌握使用的編程語言對編寫一個模擬器是相當重要的,工程越復雜,就要使代碼更加優化,從而獲得更快的速度。計算機模擬器絕不是用來學習編程語言的工程例子。
如何獲取所模擬硬件的信息
下面是可能需要查找資料的地方:
新聞組
· comp.emulators.misc
這是一個討論計算機模擬的新聞組,許多模擬器作者都會閱讀,雖然里面也有一定程度的水分。在發言之前可以先閱讀里面的FAQ。
· comp.emulators.game-consoles
和comp.emulators.misc相似,只不過主要討論電子游戲機模擬器。同樣在發言之前閱讀FAQ。
· comp.sys./emulated-system/
comp.sys.*的下級欄目包含各種不同的計算機,從中可以獲得大量有用的技術信息。可以按如下格式輸入:
comp.sys.msx MSX/MSX2/MSX2+/TurboR computers
comp.sys.sinclair Sinclair ZX80/ZX81/ZXSpectrum/QL
comp.sys.apple2 Apple ][
etc.
發言之前請閱讀相關的FAQ。
· alt.folklore.computers
· rec.games.video.classic
FTP
Console and Game Programming
site in Oulu, Finland
Arcade Videogame Hardware archive at ftp.spies.com
Computer History and Emulation archive at KOMKON
Arcade Videogame Hardware archive at ftp.spies.com
Computer History and Emulation archive at KOMKON
WWW
如何模擬CPU?
對於那些想編寫自己的CPU模擬器內核或是對模擬器工作原理感興趣的人,我提供了一個C編寫的典型CPU模擬器框架。在實際應用中,你可能需要增減部分內容。
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;
周期計數器用來存放離下一次可能中斷發生所剩的CPU時鍾周期,注意的是當它越界時,中斷不一定必然發生。周期計數器有多種用途,比如用作同步時鍾,或是更新屏幕的掃描線。另外,PC存放模擬器下一次讀取的操作碼在存儲器的地址。
賦完初值后,開始主循環:
for(;;)
{
循環也可能這樣實現:
while(CPUIsRunning)
{
CPUIsRunning是布爾變量,這樣有一個好處,就是可以通過設置CPUIsRunning=0隨時退出循環。但是,每一輪循環時對這個變量的檢查也會消耗大量CPU時間,所以還是盡可能避免采用這種方法。當然,不要這樣來實現循環:
while(1)
{
因為這樣的話,某些編譯器會生成判斷“1”是“真”還是“假”的代碼,你當然不願意編譯器在每一輪循環中做這些無用功。
現在,在循環中首先要讀取下一條指令的操作碼,並修改程序計數器PC:
OpCode=Memory[PC++];
雖然這是讀取所模擬存儲器最簡單的方法,但並不總是可行的,文章之后會再增加更多的通用方法。
讀取操作碼之后,周期計數器要減去執行該指令所需的時鍾周期數:
Counter-=Cycles[OpCode];
數組Cycles包含執行每條指令所需的CPU周期數。要注意某些指令(如條件轉移或子程序調用)根據操作數的不同,所需的周期數也不同,可以在以后的代碼中進行調整。
現在要做的是對指令譯碼並執行:
switch(OpCode)
{
Switch結構通常被誤認為缺乏效率,因為會被編譯一系列的if()…else if()…語句。這種情況在少量的case分支時確實存在,但是大量的分支結構(100-200甚至更多)會被編譯生成跳轉表,這樣是非常高效的。
譯碼有另外兩種方法:一種是生成一個函數表,並調用對應的函數,由於這種方法采用間接調用函數,效率比使用Switch要低;另一種方法是建立一個標號表,使用goto語句來實現,這種方法比用Switch要快一些,但它只適用於支持“precomputed labels”的編譯器,其它編譯器則不允許創建地址標號表。
成功譯碼並執行之后,要檢查是否有中斷發生,同時也執行一些需要系統時鍾同步的任務。
if(Counter<=0)
{
/* Check for interrupts and do other hardware emulation here */
...
Counter+=InterruptPeriod;
if(ExitRequired) break;
}
有關周期性任務的內容在文章后面會有介紹。
要注意這里並不是簡單地采用Counter=InterruptPeriod來賦值,而是采用Counter+=InterruptPeriod:這樣做可以使周期計數更加精確,因為周期計數器有可能會出現負值的情況。
再看這一行:
if(ExitRequired) break;
由於每輪循環都檢查是否退出開銷太大,所以只在周期計數器越界時檢查:這樣當ExitRequired=1時模擬始終會退出,而且不會消耗太多CPU時間。
如何訪問所模擬的存儲器?
要訪問所模擬的存儲器,最簡單的辦法就是將其視為一個字節數組,訪問的方法很簡單:
Data=Memory[Address1]; /* Read from Address1 */
Memory[Address2]=Data; /* Write to Address2 */
但是這種簡單的方法在下面幾種情況並不總是適用的:
· 頁面存儲器
地址空間被分成若干個可切換的頁面(或者塊),當地址空間比較小(64KB),這種存儲方法用來擴展存儲空間。
· 鏡像存儲器
同一塊存儲空間可以由若干個不同地址來訪問。如在地址$4000寫入的數據可能會同時出現在地址$6000和$8000中。ROM也可以利用不完全譯碼映射到鏡像存儲空間中。
· ROM保護
某些存儲在卡帶上的軟件(如MSX游戲)會試圖向自身的ROM寫入數據,如果寫入成功機器就無法工作,這就經常需要進行復寫保護。為了在模擬器上模擬這些軟件,需要禁止向ROM中寫入數據。
· 存儲空間映射I/O
系統中會有一些I/O設備映射到存儲空間,對這些存儲空間的訪問會產生“特殊效應”,所以需要進行跟蹤。
為了應付這些問題,我們引入兩個函數:
Data=ReadMemory(Address1); /* Read from Address1 */
WriteMemory(Address2,Data); /* Write to 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,那就試着將函數改為靜態函數:一些編譯器(如WatcomC)會采用內聯的方式對較短的靜態函數進行優化。
另外要注意大多數情況下,ReadMemory()被調用的次數會比WriteMemory()多。所以讓ReadMomery()盡可能地簡短,在WriteMemory()中實現大多數代碼。
· 關於存儲鏡像要注意的一個小問題:
正如之前所講的,很多計算機都有鏡像RAM,在某處寫入的值會出現在其它位置。雖然這種情況可以在ReadMemory()中處理,但並不可取,因為ReadMemory()被調用的次數比WriteMemory()要多得多,在WriteMemory()中實現存儲鏡像要更有效率。
輪轉任務的概念
輪轉任務是指在所模擬的機器中周期性出現的事件,諸如:
· 屏幕刷新
· 垂直空白(VBlank)和水平空白(HBlank)中斷
· 更新定時器
· 更新聲音參數
· 更新鍵盤/游戲桿狀態
· 其它
為了模擬這些任務,要將其綁定適當的CPU周期數。例如,假設CPU以2.5MHz運行,顯示采用50Hz的刷新頻率(PAL視頻標准),則每隔
2500000/50 = 50000 CPU cycles
出現一次垂直空白中斷。
現在我們假設整個屏幕(包括垂直空白)有256條掃描線,其中212條會顯示在屏幕上(另外44條成為垂直空白),這樣每隔
50000/256 ~= 195 CPU cyles
必須刷新一條掃描線。
在這之后,必須產生一個垂直空白中斷,並且在
(256-212)*50000/256 = 44*50000/256 ~= 8594 CPU cycles
內(垂直空白期)不做任何工作。
對每個任務所需的CPU周期數進行仔細的計算,然后取它們的最大公約數作為中斷周期,並綁定到所有任務中(周期計數器越界時並不一定執行這些任務)。
如何優化C代碼?
首先,在編譯器中選擇正確的優化選項可以讓代碼提高更多額外的性能。根據我的經驗,下列幾種選項的組合可以獲得最快的執行速度:
Watcom C++ -oneatx -zp4 -5r -fp3
GNU C++ -O3 -fomit-frame-pointer
Borland C++
如果你發現上述這些編譯器或其它編譯器有更好的選項設置,請讓我知道。
· 循環展開要注意的小事項:
在優化時選擇“循環展開”選項有時很管用,這個選項會試着將比較短的循環展開為順序代碼。但根據我的經驗,這個選項並不會提高多少性能,選擇該選項反而會在某些十分特殊的情況下破壞你的代碼。
優化C代碼本身要比選擇編譯器選項稍微復雜,而且通常依賴於編譯代碼所用的CPU。下面幾條准則一般適用於所有CPU,但是不要把它們當成絕對的真理,因為使用環境可能不同。
· 使用profiler工具!
在一款優秀的profiling工具下運行你的程序(我立刻想到GPROF),會出現很多讓你意想不到的有意思的東西。你可能會發現一些看似無關緊要的代**比其它代碼更加頻繁地被調用,從而導致程序整體運行速度變慢。要提高程序性能,可以對這部分代碼進行優化或者直接用匯編重寫。
· 避免C++
避免使用任何結構體,這樣你不得不用C++編譯器而不是C編譯器來編譯你的程序:C++編譯器會生成更多不必要的代碼。
· 整數長度
盡量使用CPU所支持的基准長度的整數類型,即用int來代替short或者long。這樣編譯器在生成最終代碼時可以減少那部分用來轉換不同長度數據類型的代碼,也可以減少存儲器訪問的時間,因為某些CPU在讀寫那些長度可以與地址邊界對齊的數據時速度最快。
· 寄存器分配
在每個程序塊中盡可能地少用變量,將最常用的變量聲明為寄存器(雖然大多數新編譯器會自動將變量放到寄存器中)。相對於那些只有少量專用寄存器的CPU(如Intel 80x86),這樣做對於那些有着大量通用寄存器的CPU(如PowerPC)是更有意義。
· 將小循環展開
如果你正好有一個只執行幾次的小循環,那就手動將它展開為一段線性代碼,這始終是一個好辦法。參見上面有關自動循環展開的注意點。
· 移位和乘除法的比較
當需要乘以(或除以)2^n時始終用移位來代替(如J/128==J>>7),對於大多數CPU來說執行的速度更快。同樣地,可以使用位運算AND來代替模運算(如J%128==J&0x7F)。
什么是大小端?
通常根據數據在存儲器中的如何存儲,可以將CPU分為若干類。除非是某些極特殊的情況,大多數CPU都可以歸為以下兩種:
· 大端 CPUu將一個字中的高字節存儲在存儲器的低地址。例如,CPU存儲0x12345678,存儲器如下:
0 1 2 3
+--+--+--+--+
|12|34|56|78|
+--+--+--+--+
· 小端 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等。
編寫模擬器時,要同時注意所模擬的CPU和宿主機CPU的大小端情況。比如說,想模擬一個小端CPU Z80,它將一個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、視頻控制器和聲音發生器等。這些芯片大都有自己的存儲器和連接的其它硬件。
一個典型的模擬器要重現原有系統的設計,就要在單獨的模塊中實現各個子系統的功能。首先,可以方便調試,因為可以將bug定位到各個模塊當中。其次,采用模塊化架構可以讓你在其它模擬器中重用某些模塊。計算機硬件是高度標准化的,你可以在許多不同的計算機模型中找到相同的CPU和視頻芯片。模擬出這個芯片一次,顯然比在每個使用同種芯片的計算機中重復實現它要容易很多。
©1997-2000 Copyright by
Marat Fayzullin
