로우레벨 컴퓨팅을 더 깊이 이해하고 C++로 크로스플랫폼 게임 보이 에뮬레이터를 만들기 위한 여정. 그 과정과 구현 방법을 정리했다.

비디오 편집/크리에이터, Twitch 어필리에이트, 웹 디자인, 소프트웨어 개발, 그리고 너무 많은 것들
홈
소개
블로그
프로젝트
소셜
연락처
게임 보이 에뮬레이터를 만들며 로우레벨 컴퓨팅과 C++ 배우기 - GameByte 제작기

로우레벨 컴퓨팅과 C++에 대해 더 배우기 위한 여정을 시작했고, 그 과정에서 크로스플랫폼 게임 보이 에뮬레이터를 만들었다. 이 글은 그 이야기와 내가 어떻게 했는지에 대한 기록이다.
Michael Webb 작성 • 2026년 1월 21일 게시
게시글 내용
이 블로그 글은 네 개의 주요 섹션으로 나누었다: “왜(Why)”, “어떻게(How)”, “무엇이 빠졌나(What’s Missing)”, “다음은(What’s Next)”. 소스 코드를 바로 보고 GameByte를 직접 만져보고 싶다면 여기를 클릭해 GitHub 저장소로 가면 된다.
나는 하드웨어와 소프트웨어의 아주 세세한 부분까지 파고드는 방대한 오픈 소스 프로젝트들을 항상 매력적으로 느껴왔다. 예를 들면 LEGO Island 디컴파일 프로젝트 같은 것들, 그리고 특히 Xbox 360용 Bad Update 익스플로잇 및 Xbox 360의 (어느 정도) 소프트 모딩의 물꼬를 트기 위한 관련 후속 노력들(내 친구 InvoxiPlayGames에게 샤라웃!). 이런 프로젝트들 뒤에 있는 열정과 추진력은 늘 존경의 대상이었고, 나도 어떤 형태로든 그 일부가 되고 싶었다. 문제는, 내가 C#, Golang, JavaScript/TypeScript 같은 언어로 여러 해 동안 코드를 써 왔음에도 불구하고, 로우레벨 컴퓨팅이 어떻게 동작하는지나 C/C++ 같은 더 낮은 수준의 언어를 어떻게 쓰는지에 대한 기반이 허술했다는 점이다. 그래서 내 생각에 가장 좋은 방법, 즉 ‘실제로 이런 스킬이 필요한 작은 프로젝트를 직접 만들어 보는 것’을 통해 배우기로 했다.
이런 맥락에서, 프로젝트의 결과물로 에뮬레이터를 만드는 건 멋질 것이라 생각했다. 동기부여가 될 목표가 되면서도 로우레벨 컴퓨팅 스킬을 배우고 동시에 C++도 배우기 시작할 수 있기 때문이다. 내겐 완전 윈윈이었다! 특히 원조 Game Boy를 에뮬레이션 대상으로 정했는데, 다른 게임기들에 비해 문서화가 매우 잘 되어 있을 뿐 아니라, 에뮬레이션 난이도도 상대적으로 쉬운 편이기 때문이다.
처음 이 프로젝트를 시작한 건 2025년 6월이었다. 게임 보이 하드웨어는 주로 Pan Docs를 가이드로 삼아 조사했고, CTurt가 C 기반 최소 게임 보이 에뮬레이터를 만드는 과정을 설명한 훌륭한 블로그 글도 함께 참고했다. 아주 기본적인 CMakeLists.txt로 시작했고, 게임 보이 CPU 레지스터를 표현하는 변수와, 두 개의 8비트 레지스터를 짝지어 만든 16비트 레지스터 쌍을 다루는 기본 함수 같은, 약간의 보일러플레이트를 추가하기 시작했다.
// 초기 cpu.cpp 파일의 아주 일부.
class CPU {
public:
// 8비트 범용 레지스터
uint8_t a, b, c, d, e, h, l;
// 플래그 레지스터
uint8_t f;
// 16비트 레지스터
uint16_t sp; // 스택 포인터
uint16_t pc; // 프로그램 카운터
/**
* 16비트 레지스터 쌍용 Getter/setter 메서드
*/
// AF 레지스터 쌍
uint16_t get_af() const {
return (static_cast<uint16_t>(a) << 8) | f;
}
void set_af(uint16_t value) {
a = (value >> 8) & 0xFF; f = value & 0xF0; // F의 하위 4비트는 항상 0
}
};
그리고 거의 6개월 동안 그대로 방치됐다!
그래, 난 스스로 동기부여를 유지하는 데 정말 약하다. 이게 얼마나 큰 커밋먼트가 될지(적어도 그땐 그렇게 생각했다) 빠르게 깨닫고는 포기해 버렸다. 그러다 6개월이 조금 못 되는 어느 날, 우연히 GameByte 저장소를 다시 보게 되었고, 위에서 말한 목표들을 이루기 위해서라도 반드시 이 프로젝트를 끝내고 싶다는 마음이 들었다. 이때부터 진짜 시작이었다.
2026년 1월 6일, 나는 프로젝트를 본격적으로 파기 시작했다. 여전히 Pan Docs와 CTurt의 글을 기본 구현 방향을 정하는 데 유용한 가이드로 활용했고, 앞서 언급한 InvoxiPlayGames 같은, 로우레벨 프로그래밍에 대한 일반 지식이 훨씬 좋은 친구들에게도 조언을 구했다.
가장 먼저 CPU/MMU 클래스를 C++ 관례에 맞게 헤더와 소스 파일로 제대로 분리했다. 처음엔 변수/구조체/함수를 한 클래스 파일에 몰아넣던 방식에 익숙해서 좀 짜증났지만, 특히 CPU 클래스가 필요한 오퍼코드 구현들 때문에 거대한 괴물이 되어 갈수록 이 분리가 오히려 마음에 들었다. 이 방식이 특정 변수 같은 걸 찾기 훨씬 쉬웠다.
그다음 CPU와 MMU 사이의 기본 통신을 구현해, CPU가 에뮬레이트된 RAM을 읽고/쓸 수 있게 했다. 또한 Pan Docs에 정리된 대로 CPU 주소 공간의 각 구간을 카트리지 ROM, 비디오 RAM(VRAM), 특정 I/O 레지스터, 스프라이트 속성 메모리(OAM: 입력과 PPU 관련) 등으로 MMU가 할당하도록 했다.
class MMU {
public:
uint8_t read_byte(uint16_t address);
void write_byte(uint16_t address, uint8_t value);
uint16_t read_word(uint16_t address);
void write_word(uint16_t address, uint16_t value);
bool load_game(const uint8_t* data, size_t size);
private:
unsigned char cart[0x8000]; // 총 32 KB 카트리지 ROM 공간
unsigned char vram[0x2000]; // 8 KB 비디오 RAM(VRAM)
unsigned char eram[0x2000]; // 8 KB 외부 RAM(카트리지 배터리 백업 RAM)
unsigned char wram[0x2000]; // 8 KB 워크 RAM(WRAM). CGB 모드에서는 1-7 뱅크로 스위칭 가능
unsigned char oam[0xA0]; // 160바이트 스프라이트 속성 메모리(OAM)
unsigned char io[0x80]; // 128바이트 I/O 레지스터
unsigned char hram[0x7F]; // 127바이트 하이 RAM
uint8_t ie; // 0xFFFF의 인터럽트 활성화 레지스터(IE)
};
또한 표준 C++ 함수인
plaintextfseek
및
plaintextfread
를 이용해 기본 Game Boy ROM을 로드할 수 있는 ROM 클래스도 추가했다. 이 시점에는 기본 카트리지 ROM만 구현했다. 사실 많은 게임 보이 게임들은 메모리 뱅크 컨트롤러(MBC)라는 것을 사용해 뱅크 스위칭을 통해 게임이 사용할 수 있는 주소 공간을 확장한다. 이 로직은 에뮬레이터에 넣기가 훨씬 복잡할 뿐 아니라, 원조 게임 보이 라이브러리에는 알려진 매퍼만 최소 27종이 있어서 완전한 호환성을 달성하기가 매우 어렵다. 게다가 유일하게 중요한 게임인 테트리스는 MBC나 기타 카트리지 하드웨어를 전혀 쓰지 않는다. 완벽했다.
다음으로
plaintextmain.cpp
에 에뮬레이션 루프 코드를 잔뜩 작성했다. 예를 들어 프레임당 사이클 수(70,244)를 처리해서 타이밍을 맞추는 것 등이다. 기본 CPU 루프는 다음과 같았다.
// main.cpp
// 한 프레임 동안 CPU 실행
while (cycles_this_frame < CYCLES_PER_FRAME) {
try {
int cycles = cpu.step();
cycles_this_frame += cycles;
} catch (const std::exception& e) {
std::cerr << "[GameByte] 곧 에뮬레이션 에러가 발생하려 합니다. 지금까지 진행한 총 사이클: " << cpu.total_cycles << std::endl;
std::cerr << e.what() << std::endl;
running = false; // 에러 시 중단
break;
}
}
// cpu.cpp
uint8_t CPU::step() {
if (!mmu) {
throw std::runtime_error("[CPU] 실행 전에 MMU가 CPU에 연결되지 않았습니다");
}
uint8_t opcode = mmu->read_byte(pc);
printf("[CPU] DEBUG: opcode 0x%02X (instruction %s) 실행 중, 주소 0x%04X\n", opcode, instructions[opcode].name, pc);
pc++;
uint8_t cycles = (this->*instructions[opcode].operate)();
total_cycles += cycles;
return cycles;
}
void CPU::init_instructions() {
instructions.assign(256, { "XXX", &CPU::XXX });
instructions[0x00] = { "NOP", &CPU::NOP };
instructions[0xC3] = { "JP a16", &CPU::JP_a16 };
instructions[0xAF] = { "XOR A, A", &CPU::XOR_a };
instructions[0x21] = { "LD HL, n16", &CPU::LD_HL_n16 };
instructions[0x0E] = { "LD C, n8", &CPU::LD_C_n8 };
instructions[0x06] = { "LD B, n8", &CPU::LD_B_n8 };
instructions[0x31] = { "LD SP, n16", &CPU::LD_SP_n16 };
instructions[0x32] = { "LD (HL-), A", &CPU::LD_HLmA_dec };
instructions[0x05] = { "DEC B", &CPU::DEC_B };
instructions[0x0D] = { "DEC C", &CPU::DEC_C };
instructions[0x20] = { "JR NZ, e8", &CPU::JR_NZ_e8 };
instructions[0x3E] = { "LD A, n8", &CPU::LD_A_n8 };
instructions[0xF3] = { "DI", &CPU::DI };
instructions[0xFB] = { "EI", &CPU::EI };
}
이 시스템을 선택한 이유는 Cinoop과 GitHub에서 살펴본 몇몇 게임 보이 에뮬레이터들이 비슷하게 하고 있었고, 무엇이 일어나는지 내가 이해하기 가장 쉬웠기 때문이다. 나중에 돌아보면 더 효율적인 방식도 분명 있지만, 코드가 더 읽기 쉬워서 ‘리서치용 에뮬레이터’로는 오히려 더 낫다고 생각한다.
초기 시간 동기화를 위해 SDL의 GetTicks() 기능을 사용했고, 경과 시간이 실제 시스템에서 해당 오퍼코드를 실행하는 데 걸리는 시간보다 짧으면 에뮬레이터가 잠들도록 했다.
// 타이밍 동기화
uint64_t end_time = SDL_GetTicks();
double elapsed_ms = static_cast<double>(end_time - start_time);
if (elapsed_ms < FRAME_TIME_MS) {
// 남은 시간 동안 Sleep
SDL_Delay(static_cast<uint32_t>(FRAME_TIME_MS - elapsed_ms));
}
이 시점에서 에뮬레이터에서 ‘생명 징후’를 보긴 했는데, 아직 구현한 오퍼코드가 거의 없어서 미구현 오퍼코드 에러만 뱉는 수준이었다.
![이미지 9: 터미널 창에서 실행 중인 GameByte 에뮬레이터. 아래쪽에 "[CPU] Illegal/unimplemented opcode 0xC3 at 0x101"라고 표시된다.](https://byteofmelon.com/img/blog/making-of-gamebyte/initial-life.png)
이제 게임 보이 CPU 구현의 본론, 즉 테트리스가 제대로 로드될 만큼 충분한 오퍼코드들을 구현하는, 아주 세세한 작업에 들어갈 시간이었다. 이건 GB Dev의 멋진 optables 없이는 불가능했을 것이다. 이 표는 원시 명령 바이트를 올바른 어셈블리로 변환해 주고, 정확한 에뮬레이션을 위한 t-state 기준 수행 시간도 보여 주며, 실행 시 변경되는 플래그가 있다면 무엇인지도 알려 준다.
대부분의 오퍼코드 구현은 꽤 기본적이라 여기서 다룰 가치가 없지만, 예를 들어
plaintextHALT
(오퍼코드 0x76) 같은 것의 경우 CPU에
plaintexthalted
변수를 추가하고
plaintextmain.cpp
에 약간의 로직도 넣어야 했다.
// 사이클 카운트 초기화 및 HALT 체크
int cycles = 0;
if (!cpu.halted) {
cycles = cpu.step();
} else {
cycles = 4;
}
“오퍼코드”
plaintext0xCB
는 사실 오퍼코드 자체가 아니라 프리픽스(prefix)라고 불리는 것이다. PREFIX는 비트 조작, 회전, 시프트 같은 것들을 처리하는 게임 보이 CPU의 “추가” 오퍼코드에 접근하게 해준다. GB Dev optables에는 프리픽스 오퍼코드 각각이 무엇을 하는지 정확히 나와 있어 정말 유용하다.
나는 이 시스템을 어떻게 구현해야 할지 꽤 오랫동안 심하게 막혔다. 기본 오퍼코드도 이미 잔뜩 구현해야 하는데, 여기서 또 256개를 추가로 전부 써야 하나? 다행히도, 알고 보니 이 명령들은 각 오퍼코드가 아주 조금씩만 달라지는 몇 가지 기본 “카테고리”로 꽤 쉽게 압축할 수 있었다.
// 확장 오퍼코드 구현
uint8_t CPU::execute_cb_instruction(uint8_t opcode) {
// 하위 3비트로 타깃 레지스터 결정
uint8_t* registers[] = { &b, &c, &d, &e, &h, &l, nullptr, &a };
uint8_t target_idx = opcode & 0x07;
// 대부분 CB 명령은 8 사이클이지만, [HL] 연산은 16
uint8_t cycles = (target_idx == 6) ? 16 : 8;
uint8_t value;
// 타깃이 메모리([HL])인지 레지스터인지 체크
if (target_idx == 6) {
value = mmu->read_byte(get_hl());
} else {
value = *registers[target_idx];
}
// 상위 2비트로 카테고리 디코드
switch (opcode >> 6) {
// 시프트 및 로테이트 (0x00 - 0x3F)
case 0x00:
value = handle_cb_shift_rotate(opcode, value);
break;
// BIT (0x40 - 0x7F)
case 0x01:
{
uint8_t bit = (opcode >> 3) & 0x07;
set_flag_z(!(value & (1 << bit)));
set_flag_n(false);
set_flag_h(true);
// BIT는 값을 다시 쓰지 않음
return cycles;
}
// RES (0x80 - 0xBF)
case 0x02:
{
uint8_t bit = (opcode >> 3) & 0x07;
value &= ~(1 << bit);
}
break;
// SET (0xC0 - 0xFF)
case 0x03:
{
uint8_t bit = (opcode >> 3) & 0x07;
value |= (1 << bit);
}
break;
}
// 결과를 다시 기록
if (target_idx == 6) {
mmu->write_byte(get_hl(), value);
} else {
*registers[target_idx] = value;
}
return cycles;
}
uint8_t CPU::handle_cb_shift_rotate(uint8_t opcode, uint8_t value) {
uint8_t sub_op = (opcode >> 3) & 0x07;
bool old_carry = get_flag_c();
switch (sub_op) {
// RLC (왼쪽 회전)
case 0:
set_flag_c(value & 0x80);
value = (value << 1) | (value >> 7);
break;
// RRC (오른쪽 회전)
case 1:
set_flag_c(value & 0x01);
value = (value >> 1) | (value << 7);
break;
// RL (캐리를 통해 왼쪽 회전)
case 2:
set_flag_c(value & 0x80);
value = (value << 1) | (old_carry ? 1 : 0);
break;
// RR (캐리를 통해 오른쪽 회전)
case 3:
set_flag_c(value & 0x01);
value = (value >> 1) | (old_carry ? 0x80 : 0);
break;
// SLA (산술 왼쪽 시프트)
case 4:
set_flag_c(value & 0x80);
value <<= 1;
break;
// SRA (산술 오른쪽 시프트 - 7비트 유지)
case 5:
set_flag_c(value & 0x01);
value = (static_cast<int8_t>(value)) >> 1;
break;
// SWAP (니블 스왑)
case 6:
set_flag_c(false);
value = ((value & 0x0F) << 4) | ((value & 0xF0) >> 4);
break;
// SRL (논리 오른쪽 시프트)
case 7:
set_flag_c(value & 0x01);
value >>= 1;
break;
}
set_flag_z(value == 0);
set_flag_n(false);
set_flag_h(false);
return value;
}
이걸 알아내는 과정은 전혀 즐겁지 않았고, 프리픽스 시스템이 어떻게 동작하는지(그리고 기반이 된 Z80이 어떻게 동작하는지) 엄청나게 조사해야 했다. 그래도 결국엔 깔끔한 해법이 나왔다.
이 글이 이미 너무 길기 때문에 여기서는 과하게 자세히 들어가진 않겠다. 전체 소스 코드는 직접 읽어볼 수 있고, PPU를 아주 자세히 설명하는 Pan Docs 및 다른 유용한 문서들도 참고하면 된다.
간단히 말하면, PPU는 MMU(즉 VRAM이 저장된 곳)에 연결된 뒤 SDL의 비디오 컴포넌트를 초기화하고, 윈도우/렌더러/현재 프레임버퍼 데이터를 담는 텍스처를 만든다. 또한 PPU는 4가지 상태(OAM 검색, 픽셀 전송, H-blank, V-blank)를 오가도록 틱(tick)하며, 이 타이밍들은 프레임버퍼에 올바른 데이터를 넣는 데 필요하다.
스캔라인을 그리기 위해
plaintextdraw_scanline()
함수는 현재 스크롤 위치(전체 256x256 맵 중 프레임이 렌더링되어야 하는 X/Y 좌표)를 가져오고, 타일 맵/타일 인덱스를 읽은 다음, 스캔라인에 배치해야 할 픽셀을 추출해 프레임버퍼(이 경우 SDL 텍스처)에 넣는다.
마침내 렌더링에 성공했다!

좋아, 전혀 쓸 수 있는 수준은 아니지만, 어쨌든 ‘뭔가’가 렌더링되긴 했다! 내가 제대로 뭘 하고 있는지도 잘 몰랐던 상황에서 내 관점에선 정말 대단한 일이었다. 여기서부터 해결해야 할 게 많았다. 예를 들어, 정말 오랫동안 나를 미치게 한 게 있는데, 사실 너무 기본적인 걸 내가 실수해 놓고도 몰랐던 것이다.
이 코드는 굉장히 무해해 보인다.
uint8_t ppu_cycles;
하지만 이 타입은 PPU의, 그러니까 ‘전체 PPU 프레임’을 완료하는 데 필요한 사이클 수를 저장하기엔 너무 작다. 기본적으로 v-blank 단계에 도달하기 전까지 456 사이클까지 올라가야 하는데, 8비트 정수는 255까지만 표현할 수 있다. 이런.
이를 16비트로 바꾸자 제대로 v-blank가 잡혔다.
uint16_t ppu_cycles;

뭐, 어느 정도는. 이걸 해결하려면 훨씬, 훨씬 더 많은 작업이 필요했고 여러 요소가 얽힌 수정이었지만, 가장 중요한 퍼즐 조각은 실행 시작 시 PPU가 올바르게 초기화되어 있어야 한다는 점이었다. 내가 모든 걸 미초기화 상태로 두고 있었기 때문에 VRAM/PPU I/O 레지스터 영역에 랜덤 쓰레기 값이 떠다녔고, 그 결과 완전히 망가져 있었다.
PPU::PPU() {
// 부트 ROM 이후 기본값으로 레지스터 초기화
lcdc = 0x91; // LCD 활성, Window 활성, BG window/tile Data @ $8000
stat = 0x85;
scy = 0x00;
scx = 0x00;
lyc = 0x00;
bgp = 0xFC;
current_ly = 0;
ppu_cycles = 0;
mode = 2; // 기본 - OAM 검색
// 프레임버퍼 클리어
memset(framebuffer, 0, sizeof(framebuffer));
}
나는 의도적으로 부트 ROM을 포함하거나 에뮬레이터에서 재현하지 않았다. 이유는 두 가지다: A) 닌텐도는 매우 소송을 좋아해서, 스위치 에뮬레이터 사태에서 보듯 에뮬레이터 개발자를 고소할 구실을 어떻게든 찾을 것이고, B) 일반적으로 이 방법이 훨씬 쉽기 때문이다.
이 모든 작업 끝에, 마침내 이 영광스러운 장면이 나타났다!

SDL을 다시 활용해 2배 스케일 업을 더 또렷하게 만들기 위해 PPU 초기화에 한 줄을 추가했다.
SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST);

훨씬 낫다!
입력 처리는, 특히 SDL이 있는 덕분에, 솔직히 프로젝트에서 가장 쉬운 부분 중 하나였다. 게임 보이는 JOYP라는 특정 I/O 레지스터를 사용해 조이패드의 현재 상태를 꽤 단순한 방식으로 읽는다. 유일하게 좀 이상한 점은 액티브 로우(active-low)라는 것인데, 즉 OFF(또는
plaintext0
)로 설정된 비트가 실제로는 버튼이 눌린 상태를 의미한다.
그 외에는 Joypad 클래스에 SDL 이벤트를 처리하는 핸들러를 추가하고, 키보드 입력을 JOYP 레지스터로 보낼 올바른 비트 레이아웃으로 변환하기만 하면 됐다. 전체 구현은 여기에서 볼 수 있다.
마침내 이 작업(그리고 부족했던 오퍼코드들을 더 구현한 뒤)을 거쳐, 테트리스를 제대로 플레이할 수 있게 됐다!

이 순간은 엄청난 성취감으로 다가왔다. 물론 이건 이미 수백만 번도 더 만들어진 것들이다. 그리고 게임 보이 에뮬레이션의 선구자들이 했어야 했던 기초 하드웨어 연구를 내가 할 필요도 없었다. 하지만 이건 내가 직접 만든, 여전히 꽤 복잡한 무언가였고, 그 사실만으로도 나는 스스로가 매우 자랑스러웠다.
테트리스는 매우 기본적인 게임이라, 플레이하기 위해 에뮬레이터가 엄청나게 정확할 필요는 없었고 실제로도 그렇지 않았다. SDL의 OS 파일 열기 다이얼로그 기능을 구현해 테스트 ROM을 제대로 사용할 수 있게 된 뒤부터는 더욱 그랬다. c-sp의 Game Boy Test Roms 저장소에는 에뮬레이터 개발자들이 정확도 문제를 더 쉽게 찾아낼 수 있게 해주는 여러 훌륭한 테스트 ROM들이 들어 있다.
나는 다양한 테스트 ROM을 이용해 GameByte의 정확도를 높였지만, 그중 PPU를 고치는 데 특히 도움이 된 하나를 간단히 이야기해 보겠다. Matt Currie의 [```plaintext dmg-acid2
처음 GameByte에서 이 ROM을 열었을 때는 이렇게 보였다.

dmg-acid2의 README는 흔한 실패 사례들과, 각 실패가 어떤 부정확함을 테스트하는지 자세히 알려준다. 예를 들어 위와 같이 눈 흰자 왼쪽 절반이 깨지는 문제는 Object to Background Priority 비트(비트 7)가 제대로 고려되지 않았기 때문인데, 이 비트를 사용해 흰색 부분을 진회색 오브젝트로 교체해야 한다.
이 문제와 다른 모든 에러를 고쳐서, 마침내 완벽하게 렌더링되도록 만들었다.

### 좋아, MBC 하나만 추가하자
내가 좋아하는 또 다른 원조 게임 보이 게임인 슈퍼 마리오 랜드가, MBC1(다행히 첫 번째이자 가장 기본적인 MBC 타입) 미구현 때문에 동작하지 않는다는 걸 깨달았을 때, 마침내 그 하나만이라도 구현하기로 했다. 또한 The Legend of Zelda: Link’s Awakening 같은 게임이 실제 카트리지처럼 저장할 수 있도록, 배터리 백업 RAM 파트까지 포함해 완전히 구현하기로 했다.
MBC1은 512KiB부터 2MiB까지의 ROM과 8KiB부터 32KiB까지의 추가 RAM을 지원하는 메모리 뱅크 컨트롤러다. 특정 메모리 주소에 값을 쓰면, 카트리지의 MBC와 인터페이스하여 현재 사용 중인 ROM/RAM 뱅크를 전환할 수 있다.
MMU는 MBC1 로직의 거의 전부를 담당하며, 배터리 백업 RAM 기능부터 시작한다.
bool MMU::load_save(const char* filename) { std::ifstream file(filename, std::ios::binary); if (!file) return false;
file.read(reinterpret_cast<char*>(eram), sizeof(eram));
file.close();
std::cout << "[MMU] " << filename << "에서 배터리 백업 RAM을 로드했습니다" << std::endl;
return true;
}
bool MMU::save_game(const char* filename) { std::ofstream file(filename, std::ios::binary); if (!file) return false;
file.write(reinterpret_cast<const char*>(eram), sizeof(eram));
file.close();
std::cout << "[MMU] " << filename << "에 배터리 백업 RAM을 저장했습니다" << std::endl;
return true;
}
그리고 실제 ROM 뱅킹 데이터를 읽는 로직이 있는데, 아래는
```plaintext
MMU::read_byte()
의 일부 발췌다.
if (address <= 0x7FFF) {
// 카트리지 ROM
if (rom && rom->data) {
uint8_t type = rom->data[ROM::OFFSET_TYPE];
if (type == ROM::ROM_MBC1 || type == ROM::ROM_MBC1_RAM || type == ROM::ROM_MBC1_RAM_BATT) {
if (address <= 0x3FFF) {
// 모드 1이 선택되지 않으면 뱅크 0
uint8_t bank = 0;
if (mbc1_banking_mode == 1) {
bank = (mbc1_ram_bank << 5);
}
size_t offset = (bank * 0x4000) + address;
return rom->data[offset % rom->size];
} else {
// 뱅크 1-7F(스위칭 가능)
uint8_t bank = mbc1_rom_bank; // 하위 5비트
// 모드 0이면 ram_bank의 상위 2비트를 포함
if (mbc1_banking_mode == 0) {
bank |= (mbc1_ram_bank << 5);
}
size_t offset = (bank * 0x4000) + (address - 0x4000);
return rom->data[offset % rom->size];
}
}
// MBC1이 아닌 ROM은 그냥 직접 읽기
return rom->data[address % rom->size];
}
return cart[address];
}
이것과 몇 가지 다른 부분을 구현한 뒤, 슈퍼 마리오 랜드(따라서 다른 많은 게임들)도 동작하기 시작했고, Link’s Awakening은 실제 카트리지처럼 저장/로드도 제대로 할 수 있게 되었다!

이 에뮬레이터가 완전한 기능의 게임 보이 에뮬레이터가 되지 못하게 하는, 아직 빠져 있는 주요 구성 요소는 세 가지다.
GameByte 자체로는, 특별한 수요가 있지 않는 한 아마 더 발전시키지 않을 것 같다. 이 프로젝트는 로우레벨 컴퓨팅과 C++에 대한 기본 스킬을 쌓기 위한 멋진 방법으로 만들었고, 지금 수준까지 개발하면서 그 목표는 충분히 달성했다. 게다가 사운드는 없지만, 내가 가장 신경 쓰는 게임 보이 게임들은 다 플레이할 수 있다.
또 SameBoy 같은 에뮬레이터는 게임 보이, 심지어 게임 보이 컬러까지 훨씬 정확하게 에뮬레이션할 수 있고, 완전한 디버깅 기능과 멋진 UI까지 갖추고 있다.
하지만 GameByte 이후의 나의 다음 모험은 아직 정해지지 않았다. 에뮬레이터든, OG Xbox나 Wii 같은 콘솔을 위한 홈브루 개발이든, 로우레벨 프로젝트를 계속해 나가고 싶다. 내가 할 만한 프로젝트 아이디어가 있거나, 그런 프로젝트를 운영 중이고 내가 참여하길 원한다면 Bluesky, Discord, X/Twitter로 알려 달라.
GameByte의 뒷이야기를 파고드는 이 덕질 심화 탐구를 즐겼길 바란다! 위 소셜 링크를 통해 이 글에 대한 의견을 들려주고, GameByte 저장소도 여기에서 확인하고 별도 눌러 줬으면 한다!
© Michael Webb, 2025 • Creative Commons Attribution Share Alike 4.0 International 라이선스 • Windows 7 테마는 7.css 사용 • 웹사이트 소스 코드는 GitHub에서 확인