# Gamboy + JIT 由於不太清楚如何結合 [GBIT](https://github.com/koenk/gbit),先試著理解並記錄一下程式的運作,以 [0466847](https://github.com/sysprog21/jitboy/tree/046684749b8ca3dca379f2740aede7c859f35311) 版本來記錄。 :::warning TODO: 1. 實作直譯器,作為 JIT 的對照,後續也可比照 * 修改 `src/core.c` 的 `run_vm` 函式,比照 [gameboy-emu](https://github.com/sysprog21/gameboy-emu) 和 [GBIT](https://github.com/koenk/gbit) 的 opcode 直譯器實作方式,提供「非 JIT」(即直譯器) 的執行流程 * 在 `INTERPRETER_ONLY` 的模式,應忽略所有的 `compiled_blocks` 2. 用上述的直譯器搭配 [GBIT](https://github.com/koenk/gbit) 驗證 可能的程式碼: ```cpp bool run_vm(gb_vm *vm) { uint16_t prev_pc = vm->state.last_pc; vm->state.last_pc = vm->state.pc; #ifdef INTERPRETER_ONLY /* execute each opcode */ #else /* compile next block / get cached block */ ... #endif ``` :notes: jserv ::: ## 程式原理 ### main.c ```c=45 gb_vm *vm = malloc(sizeof(gb_vm)); if (!init_vm(vm, argv[optind], opt_level, true)) { LOG_ERROR("Fail to initialize\n"); exit(1); } ``` 先分配空間給 vm,再初始化 vm (詳見 core.c) 57 ~ 137 行則是對按鍵做反應,以 `vm->state.keys.state` 記錄哪顆按鍵被按下,按下時會做 `vm->memory.mem[0xff0f] |= 0x10;` 產生 joypad interrupt。 ### core.c **`bool init_vm(gb_vm *vm, const char *filename, int opt_level, bool init_io)`** 先做記憶體初始化後將卡帶資訊印出 (見 memory.c)。 19 ~ 74 行給 `vm->state` 當中的參數初始值,以及給 `vm->memory` 中的部分 IO ports 和 Interrupt Enable Register 初始值。 87 ~ 99 行依照 init_io 的值來決定是否顯示視窗。 **`bool run_vm(gb_vm *vm)`** 110 ~ 155 行依照 program counter 指向的位置來決定執行的 function, 當 pc < 0x4000 (ROM bank 0),若對應的 compiled_blocks[0][pc] 沒有被執行過,則先編譯 pc 指到的 byte 成 opcode 後,存入 block 中(見 gbz80.c),接著將執行次數加一後執行該 function。 而如果 0x4000 <= pc < 0x8000 (ROM bank 1 ... N),會先確認第幾個 bank 後,對對應的 compiled_blocks[bank][pc - 0x4000] 做跟上面一樣的處理,而因為 compiled_blocks 的範圍是 [MAX BANK][0x4000],所以這邊的 pc 對應到的會是 pc - 0x4000。 當 pc >= 0xff80 (High RAM) 時,其實也與上方類似,不過這次對應到的是 highmem_blocks[pc - 0xff80]。 最後當 pc 在以上情況外時,代表執行 RAM 中的 function,這時以一個臨時的 block temp 存取編譯後的結果並且執行。 170 ~ 237 行會在 cpu halt 時執行。 首先檢查已執行的 clock cycles 是否 >= 下次更新的時間,如果是的話先透過 update_ioregs 檢查該不該產生中斷(見 interrupt.c)。接著檢查 mem 0xff44 是否等於 144 (畫到最後一個 row),如果還沒的話將 draw_frame 設成 true,而如果是的話檢查 draw_frame 是否為 true,如果是的話先透過 time = SDL_GetTicks() 取得 SDL library 初始化後經過的時間,接著確認 time 已經經過下一個幀的時間,或是等待到經過為止。 每更新了 60 個影格,會計算系統忙碌時間的比例更新在視窗的標題上。 當有中斷發生時,會先取消 halt 模式,將 pc 存到 stack 中,然後跳到中斷開始的地址。 檢查 LCD 狀態是否是 mode 3 且是因為等待 mode 3 而 halt,是的話取消 halt。 ``` 0xff41 bits 0-1 00: H-Blank 01: V-Blank 10: Searching Sprites Atts 11: Transfering Data to LCD Driver ``` 檢查 LCD 是否畫到造成 halt 的行數,且是因為等待畫線而 halt,是的話取消 halt。 最後更新下次的更新時間,以及如果這時依然沒有取消 halt 的話,更新已經執行的 instruction 數。 **`static void show_statistics(gb_vm *vm)`** 計算總共執行過的 ROM function,最常執行的 function 和執行過的次數,以及所有 function 被執行過的次數,和執行過的幀數,並且印出來。 **`bool free_vm(gb_vm *vm)`** 首先執行上面的 show_statistics,接著釋放有被執行過的 block,關掉視窗,釋放記憶體。 ### memory.c **`static uint8_t get_joypad_state(gb_keys *keys, uint8_t value)`** 此函式用來更新 joypad register,運作原理可參考 [input update function](https://thomas.spurden.name/gameboy/#input_update_function0) **`static void gb_memory_change_ram_bank(gb_memory *mem, int bank)`** **`static void gb_memory_change_rom_bank(gb_memory *mem, int bank)`** **`static void gb_memory_access_rtc(gb_memory *mem, int elt)`** 存取 RTC (real time clock),未完成 **`static void gb_memory_update_rtc_time(gb_memory *mem, int value)`** 更新 RTC,未完成 **`void gb_memory_write(gb_state *state, uint64_t addr, uint64_t value)`** **`bool gb_memory_init(gb_memory *mem, const char *filename)`** 一開始先檢查 filename 是否存在,不過這裡的 filename 即是遊戲檔案(如 mario.gb),而如果沒有輸入的話在 main.c 就會直接退出了,所以這裡不會有 filename 不存在的問題。 首先會以唯讀模式打開輸入的 filename,接著將檔案中前 0x8000 的內容映射到 0x1000000 這段記憶體位址上,而 0x0000 ~ 0x7fff 正是 GB 中 ROM 的位址空間。之後再用 anonymous mapping 映射 0x8000 ~ 0xffff 的空間來做為 RAM。 最後再初始化 mem 中的各項參數。 **`bool gb_memory_free(gb_memory *mem)`** 釋放 memory init 時分配給 mem->ram_banks 時的記憶體,關閉檔案,並且用 munmap 來取消 memory init 時映射的內容。 **`void dump_header_info(gb_memory *mem)`** 印出卡帶中的各項資訊,裡面引用的位址可以參考,不過 Manufacturer 引用的位址是 Title 的後半段,可能只適用在部分遊戲上。 ![](https://i.imgur.com/caJqBwl.png =400x) ### gbz80.c **`static bool optimize_cc(GList *inst)`** 根據指令的 flag 來做最佳化,不清楚原理。 **`bool compile(gb_block *block, gb_memory *mem, uint16_t start_address, int opt_level)`** 首先建立一個叫 instructions 的 list,然後進入 for loop。 for loop 中會先用 g_new 分配空間給叫做 inst 的 gbz80_inst 結構(在 emit.h 中定義),接著依照 opcode 將指令表內的內容存入 inst 中,更新 inst->args (不知道是什麼),還有 inst->address (指令對應的位址),還有 i (下一輪要讀取的位址)。接著透過 g_list_prepend 將這次讀取的指令 append 到 instructions 中。如果讀取的指令有 INST_FLAG_ENDS_BLOCK 這個 flag 則退出 for loop。 之後透過 g_list_reverse 將 instructions 反轉,經過兩個最佳化 function(optimize_block, optimize_cc) 後,透過 emit 來執行指令,最後將 instructions 釋放掉。 ### interrupt.c **`uint64_t next_update_time(gb_state *state)`** 先透過 0xff07 和 0xff41 取得時脈以及 LCD 狀態 ``` 0xff07 bits 0-1 00: 4.096 khz 01: 262.144 khz 10: 65.536 khz 11: 16.384 khz ``` 由於刷新一個 line 需要 114 machine cycles,所以預設的 next update time 是 state->ly_count + 114。如果預設的 time > state->inst_count + cl (時脈),將 time 改成 state->inst_count + cl。接著根據 LCD 狀態決定 time 的判斷,如果是 OAM search 時,判斷 ``` if (time > state->ly_count + 20) time = state->ly_count + 20; ``` 而如果是 pixel transfer 則是 ``` if (time > state->ly_count + 64) time = state->ly_count + 64; ``` 不過根據這張圖來看,應該是 + 63 (?) ![](https://i.imgur.com/6Ou2pz5.png =700x) :::warning 針對上述 pixel transfer,可提交 pull request :notes: jserv ::: > 好的,待了解如何操作後更新 **`void update_ioregs(gb_state *state)`** ```c=38 if (state->inst_count > state->tima_count + cl) { state->tima_count = state->inst_count; if (mem[0xff07] & 0x4) { mem[0xff05]++; if (mem[0xff05] == 0) { mem[0xff05] = mem[0xff06]; // timer interrupt selected mem[0xff0f] |= 0x04; } } } ``` 一開始透過 0xff07 取得時脈,並做 `if (state->inst_count > state->tima_count + cl)` 判斷 0xff05 (timer counter) 該更新了沒,成立的話先 `state->tima_count = state->inst_count`,判斷 0xff07 & 0x4 (start timer),成立的話 0xff05++。而如果這時 0xff05 == 0 (timer overflow),載入 0xff06 (timer modulo),且將 0xff0f (interrupt flag) 設為 tima interrupt。 ```c=56 /* reset the coincidence flag */ mem[0xff41] &= ~0x04; // ly-register 0xff44 if (state->inst_count > state->ly_count + 114) { state->ly_count = state->inst_count; if (mem[0xff44] < 144) update_line(mem); mem[0xff44]++; mem[0xff44] %= 153; if (mem[0xff45] == mem[0xff44]) { /* Set the coincidence flag */ mem[0xff41] |= 0x04; /* Coincidence interrupt selected */ if (mem[0xff41] & 0x40) mem[0xff0f] |= 0x02; /* stat interrupt occurs */ } /* if-register 0xff0f */ if (mem[0xff44] == 144) { /* VBLANK interrupt is pending */ mem[0xff0f] |= 0x01; /* mode 1 interrupt selected */ if (mem[0xff41] & 0x10 && (mem[0xff41] & 0x03) != 1) mem[0xff0f] |= 0x02; /* stat interrupt occurs */ /* LCDC Stat mode 1 */ mem[0xff41] &= ~0x03; mem[0xff41] |= 0x01; } } ``` 將 0xff41 的 coincidence flag 取消後,做 `if (state->inst_count > state->ly_count + 114)` 判斷該不該刷新 line,成立的話先 `state->ly_count = state->inst_count`,而如果 0xff44 < 144 代表畫面還沒畫完,用 update_line (lcd.c) 更新畫面。之後 0xff44++ 以及 0xff44 %= 153 (根據前人的成果可以知道這裡應是 144 + 10 = 154)。 如果 0xff45(LY compare) == 0xff44,重新立 coincidence flag,如果同時 LY coincidence interrupt 有被選擇,就會將 0xff0f 設 LCDC interrupt。 而如果 0xff44 == 144 時,將 0xff0f 設為 V-Blank interrupt。如果同時 `mem[0xff41] & 0x10 (V-Blank interrupt enable) && (mem[0xff41] & 0x03) != 1 (不在 V-Blank 期間)`,將 0xff0f 設 LCDC interrupt。最後將 0xff41 設為 During V-Blank。 ```c=93 if (mem[0xff44] < 144) { /* if not VBLANK */ if (state->inst_count - state->ly_count < 20) { /* mode 2 interrupt selected */ if (mem[0xff41] & 0x20 && (mem[0xff41] & 0x03) != 2) mem[0xff0f] |= 0x02; /* stat interrupt occurs */ /* LCDC Stat mode 2 */ mem[0xff41] &= ~0x03; mem[0xff41] |= 0x02; } else if (state->inst_count - state->ly_count < 63) { /* LCDC Stat mode 3 */ mem[0xff41] &= ~0x03; mem[0xff41] |= 0x03; } else { /* mode 0 interrupt selected */ if (mem[0xff41] & 0x08 && (mem[0xff41] & 0x03) != 0) mem[0xff0f] |= 0x02; /* stat interrupt occurs */ /* LCDC Stat mode 0 */ mem[0xff41] &= ~0x03; } } ``` 若 0xff44 < 144,先判斷 `if (state->inst_count - state->ly_count < 20)` 確認是否在 OAM search,是的話先依照 `if (mem[0xff41] & 0x20 && (mem[0xff41] & 0x03) != 2)` 決定要不要設 LCDC interrupt flag (與前一段類似),然後將 0xff41 設為 During searching OAM。 如果在 pixel transfer 期間,將 0xff41 設為 During Transfering Data to LCD Driver。 如果在 H-Blank 期間,先依照 `if (mem[0xff41] & 0x08 && (mem[0xff41] & 0x03) != 0)` 決定是否設 LCDC interrupt flag,接著將 0xff41 設為 During H-Blank (Entire Display Ram can be accessed)。 **`uint16_t start_interrupt(gb_state *state)`** 先確認 state->ime == true (interrupt master enable),篩選同時在 Interrupt Flag 和 Interrupt Enable 的中斷存在 interrupts 中。 如果是 V-Blank interrupt,將 ime 取消,取消 V-Blank flag,將 state->trap_reason |= REASON_INT (追蹤造成 block 中止的原因,設成因為 interrupt 而中止),最後回傳 0x40 (interrupt start address)。 其他 interrupt 的處理大致相同,只有因應不同的 interrupt 取消對應的 flag 以及回傳對應的開始位址。而 serial interrupt 的部分並未實作,可能是因為模擬器並沒有像實機具有傳送功能。 ### lcd.c **`static void render_back(uint32_t *buf, uint8_t *addr_sp)`** **`static int render_thread_function(void *ptr)`** 先將 ptr assign 給 lcd,再將 lcd assign 給 g_lcd (兩者皆是 gb_lcd 結構)。 利用 SDL_CreateRenderer(lcd->win, -1, SDL_RENDERER_ACCELERATED) 製造 rendering context,將 rendering 結果顯示在 lcd->win,並選擇支援硬體加速的 driver。 當 !lcd->exit 時,重複執行以下動作 : 利用 SDL_LockMutex(lcd->vblank_mutex) 鎖住 vblank_mutex,利用 SDL_CondWait(lcd->vblank_cond, lcd->vblank_mutex) 將 vblank_mutex 解鎖並等待 vblank_cond 的訊號後重新鎖住 vblank_mutex,在兩個 cur_imgbuf 之間切換(這裡應該是有兩個 imgbuf 來儲存畫面,並以 cur_imgbuf 來決定目前要顯示的畫面),SDL_UnlockMutex(lcd->vblank_mutex) 解鎖 vblank_mutex,render_frame(lcd) 更新畫面。 最後用 SDL_GetRenderer(lcd->win) 取得 renderer 後用 SDL_DestroyRenderer(renderer) 來清除該 renderer。 **`static void render_frame(gb_lcd *lcd)`** 一開始透過 SDL_GetRenderer(lcd->win) 取得 renderer,接著用 `bitmapTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, 160, 144);` 創一個 SDL texture 並 assign 到 bitmapTex 中。format 表示顏色的順序,streaming 表示 texture 會經常改變。 ```c=238 void *pixels = NULL; int pitch = 0; SDL_LockTexture(bitmapTex, NULL, &pixels, &pitch); memcpy(pixels, imgbuf[(cur_imgbuf + 1) % 2], 160 * 144 * sizeof(uint32_t)); SDL_UnlockTexture(bitmapTex); ``` 這段將 bitmapTex 鎖住並寫入下一張圖片的內容,但有疑問的地方是根據 [SDL](https://wiki.libsdl.org/SDL_LockTexture) 的內容,pitch 指的是一個 row 的長度,以這邊來說,應該是 160 * 4 bytes,但這裡卻給 0。 ```c=244 SDL_RenderCopy(renderer, bitmapTex, NULL, NULL); SDL_RenderPresent(renderer); ``` 最後則是將 texture 內容複製到 renderer 中,再將螢幕透過 renderer 做更新。 ## 實作直譯器 根據提示,修改 core.c 中的 run_vm,把 `/* compile next block / get cached block */` 到 `LOG_DEBUG("ioregs: STAT=%02x LY=%02x IF=%02x IE=%02x\n",` 前一行用 #else 和 #endif 分開。(109 ~ 156 行)。 gameboy-emu 中直譯器的部分應該是在 [gameboy.h](https://github.com/sysprog21/gameboy-emu/blob/master/gameboy.h) 中,`__gb_step_cpu` 中,標註 `/* Excute opcode */` 的這段,但因為兩個程式碼間有不少差距,可能需要改很久,所以這裡我先試了另一個方法,也就是參考 [gbz80.c](https://github.com/sysprog21/jitboy/blob/046684749b8ca3dca379f2740aede7c859f35311/src/gbz80.c) 裡面 `compile` 的內容。 在原本的實作中,會編譯 pc 指到的地址的內容並且存到 block 中,以便之後使用,然後更新 pc。而現在嘗試不使用 block 而是直接把 compile 的內容拿來改,如下 : ```cpp // core.c AAA *BBB = malloc(sizeof(AAA)); BBB->funcA = 0; GList *instructions = NULL; gb_memory *mem = &vm->memory; uint16_t i = vm->state.pc; for (;;) { gbz80_inst *inst = g_new(gbz80_inst, 1); uint8_t opcode = mem->mem[i]; if (opcode != 0xcb) { *inst = inst_table[opcode]; } else { opcode = mem->mem[i + 1]; *inst = cb_table[opcode]; } inst->args = mem->mem + i; inst->address = i; i += inst->bytes; instructions = g_list_prepend(instructions, inst); if (inst->flags & INST_FLAG_ENDS_BLOCK) break; } instructions = g_list_reverse(instructions); if (!emit(NULL, instructions, BBB)) printf("emit failed!\n"); vm->state.pc = BBB->funcA(&vm->state); g_list_free_full(instructions, g_free); ``` ```cpp // emit.dasc bool emit(gb_block *block, GList *inst, AAA *BBB) { . . . if (block != NULL) { block->func = labels[lbl_f_start]; block->mem = buf; block->size = sz; block->end_address = end_address; block->exec_count = 0; } else { BBB->funcA = labels[lbl_f_start]; } . . . } ``` 基本上就是把原本關於 block 的內容忽略掉,但由於不知道原本 block 裡面 func 的內容怎麼傳的,所以我宣告了一個 struct AAA 在 emit.h 中,並加入 `uint16_t (*func)(gb_state *);` 這個屬性用來更新 pc。 實驗的結果是程式可以跑,但是遊玩時會遇到不少錯誤,例如跳到 goomba 上面不會打倒它反而會損血,最致命的是玩了不久後會當機直接卡死。 雖然也可能是因為我亂改的過程中忽略了一些地方造成錯誤,但目前看來還是得照 gameboy-emu 來改才能知道了。 :::warning 所以才要一開始就思考「如何驗證」,也是 GBIT 存在的緣故。按部就班來實作,及早提交 pull request :notes: jserv ::: [最新的變更](https://github.com/nelsonlai1/jitboy/tree/test/src),照著 gameboy-emu 的方式改,但畫面顯示不出來,觀察 pc 和 opcode 會發現陷入一個循環中,推測是中斷的部分沒處理好。 ## 額外參考資料 [Everything You Always Wanted To Know About GAME BOY](http://www.emulatronia.com/doctec/consolas/gameboy/gameboy.txt)