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

[CHIP-8 小東西] 指令集虛擬化與解碼機制

來到最後重頭戲啦!終於要進入 CHIP-8 虛擬機指令集(Instruction Set)的解碼與實作方式了。

CHIP-8 的每條指令固定由 2 bytes,也就是 16 bits 組成,並利用不同位置的位元表示操作類型、暫存器、記憶體位址與常數值。本篇將先說明 opcode 中常見的 nnnxykkn 等欄位,再利用 bit mask、位移運算與 switch-case 建立指令解碼流程,最後依序實作控制流程、條件判斷、暫存器運算、繪圖、鍵盤、計時器、與記憶體存取等 CHIP-8 核心指令。


從第一條指令開始!基本執行流程

一開始先從最簡單的指令著手。

00E000EE 都是固定的完整 opcode,因此目前還不需要處理額外的位元解析,可以直接在 chip8_exec 中使用 switch 判斷。先從這兩條指令開始,再逐步進入需要 bitwise decoding 的 opcode。

清除螢幕與返回副程式

00E0(CLS)代表清除整個顯示畫面。

可以先在螢幕模組中加入清除函式,利用 memset 將所有 pixel 重設為 0:

void chip8_screen_clear(struct chip8_screen* screen)
{
    memset(screen->pixels, 0, sizeof(screen->pixels));
}

接著在 chip8_exec 中處理 00E0

switch (opcode)
{
    case 0x00E0:
        chip8_screen_clear(&chip8->screen);
        break;
}

第一條指令完成後,可以暫時手動呼叫 chip8_exec 並傳入 0x00E0,確認原本畫面上的 sprite 是否被清除。

下一條 00EE(RET)則用來從 subroutine 返回。

執行副程式時,返回位址會事先保存於 stack;當遇到 00EE 時,只需要從 stack 取出該位址,再設定回 Program Counter(PC):

case 0x00EE:
    chip8->registers.PC = chip8_stack_pop(&chip8->stack);
    break;

由於 chip8_stack_pop 本身已經負責取得 stack 頂端的資料並調整 stack pointer,因此 instruction execution 不需要再次處理 stack 的內部邏輯。

完成這兩條固定 opcode 後,就會發現接下來的指令就開始出現不同的參數格式了XD 因此在此之前我們可以先停下來認識一下指令集基本規格。

Opcode 解碼:從固定指令到參數化指令

指令集基本規格與變數解譯

CHIP-8 的每條指令由 16-bit opcode 組成,可以拆成 4 個 nibble,每個 nibble 為 4 bits。例如:

8    A    B    4
---- ---- ---- ----
4bit 4bit 4bit 4bit

在 CHIP-8 指令規格中,通常會使用 nnnxykkn 表示 opcode 中不同位置的資料。後續進行指令解碼時,會大量使用這些欄位。

例如:

1nnn
2nnn
3xkk

這些並不是單一固定值。

1nnn 為例:

1123
1456
1ABC

三條 opcode 的後 12 bits 都不同,但它們其實都屬於同一條 1nnn 指令。

因此,從這裡開始就不能再直接使用完整 opcode 作為 switch 條件,而是需要透過 bit mask,只留下真正用來識別 instruction family 的部分。

可以另外建立一個處理這類 opcode 的函式:

static void chip8_exec_extended(struct chip8* chip8, unsigned short opcode)
{
  // 待會實做
}

接著先取得 opcode 最高的 4 bits:

opcode & 0xF000

例如:

0x1FF2 & 0xF000 = 0x1000

因此:

switch (opcode & 0xF000)
{
    case 0x1000:
        // 1nnn
        break;

    case 0x2000:
        // 2nnn
        break;

    case 0x3000:
        // 3xkk
        break;
}

這樣便能忽略 opcode 中作為參數的部分,只依照最高 nibble 判斷目前屬於哪一類指令。完成 00E000EE 後,才開始加入 bitwise operation 與額外的 execution function。

跳躍與子程序呼叫

1nnn(JP addr)代表跳躍到指定的記憶體位址。

其中的 nnn 是 opcode 最低的 12 bits,因此可以透過:

unsigned short nnn = opcode & 0x0FFF;

取得真正的位址。

例如 1FF2 可以拆成:

1    FF2
│     │
│     └-> nnn
└-------> JP

所以:

case 0x1000:
    chip8->registers.PC = nnn;
    break;

我們可以利用 0x1FF2 進行測試,確認執行後 PC 是否正確更新。

chip8_exec(&chip8, 0x1FF2);
printf("PC: %x\n", chip8.registers.PC);

應該要看到介面印出:

PC: ff2
...
...

也就是最低 12 bits。

2nnn(CALL addr)則是呼叫位於 nnn 的 subroutine。

與一般 jump 不同,CALL 必須先記住返回位置:

case 0x2000:
    chip8_stack_push(&chip8->stack, chip8->registers.PC);
    chip8->registers.PC = nnn;
    break;

此時也會發現 PC 更新時機的重要性。

如果 push 進 stack 的仍然是 CALL 自己的位址,那麼 RET 返回後又會再次執行 CALL,造成無限循環。

因此目前的執行順序應調整為:

Fetch Opcode
-> PC += 2
-> Execute Opcode

也就是先讓 PC 指向下一條 instruction,再執行目前的 opcode。

這樣 CALL 保存到 stack 的才會是正確的返回位置。

解析暫存器與常數

接著來到 3xkk(SE Vx, byte)。

這次 opcode 又出現兩個新的欄位:

3 x k k

其中:

x -> V Register 編號
kk -> 8-bit 常數

例如 3022 代表:如果 V0 == 0x22,則跳過下一條 instruction、
如果是 3326 則代表:如果 V3 == 0x26,則跳過下一條 instruction

要取得 x,需要將 opcode 向右移動 8 bits,再留下最低 4 bits:

unsigned char x = (opcode >> 8) & 0x000F;

kk 就是 opcode 最低的 8 bits:

unsigned char kk = opcode & 0x00FF;

因此 3xkk 可以寫成:

case 0x3000:
    if (chip8->registers.V[x] == kk)
    {
        chip8->registers.PC += 2;
    }
    break;

由於每條 CHIP-8 instruction 固定占用 2 bytes,因此「跳過下一條指令」就是讓 PC 再額外增加 2。

使用 3022 測試這段邏輯,分別確認條件成立與不成立時 PC 的變化:

chip8.registers.PC = 0x00;
chip8_exec(&chip8, 0x3022);
printf("%x\n", chip8.registers.PC);

應該會出現:

0

因為 0x3022 可以拆成:

3 0 22
│ │  │
│ │  └-> kk = 0x22
│ └----> x = 0,所以比較 V0
└------> 3xkk

意思就是:如果 V0 == 0x22,就 PC += 2,不成立所以 PC 還是 0。再來若正確設定:

chip8.registers.PC = 0x00;
chip8.registers.V[0x00] = 0x22;
chip8_exec(&chip8, 0x3022);
printf("%x\n", chip8.registers.PC);

應該會出現:

2

4xkk(SNE Vx, byte)則剛好相反:

case 0x4000:
    if (chip8->registers.V[x] != kk)
    {
        chip8->registers.PC += 2;
    }
    break;

接著的 5xy0(SE Vx, Vy)多出一個 y5 x y 0y 位於第三個 nibble,因此可以透過:

unsigned char y = (opcode >> 4) & 0x000F;

取得。

這裡也能更清楚看到十六進位與 nibble 的關係。一個十六進位數字剛好代表 4 bits,因此 CHIP-8 opcode 可以視為四個 nibble:

5    x    y    0
4bit 4bit 4bit 4bit

有了 xy 後,5xy0 就可以直接比較兩個暫存器:

case 0x5000:
    if (chip8->registers.V[x] == chip8->registers.V[y])
    {
        chip8->registers.PC += 2;
    }
    break;

使用 5023 測試這段邏輯:

chip8.registers.PC = 0;
chip8.registers.V[2] = 0x10;
chip8.registers.V[3] = 0x20;
chip8_exec(&chip8, 0x5230);
printf("%x\n", chip8.registers.PC);

會顯示 0,因為 V2 和 V3 不一樣。若改成

chip8.registers.V[2] = 0x10;
chip8.registers.V[3] = 0x10;

就會顯示 2,表示條件滿足因此 PC + 2。

再來的 6xkk(LD Vx, byte)與 7xkk(ADD Vx, byte)就相對簡單。

6xkkkk 直接寫入 Vx:

case 0x6000:
    chip8->registers.V[x] = kk;
    break;

例如 6A20 代表 VA = 0x20

7xkk 則將 kk 加到 Vx:

case 0x7000:
    chip8->registers.V[x] += kk;
    break;

到這裡,nnnxykk 都已經在實際需要時逐步加入解碼流程。

暫存器運算與第二層解碼

接下來的 8xy* 指令比前面的格式更特殊。

例如:

8xy0
8xy1
8xy2
8xy3
8xy4
8xy5
8xy6
8xy7
8xyE

如果只使用 opcode & 0xF000,全部都只會得到 0x8000,也就是第一層 decoder 只能知道「這是一條 8xy* 指令」,但還不知道真正要執行哪個 operation。

因此這裡可以再建立第二層 decoder:

static void chip8_exec_8xy(struct chip8* chip8, unsigned short opcode) 
{
    unsigned char x = (opcode >> 8) & 0x000F;
    unsigned char y = (opcode >> 4) & 0x000F;
    unsigned char final_four_bits = opcode & 0x000F;

    switch (final_four_bits)
    {
        case 0x00: // 8xy0 - LD Vx, Vy
            chip8->registers.V[x] = chip8->registers.V[y];
            break;
    }

}

這次判斷的是最後一個 nibble。這樣也算職責分離對吧XD 回去寫 case 會乾淨些:

case 0x8000:
	chip8_exec_8xy(chip8, opcode);
	break;

若要測試的話可以試試:

chip8.registers.V[0] = 0x20;
chip8.registers.V[1] = 0x30;
chip8_exec(&chip8, 0x8010);
printf("%x\n", chip8.registers.V[0]);

那應該會輸出 V0 的值已經變成 30

邏輯運算

8xy0(LD Vx, Vy)將 Vy 複製到 Vx:

case 0x0000:
    chip8->registers.V[x] = chip8->registers.V[y];
    break;

8xy1(OR Vx, Vy)執行 bitwise OR:

case 0x0001:
    chip8->registers.V[x] |= chip8->registers.V[y];
    break;

8xy2(AND Vx, Vy)執行 bitwise AND:

case 0x0002:
    chip8->registers.V[x] &= chip8->registers.V[y];
    break;

8xy3(XOR Vx, Vy)則執行 bitwise XOR:

case 0x0003:
    chip8->registers.V[x] ^= chip8->registers.V[y];
    break;

算術運算與 VF

8xy4(ADD Vx, Vy)會將 Vx 與 Vy 相加 Vx = Vx + Vy,但 V register 只有 8 bits,最大值為 0xFF = 255,因此必須另外判斷是否發生進位(carry)。

可以先使用較大的型別保存完整結果:

unsigned short tmp = chip8->registers.V[x] + chip8->registers.V[y];

接著判斷:

chip8->registers.V[0xF] = tmp > 0xFF;

最後再將結果存回 Vx:

chip8->registers.V[x] = tmp;

例如 200 + 60 = 260,260 已經超過 255,因此 VF = 1
可以直接用這個例子驗證看看:

chip8.registers.V[0] = 200;
chip8.registers.V[1] = 60;
chip8_exec(&chip8, 0x8014);

printf("%i\n", chip8.registers.V[0]);
printf("%i\n", chip8.registers.V[0x0f]);

輸出會是:

4
1

8xy5(SUB Vx, Vy)則執行 Vx = Vx - Vy,並先依照目前使用的規則設定 VF:

chip8->registers.V[0xF] = chip8->registers.V[x] > chip8->registers.V[y];

接著再進行 subtraction:

chip8->registers.V[x] = chip8->registers.V[x] - chip8->registers.V[y];

8xy7(SUBN Vx, Vy)方向相反 Vx = Vy - Vx。(嘿對沒錯先暫時跳過 8xy6

因此先判斷:

chip8->registers.V[0xF] = chip8->registers.V[y] > chip8->registers.V[x];

再執行:

chip8->registers.V[x] = chip8->registers.V[y] - chip8->registers.V[x];

位元位移

8xy6(SHR)會先取得 Vx 的最低有效位元(Least Significant Bit,LSB),保存到 VF,再將 Vx 向右位移。

例如:

10110101
       ^
       LSB

可以透過:

chip8->registers.V[0xF] = chip8->registers.V[x] & 0x01;

取得最低 bit,再執行:

chip8->registers.V[x] >>= 1;
或
chip8->registers.V[x] /= 2;

8xyE(SHL)則相反,要先檢查最高有效位元(Most Significant Bit,MSB)。

對一個 8-bit 數值來說:

10000000
^
MSB
chip8->registers.V[0x0f] = (chip8->registers.V[x] & 0x80) != 0;

取得該 bit 後,再將 Vx 向左位移:

chip8->registers.V[x] <<= 1;

完成 8xy* 的第二層 decoder 後,就可以回到主要 instruction decoder 繼續處理後面的 opcode。

9xy0(SNE Vx, Vy)與前面的 5xy0 相反:

case 0x9000:
    if (chip8->registers.V[x] != chip8->registers.V[y])
    {
        chip8->registers.PC += 2;
    }
    break;

記憶體位址、亂數與繪圖

接下來幾條 instruction 操作 I register、PC、亂數與畫面。

Annn(LD I, addr)將 I register 設為 nnn

case 0xA000:
    chip8->registers.I = nnn;
    break;

I register 可以用來指向記憶體中的資料,後面的 sprite drawing 就會使用它。

備註一下和之前邏輯一樣,nnn 代表 opcode 最低的 12 bits,通常作為記憶體位址使用。例如以 1ABC 為例子,其中 ABC 就是 nnn

12 bits 可以表示 0x0000xFFF,正好涵蓋 CHIP-8 的 4 KB 記憶體位址範圍。

Bnnn 則將 PC 設為 nnn + V0,因此:

case 0xB000:
    chip8->registers.PC = nnn + chip8->registers.V[0];
    break;

Cxkk(RND Vx, byte)會產生 random value,再與 kk 執行 bitwise AND:

Vx = Random Value AND kk,概念上可以寫成:

chip8->registers.V[x] = random_value & kk;

而實際上 random 怎麼取呢各有所好,倒是沒有硬性規定,例如:

case 0xC000:
	srand(clock());
	chip8->registers.V[x] = (rand() % 255) & kk;
	break;

完成這幾條較簡單的操作後,就輪到相對複雜的繪圖指令了XD

繪圖指令與 n 欄位

Dxyn(DRW Vx, Vy, nibble)會從 I register 指向的記憶體位置讀取 sprite,並繪製到由 Vx 與 Vy 指定的位置。

n 就是 opcode 最低的 4 bits:

unsigned char n = opcode & 0x000F;

Dxyn 中,它代表 sprite 的高度。

例如 D015 可以拆成:

D    0    1    5
     │    │    │
     │    │    └-> 高度為 5 bytes
     │    └------> Y 座標使用 V1
     └-----------> X 座標使用 V0

因此:

X Coordinate = V0
Y Coordinate = V1
Sprite Address = I
Height = 5

這些資料可以直接交給繪圖函式:

chip8->registers.V[0x0F] = chip8_screen_draw_sprite(&chip8->screen, chip8->registers.V[x], chip8->registers.V[y],  sprite, n);

繪圖函式本身會回傳是否發生 collision,因此可以直接利用結果更新 VF:

Collision -> VF = 1
No Collision -> VF = 0

Collision -> VF = 1
No Collision -> VF = 0

鍵盤狀態與等待輸入

Ex9E(SKP Vx)會檢查 Vx 所代表的 CHIP-8 key 是否正在被按下。

如果按鍵為 Down:

if (chip8_keyboard_is_down(&chip8->keyboard, chip8->registers.V[x]))
{
    chip8->registers.PC += 2;
}

也就是跳過下一條 instruction。

ExA1(SKNP Vx)則剛好相反:

if (!chip8_keyboard_is_down( &chip8->keyboard, chip8->registers.V[x]))
{
    chip8->registers.PC += 2;
}

接著進入 Fx** instruction family。

這一組指令種類很多,因此與前面的 8xy* 類似,可以另外建立一個 execution function,再依照 opcode 最低的 8 bits 進行第二層解碼。

例如我新增了 chip8_exec_fxkk 函式,並架構:

unsigned char x = (opcode >> 8) & 0x000F;

switch (opcode & 0x00FF)
{
    case 0x0007:
        // Fx07
        break;

    case 0x000A:
        // Fx0A
        break;

    case 0x0015:
        // Fx15
        break;
}

Fx07(LD Vx, DT)會將 Delay Timer 的目前值載入 Vx:

chip8->registers.V[x] = chip8->registers.delay_timer;

Fx0A(LD Vx, K)則不只是查看按鍵狀態,而是要等待使用者按下一個有效的 CHIP-8 key。

因此執行流程是:

等待 Key Press
-> 取得輸入
-> 轉換成 CHIP-8 Key
-> 寫入 Vx

取得有效按鍵後:

chip8->registers.V[x] = pressed_key;

另外還記得我們之有建立 mapping 嗎?這裡會另外建立等待 key press 的 helper function,並調整 keyboard mapping 的保存方式。

計時器與 I Register

Fx15(LD DT, Vx)將 Vx 寫入 Delay Timer:

chip8->registers.delay_timer = chip8->registers.V[x];

Fx18(LD ST, Vx)則寫入 Sound Timer:

chip8->registers.sound_timer = chip8->registers.V[x];

Fx1E(ADD I, Vx)將 Vx 加到 I register:

chip8->registers.I += chip8->registers.V[x];

Fx29(LD F, Vx)會讓 I register 指向 Vx 對應的內建字元 sprite。

由於預設字型中每個 sprite 高度固定為 5 bytes,因此可以先定義:

#define CHIP8_DEFAULT_SPRITE_HEIGHT 5

接著利用 Vx 計算對應 sprite 的位置:

chip8->registers.I = chip8->registers.V[x] * CHIP8_DEFAULT_SPRITE_HEIGHT;

BCD 與記憶體批次存取

Fx33(LD B, Vx)會將 Vx 的值拆成十進位的百位、十位與個位數,再依序寫入:

memory[I]
memory[I + 1]
memory[I + 2]

例如 Vx = 523 執行後:

memory[I] = 5
memory[I + 1] = 2
memory[I + 2] = 3

百位數可以透過:

unsigned char hundreds = chip8->registers.V[x] / 100;

取得。

十位數:

unsigned char tens = (chip8->registers.V[x] / 10) % 10;

個位數:

unsigned char units = chip8->registers.V[x] % 10;

接著依序寫入 memory:

chip8_memory_set(&chip8->memory, chip8->registers.I, hundreds);
chip8_memory_set(&chip8->memory, chip8->registers.I + 1, tens);
chip8_memory_set(&chip8->memory, chip8->registers.I + 2, units);

接著 Fx55(LD[I], Vx)會將 V0 到 Vx 的內容依序寫入 memory:

V0 -> memory[I]
V1 -> memory[I + 1]
V2 -> memory[I + 2]
...
Vx -> memory[I + x]

因此可以利用迴圈:

for (int i = 0; i <= x; i++)
{
    chip8_memory_set(&chip8->memory, chip8->registers.I + i, chip8->registers.V[i]);
}

最後的 Fx65(LD Vx, [I])則執行相反操作:

memory[I]     -> V0
memory[I + 1] -> V1
memory[I + 2] -> V2
...

實作為:

for (int i = 0; i <= x; i++)
{
    chip8->registers.V[i] = chip8_memory_get(&chip8->memory, chip8->registers.I + i);
}

完成指令集並執行遊戲

完成上述指令後,chip8_exec 已經不再只是接收一個 opcode,而是能夠根據 opcode 中不同位置的 bits 判斷真正的 instruction,並將其中的參數解析出來。

應該可以看得出來整個過程並不是一開始就先定義所有欄位,而是隨著指令逐步增加:

00E0 / 00EE
-> 可以直接比對完整 Opcode

1nnn / 2nnn
-> 開始使用 Bit Mask
-> 解析 nnn

3xkk
-> 解析 x、kk

5xy0
-> 加入 y

8xy*
-> 加入第二層 Decoder

Dxyn
-> 加入 n

Ex** / Fx**
-> 再依不同 Instruction Family 細分

也就是從最簡單的固定 opcode 開始,逐步建立出完整的 instruction decoding 機制。

完成最後的 Fx55Fx65 後,就可以重新編譯模擬器並載入實際的 CHIP-8 遊戲進行整合測試。


References

讓我知道你在想什麼!