從零開始的 Gameboy 模擬器開發 -- Step 2

  1. 1. 建立 opcode map
  2. 2. boot code 的選擇
  3. 3. 建立起 256 + 256 個 opcode
    1. 3.1. LD r1, r2 指令
    2. 3.2. bit b, r,res b, r 與 set b, r 指令
    3. 3.3. add_a_n 與 adc_a_n 指令
    4. 3.4. rlc 指令
    5. 3.5. jr cc, n 指令
    6. 3.6. di 與 ei 指令
  4. 4. 其他的功能
  5. 5. 如何 Debug

建立 opcode map

在開始之前讀取第一道指令之前,我們要做一件很不有趣的事情,就是建立所有 op code 的 map

首先我們建立 opcode_map.c,裡面的內容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void not_support_cb_code() { ASSERT_CODE(0, "not support CB code"); }
void not_support_op_code() { ASSERT_CODE(0, "not support op code"); }

void op_00() { not_support_op_code(); }
.......
void op_FF() { not_support_op_code(); }

void op_cb_00() { not_support_cb_code(); }
.......
void op_cb_FF() { not_support_cb_code(); }

opcode_fun g_opcode_fun_map[0x100] = {
GEN_FUN_MAP(, 0), GEN_FUN_MAP(, 1), GEN_FUN_MAP(, 2), GEN_FUN_MAP(, 3),
GEN_FUN_MAP(, 4), GEN_FUN_MAP(, 5), GEN_FUN_MAP(, 6), GEN_FUN_MAP(, 7),
GEN_FUN_MAP(, 8), GEN_FUN_MAP(, 9), GEN_FUN_MAP(, A), GEN_FUN_MAP(, B),
GEN_FUN_MAP(, C), GEN_FUN_MAP(, D), GEN_FUN_MAP(, E), GEN_FUN_MAP(, F)
};

opcode_fun g_opcode_cb_fun_map[0x100] = {
GEN_FUN_MAP(cb_, 0), GEN_FUN_MAP(cb_, 1), GEN_FUN_MAP(cb_, 2), GEN_FUN_MAP(cb_, 3),
GEN_FUN_MAP(cb_, 4), GEN_FUN_MAP(cb_, 5), GEN_FUN_MAP(cb_, 6), GEN_FUN_MAP(cb_, 7),
GEN_FUN_MAP(cb_, 8), GEN_FUN_MAP(cb_, 9), GEN_FUN_MAP(cb_, A), GEN_FUN_MAP(cb_, B),
GEN_FUN_MAP(cb_, C), GEN_FUN_MAP(cb_, D), GEN_FUN_MAP(cb_, E), GEN_FUN_MAP(cb_, F)
};

其中 GEN_FUN_MAP() macro 是幫你產生 op_00(), op_01 ... op_FF 的格式,因為一個一個打實在太累了,當然你有毅力我也不反對

這邊的重點放在兩個 array, 分別是 opcode_fun g_opcode_fun_map[0x100]opcode_fun g_opcode_cb_fun_map[0x100],也就是說,我們使用一個 function pointer 的 array,然後使用該 array 的 index 當作 op code 呼叫號碼,這樣就不用寫落落長的 switch case 了,有了 opcode 了,我們就可以開始 fetch 指令了。新增以下的 code 到 cpu.c 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
eu8 fetch(void) {
eu8 val = get_ram(REG_PC);
INC_REG(pc);
return val;
}

void execute_opcode() {
eu8 opcode = fetch();
opcode_fun_usp op_map = g_opcode_fun_map;
bool is_cb_cmd = false;

if (opcode == PREFIX_CMD) {
opcode = fetch();
op_map = g_opcode_cb_fun_map;
is_cb_cmd = true;
}

op_map[opcode]();
}

eu8 cpu_tick() {
return execute_opcode();
}

void tick() {
cpu_tick();
}

void run_cpu() {
while (g_cpu.running) {
tick();
}
}
  • fetch() 其實就是以 pc 當作記憶體位置,然後把該記憶體位置的值取出來,取出來後 pc 就要步進1
  • Gameboy 的 op code 有兩種,一種是 normal 的,一種是老任新增的 -- 以 0xCB 做前綴的 external op code ,所以我們這邊一旦遇到 cb cmd 的時候,就使用 cb cmd 的 function array,反之則用正常的 function array,而遇到 0xCB cmd 則必須要在 fatch 一次,以便讀出真正的 cb cmd opcode

boot code 的選擇

好,在開始之前,還有一件事很重要就是,這顆 cpu 的 pc 是從 0 開始讀,然後我們又把 game rom 放到 0 ~ 32k 的地方,理論上 pc = 0 開始讀的位置是 game rom 的第 0 byte,但是呢,如果你有注意到的話,其實 address 0 ~ 255 這個位置會跟 boot rom 重疊,也就是說 boot rom 與 game com 在 address 0 ~ 255 byte 的地方產生了 overlay

那既然 overlay 了,那我去讀 address 0,到底是讀到的是 game rom 的第一個 byte,還是 boot rom 的第一個 byte 呢? 為了讓 fw 可以選擇使用哪個 code,所以有個 0xFF50 這個 boot_rom_disable 的 register  會做切換的功能,當我們要存取 0 ~ 255 的位置時,就要先去看一下這個 reg,當 boot_rom_disable 設為 0 時,我們的 mmu 就要送出 boot rom 的值,反之就會讀到 game rom 的位置,這個 reg 預設是讀 boot rom, 而一旦當要開始執行遊戲畫面時,fw 就會把這個 register 切到 1,這樣 mmu 就必須把這塊記憶體 mapping 到 game rom 去

那這個 isp 的功能要實作也很簡單,就是在額外宣告一個 eu8 boot_rom_dmg[0x100] ,並且在 get_ram_ptr 動手腳就可以了

1
2
3
4
5
6
7
8
eu8_p get_ram_ptr(RamAddr address) {
if (address < BOOT_ROM_LENGTH) {
if (g_zero_page->boot_rom_disable == 0) {
return (eu8_p)&boot_rom_dmg[address];
}
}
return (eu8_p)&g_ram[address];
}

如果 ok 之後,我們就可以正式的 fatch 第一個指令了,此時順利的話,你會 fetch 第一個 op code ,這個是從 boot rom 提出來的,號碼為 0x31,此時如果繼續執行的話會碰到 error ,因為我們在所有沒有實作的 cmd 裡面都加上了 not_support_op_code(),進而會引發 assert,所以我們的目標就是把所有的 cmd 給做出來


建立起 256 + 256 個 opcode

這邊雖然一共有 normal op + external op 最多 512 道指令,但你如果仔細觀察的話,其實很多指令都相同,只是來源 register 或是目標 register 不同,我的建議是按照手冊一個一個做下去,這樣在實作的時候也比較不會亂

回到教學這邊,雖然每個 cmd 都是短短的,即便如此,若講解每道指令的話會使用很多篇幅,所以這邊只會挑重點講

首先我們先做點前置作業,在 cpu.c 中加入等下會用到的 function,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void disable_halt() {
g_cpu.halt = false;
}

void enable_halt() {
g_cpu.halt = true;
}

void stack_push(WordReg_p reg) {
DEC_REG(sp);
set_ram(REG_SP, REG_VAL(REG_HIGH(reg)));
DEC_REG(sp);
set_ram(REG_SP, REG_VAL(REG_LOW(reg)));
}

void stack_pop(WordReg_p reg) {
REG_VAL(REG_LOW(reg)) = get_ram(REG_SP);
INC_REG(sp);
REG_VAL(REG_HIGH(reg)) = get_ram(REG_SP);
INC_REG(sp);
}

eu16 fetch_word() {
eu16 val = 0;
val |= fetch();
val |= fetch() << 8;
return val;
}

eu8 cpu_tick() {
if (g_cpu.halt) {
return 1;
}
return execute_opcode();
}

你可以看到其實實作的方式都很簡單, fetch_word 就是使用 fetch 兩次,而 stack_push 就是先把 sp 減一後,在把 Word Reg 的 High byte 取值後填入 sp 所指的位置,stack 的操作大多是以 WordRegister 的方式 而 stack_pop 就是反過來做,此外,我們也增加了 halt 的 function ,並在原本的 cpu_tick() 中,加入 check g_cpu.halt 來模擬 halt 的指令


LD r1, r2 指令

這個指令是最多使用的,從 op code 0x40~0x7F 都是用它做出來的,所以一下子 64 個指令就完成了, 他的實作方式也很簡單,就是把 r2 這個 byte ,設給 r1 這個 byte register

而有時候設值目標不是 register ,而是記憶體位置,所以這邊就又弄一個 opcode_ld_r1_r2_addr() ,C 不支援同名異式的 Polymorphism ,所以抽象表現力就比較差一點,不過對於這個小專案來說還是可以忍受的範圍內就是了,當然 reg 與記憶體位置你也可以看成都是這隻模擬器的記憶體位置,真的發狠起來要搞合併也不是不行,只是這樣 code 會變成更難理解,這樣就本末倒置了

1
2
3
4
5
6
void opcode_ld_r1_r2(ByteReg_p r1, eu8 r2) {
REG_VAL(r1) = r2;
}
void opcode_ld_r1_r2_addr(RamAddr address, eu8 r2) {
set_ram(address, r2);
}

這邊也稍微貼一下他在 opcode_map.c 中的樣子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void op_40() { opcode_ld_r1_r2(b, REG_B); }
void op_41() { opcode_ld_r1_r2(b, REG_C); }
void op_42() { opcode_ld_r1_r2(b, REG_D); }
void op_43() { opcode_ld_r1_r2(b, REG_E); }
void op_44() { opcode_ld_r1_r2(b, REG_H); }
void op_45() { opcode_ld_r1_r2(b, REG_L); }
void op_46() { opcode_ld_r1_r2(b, RAM_VAL_HL); }
....
void op_48() { opcode_ld_r1_r2(c, REG_B); }
void op_49() { opcode_ld_r1_r2(c, REG_C); }
void op_4A() { opcode_ld_r1_r2(c, REG_D); }
void op_4B() { opcode_ld_r1_r2(c, REG_E); }
void op_4C() { opcode_ld_r1_r2(c, REG_H); }
void op_4D() { opcode_ld_r1_r2(c, REG_L); }
void op_4E() { opcode_ld_r1_r2(c, RAM_VAL_HL); }

bit b, r,res b, r 與 set b, r 指令

在 cb cmd 中,也是有跟 opcode_ld_r1_r2 一樣廣泛使用的 cmd ,那就是 opcode_cb_bit_b_r() , opcode_cb_res_b_r()與 opcode_cb_set_b_r(),他們也是各占 64 個 opcode,算是很補的 cmd, 作法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void opcode_cb_bit_b_r(eu8 val, eu8 bit) {
set_z(CHECK_BIT(val, bit) == 0);
set_n(false);
set_h(true);
}

void opcode_cb_res_b_r(ByteReg_p reg, eu8 bit) {
CLEAR_BIT(REG_VAL(reg), bit);
}

void opcode_cb_res_b_r_addr(RamAddr addr, eu8 bit) {
eu8 res = get_ram(addr);
CLEAR_BIT(res, bit);
set_ram(addr, res);
}

void opcode_cb_set_b_r(ByteReg_p reg, eu8 bit) {
SET_BIT(REG_VAL(reg), bit);
}

這3個 function 只有  opcode_cb_bit_b_r 需要注意的就是,你必須要開始設定 flags,不過這3個 function 的 flag 比較沒那麼複雜,我挑一個比較複雜的運算來討論一下


add_a_n 與 adc_a_n 指令

以下是 opcode 0x80 ~ 0x87 的 add_a_n 與 opcode 0x88 ~ 0x8F 的 adc_a_n 的實作,順帶一提的是,這邊 function 的命名規則是,如果是對應到 op code 的話就會以 "opcode_ " 為開頭,如果沒這開頭的就都是 base function 了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool check_hc_add(eu32 summand, eu32 addend, eu32 mask) {
return ((summand & mask) + (addend & mask) > mask);
}

eu32 add_a_b(eu32 summand, eu32 addend, eu32 mask) {
eu32 result = summand + addend;

if (mask == 0xFF) {
set_z((result & mask) == 0);
}

set_n(false);
set_h(check_hc_add(summand, addend, mask >> 4));
set_c(result > mask);

return result;
}

void opcode_add_a_n(eu8 n) {
REG_VAL(a) = (eu8)add_a_b(REG_VAL(a), n, 0xFF);
}

void opcode_adc_a_n(eu8 n) {
opcode_add_a_n(n + flags->c);
}

opcode_adc_a_n 與 opcode_add_a_n 其實是由 add_a_b 組成,這邊可看到 add 指令對 flag 的變化,在大部分的狀況規則整理如下

  • flag->z: 是用來判斷運算結果是不是為0
  • flag->n: 代表是不是減法
  • flag->h: 代表 bit 4 有沒有產生變化,也就是 halt carry 的意思
  • flag->c: 就是代表 carry,也就是運算結果本身有無溢位

對於 flag->c 來說,要判斷兩個值相加是否 overflow,所以就直接設了一個 eu32 result 的變數來存放相加過的值,並且判斷相加後是否超過 0x100 即可,反正我們是 32 位元的 cpu,要觀察兩個 8 bit regsiter 相加後是否溢位其實是一塊小蛋糕

這邊要注意的是, add u8 與 add u16 對於 flag 的設定有一點點不同


rlc 指令

除了加/減法會影響到 flag 外,它們也會拿去支援一些 cmd,可說是多才多藝,例如 cb cmd 的 opcde 0x00 ~ 0x07 的 rlc, 他的內容是把輸入值左移一位,那這樣最高位就會消失,不過這個指令會把最高位的 bit,保存在 flag->c 中,不過這個指令有趣的地方就在於它其實也把消失的 bit,又加回到 bit 0 的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
eu8 rlx_n(eu8 value, eu8 plus) {
eu8 result = (value << 1) | plus;

set_z(result == 0);
set_n(false);
set_h(false);
set_c(CHECK_BIT(value, 7));

return result;
}

void opcode_cb_rlc_n(ByteReg_p reg) {
eu8 plus = CHECK_BIT(REG_VAL(reg), 7);
REG_VAL(reg) = rlx_n(REG_VAL(reg), plus);
}

void opcode_cb_rlc_n_addr(RamAddr addr) {
eu8 plus = CHECK_BIT(get_ram(addr), 7);
set_ram(addr, rlx_n(get_ram(addr), plus));
}

jr cc, n 指令

最後講一下 opcode_jr_cc_n(),他其實是用 jr_n 組合出來的,特別要提的是,如果判斷是不需要 jump 的話,PC 也必須要往前移動一個,所以這邊就使用 INC_REG(pc) 來達到這個目的

而跳轉命令的實作也很簡單,就是改變 pc 值就可以了,所以 jr_n 指令的作法就是把現在的 pc 加上一個 offset,而這個 offset 的是從 fetch 而來,不過 jr_n 這邊是有順序性的,你一定要先 fetch 才可以做 offset 的動作,因為 fetch 會改變 pc 的值

1
2
3
4
5
6
7
8
9
10
11
12
void opcode_jr_n() {
es8 offset = fetch();
REG_PC += offset;
}

void opcode_jr_cc_n(bool condition) {
if (condition) {
opcode_jr_n();
} else {
INC_REG(pc);
}
}

di 與 ei 指令

再來比較特別的就是 di 與 ei,這個是打開 interrupt,我的感覺有點像是 8051 中的 EA=1 的感覺

1
2
3
4
5
6
7
void opcode_di() {
g_cpu.enable_interrupt = false;
}

void opcode_ei() {
g_cpu.enable_interrupt = true;
}

其實 op code 大概就是這樣,真正需要認真實作的部分約100行左右,除了一個叫 daa 指令之外, 剩下的大部分都很簡單,就是組合再組合就搞定了,接下來下一篇就是要寫 gb 的 video 與 tile 系統了

其他的功能

有一個比較特別的東西要拉出來講,就是 Z80 在 0xFF46 有提供一個 DMA 的功能,當那個記憶體被寫入的時候,我們就要執行 dma 的功能,他的 code 也很簡單

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void start_hw_dma(eu8 byte) {
RamAddr base = byte * 0x100;
for (eu8 i = 0x0; i < 0xA0; i++) {
RamAddr src = base + i;
RamAddr dst = SPRITE_MAP_START_ADDR + i;
set_ram(dst, get_ram(src));
}
}

eu8 get_ram(RamAddr addr) {
if (addr == IO_P1) {
return input_read();
}

// fix 0xFF
if (addr == IO_SERIAL) {
return 0xFF;
}
// DMA, fix 0
if (addr == IO_DMA) {
return 0x0;
}

if ((0xFEA0 <= addr) && (addr < 0xFF00)) {
return 0xFF;
}

return (*get_ram_ptr(addr));
}

void set_ram(RamAddr addr, eu8 value) {
//can not write when Non-MBC mode
if (addr < 0x8000) {
PRINTF_ALWAYS("inhibit write rom addr=%X, val=%X", addr, value);
return;
}

// debug: workaround
if (addr == IO_TIMER_MODULE) {
return;
}

if (addr == IO_TIMER_CTRL) {
return;
}

// start DMA
if (addr == IO_DMA) {
start_hw_dma(value);
return;
}

(*get_ram_ptr(addr)) = value;
}

fw 會寫入一個 byte,這個 byte 就代表 dma 的來源,而 DMA 的目的地就是 0xFE00,這是固定的,般的長度是 0xA0,也是固定的

如何 Debug

其實剩下的指令都很短,也不複雜,但就是多,東西一多起來就很難不出錯,把每個步驟執行後的每個 register 都印出來是一個好方法,不過即便如此,光是開機到開頭動畫,可能就會執行 30~40 萬筆指令,若把所有的東西都寫進 log 中,log 會很龐大,所以我們會需要一些有效率的 debug 方式,新增一個 debug.c 並加入以下的 code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#define START_CNT (0x0)
#define END_CNT (0x0)

void print_ram(RamAddr ptr, eu32 len) {
print_ram_base(ptr, len, get_ram);
}

void set_debug_flag() {
if ((START_CNT <= g_cmdCnt) && (g_cmdCnt < END_CNT)) {
g_debug = true;
} else {
g_debug = false;
}

if (g_cmdCnt < START_CNT) {
if ((g_cmdCnt % 0x1000) == 0) {
g_debug = true;
} else {
g_debug = false;
}
}
}

void show_reg_ram(bool is_cb_cmd, eu8 opcode, eu8 clock) {
if (is_cb_cmd) {
printf("\n\nccnt=%X, cbc=%02X, ", g_cmdCnt, opcode);
} else {
printf("\n\nccnt=%X, opc=%02X, ", g_cmdCnt, opcode);
}
printf("af=%04X, bc=%04X, de=%04X, hl=%04X, pc=%04X, sp=%04X, clk=%X\n", af->all, bc->all, de->all, hl->all, pc->all, sp->all, clock);

print_ram(0x0, 0x10);
print_ram(0x100, 0x10);
print_ram(0x2000, 0x10);
print_ram(0x4000, 0x10);
print_ram(0x8000, 0x10);
print_ram(0x9800, 0x10);
print_ram(0xFE00, 0x10);
print_ram(0xFF00, 0x10);
print_ram(0xFF40, 0x11);
print_ram(0xFFA0, 0x10);
printf("\n");
}

void debug_show_reg_ram(bool is_cb_cmd, eu8 opcode, eu8 clock) {
set_debug_flag();
if (g_debug) {
show_reg_ram(is_cb_cmd, opcode, clock);
}
}

我們把 debug code 集中在 debug.c 一起管理,盡量不要去汙染到原本的 code

這邊會使用兩種印指令的方式,一種是每隔 0x1000 就印一次,一種是指定區域印,這有個好處就是,你每次比較的時候,都可以知道錯誤大概在哪個區間,然後知道區間之後,再用詳細印的方式,就可以知道是哪一筆出錯了, log 也部會太大

把印 register 的指令插入到 execute_opcode 內,這樣你就可以得到每次執行指令後,register 的變化了

1
2
3
4
5
6
7
8
9
10
11
12
13
eu8 execute_opcode() {
eu8 opcode = fetch();

....
....

opcodeFunMap[opcode]();
debug_show_reg_ram(is_cb_cmd, opcode, clock);

....

return clock;
}

然後你應該可以找一個 GB Emu project,讓他也印出相同的東西,跟你的 log 一比,很快就會知道你錯在哪裡

下一篇: 從零開始的 Gameboy 模擬器開發 -- Step 3