Прошло чуть больше года с момента публикации первой части. Я хоть и делал паузу, но проект не пылился в ящике, я занимался изучением различных аспектов работы микропроцессоров, смотрел видео, читал книги, справочники, документацию, задавал вопросы на 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 &lt;&lt; 8);  
}  
  
FORCE_INLINE void WriteByte(Memory &memory, const BYTE value, const WORD address) {  
    bus-&gt;Write(address, value);
}  
  
FORCE_INLINE void WriteWord(Memory &amp;memory, const WORD value, const WORD address) {  
    WriteByte(memory, value &amp; 0xFF, address);  
    WriteByte(memory, (value &gt;&gt; 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, которая сохраняет состояние статус-регистра в стек:

Ассемблер у такой инструкции тоже элементарный:

PUSHF

Immediate

Такой тип инструкций нам тоже встречался в 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 &lt;- AX &amp; mem[(DS &lt;&lt; 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 кодирует конечную вызываемую инструкцию.

Вот такая получается хитрая схема, и нужно было решить сразу несколько задач:

  1. Декодирование управляющего байта.

  2. Проектирование универсальной структуры, которая содержала бы в себе готовые для использования операнды.

  3. Кодирование управляющего байта для использования в тестах.

Здесь я снова сделал большую паузу, потому что мне нужно было время на то, чтобы осмыслить и уложить в голове всю ту информацию, которую мне пришлось изучить. Я долго откладывал, но застой в работе был для меня тяжелее, чем сама работа, поэтому я начал делать хоть что-то. Отправная точка напрашивается сама собой:

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 &lt;= 7);
    BYTE *regTable[] = {&amp;AL, &amp;CL, &amp;DL, &amp;BL, &amp;AH, &amp;CH, &amp;DH, &amp;BH};
    return regTable[modByte];
}  

// REG | R/M should be passed
WORD *GetRegWordPtr(const BYTE modByte) {
    assert(modByte &lt;= 7);
    WORD *regTable[] = {&amp;AX, &amp;CX, &amp;DX, &amp;BX, &amp;SP, &amp;BP, &amp;SI, &amp;DI};
    return regTable[modByte];
}

Для вычисления адреса мы используем уже знакомые нам из раздела адресации функции:

DWORD GetModRegAddress(const ModRegByte &amp;modReg) {
    switch (modReg.rm) {
        case 0b000:
            return GetBasedIndexedAddress(&amp;BX, &amp;SI, modReg.mod);
        case 0b001:
            return GetBasedIndexedAddress(&amp;BX, &amp;DI, modReg.mod);
        case 0b010:
            return GetBasedIndexedAddress(&amp;BP, &amp;SI, modReg.mod);
        case 0b011:
            return GetBasedIndexedAddress(&amp;BP, &amp;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, ну или на почту. Всем пока!

Комментарии (0)