
Прошло чуть больше года с момента публикации первой части. Я хоть и делал паузу, но проект не пылился в ящике, я занимался изучением различных аспектов работы микропроцессоров, смотрел видео, читал книги, справочники, документацию, задавал вопросы на Reddit, и кажется, пришло время поделиться продолжением моей небольшой истории.
В этой статье я не буду повторять основы — архитектурный скелет решения остался тем же, что и в первой части. Кстати, вот он:

Здесь я хочу поделиться прогрессом, которого удалось достичь, проблемами, с которыми пришлось столкнуться, и решениями, которые я нашел для каждой задачи.
Рекап для тех, кто не читал первую часть
Ранее я достаточно подробно описал, как и зачем я задумался о разработке своего симулятора микропроцессоров, описал свой подход, рассказал, на чем строится работа моего проекта, и даже затронул базовый ассемблер. Всё это описано в рамках микропроцессора MOS6502, распространенного в сфере эмуляторов/симуляторов. Там же я кратко затронул терминологию и объяснил, почему то, что я делаю — не эмулятор, а именно симулятор.
Я бы советовал ознакомиться с первой частью (ну или хотя бы пробежаться по диагонали), иначе будет совсем непонятно о чем я тут рассказываю. В конце концов, там много красивых картинок, я старался.
Что было после 6502
Уже на этапе написания первой части статьи я вовсю работал над Intel 8080. С ним в общем-то не было больших проблем, потому что выбранный мною подход, в силу простоты микропроцессора, работал здесь исправно.
Конечно, я начал замечать дублирующиеся участки кода, и мне пришлось выносить их в базовый класс CPU_Base (который позднее стал называться просто Compute). Туда переехали уже знакомые методы Run, Reset, а также LoadROM и ReadBinary, с помощью которых я тестировал ассемблерные программы для MOS6502. Для I8080 ассемблерные тесты тоже завезли, но в небольшом количестве.
Тогда же, с появлением базового класса, случился большой перерыв в работе, потому что мне пришлось серьезно задуматься над архитектурой проекта. Я рассматривал различные варианты дальнейшего развития, а главной темой размышлений было внедрение tick-системы, которая была бы источником импульсов для "вычислительного блока" (Compute Unit). Всё это позволило бы сделать симулятор максимально приближенным к работе реального процессора, когда каждая инструкция разбивается на атомарные шаги и превращается в стейт-машину, а каждый импульс tick-системы приводит к переходу на следующий шаг. В моем представлении это выглядело так:

Я думал над этим вариантом, пожалуй, слишком долго и в конце концов, оценив сложность данного подхода, объем старого кода, который нужно было бы переписать, и свои ресурсы, я решил отказаться от него. Happy end.
На данный момент цикл исполнения выглядит так:

Было бы странно, если бы от меня требовалось просто написать еще один набор инструкций. Меня ожидала парочка задач, для которых нужно было найти принципиально новое решение.
Например, работа с регистрами. В I8080 она строилась следующим образом: 8-битные регистры объединялись в пары, и одни инструкции могли работать с ними как с 16-битными, а другие — как с одним 8-битным (старшим или младшим). Ну и как предлагаете решать эту задачу? Я не придумал ничего лучше, чем ввести тип BiRegister, который строился как union, а внутри был один WORD-регистр и анонимная структура из двух BYTE-регистров H (High) и L (Low):
struct BiRegister {
union {
WORD Value;
struct {
BYTE H;
BYTE L;
};
};
BiRegister& operator=(const WORD& value) {
Value = value;
return *this;
}
BiRegister& operator=(const BiRegister& other) {
Value = other.Value;
return *this;
}
};
...
class I8080 final: public CPU_Base{
public:
...
BiRegister BC;
BiRegister DE;
BiRegister HL;
...
...В таком случае обращение к регистру C имело вид CPU.BC.H, и это, мягко говоря, не отражало действительное имя регистров. Позднее я пересмотрел своё решение, и всё стало чуть проще, хоть и не лишенным магии с макросами:
#define DECLARE_PAIRED_REG(SUB_SIZE, RESULT_SIZE, NAME1, NAME2) \
union{ \
struct{ \
SUB_SIZE NAME1; \
SUB_SIZE NAME2; \
}; \
RESULT_SIZE NAME1##NAME2; \
}
Суть остается та же, но регистр не выносится как тип, а декларируется в теле класса как анонимный union. Имя же "спаренного" 16-битного регистра получается из склеивания через токен NAME1##NAME2. В коде выглядит так:
class I8080 final: public Compute{
public:
...
DECLARE_PAIRED_REG(BYTE, WORD, B, C); /**< Paired BC Register */
DECLARE_PAIRED_REG(BYTE, WORD, D, E); /**< Paired DE Register */
DECLARE_PAIRED_REG(BYTE, WORD, H, L); /**< Paired HL Register */
...
После раскрытия макросов получаем:
class I8080 final: public Compute{
public:
...
union{
struct{
BYTE B;
BYTE C;
}
WORD BC;
};
union{
struct{
BYTE D;
BYTE E;
}
WORD DE;
};
union{
struct{
BYTE H;
BYTE L;
}
WORD HL;
};
...На выходе класс содержит регистры BC (B+C), DE (D+E), и HL (H+L), к которым можно обращаться напрямую, например: cpu.BC или CPU.D.
Второй необычной задачей была работа с флагом статус-регистра, который называется AC (Auxiliary Carry) или "флаг переноса в четвертом бите", который нужен для двоично-десятичных расчетов. Используется он в операциях сложения, и решение на данный момент сделано "в лоб" — выполнить побитовое сложение первых четырех битов:
FORCE_INLINE void
SetAuxiliaryCarryFlagOfAdd(const BYTE firstOp, const BYTE secondOpWithCarry, const BYTE initialCarry = 0) {
BYTE carryFlag = initialCarry;
BYTE firstOpArg, secondOpArg;
for (BYTE idx = 0; idx < 4; ++idx) {
firstOpArg = (firstOp >> idx) & 0x01;
secondOpArg = (secondOpWithCarry >> idx) & 0x01; // Consider the carry in the second operand
carryFlag = ((firstOpArg + secondOpArg + carryFlag) >> 1) & 0x01;
}
AC = carryFlag;
}В остальном с "восьмидесяткой" было даже проще чем с 6502. Как минимум потому что в нем нет никаких других режимов работы, кроме Implied, Immediate и Direct: для инструкции либо вообще не нужны операнды, либо мы читаем данные сразу после опкода, либо читаем их из памяти, используя один из регистров в качестве указателя. Вот такие дела.
Работа, помимо реализации инструкций и написания тестов, была в основном сфокусирована на чистке кода. Я удалял мертвые строчки, избавлялся от лишних действий, упрощал синтаксис, и всё это для того, чтобы код был простым, понятным, и при этом эффективно выполнялся. Местами даже приходилось рассматривать дизассемблер релизной сборки и играться с godbolt.
Шина ввода/вывода
В комментариях к первой части справедливо заметили: "ну эмулятор и эмулятор, а зачем он нужен, если ничего не делает?" Я полностью согласен с этим утверждением, поэтому, чтобы добавить в проект хоть какую-то интерактивность, я решил потратить немного времени на I/O.
С точки зрения работы с внешними устройствами, выделяют два подхода: маппинг памяти (Memory-mapped I/O) и маппинг портов (Port-mapped I/O).
Разбираем Memory-mapped I/O
Примером микропроцессора, который поддерживает Memory-mapped I/O (и только его), является уже известный нам MOS6502. Такой подход обеспечивает доступ к внешним устройствам за счет использования адресного пространства.
Память тоже является внешним устройством, поэтому для взаимодействии с ней микропроцессор передает в микросхему адрес через 16 выводов. Мы можем выделить в этих линиях определенные адреса (или группу адресов), которые будут подключаться к устройству. С точки зрения процессора и системы команд, мы точно так же осуществляем операции чтения/записи, ведь нам неважно, куда мы отправляем данные и откуда мы их получаем. Упрощенная схема такого подключения выглядит так:

Например, разработчики отвели адрес $2006 для записи координат пикселя на экране, а адрес $2007 — для записи цвета этого пикселя. Тогда в коде это будет выглядеть следующим образом:
LDA #$20 ; Загружаем в аккумулятор значение $20 (координата Y)
STA $2006 ; "Записываем" его в память по адресу $2006
LDA #$40 ; Загружаем значение $40 (координата X)
STA $2006 ; "Записываем" его по тому же адресу $2006
LDA #$05 ; Загружаем значение $05 (цвет, скажем, красный)
STA $2007 ; "Записываем" цвет по адресу $2007Как можем видеть, используются абсолютно привычные команды для доступа к памяти, а весь I/O работает уже на уровне схемотехники устройства.
Разбираем Port-mapped I/O
При таком подходе в процессоре должна быть отдельная группа выводов, к которой подключаются внешние устройства. Для этого механизма у нас тоже есть пример (вариантов немного, да?), и это I8080. В нём имеются две специальные команды:
IN 20H ; Прочитать байт данных с порта 0x20 в аккумулятор
OUT 21H ; Записать байт из аккумулятора в порт 0x21Хотя, на самом деле, I8080 плохой пример port-mapped I/O, потому что в действительности у него нет выделенной группы выводов под порт. Ввод-вывод там реализуется за счет внешних устройств, например микросхемы I8212 (8-битный буфер). Тем не менее, функциональность заложена, и при выполнении приведенных выше команд на служебных выводах устанавливаются определенные состояния, а адрес порта записывается в младший байт шины адреса (A0...A7). Так или иначе, это уже схемотехника, а нас это мало интересует, потому что нам здесь важен только функционал, о котором я и расскажу далее.
Пишем примитивный I/O
Вместо того чтобы разделять две концепции, я решил объединить их. В обоих случаях есть идентификатор устройства: ячейка памяти или адрес периферии, и есть значение, которое мы читаем или записываем. Можно провести аналогии. Все же помнят, как в общем случае выглядели функции Read/Write?
FORCE_INLINE BYTE ReadByte(const Memory &memory, const WORD address) {
const BYTE Data = memory[address];
return Data;
}
FORCE_INLINE WORD ReadWord(const Memory &memory, const WORD address) {
const BYTE Lo = ReadByte(memory, address);
const BYTE Hi = ReadByte(memory, address + 1);
return Lo | (Hi << 8);
}
FORCE_INLINE void WriteByte(Memory &memory, const BYTE value, const WORD address) {
memory[address] = value;
}
FORCE_INLINE void WriteWord(Memory &memory, const WORD value, const WORD address) {
WriteByte(memory, value & 0xFF, address);
WriteByte(memory, (value >> 8), address + 1);
}Сюда напрашивается интерфейс, у которого будет лишь две функции: чтение байта и запись байта. При этом класс необходимо сделать шаблонным — у нас возможна (и будет) разная ширина шины, которая будет регулировать диапазон адресов, а следовательно и тип. Так появился интерфейс IO_Device:
template<typename BusWidth>
class IO_Device {
public:
virtual BYTE Read(BusWidth address) = 0;
virtual void Write(BusWidth address, BYTE value) = 0;
virtual BYTE &operator[](BusWidth address) = 0;
};Последний виртуальный метод доступа по индексу необходим для ручной установки данных в ячейку и используется исключительно в тестах.
Соответственно, класс Memory наследуется от IO_Device и реализует указанные методы:
template<typename BusWidth>
class Memory : public IO_Device<BusWidth> {
private:
BYTE *mem;
BusWidth size;
...
public:
BYTE &operator[](BusWidth address) override {
return mem[address];
}
BYTE Read(BusWidth address) override {
return mem[address];
}
void Write(BusWidth address, BYTE value) override {
mem[address] = value;
}Здесь вопросов возникать не должно. Теперь любое устройство, будь то виртуальная клавиатура (Input) или виртуальный терминал (Output), можно написать на основе класса IO_Device, реализовав необходимые методы чтения/записи. А что же с шиной?
Не буду лукавить, подход я выбрал достаточно ленивый. Основан он на std::map и работает на диапазонах значений. Я указываю начальный и конечный адреса и указываю какое устройство относится к этому интервалу:
template<typename BusWidth>
class Bus {
private:
std::map<AddressType, IO_Device*> regions;
public:
void SetBusRegion(BusWidth startAddr, BusWidth endAddr, IO_Device<BusWidth>* io_device) {
if (auto it = regions.lower_bound(startAddr); it != regions.end()) {
regions[startAddr - 1] = it->second;
}
regions[startAddr] = io_device;
regions[endAddr] = io_device;
}
...Что-то вроде такого:

Ну а далее я описываю методы Read/Write:
...
void Write(BusWidth address, BYTE value) {
FindDevice(address)->Write(address, value);
}
BYTE Read(BusWidth address) {
return FindDevice(address)->Read(address);
}
...Нетрудно догадаться, что метод FindDevice находит устройство, соответствующее указанному адресу (или находится в интервале), однако делать это приходится в два прохода:
IO_Device<BusWidth>* FindDevice(BusWidth address) {
if (auto it = regions.find(address); it != regions.end()) {
return it->second;
}
if (auto it = regions.upper_bound(address); it != regions.end()) {
return it->second;
}
throw std::out_of_range("No device mapped to address " + std::to_string(address));
}В ситуации, когда мы обращаемся по верхней границе этого интервала, метод upper_bound вернет устройство, соответствующее следующему интервалу, поэтому приходится сначала искать конкретное вхождение, а только потом ориентироваться на upper_bound. Это неэффективно, я согласен.
Далее в базовом классе Compute достаточно объявить protected поле bus и методы get/set:
template<typename BusWidth>
class Compute {
...
protected:
Bus* bus = nullptr;
...
public:
void SetBusInstance(Bus* new_bus) { bus = new_bus; }
BusRead(address);
return Data;
}
FORCE_INLINE WORD ReadWord(const Memory &memory, const WORD address) {
const BYTE Lo = ReadByte(memory, address);
const BYTE Hi = ReadByte(memory, address + 1);
return Lo | (Hi << 8);
}
FORCE_INLINE void WriteByte(Memory &memory, const BYTE value, const WORD address) {
bus->Write(address, value);
}
FORCE_INLINE void WriteWord(Memory &memory, const WORD value, const WORD address) {
WriteByte(memory, value & 0xFF, address);
WriteByte(memory, (value >> 8), address + 1);
}
Финальный штрих — обновить функции ReadByte/WriteByte в реализации конкретного микропроцессора:
FORCE_INLINE BYTE ReadByte(const Memory &memory, const WORD address) {
const BYTE Data = bus->Read(address);
return Data;
}
FORCE_INLINE WORD ReadWord(const Memory &memory, const WORD address) {
const BYTE Lo = ReadByte(memory, address);
const BYTE Hi = ReadByte(memory, address + 1);
return Lo | (Hi << 8);
}
FORCE_INLINE void WriteByte(Memory &memory, const BYTE value, const WORD address) {
bus->Write(address, value);
}
FORCE_INLINE void WriteWord(Memory &memory, const WORD value, const WORD address) {
WriteByte(memory, value & 0xFF, address);
WriteByte(memory, (value >> 8), address + 1);
}Всё, что изменилось по сравнению с ранее приведенным кодом — замена memory[address] на bus->Read и bus->Write в зависимости от контекста.
Эта реализация непосредственно memory-mapped IO. В случае же с port-mapped IO мы объявляем в классе процессора еще один экземпляр класса Bus, который обслуживает порты. Так я делал в I8080:
class I8080 final: public Compute<WORD>{
public:
WORD PC; /**< Program Counter */
WORD SP; /**< Stack Pointer */
BYTE A; /**< Accumulator */
...
...
...
Bus<WORD>* GetDataBus() { return dataBus; }
protected:
Bus<WORD>* dataBus;
}А в инструкциях IN/OUT обращаться уже к этому объекту:
void I8080_IN(I8080 &cpu) {
const BYTE deviceAddress = cpu.FetchByte();
cpu.A = cpu.GetDataBus()->Read(deviceAddress);
}
void I8080_OUT(I8080 &cpu) {
const BYTE deviceAddress = cpu.FetchByte();
cpu.GetDataBus()->Write(deviceAddress, cpu.A);
}Решающий момент
Признаюсь честно, было страшно запускать тесты, переписав такую низкоуровневую механику всего проекта. А кому не страшно проверять работоспособность после глубокой переработки? Пусть и не с первого раза, но оно заработало, и работает до сих пор. Пока что я ни разу не столкнулся с проблемами, которые были связаны именно с шиной, поэтому она живет сама по себе и никому не мешает. Честно говоря, я стараюсь лишний раз туда не заглядывать...
Самый большой прорыв произошел тогда, когда я решил запустить Wozmon.
Wozmon — программа для просмотра памяти, написанная Стивом Возняком для компьютера Apple-I. Пользователь мог вводить команды типа
024Dили1000.100Fи получать на экране содержимое указанной ячейки или интервала. Компьютер, к слову, был построен на базе MOS6502.
Эксперимент оказался успешным, но лишь частично.
Первым делом я пошел искать исходники программы. В интернете много вариаций с разной степенью проработки, но я решил взять ту, что попроще. Далее была пара вечеров, которые я потратил на то, чтобы собрать эту программу через cc65 — тулчейн для 6502.
Когда эта часть работы была завершена, мне осталось написать клавиатуру и эмулятор терминала. Оба этих устройства реализовывали интерфейс IO_Device и запускались в отдельных потоках. Терминал просто принимал символ и выводил его на экран, а клавиатура считывала ввод пользователя и сохраняла его в нужную ячейку свой памяти. Wozmon читал значение в ячейке статуса клавиатуры, и если оно не было равно нулю, то считывал ячейку данных. Таким образом я реализовал ввод-вывод. Далее примерно следующим образом устанавливаем интервалы адресов:
bus.SetBusRegion(memory, 0x0000, 0xFFFF);
bus.SetBusRegion(keyboard, 0x1200, 0x1201);
bus.SetBusRegion(tty, 0x2000, 0x2000);И запускаем...
Опять же, не с первого раза, но мне удалось заставить программу работать. Почему эксперимент был успешным лишь частично? Потому что где-то, видимо в самой программе (точнее в том варианте, что я нашел), была ошибка, и при указании любого интервала, даже одиночного адреса, программа выдавала на экран всю память, начиная с указанного диапазона и до конца (0xFFFF). Тем не менее, оно работало! Это был очень радостный день!
А потом случилось страшное...
...пришел x86
Популярный микропроцессор Z80 был бы идеальным вариантов для следующего звена моего проекта, тем более что он полностью совместим с I8080, но я решил поступить иначе. Я взял в работу I8086.
Замечу, что I8086 не реализовывал полноценную x86-архитектуру, а был её первоосновой. Архитектура этого процессора получила название x86-16 и стала прообразом x86 в том виде, в котором мы его знаем.
С этого момента жизнь уже не могла быть прежней, потому что мне пришлось разобраться с целым ворохом новых понятий: сегменты памяти, хитрое кодирование операндов инструкций, запутанная работа с режимами адресации и целые подгруппы опкодов (хотя в Z80 с этим гораздо веселее).
Что ж, начнем. I8086 описан следующим образом:
class I8086 final : public Compute {
public:
DECLARE_PAIRED_REG_UNIQUE_NAME(BYTE, WORD, AH, AL, AX); // primary accumulator
DECLARE_PAIRED_REG_UNIQUE_NAME(BYTE, WORD, BH, BL, BX); // base, accumulator
DECLARE_PAIRED_REG_UNIQUE_NAME(BYTE, WORD, CH, CL, CX); // counter, accumulator
DECLARE_PAIRED_REG_UNIQUE_NAME(BYTE, WORD, DH, DL, DX); // accumulator, other functions
WORD SI; // Source Index
WORD DI; // Destination Index
WORD BP; // Base Pointer
WORD SP; // Stack Pointer
I8086_Status Status;
WORD CS; // Code Segment
WORD DS; // Data Segment
WORD ES; // Extra Segment
WORD SS; // Stack Segment
WORD PC; // Program Counter
...
Макрос
DECLARE_PAIRED_REG_UNIQUE_NAMEстроится поверхDECLARE_PAIRED_REG, но принимает дополнительный аргумент имени регистра вместо склеивания черезNAME1##NAME2
Здесь уже гораздо больше регистров, чем было в I8080, и все они 16-битные:
регистры общего назначения: AX (AH+AL), BX (BH+BL), CX (CH+CL) и DX (DH+DL)
регистры смещения: SI и DI
регистры-указатели: BP и SP
статус-регистр
сегментные регистры:
CS - сегмент кода
DS - сегмент данных
ES - дополнительный сегмент/сегмент внешних данных
SS - сегмент стека
регистр указателя на следующую инструкцию: PC

Начнем, пожалуй, с режимов адресации.
Адресация
В отличие от уже описанных 6502 и 8080, которые могут адресовать всего 64 кб памяти, 8086 может адресовать уже целый 1 Мб. Процессор у нас 16-битный, но в него добавили сегментацию адресного пространства, из-за чего и стало возможным такое кратное увеличение доступной памяти.
Также, в отличие от 6502, где под каждый режим адресации существует отдельный код инструкции, в 8086 такого архитектурного решения нет, однако есть другой механизм — (почти) после каждой инструкции встраивается дополнительный управляющий байт, который хранит информацию об операндах, их типе и режиме адресации (если один из них является памятью). Инструкции работают в режиме регистр-регистр, регистр-память или память-регистр. Инструкций память-память в 8086 нет.
Хочу лишний раз отметить, что описанные здесь концепции, в том числе и сегментация памяти, не для всех являются элементарными и лежащими на поверхности. Я пишу данную статью в первую очередь для тех, кто, как и я, слабо разбирается в вопросе.
Сегментация
Сегментация основана на разделении памяти на отдельные участки, которые при этом могут пересекаться. Сначала я приведу пример взаимного расположения сегментов в памяти, а потом на нем рассмотрим, как же всё-таки оно работает:

Как мы видели выше, регистры, отвечающие за указание на сегмент, являются 16-битными (как и вообще все регистры процессора), но для того чтобы адресовать 1 Мб памяти нам нужно 20 бит. И действительно, шина адреса у I8086 20-битная. Так как это работает?
Всё достаточно просто. При вычислении реального (физического) адреса нужной нам ячейки памяти, значение сегментного регистра загружается уже в 20-битный регистр, недоступный для разработчика. Загруженное значение сдвигается влево на 4 бита, и к нему прибавляют необходимое смещение. Смещение при этом тоже 16-битное, а значит, что в пределах одного сегмента мы по-прежнему можем адресовать только 64 кб, зато сегментов у нас несколько. Для наглядности приведу схему вычислений из "The 8086 Family Users Manual (Oct 79)":

Или более формально из "Russell Rector, George Alexy - The 8086 Book":

Для закрепления вернемся к рисунку в начале раздела о сегментации и немного посчитаем:
1. Значение регистра DS из схемы равно 0x021F. После загрузки значения в 20-битный сдвиговый регистр мы получаем уже 0x0021F.
2. Это число сдвигается влево на 4 разряда, и мы получаем уже значение 0x021F0 (Segment Register contents).
3. Прибавляем к этому значению максимально возможное 16-битное значение смещения 0xFFFF (Effective memory address) и получаем 0x121EF (Actual address output), что является концом сегмента данных (Data Segment), ровно, как и указано на схеме.

Соответственно, в рамках всей памяти сегмент расположен на участке 0x021F0-0x121EF, а в пределах сегмента нам доступно 0x0000-0xFFFF значений (65536). Такие дела.
Понемногу приближаемся к инструкциям, так что дополнительно приведу формат хранения кода в памяти. Это избавит меня от лишних повторений далее по тексту.

На рисунке:
opcode — непосредственно код инструкции
mod/rm — тот самый управляющий байт, о котором я уже говорил ранее: он хранит информацию о типе операндов и режиме адресации
offset value — опциональное 8/16-битное, которое применяется к адресу (если один из операндов — память)
data — immediate данные, если таковые подразумеваются для данной инструкции Я обязательно расскажу подробнее о mod/rm и о том, сколько сил мне понадобилось, чтобы грамотно описать его работу, но всё это будет позже, а пока самое время перейти к типам инструкций и способам адресации.
Implicit
Строго говоря, это не режим адресации, а тип команд, которые работают "сами по себе", без аргументов. Мы такие уже видели в MOS6502, и ничего нового в них нет. К таким инструкциям относится, например, PUSHF, которая сохраняет состояние статус-регистра в стек:

Ассемблер у такой инструкции тоже элементарный:
PUSHFImmediate
Такой тип инструкций нам тоже встречался в MOS6502, и в этом случае операнд в памяти находится непосредственно после инструкции. В качестве примера приведу два варианта записи инструкции: AND — 8-битный AL и 16-битный AX

Ассемблерная запись выглядит следующим образом:
AND AL,ADH ; AL <- AL & 0xAD
AND AX,400H ; AX <- AX & 0x0400Далее начинаются более продвинутые режимы с несложной математикой.
Direct
Самый простой тип адресации. Позволяет осуществлять доступ к статическим (глобальным) переменным, которые находятся по известному адресу в сегменте данных. В таком режиме мы читаем адрес в сегменте из двух, следующих после инструкции, байтов, после чего вычисляем реальный адрес на основе Data Segment регистра:

Код 0x23 описывает 16-битную инструкцию AND, которая может работать как в режиме регистр-регистр, так и в режиме регистр-память. Записываться это будет так:
AND BC,CL ; BC <- BC & CL
AND BL,102AH ; BL <- BC & mem[(DS << 4) + 0x102A]Мнемоника AND одна и та же, и даже код один и тот же (0x23), но разница будет заключаться как раз в mod|R/M байте, который ассемблер (не язык а транслятор) сам подставит при обработке кода.
Реализация элементарная:
FORCE_INLINE DWORD GetDirectAddress() {
const WORD offset = Fetch<WORD>();
return EFFECTIVE_ADDRESS(offset, *currentSegment);
}1. Да, в проекте появились шаблоны, но об этом чуть позже.
2. В аргументах макросаEFFECTIVE_ADDRESSфигурируетcurrentSegment. Это задел на будущее, так как в I8086 есть возможность временно переопределить DS на какой-нибудь другой. Пока что можно воспринимать его как синоним к указателю на DS.
Based
Я не смог найти нормальный перевод для этого режима адресации, так что назову его "адресация по базовому регистру". К таким относятся регистр общего назначения BX и регистр указателя на "базу" BP (Base Pointer). Информация о том, какой из регистров BP и BX выбран в качестве рабочего, опять же, хранится в управляющем байте mod|R/M. Также данный режим адресации допускает дополнительное 8-битное или 16-битное смещение, которое записывается после mod|R/M байта (информация о наличии смещения тоже хранится в нем). Рассмотрим вариант работы с 16-битным смещением в режиме DS+BX:

Мы берем Data Segment, по известной формуле прибавляем к нему регистр BX или BP, и добавляем смещение, если оно необходимо. Там и находится нужный операнд. В целом механизм простой и чем-то похож на Direct. Режим нужен для доступа к данным в структуре или к локальным переменным в стеке. Базовый регистр (BX или BP) содержит адрес начала структуры/кадра стека, а смещение — смещение до конкретного поля/переменной.
Если в качестве регистра смещения выбран регистр BP, то сегментация расчитывается не по DS, а по SS (Stack Segment). Данное условие распространяется только на Based-адресацию. The 8086 Family Users Manual, стр. 93
Ассемблерный код для инструкции из примера записывается следующим образом:
AND AX,[BX + 26AH] ; AX <- AX & mem[(DS << 4) + BX + 0x026A]В реализации я оставил соответствующий комментарий, чтобы не забыть, откуда взялся SS:
FORCE_INLINE DWORD GetBasedAddress(const WORD &baseRegister, const BYTE dispSize = 0) {
WORD disp = 0;
if (dispSize != 0) {
disp += dispSize == 2 ? Fetch<WORD>() : Fetch<BYTE>();
}
// if BP was chosen as a Base register, SS is forced to be used as a segment register
// See "The 8086 Family Users Manual", p.93 BaseAddressing section
const WORD *realSegment = (&baseRegister == &BP) ? &SS : currentSegment;
return EFFECTIVE_ADDRESS((baseRegister + disp), *realSegment);
}Indexed
Адресация по индексному регистру (SI или DI) работает точно так же, как и Based,но назначение обычно немного другое — доступ к элементам массива или строки. Индексный регистр (SI или DI) выступает в роли "счетчика" или "сдвига" от начала массива:

Ассемблер:
AND AX,[DI + 26AH] ; AX <- AX & mem[(DS << 4) + DI + 0x026A]В коде тоже всё достаточно банально:
FORCE_INLINE DWORD GetIndexedAddress(const WORD &indexRegister, const BYTE dispSize = 0) {
WORD disp = 0;
if (dispSize != 0) {
disp += dispSize == 2 ? Fetch<WORD>() : Fetch<BYTE>();
}
return EFFECTIVE_ADDRESS((indexRegister + disp), *currentSegment);
}Based Indexed
Последний режим адресации является комбинацией двух предыдущих: мы берем один базовый регистр (BX/BP) и один индексный регистр (SI/DI), рассчитываем адрес на основе регистра сегмента данных (DS) и получаем реальный адрес, по которому находится операнд операции. Он позволяет обращаться к элементам массивов структур или двумерных массивов. Схема памяти на примере адресации по BX+DI и 8-битным смещением:

Ассемблер такой инструкции будет выглядеть так:
AND AX,[BX + DI + 0AH] ; AX <- AX & mem[(DS << 4) + BX + DI + 0x0A]Реализация в проекте повторяет сказанное ранее:
FORCE_INLINE DWORD GetBasedIndexedAddress(const WORD *baseRegister, const WORD *indexRegister, const BYTE dispSize = 0) {
WORD disp = 0;
if (dispSize != 0) {
disp += dispSize == 2 ? Fetch<WORD>() : Fetch<BYTE>();
}
return EFFECTIVE_ADDRESS((*baseRegister + *indexRegister + disp), *currentSegment);
}Вот и всё. Режимов адресации тут всего четыре, если не считать Implicit и Immediate (которые и режимами адресации то не считаются). Вариаций при этом получается порядка 17 штук. Это с учетом различных регистров, которые могут быть использованы в каждом типе адресации, и с учетом опционального фиксированного смещения. Здесь проблем у меня не было, но возник вопрос, как это связать с тем самым mod|R/M байтом. Прежде чем мы всерьез погрузимся в обсуждение этой управляющей структуры, я позволю себя сделать еще одну небольшую вставку по поводу префиксных и групповых инструкций.
Префиксы и группы
Помимо привычных инструкций, в I8086 есть так называемые префиксные: они накладывают определенные условия на выполнение следующей инструкции. Например, инструкция LOCK гарантирует привелигированный доступ к IO шине данных инструкции, в рамках которой она вызвана, а инструкции ES/CS/SS/DS позволяют переопределить значение сегментного регистра в процессе выполнения (для чего и была сделана встречающаяся ранее заготовка currentSegment):
LOCK XCHG AX,SEMAPHORE
MOV AX, ES:[1234H]Группы инструкций объединяют в себе примитивные инструкции. Таких групп пять, и каждая из них может объединять до семи команд, так как нужная операция кодируется тремя битами в mod|R/M. В коде такие инструкции записываются так же, как и любые другие:
RCL BL,1 ; BL <- BL << 1Но при вызове происходит декодирование:
template<typename T>
using GRP_CallbackSignature = void (*)(I8086&, const ModRegByte&);
...
template<typename T>
FORCE_INLINE void I8086_GRP2_Ex_1(I8086 &cpu) {
const BYTE modByte = cpu.Fetch<BYTE>();
const ModRegByte modReg = ModRegByte(modByte);
static constexpr GRP_CallbackSignature<T> callMap[] = {
&ROL_ByOne<T>, // 000 -> ROL
&ROR_ByOne<T>, // 001 -> ROR
&RCL_ByOne<T>, // 010 -> RCL
&RCR_ByOne<T>, // 011 -> RCR
&SAL_SHL_ByOne<T>, // 100 -> SAL/SHL
&SHR_ByOne<T>, // 101 -> SHR
&GRP_InvalidCall<T>, // 110 -> INVALID
&SAR_ByOne<T> // 111 -> SAR
};
callMap[modReg.reg](cpu, modReg);
}
void I8086_GRP2_Eb_1(BYTE, I8086 &cpu) { // 0xD0
I8086_GRP2_Ex_1<BYTE>(cpu);
}
void I8086_GRP2_Ev_1(BYTE, I8086 &cpu) { // 0xD1
I8086_GRP2_Ex_1<WORD>(cpu);
}Группа GRP2 может работать с 8-битными операндами (0xD0) и с 16-битными операндами (0xD1). После попадания в первичный обработчик инструкции (1) мы переходим в основную функцию (2), которая читает reg|R/M байт и вызывает реализацию (3-4).

Конструкция mod|reg|R/M и появление шаблонов
Дальше будет много кода. Подготовьтесь к погружению.
Я не настаиваю на внимательном вчитывании в примеры кода, достаточно ориентироваться на словесное описание.
В разных источниках эта структура записывается по-разному: где-то просто mod|R/M, где-то mod|reg|R/M, так что не путайтесь, если видите по тексту разные форматы записи.
Запись через разделитель не просто так:
mod(2 бита): задает режим инструкции00: регистр-память/память-регистр, без дополнительного смещения
01: регистр-память/память-регистр, дополнительное смещение записано в 1 байт
10: регистр-память/память-регистр, дополнительное смещение записано в 2 байта
11: регистр-регистр
reg(3 бита): кодирует целевой регистр (8-битный или 16-битный)r/m(3 бита):mod == 11: кодирует целевой регистр (8-битный или 16-битный)
mod != 11: кодирует тип адресации

В зависимости от контекста некоторые поля могут нести совершенно другой смысл, как, например, в обработке групп инструкций из примера выше: там поле reg кодирует конечную вызываемую инструкцию.
Вот такая получается хитрая схема, и нужно было решить сразу несколько задач:
Декодирование управляющего байта.
Проектирование универсальной структуры, которая содержала бы в себе готовые для использования операнды.
Кодирование управляющего байта для использования в тестах.
Здесь я снова сделал большую паузу, потому что мне нужно было время на то, чтобы осмыслить и уложить в голове всю ту информацию, которую мне пришлось изучить. Я долго откладывал, но застой в работе был для меня тяжелее, чем сама работа, поэтому я начал делать хоть что-то. Отправная точка напрашивается сама собой:
struct ModRegByte {
explicit ModRegByte(BYTE inValue = 0) : value(inValue) {}
union {
struct {
BYTE rm :3;
BYTE reg :3;
BYTE mod :2;
};
BYTE value = 0;
};
};
Окей. Идем дальше. Мы знаем, что на основе reg нам нужно реализовать получение регистра (8-битного и 16-битного), а на основе rm — реальный адрес или регистр по тем же правилам, что и для reg.
Функции получения регистра простые: в них передается байт, и возвращается указатель на поле класса (регистр)
// REG | R/M should be passed
BYTE *GetRegBytePtr(const BYTE modByte) {
assert(modByte <= 7);
BYTE *regTable[] = {&AL, &CL, &DL, &BL, &AH, &CH, &DH, &BH};
return regTable[modByte];
}
// REG | R/M should be passed
WORD *GetRegWordPtr(const BYTE modByte) {
assert(modByte <= 7);
WORD *regTable[] = {&AX, &CX, &DX, &BX, &SP, &BP, &SI, &DI};
return regTable[modByte];
}
Для вычисления адреса мы используем уже знакомые нам из раздела адресации функции:
DWORD GetModRegAddress(const ModRegByte &modReg) {
switch (modReg.rm) {
case 0b000:
return GetBasedIndexedAddress(&BX, &SI, modReg.mod);
case 0b001:
return GetBasedIndexedAddress(&BX, &DI, modReg.mod);
case 0b010:
return GetBasedIndexedAddress(&BP, &SI, modReg.mod);
case 0b011:
return GetBasedIndexedAddress(&BP, &DI, modReg.mod);
case 0b100:
return GetIndexedAddress(SI, modReg.mod);
case 0b101:
return GetIndexedAddress(DI, modReg.mod);
case 0b110:
return (modReg.mod != 0) ? // special case for 110
GetBasedAddress(BP, modReg.mod) :
GetDirectAddress();
case 0b111:
return GetBasedAddress(BX, modReg.mod);
}
}
Так вышло, что в I8086 режимы Direct и Based мапятся на одно и то же значение поля rm, и в таком случае верный тип адресации выбирается на основе значения поля mod, которое отвечает за смещение (displacement).
Хорошо. Теперь у нас есть два главных строительных кирпичика, которые необходимы нам для работы с управляющим байтом. Дело осталось за малым: разработать структуру данных, которая поможет нам хранить все эти значения разом и расписать способ обработки этого байта.
Второе невозможно без первого, поэтому сначала нужно разобраться с маленькой коробочкой для хранения информации.
Итак, у нас есть два операнда, которые при этом могут быть следующих типов: 8/16-битный регистр и 8/16-битная память. Каждый из них должен предоставлять единый интерфейс как на чтение, так и на запись. Не стоит забывать, что поскольку в группах поле reg занято под кодирование инструкции, поэтому в таких операциях только один операнд, который кодируется в R/M.
Учитывая сказанное выше, получается следующий каркас, который пока что не требует особых разъяснений:
enum class OperandType {
Reg8, Reg16, Mem8, Mem16
};
struct OperandInfo {
OperandType type{};
union {
BYTE *reg8 = nullptr;
WORD *reg16;
DWORD mem;
};
};
struct InstructionData {
OperandInfo leftOp;
OperandInfo rightOp;
};
В общем-то, это тот вариант написания, который приходит в голову первым же делом сразу после формулировки задачи, но это было еще в те времена, когда я сопротивлялся шаблонам. Использование такого подхода сопровождалось постоянными проверками "если тип Reg8, то прочитать из *reg8, а если Reg16, то..." ну вы поняли. А уж когда появились шаблоны, то я дал себе волю, и меня малость занесло...
template<typename T>
struct InstructionData {
union {
struct { // Regular instruction operands
OperandInfo<T> leftOp;
OperandInfo<T> rightOp;
};
OperandInfo<T> singleOp; // GRP instructions operand
};
};Да уж, что-то мне слишком ��олюбились анонимные структуры и юнионы...
А OperandInfo стал выглядеть так:
enum class OperandType {
Reg, Mem
};
template<typename T>
struct OperandInfo {
OperandType type;
union {
void *reg = nullptr; // ...хотя может и не малость
DWORD mem;
} operand;
...Union, в котором одновременно лежит число и void* — это, конечно, сильно, и на первый взгляд, не имеет никакого смысла, но я поясню: естественно, изначально я планировал сделать указателем и поле mem, но быстро понял, что ему, в общем-то, не на что указывать, потому что mem, это просто число, полученное в результате определенных расчетов.
Поскольку я знаю тип операнда, то у union не может быть разночтений, и мне лишь нужно организовать функции доступа, которые будут определяться на этапе формирования структуры, описывающей операнд. Организовать такое поведение я решил через указатели на функции, которые предоставляют операции get/set.
ВНИМАНИЕ! Уберите от экранов детей, нервнобольных, беременных и людей со слабым сердцем
После ряда экспериментов всё стало выглядеть следующим образом:
template<typename T>
using OperandSetter = void (*)(I8086 &, const void *, T);
template<typename T>
using OperandGetter = T(*)(I8086 &, const void *);
template<typename T>
struct OperandInfo {
OperandType type;
union {
void *reg = nullptr;
DWORD mem;
} operand;
void set(I8086& cpu, T value) const {
setterFuncPtr(cpu, &operand, value);
}
T get(I8086& cpu) const {
return getterFuncPtr(cpu, &operand);
}
void getterSet(OperandGetter<T> f) {
getterFuncPtr = f;
}
void setterSet(OperandSetter<T> f) {
setterFuncPtr = f;
}
private:
OperandGetter<T> getterFuncPtr;
OperandSetter<T> setterFuncPtr;
};Что здесь происходит: где-то снаружи, когда я формирую информацию об операнде, я прокидываю в структуру указатель на функцию, вызывая getterSet/setterSet, а когда работаю с операндом, то вызываю их через методы get/set. Это, полагаю, вопросов не вызывает, но почему void*, а не T*? Хороший вопрос. Моя первая попытка выглядела именно так, но "не взлетело". Причиной является тот самый union, и в таком случае в нем одновременно будет существовать T* и DWORD, а у них разные типы, и я не могу единообразно передавать их в функцию. Было бы там T* и T — я бы попытался, но увы. В общем, мне приходится передавать указатель, а чтобы стереть его тип, я использую void*.
Реализации этих get/set функций находятся в файле I8086.h. Реализация геттера/сеттера для ячейки памяти выглядит в целом безобидно, приводим void* к DWORD* и разыменовываем:
template<typename T>
static void AddressSet(I8086 &cpu, const void *address, T value) {
cpu.Write(*(DWORD *) address, value);
}
template<typename T>
static T AddressGet(I8086 &cpu, const void *address) {
return cpu.Read<T>(*(DWORD *) address);
} А вот для работы с регистрами пришлось сделать грязь:
template<typename T>
static void RegisterSet(I8086&, const void *destReg, T value) {
*(T *) *(uintptr_t *) destReg = value;
}
template<typename T>
static T RegisterGet(I8086&, const void *srcReg) {
return *(T *) *(uintptr_t *) srcReg;
}Дело в том, что
void*нельзя разыменовать, и поскольку в моем случае это указатель на указатель, то мы можем трактовать его какuintptr_t*, потому что это валидный тип для представления значения указателя. После разыменования этого указателя мы получаем уже указатель на регистр, который приводим к конкретному типуT*, и снова разыменовываем. Хитро, опасно, но оно работает.
Имея описанный выше механизм, в момент "конструирования" OperandInfo я использую getterSet(&RegisterGet)/setterSet(&RegisterSet) для операндов-регистров и getterSet(&AddressGet)/setterSet(&AddressSet) для операндов-памяти.
Прежде чем описать логику разбора mod|reg|R/M, стоит уточнить следующее: поскольку в ассемблере x86-16 source-операнд всегда указывается справа, а destination-операнд слева, я решил оставить эту семантику, поэтому rightOp — source, а leftOp — destination. Чтобы с этим было удобнее работать на уровне кода, я ввел несколько простых перечислений:
enum class InstructionDirection {
MemReg_Reg, Reg_MemReg, MemReg_Imm
};
enum OperandDirection {
LeftToRight = 1, RightToLeft, Bidirectional
};
enum class OperandType {
Reg, Mem
};А далее нас встречает собственно сам метод обработки:
template<typename T>
InstructionData<T> GetInstructionDataNoFetch(const OperandSize operandSize, const InstructionDirection direction, const ModRegByte modReg) {
InstructionData<T> instructionData{};
// Pre-calculate target registers pointers
void *regRegPtr = operandSize == OperandSize::BYTE ?
(void *) GetRegBytePtr(modReg.reg) :
(void *) GetRegWordPtr(modReg.reg);
void *rmRegPtr = operandSize == OperandSize::BYTE ?
(void *) GetRegBytePtr(modReg.rm) :
(void *) GetRegWordPtr(modReg.rm);
// Reg-Reg instructions
if (modReg.mod == 0b11) {
OperandType regOperands = OperandType::Reg;
instructionData.leftOp.type = instructionData.rightOp.type = regOperands;
instructionData.leftOp.getterSet(&RegisterGet);
instructionData.rightOp.getterSet(&RegisterGet);
instructionData.leftOp.setterSet(&RegisterSet);
instructionData.rightOp.setterSet(&RegisterSet);
// MemReg_Imm instruction direction in this branch covers only Register destination
// Only one operand needed if instruction direction is MemReg_Imm
if (direction == InstructionDirection::MemReg_Imm) {
instructionData.singleOp.operand.reg = rmRegPtr;
return instructionData;
}
instructionData.leftOp.operand.reg = regRegPtr;
instructionData.rightOp.operand.reg = rmRegPtr;
return instructionData;
}
// Mem-Reg or Reg-Mem instructions
else {
OperandInfo<T> op1;
op1.type = OperandType::Mem;
op1.operand.mem = GetModRegAddress(modReg);
op1.getterSet(&AddressGet);
op1.setterSet (&AddressSet);
// MemReg_Imm instruction direction in this branch covers only Memory destination
// Only one operand needed if instruction direction is MemReg_Imm
if (direction == InstructionDirection::MemReg_Imm) {
instructionData.singleOp = op1;
return instructionData;
}
OperandInfo<T> op2;
op2.type = OperandType::Reg;
op2.operand.reg = regRegPtr;
op2.getterSet(&RegisterGet);
op2.setterSet(&RegisterSet);
instructionData.leftOp = direction == InstructionDirection::MemReg_Reg ? op1 : op2;
instructionData.rightOp = direction == InstructionDirection::MemReg_Reg ? op2 : op1;
return instructionData;
}
}Можно не вчитываться в код, логика здесь сводится к следующему:
Если инструкция имеет тип регистр-регистр, то установить соответствующие геттеры/сеттеры в
leftOpиrightOp, а также сохранить указатели на регистры в соответствии с полями reg и r/m. Если инструкция типа Immediate, то установить leftOp в singleOp.Если инструкция имеет тип регистр-память, то установить соответствующие геттеры/сеттеры для памяти в
op1, а для регистра — вop2. Также устанавливаем адрес памяти и указатель на поле регистра. В конце устанавливаемleftOp/rightOpв соответствии с "направлением": регистр-память или память-регистр. Кейс с Immediate инструкцией в этой ветке тоже покрывается, в таком случае Immediate значением будет память, то естьop1.
Для лучшего понимания я попытался визуализировать этот процесс:

Да, возможно это немного запутанно, но эта концепция закрепилась и пока еще ни разу не требовала переработки. Случайно я обнаружил там пару недочетов, но это скорее были опечатки. Сама задумка совершенно спокойно живет в проекте и выполняет возложенные на нее функции.
Кстати, обратите внимание, метод называется GetInstructionDataNoFetch, потому что он работает с уже готовым mod|reg байтом. Это особый случай, который нужен в группах. Более общий вид функции имеет название GetInstructionData и выглядит так:
template<typename T>
InstructionData<T> GetInstructionData(const OperandSize operandSize, const InstructionDirection direction) {
const BYTE modByte = Fetch<BYTE>();
const ModRegByte modReg = ModRegByte(modByte);
return GetInstructionDataNoFetch<T>(operandSize, direction, modReg);
}Пример использования в коде такой:
void I8086_POP_Ev(BYTE, I8086 &cpu) {
const InstructionData instructionData = cpu.GetInstructionData<WORD>(OperandSize::WORD, InstructionDirection::MemReg_Imm);
const WORD operand = cpu.PopDataFromStack();
instructionData.singleOp.set(cpu, operand);
}Это реализация инструкции POP_Ev, которая извлекает 16-битное значение из стека и загружает в память/регистр. Понимание написанного не должно вызывать вопросов.
Такой пример вызова, однако, является исключением. Самая базовая, повсеместно используемая в проекте реализация такая:
// Generic version of execution default instruction
// Works with Ex,Gx/Gx,Ex instructions
// Result will be stored in left operand on instruction
template<typename T>
FORCE_INLINE void I8086_EGx_EGx(I8086 &cpu,
InstructionCallback<T> *callback,
StatusCallback<T> *statusCallback,
const InstructionDirection instructionDirection,
const OperandDirection operandDirection,
const bool shouldStoreResult = true) {
InstructionResult<T> instructionResult{};
const OperandSize opSize = std::is_same_v<T, BYTE> ? OperandSize::BYTE : OperandSize::WORD;
const InstructionData instructionData = cpu.GetInstructionData<T>(opSize, instructionDirection);
const T leftOp = instructionData.leftOp.get(cpu);
const T rightOp = instructionData.rightOp.get(cpu);
instructionResult.leftOp.before = leftOp;
instructionResult.rightOp.before = rightOp;
callback(instructionResult);
if (shouldStoreResult) {
if (operandDirection & OperandDirection::RightToLeft)
instructionData.leftOp.set(cpu, instructionResult.leftOp.after);
if (operandDirection & OperandDirection::LeftToRight)
instructionData.rightOp.set(cpu, instructionResult.rightOp.after);
}
if (statusCallback)
statusCallback(cpu, instructionResult);
}Давайте по порядку. Структура InstructionResult имеет достаточно примитивный вид:
template<typename T>
struct InstructionResult{
struct {
T before;
T after;
} leftOp;
struct {
T before;
T after;
} rightOp;
struct {
bool C; // Carry
bool A; // Auxiliary
bool O; // Overflow
} status;
};Что было, что стало, какие флаги статуса затронуты. Причем не все флаги, а лишь отдельные, да и те используются только в инструкции ADD. Я исправлю...
InstructionCallback* — указатель на функцию, которая реализует саму инструкцию:
template<typename T>
using InstructionCallback = void(InstructionResult<T> &);Типичный пример:
template<typename T>
void PerformAND(InstructionResult<T>& result) {
result.leftOp.after = result.leftOp.before & result.rightOp.before;
result.rightOp.after = result.rightOp.before;
}StatusCallback* — указатель на функцию обновления статус-регистра по итогам выполнения инструкции:
template<typename T>
void UpdateStatusAfterAND(I8086 &cpu, const T &value){
cpu.Status.C = 0;
cpu.Status.O = 0;
cpu.Status.UpdateStatusByValue(value, I8086_Status_S | I8086_Status_Z | I8086_Status_P);
}Ну а дальше всё просто: узнали "размерность" инструкции, вызвали GetInstructionData, получили операнды, выполнили InstructionCallback, сохранили значения after, вызвали StatusCallback. Да, там есть тонкости с ЕЩЕ ОДНИМ направлением инструкции, потому что инструкции могут быть двунаправленными, например XCHG — обмен значений операндов. В общем и целом подход простой, должно быть понятно.
Ну и типичный пример использования всего этого добра:
template<typename T>
void PerformAND(InstructionResult<T>& result) {
result.leftOp.after = result.leftOp.before & result.rightOp.before;
result.rightOp.after = result.rightOp.before;
}
template<typename T>
void UpdateStatusAfterAND(I8086 &cpu, const T &value){
cpu.Status.C = 0;
cpu.Status.O = 0;
cpu.Status.UpdateStatusByValue(value, I8086_Status_S | I8086_Status_Z | I8086_Status_P);
}
template<typename T>
void UpdateStatusAfterAND_Wrapper(I8086 &cpu, const InstructionResult<T> &instructionResult) {
UpdateStatusAfterAND(cpu, instructionResult.leftOp.after);
}
template<typename T>
void I8086_EGx_EGx_AND(I8086 &cpu) {
I8086_EGx_EGx<T>(cpu, &PerformAND, &UpdateStatusAfterAND_Wrapper, InstructionDirection::MemReg_Reg, RightToLeft);
}
// Mem8 <-- Mem8 AND Reg8
void I8086_AND_Eb_Gb(BYTE, I8086 &cpu) {
I8086_EGx_EGx_AND<BYTE>(cpu);
}Обрабатываем инструкцию AND_Eb_Gb (0x20), попадаем в общий обработчик для BYTE/WORD инструкции I8086_EGx_EGx_AND, а дальше вызываем глобальный обработчик всех инструкций I8086_EGx_EGx, котоырй мы видели ранее.
Если бы mod|reg|R/M сопровождал каждую инструкцию, то его можно было бы встроить даже на этап Decode, но он есть только в тех инструкциях, где операнды вариативны. Например, в инструкции MOV AX,20H (она же MOV в режиме Immediate) его нет, потому что под такие инструкции выделены отдельные опкоды, а вот в MOV AX,[2487H] будет уже с управляющим байтом. Поэтому подход такой: там где надо — вызывается метод GetInstructionData, а где не надо — не вызывается. В остальном же используется известный из первой части подход, который от процессора к процессору обрастает дополнительными функциональными частями. Такие дела.
Заключение
Ну вот и всё. Получилось чуть глубже, чем я ожидал, но как рассказывать о таком проекте, не вдаваясь в детали? Наверное, никак.
Что сейчас происходит
Проект живет и развивается. Медленно, со скрипом, но всё же появляются свежие коммиты, которые добавляют всё новые и новые функции.
Начата работа над Z80, я подготовил структуру и провел первоначальный ресерч на эту тему. Дела с 8086 тоже идут к завершению, если бы не одно НО: в 8086 есть кэш инструкций на 6 байтов, и как бы это смешно не звучало, но пока я не знаю, как к этому подступиться, потому что информации крайне мало. В моем арсенале — три внушительных книги, информацию из которых я использовал для написания статьи: ASM86 Language Reference Manual, Russell Rector, George Alexy — The 8086 Book и The 8086 Family Users Manual, но ни в одной из них эта тема не раскрывается достаточно подробно.
Что дальше
По сравнению с первой частью я определяю свои планы на будущее гораздо точнее:
закончить работу над 8086
начать работу над Z80
отполировать I/O и добавить интерактивные программы
рефакторить кодовую базу
В то время, год назад, мои мысли по поводу проекта были всё ещё немного сумбурными. Я не знал, куда податься: в эмуляцию процессоров или же в эмуляцию железа по типу компьютеров или приставок.
Сейчас я понимаю, что в первую очередь я хочу набраться опыта, попробовать разные техники и добиться единого подхода. После этого я возьму в работу что-то типа эмулятора GameBoy и попробую построить его на основе своего эмулятора. Это отличается от стандартного подхода к разработке эмуляторов железа, потому что в такой сфере люди обычно исходят из специфики конкретного железа, и хочешь не хочешь, а некоторые детали реализации опускаются. Я хочу подойти к вопросу с обратной стороны.
Спасибо, что дочитали до этого момента. Не страшно, если вы просто долистали. Я поделился еще одной частичкой своей истори, и надеюсь, у нее будет продолжение.
Я всё еще в поиске неравнодушных людей, желающих присоединиться к проекту и с таким же и��тересом погруженных в процесс. Если вы хотите помочь в разработке или у вас просто есть какие-то мысли и предложения — пишите в Telegram @dimanchique, ну или на почту. Также напоминаю, что проект можно потрогать здесь. Всем пока!
