Как легко портировать ассемблер на примере 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. Это может стать следующим шагом для создания языков программирования под ваши вымышленные архитектуры.
Изобретайте, проверяйте, улучшайте!

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

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

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

Ответить на Mashin Отменить ответ