Язык Go. Пишем эмулятор CHIP-8

Язык 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…
// Так выглядит "скелет" программы на 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
				}
			}
		}
	}

Чтобы хоть как-то протестировать получившийся эмулятор, я выводил содержимое видеобуфера прямо в терминал. Использовалась эта программа. К моему удивлению она заработала и начала рисовать бегающие крестики-нолики:
image

Что дальше?


Вообще мне очень нравится платформа CHIP-8. Никакой практической пользы, но мозги тренировать на ней можно. Я начал проект c8kit — планирую включить в него эмулятор, ассемблер и дизассемблер.
Графику и клавиатуру думаю прикрутить с помощью SDL (Go его успешно поддерживает). Синхронизировать модуль интерфейса и ядро CHIP-8 было бы удобно с помощью каналов Go.
Надеюсь, будет интересно!
Поделиться публикацией

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое