Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

설정

이 프로젝트의 최종 목표는 데스크톱과 웹 브라우저 모두를 대상으로 빌드할 수 있는 에뮬레이터를 만드는 것이므로, 프로젝트 구조를 조금 특이하게 잡겠습니다. 두 빌드에서 Chip-8 시스템의 실제 에뮬레이션은 완전히 같아야 하며, 파일 읽기나 화면 표시 같은 부분만 데스크톱과 브라우저에서 달라집니다. 이를 위해 제가 코어라고 부를 백엔드를 별도의 크레이트로 만들어, 앞으로 만들 두 프런트엔드가 모두 사용하게 하겠습니다.

프로젝트를 저장할 폴더로 이동한 다음 아래 명령을 실행하십시오. $는 입력하지 마십시오. 이는 단지 터미널 명령어임을 나타내기 위한 것입니다.

$ cargo init chip8_core --lib

--lib 플래그는 cargo에게 실행 가능한 모듈이 아닌 라이브러리를 만들라고 지시합니다. 에뮬레이션 백엔드는 이곳에 들어갑니다. 이 프로젝트에서는 chip8_core라고 이름 붙였지만, 원하는 이름을 자유롭게 붙여도 됩니다.

프런트엔드는 해당 코드를 담을 크레이트를 하나 더 만들겠습니다.

$ cargo init desktop

이번에는 코어와 달리 실제로 실행할 수 있는 프로젝트가 만들어집니다. 제대로 진행했다면 이제 폴더 구조가 다음과 같을 것입니다.

이제 남은 일은 desktop 프런트엔드에 chip8_core의 위치를 알려 주는 것뿐입니다. desktop/Cargo.toml을 열고 [dependencies] 아래에 다음 줄을 추가하십시오.

chip8_core = { path = "../chip8_core" }

지금은 chip8_core가 비어 있으므로 실제로 추가되는 것은 없지만, 언젠가는 해야 할 작업입니다. 모든 것이 제대로 작동하는지 확인하기 위해 프로젝트를 컴파일하고 실행해 보십시오. desktop 디렉터리 안에서 다음을 실행하십시오.

$ cargo run

모든 설정이 올바르게 되었다면 “Hello, world!“가 출력됩니다. 좋습니다! 다음으로는 CPU 에뮬레이션을 시작하여 조금 더 흥미로운 것을 만들어 보겠습니다.

에뮬레이터 정의하기

시스템에서 가장 근본적인 부분은 CPU이므로, 에뮬레이터를 만들 때도 CPU부터 시작하겠습니다. 당분간은 주로 chip8_core 백엔드를 개발하고, 이 작업이 충분히 진행되면 다시 돌아와 프런트엔드를 만들겠습니다. 먼저 chip8_core/src/lib.rs 파일에서 작업을 시작하겠습니다(자동으로 생성된 test 코드는 삭제해도 됩니다). 기능을 추가하기 전에 앞으로 할 작업의 몇 가지 개념을 다시 짚어 봅시다.

에뮬레이션이란 원래 다른 시스템용으로 작성된 프로그램을 실행하는 것일 뿐이므로, 현대의 컴퓨터 프로그램이 실행되는 방식과 매우 비슷하게 동작합니다. 일반적인 프로그램을 실행하면 코드 한 줄을 읽고, 컴퓨터가 이를 변수 수정, 비교, 다른 코드 줄로의 점프 같은 작업으로 해석합니다. 그다음 해당 동작을 수행하고, 실행은 다음 줄로 넘어가 이 과정을 반복합니다. 컴파일러를 공부해 보았다면 알겠지만, 시스템은 코드를 한 줄씩 실행하는 것이 아니라 먼저 코드를 프로세서가 이해하는 명령어로 변환한 뒤, 그 명령어에 대해 이 루프를 수행합니다. 우리 에뮬레이터도 정확히 이렇게 동작합니다. 게임 프로그램을 값 하나씩 순회하면서 그 위치에 저장된 명령어를 가져오고(fetch), 수행해야 할 연산을 디코딩(decode)한 다음, 이를 실행(execute)하고 나서 다음 값으로 넘어갑니다. 이 가져오기-디코딩-실행(fetch-decode-execute) 루프가 CPU 에뮬레이션의 핵심이 됩니다.

이 점을 염두에 두고, 에뮬레이터를 관리할 클래스를 정의하는 것부터 시작합시다. lib.rs에 비어 있는 새 구조체를 추가하겠습니다:

#![allow(unused)]
fn main() {
pub struct Emu {
}
}

이 구조체는 에뮬레이션 백엔드의 주 객체가 되므로, 실제 게임 실행을 처리해야 하며 필요한 정보(화면에 표시되는 내용, 버튼 입력 등)를 프런트엔드와 주고받을 수 있어야 합니다.

프로그램 카운터

그렇다면 Emu 객체에는 무엇을 넣어야 할까요? 앞서 설명했듯이, 프로그램은 게임의 어느 부분을 현재 실행하고 있는지 알아야 합니다. 모든 CPU는 현재 명령어의 인덱스를 유지하는 간단한 방법으로 이를 해결하며, 이 인덱스는 프로그램 카운터(줄여서 PC)라고 불리는 특별한 레지스터에 저장됩니다. 이는 루프의 fetch 단계에서 핵심적인 역할을 하며, 게임이 실행됨에 따라 증가하면서 게임을 따라 진행하고, 일부 명령어에 의해 직접 수정될 수도 있습니다(코드의 다른 부분으로 점프하거나 서브루틴을 호출하는 경우 등). 이것을 구조체에 추가합시다:

#![allow(unused)]
fn main() {
pub struct Emu {
    pc: u16,
}
}

RAM

새 명령어가 필요할 때마다 게임 파일에서 읽어 올 수도 있겠지만, 이 방법은 매우 느리고 비효율적이며, 실제 시스템이 동작하는 방식도 아닙니다. 대신 Chip-8은 게임 프로그램 전체를 자체 RAM 공간에 복사하도록 설계되어 있으며, 그 후 필요에 따라 그곳에서 읽고 쓸 수 있습니다. 참고로 Game Boy 등 많은 시스템에서는 게임이 저장된 메모리 영역을 CPU가 덮어쓸 수 없게 되어 있습니다(버그 때문에 게임 코드가 완전히 망가지는 일은 원치 않을 것입니다). 하지만 Chip-8에는 그러한 제약이 없습니다. Chip-8은 물리적인 시스템으로 존재한 적이 없으므로, 메모리를 얼마나 가져야 하는지에 대한 공식 표준이 없습니다. 하지만 원래 4096바이트(4 KB)의 RAM을 갖춘 컴퓨터에서 구현되도록 설계되었으므로, 우리도 그만큼의 메모리를 주겠습니다. 대부분의 프로그램은 이를 다 쓰는 데 근접하지도 않겠지만, 필요할 경우를 위해 마련해 두는 것입니다. 이를 프로그램에 정의합시다.

#![allow(unused)]
fn main() {
const RAM_SIZE: usize = 4096;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
}

}

디스플레이

Chip-8은 64x32 흑백 디스플레이(픽셀당 1비트)를 사용합니다. 이 디스플레이에 특별한 점은 별로 없지만, 백엔드에서 여러 프런트엔드와 사용자가 접근할 수 있어야 하는 몇 안 되는 요소 중 하나입니다. 많은 시스템과 달리 Chip-8은 매 프레임을 다시 그리기 위해 화면을 자동으로 지우지 않으며, 대신 화면 상태가 유지되고 그 위에 새 스프라이트가 그려집니다(다만 화면을 지우는 명령은 있습니다). 이 화면 데이터는 Emu 객체 안의 배열에 보관할 수 있습니다. 또한 Chip-8은 검은색과 흰색 두 가지 색만 다루면 되므로 대부분의 시스템보다 단순합니다. 1비트 디스플레이이므로, 다음과 같이 불리언 배열을 저장하기만 하면 됩니다:

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
}

}

또한 앞서 정의한 상수와 달리, 화면 크기는 공개 상수로 정의했다는 점을 알 수 있을 것입니다. 이는 프런트엔드가 실제로 화면을 그리는 데 필요한 몇 안 되는 정보 중 하나입니다.

V 레지스터

시스템이 사용할 수 있는 RAM이 꽤 많기는 하지만, RAM 접근은 일반적으로 상당히 느린 것으로 여겨집니다(그래도 디스크에서 읽는 것보다는 몇 자릿수나 빠릅니다). 속도를 높이기 위해 Chip-8은 게임이 훨씬 빠른 연산을 위해 자유롭게 사용할 수 있는 8비트 레지스터 16개를 정의합니다. 이들은 V 레지스터라고 불리며, 보통 V0부터 VF까지 16진수로 번호가 매겨집니다(솔직히 V가 무엇의 약자인지는 잘 모르겠습니다). 우리는 이들을 Emu 구조체 안의 배열 하나로 묶어 두겠습니다.

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
    v_reg: [u8; NUM_REGS],
}

}

I 레지스터

RAM에서 읽고 쓸 위치를 가리키는 데 사용하는 I 레지스터라는 16비트 레지스터도 하나 더 있습니다. 이것이 어떻게 동작하는지에 대한 자세한 내용은 나중에 다루겠으며, 지금은 이 레지스터를 갖추기만 하면 됩니다.

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
    v_reg: [u8; NUM_REGS],
    i_reg: u16,
}

}

스택

CPU에는 작은 스택도 있는데, 이는 CPU가 읽고 쓸 수 있는 16비트 값의 배열입니다. 스택은 “후입선출(Last In, First Out, LIFO)” 원칙에 따라서만 읽고 쓸 수 있다는 점에서 일반 RAM과 다르며(팬케이크 더미처럼 말입니다!), 값을 꺼낼(pop) 때에는 마지막으로 넣은(push) 값을 꺼내게 됩니다. 많은 시스템과 달리, 이 스택은 범용이 아닙니다. 스택을 사용할 수 있는 때는 서브루틴에 진입하거나 서브루틴에서 빠져나올 때뿐이며, 이때 스택은 어디에서 시작했는지를 기억해 두었다가 루틴이 끝난 뒤 그곳으로 돌아갈 수 있도록 하는 데 사용됩니다. 이번에도 Chip-8은 스택에 숫자를 몇 개까지 담을 수 있는지 공식적으로 명시하지 않지만, 에뮬레이션 개발자들은 보통 16개를 사용합니다. 스택을 구현하는 방법은 여러 가지가 있는데, 아마 가장 쉬운 방법은 Rust 표준 라이브러리의 std::collections::VecDeque 객체를 사용하는 것입니다. 이 방법은 데스크톱 전용 빌드에서는 잘 동작하지만, 이 글을 쓰는 시점에는 std 라이브러리의 많은 항목이 WebAssembly 빌드에서 동작하지 않습니다. 대신 고전적인 방식으로, 크기가 고정된 배열과 함께 스택의 맨 위가 어디인지 알려 주는 인덱스를 사용하겠습니다. 이 인덱스를 스택 포인터(SP)라고 합니다.

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
    v_reg: [u8; NUM_REGS],
    i_reg: u16,
    sp: u16,
    stack: [u16; STACK_SIZE],
}

}

키

Chip-8은 놀랍게도 16개나 되는 서로 다른 키를 지원하며, 이 키들에는 보통 16진수로 0부터 9, A부터 F까지 번호가 매겨져 있습니다. 키는 전화기 자판과 비슷하게 배열되어 있어 A와 B는 0의 양옆에, C부터 F까지는 오른쪽 열에 놓이며, 전체적으로 4x4 격자를 이룹니다.

어떤 키가 눌려 있는지 추적해야 하므로, 불리언 배열을 사용하여 각 키의 상태를 추적하면 됩니다.

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
    v_reg: [u8; NUM_REGS],
    i_reg: u16,
    sp: u16,
    stack: [u16; STACK_SIZE],
    keys: [bool; NUM_KEYS],
}

}

타이머

한꺼번에 다룰 내용이 많았지만, 이제 마지막 항목에 이르렀습니다. Chip-8에는 타이머로 사용하는 특수 레지스터가 두 개 더 있습니다. 첫 번째인 지연 타이머는 시스템에서 일반적인 타이머로 사용되며, 매 사이클마다 값이 줄어들다가 0에 도달하면 어떤 동작을 수행합니다. 반면 사운드 타이머도 매 클록 사이클마다 값이 줄어들지만, 0에 도달하면 소리를 냅니다. 나중에 보겠지만, 사운드 타이머를 0으로 설정하는 것이 Chip-8에서 소리를 내는 유일한 방법입니다. 둘 다 8비트 레지스터이며, 다음으로 넘어가려면 반드시 지원해야 합니다.

#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;

const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;

pub struct Emu {
    pc: u16,
    ram: [u8; RAM_SIZE],
    screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
    v_reg: [u8; NUM_REGS],
    i_reg: u16,
    sp: u16,
    stack: [u16; STACK_SIZE],
    keys: [bool; NUM_KEYS],
    dt: u8,
    st: u8,
}
}

초기화

지금은 이 정도면 충분합니다. 다음 부분으로 넘어가기 전에 이 클래스의 new 생성자를 구현해 봅시다. struct 정의 다음에 기본값을 설정하는 코드를 구현하겠습니다.

#![allow(unused)]
fn main() {
// -- Unchanged code omitted --

const START_ADDR: u16 = 0x200;

// -- Unchanged code omitted --

impl Emu {
    pub fn new() -> Self {
        Self {
            pc: START_ADDR,
            ram: [0; RAM_SIZE],
            screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
            v_reg: [0; NUM_REGS],
            i_reg: 0,
            sp: 0,
            stack: [0; STACK_SIZE],
            keys: [false; NUM_KEYS],
            dt: 0,
            st: 0,
        }
    }
}
}

모든 것이 꽤 간단해 보입니다. 모든 값과 배열을 0으로 초기화하기만 하면 됩니다… 다만 프로그램 카운터는 예외로, 0x200(10진수로 512)으로 설정됩니다. 그 이유는 이전 장에서 언급했지만, 에뮬레이터는 프로그램이 어디에서 시작하는지 알아야 하며, 모든 Chip-8 프로그램은 RAM 주소 0x200부터 적재된다는 것이 Chip-8의 표준입니다. 이 숫자는 앞으로도 다시 나오므로 상수로 정의해 두었습니다.

이것으로 이번 부분을 마칩니다! 에뮬레이터의 기반이 마련되었으니, 이제 실행 부분을 구현하기 시작할 수 있습니다!