Пишем никому не нужный эмулятор

Доброго времени суток.


Довольно давно имелось желание написать эмулятор какого-нибудь процессора.
А что может быть лучше, чем изобрести велосипед?


Имя велосипеду — V16, от склеивания слова Virtual и, собственно, разрядности.



С чего начать?


А начать нужно, разумеется, с описания процессора.


В самом начале, я планировал написать эмулятор DCPU-16, но таких чудес на просторах Интернета хватает с лихвой, поэтому я решил остановиться только на "слизывании" самого основного с DCPU-16 1.1.


Архитектура


Память и порты


  • V16 адресует 128Kb (65536 слов) оперативной памяти, которая также может использоваться как буферы устройств и стек.
  • Стек начинается с адреса FFFF, следовательно, RSP имеет стандартное значение 0xFFFF
  • Портов ввода-вывода V16 имеет 256, все они имеют длину в 16 бит. Чтение и запись из них осуществляется через инструкции IN b, a И OUT b, a.

Регистры


V16 имеет два набора регистров общего назначения: основной и альтернативный.
Работать процессор может только с одним набором, поэтому между наборами можно переключаться при помощи инструкции XCR.


Инструкции


Все инструкции имеют максимальную длину в три слова и полностью определяются первым
Первое слово делится на три значения: младший байт — опкод, старший байт в виде двух 4-битных значений — описание операндов.


Прерывания


Прерывания здесь — не более чем таблица с адресами, на которые процессор дублирует инструкцию CALL. Если значение адреса равно нулю, то прерывание не делает ничего, просто обнуляет флаг HF.


Диапазон значений Описание
0x0...0x3 Регистр как значение
0x4...0x7 Регистр как значение по адресу
0x8...0xB Регистр + константа как значение по адресу
0xC Константа как значение по адресу
0xD Константа как значение
0xE Регистр RIP как значение только для чтения
0xF Регистр RSP как значение

Пример псевдокода и слов, в которые все это должно странслироваться:


MOV RAX, 0xABCD     ; 350D ABCD
MOV [RAX], 0x1234   ; 354D 1234

Cycles (Такты)


V16 может выполнять одну инструкцию за 1, 2 или 3 такта. Каждое обращение к оперативной памяти это один отдельный такт. Инструкция это не такт!


Начнем писать!


Реализация основных структур процессора


  1. Набор регистров. Регистров всего четыре, но ситуацию улучшает то, что таких наборов в процессоре целых два. Переключение происходит при помощи инструкции XCR.


    typedef struct Regs {
    uint16_t    rax, rbx;   //Primary Accumulator, Base Register
    uint16_t    rcx, rdx;   //Counter Register, Data Register
    } regs_t;

  2. Флаги. В отличии от DCPU-16, V16 имеет условные переходы, вызовы подпрограмм и возвраты оттуда же. На данный момент процессор имеет 8 флагов, 5 из которых — флаги условий.


    //Чтобы было красиво, нужно включить заголовок stdbool.h
    typedef struct Flags {
    bool        IF, IR, HF;
    bool        CF, ZF;
    bool        EF, GF, LF;
    } flags_t;

  3. Собственно, сам процессор. Здесь также описана таблица адресов прерываний, что вполне можно назвать дескрипторами и найти ещё одну отсылку на x86.


    typedef struct CPU {
    //CPU Values
    uint16_t    ram[V16_RAMSIZE];   //Random Access Memory
    uint16_t    iop[V16_IOPSIZE];   //Input-Output Ports
    uint16_t    idt[V16_IDTSIZE];   //Interrupt vectors table (Interrupt Description Table)
    flags_t     flags;              //Flags
    regs_t      reg_m, reg_a;       //Main and Alt register files
    regs_t *    reg_current;        //Current register file
    uint16_t    rip, rsp, rex;      //Internal Registers: Instruction Pointer, Stack Pointer, EXtended Accumulator
    
    //Emulator values
    bool        reg_swapped;        //Is current register file alt
    bool        running;            //Is cpu running
    uint32_t    cycles;             //RAM access counter
    } cpu_t;

  4. Операнд. При получении значений, нам необходимо сначала прочитать, затем изменить, а затем записать значение туда, откуда мы его взяли.


    typedef struct Opd {
    uint8_t     code : 4;
    uint16_t    value;
    uint16_t    nextw;
    } opd_t;


Функции для работы со структурами


Когда все структуры описаны, всплывает необходимость в функциях, которые наделят эти структуры магической силой угашенного кода.


cpu_t * cpu_create(void);                    //Создаем экземпляр процессора
void cpu_delete(cpu_t *);                    //Удаляем экземпляр процессора
void cpu_load(cpu_t *, const char *);        //Загружаем ROM в память
void cpu_rswap(cpu_t *);                     //Меняем наборы регистров
uint16_t cpu_nextw(cpu_t *);                 //RAM[RIP++]. Nuff said
void cpu_getop(cpu_t *, opd_t *, uint8_t);   //Читаем операнд
void cpu_setop(cpu_t *, opd_t *, uint16_t);  //Пишем операнд
void cpu_tick(cpu_t *);                      //Выполняем одну инструкцию
void cpu_loop(cpu_t *);                      //Выполняем инструкции, пока процессор работает

Также я не упомянул большое перечисление с кодами операций, но это необязательно и необходимо только для понимания, что происходит во всей этой каше.


Функция tick()


Также здесь присутствуют вызовы static-функций, предназначенных только для вызова из tick().


void cpu_tick(cpu_t *cpu)
{
    //Если была выполнена инструкция HLT, то функция ничего не сделает
    if(cpu->flags.HF) {
        //Если к тому же обнулен флаг прерываний, то паузу снимать уже нечему
        if(!cpu->flags.IF) {
            cpu->running = false;
        }

        return;
    }

    //Получаем следующее слово и декодируем как инструкцию
    uint16_t nw = cpu_nextw(cpu);
    uint8_t op = ((nw >> 8) & 0xFF);
    uint8_t ob = ((nw >> 4) & 0x0F);
    uint8_t oa = ((nw >> 0) & 0x0F);    //А потому что дизайн кода

    //Создаем структуры операндов
    opd_t opdB = { 0 };
    opd_t opdA = { 0 };

    //И читаем их значения
    cpu_getop(cpu, &opdB, ob);
    cpu_getop(cpu, &opdA, oa);

    //Дальше для сокращения и улучшения читабельности кода делаем переменные-значения операндов
    uint16_t B = opdB.value;
    uint16_t A = opdA.value;
    uint32_t R = 0xFFFFFFFF;    //Один очень интересный костыль
    bool clearf = true;         //Будут ли флаги условий чиститься после выполнения инструкции?

    //И начинаем творить магию!
    switch(op) {
        //Здесь мы проходим все возможные опкоды. Те, которые пишут результаты, меняют значение переменной R
    }

    //Чистим флаги условий
    if(clearf) {
        cpu->flags.EF = false;
        cpu->flags.GF = false;
        cpu->flags.LF = false;
    }

    //Очень интересный костыль, максимальное 32-битное значение при 16-битных операциях
    //  равно 0xFFFF0000, то есть 0xFFFF << 16
    //  А поэтому очень удобно для результата использовать 32-битное число
    if(R != 0xFFFFFFFF) {
        cpu_setop(cpu, &opdB, (R & 0xFFFF));
        cpu->rex = ((R >> 16) & 0xFFFF);
        cpu->flags.CF = (cpu->rex != 0);
        cpu->flags.ZF = (R == 0);
    }

    return;
}

Что делать дальше?


В попытках найти ответ на сей вопрос, я раз пять переписал эмулятор с C на C++, и обратно.


Однако главные цели можно выделить уже сейчас:


  • Прикрутить нормальные прерывания (Вместо простого вызова функции и запрета на прием других прерываний сделать вызов функции и добавление новых прерываний в очередь).
  • Прикрутить устройства, а также способы общения с ними, благо опкодов может быть 256.
  • Научить себя не писать всякую ересь на хабр процессор работать с определенной тактовой частотой в 200 МГц.

Заключение


Надеюсь, что кому-нибудь эта "статья" станет полезной, кого то подтолкнет на написание чего-то похожего.


Мои куличики можно посмотреть на github.


Также, о ужас, у меня есть ассемблер для старой версии этого эмулятора (Нет, даже не пытайтесь, эмулятор как минимум пожалуется на неправильный формат ROM)

Комментарии 24

    +13
    В попытках найти ответ на сей вопрос, я раз пять переписал эмулятор с C на C++, и обратно.
    Если переписывание с C на C++ и обратно не даёт полного удовлетворения, то нужно включить в эту цепочку ещё и Rust.
    +3
    16 битный процессор с регистрами с «R» в начале названия вызывают диссонанс. Почему так сделали? Чтобы было удобнее дебажить?
      0
      Почему так сделали? Чтобы было удобнее дебажить?

      По-идее да, да и меньше путаницы в коде
      +1
      Что делать дальше?

      Делать ПО для эмулированного процессора! :)

      P.S. Например, автор TTL компьютера Gigatron сейчас пишет эмуляцию процессора 6502 на данном компьютере.
      А, симулятор данного компьютера уже написали и даже в виде одночипового варианта данного компьютера на STM32F405
        0
        Чтобы было ПО, нужны устройства ввода-вывода, а то на одном процессоре далеко не уедешь :D
        +2
        Писал когда-то подобное
        github.com/averrin/vvm
        medium.com/@averrin/toy-vm-c72e9d962451
          +1
          Что делать дальше?

          А нет ли желания попробовать этот процессор написать в железе: сделать ядрышко под FPGA?
          Дальше на него можно попробовать портировать линукс).
            0
            Идея хорошая, но мне бы сначала устройства прикрутить :), а то какой линукс без монитора?
              +1
              А зачем линуксу монитор?
                0
                ну или то, что будет играть роль устройства ввода-выводы
            0
            1) Эмулятор обычно опирается на реальное железо(которое и пытается эмалировать), а все особенности работы железа определены из физических и экономических соображений.
            Т.е. почему есть та или иная инструкция, почему есть такое кол-во регистров, и т.д. это все не с потолка. Иначе складывается впечатление что автор слышал, но не понимает природу вещей.

            2) «переписал эмулятор с C на C++, и обратно.» — скорее всего автор слаб в Си, и как следствие слабо представляет во что разворачиваются Си++ конструкции, какие, как, когда, где хранятся и т.п…

            3) «Что делать дальше?» Как минимум почитать «Цифровая схемотехника
            и архитектура компьютера» Дэвид М. Харрис и Сара Л. Харрис. И потратить лет 10-15 на подтягивания понимания Си, архитектуры и желательно плис. И тогда мы от вас получим статью о «никому не нужном эмуляторе», с еще одним абзацем обоснованным фундаментом и исходниками в которых вы по всем параметрам уверенны.
              +1
              а все особенности работы железа определены из физических и экономических соображений.

              Процессор виртуальный, придуманный на коленке

              скорее всего автор слаб в Си

              На некоторых отрезках времени было целесообразно переписать все это дело на плюсы

                +1
                На некоторых отрезках времени было целесообразно переписать все это дело на плюсы

                На каких именно? Не вижу проблем с написанием эмулятора на Си.

                +3
                Эмулятор обычно опирается на реальное железо

                Правда? И на какое же железо опирается JVM или CIL?

                «переписал эмулятор с C на C++, и обратно.» — скорее всего автор слаб в Си

                Простите, а какая тут вообще связь со знанием языка? Что может помешать переписывать тому кто отлично знает оба языка?
                  0
                  Я не увидел у автора статьи обоснования набора инструкции, регистров и т.п…
                  В кремнии или виртуальный будет ваш конечный результат, все же производительность, энергопотребление, размер на кристалле/в памяти, что-то из всего этого стоит учитывать и стремиться улучшать. И вопрос «что делать дальше» станет более очевидным.
                  Правда? И на какое же железо опирается JVM или CIL?

                  На то, на котором будет запущено jvm. Т.е. и там тоже когда разрабатывали не с потолка инструкции брали, а думали о том на чем будет выполняться. Я думаю что там тоже есть обоснования и что там есть моменты упирающиеся в реальное железо, в реальные архитектуры. А так же я считаю что скорость исполнения байт кода jvm, которая крутится на реальном железе, важна.
                  Думаю вы согласны с этим.

                  «переписал эмулятор с C на C++, и обратно.» — скорее всего автор слаб в Си
                  Простите, а какая тут вообще связь со знанием языка? Что может помешать переписывать тому кто отлично знает оба языка?

                  «кто отлично знает оба языка?» Переписывать туда и обратно? Здравый смысл, и время.
                  Иначе я начну сомневаться в «отличном знании», но скорей всего выясниться, что я понимаю под знанием языка Си не только его синтаксис. Думаю вы согласны с этим.
                +1
                typedef struct regs_t {
                uint16_t    rax, rbx;   //Primary Accumulator, Base Register
                uint16_t    rcx, rdx;   //Counter Register, Data Register
                } regs_t;

                Целесообразнее было бы написать так:


                typedef struct Regs {
                uint16_t    rax, rbx;   //Primary Accumulator, Base Register
                uint16_t    rcx, rdx;   //Counter Register, Data Register
                } regs_t;

                Так как постфикс _t обозначает, что это структура, но не имеет смысла вместе с ключевым словом struct:


                struct regs_t registers;

                Так же и со всеми остальными структурами.

                  +1
                  да, спасибо, поправлю
                    +2
                    Постфикс _t обозначает любой тип, а не только структуру. В сишных гайдлайнах (например по ядру Linux) рекомендуют не удалять struct, если на то нет веских причин, дабы не создавать путаницу и делать код более читабельным
                      0

                      И то верно, к примеру int64_t, uint16_t — это лишь тайпдефы с уже существующим примитивам.

                    +1
                    Что делать дальше?

                    Прикрутить LLVM вестимо

                      0

                      В универе задали курсовую?

                        +1
                        Просто было немного свободного времени, вот и решил заняться чем-то подобным
                        +1
                        Что же дальше?

                        Написание кастомного аллокатора памяти?

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое