Недавно мне пришла в голову мысль взяться за старое, а именно, продолжить исследовать возможность написания собственного транслятора под собственный язык программирования. Но, первая моя попытка создания собственного языка программирования потерпела крах: я, к сожалению, потерял часть первоначального проекта по созданию лисп-подобного языка, а начинать с нуля после публикации статьи про создание языка было бы бессмысленно.
Новый импульс я получил после того, как наткнулся на нечто интересное и действительно захватывающее…
Как-то давно я интересовался забавным языком программирования под названием Forth.
Данный язык программирования славится тем, что имеет крайне простую грамматику (описание ее предельно просто), а также Forth известен еще как и один из успешных стековых языков программирования. Более того, этот язык программирования интересен еще как язык промежуточного типа – его транслятор, не является ни компилятором, ни интерпретатором, а занимает некое среднее положение (данный факт, хотя и несколько сомнителен, но определенно интересен). Именно, такое сочетание весьма любопытных свойств мотивировало меня на попытку написания своего Forth-транслятора, причем настолько, что я захотел изучить ряд реализаций и просто немного почитать о данном языке…
Моя попытка реализации Forth-транслятора не привела к успеху, поскольку у меня в голове так и не сложился целостный подход к его алгоритму, и не было ключевой идеи о том, как будет разбираться код на Forth и во что он будет транслироваться. В бесчисленных пробах и ошибках, я начал отчаиваться, просеивая разного рода и качества материал, и тут внезапно я натыкаюсь на очень интересную статью про Forth-процессор! И вот тут я реально загорелся идеей своего виртуального процессора на D!
Я читал статью про J1 – Forth-процессор, который написан на Verilog (занимает около 200 строчек кода) и который является стековым процессором. Данный процессор устроен весьма просто: два стека, способные поместить в себя 33 (стек данных) и 32 (стек возвратов) 16-разрядных элемента, небольшая память под 16-разрядные беззнаковые числа. Никаких внутренних регистров, никакого запутанного АЛУ, никакого скрытого кэша!
J1, как процессор, поддерживает несколько различных типов инструкций и они также являются очень простыми. Всего инструкций, которые понимает J1, 5: загрузка литерала (т.е. числового беззнакового 16-разрядного значения) в стек данных, переход на заданный адрес в памяти, условный переход на заданный адрес в памяти, вызов процедуры по заданному адресу памяти и инструкции АЛУ (т.е. арифметико-логического устройства).
Инструкции для процессора представляют собой 16-разрядные числа без знака, в которых конкретные биты, как флаги, указывают тип команды процессора. Выглядят инструкции следующим образом:

Схема понимается достаточно просто:
- Литерал (literal). 15-бит устанавливается в 0, все остальные биты занимает само число, которое будет загружено в стек данных.
- Безусловный переход (jump). Три старших бита устанавливаются в 0, остальные биты занимает число, которое указывает адрес перехода.
- Условный переход (conditional jump). 2 старших бита установлены в 0, 13 бит установлен в 1, остальные биты занимает число, указывающее адрес перехода. Переход на адрес будет осуществлен только в том случае, если на вершине стека данных лежит 0.
- Вызов процедуры (call). 15-ый бит установлен в 0, 14-ый – в 1, 13-ый бит установлен в 0, остальные биты занимает число, указывающее адрес, по которому расположена процедура.
- Инструкции АЛУ (АЛУ). Вот тут остановимся подробнее ниже.
Инструкции АЛУ также представляют собой 16-разрядные беззнаковые числа, но имеют больше значимых битовых полей, по сравнению, с другими командами:
- Бит 15. Установлен в 0.
- Биты 14 и 13. Установлены в 1.
(таким образом, формируется префикс-указание команды АЛУ) - Бит 12 (флаг R->PC и RPC). Если установлен а 1, то число с вершины стека возвратов будет скопировано в процессорный счетчик команд.
- Биты с 11 по 8. Содержит число, указывающее тип команды АЛУ.
- Бит 7 (флаг T->N и TN). Если установлен в 1, то элемент с вершины стека данных копируется в элемент под вершиной.
- Бит 6 (флаг T->R и TR). Если установлен в 1, то элемент с вершины стека данных копируется на вершину стека возвратов.
- Бит 5 (флаг N->[T] и NTI). Если установлен в 1, то элемент под вершиной стека данных копируется в память по адресу, который представлен числом, лежащим на вершине стека данных.
- Бит 4. Не используется. Всегда 0, но можно использовать для увеличения количества команд АЛУ в своих версия J1.
- Биты 3 и 2 (флаг rstack). Содержат число, на которое смещается указатель стека возвратов при выполнении инструкций. Если это число – 1, тогда указатель увеличивается на 1; если число 2, то указатель уменьшается на 1.
- Биты 1 и 0 (флаг dstack). Содержат число, на которое смещается указатель стека данных при выполнении инструкций. Если это число – 1, тогда указатель увеличивается на 1; если число 2, то указатель уменьшается на 1.
Для понимания команд АЛУ введем обозначения: T – вершина стека данных, N – элемент под вершиной стека данных, R – вершина стека возвратов, depth – текущая глубина стека. Тогда, коды операций АЛУ с обозначениями самих операций дают следующую картину:
Операции АЛУ в J1 |
|
Код операции |
Операция |
| 0 | T (вершина стека) |
| 1 | N (элемент под вершиной стека) |
| 2 | T + N |
| 3 | T & N |
| 4 | T | N |
| 5 | T ^ N |
| 6 | ~T |
| 7 | N == T |
| 8 | N < T |
| 9 | N >> T |
| 10 | T – 1 |
| 11 | R (вершина стека возвратов) |
| 12 | [T] (чтение из памяти по адресу T) |
| 13 | N << T |
| 14 | depth (глубина стека) |
| 15 | Nu < T (беззнаковое сравнение) |
Данный процессор мы реализуем в виде класса, который будет содержать внутри себя всю основную логику работы процессора J1. При необходимости вы сможете подогнать его под себя.
Сначала импортируем модуль, в котором содержится все необходимое для конвертации типов, а также для удобства объявим некоторые псевдонимы:
import std.conv : to; // 16-разрядное беззнаковое целое alias int16 = short; // 16-разрядное беззнаковое целое alias uint16 = ushort;
После этого объявим внутри класса ряд перечислений, которые будут использованы для распознавания типов инструкций J1 и для выделения по маскам аргументов инструкций, а также укажем ряд переменных внутреннего состояния процессора:
enum RAM_SIZE = 32_768;
// стек данных
uint16[33] dataStack;
// стек вызовов
uint16[32] returnStack;
// память
uint16[RAM_SIZE] RAM;
// указатель на вершину стека данных
int16 dataPointer;
// указатель на вершину стека вызовов
int16 returnPointer;
// счетчик инструкций
uint16 programCounter;
// маски для различения типов инструкций j1
enum J1_INSTRUCTION : uint16
{
JMP = 0x0000,
JZ = 0x2000,
CALL = 0x4000,
ALU = 0x6000,
LIT = 0x8000
};
// маски для различения аргументов инструкций j1
enum J1_DATA : uint16
{
LITERAL = 0x7fff,
TARGET = 0x1fff
};
Теперь рассмотрим самую важную часть логики процессора – исполнение команд АЛУ:
// исполнение команд АЛУ
auto executeALU(uint16 instruction)
{
uint16 q;
uint16 t;
uint16 n;
uint16 r;
// вершина стека
if (dataPointer > 0)
{
t = dataStack[dataPointer];
}
// элемент под вершиной стека
if (dataPointer > 0)
{
n = dataStack[dataPointer - 1];
}
// предыдущий адрес возврата
if (returnPointer > 0)
{
r = returnStack[returnPointer - 1];
}
// увеличить счетчик инструкций
programCounter++;
// извлечение кода операции АЛУ
uint16 operationCode = (instruction & 0x0f00) >> 8;
// опознание операций
switch (operationCode)
{
case 0:
q = t;
break;
case 1:
q = n;
break;
case 2:
q = to!uint16(t + n);
break;
case 3:
q = t & n;
break;
case 4:
q = t | n;
break;
case 5:
q = t ^ n;
break;
case 6:
q = to!uint16(~to!int(t));
break;
case 7:
q = (t == n) ? 1 : 0;
break;
case 8:
q = (to!int16(n) < to!int16(t)) ? 1 : 0; break; case 9: q = n >> t;
break;
case 10:
q = to!uint16(t - 1);
break;
case 11:
q = returnStack[returnPointer];
break;
case 12:
q = RAM[t];
break;
case 13:
q = to!uint16(n << t);
break;
case 14:
q = to!uint16(dataPointer + 1u);
break;
case 15:
q = (n < t) ? 1 : 0;
break;
default:
break;
} // код действия с указателем на стек данных // (+1 - увеличить указатель, 0 - не трогать, -1 уменьшить (= 2 в двоичном коде))
uint16 ds = instruction & 0x0003; // код действия с указателем на стек возвратов // (+1 - увеличить указатель, 0 - не трогать, -1 уменьшить (= 2 в двоичном коде))
uint16 rs = (instruction & 0x000c) >> 2;
switch (ds)
{
case 1:
dataPointer++;
break;
case 2:
dataPointer--;
break;
default:
break;
}
switch (rs)
{
case 1:
returnPointer++;
break;
case 2:
returnPointer--;
break;
default:
break;
}
// флаг NTI
if ((instruction & 0x0020) != 0)
{
RAM[t] = n;
}
// флаг TR
if ((instruction & 0x0040) != 0)
{
returnStack[returnPointer] = t;
}
// флаг TR
if ((instruction & 0x0080) != 0)
{
dataStack[dataPointer-1] = t;
}
// флаг RPC
if ((instruction & 0x1000) != 0)
{
programCounter = returnStack[returnPointer];
}
if (dataPointer >= 0)
{
dataStack[dataPointer] = q;
}
}
Исполнение основных команд процессора:
auto execute(uint16 instruction)
{
// опознать тип инструкции
uint16 instructionType = instruction & 0xe000;
// операнд над которым осуществляется инструкция
uint16 operand = instruction & J1_DATA.TARGET;
// распознать конкретную инструкцию процессора
switch (instructionType)
{
// безусловный переход
case J1_INSTRUCTION.JMP:
programCounter = operand;
break;
// переход на адрес, если на вершине стека 0
case J1_INSTRUCTION.JZ:
if (dataStack[dataPointer] == 0)
{
programCounter = operand;
}
dataPointer--;
break;
// передать управление на адрес
case J1_INSTRUCTION.CALL:
returnPointer++;
returnStack[returnPointer] = to!uint16(programCounter + 1);
programCounter = operand;
break;
// выполнить инструкцию АЛУ
case J1_INSTRUCTION.ALU:
executeALU(operand);
break;
// положить на стек литерал
case J1_INSTRUCTION.LIT:
operand = instruction & J1_DATA.LITERAL;
dataPointer++;
dataStack[dataPointer] = operand;
programCounter++;
break;
default:
break;
}
}
Исполнение программы из памяти, считаем что число 0 – это команда остановки исполнения:
void executeProgram()
{
while (RAM[programCounter] != 0)
{
execute(RAM[programCounter]);
print;
}
}
Теперь, можно испытать процессор, для этого напишем круговую перестановку элементов стека в 16-ричных кодах J1 и загрузим ее в J1:
void main()
{
J1_CPU j1 = new J1_CPU;
uint16[32_768] ram = new uint16[32_768];
ram[0] = 0x4003; // call 3 вызвать подпрограмму по адресу 3
ram[1] = 0x8008; // push 8 положить 8 на стек
ram[2] = 0x0000; // halt остановить программу
ram[3] = 0x8001; // push 1 положить 1 на стек
ram[4] = 0x8002; // push 2 положить 2 на стек
ram[5] = 0x8003; // push 3 положить 8 на стек
ram[6] = 0x6146; // >r скопировать вершину стека данных в стек возвратов
ram[7] = 0x6180; // swap поменять местами два верхних элемента стека данных
ram[8] = 0x6b89; // r> поместить вершину стека возвратов в стек данных
ram[9] = 0x7180; // swap поменять местами два верхних элемента стека данных
j1.setMemory(ram);
j1.executeProgram();
j1.print;
}
Результат:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [2, 1, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [2, 1, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [2, 3, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [2, 3, 1, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [2, 3, 1, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
В качестве бонуса прилагаю описание реализации некоторых команд Forth в J1:
И, собственноручно полученная таблица 16-ричных кодов для этих и некоторых других команд Forth:
noop 0x6000 add 0x6202 xor 0x6502 and 0x6302 or 0x6402 invert 0x6600 = 0x6702 < 0x6802 u< 0x6f02 swap 0x6180 dup 0x6081 drop 0x6102 over 0x6181 nip 0x6002 >r 0x6146 r> 0x6b89 r>> 0x6bc9 r@ 0x6b81 @ 0x6c00 ! 0x6022 0x6102 dsp 0x6e81 lshift 0x6d02 rshift 0x6902 1- 0x6a00 2r> 0x6b89 0x6b89 0x6180 2>r 0x6180 0x6146 0x6146 2r@ 0x6b89 0x6b89 0x6181 0x6181 0x6146 0x6180 dup@ 0x6c81 dup>r 0x6044 2dupxor 0x6581 2dup= 0x6781 !nip 0x6022 2dup! 0x6020 up1 0x6001 down1 0x6002 copy 0x6100 unloop 0x6008 0x6008 exit 0x7008 ; 0x6048
J1 – занятный процессор, который годится отнюдь не только для забавного изучения программирования на нестандартной архитектуре, также это интересный процессор для виртуальных машин и для некоторых проектов электронных устройств (вы удивитесь, но процессор существует в железе, и не только для FPGA-плат, он также доступен в шилде GameDuino, как сопроцессор!). Помимо этого, можно реализовать свой простой ассемблер для данного процессора или написать целый транслятор Forth для него, и использовать J1 как исполнитель байткода!
Полный исходный код проекта находится тут: https://github.com/LightHouseSoftware/j1_cpu
