「Vintage Computer Festival Midwest 11.0」 (由 Dave Ruske 製作), CC BY 2.0

[CHIP-8 小東西] 核心指令集虛擬化與程式載入器實作

虛擬硬體元件開發完成後,接下來就進入模擬器最核心的階段:實作指令集模擬器(Instruction Set Simulator)!相較於現代處理器,CHIP-8 的指令集規模非常小,因此非常適合作為理解處理器取指(Fetch)、解碼(Decode)與執行流程(Execute)的入門專案。本篇將先介紹 CHIP-8 指令與機器碼的基本概念,接著實作二進位程式載入機制與最基本的 fetch 流程,為後續真正執行 CHIP-8 遊戲程式奠定基礎。

指令集與位元組碼的基礎觀念

CHIP-8 虛擬機的核心工作,就是依序讀取並執行一組預先定義的指令。每一條指令都以固定的二進位格式存在記憶體中,虛擬處理器讀取這些資料後,再根據其內容執行對應的操作。

機器碼與二進位資料

CHIP-8 程式本質上是一連串二進位資料,而我們平常看到的十六進位表示法,只是為了方便閱讀這些二進位數值。

例如,CHIP-8 的清除螢幕指令為:

00E0

換成二進位則是:

00000000 11100000

兩者代表的是完全相同的 16-bit 數值,只是十六進位表示法更適合人類閱讀。

因此,不能直接使用一般文字編輯器輸入 00E0 再將檔案當作 CHIP-8 程式執行。因為文字編輯器儲存的是字元:

'0' '0' 'E' '0'

這些字元會以 ASCII 等文字編碼儲存,實際上形成 4 bytes 的資料,而 CHIP-8 所需要的是:

0x00 0xE0

也就是實際直譯器能識別的 2-byte 二進位指令。

若想更直觀地理解文字資料與二進位資料的差異,可以使用 Hex Editor Neo 直接查看檔案內容。

CHIP-8 程式會以二進位形式儲存,每一條指令由一個 16-bit 的操作碼(opcode)表示。這些連續排列的指令資料,有時也會被統稱為 bytecode,也就是提供給 CHIP-8 虛擬機讀取並執行的位元組序列。

核心執行函式

接下來在 chip8.c 中建立 chip8_exec 函式,作為後續所有 CHIP-8 指令的執行入口:

void chip8_exec(struct chip8* chip8, unsigned short opcode) 
{

}

這個函式接收兩項資料:

  1. CHIP-8 本身的實例指標。
  2. 一條 16-bit 的 opcode。

CHIP-8 每一條指令固定占用 2 bytes,也就是 16 bits。這 16 bits 又可以拆成四個 nibble,而每個 nibble 為 4 bits。

例如:

1ABC

1    A    B    C
---- ---- ---- ----
4bit 4bit 4bit 4bit

之後進行指令解碼時,就會大量利用這四個 nibble 來判斷指令類型與取得參數。

程式載入機制與記憶體安全防護

在真正執行 CHIP-8 指令之前,我們必須先把遊戲 ROM 載入虛擬記憶體。

預設載入位址

根據 CHIP-8 規格書,多數的 CHIP-8 程式皆是從記憶體位址 0x200 開始載入,而 0x0000x1FF 的空間則保留給直譯器與相關資料使用。

因此,可以先在 config.h 中定義程式的預設載入位置:

#define CHIP8_PROGRAM_LOAD_ADDRESS 0x200

之後所有 CHIP-8 程式都會從這個位址開始放入虛擬記憶體。

實作載入函式(chip8_load

接著建立 chip8_load,負責將外部讀入的程式資料複製到 CHIP-8 記憶體:

void chip8_load(struct chip8* chip8, const char* buf, size_t size)
{
  // 待會實做
}

透過斷言預防緩衝區溢位

在複製資料之前,首先必須確認程式大小不會超出 CHIP-8 的可用記憶體範圍:

assert(size + CHIP8_PROGRAM_LOAD_ADDRESS <= CHIP8_MEMORY_SIZE);

這項檢查確保:

程式起始位址 + 程式大小 <= CHIP-8 記憶體大小

如果載入的 ROM 太大,程式便會立即觸發 assertion,而不是繼續寫入超出陣列範圍的記憶體。

記憶體複製與 PC 初始化

當通過檢查後,函式會執行兩項關鍵動作:資料複製以及設定程式計數器。

第一,使用 memcpy 將程式資料複製到 CHIP-8 記憶體:

memcpy(&chip8->memory.memory[CHIP8_PROGRAM_LOAD_ADDRESS], buf, size);

目的位址從:

&chip8->memory.memory[CHIP8_PROGRAM_LOAD_ADDRESS]

開始,也就是記憶體中的 0x200

第二,將程式計數器(Program Counter,PC)設定為相同位置:

chip8->registers.PC = CHIP8_PROGRAM_LOAD_ADDRESS;

如此一來,虛擬處理器啟動後,第一條指令就會從 0x200 開始讀取。

完整函式如下:

void chip8_load(struct chip8* chip8, const char* buf, size_t size)
{
    assert(size + CHIP8_PROGRAM_LOAD_ADDRESS <= CHIP8_MEMORY_SIZE);
    memcpy(&chip8->memory.memory[CHIP8_PROGRAM_LOAD_ADDRESS], buf, size);
    chip8->registers.PC = CHIP8_PROGRAM_LOAD_ADDRESS;
}

在還沒有加入真正的 ROM 檔案之前,可以先在 main loop 中簡單測試:

chip8_load(&chip8, "Hello, World!", sizeof("Hello, World!"));

這裡的目的不是執行 "Hello, World!",而只是暫時確認 chip8_load 能順利將資料寫入記憶體,並且不會造成程式 crash。

從檔案載入 CHIP-8 程式

確認記憶體載入流程正常後,下一步就是從外部檔案讀取真正的 CHIP-8 ROM。

命令列參數檢查

main.c 中,我們使用 argcargv 接收要執行的 CHIP-8 程式路徑。

如果使用者沒有提供檔案名稱,也就是 argc < 2,便直接顯示錯誤訊息並結束程式:

if (argc < 2)
{
    printf("You must provide a CHIP-8 file to run.\n");
    return -1;
}

使用二進位唯讀模式

取得檔案名稱後,就可以透過 fopen 開啟 ROM:

const char* filename = argv[1];
printf("Loading CHIP-8 file: %s\n", filename);

FILE* file = fopen(filename, "rb");
if (!file)
{
    printf("Failed to open the file: %s\n", filename);
    return -1;
}

這裡特別使用 rb,其中:

r = read
b = binary

代表以二進位唯讀模式開啟檔案。

對 CHIP-8 ROM 這類原始二進位資料而言,應避免使用單純的文字模式 "r",尤其在 Windows 環境下,文字模式可能對部分位元組進行換行相關的轉換,導致原始資料發生變化。

取得檔案大小

接下來先將檔案位置移動到尾端:

fseek(file, 0, SEEK_END);

再使用:

long size = ftell(file);

取得目前位置,也就是檔案大小。

取得大小後,再將讀取位置移回檔案開頭:

fseek(file, 0, SEEK_SET);

完整流程為:

fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);

配置緩衝區並讀取檔案

知道檔案大小之後,就可以使用 malloc 動態配置足夠的記憶體:

char* buf = malloc(size);

接著利用 fread 將整個 ROM 讀入:

size_t res = fread(buf, 1, size, file);

這裡的意思是:

每個元素大小 = 1 byte
最多讀取 = size 個元素

因此 fread 的回傳值代表實際成功讀取的 byte 數。

完成讀取後應確認:

if (res != size)
{
    printf("Failed to read the file: %s\n", filename);
    free(buf);
    fclose(file);
    return -1;
}

接著關閉檔案:

fclose(file);

在 CHIP-8 初始化完成後,再將讀取到的資料交給 chip8_load

chip8_load(&chip8, buf, size);
free(buf);

由於 chip8_load 已經利用 memcpy 將內容複製進 CHIP-8 的虛擬記憶體,所以外部的 buf 在這之後便可以安全釋放。

在正式使用 ROM 前,可以先建立一個暫時的 file.txt,確認整個檔案讀取與記憶體載入流程不會發生重大錯誤:

./bin/main.exe ./bin/file.txt

這個階段我們只是在驗證載入流程,因此檔案內容是否為合法 CHIP-8 指令還不是重點。

取指(Fetch)與主迴圈執行流程

程式成功載入記憶體後,接下來就可以開始建立處理器最核心的 fetch 流程。

合併雙位元組(get_short)

CHIP-8 的每一條指令為 2 bytes,但記憶體陣列一次只能讀取一個 byte。

因此需要從目前的 PC 讀取兩個連續的位元組 PCPC + 1,再將它們合併成一個 16-bit opcode。

unsigned short chip8_memory_get_short(struct chip_memory* memory, int index)
{
    unsigned char byte1 = chip8_memory_get(memory, index);
    unsigned char byte2 = chip8_memory_get(memory, index + 1);

    return (byte1 << 8) | byte2;
}

假設記憶體中包含:

memory[0x200] = 0x12
memory[0x201] = 0x34

其中 byte1 << 8 會將第一個 byte 移到高 8 bits 0x12 -> 0x1200,接著與第二個 byte 執行 OR:

   0x1200
OR 0x0034
---------
   0x1234

最後便得到完整的 16-bit opcode:

0x1234

主迴圈運作步驟

有了 chip8_memory_get_short 之後,就可以在主迴圈中建立最基本的指令執行流程:

  1. Fetch:根據目前的 PC 從記憶體讀取一條 16-bit opcode。
  2. Execute:將 opcode 傳入 chip8_exec 進行解碼與模擬執行。
  3. Advance PC:暫時將 PC 加 2,前往下一條指令。

目前可以先寫成:

unsigned short opcode = chip8_memory_get_short(&chip8.memory, chip8.registers.PC);
chip8_exec(&chip8, opcode);
chip8.registers.PC += 2;

因為 CHIP-8 每條指令固定占用 2 bytes,所以在沒有任何流程控制指令的情況下:

0x200
-> 0x202
-> 0x204
-> 0x206
-> ...

PC 會依序向後移動。

不過,這裡的 PC += 2 只是目前用來建立基本 fetch 流程的暫時實作。

後續加入 Jump、Call、Return 等流程控制指令後,PC 並不一定永遠指向下一個連續位址,因此 PC 的更新方式也需要配合指令執行邏輯重新整理,不能無條件在每次 chip8_exec 後加 2。

使用 CHIP-8 ROM 進行整合測試

基本的程式載入與 fetch 流程完成後,就可以開始使用真正的 CHIP-8 ROM 進行測試囉!

例如,可以從 Zophar’s Domain CHIP-8 Games Pack 取得測試用遊戲,並將其中一個 ROM 放到 bin 目錄,這裡以 Space Invaders 為例子:

./bin/main.exe ./bin/INVADERS

此時模擬器會:

讀取 ROM
-> 載入至 0x200
-> 將 PC 設為 0x200
-> Fetch opcode
-> 呼叫 chip8_exec
-> 移動 PC
-> Fetch 下一條 opcode

如果同時輸出 PC 與 opcode,就可以觀察 ROM 中的指令資料逐條被讀取。

printf("PC: %04x, Opcode: %04x\n", chip8.registers.PC, opcode);

不過,目前 chip8_exec 尚未真正實作 Jump、Call、Return 或其他 subroutine(副程式)呼叫等能控制 PC 走向的指令等會改變控制流程的指令,因此 PC 只會持續向後增加。

當 PC 最終超出合法的記憶體範圍時,先前在記憶體模組中加入的安全檢查便可能觸發 assertion,使程式停止執行。

在目前這個開發階段,這種停止是可以預期的結果。它至少證明:

  • ROM 已成功載入
  • PC 能從 0x200 開始 fetch
  • 兩個 bytes 能正確組成 opcode
  • 主迴圈能持續進行 fetch
  • 記憶體邊界檢查能阻止非法存取

接下來真正重要的工作,就是開始實作 chip8_exec 中的 opcode decoding 與各項 CHIP-8 指令,讓 PC 不再只是一路向後增加,而是能依照遊戲程式的實際控制流程執行。


References

讓我知道你在想什麼!