虛擬硬體元件開發完成後,接下來就進入模擬器最核心的階段:實作指令集模擬器(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)
{
}
這個函式接收兩項資料:
- CHIP-8 本身的實例指標。
- 一條 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 開始載入,而 0x000 到 0x1FF 的空間則保留給直譯器與相關資料使用。
因此,可以先在 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 中,我們使用 argc 與 argv 接收要執行的 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 讀取兩個連續的位元組 PC 和 PC + 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 之後,就可以在主迴圈中建立最基本的指令執行流程:
- Fetch:根據目前的 PC 從記憶體讀取一條 16-bit opcode。
- Execute:將 opcode 傳入
chip8_exec進行解碼與模擬執行。 - 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 不再只是一路向後增加,而是能依照遊戲程式的實際控制流程執行。




