Интерпретатор VM

advertisement
Глава 2. Реализация интерпретаторов
В предыдущей главе была определена архитектура абстрактных (виртуальных)
машин AMF, AMS и VM. Чтобы исполнять программы в кодах абстрактной машины,
необходим исполнитель команд. В этой главе мы рассмотрим реализацию
исполнителя в виде прямого интерпретатора команд виртуальной машины.
Интерпретатор — это программная модель исполнителя команд абстрактной
(виртуальной) машины.
Для реализации исполнителя в первую очередь требуется выбрать язык реализации.
В этой книге для разработки любых системных программ используется язык С++.
Разработка интерпретатора требует выполнить следующую работу:
 средствами С++ определить модели памяти и регистров абстрактной машины;
 отобразить типы данных виртуальная машины на типы данных С++;
 разработать модель процессора и реализовать алгоритм его работы;
 для каждого формата команд определить модель команды, методов адресации
аргументов и реализовать алгоритм выполнения операции;
 если в архитектуре виртуальной машины определен стек, то нужно реализовать
модель стека.
Помимо этого требуется решить еще одну важную проблему: разработать способ
записи программы в память виртуальной машины. Для этого требуется разработать
формат, в котором программы для реализуемого исполнителя должны храниться на
внешнем носителе, и реализовать программу, которая будет загружать программу в
память виртуальной машины и запускать процессор.
Для сравнения реализуем два исполнителя: интерпретатор AMS и интерпретатор VM.
Интерпретатор AMS
Поскольку архитектура AMS очень проста, можно реализовать интерпретатор в виде
единственного модуля, в котором все и определено. Сначала определим модели
данных, команд, памяти и регистров (листинг 2.1).
Листинг 2.1. Структуры данных интерпретатора AMS
// типы данных интерпретатора AMS
typedef unsigned int word;
#pragma pack(1)
struct command
{ unsigned short code:
6;
unsigned short address: 10;
};
//
//
//
//
выравнивание по байту
модель команды
код операции
адрес аргумента
struct Commands
{ command first;
command second;
};
union Data
{ int i;
float r;
};
union Word
{ Data d;
Commands cmd;
};
// модели памяти и регистров
const unsigned int N = 1024;
Word mem[N];
// регистры
int Flag;
bool Error;
Word RM;
word RA;
command RC;
Data RS;
Data R1;
Data R2;
bool Jump;
bool stop = false;
// две команды в слове
// первая команда
// вторая команда
// данные в сумматоре/памяти
// либо целое
// либо вещественное
// машинное слово
// либо данные
// либо две команды
|// количество слов памяти
// массив памяти
// регистр признака результата
// регистр ошибки
// регистр выборки
// регистр адреса команды
// регистр команд
// регистр-сумматор
// внутренний регистр
// внутренний регистр для
// если команда перехода
// для окончания цикла процессора
Для определения структуры команды удобно использовать структуру с битовыми
полями, в которой явно задается количество бит для кода операции и для адреса
операнда. Так как в одном и том же слове памяти может быть записано целое число,
вещественное число или команда, для определения типа слова используется
конструкция union. Как вы можете заметить, размер одного слова памяти в точности
составляет 32 бита. Мы воспользовались тем, что размер типов int и float в
системе Visual C++.NET составляет 4 байта, поэтому их можно объявлять в качестве
полей класса union.
Алгоритм работы процессора чрезвычайно прост (листинг 2.2).
Листинг 2.2. Алгоритм работы процессора AMS
void start()
{ load();
reset();
}
// алгоритм работы процессора
// действия по кнопке "Пуск"
// загрузка программы в память
// начальная установка регистров
int main()
{
start();
// программа считана в памяти, RA = 1;
while (true)
{ RM = mem[RA];
// выбрать слово из памяти
RA = (RA + 1) % N;
// изменение адреса - по модулю 1024
RC = RM.cmd.first;
// выбрать команду first
run();
// выполнить команду
// если переход, то не выполнять вторую команду из текущего слова
if (Jump) continue;
if (Error || stop) break;
// если команда stop или ошибка
RC = RM.cmd.second;
// выбрать команду second
run();
// выполнить команду
if (Error || stop) break;
// если команда stop или ошибка
}
system("pause");
// остановить перед окончанием
}
Функция run() реализует алгоритмы выполнения операций и показана ниже в
листинге 2.3. Функция start() выполняет работу «кнопки» Пуск в машине AMS:
загружает программу с внешнего «устройства» и устанавливает все регистры в
начальное состояние. Эти два действия инкапсулированы в функциях load() (см.
листинг 2.4) и reset() (см. листинг 2.3).
Листинг 2.3. Алгоритмы выполнения команд
// начальная установка регистров
void reset()
{ Flag = 0; Error = false;
mem[0].d.i = 0;
RM.d.i = 0;
RC.code = 0; RC.address = 0;
RS.i = R1.i = 0;
RA = 1;
}
void FlagI()
{ if (RS.i == 0) Flag = 0;
else if (RS.i < 0) Flag = -1;
else if (RS.i > 0) Flag = +1;
}
void FlagR()
{ if (RS.r == 0) Flag = 0;
else if (RS.r < 0) Flag = -1;
else if (RS.r > 0) Flag = +1;
}
// нулевая ячейка памяти всегда = 0
// стартовый адрес программы
// установка Flag для целых
// установка Flag для вещественных
void RCMP()
// сравнение без записи результата
{ R1.r = mem[RC.address].d.r;
float diff = RS.r - R1.r;
if (diff == 0) Flag = 0;
else if (diff < 0) Flag = -1;
else if (diff > 0) Flag = +1;
}
// выполнение команд – реализованы только для примера из таблицы 1.4
void run()
{ enum operations
// коды операций
{ Stop = 0,
// – остановка работы процессора
// нельзя писать первый ноль!!! Восьмеричные!
Iadd = 1,
// – сложение целых чисел
Isub = 2,
// – вычитание целых чисел
Imul = 3,
// – умножение целых чисел
Idiv = 4,
// – деление целых чисел
Imod = 5,
// – остаток от деления целых чисел
Icmp = 6,
// – сравнение сумматора с целым числом
Iin
= 8,
// – ввод целого числа
Iout = 9,
// – вывод целого числа
Radd = 11,
// – сложение вещественных чисел
Rsub = 12,
// – вычитание вещественных чисел
Rmul = 13,
// – умножение вещественных чисел
Rdiv = 14,
// – деление вещественных чисел
Rcmp = 16,
// – сравнение сумматора с вещественным числом
Rin
= 18,
// – ввод вещественного числа
Rout = 19,
// – вывод вещественного числа
Load = 40,
// – загрузка сумматора
LdIR = 41,
// – загрузка с переводом целого в вещественное
LdRI = 42,
// – загрузка с переводом вещественного в целое
Store = 50,
// – сохранение сумматора
StIR = 51,
// – сохранение с переводом целого в вещественное
StRI = 52,
// – сохранение с переводом вещественного в целое
Go
= 60,
// – безусловный переход
JZ
= 61,
// – переход, если ноль
JG
= 62,
// – переход, если больше
JL
= 63
// – переход, если больше
};
Jump = false;
switch(RC.code)
// анализ КОП
{ case Load: RS.i = mem[RC.address].d.i; break;
case Store: mem[RC.address].d.i = RS.i; break;
case Iadd: R1.i = mem[RC.address].d.i;
case Isub:
case Rin:
case Rout:
case Radd:
case Rdiv:
case Rcmp:
case JZ:
case JG:
case JL:
case Go:
case Stop:
default:
R2.i = RS.i;
RS.i = RS.i + R1.i; FlagI();
Error = (abs(RS.i) < abs(R1.i)) &&
(abs(RS.i) < abs(R2.i));
break;
R1.i = mem[RC.address].d.i;
RS.i = RS.i - R1.i; FlagI();
break;
cin >> mem[RC.address].d.r;
RCMP();
// устанавливаем Flag
break;
cout << mem[RC.address].d.r << endl; break;
R1.r = mem[RC.address].d.r;
RS.r = RS.r + R1.r; FlagR();
break;
R1.r = mem[RC.address].d.r;
RS.r = RS.r / R1.r; FlagR();
break;
RCMP(); break;
if (Flag == 0) { RA = RC.address; Jump = true; }
break;
if (Flag == +1) { RA = RC.address; Jump = true; }
break;
if (Flag == -1) { RA = RC.address; Jump = true; }
break;
RA = RC.address; Jump = true;
break;
stop = true; break;
Error = true;
// неправильный КОП
}
}
Функция выполнения операций run() представляет собой единственный операторпереключатель, в котором альтернатива case выбирается по коду операции команды,
находящейся в данный момент в регистре команд RC. Чтобы не иметь дело с кодамичислами, их удобно представить как перечисление.
«Устройством» ввода является стандартный поток ввода cin, а «устройством”
вывода — стандартный поток вывода cout. Команда ввода вещественного числа Rin
устанавливает регистр Flag, используя для этого функцию сравнения RCMP(),
которая реализует алгоритм операции сравнения вещественных чисел Rcmp. В
реализации операции Iadd показан один из вариантов установки регистра Error,
если произошло переполнение (при этом абсолютное значение результата меньше
абсолютных значений обоих операндов).
Все команды перехода устанавливают служебный флаг Jump. Дело в том, что
команда перехода может быть записана первой в выбранном из памяти слове
(регистр RM). Если команда перехода выполнена успешно, то в регистр RA будет
занесен новый адрес памяти, откуда процессор должен выбрать следующую команду.
Поэтому выполнять вторую команду в текущем выбранном слове не требуется. Как
мы видим в алгоритме процессора (листинг 2.2), служебный флаг Jump проверяется
после выполнения первой команды. Если он установлен (первая команды была
командой перехода, которая успешно выполнилась), то происходит немедленный
переход на выбор следующего слова.
В примере показана реализация только тех операций, которые использовались в
примере программы для AMS (см. таблицу 1.4). Остальные команды реализуются
аналогично, и эта работа оставляется читателю.
Формат программы и загрузчик для AMS
Осталось определить формат массива слов на внешнем «устройстве» и реализовать
функцию load(), которая загружает этот массив слов в память AMS.
В современных вычислительных системах загрузкой программ в память занимается
программа-загрузчик операционной системы. Отметим, что в реальных системах
загрузчик сам находится в памяти и работает на том же компьютере. Загружаемая
программа имеет специальный формат, который «понимает» программа-загрузчик.
Загрузчик — это программа, выполняющая загрузку массива слов (программ и
данных) с внешнего устройства в память компьютера.
Нам нет необходимости писать операционную систему, чтобы загружать программы
в память виртуальной машины. Поэтому решим проблему простейшим способом:
программа для AMS (вместе с данными) хранится в файле и перед интерпретацией
считывается память. Затем функция reset() устанавливает PC, и интерпретатор
начинает выполнять команды, выбирая их из памяти. Такая схема работы называется
«загрузка-выполнение».
Загружаемый в память массив слов может содержать команды, вещественные числа,
целые числа и должен заканчиваться маркером конца. Массив слов представляет
собой текстовый файл, который программист (например, вы) наберет в текстовом
редакторе (например, в Блокноте). Поэтому формат файла должен быть, с одной
стороны, удобным для программиста, и с другой стороны, простым для реализации
загрузчика.
Весьма удобно для программиста представлять по одному слову в одной строке
текстового файла. Первым символом строки должен быть маркер типа слова:
 символ «k» означает, что слово является командой;
 символ «r» означает, что слово является вещественным числом;
 символ «i» означает, что слово является целым числом.
Программа должна заканчивать маркером, обозначающим конец массива слов. Пусть
это будет символ «e» (от слова end).
Первой строкой массива слов должно быть слово с двумя командами, поскольку
процессор AMS начинает работать с адреса 0001. Формат слова с командами может
быть такой:
k xx aaaa xx aaaa
Здесь xx — код операции (от 00 до 63), aaaa — адрес (от 0 до 1023). Формат слов с
числами гораздо проще:
r число
i число
Таким образом, программа из примера 1.4 будет записана так: (строки для удобства
пронумерованы, но номер не является частью входной строки):
1. k
2. k
3. k
4. k
5. k
6. k
7. k
8. k
9. k
10. k
11. k
12. k
13. k
14. k
15. r
16. i
17. i
18. k
19. k
20. e
40 0000
18 0100
62 0006
40 0015
16 0015
40 0016
50 0016
40 0002
50 0002
01 0019
40 0000
11 0020
40 0000
19 0020
1.0
100
1
00 0001
00 0000
50
16
61
14
62
02
61
01
40
50
60
50
60
00
0020
0100
0006
0100
0012
0017
0014
0018
0004
0004
0002
0020
0006
0000
00 0001
00 0001
Обратите внимание на строки 18 и 19: константы переадресации команд сами
представлены как команды.
Функция загрузки, обрабатывающая такой формат файла показана в листинге 2.4.
Листинг 2.4. Функция загрузки массива слов в память AMS
void load()
{ ifstream proga("proga.txt");
int n = 0;
Word Record;
string s;
if(proga.is_open())
{ char typeRecord = 0;
//
//
//
//
файл с программой
стартовый адрес загрузки
читаемая запись
входная строка
// тип записи
while((typeRecord != 'e'))
// е - последняя запись
{ getline(proga, s);
// чтение строки файла
typeRecord = s[0];
// тип записи присвоен
word Code, Address;
// команда: КОП, адрес
istringstream lineP(s.substr(1));
// для перевода
switch(typeRecord)
// в зависимости от типа
{ case 'k':
// слово - команда
// читаем и заносим в слово первую команду
lineP >> Code >> Address;
// читаем КОП и адрес
Record.cmd.first.code = Code;
Record.cmd.first.address = Address;
// читаем и заносим в слово вторую команду
lineP >> Code >> Address;
// читаем КОП и адрес
Record.cmd.second.code = Code;
Record.cmd.second.address = Address;
break;
case 'i':
// слово – целое число
lineP >> Record.d.i; break;
case 'r':
// слово – вещественное число
lineP >> Record.d.r; break;
}
n = (n + 1) % N;
// адрес записи в памяти
mem[n] = Record;
// запись в память
} // - while
}
// - if
}
// - load
Функция проста и не требует специальных пояснений. Файл с программой
включается непосредственно в проект в папку Sourse — тогда можно открывать его,
задавая только имя файла, без указания полного пути.
Как может видеть читатель, интерпретатор AMS чрезвычайно прост: суммарный
объем всех показанных программ составляет меньше 200 строк исходного кода
(вместе с пустыми строками и комментариями). Полная реализация всех команд в
функции run() несколько увеличат объем, но не намного. Программа-интерпретатор
была написана менее чем за два часа и практически не потребовала отладки. Гораздо
больше времени заняла отладка кодовой программы, в результате которой она стала
именно такой, как показано в таблице 1.4. Для отладки кодовой программы
потребовалось написать служебную функцию трассировки выполнения команды,
которая представлена в листинге 2.5.
Листинг 2.5. Служебная программа трассировки
bool debug = true;
void Trace()
// отладочный вывод состояния процессора
{ static bool pass = false; // флаг до и после
pass = !pass;
cout << endl;
if (pass) cout << "Trace()- before" << endl;
else
cout << "Trace()- after" << endl;
cout << "RS: " << setw(10) << setfill('0') << RS.i << "; "
<< setw(12) << RS.r << endl;
cout << "Flag: " << boolalpha << Flag << "; "
<< "Error: " << boolalpha << Error << endl;
cout << "RM: " << setw(2) << RM.cmd.first.code << ' '
<< setw(4) << RM.cmd.first.address << '\t';
cout << setw(2) << RM.cmd.second.code << ' '
<< setw(4) << RM.cmd.second.address<< endl;
cout << "RC: " << setw(2) << RC.code << ' '
<< setw(4) << RC.address << endl;
cout << "RA: " << setw(4) << setfill('0') << RA << " - next word";
cout << endl;
if(!pass) cout << "---------------------------------" << endl;
}
Функция просто выводит на экран состояние всех регистров процессора AMS. В
зависимости от состояния локальной статической переменной pass выводится одна
или другая первая строка.
Вызов функции можно осуществлять в зависимости от состояния служебной
переменной debug, например:
if (debug) Trace();
run();
if (debug) Trace();
Можно написать и другие служебные функции. Например, полезной будет функция,
выводящая на экран состояние некоторой части памяти в различных форматах: в
виде команд, в виде целых или в виде вещественных. Оставляем эту работу
читателю.
Интерпретатор VM
Архитектура виртуальной машины VM значительно сложнее архитектуры AMS,
поэтому интерпретатор VM, очевидно, должен представлять собой программу
значительно большего размера, чем интерпретатор AMS. Следовательно, потребуется
разделение на модули. Но решить нужно те же задачи, что и при разработке
интерпретатора AMS: определять модели данных, команд, памяти и регистров.
Сначала определим необходимые типы данных:
typedef signed char Byte;
typedef unsigned char uByte;
typedef short Short;
typedef unsigned short uShort;
typedef int Word;
typedef unsigned int uWord;
//---- специфика Microsoft Visual C++.NET
typedef long long Long;
typedef unsigned long long uLong;
//--------------------------------------typedef float Float;
typedef double Double;
Для определения типов Long и uLong использовано одно из расширений,
реализованное в системе Microsoft Visual C++.NET.
Модель памяти и регистров
Регистры общего назначения представляют собой 256 байт локальной памяти
процессора, в которой могут быть записаны данные разных форматов. Так как эти
256 байт группируются в регистры разной длины, то для их представления удобно
использовать конструкцию union.
union POH
{ Byte
uByte
Short
uShort
Word
uWord
Float
Long
uLong
Double
};
POH ron;
// регистры общего назначения
b [256];
// байтовые знаковые целые
ub[256];
// байтовые беззнаковые целые
s [128];
// двухбайтовые знаковые целые
us[128];
// двухбайтовые беззнаковые целые
w [64];
// четырехбайтовые знаковые целые
uw[64];
// четырехбайтовые беззнаковые целые
f [64];
// короткие вещественные
l [32];
// восьмибайтовые знаковые целые
ul[32];
// восьмибайтовые беззнаковые целые
d [32];
// длинные вещественные
Доступ к отдельному регистру делается так:
ron.b[12]
ron.f[63]
ron.uw[0]
ron.d[31]
Системные регистры определяются так:
typedef
address
address
address
unsigned int address;
PC;
// счетчик адреса команды;
SP;
// указатель стека;
RI;
// регистр адреса таблицы прерываний.
Для определения слова состояния процессора PSW удобно использовать структуру с
битовыми полями:
struct bits
{ unsigned
unsigned
unsigned
unsigned
unsigned
unsigned
unsigned
} PSW;
int CF:1;
int OF:1;
int OV:1;
int UV:1;
int:12;
int TF:1;
int:15;
//
//
//
//
//
//
//
бит переноса
переполнение знаковое целое
переполнение плавающей арифметики
антипереполнение плавающей арифметики
пока не используется
флаг трассировки
пока не используется
Первые 16 бит PSW используются как аварийные флаги, которые устанавливаются
при выполнении операций. Биты второго полуслова являются управляющими.
Все регистры можно собрать в общий класс Registers (листинг 2.6).
Листинг 2.6. Регистры процессора
struct Registers
{ union POH { /* … */ } ron;
address PC;
// счетчик адреса команды;
address SP;
// указатель стека;
struct bits
// структура PSW
{ /* … */ } PSW;
address RI;
// адрес таблицы прерываний
};
Память VM с концептуальной точки зрения представляет собой массив. С одной
стороны, это массив байтов, с другой стороны, группы по несколько байт
представляют собой данные различных типов. Кроме того, процессор при
выполнении программы выбирает из памяти по 2 байта. Эту проблему тоже можно
решить с помощью union, аналогично тому, как это сделано для регистров.
П р им еч а н ие
Обращаем внимание читателя, что посредством конструкции union моделируется
выравнивание данных определенного типа по соответствующег границе. Если
выравнивания не требуется, то объявлять объединение не требуется.
Однако весь объем памяти в 4 гигабайта в программе представить невозможно,
поэтому зарезервируем массив меньшего размера. Так как размещать в памяти
операционную систему нет необходимости, то 64 килобайта будет достаточно. В этой
памяти располагаются и программа, и данные. Таким образом, память может быть
представлена следующим образом:
const unsigned int mKb = 64;
// количество килобайт памяти
const unsigned int mKs = mKb/sizeof(Short);
const unsigned int mKw = mKb/sizeof(Word);
const unsigned int mKl = mKb/sizeof(Long);
union Memory
{ uByte
b[mKb * 1024];
// 64 килобайт
uShort
uWord
Float
uLong
Double
};
Memory mem;
s[mKs
w[mKw
f[mKw
l[mKl
d[mKl
*
*
*
*
*
1024];
1024];
1024];
1024];
1024];
// 32 K полуслов
// 16 K слов
// 16 K слов
// 8 K двойных слов
// 8 K двойных слов
Обращение к элементам памяти выполняется аналогично обращению к регистрам:
mem.b[56]
mem.d[1000]
Вообще говоря, нет никаких особых причин определять память в виде массива
фиксированной длины — интерпретатор вполне может выделять память для
выполняемой программы динамически, причем ровно столько, сколько требуется 1.
Однако этот вопрос тесно связан с разработкой программы-загрузчика, поэтому
вернемся к нему позднее.
Несмотря на то, что в составе виртуальной машины предусмотрен указатель стека, на
данном этапе разработки можно обойтись без реализации «аппаратного» стека в
памяти VM. Гораздо проще промоделировать стек с помощью контейнера-стека
стандартной библиотеки STL [_]. Элементами стека VM могут быть только слова,
поэтому нужно определить тип word, который позволит помещать в стек слова
разного типа:
union word
{ uWord uw;
Word w;
address a;
Float f;
};
//
//
//
//
беззнаковые целые
знаковые целые
адреса
короткие дробные
Тогда стек определяется так:
stack<word> s;
Помещение слов в стек и извлечение из стека выполняется методами контейнерастека push() и pop().
Модель процессора
Модель процессора должна определять алгоритм выборки команд из памяти и
алгоритмы выполнения каждой операции. Алгоритм выборки команд — это
основной цикл интерпретатора, который представляет собой следующую
последовательность действий:
1
1.
Выбрать из M2[PC] очередные два байта;
2.
Определить КОП;
Вспомним, что в P-машине массив кодов определен отдельно от модели памяти.
3.
Если требуется, выбрать еще 2, 4 или 6 байт;
4.
Сформировать полную команду во внутреннем регистре процессора
5.
Выполнить команду;
6.
Изменить PC;
7.
Перейти на 1;
Во-первых, отметим, что в алгоритме регистр счетчика команды изменяется после
выполнения команды. Таким образом, в нем сохраняется адрес обрабатываемой
команды. Это более удобно, так как в этом случае при аварийной ситуации PC
показывает на аварийную команду.
Во-вторых, пункт 3 в данной схеме не совсем ясен: как процессор «узнает», сколько
еще байт выбирать из памяти? Это можно однозначно определить по коду операции.
Например, процессор выбирает из памяти два байта 08 0А. Первый байт
соответствует коду операции условного перехода JL. Следовательно, второй байт —
это номер регистра. Значит, процессору осталось выбрать еще 2 байта смещения.
Другой пример: выбраны байты 2А 00. Первый байт является кодом операции LDWI,
следовательно, второй — номер регистра $W. Это значит, что процессору осталось
выбрать еще 4 байта — непосредственный операнд. Если процессор выбрал два байта
43 04, то первый байт соответствует коду операции NOT, а второй — это номер
регистра. В этом случае ничего дополнительно выбирать не нужно.
Если процессор выбрал два байта, первый из которых равен специальному коду FF16,
он поступает совершенно аналогично, поскольку и в этом случае длина команды
однозначно определяется на основании полного двухбайтного кода операции.
Таким образом, для каждой команды где-то должна храниться ее размер. Кроме того,
интерпретатор должен «собирать» полную команду, выбирая из памяти оставшиеся
байты. В реальных компьютерах команда собирается во внутреннем регистре
процессора, недоступном программисту. Этот регистр в интерпретаторе можно
промоделировать восьмибайтным массивом:
uByte rc[8];
// регистр команд
Байт rc[0] содержит код операции для основных команд. Для команд из
дополнительного множества код операции находится в rc[1].
Все составляющие модели процессора удобно собрать в один модуль. Более того,
удобно представить модель компьютера в виде класса, включающего все структуры
данных (регистры, память, стек) и все необходимые алгоритмы в виде методов.
Очевидно, что один из методов реализует основной цикл процессора, другой —
начальные установки всех регистров при старте.
Теперь подробнее остановимся на шаге 5 — выполнение команды. В интерпретаторе
AMS выполнение команд было инкапсулировано в функции run() в единственном
операторе-переключателе. Такое решение было приемлемым, поскольку архитектура
AMS проста, и объем интерпретатора весьма мал. Однако для VM такая реализация
представляется не самым лучшим вариантом по следующим причинам:
 количество операций достаточно большое, поэтому оператор-переключатель
будет очень длинным; кроме того, одним переключателем не обойтись, так как
код операции может быть либо 1 байт (основное множество операций), либо 2
байта (дополнительное множество операций);
 команды имеют разный размер, поэтому необходимо где-то хранить длину
команды; эта величина должна быть известна во время выполнения основного
цикла процессора;
 команды разного формата, поэтому прежде, чем выполнять команду, требуется
«разобраться» с ее аргументами;
 в дальнейшем предполагаются расширения VM, например, добавление новых
команд или регистров; возможны изменения алгоритмов операций.
Все эти соображения наводят на мысль, что лучше выполнение каждой операции
инкапсулировать в отдельной функции, которая вызывается в основном цикле
процессора. Так как функций много, лучше собрать их в отдельный модуль. Таким
образом, при изменении-расширении системы команд виртуальной машины
потребуется вносить изменения только в модуль реализации команд, не затрагивая
основной модуль интерпретатора.
Однако при отсутствии оператора-переключателя возникает проблема вызова
необходимой функции: по имени функцию вызывать невозможно, поскольку все
имена разные. Однако в С++ можно вызывать функцию по указателю, и это
позволяет нам применить хорошо известный типичный прием, который состоит в
следующем:
 имена функций при старте заносятся в массив указателей (на функцию);
 индексом в массиве указателей является код операции.
В этом же массиве удобно хранить и размер соответствующей команды. Поэтому
представляется весьма удобным определить для каждой команды структуру из двух
полей:
 имя реализующей функции;
 длина команды.
Массив таких структур должен быть включен в класс-компьютер, и его нужно
инициализировать перед запуском основного цикла.
Реализация основного цикла процессора
Коды операций, как обычно, представляются в виде перечисления. Для VM нам
нужно два перечисления: для основного и дополнительного наборов операций.
Вынесем определение перечислений в отдельный модуль (листинг 2.7).
Листинг 2.7. Определение перечислений для кодов операций
#if !defined _OPERATIONS_
#define _OPERATIONS_
enum Operations
// Коды операций - основной набор
{ STOP = 0x00,
// СТОП!!!!
RET = 0x04,
// возврат из подпрограммы
// переходы:
// условные переходы по регистру
JL = 0x08, JZ, JG, JOD, JLE, JNZ, JGE, JEV,
JMPR
= 0x10, JMP,
// безусловный goto
CALLR = 0x18, CALL,
// переход к подпрограмме с возвратом
LOOPR = 0x1c, LOOP,
// цикл
// загрузка-сохранение-очистка
LDBA = 0x20, LDSA, LDWA, LDDA, LDB = 0x24, LDS, LDW, LDD,
STBA = 0x28, STSA, STWA, STDA, STB = 0x2C, STS, STW, STD,
LDBI = 0x34, LDSI, LDWI,
CLRB = 0x30, CLRS, CLRW, CLRD,
// целая арифметика
ADD = 0x60, ADDU, ADDI, ADDUI,
SUB = 0x64, SUBU, SUBI, SUBUI,
MUL = 0x68, MULU, MULI, MULUI,
DIV = 0x6c, DIVU, DIVI, DIVUI,
MOD = 0x70, MODU, MODI, MODUI,
CMP = 0x74, CMPU, CMPI, CMPUI,
INC = 0x78, INCU, INCI, INCUI,
DEC = 0x7c, DECU, DECI, DECUI,
NEG = 0x50, ABS = 0x51,
// плавающая арифметика
FADD = 0x80, FSUB, FMUL, FDIV,
FMOD = 0x84, FCMP,
FINT = 0x88, FRND, FSQRT, FEXP,
FSIN, FATAN, FLN, FSGN,
FLD1 = 0x98, FLD2, FLDPI, FLDE,
// стековые операции
PUSH = 0xc0, PUSHM, POP, POPM,
STSP = 0xc4, STSPM, FMODS = 0xc6, FMODR,
FADDS = 0xc8, FSUBS, FMULS, FDIVS,
FADDR = 0xcc, FSUBR, FMULR, FDIVR,
// битовые операции и сдвиги
OR = 0xe0, AND, XOR, NOT = 0xe3,
ORI = 0xec, ANDI, XORI,
SH = 0xb0, SHU, SHI, SHUI,
// ввод-вывод
IN = 0xf0,
OUT = 0xf1,
IIN = 0xF4, IOUT, FIND, FOUTD,
// загрузки системных регистров
LDSP = 0xf8, LDRI, LDPSW, STPSW,
// расширенные коды
set01 = 0xFF
};
enum OperationsFF
{ SWPB = 0x00, SWPS, SWPW, SWPD,
MOVB = 0x04, MOVS, MOVW, MOVD,
CBS = 0x08, CSW, CWL,
// переводы
CWF = 0x20, CFW, CLF, CFL,
CWD = 0x24, CDW, CLD, CDL
CFD = 0x30, CDF
};
#endif
//
//
//
//
Дополнительное множество операций
обмен регистров
копирование регистров
расширение знака
Модуль оформлен в соответствии с обычной практикой разработки многомодульных
программ на С++: проверяется и определяется «страж» _OPERATIONS_.
Определим типы и структуры данных для организации массива указателей на
функции (листинг 2.8).
Листинг 2.8. Структура данных для организации вызова команд
typedef int (*cmd)(Computer &VM);
// указатель на функцию
struct command
{ uByte Length;
// длина команды
cmd function;
// исполняющая функция
// конструкторы
command(uByte l, cmd f):Length(l), function(f){};
command():Length(), function(){};
};
Оператор typedef определяет тип указателя на функцию. Каждая функция-команда
получает в качестве параметра объект-компьютер, так как практически любой
функции необходим доступ к регистрам и памяти.
Конструкторы заполняют поля: заносят длину команды и имя функции, реализующей
алгоритм команды. Эта структура должна быть доступна в классе-компьютере, чтобы
в нем можно было объявить соответствующий массив указателей.
В архитектуре VM определено несколько разных форматов команд. Процессор
собирает команду в регистре команд. Поскольку в этом регистре может быть
записана команда любого формата, потребуется определить типы для каждого
формата и задать union (листинг 2.9), который является типом регистра команд.
Листинг 2.9. Определение регистра команд
// типы форматов команд
struct mCN_2
// КОП N - 2 байта
{ uByte Code; uByte N; };
struct mCR_2
// КОП R - 2 байта
{ uByte Code; uByte R; };
struct mCROO_4
// КОП R RA - 4 байта
{ uByte Code; uByte R; Short OO; };
struct mCRiS_4
// КОП R iS - 4 байта;
{ uByte Code; uByte R; Short iS; };
struct mCRuS_4
// КОП R uS - 4 байта;
{ uByte Code; uByte R; uShort uS; };
struct mCRiB_4
// КОП R iB - 4 байта;
{ uByte Code; uByte R; Byte iB; uByte b; };
// используется только iB
struct mCRRR_4
// КОП R R R – 4 байта
{ uByte Code; uByte R1; uByte R2; uByte R3; };
struct mCRRB_4
// КОП R R iB – 4 байта
{ uByte Code; uByte R1; uByte R2; Byte iB; };
// единственный формат, котором нужно собирать аргумент из 3 байтов
struct mCOOO_4
// КОП RA – 4 байта (смещение 3 байта)
{ uByte Code; uByte b1; uByte b2; uByte b3; };
struct mCRiW_6
// КОП R iiii – 6 байт
{ uByte Code; uByte R; Word iW; };
struct mCRaW_6
// КОП R aaaa – 6 байт
{ uByte Code; uByte R; uWord aW; };
// Дополнительный набор команд
struct dFCRR_4
// FF КОП R R – 4 байта
{ uByte FF; uByte Code; uByte R1; uByte R2; };
// тип для регистра команд
union RegisterCommand
{ uByte rc[8];
// общий размер
uByte Code;
// КОП основной
struct ff { uByte FF; uByte Code; } FF; // КОП дополнительный
// «рамки» форматов команд
mCN_2
CN;
mCR_2
CR;
mCRRR_4 CRRR;
mCRRB_4 CRRB;
mCROO_4 CROO;
mCRiS_4 CRiS;
mCRuS_4 CRuS;
mCRiB_4 CRiB;
mCOOO_4 COOO;
mCRiW_6 CRiW;
mCRaW_6 CRaW;
dFCRR_4 FCRR;
};
RegisterCommand RC;
// регистр команды
В названии типа структуры закодирован соответствующий формат команды: буква C
означает байт кода операции (КОП), R — номер регистра, OO обозначает смещение,
iB, iS, iW, uS — непосредственные операнды. Первая буква m определяет формат
команды из основного набора, а буква d — формат команды из дополнительного
набора. Цифра показывает размер команды данного формата. Таким образом, тип
mCRRR_4 обозначает все команды с тремя операндами-регистрами, размер которых
равен 4 байтам; к типу mCROO_4 относятся все 4-хбайтные команды условного
перехода и команда цикла LOOP, в которых первый аргумент — это регистр, а второй
— смещение (offset); тип mCR обозначет команды с одним аргументом-регистром,
размер которых равен 2 байта.
Чтобы все поля корректно наложились друг на друга, нужно установить
выравнивание по границе байта:
#pragma pack(1)
Теперь можно определить класс Computer. По традиции объектно-ориентированного
программирования на С++ при реализации класса его разделяют на два модуля:
интерфейс класса и реализацию класса. Интерфейс класса Computer представлен в
листинге 2.10.
Листинг 2.10. Интерфейс класса Computer
class Computer
{ typedef int (*cmd)(Computer &VM);
struct command { /* … */ };
// код операции - индекс в массиве Cmd;
command
Cmd[256];
// основной набор команд
command ffCmd[256];
// дополнительный набор
RegisterCommand RC;
// регистр команды
address Address;
// адрес из команд загрузки-выгрузки
bool jumping;
// для команд перехода
void Clear();
// обнуление RC
void Trace();
// трассировка выполнения
void setPSW();
// установка флагов в PSW
union word { uWord uw; Word w; address a; Float f; };
public:
stack<word> s;
// стек
Registers regs;
// регистры
Memory memory;
// память
Computer();
// конструктор
int reset(bool debug);
// начальная установка регистров
int interpreter(bool debug);
// основной цикл процессора
void setPC(address pc)
// установка счетчика команд
{ regs.PC = pc; return; }
friend class Command;
// операции – все друзья
};
Память и регистры определены в том же модуле (см. листинг 2.6).
Массивы Cmd и ffCmd предназначены для хранения адресов функций основного и
дополнительного наборов команд и размеров команд.
В регистре команд RC «собирается» выбираемая из памяти команда. Перед выборкой
следующей команды этот «регистр» очищается — это выполняет метод Clear().
Метод Trace() весьма полезен при отладке программы для VM: он выводит на
экран выполняемую команду, если в PSW установлен флаг трассировки
Назначение открытых методов очевидно:
 конструктор по умолчанию выполняет все инициализирующие действия при
создании объекта-компьютера;
 метод reset() просто обнуляет все регистры процессора;
 метод setPC() очевидно необходим, чтобы установить первоначальное значение
счетчика команд;
 метод interpreter() собственно выполняет программу;
Метод interpreter() реализует показанный выше основной алгоритм процессора.
Концептуально исполняющие функции являются частью процессора, поэтому их
можно реализовать как закрытые методы класса Computer. Однако в таком случае
класс Computer будет слишком «раздут». Поэтому лучше вынести все функциикоманды в отдельный модуль, и объединить их в общем классе Command, реализовав
их как статические методы. Каждая функция должна иметь доступ к регистрам и/или
памяти процессора, поэтому классы Command и Computer, являются друзьями, и
объект-компьютер передается функции-команде как параметр
В отдельном модуле размещены реализации методов класса Computer. В листинге
2.11 показана реализация конструктора и вспомогательных методов.
Листинг 2.11. Конструктор класса Computer и вспомогательные методы
int Computer::reset(bool debug)
// очистка регистров
{ regs.PC = regs.RI = regs.SP = 0;
regs.PSW.CF = regs.PSW.OF = regs.PSW.OV = regs.PSW.UV = 0;
if (debug) regs.PSW.TF = 1; else regs.PSW.TF = 0;
for(int i = 0; i < 64; ++i) regs.ron.w[i] = 0;
return 0;
}
void Computer::Clear()
// обнуление RC
{ for(int i = 0; i < 8; ++i) RC.rc[i] = 0; }
void Computer::setPSW() {};
// пока не реализовано
void Computer::Trace()
// трассировка покомандная
{ cout << setfill('0');
cout << setw(5) << regs.PC << ": ";
int nByte = 0;
if (RC.Code < 0xFF)
// основной набор команд
nByte = Cmd[RC.Code].Length;
else
// дополнительные команды
nByte = ffCmd[RC.FF.Code].Length;
for(int i = 0; i < nByte; ++i)
cout << setw(2) << hex << int(RC.rc[i]) << ' ';
cout << endl;
}
Computer::Computer():Cmd(), ffCmd()
{ Clear();
// очистка регистра комманд
// инициализация массива основных команд
Cmd[STOP] = command(2,Command::cSTOP);
Cmd[JL]
= command(4,Command::cJL);
/* … … */
Cmd[JGE] = command(4,Command::cJGE);
/* … … */
Cmd[JMP] = command(4,Command::cJMP);
Cmd[JMPR] = command(2,Command::cJMPR);
Cmd[LOOP] = command(4,Command::cLOOP);
Cmd[LOOPR]= command(4,Command::cLOOPR);
Cmd[CALL] = command(4,Command::cCALL);
Cmd[CALLR]= command(2,Command::cCALLR);
Cmd[RET] = command(2,Command::cRET);
/* … … */
Cmd[STD] = command(4,Command::cSTD);
/* … … */
Cmd[LDBI] = command(4,Command::cLDBI);
Cmd[LDWI] = command(6,Command::cLDWI);
Cmd[ADD] = command(4,Command::cADD);
/* … … */
Cmd[CMP] = command(4,Command::cCMP);
/* … … */
Cmd[INC] = command(2,Command::cINC);
/* … … */
Cmd[FADD] = command(4,Command::cFADD);
/* … … */
Cmd[FCMP] = command(4,Command::cFCMP);
/* … … */
Cmd[FLD1] = command(2,Command::cFLD1);
/* … … */
Cmd[CLRB] = command(2,Command::cCLRB);
Cmd[CLRS] = command(2,Command::cCLRS);
Cmd[CLRW] = command(2,Command::cCLRW);
Cmd[CLRD] = command(2,Command::cCLRD);
/* … … */
Cmd[FIND] = command(2,Command::cFIND);
Cmd[FOUTD] = command(2,Command::cFOUTD);
// команды дополнительные-------------------------------------------ffCmd[SWPW] = command(4,Command::cSWPW);
ffCmd[MOVW] = command(4,Command::cMOVW);
}
В конструкторе выполняется заполнение массива функций команд. Слева от
присваивания записан элемент массива, индексом которого является код операции —
элемент перечисления Operations. Справа от присваивания записан вызов
конструктора структуры command (см. листинг 2.8), которому в качестве параметров
передаются размер команды и имя функции, реализующей алгоритм выполнения
операции. Все функции собраны в классе Command, что подчеркивает префикс в
имени функции.
Теперь рассмотрим реализацию основного алгоритма процессора (листинг 2.12).
Листинг 2.12. Основной цикл процессора
// главный цикл интерпретатора------------------------------------class OddPCexception {};
// Исключения – в качестве примера
int Computer::interpreter(bool debug)
{ int stoping = 1; uByte nByte = 0;
while(stoping)
{ Clear(); jumping = false;
if (regs.PC & 0x00000001u) throw OddPCexception();
// выборка 2 байтов
RC.rc[0] = memory.b[regs.PC]; RC.rc[1] = memory.b[regs.PC+1];
// вычисление длины команды
if (RC.Code < 0xFF)
// основной набор команд
{ nByte = Cmd[RC.Code].Length; }
else
// дополнительные команды
{ nByte = ffCmd[RC.FF.Code].Length; }
// добираем из памяти байты
for(int i = 2; i < nByte; ++i) RC.rc[i] = memory.b[regs.PC+i];
if (regs.PSW.TF) Trace();
// отладочная выдача
// выполнение команды - косвенный вызов по указателю
// код операции - индекс в массиве адресов
if (RC.Code < 0xFF) stoping =
Cmd[RC.Code].function(*this);
else
stoping = ffCmd[RC.FF.Code].function(*this);
// если не команда перехода
if (!jumping) regs.PC += nByte;
// изменение PC
regs.PC %= (mKb * 1024);
}
return 0;
// ограничение памяти
}
В начале цикла обнуляется регистр RC и сбрасывается флаг jumping. Этот флаг
определен как поле в классе Computer и устанавливается в true только командами
перехода (аналогично тому, как это делают команды перехода в интерпретаторе
AMS). Затем в регистр RC записываются первые два байта команды из памяти. В
переменной nByte вычисляется общая длина команды и в RC «добираются»
недостающие байты.
Если в PSW установлен флаг TF, то осуществляется трассировка команды (см. листинг
2.11). Далее вызывается функция, выполняющая команду, и ей в качестве параметра
передается текущий объект-компьютер (*this). После выполнения команды — если
это не команда перехода — изменяется счетчик команд. Команды перехода изменяют
счетчик команд самостоятельно, о чем и сигнализирует флаг jumping.
Так как количество памяти в VM ограничено, нужно корректировать PC в случае
превышения максимального адреса. Именно это и выполняет оператор
regs.PC %= (mKb * 1024);
Заметьте, что именно так работают реальные компьютеры. Мы же в интерпретаторе
можем на месте этой строки поставить проверку превышения предела и прекращение
работы интерпретатора. Это может быть полезно при работе в режиме отладки
интерпретируемой программы.
Цикл завершается, если в переменной stoping оказывается ноль. Это значение
возвращает функция-команда остановки процессора Stop(). Такое же поведение
реализуется при аварийных результатах выполнения команд.
Выполнение команд
Все функции собраны в класс Command и реализованы как статические методы. В
этом же классе реализован ряд закрытых методов, которые необходимы для
реализации некоторых команд.
Класс Command является другом класса Computer, поэтому все функции имеют
доступ к регистру команд RC. Код операции команды основного набора находится в
RC.rc[0], а код операции команды из дополнительных наборов — в RC.rc[1].
Следующие байты занимают аргументы команд. Каждая функция «знает» формат
своей команды, поэтому для обращения к частям команды использует
соответствующее поле регистра команд. Наиболее просто реализуются команды, в
которых аргументами являются регистры и непосредственные аргументы: в листинге
2.13 показаны те из них, которые используются в примере программы для VM (см.
табл. 1.5). В данном случае не показана обработка аварийных ситуаций при
выполнении команды.
Листинг 2.13. Реализация регистровых команд
// целая арифметика---------------------------------------static int cINC(Computer &VM)
{ ++VM.regs.ron.w[VM.RC.CR.R/4];
return 1;
}
static int cINCUI(Computer &VM)
{ VM.regs.ron.w[VM.RC.CRuS.R/4] += VM.RC.CRuS.uS;
return 1;
}
static int cABS(Computer &VM)
{ if (VM.regs.ron.w[VM.RC.CR.R/4]<0)
VM.regs.ron.w[VM.RC.CR.R/4] = -VM.regs.ron.w[VM.RC.CR.R/4];
return 1;
}
static int cNEG(Computer &VM)
{ VM.regs.ron.w[VM.RC.CR.R/4] = -VM.regs.ron.w[VM.RC.CR.R/4];;
return 1;
}
static int cADD(Computer &VM)
{ VM.regs.ron.w[VM.RC.CRRR.R1/4] =
VM.regs.ron.w[VM.RC.CRRR.R2/4] + VM.regs.ron.w[VM.RC.CRRR.R3/4];
return 1;
}
// сравнения--------------------------------------------------------static int cCMP(Computer &VM)
{ if
(VM.regs.ron.w[VM.RC.CRRR.R1/4]>VM.regs.ron.w[VM.RC.CRRR.R3/4])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = +1;
else if(VM.regs.ron.w[VM.RC.CRRR.R1/4]==VM.regs.ron.w[VM.RC.CRRR.R3/4])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = 0;
else if(VM.regs.ron.w[VM.RC.CRRR.R1/4]<VM.regs.ron.w[VM.RC.CRRR.R3/4])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = -1;
return 1;
}
// с непосредственным операндом
static int cCMPUI(Computer &VM)
{ if (VM.regs.ron.uw[VM.RC.CRRR.R1/4] > uByte(VM.RC.CRRB.iB))
VM.regs.ron.w[VM.RC.CRRR.R2/4] = +1;
else if (VM.regs.ron.uw[VM.RC.CRRR.R1/4] == uByte(VM.RC.CRRB.iB))
VM.regs.ron.w[VM.RC.CRRR.R2/4] = 0;
else if (VM.regs.ron.uw[VM.RC.CRRR.R1/4] < uByte(VM.RC.CRRB.iB))
VM.regs.ron.w[VM.RC.CRRR.R2/4] = -1;
return 1;
}
// очистка регистров----------------------------------------------static int cCLRW(Computer &VM)
{ VM.regs.ron.uw[VM.RC.CR.R/4] = 0;
return 1;
}
static int cCLRD(Computer &VM)
{ VM.regs.ron.ul[VM.RC.CR.R/8] = 0;
return 1;
}
// дробная арифметика----------------------------------------------static int cFADD(Computer &VM)
{ VM.regs.ron.d[VM.RC.CRRR.R1/8] =
VM.regs.ron.d[VM.RC.CRRR.R2/8] + VM.regs.ron.d[VM.RC.CRRR.R3/8];
return 1;
}
static int cFDIV(Computer &VM)
// не проверяется деление на 0!
{ VM.regs.ron.d[VM.RC.CRRR.R1/8] =
VM.regs.ron.d[VM.RC.CRRR.R2/8] / VM.regs.ron.d[VM.RC.CRRR.R3/8];
return 1;
}
static int cFCMP(Computer &VM)
{ if (VM.regs.ron.d[VM.RC.CRRR.R1/8] > VM.regs.ron.d[VM.RC.CRRR.R3/8])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = +1;
else if(VM.regs.ron.d[VM.RC.CRRR.R1/8]==VM.regs.ron.d[VM.RC.CRRR.R3/8])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = 0;
else if(VM.regs.ron.d[VM.RC.CRRR.R1/8] < VM.regs.ron.d[VM.RC.CRRR.R3/8])
VM.regs.ron.w[VM.RC.CRRR.R2/4] = -1;
return 1;
}
// вычисление функций и загрузка констант
static int cFLN(Computer &VM)
{ VM.regs.ron.d[VM.RC.CR.R/8] = log(VM.regs.ron.d[VM.RC.CR.R/8]);
return 1;
}
static int cFLD1(Computer &VM)
{ VM.regs.ron.d[VM.RC.CR.R/8] = 1.0; return 1;
}
static int cFLDE(Computer &VM)
{ VM.regs.ron.d[VM.RC.CR.R/8] = exp(1.0); return 1;
}
static int cFLDPI(Computer &VM)
{ VM.regs.ron.d[VM.RC.CR.R/8] = M_PI; return 1;
}
// загрузка непосредственная----------------------------------static int cLDSI(Computer &VM)
// операнд - в команде
{ VM.regs.ron.s[VM.RC.CRiS.R/2] = VM.RC.CRiS.iS;
return 1;
}
static int cLDWI(Computer &VM)
// операнд - в команде
{ VM.regs.ron.w[VM.RC.CRiW.R/4] = VM.RC.CRiW.iW;
return 1;
}
// обмен и пересылка регистров - дополнительные команды----------static int cSWPW(Computer &VM)
{ uWord t = VM.regs.ron.uw[VM.RC.FCRR.R1/4];
VM.regs.ron.uw[VM.RC.FCRR.R1/4] = VM.regs.ron.uw[VM.RC.FCRR.R2/4];
VM.regs.ron.uw[VM.RC.FCRR.R2/4] = t;
return 1;
}
static int cMOVW(Computer &VM)
{ VM.regs.ron.uw[VM.RC.FCRR.R1/4] = VM.regs.ron.uw[VM.RC.FCRR.R2/4];
return 1;
}
Аналогично реализуются и остальные команды с регистровыми операндами. Так как
в машинной команде номер регистра-слова кратен 4, то для правильного обращения к
соответствующему слову массива регистров VM.regs.ron требуется делить этот
номер на 4. Соответственно, для обращения к регистру-двойному слову требуется
делить на 8, а при обращении к регистру-полуслову — на 2.
Помимо регистров в ряде команд присутствует непосредственный операнд.
Например, в команде сравнения CMPUI непосредственный операнд является третьим
операндом. В функции, реализующей алгоритм выполнения команды CMPUI, операнд
указывается как uByte(VM.RC.CRRB.iB).
Обратите внимание на функции INCUI, LDSI, LDWI, которые используют
непосредственный операнд, обращаясь к соответствующему полю команды (см.
листинг 2.9) в регистре RC.
Теперь рассмотрим реализацию функций, выполняющих алгоритмы команд,
обращающихся к памяти, — загрузки-сохранения (листинг 2.14).
Листинг 2.14. Реализация команд загрузки-сохранения
// загрузки-сохранения ------------------------------------------static int cLDD(Computer &VM)
// операнд - в памяти
{ VM.Address = VM.regs.ron.a[VM.RC.CRRR.R2/4] +
VM.regs.ron.a[VM.RC.CRRR.R3/4];
VM.Address %= (mKb * 1024);
for(int i = 0; i < sizeof(Double); ++i)
VM.regs.ron.ub[VM.RC.CRRR.R1+i] = VM.memory.b[VM.Address+i];
return 1;
}
static int cSTD(Computer &VM)
// операнд - в память
{ VM.Address = VM.regs.ron.a[VM.RC.CRRR.R2/4] +
VM.regs.ron.a[VM.RC.CRRR.R3/4];
VM.Address %= (mKb * 1024);
for(int i = 0; i < sizeof(Double); ++i)
VM.memory.b[VM.Address+i] = VM.regs.ron.b[VM.RC.CRRR.R1+i];
return 1;
}
static int cLDWA(Computer &VM)
// операнд - в памяти
{ VM.Address = VM.RC.CRaW.aW;
// адрес из команды
VM.Address %= (mKb * 1024);
// ограничение по памяти
for(int i = 0; i < sizeof(Word); ++i)
// побайтно в память
VM.regs.ron.ub[VM.RC.CRaW.R+i] = VM.memory.b[VM.Address+i];
return 1;
}
static int cSTWA(Computer &VM)
// операнд - в память
{ VM.Address = VM.RC.CRaW.aW;
VM.Address %= (mKb * 1024);
for(int i = 0; i < sizeof(Word); ++i)
VM.memory.b[VM.Address+i] = VM.regs.ron.b[VM.RC.CRaW.R+i];
return 1;
}
Во всех этих командах сначала вычисляется эффективный адрес памяти: либо
складываются регистры, либо адрес выбирается непосредственно из команды. При
этом величина адреса ограничивается текущим размером адресного пространства
VM. Обратите внимание, что чтение из памяти и запись в память выполняются
побайтно. Это позволяет не следить за выравниванием данных по кратным границам.
Теперь рассмотрим реализацию функций, выполняющих алгоритмы команды цикла и
команд условного перехода (листинг 2.15).
Листинг 2.15. Реализация команды цикла и команд условного перехода
// команды цикла--------------------------------------------static int cLOOPR(Computer &VM)
// адрес перехода – в регистрах
{ --VM.regs.ron.uw[VM.RC.CRRR.R1/4];
if (VM.regs.ron.uw[VM.RC.CRRR.R1/4] != 0)
{ VM.Address = VM.regs.ron.a[VM.RC.CRRR.R2/4]+
VM.regs.ron.a[VM.RC.CRRR.R3/4];
VM.Address %= (mKb * 1024); VM.regs.PC = VM.Address;
VM.jumping = true;
}
return 1;
}
static int cLOOP(Computer &VM)
// смещение 2 байта
{ --VM.regs.ron.uw[VM.RC.CROO.R/4];
if (VM.regs.ron.uw[VM.RC.CROO.R/4] != 0)
{ VM.regs.PC+=(2*VM.RC.CROO.OO); VM.regs.PC%=(mKb * 1024);
VM.jumping = true;
}
return 1;
}
// условные переходы-----смещение 2-байта----------------static int cJL(Computer &VM)
{ if (VM.regs.ron.w[VM.RC.CROO.R/4] < 0)
{ VM.regs.PC+=(2*VM.RC.CROO.OO); VM.regs.PC%=(mKb * 1024);
VM.jumping = true;
}
return 1;
}
static int cJZ(Computer &VM)
{ if (VM.regs.ron.w[VM.RC.CROO.R/4] == 0)
{ VM.regs.PC+=(2*VM.RC.CROO.OO); VM.regs.PC%=(mKb * 1024);
VM.jumping = true;
}
return 1;
}
static int cJGE(Computer &VM)
{ if (VM.regs.ron.w[VM.RC.CROO.R/4] >= 0)
{ VM.regs.PC+=(2*VM.RC.CROO.OO); VM.regs.PC %= (mKb * 1024);
VM.jumping = true;
}
return 1;
}
static int cJOD(Computer &VM)
{ if (VM.regs.ron.w[VM.RC.CROO.R/4]%2)
{ VM.regs.PC+=(2*VM.RC.CROO.OO); VM.regs.PC %= (mKb * 1024);
VM.jumping = true;
}
return 1;
}
Остальные команды условного перехода реализуются совершенно аналогично.
Нужно отметить несколько важных особенностей в реализации всех функций. Во-
первых, смещение в командах перехода считается в полусловах, поскольку размеры
команд кратны 2. Поэтому «собранное» смещение нужно умножать на 2.
При любых операциях с адресами требуется моделировать ограничения памяти —
это делает оператор:
VM.Address %= (mKb * 1024);
Команды перехода отличаются от всех прочих команд тем, что непосредственно
изменяют значение счетчика команд. Поэтому все они устанавливают внутренний
флаг jumping, сигнализируя процессору о том, что стандартного изменения PC не
требуется.
Отдельно нужно остановиться на реализации операций безусловного перехода и
перехода к подпрограмме. Дело в том, что во всех командах, кроме команд JMP и
CALL, операнды имеют размеры 1, 2 или 4 байта, поэтому при реализации мы имеем
возможность отобразить такие операнды на типы данных С++. В командах же JMP и
CALL операнд-смещение имеет размер 3 байта, поэтому этот операнд требуется
извлечь из
команды и «собрать» в 4-хбайтное целое. В листинге 2.16
демонстрируется, как это сделать.
Листинг 2.16. Реализация команд перехода
// Компоновка смещения 3 байта
static Word Oprnd3bJ(Computer &VM)
{ union op3
{ uByte arg[4];
// отдельные байты операнда
Word operand;
// собранный аргумент команды
} arg;
arg.arg[0] = VM.RC.rc[1]; arg.arg[1] = VM.RC.rc[2];
arg.arg[2] = VM.RC.rc[3];
//если старший бит = 1, то нужно 255 писать в старший байт
if(arg.arg[2] & 0x80) arg.arg[3] = 0xFF; else arg.arg[3] = 0;
return arg.operand;
}
// косвенный регистровый безусловный переход
static int cJMPR(Computer &VM)
{ VM.regs.PC = VM.regs.ron.a[VM.RC.CR.R/4];
// переход абсолютный
VM.jumping = true;
return 1;
}
// относительный безусловный jmp ra
static int cJMP(Computer &VM)
{ Word operand = Oprnd3bJ(VM);
// сборка смещения
VM.regs.PC += (2*operand);
// переход относительный
VM.regs.PC%=(mKb * 1024);
return 1;
}
// переход к подпрограмме--------------------------static int cCALLR(Computer &VM)
{ Computer::word W;
W.a = VM.regs.PC + VM.Cmd[CALLR].Length;
// адрес следующей
VM.s.push(W);
// в стек
VM.regs.PC = VM.regs.ron.a[VM.RC.CR.R/4];
// переход абсолютный
VM.jumping = true;
return 1;
}
static int cCALL(Computer &VM)
{ Computer::word W;
W.a = VM.regs.PC + VM.Cmd[CALL].Length;
// адрес следующей
VM.s.push(W);
// в стек
Word operand = Oprnd3bJ(VM);
// сборка смещения
VM.regs.PC += (2*operand);
// переход относительный
VM.regs.PC%=(mKb * 1024);
VM.jumping = true;
return 1;
}
// возврат из подпрограммы------------------------------static int cRET(Computer &VM)
{ VM.regs.PC = VM.s.top().a;
// RA - из стекa
VM.s.pop();
uByte N = VM.RC.CN.N;
// извлечь из стека N слов
while( N > 0 ) { VM.s.pop(); --N; }
VM.jumping = true;
return 1;
}
Смещение может быть как положительным, так и отрицательным, поэтому при
сборке 3-хбайтного смещения приходится явным образом выставлять старшие 8 бит.
Обратите внимание на то, как в командах перехода к подпрограмме записывается в
стек адрес следующей команды. Так как в PC во время работы команды CALL
находится ее собственный адрес, в стек нужно записывать не значение счетчика
команд, а увеличенное на длину команды значение. Выполнение самого перехода не
отличается действий команд безусловного перехода.
Функция, выполняющая возврат из подпрограммы, кроме извлечения адреса,
извлекает из стека N слов, что задает операнд в команде. Как обычно команда
переходы устанавливает флаг jumping.
И наконец, продемонстрируем работу команд со стеком. В листинге 2.17
представлена часть функций, реализующих алгоритмы стековых команд.
Листинг 2.17. Команды для работы со стеком
// стековые-операции-------------------------------------static int cPUSH(Computer &VM)
{ Computer::word W; W.uw = VM.regs.ron.uw[VM.RC.CR.R/4];
VM.s.push(W);
return 1;
}
static int cPOP(Computer &VM)
{ Computer::word W; W.uw = VM.s.top().uw;
VM.regs.ron.uw[VM.RC.CR.R/4] = W.uw;
VM.s.pop();
return 1;
}
static int cPUSHM(Computer &VM)
{ address a = VM.regs.ron.a[VM.RC.CR.R/4];
// адрес из
Computer::word W;
W.uw = VM.memory.w[a];
// слово из
VM.s.push(W);
// в стек
return 1;
}
static int cPOPM(Computer &VM)
{ Computer::word W; W.uw = VM.s.top().uw;
address a = VM.regs.ron.a[VM.RC.CR.R/4];
// адрес из
VM.memory.w[a] = W.uw;
// запись в
VM.s.pop();
return 1;
}
static int cSTSP(Computer &VM)
{ Computer::word W; W.uw = VM.s.top().uw;
VM.regs.ron.uw[VM.RC.CR.R/4] = W.uw;
return 1;
}
static int cSTSPM(Computer &VM)
{ Computer::word W; W.uw = VM.s.top().uw;
address a = VM.regs.ron.a[VM.RC.CR.R/4];
VM.memory.w[a] = W.uw;
return 1;
}
static int cFADDS(Computer &VM)
{ VM.s.top().f += VM.regs.ron.f[VM.RC.CR.R/4];
return 1;
}
static int cFADDR(Computer &VM)
{ VM.regs.ron.f[VM.RC.CR.R/4] += VM.s.top().f;
регистра
памяти
регистра
память
return 1;
}
В качестве примера арифметических операций приведены функции сложения с
занесением результата в стек или в регистр. Остальные арифметические операции
реализуются аналогично.
Операции STSP и STSPM отличаются от операций PUSH и PUSHM только тем, что не
извлекают элемент из стека.
Внимательный читатель должен заметить, что в реализации стековых команд
обращение к памяти выполняется словами, а не байтами. Такая реализация требует,
чтобы программист при использовании стековых команд следил за выравниванием
данных в памяти по границе слова. Если читателя не уситраивает это, то он может
реализовать обращение к памяти побайтно, аналогично тому, как это сделано в
командах загрузки-созранения (см. листинг 2.14).
Для выполнения программы из примера в табл. 1.5 нам потребуется реализация
команд ввода-вывода длинных вещественных чисел.
static int cFIND(Computer &VM)
{ Double t; cin >> t;
VM.regs.ron.d[VM.RC.CR.R/8] = t;
return 1;
}
static int cFOUTD(Computer &VM)
{ cout << VM.regs.ron.d[VM.RC.CR.R/8] << endl;
return 1;
}
Как видите, это самые простые функции. Читатель по аналогии легко реализует
любые необходимые ему команды ввода-вывода.
Аварийные ситуации в работе процессора
До сих пор мы не обращали внимания на возможные аварийные ситуации, которые
могут возникнуть во время работы процессора или при выполнении какой-нибудь
команды. Аналогично работе со стеком, пока нам нет необходимости моделировать
«аппаратный» механизм прерываний. Реализация «аппаратного» механизма для
виртуальной машины требуется в двух случаях: при написании операционной
системы и при написании отладчика. Для интерпретатора команд достаточно
моделировать прерывания с помощью исключений.
Рассмотрим возможные причины возникновения аварий в нашей виртуальной
машине. Начнем с наиболее общих аварийных ситуаций.
Во-первых, значение счетчика команд всегда должно быть четным. Эту проверку
легко осуществить в самом начале основного цикла интерпретатора до выборки
первых двух байт команды (см. листинг 2.12):
if (regs.PC & 0x00000001u) throw OddPCException();
Во-вторых, интерпретатор может выбрать из памяти байт, который не является кодом
операции. В реализованной версии соответствующие элементы массива указателей
просто равны нулю (что соответствует коду операции команды останова). Для всех
таких кодов можно реализовать специальную функцию, генерирующую исключение,
например:
int invalidCode(Computer &VM)
{ throw InvalidCodeException(); }
Функция обычным образом прописывается конструктором в массивах Cmd на месте
отсутствующих кодов операций.
В-третьих, каждая регистровая команда обязана проверять корректность номеров
регистров. Например, номер регистра-слова должен быть кратен четырем. Тогда
проверка номера регистра в командах типа mCR_2 может быть реализована так:
if (VM.RC.CR.R % 4) throw InvalidRegisterException();
Аналогичные действия реализуются и в других регистровых командах для каждого
из регистров.
Важные аварийные ситуации могут возникнуть в командах перехода. Например,
адрес в регистре в команде косвенного перехода JMP $A обязан быть четным2:
if (VM.regs.ron.a[VM.RC.rc[1]] % 2) throw OddPCException();
Кроме того, команды целой и дробной арифметики должны проверять особые
случаи: деление на ноль, переполнение, деление на ноль, антипереполнение. Ряд
команд, вычисляющих функции (например, FLN), обязаны проверять свой аргумент
на допустимость.
Реализация набора классов исключений не составляет труда. Можно построить
иерархию исключений с базовым классом exceptionProcessor. Этот класс может
быть реализован либо как независимый, либо как наследник от одного из
стандартных классов исключений.
В секцию-ловушку catch можно передавать объект-компьютер — тогда обработчик
будет иметь полную информацию о состоянии компьютера в момент аварии.
Поскольку регистр-счетчик команд PC изменяется после выполнения команды, то в
случае аварийной ситуации в PC будет находиться адрес аварийной команды.
В секции- ловушке можно вызывать функцию, устанавливающую соответствующий
бит в PSW и выполняющую аварийный вывод информации о состоянии виртуальной
машины в файл-журнал. Оставляем реализацию описанных возможностей читателю.
Загрузчик программ VM
При разработке VM мы не упоминали действия по «кнопке» Пуск. Однако схема
действий остается той же, что и для AMS: запускается программа-загрузчик и после
загрузки выполняется инициализация регистров (методом reset()) и запускается
программа, находящейся в памяти.
2
Хотя эту проверку можно оставить основному циклу процессора.
Отметим, что схема «загрузка-выполнение», вообще говоря, не требует
обязательного моделирования памяти в виде массива фиксированного размера.
Загрузчик может загрузить программу в динамическую память, а указатель передать
интерпретатору. Другой вариант — считать программу в контейнер-вектор и
передать его интерпретатору в качестве аргумента. Тем не менее, поскольку память
уже смоделирована, реализуем загрузку программы в нее.
Для VM можно разработать более гибкий и удобный загрузчик по сравнению с
загрузчиком AMS. Вообще говоря, загрузчик обязан «уметь» делать следующее:
 загружать программу в любое место памяти VM;
 устанавливать в PC виртуальной машины адрес первой выполняемой команды
загруженной программы.
Эти свойства повышают «степень свободы» при написании программ для VM.
Очевидно, что и адрес загрузки, и адрес запуска должны быть записаны в
загружаемом файле. Адрес загрузки удобно записать в первой записи загружаемого
файла, а стартовый адрес программы — в последней. Тогда формат загружаемого
файла может быть таким:
a адрес загрузки
Команды и данные
e стартовый адрес
Формат адресов, команд и данных требует детального рассмотрения, так как по
сравнению с AMS виртуальная машина VM имеет значительно более сложную
архитектуру памяти и много разных форматов команд и данных:
 адресуемым элементом является байт;
 для размещения данных и команд байты группируются по 1, 2, 4, 6 и 8;
 данные одного вида имеют разную длину: вещественные числа могут быть
короткими и длинными, а целые имеют 4 варианта длины.
 команды имеют разную длину (2, 4, 6, 8 байт) и разный формат;
Эти особенности приводят к тому, что обойтись всего тремя типами записей, как в
загружаемом файле для AMS, в данном случае не удастся. Например, для
вещественных чисел требуется два типа записей, для целых — четыре типа записей.
Тип записи для представления чисел можно представить единственным символом, с
помощью которого задается и тип числа, и его размер, например:
f
d
b
s
w
l
1.2
-4.67
127
-32768
4567
12345678
Однако для команд обойтись одним символом не удастся, поскольку команды имеют
не только разную длину, но и при одинаковой длине имеют разный формат — это
можно видеть в листинге 2.9, где определяются типы для регистра команд. Для
однозначной идентификации типа команды требуется разработать более развитую
систему обозначений. Мы этого делать не будем, поскольку это значительно
усложнит загрузчик и фактически прератит его в транслятор типов записей.
Мы поступим по-другому: команды и данные в загружаемом файле будут
представлены непосредственно в шестнадцатеричном виде побайтно. Адреса тоже
будут записываться как шестнадцатеричные числа. Это потребует кропотливого
труда программиста при подготовке программы, но существенно упрощает
загрузчик.
Такой формат программы называется абсолютным двоичным форматом. Можно
придумать любой другой двоичный формат программы, однако суть от этого не
меняется: программа записана в абсолютных двоичных кодах.
Естественно записывать программу по одной команде в строке. Будем отделять
каждый байт (2 шестнадцатеричные цифры) пробелом. Данные записываются в такой
же шестнадцатеричной форме по одному числу на строке.
При записи программы в машинном виде нужно быть очень внимательным, так как
единственная ошибка в цифрах сделает программу неработоспособной. Поэтому
сначала нужно записать программу в символическом виде, а затем вручную
перевести ее в числовой вид. Коды операций легко перевести по таблице кодов.
Однако при переводе нельзя забывать об особенностях машинного представления
регистров: номера регистров–слов нужно умножать на 4, полуслов и двойных слов —
на 2 и 8 соответственно. Особенно сложно вычислять смещения в командах
относительного перехода: нужно вычислить смещение команды перехода от начала
программы, смещение команды, куда следует переход, взять разницу, и поделить
пополам. После этого перевести полученное число в шестнадцатеричную систему.
Если выполняется переход назад, то нужно вычислить дополнительный код и
представить его в шестнадцатеричном виде.
Помимо всего прочего нужно не забывать, что целые числа размещаются в памяти в
соответствии с правилом: «младшая часть по младшему адресу». То же самое
относится и к операндам в командах. В частности, смещение в командах перехода
тоже нужно представлять в таком виде.
П р им еч а н ие
Всю эту работу можно автоматизировать, разработав ассемблер для VM (см. главу 3 и
главу 4).
Рассмотрим в качестве примера программу, представленную в табл. 1.5.
Переведенная программа в формате загружаемого файла представлена в листинге
2.18 (команды пронумерованы, однако номера не являются частью файла, а показаны
только для удобства).
Листинг 2.18. Загружаемый файл для VM
a 0000
1. k 36
2. k 32
28 00 08 00 00
2C
3. k
4. k
5. k
6. k
7. k
8. k
9. k
10. k
11. k
12. k
13. k
14. k
15. k
e 0000
36
98
33
F6
2F
85
0E
83
80
7B
1D
F7
01
30
08
F0
A0
A0
A0
14
A0
F0
2C
30
F0
00
05 00 00 00
28
14
06
08
F0
08
f3
2C
08
00
A0
A0
00
ff
Коды операций — это первая колонка чисел. Во второй колонке во всех командах
указаны регистры. Рассмотрим процесс перевода регистров, записанных в программе
из табл. 1.5. Регистр w10 переводится так: 10 * 4 = 4010 = 2816. Соответственно,
регистры-слова w11, w12 и w5 переводлятся в 2С16, 3016, 1416. Регистры-двойные
слова d1, d20 и d30 переводятся соответственно в 0816, 20 * 8 = 16010 = A016, 30 * 8 =
24010 = F016.
В командах 1, 3, 9, 12, 13 второй операнд либо смещение, либо непосредственный
операнд. В строке 1 (команда LDWI) непосредственный операнд записан в обратном
порядке байтов3. При загрузке этой команды байты памяти будут заполнены
следующим образом:
байт
байт
байт
байт
байт
байт
00:
01:
02:
03:
04:
05:
36
28
00
08
00
00
Если вы прочитаете байты сверху вниз, то увидите, что младшие разряды числа –
непосредственного операнда – записаны в байтах с меньшими адресами. В строке 3
(команда LDWI) аналогичным образом задается константа 0005 – количество
повторений цикла. То же самое можно видеть в строке 12 (команда INCUI), где
аналогично задается непосредственный операнд 0008.
Рассмотрим команду условного перехода JGE (строка 9). При загрузке байты будут
записаны так (адреса показаны в табл. 1.5).
байт
байт
байт
байт
3
26:
27:
28:
29:
0E
14
06
00
Напомним, что память VM организована по принципу little-endian (см. главу 1).
И в этом случае читая число сверху вниз , получаем правильную запись смещения в
памяти. Смещение 0006 от байта 0028 означает, что переход выполняется вперед на
12 байтов. Если команда сработает, то следующие 2 байта процессор будет выбирать
по адресу 0028 + 0006 * 2 = 0040. Как можно видеть в табл. 1.5, по этому адресу
записана команда INCUI (строка 12 в загружаемом файле).
В строке 13 записана команда цикла, второй операнд которой является
отрицательным смещением. При загрузке байты памяти будут записаны так:
байт
байт
байт
байт
42:
43:
44:
45:
1D
30
f3
ff
Таким обращзом, смещение представляет собой шестнадцатеричное число fff3. В
двоичном виде это число записывается как 1111 1111 1111 00112. Переведя в
дополнительный код, получим 0000 0000 0000 11012 = 000D16 = 1310. Это означает,
что исходное число было -1310. Таким образом, если команда цикла выполнит
переход, то адрес перехода будет вычислен так: 44 – 13 * 2 = 18. По адресу 18
записана команда ввода FIND.
После определения формата загружаемого файла можно реализовать программузагрузчик. Параметров у загрузчика два: файл с программой и объект-компьютер.
int loader(char *filename, Computer &VM);
Программа загружается в память объекта-компьютера, и загрузчик устанавливает
начальный адрес запуска загруженной программы. После этого запускается
интерпретатор. Реализация загрузчика представлена в листинге 2.19.
Листинг 2.19. Программа-загрузчик для VM
bool loaderTXT(const char *filename, Computer &VM)
{ unsigned int loadaddr = 0;
// адрес загрузки
ifstream binary(filename);
// файл с программой
if(binary.is_open()){
// если файл открылся
string code;
// введенная строка
unsigned int bincode=0;
// байт из строки
address pc = 0;
// адрес запуска программы
istringstream in;
// строковый поток перевода
getline(binary, code);
// ввод адреса загрузки
if (code[0] != 'a')
// нет записи со стартовым адресом
{ cout << "No load address" << endl;
return false;
}
else
// перевод стартового адреса
{ code = code.substr(1);
// отрезали тип записи a
in.str(code); in >> hex >> bincode;
// перевели стартовый адрес
loadaddr = bincode;
}
getline(binary, code);
// ввод кода
while(!binary.eof())
{ if (code[0] != 'e')
// Конец кодов
{ code = code.substr(1);
// отрезали тип записи k
in.str(code); in.clear(); // инициализация строкового потока
while((in >> hex >> bincode))
// чтение побайтное
{ // запись в память
VM.memory.b[loadaddr] = static_cast<uByte>(bincode);
++loadaddr;
} // while
} // if
else break;
getline(binary, code);
// ввод кода
} // while
// последняя строка уже была прочитана
in.clear(); in.str(code.substr(1)); in >> hex >> pc;
VM.setPC(pc%0x10000); return true;
}
else
// файл не открылся!
{ cout << filename << " - not open!" << endl;
return false;
}
} // loader
Работа функции практически не требует комментариев. Если файл успешно
отклылся, то читается стартовая запись и адрес загрузки записывается в переменную
loadaddr. Затем строки загружаемого файла последовательно читаются и заносятся
в память до тех пор, пока не будет прочитана завершающая запись. Из нее
выбирается стартовый адрес программы и заносится в регистр PC объектакосмпьютера. Если все прошло успешно, то загрузчик возвращает true. Если же
возникли ошибки, то возвращается false.
Загрузчик прописан в главном модуле проекта, и вызывается из главной функции
(листинг 2.20).
Листинг 2.20. Главная программа интерпретатора
int main(int argc, char *argv[])
{ bool debug = false;
if (argc > 1)
{ Computer VM;
VM.reset(debug);
bool yes = false;
yes = loaderTXT(argv[1], VM);
if (yes) VM.interpreter(debug);
else cout << argv[1] << " - not run!" << endl;
}
else cout << "No file in command line!" << endl;
system("pause");
}
Программа получает имя загружаемого файла из параметров командной строки.
П р им еч а н ие.
Корректность имени файла не проверяется, чтобы не отвлекать читателя от главной
задачи — разработки интерпретатора. Читатель может самостоятельно написать
функцию проверки и добавить ее в главный модуль проекта
Если в командной строке имя не задано, то выводится сообщение и программа
заканчивает работу. Если имя задано, то создается объект-компьютер, и вызывается
метод reset() и потом вызывается загрузчик. Если у загрузчика все прошло
нормально, то он сообщает об этом, возвращая true. В этом случае можно запустить
метод interpreter(), который и выполняет загруженную программу.
Вызов загрузчика можно производить так:
Computer VM;
if (!loaderTXT(argv[1], VM))
VM.interpreter();
// объект-компьютер
// загрузка программы
// выполнение программы
При вызове методов reset() и interpreter() им передается булевская
переменная debug. Если эта переменная имеет значение true, то метод reset()
установит флаг TF в PSW, что приведет к вызову метода Trace() трассировки
команды в основном цикле процессора (см. листинг 2.5).
Для отладки алгоритмов процессора и команд полезно иметь еще несколько
вспомогательных функций, выполняющих вывод содержимого регистров, памяти и
стека на экран.
О проекте интерпретатора VM
Программа-интерпретатор VM значительно сложнее программы-интерпретатора
AMS. Объем интерпретатора VM составляет порядка 1000 строк исходного текста.
Проект интерпретатора VM содержит следующие модули:
1.
command.h — модуль, в котором записаны функции, исполняющие алгоритмы
команд;
2.
computer.h — интерфейс класса Computer; здесь же записаны классы регистров
и памяти;
3.
4.
computer.cpp — модуль реализации класса Computer;
types.h — основные типы, используемые в других модулях; в частности, типы
для регистра команд; сюда же можно вынести и типы для регистров и памяти;
5.
6.
operation.h — модуль, в котором записаны перечисления с кодами операций;
interpreterVM.cpp — основной модуль, в котором записан текст загрузчика и
главная функция.
При расширении системы команд нужно выполнить следующие действия:
 определить код операции в перечислении кодов операций (модуль operations.h);
 добавить в текст конструктора класса Computer (модуль computer.cpp) оператор
с заполнением соответствующего элемента массива Cmd;
 можно (но не обязательно) добавить тип формата команды для регистра команд
(модуль types.h) и добавить соответствующее поле в регистр команд (модуль
computer.h);
 добавить реализацию функции, исполняющей алгоритм команды, в класс
Command (модуль commands.h).
Если потребуется модифицировать формат загружаемого файла, то нужно вносить
исправления в функцию loaderTXT (модуль interpreterVM.cpp).
Резюме
Для исполнения программ виртуальной машины требуется реализовать программуисполнителя, которая называется интерпретатором. Если архитектура машины
проста, то интерпретатор представляет собой простую программу небольшого
объема. В данной главе реализован интерпретатор машины AMS. Для виртуальной
архитектуры, какой является виртуальная машина VM, интерпретатор является
гораздо более сложной программой. Загрузку программ в память виртуальной
машины осуществляет программа-загрузчик. Программа для загрузчика
записывается в текстовом файле, формат которого разрабатывается с учетом
архитектуры виртуальной машины.
Контрольные вопросы и упражнения
1.
Объясните работу основного цикла процессора.
2.
Объясните назначение переменной Jump в интерпретаторе AMS.
3.
Дополните архитектуру AMS и реализуйте в интерпретаторе набор битовых
команд and, or, xor и not.
4.
Реализуйте интерпретатор AMF.
5.
Реализуйте команды сдвига учебной виртуальной машины VM.
6.
Объясните, почему в загружаемой программе для AMS числа записываются в
привычном виде, а в программе для VM мы отказались от такого представления.
7.
Объясните, зачем интерпретатор VM «добирает» байты из памяти?
8.
Реализуйте набор битовых команд VM.
9.
Объясните, почему нет необходимости моделировать для учебной виртуальной
машины аппаратную систему прерываний?
10. Дополните множество команд VM набором безадресных арифметических
команд. Где могут храниться аргументы таких команд?
11. Какие проблемы могут возникнуть
вещественного числа в целое число?
при
преобразовании
короткого
12. Реализуйте полный набор команд ввода-вывода целых и вещественных чисел.
13. Какие исключения должна генерировать функция, реализующая алгоритм
команды ADD?
14. Модифицируйте функции, реализующие арифметические команды, включив
установку аварийных флагов в регистре PSW.
15. Модифицируйте реализацию регистровых команд, чтобы выполнялась проверка
корректности номеров регистров, и генерировалось исключение.
16. Модифицируйте стековые команды так, чтобы не нужно было следить за
выравниванием данных в памяти.
17. Реализуйте дробную арифметику со всеми возможными проверками аргументов.
18. Модифицируйте формат файла загрузки, добавив для каждой команды адрес
размещения в памяти.
19. Модифицируйте загрузчик, чтобы он обрабатывал запись команды в формате:
адрес: код команды ;комментарий
20. Напишите вспомогательные функции, выводящие регистры общего назначения
любого размера и в задаваемом (десятичном, двоичном, шестнадцатеричном)
формате.
Глава 2. Реализация интерпретаторов ................................................................................1
Интерпретатор AMS ........................................................................................................1
Формат программы и загрузчик для AMS .....................................................................6
Интерпретатор VM ...........................................................................................................9
Модель памяти и регистров ...................................................................................... 10
Модель процессора ....................................................................................................12
Реализация основного цикла процессора .................................................................14
Выполнение команд ...................................................................................................22
Аварийные ситуации в работе процессора .............................................................. 31
Загрузчик программ VM ................................................................................................ 32
О проекте интерпретатора VM...................................................................................... 38
Резюме ............................................................................................................................. 39
Контрольные вопросы и упражнения ...........................................................................39
Download