如何設計一個電腦的模擬器

by Marat Fayzullin

未經授權請勿散播此文件. 請鏈結此網頁,禁止拷貝

在我收到許多電子郵件,他們詢問如何設計電腦上的模擬器, 從何處開始? 於是我寫下這篇文件.以下的建議與忠告是我自己的想法,不保證絕對正確. 這份文件只要涵蓋了所謂的"解譯式"模擬器,而非"編譯式",因為我並沒有很多的經驗在"重新編譯"的技術. 有一兩個地方你可以找到有關這類技術的資訊.

如果你認為這份文件遺漏了什麼或者是需要修正的地方,請 E-mail 告知我你的建議. 雖然我不會回答狂熱份子與白癡對於ROM方面的請求. 很不幸的我遺失了一些重要的 FTP/WWW的網站位址在這份文件的最後,所以如果你知道有價值的相關資料的話,請告訴我.其他像 FAQ 的常見問題集也不包含在文件中.

這份文件的英文原版在此, 日文版 由 Bero翻譯, 中文版(BIG-5)由麥克熊翻譯


內容

所以啦,你決定要寫一個軟體模擬器嗎?很好,這份文件對你可能有幫助. 它包含了一些常用的技術問題關於模擬器的實作.它也提供模擬器內部的藍圖,你可以一步一步的學習.

大綱

實作

程式設計的技術

  • 還有其他的以後再加

    什麼可以被模擬?

    基本上,任何有微處理器的都可以.當然,只有具備有較佳彈性或較差彈性的程式設備模擬起來才較為有趣.它們包含了:

    必須注意的是你可以模擬任何電腦系統,甚至是非常複雜的(例如 Commodore Amiga電腦). 但是模擬的效率會非常的慢.


    什麼是"模擬器",它和"模仿"有何不同?

    模擬是企圖去仿製設備的內部設計.模仿是仿製設備的功能. 例如,一個程式模擬小精靈的硬體並且直接用小精靈的ROM去跑,這就是模擬器. 而一個為你的電腦設計的小精靈遊戲,外表看起來像是小精靈遊戲,這就是模仿.


    模擬有版權的硬體是否合法?

    雖然這是在"灰色"地帶的東西,表示模擬有版權的硬體是合法的,只要還沒有任何資訊說它是不合法的.你應該小心散播有版權的系統ROM(唯讀記憶體或是BIOS等等)在一個模擬器中是不合法的.


    什麼是"解譯式模擬器",它和"編譯式模擬器"有何不同?

    一個模擬器有三個基本的方案可以使用.並可以為了最佳效果來合併使用.


    我想寫一個模擬器該從哪裡開始呢?

    為了寫一個模擬器,你必須有良好的基本知識有關電腦程式設計與數位電子學.有組合語言的程式設計經驗將會很快上手.

    1. 選擇要使用的程式語言.
    2. 找出所有有關模擬器硬體的有用資料.
    3. 寫CPU模擬器或者拿到現存的CPU模擬器程式碼.
    4. 寫一些概略的程式碼去模擬硬體,至少是一部份.
    5. 重點是,寫一個內建的除錯器是很有幫助的, 它可以中止模擬器並且看看程式執行到哪裡. 你可能也需要模擬系統組合語言的解碼器.
    6. 試著用你的模擬器去執行程式.
    7. 使用解碼器和除錯器去觀察程式如何使用硬體並且適當地調整你的程式碼.

    我該使用何種程式語言?

    最常見的就是 C 和組合語言. 這裡列出兩種語言的優缺點:

    對於寫一個可用的模擬器而言, 一定得對你選用的程式語言有充分的了解, 這是相當複雜的計劃, 你的程式碼應該被最佳化, 執行起來才會更快更有效率. 電腦模擬器很明顯的並不是你學習一個程式語言的計劃之一.


    我在哪裡可以找到模擬硬體的資訊呢?

    你可以參考下列的位址:

    新聞群組

    FTP

    [#] Console and Game Programming site in Oulu, Finland
    [#] Arcade Videogame Hardware archive at ftp.spies.com
    [#]Computer History and Emulation archive at KOMKON

    WWW

    [#] comp.emulators.misc FAQ
    [#] My Homepage
    [#] Arcade Emulation Programming Repository
    [#] Emulation Programmer's Resource


    如何模擬一個CPU?

    首先, 如果你只需要模擬一個標準的 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() 函式盡量的愈短愈簡潔, 這樣是值得的.


    週期性的工作:是什麼東東?

    週期性的工作應該是會定期發生在模擬器的機器上的, 如下:

    為了模擬這樣的工作, 你應該將它們聯繫在適當的 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, 並所有其他的工作聯繫於此的(每次計數器的終結時則不必執行).


    如何將 C 程式碼最佳化?

    首先, 許多額外有效率編碼可以藉由正確的選擇編譯器最佳化的選項來達成. 在我個人的經驗上, 以下列旗標的組合會給你最佳的執行速度:

    Watcom C++      -oneatx -zp4 -5r -fp3
    GNU C++         -O3 -fomit-frame-pointer
    Borland C++
    

    如果你發現有這些編譯器更好的選項設定或是不同的編譯器, 請告訴我.

    將 C 程式碼本身最佳化相對於選用編譯器的選項而言, 是一些小的技巧, 一般而言通常還要依不同的 CPU 去做最佳化. 幾個一般的法則適用於所有類型的 CPU. 雖然沒有絕對正確的, 當你所需要的利益是多變的:


    什麼是低位元/高位元結尾?

    所有的 CPU 一般分為幾個等級, 基於它們如何將資料存放於記憶體中. 雖然會有一些少見的樣本, 而大多的 CPU 可以分為這兩類:

    典型的高位元結尾 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, 顯示控制器, 聲音產生器, 等等. 某些晶片有它們自己的記憶體, 其他的硬體就跟它們相連接.

    一個典型的模擬器應該要重複原本的系統設計, 藉著實作每一個子系統的功能函式在個別的模組中. 首先, 這會讓除錯容易些, 所有的臭蟲就會在局部的模組中. 另外, 模組化的架構允許你重複使用模組程式在別的模擬器中. 電腦硬體是相當標準化的: 你可以預期會在許多不同的電腦基版中找到相同的中央處理器或是顯示晶片. 這樣就較容易些, 只要模擬過這樣的晶片一次就好了, 而不需再使用這個晶片的每種電腦上一次又一次的實作撰寫了.


    &copy1997-1999 Copyright by Marat Fayzullin [fms@cs.umd.edu]

  • 回到首頁 last updated Feb 26,1999

    1