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

[CHIP-8 小東西] 實作虛擬硬體(記憶體與暫存器)

本文將介紹如何根據 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_setchip8_memory_get

寫入函式 chip8_memory_set 需要接收三個參數:

  1. 記憶體指標:指定要操作的記憶體結構體。
  2. 索引值 index:指定要讀取或寫入的記憶體位址。
  3. 數值 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 的記憶體位址範圍為 0x0000xFFF,換算成陣列索引,就是 04095

如果程式嘗試讀取或寫入這個範圍以外的索引,就會造成陣列越界。陣列越界在 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);
}

集中管理的安全優勢

我們可以在這兩個讀寫函式內部統一呼叫邊界檢查。一旦發生越界存取的狀況,斷言機制會中斷程式,並在終端機上明確顯示錯誤發生的程式碼行號。為什麼我們要透過 setget 函式來封裝記憶體操作,而不是直接存取陣列的原因,算是基本概念之後就不再多提。

這還蠻重要的,可以讓讓開發者在除錯時,能夠精確地設置 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 型態,我們宣告一個名為 Vunsigned 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_timerunsigned 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_tuint16_t,比 unsigned charunsigned short 更能明確表達位元寬度。像是上面將 SP 描述為 8-bit,程式碼目前宣告 unsigned short


References

讓我知道你在想什麼!