Язык Go отпразновал недавно первый год своей жизни. Интерпретатору CHIP-8 стукнуло уже под сорок.
Любителям новых языков и старого железа посвящается этот пост — в нем мы будем писать эмулятор виртуальной машины CHIP-8 на языке Go.
О том, как настроить окружение для работы с Go писали уже не раз. За последнее время мало что изменилось, разве что версия для Windows стала более стабильной.
Установив все согласно инструкциям, приступаем к изучению внутренностей CHIP-8.
Игровые приставки на основе CHIP-8 примечательны тем, что являются одними из первых виртуальных машин в истории.
Программы для CHIP-8 выполняются не на реальном процессоре, а интерпретируются. При чем оригинальный интерпретатор занимал всего 512 байт.
Характеристики CHIP-8 впечатляюще скромные: 8-битный процессор частотой в пару мегагерц, 4 Кб ОЗУ (код программы хранится также в оперативной памяти), монохромный экран 32х64 пискеля, два таймера — один для отсчета времени, второй для воспроизведения звука («пищалки»).
Несмотря на всю ущербность, мощностей CHIP-8 хватало, чтобы запустить Space Invaders, Pong и другие олдскульные игры.
Программы для CHIP-8 написаны на специфичном ассемблере. Весь язык состоит из 35 комманд — арифметика, условные/безусловные переходы, ввод/вывод (работа с дисплеем, клавиатурой, звуком).
Наш эмулятор будет состоять из единственного файла c8emu.go…
… и Makefile:
Чтобы было легче разбираться в исходниках, приведенных ниже, напомню, что:
Конечно, особенностей у языка намного больше, и все они кажутся непривычными, но не судите строго. Язык просто другой.
Итак, для эмуляции CHIP-8 нам нужен соответствующий класс (в Go классов нет, но есть структуры с полями и методы для работы со стркутурой):
Эта структура описывает внутренности CHIP-8: блок памяти, 16 восьмибитных регистров, регистр-указатель I, стек на 16 уровней вложенности, а также счетчик команд и стековый указатель.
Для создания нашего объекта CHIP-8 напишем функцию
Для загрузки кода программы в интерпретатор CHIP-8 пишем метод Load(). Методы в Go описываются аналогично функциям:
Метод Step() позволит выполнять отдельную инструкцию CHIP-8. Все инструкции 2х-байтные. Опкоды более-менее структурированы, хотя конечно не как в ARM… В основном ориентироваться можно по старшим 4 битам кода.
Большинство инструкций довольно тривиальны:
Аналогично обрабатываются арифметические и логические инструкции.
Обработка вызова процедур и выхода из них тоже довольно выглядят несложно:
Все инструкции я описывать здесь не буду. Остановлюсь еще на трех самых важных (они используются практически в любой игре) — работа с таймером и вывод изображений на экран.
Таймер работает на частоте 60 Гц, в него можно занести число (один байт), и оно с каждым «тиканьем» будет уменьшаться на 1.
Таймер можно периодически считывать и смотреть сколько «тиков» прошло с момента его запуска. Ниже нуля значение таймера уйти не может.
Вот что получается:
В структуру Chip8 добавим поле таймера и будем инициализировать его при создании объекта Chip8:
Есть всего одна инструкция для вывода изображения на экран. В CHIP-8 графика основана на понятии спрайта. Все спрайты одинаковой ширины — 8 пикселей, отличаются только высотой.
Например спрайт, рисующий крестик выглядит как последовательность пяти байт:
Перед выводом надо указать адрес начала спрайта, установив соответствующим образом значение регистра I.
Чтобы вывести спрайт на экран, надо занести координаты в любые два регистра и вызывать инструкцию draw, в которой указать высоту спрайта:
Но draw не просто рисует, она делает XOR существующих пикселей с пикселями спрайта. Это удобно — чтобы стереть спрайт его можно вывести повторно в тех же координатах.
Кроме того, если какой-то из пикселей был сброшен в 0, draw устанавливает значение регистра vf (обычно использующийся как регистр флагов) в единицу.
Добавим в структуру Chip8 массив «видеобуфера»: screen [64*32]bool и напишем функцию для рисования:
Чтобы хоть как-то протестировать получившийся эмулятор, я выводил содержимое видеобуфера прямо в терминал. Использовалась эта программа. К моему удивлению она заработала и начала рисовать бегающие крестики-нолики:
Вообще мне очень нравится платформа CHIP-8. Никакой практической пользы, но мозги тренировать на ней можно. Я начал проект c8kit — планирую включить в него эмулятор, ассемблер и дизассемблер.
Графику и клавиатуру думаю прикрутить с помощью SDL (Go его успешно поддерживает). Синхронизировать модуль интерфейса и ядро CHIP-8 было бы удобно с помощью каналов Go.
Надеюсь, будет интересно!
Любителям новых языков и старого железа посвящается этот пост — в нем мы будем писать эмулятор виртуальной машины CHIP-8 на языке Go.
О том, как настроить окружение для работы с Go писали уже не раз. За последнее время мало что изменилось, разве что версия для Windows стала более стабильной.
Установив все согласно инструкциям, приступаем к изучению внутренностей CHIP-8.
История
Игровые приставки на основе CHIP-8 примечательны тем, что являются одними из первых виртуальных машин в истории.
Программы для CHIP-8 выполняются не на реальном процессоре, а интерпретируются. При чем оригинальный интерпретатор занимал всего 512 байт.
Характеристики CHIP-8 впечатляюще скромные: 8-битный процессор частотой в пару мегагерц, 4 Кб ОЗУ (код программы хранится также в оперативной памяти), монохромный экран 32х64 пискеля, два таймера — один для отсчета времени, второй для воспроизведения звука («пищалки»).
Несмотря на всю ущербность, мощностей CHIP-8 хватало, чтобы запустить Space Invaders, Pong и другие олдскульные игры.
Программы для CHIP-8 написаны на специфичном ассемблере. Весь язык состоит из 35 комманд — арифметика, условные/безусловные переходы, ввод/вывод (работа с дисплеем, клавиатурой, звуком).
Структура проекта
Наш эмулятор будет состоять из единственного файла c8emu.go…
// Так выглядит "скелет" программы на Go
package main
func main() {
}
… и Makefile:
# Стандартный Makefile для языка Go
include $(GOROOT)/src/Make.inc
TARG=c8emu
GOFILES=c8emu.go
include $(GOROOT)/src/Make.cmd
Чтобы было легче разбираться в исходниках, приведенных ниже, напомню, что:
- точки с запятыми в Go необязательны, круглые скобки в операторах for/if — тоже. В остальном язык похож на C/Java. Переменные в Go можно объявить несколькими способами (да, шиворот-навыворот по сравнению с C):
var i int
var i int = 0
i := 0 - для циклов есть только один оператор — for
for i:=0; i<10; i++ { .. }
for cond == true { ... }
for { } - выполнять действия и проверять его результат можно в одном операторе:
if err = DoSomethin(); err != nil { fmt.Println("Error: "+err) }
- private/public методы и аттрибуты отличаются только регистром:
someMethod() // приватный
SomeMethod() // публичный
Конечно, особенностей у языка намного больше, и все они кажутся непривычными, но не судите строго. Язык просто другой.
Итак, для эмуляции CHIP-8 нам нужен соответствующий класс (в Go классов нет, но есть структуры с полями и методы для работы со стркутурой):
type Chip8 struct {
memory []byte // memory address is in range: 0x200...0xfff
regs [16]byte // CHIP-8 has 16 8-bit registers
ireg uint16 // I-reg was a 16-bit register for memory operations
stack [16]uint16 // Stack up to 16 levels of nesting
sp int // Stack pointer
pc uint16 // Program counter
}
Эта структура описывает внутренности CHIP-8: блок памяти, 16 восьмибитных регистров, регистр-указатель I, стек на 16 уровней вложенности, а также счетчик команд и стековый указатель.
Инициализация эмулятора
Для создания нашего объекта CHIP-8 напишем функцию
NewChip8()
:
func NewChip8() (c *Chip8) {
c = new(Chip8); // выделяем память для объекта
c.memory = make([]byte, 0xfff) // создаем "слайс" (аналог массива) для памяти
c.sp = 0
c.pc = 0x200 // Программы в CHIP-8 стартуют с адреса 0x200
c.ireg = 0
// тут еще можно обнулить память и регистры
return c
}
Для загрузки кода программы в интерпретатор CHIP-8 пишем метод Load(). Методы в Go описываются аналогично функциям:
func (c *Chip8) Load(rom []byte) (err os.Error) {
if len(rom) > len(c.memory) - 0x200 { // функция len возвращает размер массива (кол-во элементов)
err = os.NewError("ROM is too large!") // чем-то напоминает механизм исключений
return
}
copy(c.memory[0x200:], rom) // копируем программу по адресу 0х200
err = nil
return
}
Обработка инструкций
Метод Step() позволит выполнять отдельную инструкцию CHIP-8. Все инструкции 2х-байтные. Опкоды более-менее структурированы, хотя конечно не как в ARM… В основном ориентироваться можно по старшим 4 битам кода.
func (c *Chip8) Step() (err os.Error) {
var op uint16
// если вышли за границы памяти
if (c.pc >= uint16(len(c.memory))) {
err = os.EOF
return
}
// получили текущий код операции
op = (uint16(c.memory[c.pc]) << 8) | uint16(c.memory[c.pc + 1])
switch (op & 0xf000) >> 12 {
case ... /* Самый интересный кусочек я пока пропущу */
default:
return os.NewError("Illelal instruction")
}
c.pc += 2
return
}
Большинство инструкций довольно тривиальны:
// JMP addr - jump to address
case 0x1:
c.pc = op & 0xfff
return
// SKEQ reg, value - skip if register equals value
case 0x3:
if c.regs[(op & 0x0f00) >> 8] == byte(op & 0xff) {
c.pc += 2 // skip one instruction
}
// SKNE reg, value - skip if not equal
case 0x4:
if c.regs[(op & 0x0f00) >> 8] != byte(op & 0xff) {
c.pc += 2 // skip one instruction
}
// MOV reg, value
case 0x6:
c.regs[(op & 0x0f00) >> 8] = byte(op & 0xff)
// MVI addr - задать значение регистра-указателя I
case 0xa:
c.ireg = op & 0xfff
// RAND reg, max - занести в регистр случайное число 0..max
case 0xc:
c.regs[(op & 0x0f00) >> 8] = byte(rand.Intn(int(op & 0xff)))
Аналогично обрабатываются арифметические и логические инструкции.
Обработка вызова процедур и выхода из них тоже довольно выглядят несложно:
// RET - выход из процедуры (код 00EE)
case 0x0:
switch op & 0xff {
case 0xee:
c.sp--
c.pc = c.stack[c.sp]
return
}
// CALL addr - Вызов процедуры по адресу
case 0x2:
c.stack[c.sp] = c.pc + 2
c.sp++
c.pc = op & 0xfff
return
Все инструкции я описывать здесь не буду. Остановлюсь еще на трех самых важных (они используются практически в любой игре) — работа с таймером и вывод изображений на экран.
Таймер
Таймер работает на частоте 60 Гц, в него можно занести число (один байт), и оно с каждым «тиканьем» будет уменьшаться на 1.
Таймер можно периодически считывать и смотреть сколько «тиков» прошло с момента его запуска. Ниже нуля значение таймера уйти не может.
Вот что получается:
type Timer struct {
value byte // значение таймера в момент запуска
start int64 // время запуска
period int64 // временные задержки между "тиками"
}
// Создаем новый таймер
func NewTimer(hz int) (t *Timer) {
t = new(Timer)
t.period = int64(1000000000/hz)
t.value = 0
return t
}
// Запускаем
func (t *Timer) Set(value byte) {
t.value = value
t.start = time.Nanoseconds()
}
// Считываем значение
func (t *Timer) Get() byte {
delta := (time.Nanoseconds() - t.start) / t.period
if int64(t.value) > delta {
return t.value - byte(delta)
}
return 0
}
В структуру Chip8 добавим поле таймера и будем инициализировать его при создании объекта Chip8:
type Chip8 struct {
...
timer *Timer
...
}
func NewChip8() (c *Chip8) {
...
c.timer = NewTimer(60)
..
}
case 0xf:
switch (op & 0xff) {
case 0x07: // получить значение таймера
c.regs[(op & 0x0f00) >> 8] = c.timer.Get()
case 0x15: // запустить таймер
c.timer.Set(c.regs[(op & 0x0f00) >> 8])
}
Дисплей
Есть всего одна инструкция для вывода изображения на экран. В CHIP-8 графика основана на понятии спрайта. Все спрайты одинаковой ширины — 8 пикселей, отличаются только высотой.
Например спрайт, рисующий крестик выглядит как последовательность пяти байт:
0x88 ; 10001000
0x50 ; 01010000
0x20 ; 00100000
0x50 ; 01010000
0x88 ; 10001000
Перед выводом надо указать адрес начала спрайта, установив соответствующим образом значение регистра I.
Чтобы вывести спрайт на экран, надо занести координаты в любые два регистра и вызывать инструкцию draw, в которой указать высоту спрайта:
mvi x_sprite
mov v0, 10
mov v1, 15
draw v0, v1, 5 ; рисуем спрайт высотой 5 строк в точке (10,15)
x_sprite:
db 0x88, 0x50, 0x20, 0x50, 0x88
Но draw не просто рисует, она делает XOR существующих пикселей с пикселями спрайта. Это удобно — чтобы стереть спрайт его можно вывести повторно в тех же координатах.
Кроме того, если какой-то из пикселей был сброшен в 0, draw устанавливает значение регистра vf (обычно использующийся как регистр флагов) в единицу.
Добавим в структуру Chip8 массив «видеобуфера»: screen [64*32]bool и напишем функцию для рисования:
c.regs[0xf] = 0
for col:=0; col<8; col++ {
for row:=0; row<int(size); row++ {
px := int(x) + col
py := int(y) + row
bit := (c.memory[c.ireg + uint16(row)] & (1 << uint(col))) != 0
if (px < 64 && py < 32 && px >= 0 && py >= 0) {
src := c.screen[py*64 + px]
dst := (bit != src) // Да, оператор XOR с булевыми значениями не работает
c.screen[py*64 + px] = dst
if (src && !dst) {
c.regs[0xf] = 1
}
}
}
}
Чтобы хоть как-то протестировать получившийся эмулятор, я выводил содержимое видеобуфера прямо в терминал. Использовалась эта программа. К моему удивлению она заработала и начала рисовать бегающие крестики-нолики:
Что дальше?
Вообще мне очень нравится платформа CHIP-8. Никакой практической пользы, но мозги тренировать на ней можно. Я начал проект c8kit — планирую включить в него эмулятор, ассемблер и дизассемблер.
Графику и клавиатуру думаю прикрутить с помощью SDL (Go его успешно поддерживает). Синхронизировать модуль интерфейса и ядро CHIP-8 было бы удобно с помощью каналов Go.
Надеюсь, будет интересно!