Как легко портировать ассемблер на примере Chip8

Иногда в жизни случаются моменты, когда в голову приходит новая архитектура процессора, и так не терпится ее проверить, насколько легко писать код, насколько компактным и быстрым он получается. И тогда большинство хватается за первый попавшийся любимый язык, и пишет на нем детский ассемблер для своего нового набора инструкций. А потом останавливается.

Почему? Потому что хочется если уж ассемблер, то макросы. А лучше над ассемблером сразу C или еще что покруче. А ассемблер у нас детский, и крэшится при каждой строчке с неправильным синтаксисом. А хочется настоящего.

Portable Assembler

По-моему это хорошая затея. Представьте — в ассемблер входит парсер, макропроцессор, работает сохранение кода в разные форматы (ELF, a.out, intel hex, …). А набор инструкций и их обработка вынесены в отдельные модули. Тогда для портирования под свою архитектуру достаточно всего лишь переписать один модуль (backend).

Когда я задался этой идеей, я смог найти только один portable assembler — vasm.

Итак, ставим задачу. Есть такая платформа — CHIP8. Славится она тем, что она очень простая. В ассемблере всего 35 инструкций. Но это значит, что закодить их у нас не займет много времени.

Для начала, установим vasm. Качаем http://sun.hasenbraten.de/vasm/release/vasm.tar.gz

Допустим наш модуль будет называться c8. Синтаксис будет стандартный (как для x86, intel syntax).

$ make CPU=c8 SYNTAX=std

Поскольку никакого модуля c8 еще нет, то сборка не проходит. Создадим необходимые файлы:

$ mkdir cpus/c8 # папка с проектом
$ touch cpus/c8/cpu.h # определения нашего процессора
$ touch cpus/c8/cpu.c # код генератора
$ cpus/c8/cpu_errors.h # список сообщений об ошибках при компиляции c8-ассемблера

Теперь необходимо реализовать несколько обязательных функций и переменных. Сделаем пока заглушки.

В Makefile вверху добавим (чтобы не указывать каждый раз вручную):

CPU = c8
SYNTAX = std

В cpu.h объявим:

#define VASM_CPU_C8  1 /* Идентификатор нашего CPU */

#define LITTLEENDIAN 1 /* CHIP8 - little endian */ 
#define BIGENDIAN    0

#define MAX_OPERANDS 3 /* Инструкция принимает до 3 команд */
#define MAX_QUALIFIERS 0 /* без квалификаторов (типа mov.l, mov.s...) */

typedef int16_t taddr; /* Адресное пространство - 16-битное */

#define INST_ALIGN 2 /* Выравнивание по 2 байта (не обязательно) */
#define DATA_ALIGN(n) 1 /* Для данных выравнивания нет */

/* Структура операнда (аргумента инструкции) */
typedef struct {
	int type; /* Тип инструкции (см. ниже OP_VREG, OP_IREG...) */
	int reg; /* Номер регистра V0=0, V1=1, ... */
	expr *value; /* Выражение (для IMM-операндов) */
} operand;

/* Типы операндов */
#define OP_VREG   1
#define OP_IREG   3
#define OP_IMM    4
#define OP_KREG   5
#define OP_DTREG  6
#define OP_STREG  7
#define OP_FREG   8
#define OP_HFREG  9
#define OP_IND    10

/* Форматы инструкций */ 
#define OP_nnnn   1
#define OP_nNNN   2
#define OP_nnnN   3
#define OP_nXYn   4
#define OP_nXNN   5
#define OP_nXYN   6
#define OP_nXnn   7

#define DATA_OPERAND(n) OP_IMM /* Данные - это тип OP_IMM */

/* Инструкция - маска (opcode) и формат (optype) */
typedef struct {
	int opcode;
	int optype;
} mnemonic_extension;

И наконец-то: cpu.c

#include "vasm.h"

char *cpu_copyright = "vasm CHIP8 cpu backend";
char *cpuname = "c8";
int bitsperbyte = 8;
int bytespertaddr = 4;

#define ADDRESS_START 0x200
#define ADDRESS_END   0xfff

mnemonic mnemonics[] = { /* TODO */ }; 
int mnemonic_cnt = sizeof(mnemonics)/sizeof(mnemonics[0]);

/* Эти функции для над пока не важны */
int init_cpu() {
	return 1; /* Successful initialization */
}

int cpu_args(char *args) {
	return 0; /* No args */
}

char *parse_cpu_special(char *s) {
	return s; /* No specials */
}

operand *new_operand() {
  operand *op = malloc(sizeof(*op));
  return op;
}

taddr instruction_size(instruction *p, section *sec, taddr pc) {
	return 2; /* все инструкции по 2 байта */
}

/* А вот эти надо будет подправить */

/* Парсер одного операнда - заполняет поля переменной op */
int parse_operand(char *text, int len, operand *op, int requires) {
}
/* Обработка одной инструкции, возвращает байт-код, который ей соответствует */
dblock *eval_instruction(instruction *p, section *sec, taddr pc) {
}
/* Обработка данных */
dblock *eval_data(operand *op, taddr bitsize, section *sec, taddr pc) {
}

Да-да, всего 3 функции — и мы у цели.

Дописываем

Для начала формируем набор мнемоник. Там формат простой — строка, определяющая инструкцию, набор операндов, и экстра-флаги (мы эту структуру экстра-флагов сами в cpu.h объявляли).

Вот что у меня вышло для CHIP-8:

mnemonic mnemonics[] = {
	"high", {}, {0x00ff, OP_nnnn}, /* Set SCHIP graphics mode */
	"low", {}, {0x00fe, OP_nnnn}, /* set CHIP graphics mode */

	"cls", {}, {0x00e0, OP_nnnn}, /* clear screen */

	"exit", {}, {0x00fd, OP_nnnn}, /* exit from the programm */

	"scll", {}, {0x00fc, OP_nnnn}, /* scroll left */
	"sclr", {}, {0x00fb, OP_nnnn}, /* scroll right */
	"scld", {OP_IMM}, {0x00c0, OP_nnnN}, /* scroll screen down */

	"call", {OP_IMM}, {0x2000, OP_nNNN}, /* call */

	"jp", {OP_IMM}, {0x1000, OP_nNNN}, /* jump */
	"jp", {OP_IMM, OP_VREG}, {0xb000, OP_nNNN}, /* jump to addr + v0 */

	"ret", {}, {0x00ee, OP_nnnn}, /* return from subroutine */

	"drw", {OP_VREG, OP_VREG, OP_IMM}, {0xd000, OP_nXYN}, /* draw */

	"se", {OP_VREG, OP_IMM}, {0x3000, OP_nXNN}, /* skip if vx == NN */
	"se", {OP_VREG, OP_VREG}, {0x5000, OP_nXYn}, /* skip if vx == vy */

	"sne", {OP_VREG, OP_IMM}, {0x4000, OP_nXNN}, /* skip if vx != NN */
	"sne", {OP_VREG, OP_VREG}, {0x9000, OP_nXYn}, /* skip if vx != vy */

	"skp", {OP_VREG},  {0xe09e, OP_nXnn}, /* skip if key in vx pressed */
	"sknp", {OP_VREG},  {0xe0a1, OP_nXnn}, /* skip if key in vx not pressed */

	"rnd", {OP_VREG, OP_IMM},  {0xc000, OP_nXNN},

	"add", {OP_VREG, OP_IMM},  {0x7000, OP_nXNN},
	"add", {OP_VREG, OP_VREG}, {0x8004, OP_nXYn},
	"add", {OP_VREG, OP_IREG}, {0xf01e, OP_nXnn}, /* I = I + vx */
	
	"sub", {OP_VREG, OP_VREG}, {0x8005, OP_nXYn},
	"subn",{OP_VREG, OP_VREG}, {0x8007, OP_nXYn},
	"or",  {OP_VREG, OP_VREG}, {0x8001, OP_nXYn},
	"and", {OP_VREG, OP_VREG}, {0x8002, OP_nXYn},
	"xor", {OP_VREG, OP_VREG}, {0x8003, OP_nXYn},

	"shl", {OP_VREG}, {0x800e, OP_nXnn}, /* vx >> 1 */
	"shr", {OP_VREG}, {0x8006, OP_nXnn}, /* vx >> 1 */

	"bcd", {OP_VREG}, {0xf033, OP_nXnn}, /* Store BCD representation at I to vr */

	/* Store registers V0 to [register] from HP Flags where [register] < 8 */
	"hps", {OP_VREG}, {0xf075, OP_nXnn},
	/* Load registers V0 to [register] from HP Flags where [register] < 8 */
	"hpl", {OP_VREG}, {0xf085, OP_nXnn},

	"ld", {OP_VREG, OP_IMM},  {0x6000, OP_nXNN}, /* Move NN to vx */
	"ld", {OP_VREG, OP_VREG}, {0x8000, OP_nXYn}, /* Move vx to vy */
	"ld", {OP_IREG, OP_IMM},  {0xa000, OP_nNNN}, /* Point I to NNN */
	"ld", {OP_FREG, OP_VREG}, {0xf029, OP_nXnn}, /* Point I to lores char in vr */
	"ld", {OP_HFREG, OP_VREG},  {0xf030, OP_nXnn}, /* Point I to hires char in vr */
	"ld", {OP_IND, OP_VREG},   {0xf055, OP_nXnn}, /* Store v0..vr to memory at I */
	"ld", {OP_VREG, OP_IND},   {0xf065, OP_nXnn}, /* Load v0..vr from memory at I */
	"ld", {OP_VREG, OP_DTREG}, {0xf007, OP_nXnn}, /* Store DT into vr */
	"ld", {OP_DTREG, OP_VREG}, {0xf015, OP_nXnn}, /* Store vr into DT */
	"ld", {OP_VREG, OP_STREG}, {0xf018, OP_nXnn}, /* Store vr into ST */
	"ld", {OP_VREG, OP_KREG},  {0xf00a, OP_nXnn}  /* Wait and store key into vr */
};

Тут все делалось по бумажкам — по документации с набором инструкций. Думаю, должно быть понятно, но если что — пишите в комментариях, разъясню подробнее.

В функции parse_operand() надо смотреть на строку, и определять тип операнда. Это могут быть регистры (v0, v1, …vf, i, k, st, dt, lf, hf) или косвенная адресация «[i]», или просто численное выражение. Проверяем все типы:

int parse_operand(char *text, int len, operand *op, int requires) {
	const char hex[] = "0123456789abcdef";
	char *p;
	p = skip(text);
	p[0] = tolower((unsigned char) p[0]);
	if (len > 1) {
		p[1] = tolower((unsigned char) p[1]);
	}

	if (len == 2 && p[0] == 'v' && strchr(hex, p[1]) != 0) {
		op->type = OP_VREG;
		op->reg = strchr(hex, p[1]) - hex;
	} else if (strncmp(p, "i", len) == 0) {
			op->type = OP_IREG;
	} else if (strncmp(p, "k", len) == 0) {
			op->type = OP_KREG;
	} else if (strncmp(p, "dt", len) == 0) {
			op->type = OP_DTREG;
	} else if (strncmp(p, "st", len) == 0) {
			op->type = OP_STREG;
	} else if (strncmp(p, "f", len) == 0) {
			op->type = OP_FREG;
	} else if (strncmp(p, "hf", len) == 0) {
			op->type = OP_HFREG;
	} else if (strncmp(p, "[i]", len) == 0) {
			op->type = OP_IND;
	} else {
		op->type = OP_IMM;
		op->value = parse_expr(&p);
	}
	return (requires == op->type);
}

Теперь когда инструкция разобрана по частям, известны все типа операндов и их значения — можно формировать байт-код этой инструкции. Для этого будет вызвана наша функция eval_instruction():

dblock *eval_instruction(instruction *p, section *sec, taddr pc) {
	int i;
	dblock *db = new_dblock();
	mnemonic *m;
	uint16_t opcode;
	taddr values[MAX_OPERANDS] = {0,0,0};

	m = &mnemonics[p->code];

	opcode = m->ext.opcode;

	db->size = instruction_size(p, sec, pc);
	db->data = malloc(db->size);

	/* вычисляем IMM-операнды */
	for (i = 0; i < MAX_OPERANDS; i++) {
		operand *op = p->op[i];
		if (op != NULL) {
			if (op->value != NULL && op->type == OP_IMM) {
				if (!eval_expr(op->value, &values[i],sec,pc)) {
					symbol *base;
					if (base = find_base(op->value,sec,pc)) {
						int type=REL_ABS,offs=8,size=16;
						rlist *rl;
						rl = add_reloc(&db->relocs,base,values[i],type,size,offs);
					}
				} 
			} else if (op->type == OP_VREG) {
				values[i] = op->reg;
			}
		}
	}
	/* Формируем байт-код по маске и операндам */
	switch (m->ext.optype) {
	case OP_nnnn: break; /* do nothing */
	case OP_nnnN: opcode |= (values[0] & 0xf); break;
	case OP_nNNN: 
			if (p->op[0]->type == OP_IMM) // jp NNN
				opcode |= (values[0] & 0xfff); 
			else // ld I, NNN
				opcode |= (values[1] & 0xfff); 
			break;
	case OP_nXNN: opcode |= ((values[0] & 0xf) << 8)|(values[1] & 0xff); break;
	case OP_nXYN: opcode |= ((values[0] & 0xf) << 8) | ((values[1] & 0xf) << 4)
					| (values[2] & 0xf); break;
	case OP_nXYn: opcode |= ((values[0] & 0xf) << 8) | ((values[1] & 0xf) << 4); 
					break;
	case OP_nXnn: opcode |= ((values[0] & 0xf) << 8); break;
	}

	db->data[0] = opcode >> 8;
	db->data[1] = opcode & 0xff;
	return db;
}

Все. Теперь мы можем собрать нашу первую программу. Пишем файл:

cls
ld v0, 5
ld v1, 0xf0
add v0, v1

Ассемблируем и смотрим на результат:

$ ./vasmc8_std test.c8 -Fbin -o test.bin
$ hexdump test.bin
e000 0560 f061 1480

Фактически это 4 инструкции:

000e # cls
6005 # ld v0, 00
61f0 # ld v1, f0
8014 # add v0, v1

Значит все работает. Осталось доделать обработку сегмента данных:

dblock *eval_data(operand *op, taddr bitsize, section *sec, taddr pc) {
  dblock *new=new_dblock();
  taddr val;
  new->size=(bitsize+7)/8;
  new->data=mymalloc(new->size);
  if (!eval_expr(op->value,&val,sec,pc)&&bitsize!=32)
    cpu_error(0); /* Это будет наше 1-е сообщение об ошибке в cpu_errors.h */
  if (bitsize==8) {
    new->data[0]=val;
  } else if(bitsize==16) {
    new->data[0]=val>>8;
    new->data[1]=val;
  }
  return new;
}

Этот код я взял из других ассемблеров (в состав VASM входят ассемблеры 6502, arm, m68k, x86, c16x, z80, ppc и игрушечный test). Здесь просто парсится выражение, и результат сохраняется как 2 байта в новый блок байт-кодов.

Теперь мы можем скомпилировать любой код для CHIP-8. Например, этот:

	.org 0x200
	ld v0, 0
	ld v1, 0

loop:
	ld I, left
	rnd v2, 1
	se v2, 1
	ld I, right
	drw v0, v1, 4
	add v0, 4
	se v0, 64
	jp loop
	ld v0, 0
	add v1, 4
	se v1, 32
	jp loop

fin:
	jp fin

right:
	.byte 0b10000000
	.byte 0b01000000
	.byte 0b00100000
	.byte 0b00010000

left:
	.byte 0b00100000
	.byte 0b01000000
	.byte 0b10000000
	.byte 0b00010000

Если его собрать и запустить в любом из эмуляторов, то получим лабиринт.

Выводы

Вы не забыли что это мы делали, чтобы научиться портировать ассемблеры на другие платформы?
Так вот, мое мнение — VASM это ужасный продукт с жутким кодом. Начнете его читать — поймете. Хочется чего-то более красивого и высокоуровневого. Можно на том же Go с его красивыми «interface» и объектами. Но для начала сойдет и так.

К слову о CHIP-8 я когда-то делал эмулятор — chipr. Он так и остался непонятым, но у меня все работает и запускаются эти чудные черно-белые игры в 200 байт кода.

Ну и продолжая тему portable compilers — есть язык такой — smallC. Это может стать следующим шагом для создания языков программирования под ваши вымышленные архитектуры.
Изобретайте, проверяйте, улучшайте!

Реклама

One comment on “Как легко портировать ассемблер на примере Chip8

  1. Спасибо за статью, интересная тема.

    Я тоже как-то писал эмулятор Chip8 http://code.google.com/p/emuchip/
    А сейчас думаю написать эмулятор чего-нибудь типа GameBoy или MasterSystem на Go.

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s