本文將介紹如何根據 CHIP-8 硬體規格,在模擬器中實作記憶體(Memory)以及暫存器(Registers)結構。
記憶體實作
最開始我們要為 CHIP-8 模擬器實作 4096 Bytes 的記憶體,參考先前規格介紹文章中的 Memory 段落。
要在模擬器中建立這塊記憶體非常簡單,本質上就是宣告一個陣列去表示這塊記憶體結構、撰寫負責讀取與寫入的輔助函式,並加入安全檢查機制以確保程式的穩定運行。
記憶體規格與結構宣告
實作模擬器記憶體最直接的方式就是建立一個對應大小的陣列。
首先,我們建立一個專屬的標頭檔(例如 chip8_memory.h),在其中定義代表記憶體的結構體。我們會引入之前已經建立的專案設定檔,使用 unsigned char 型態來宣告這個大小為 4096 的陣列,以此作為 CHIP-8 的記憶體空間。
struct chip_memory
{
unsigned char memory[CHIP8_MEMORY_SIZE];
};
記憶體讀寫函式
雖然可以直接存取陣列,但為了讓程式碼更安全且易於維護,通常會建立專屬的存取函式:chip8_memory_set 與 chip8_memory_get。
寫入函式 chip8_memory_set 需要接收三個參數:
- 記憶體指標:指定要操作的記憶體結構體。
- 索引值 index:指定要讀取或寫入的記憶體位址。
- 數值 value:寫入時所需賦予的資料。
讀取函式 chip8_memory_get 則只需要接收記憶體指標與索引值,並回傳對應位址中的資料。
在對應的檔案 chip8_memory.c 中,這兩個函式會透過傳入的指標,實際去修改或回傳陣列中對應索引的內容。
void chip8_memory_set(struct chip_memory* memory, int index, unsigned char value)
{
chip8_is_memory_index_valid(index);
memory->memory[index] = value;
}
unsigned char chip8_memory_get(struct chip_memory* memory, int index)
{
chip8_is_memory_index_valid(index);
return memory->memory[index];
}
這兩個函式本身沒有太複雜的邏輯,主要目的就是將記憶體存取集中管理,避免專案中的其他程式碼任意操作底層陣列,跟一般我們在寫應用端的概念是一樣的。
邊界檢查
CHIP-8 的記憶體位址範圍為 0x000 到 0xFFF,換算成陣列索引,就是 0 到 4095。
如果程式嘗試讀取或寫入這個範圍以外的索引,就會造成陣列越界。陣列越界在 C 語言中屬於未定義行為,可能導致資料損毀、讀取到錯誤內容,甚至讓程式直接終止。為了解決這個潛在問題,我們在實作中加入了一個靜態函式 chip8_is_memory_index_valid 來進行邊界檢查。
斷言 Assertion 的應用
透過引入 <assert.h> 函式庫,我們可以斷言傳入的索引值必須符合以下兩個條件:
- 索引值必須大於或等於零。
- 索引值必須小於設定檔中定義的記憶體最大容量
CHIP8_MEMORY_SIZE(4096)。
#include <assert.h>
static void chip8_is_memory_index_valid(int index)
{
assert(index >= 0 && index < CHIP8_MEMORY_SIZE);
}
集中管理的安全優勢
我們可以在這兩個讀寫函式內部統一呼叫邊界檢查。一旦發生越界存取的狀況,斷言機制會中斷程式,並在終端機上明確顯示錯誤發生的程式碼行號。為什麼我們要透過 set 與 get 函式來封裝記憶體操作,而不是直接存取陣列的原因,算是基本概念之後就不再多提。
這還蠻重要的,可以讓讓開發者在除錯時,能夠精確地設置 breakpoint 並快速定位問題來源,雖然現在大多是 AI 寫程式,但還是要提一下在 AI 之前我們是這樣去逐行除錯的XD
將記憶體加入 CHIP-8
最後,記得將剛才建立的記憶體結構加入主要的 CHIP-8 狀態結構中:
#include "chip8_memory.h" // 引用剛剛建立好的 header file
struct chip8
{
struct chip_memory memory; // 加上這行
};
這樣每一個 struct chip8 物件都會包含一份獨立的 CHIP-8 記憶體。
暫存器實做
再來我們要繼續實做暫存器(Registers)結構,可參考先前規格介紹文章中的 Registers 段落。
內容包含宣告 16 個通用暫存器、位址暫存器(I)、特殊計時器,以及程式計數器(PC)與堆疊指標(SP)。此外,暫存器不需如同記憶體般實作專屬的讀寫函式與邊界檢查,藉此達到更簡潔的架構設計。
暫存器規格與結構宣告
首先,我們需要在 include 資料夾中建立一個名為 chip8_registers.h 的標頭檔。接著,在此檔案內宣告一個名為 chip8_registers 的結構體:
#include "config.h"
struct chip8_registers
{
// 後續會漸漸依規格新增
};
隨後將其引入到主程式標頭檔(chip8.h)的核心結構中。
#include "chip8_registers.h" // 引用剛剛建立好的 header file
struct chip8
{
...
struct chip8_registers registers; // 加上這行
};
根據規格,CHIP-8 具備多種不同用途的暫存器,我們將依序在結構體中將它們宣告出來:
16 個通用暫存器(V0~VF)
CHIP-8 擁有 16 個 8-bit 的通用暫存器,通常被稱為 Vx(x 代表 0 到 F,亦即 16 進位的索引值)。
因為 1 個 byte 在 C 語言中剛好對應 char 型態,我們宣告一個名為 V 的 unsigned char 陣列來代表這組暫存器。
unsigned char V[CHIP8_TOTAL_DATA_REGISTERS];
這裡可以留意一下,我們不在程式碼中寫死數字 16,而是到設定檔中定義一個常數巨集 CHIP8_TOTAL_DATA_REGISTERS 並將其值設為 16,接著用這個常數來設定陣列大小。
#define CHIP8_TOTAL_DATA_REGISTERS 16
另外雖然 spec 規定 VF 暫存器有其特殊用途且不應被一般程式隨意使用,但為了保持實作簡單且不影響模擬器運行,我們在此不加上額外的限制。
記憶體位址暫存器(I)
除了通用暫存器,系統中還有一個 16-bit 的暫存器「I」,它的主要功能是儲存記憶體位址。我們使用 unsigned short I 來宣告這個暫存器。
unsigned short I;
特殊計時器暫存器
CHIP-8 有兩個負責處理時間的 8-bit 特殊暫存器:延遲計時器(Delay Timer)與聲音計時器(Sound Timer)。我們命名為 unsigned char delay_timer 與 unsigned char sound_timer。
unsigned char delay_timer; unsigned char sound_timer;
偽暫存器(Pseudo-registers):PC 與 SP
最後,我們需要實作系統的偽暫存器:
- 程式計數器(Program Counter, PC):保存下一條需要讀取或執行的指令位址。由於 CHIP-8 的每條指令占用 2 bytes,因此一般情況下,執行完一條指令後 PC 會增加
2。 - 堆疊指標(Stack Pointer, SP):記錄目前的呼叫堆疊位置,供副程式呼叫與返回指令使用。
unsigned short PC; unsigned short SP;
直接存取設計與架構考量
在實作完成上述的結構體後,我們的暫存器模組就大功告成了!
與記憶體不同,目前沒有必要為每一個暫存器額外建立存取函式。我們可以直接透過結構體成員進行存取:
chip8.registers.V[0x0f] = 50;
主要是因為 CHIP-8 操作碼的空間限制,使得我們在存取如 V 暫存器的陣列時,完全沒有空間能傳入足以造成越界的錯誤數值。
主要是因為在目前的設計中,暫存器索引的來源相對單純,而且範圍受到指令格式限制,所以不需要像一般記憶體一樣,為每一次存取建立額外的封裝層。
這種直接存取方式可以讓後續的操作碼實作更清楚。例如執行將立即值寫入 Vx 的指令時,可以直接寫成:
chip8->registers.V[register_index] = value;
若後續發現需要集中處理除錯紀錄、存取追蹤或驗證,再另外加入輔助函式也不遲。目前先維持簡潔的資料結構,會更符合這個階段的實作需求。
後記
這裡紀錄一下之後我可能會修改的點:
使用 <stdint.h> 的 uint8_t 與 uint16_t,比 unsigned char、unsigned short 更能明確表達位元寬度。像是上面將 SP 描述為 8-bit,程式碼目前宣告 unsigned short。






