Пишем простую виртуальную машину (1я часть. Минимально работоспособный код эмулятора)

Всем привет! Я студент, хочу получить опыт в системном программировании. В этом проекте я решил систематизировать свои знания в области архитектуры компьютера, и надеюсь, кому-то эта статья тоже поможет с пониманием. Итак, я хочу написать свой эмулятор ЭВМ, скорее даже что-то вроде конструктора: пользователь сам сможет писать свои опкоды и связывать их с конкретными номерами.

Договоримся об ограничениях и упрощениях

  • Все регистры — 32 битные, адреса — 32 битные, память — из 32-битных ячеек

  • Задающего генератора, то есть CPU clock, у нас, по крайней мере в этой части не будет

  • Адрес — указывает не на конкретный байт, а на 32-битную ячейку, каждый следующий адрес — следующая ячейка (для простоты)

  • Кэшей процессора не будет

  • Пока без прерываний и без стека

Шаг 1.1. Эмуляция CPU

У класса CPU следующие «обязанности»:

  • Хранение ссылки на набор команд (Command_set)

  • Хранение ссылки на объект, представляющий текущее состояние (регистры, адрес) (CPU_state)

  • Хранение ссылки на объект, управляющий памятью (Memory)

  • При старте процессор из памяти вытаскивает опкод, запрашивает количество аргументов этого опкода у своего набора команд, получает аргументы из памяти, исполняет опкод

class CPU {
private:
    Command_set &command_set;
    CPU_state &cpu_state;
    Memory &memory;
    uint32_t get_data() {
        uint32_t addr = cpu_state.get_addr();
        uint32_t data = memory.get(addr);
        cpu_state.set_addr(addr + 1); // Адресовать мы можем только ячейки по 32 бита, каждый следующий n+1 адрес - новая 32-битная ячейка
        return data;
    }
public:
    CPU(Memory &memory, Command_set &command_set, CPU_state &cpu_state) : memory(memory), command_set(command_set), cpu_state(cpu_state) {}
    void start(uint32_t start_addr) {
        cpu_state.set_addr(start_addr);
        uint32_t opcode = 0x00;
        do {
            opcode = get_data();
            size_t arg_num = command_set.get_opcode_args_count(opcode);
            std::vector args;
            for (size_t i = 0; i < arg_num; ++i) {
                uint32_t arg = get_data();
                args.push_back(arg);
            }
            command_set.execute(opcode, cpu_state, memory, args);
        } while (opcode != 0xFFFF);
    }
};

Память Memory и набор команд Command_set, а также экземляр класса CPU_state получаем извне: Так мы сможем «конструировать» архитектуру с учётом ограничений выше так, как хотим.

Command_set, CPU_state и Memory рассмотрим позднее.

Шаг 1.2. Хранение набора команд

Класс Command_set по сути представляет собой обёртку над ассоциативным контейнером, где мы связываем каждый конкретный опкод со своим номером

class Command_set {
    std::unordered_map opcodes;
    Opcode &find_opcode(uint32_t opcode_num) {
        auto it = opcodes.find(opcode_num);
        if (it == opcodes.end()) {
            throw std::runtime_error("Opcode not found");
        }
        return (*it).second;
    }
public:
    void add_opcode(uint32_t opcode_num, Opcode opcode) {
        opcodes.insert({opcode_num, opcode});
    }
    void execute(uint32_t opcode_num, CPU_state &cpu_context, Memory &memory, std::span args) {
        find_opcode(opcode_num).execute(cpu_context, memory, args);
    }
    size_t get_opcode_args_count(uint32_t opcode_num) {
        return find_opcode(opcode_num).get_args_num();

    }
};

Шаг 1.3. Хранение конкретного опкода

А именно хранить мы будем:

  • Имя опкода (для отладки и демонстрации работы на первых шагах)

  • Количество аргументов — их и запрашиваем мы в классе CPU

  • Обработчик опкода — функцию, которая и будет непосредственно выполнять роль опкода

Тут ничего сложного — храним, возвращаем количество аргументов и вызываем обработчик опкода

using Opcode_handler = std::function)>;

class Opcode {
    std::string name;
    size_t args_num = 0;
    Opcode_handler handler;
public:
    Opcode(const std::string &name, int args_num, Opcode_handler handler) {
        this->args_num = int2size(args_num);
        this->handler = handler;
        this->name = name;
    }
    size_t get_args_num() {
        return args_num;
    }
    void execute(CPU_state &cpu_context, Memory &memory, std::span args) {
        handler(cpu_context, memory, args);
#ifdef IS_DEBUGGING
        std::cout << name << " called" << std::endl;
#endif
    }
};

В отладочных целях можем выводить, что данный опкод вызван

В обработчик опкода передаём:

  • Контекст процессора, чтобы опкод мог менять регистры, адрес, устанавливать и сбрасывать флаги — об этом позже

  • Память, чтобы опкод мог записывать в память данные

  • Произвольное количество аргументов

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

Шаг 2. Контекст процессора

Вот тут мы и храним регистры, адрес текущей команды и флаги

class CPU_state {
private:
    uint32_t *registers;
    uint32_t addr;
    uint32_t flags = 0;
public:
    enum Flag {
        ZF = 1 << 0, // Сдвиг единицы на 0 знаков влево, т е 0b0001
        SF = 1 << 1, // 0b0010
        OF = 1 << 2, // 0b0100
        CF = 1 << 3 // 0b1000
    };
    enum Registers {
      SP = 1 << 0
    };
    CPU_state(int regs_number) {
        registers = new uint32_t[int2size(regs_number)];
    }
    uint32_t get_register(int number) {
        size_t reg_number = int2size(number);
        if (reg_number >= REG_NUM) throw;
        return registers[reg_number];

    }
    void set_register(int number, uint32_t reg_value) {
        size_t reg_number = int2size(number);
        if (reg_number >= REG_NUM) throw std::runtime_error("Invalid register number");
        registers[reg_number] = reg_value;
    }
    uint32_t get_addr() {
        return addr;

    }
    void set_addr(uint32_t addr_value) {
        addr = addr_value;
    }
    void set_flag(Flag flag, bool value) {
        if (value) flags |= flag; // т. е. побитово прибавляем к флаговому регистру флаг, value мы только проверяем, ставить флаг, или сбрасывать
        else flags &= ~flag;
    }
    bool get_flag(Flag flag) const {
        return flags & flag; // находим пересечение flags с тем, что во Flag flag
    }
    ~CPU_state() {
        delete []registers;
    }
};

Регистры хранятся в массиве, адрес хранится в отдельной переменной, флаги — в одной переменной побитово

Шаг 3. Память

Вот тут и началось самое сложное. Сначала я написал память фиксированного размера, хранил её в массиве и всё было хорошо. Но мне понадобилась возможность подключать устройства путём отображения в память. Давайте рассмотрим этот процесс поподробнее:

struct Buf {
        size_t size = 0;
        uint32_t address_begin;
        uint32_t *buf = nullptr;
        Buf(int size, uint32_t address_begin, uint32_t *buf) {
            this->buf = buf;
            this->address_begin = address_begin;
            this->size = int2size(size);
        }
        uint32_t begin() {
            return address_begin;
        }
        uint32_t end() {
            return address_begin + size;
        }
    };

Для внутреннего доступа мы завели в классе структурку Buf. Храним мы в ней память, получаемую извне, размер этой памяти, а также начальный адрес, куда мы спроецировали данный буфер в общую память. Логика проста: возвращаем базовый адрес функцией begin, добавляем к нему размер и полученный адрес возвращаем из end ().

Снаружи мы храним в классе Memory память машины и буферы, а также реализуем перевод из адреса памяти машины в адрес буфера.

class Memory {
    struct Buf {
        size_t size = 0;
        uint32_t address_begin;
        uint32_t *buf = nullptr;
        Buf(int size, uint32_t address_begin, uint32_t *buf) {
            this->buf = buf;
            this->address_begin = address_begin;
            this->size = int2size(size);
        }
        uint32_t begin() {
            return address_begin;
        }
        uint32_t end() {
            return address_begin + size;
        }
    };
    uint32_t *memory = nullptr;
    size_t mem_size = 0;
    std::vector buffers;
    uint32_t *get_buf(uint32_t address) {
        if (address < mem_size) {
            return memory;
        } else {
            for (auto &buf : buffers) {
                if (address > buf.begin() && address < buf.end()) {
                    return buf.buf;
                }
            }
            throw std::out_of_range("Out of bounds");
        }
    }
    void set_buf(uint32_t address, uint32_t value) {
        if (address < mem_size) {
            memory[address] = value;
        } else {
            for (auto &buf : buffers) {
                if (address > buf.begin() && address < buf.end()) {
                    buf.buf[address - buf.begin()] = value;
                    return;
                }
            }
            throw std::out_of_range("Out of bounds");
        }
    }
public:
    Memory(int size) {
        mem_size = int2size(size);
        memory = new uint32_t[mem_size];
    }
    uint32_t get(uint32_t address) {
        if (address >= full_size()) throw std::out_of_range("address is higher than memory size");
        if (get_buf(address) == nullptr) throw;
        return get_buf(address)[address];
    }
    void set(uint32_t address, uint32_t value) {
        if (get_buf(address) == nullptr) throw;
        set_buf(address, value);
    }
    uint32_t full_size() {
        if (buffers.size() == 0) return (uint32_t)mem_size;
        return buffers[buffers.size() - 1].end();
    }
    uint32_t memory_size() {
        return (uint32_t)mem_size;
    }
    uint32_t attach_buf(int size, uint32_t *buf) {
        if (buf == nullptr) throw;
        uint32_t base_address = full_size();
        buffers.emplace_back(size, base_address, buf);
        return base_address;
    }
    ~Memory() {
        if (memory != nullptr) {
            delete[] memory;
        }
    }
};

Перевод адресов реализуем в функциях get_buf (address) и set_buf (address, value). Для этого отдельно храним размер памяти самой виртуальной машины и если полученный абсолютный адрес меньше размера памяти машины (в данном случае говорим о принадлежности адреса внутренней памяти машины, а не внешнему буферу). Иначе идём в цикле, и если данный адрес попадает в диапазон какого-либо буфера, возвращаем его (для внутреннего пользования в классе Memory), иначе выбрасываем ошибку.

Подключаем мы буфер «выше» основной памяти и выше предыдущих буферов с помощью функции attach_buf, которая возвращает адрес начала подключённого буфера.

Возможность отключения буфера не пердусмотрена, так как пришлось бы реализовывать более адресную арифметику для перевода и бороться с фрагментацией.

Полный размер памяти full_size (), включая буферы, в данной схеме можем получить как конец последнего буфера или размер основной памяти, если буферов нет.

Шаг 4. Реализация опкодов

Вот мы и добрались до самого интересного! Ещё чуть-чуть, и сможем «сконструировать» свою архитектуру.

void add(CPU_state &cpu_context, Memory &memory, std::span args) {
    int32_t a = cpu_context.get_register(args[0]);
    int32_t b = cpu_context.get_register(args[1]);
    int32_t result = a + b;
    std::cout << result << std::endl;

    cpu_context.set_register(args[0], result);

    cpu_context.set_flag(CPU_state::ZF, result == 0);
    cpu_context.set_flag(CPU_state::SF, result < 0);
    cpu_context.set_flag(CPU_state::OF, (a > 0 && b > 0 && result < 0) || (a < 0 && b < 0 && result > 0));
    cpu_context.set_flag(CPU_state::CF, (uint32_t)a + (uint32_t)b < (uint32_t)a);
}

void mov(CPU_state &cpu_context, Memory &memory, std::span args) {
    cpu_context.set_register(args[0], args[1]);
}

void hlt(CPU_state &cpu_context, Memory &memory, std::span args) {}

void jmp(CPU_state &cpu_context, Memory &memory, std::span args) {
    cpu_context.set_addr(args[0]);
}

Тут мы реализовали четыре опкода для примера (в следующих частях их будет больше!). Формат получения данных обработчиком был рассмотрен ранее. В add мы получаем значения из тех регистров процессора, которые указаны в аргументах. Так как это пример опкода, то мы помимо основного функционала выводим результат на экран, впоследствии, добавив устройства ввода-вывода, мы сможем протестировать это более естественно. Но здесь наша цель — минимально рабочий прототип, демонстрация архитектурного подхода. Получаем значения из регистров, складываем, записываем в регистр, который был указан первым аргументом. Если бы мы писали ассемблер, выглядело это бы так:

add accumulator, value

Выставляем флаги: если получили ноль, ставим Zero Flag (ZF), если результат отрицателен, ставим Sign Flag (SF), если переполнение — ставим Overflow Flag (OF), флаг переноса CF (Carrier Flag) ставим, если беззнаковое переполнение.

Инструкция MOV проста: в регистр, указанный первым аргументом, записываем значение, указанное вторым аргументом:

mov register, value

Так же у нас есть опкод для hlt, но обработчик не делает ничего, а номер его опкода служит условием остановки цикла процессора в классе CPU.

Ещё одна инструкция — JMP, которая изменяет адрес, по которому процессор исполняет команды:

jmp addr

Шаг 5. Загрузка данных в память

Всё это конечно хорошо, но машина берёт команды из памяти. А в память их как-то нужно загрузить. Самое простое решение — написать Loader, который брал бы данные из файла, и подкидывал бы их в определённый адрес в памяти.

Вот моя наивная реализация:

class Memory_Loader {
public:
    static void preload_file(Memory &memory, std::ifstream &bin_file, uint32_t begin_address) {
        uint32_t addr = begin_address;
        uint32_t value = 0x0;
        while(bin_file.read(reinterpret_cast(&value), sizeof(value))) {
            memory.set(addr, value);
            ++addr; // Т.к. в нашем контексте адрес - индекс в массиве (индекс 32-битной ячейки) из uint32_t
        }
    }
};

Тут мы читаем бинарник, и каждые 4 байта (32 бита — размер uint32_t) подсовываем их в память и инкрементируем адрес (не забываем, что в целях упрощения адресуются ячейки по 4 байта).

Шаг 6. Служебные функции

size_t int2size(int size) {
    if (size < 0) throw std::runtime_error("Invalid conversion");
    return (size_t)size;
}

Тут очевидно: int кастим к size_t с проверкой на отрицательное значение.

Шаг 7. Собираем архитектуру процессора!

class Basic_CPU_Factory {
public:
    static CPU* make_basic_cpu(Memory &memory, Command_set &cs, CPU_state &cpu_context) {
        cs.add_opcode(0x0001, Opcode("ADD", 2, add));
        cs.add_opcode(0x0002, Opcode("MOV", 2, mov));
        cs.add_opcode(0x0003, Opcode("JMP", 1, jmp));
        cs.add_opcode(0xFFFF, Opcode("HLT", 0, hlt));
        return new CPU(memory, cs, cpu_context);
    }
};

Тут мы получаем память, набор команд и контекст процессора, и создаём экземпляр процессора с добавленными опкодами ADD, MOV и HLT с соответствующими номерами (Данный класс несёт цель показать, как можно «собрать» архитектуру (с некоторыми ограничениями).

Шаг 8. Финальные штрихи.

Вот моя функция main ()

int main() {
    Memory mem(1024);
    Command_set cs;
    CPU_state context(52);
    CPU* cpu = Basic_CPU_Factory::make_basic_cpu(mem, cs, context);
    std::ifstream in("bin_file.bin", std::ios::binary);
    Memory_Loader::preload_file(mem, in, 0x0000);
    cpu->start(0x0);
    return 0;
}

Тут я создал память на 1024 ячеек по 4 байта, то есть 1024×4 = 4096 байт = 4 кБайта.

Создал класс для состояния процессора на 52 регистра, набор команд, который потом пополнится командами процессора. Считал файл bin_file.bin и загрузил его по адресу 0×0, затем запустил процессор с этого же адреса. Данная реализация main () также играет чисто демонстрационную роль.

Шаг 9. Поехали!

Скомпилировав данный код, я создал в HEX-редакторе бинарник для загрузки в память.

02 00 00 00 02 00 00 00 07 00 00 00 02 00 00 00 08 00 00 00 1B 00 00 00 01 00 00 00 02 00 00 00 08 00 00 00 FF FF 00 00

На ассемблере это было бы:

mov 02, 07 ; Скопировали во 2й регистр значение 7
mov 08, 27 ; 0x001B = 27
add 02, 08 ; Прибавили ко 2му регистру значение 8-го регистра, т.е. 7
hlt        ; Стоп!

Как мы помним, в наших демонстрационных целях прямо в опкоде add мы выводим результат сложения, а также в CPU выводим, какие опкоды были вызваны.

Вот результат:

Прибавили к 7 27, получили 34
Прибавили к 7 27, получили 34

Модифицируем немного данный код, добавив в конце перед HLT инструкцию перехода в начало:

jmp 0 ; переход в начало

02 00 00 00 02 00 00 00 07 00 00 00 02 00 00 00 08 00 00 00 1B 00 00 00 01 00 00 00 02 00 00 00 08 00 00 00 03 00 00 00 00 00 00 00 FF FF 00 00

Запустим наш эмулятор:

Получили бесконечный цикл
Получили бесконечный цикл

Получился бесконечный цикл, как и ожидалось.

Как мы видим, реализованные нами опкоды работают корректно.

Что дальше?

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

  • Добавить опкоды, хотя бы чтобы машина была Тьюринг-полной

  • Добавить поддержку графики и звука: можно подключить фреймбуффер и звуковой буфер к памяти и сделать отрисовку на SDL2 или OpenGL

  • Реализовать постоянную память (как HDD): Написать класс HDDController, который с одной стороны выдаёт буфер, в который можно писать и который можно читать, а с другой — читает и пишет файл, например, disk.img

  • Добавить поддержку параллелизма, но, возможно, придётся переделывать архитектуру, я пока практически не владею параллелизмом и многопоточностью

  • Написать ассемблерный транслятор для данной машины

  • В очень далёкой перспективе: написать DOS-подобную ОС

  • Создание анимации: в бинарнике сделать в начале цикл, который будет загружать изображения с определённого адреса в фреймбуфер, а потом увеличивает адрес, а потом непосредственно данные картинок в формате Bitmap

Заключение

Итак, мы создали «виртуальную машину», которая способна исполнять опкоды, имеет свою оперативную память и возможность подключать внешние устройства, в том числе и постоянную память, GPU и аудиоустройства. Цель выполнена — минимально работоспособный эмулятор мы создали.

Надеюсь, статья кому-нибудь поможет в освоении архитектуры компьютера, мне помогла. Также хотелось бы услышать мнение более опытных людей, по дизайну кода я использовал существенные упрощения — не использовал умные указатели, move-семантику и в основном использовал касты в стиле C. Старался не писать так, как не уверен, поэтому мой подход наивен и вряд ли очень безопасен. В любом случае, данный код рабочий, и это первый мой низкоуровневый проект такого масштаба.

До новых встреч!

© Habrahabr.ru