Хроники игрушечной машинки
Виртуальной машинки игрушечного же ассемблера
Пока я усиленно пытаюсь заставить себя начать писать статью на английском, запишу хотя бы так, а то забуду что-нибудь.
Как вы, наверное, заметили, я давненько ничего не писал, хотя и клятвенно обещал. Мне даже стыдно, хотя знаю, что вам пофиг=)
Речь сегодня снова пойдет о моей игрушечной виртуалке и ее игрушечном ассемблере. Последние упоминания о ней были в январе (тыц, шлеп, эта и еще одна), а раз я решил написать о ней на английском, надо бы ее причесать. Причесать получилось не очень, но рассказать есть о чем. Даже если вы не помните или не читали предыдущие статьи, пролистайте эту. Я попытаюсь сделать ее более общей.
Напомню, программа представляет собой что-то вроде виртуальной машины для примитивного, но приближенного к реальности ассемблера. Я старался сделать что-то простое, но максимально низкоуровневное. Вроде как игра DIS-100, но на реальном железе. Поэтому я эмулирую свою псевдо-память и псевдо-регистры. А заодно стараюсь изучить поближе C++. Все, можете прыгать на следующий заголовок.
А это абзац для тех, кто что-то помнит. Пожалуй, самое важное: появился парсер. Да, то, с чего, начинает, наверное, каждый, кто пишет свой язык, я сделал только спустя три тысячи строк. На самом деле, так как ассемблер — синтаксически очень простой язык, это было совсем не так сложно, как я ожидал. Меньше двухсот строк. Столько занимает метод, который парсит файл и превращает в набор “инструкций” — сущностей, которые компилируются в байткод. Ими же оперирует отладчик.
Конечно, был соблазн использовать их и при выполнении, но я удержался. Это все мишура, выполнение кода должно зависеть исключительно от байт-кода. Поэтому я вынес методы парсинга файла и дизассемблирования кода в отдельную сущность-анализатор.
Две главные вещи, которые я приобрел, сделав парсинг кода из файла: метки и возможность хранения адресов в регистрах. Но чтобы не рассказывать неинтересные детали о неинтересных деталях, я поступлю по другому. Расскажу с самого начала и до самого конца, что происходит в программе. Так я и все нововведения зацеплю и расскажу тем, кто забыл, как оно и зачем. Спойлер: примитивно и бесполезно.
UPD: Прямо во время написания статьи было дописано и переписано просто море кода. Ну не хочется писать “пока тут вот так, а должно быть вот так”. Приходилось сразу доделывать. Так что далее в тексте я буду описывать то, как оно уже.
Сначала
Сначала настраивается SDL2. Да, я перешел с SFML на SDL. Позже расскажу почему. Кто не в курсе — это библиотека для работы с графикой и пользовательским вводом. Она все равно достаточно низкоуровневая, но все лучше, чем напрямую тягать OpenGL. Ну тут ничего интересного.
Читаем из аргумента путь до файла с кодом. Да, надо сделать открытие файла из интерфейса. И да, надо сделать и открытие виртуалки, код из нее тоже сгенерить не проблема. Метки, правда, сдохнут, но это ерунда. В общем, читаем файл. Ну то есть скармливаем анализатору, экземпляр которого тут же и создали. Ему на вход больше ничего и не нужно.
Создаем виртуальный “указатель”, чтобы правильно отображать сдвиг инструкции в отладчике и чтобы джампы знали куда прыгать. И начинаем читать файл.
- Если строка начинается с ‘;’ — пропускаем, это комментарий.
- Если строка заканчивается ‘:’ — запоминаем строку, это метка, которая будет присвоена следующей инструкции
Если все не так, то нам повезло — у нас код. Ну или нет. Ошибок компиляции у меня нет. Если строка не распарсилась, то она просто пропускается.
Код мы бьем по пробелам. Это, очевидно название инструкции, первый и второй аргументы. Собственно, это весь синтаксис. Аргументы, естественно, могут быть пустыми. Так как они могут быть и разных типов, я решил, что пора воспользоваться тем, что уже не 99ый, и можно использовать std::variant. Типов у меня три: байт, инт и адрес. А вот размеры у них могут немного настраиваться. Дело в том, что с самого начала инт был у меня 32х-битным (то есть 4 байта), но оказалось, раз я нигде не использую хардкод, то его можно объявить 16и-битным и все прекрасно продолжит работать. Что я и сделал, ибо зачем мне такие огромные инты? Размер адреса равен сумме размеров байта и инта. В байте я храню флаги (чуть позже расскажу) и собственно смещение.
Давайте посмотрим, как это работает. Первая строка “MOV EAX EDI”. Понятно, что имя инструкции — MOV. Первый аргумент я сверяю со списком констант. То есть адресов регистров. Я еще не сделал, но в качестве адреса можно будет передавать и литералы, в виде “.0x00FF”. Раз первый аргумент у нас регистр, то тип инструкции A*. То есть адрес. Если второй аргумент тоже адрес, то тип у нас AA, если же нет, то это I (int). Так что парсим. Сначала как хекс, потом как десятичное. Если успешно и значение <256 — меняем тип на W (word, то есть один байт). Собственно, вот мы знаем имя инструкции, ее тип, аргументы и их типы. Время искать спеку.
Зачем мы это делаем? Не только для того, чтобы не таскать все эти знания по одному, но и чтобы проверить, что такая инструкция поддерживает такое количество таких аргументов. По спеке мы создаем “инструкцию”, то есть уже то, что представлено именно этой строчкой кода.
Здесь уже есть непосредственно значения аргументов. Также там, есть (та-дам) алиасы. Именно туда записывается метка, которая идет до строки. Стоп, но если мы только сейчас знаем сдвиг инструкции и метку, то куда будет прыгать джамп, который шел до этого? Пока никуда. Инструкции, отвечающие за переходы помечаются “ожидающими” и до поры имеют в качестве аргумента заглушку. Когда будет завершен первый разбор файла, мы еще раз пробежимся по “ожидающим” инструкциям и проставим им в качестве аргумента оффсеты инструкций с соответствующими метками.
Возможно, вы обратили внимание, что среди аргументов есть вот такая запись: “[EAX]”. Она нужна, чтобы сказать виртуалке “мне нужен не адрес этого регистра, а адрес, который в нем хранится”. Таким образом мы можем хранить адреса и совершать с ними операции, например, двигая в нужную нам сторону. Это было бы легко сохранить в инструкции, но виртаулка про них ничего не знает, это сущность анализатора. Поэтому пришлось хранить это флажок в адресах. Там же разместился флаг о том, что по адресу нас интересует младший байт, а не весь инт. Это позволило сделать однобайтовых близнецов для регистров: AL, BL, CL. Удобно, когда тебе надо писать в память и отдельными байтами.
Далее
Итак, нас есть “скрипт”, то есть набор инструкций. Давайте скомпилируем их. Создаем “ядро”. [Пока курсор стоял на этом месте, было переписано просто гигантское количество кода.] Не сложно догадаться, что ядро — самая важная часть нашей программы. Анализатор, интерфейс с отладчиком и редакторами памяти — это все вторично. Оно ничего не знает об устройстве ядра и общается с ним по минимальному интерфейсу. Ядро легко может работать и без обвязки, а она в свою очередь с любым ядром.
Итак. При инициализации ядро создает небольшой кусочек памяти, который отвечает за внутреннюю реализацию ядра. Это заголовок, а также флаги и регистры. Эта структура будет неизменна для любого кода. В идеале (у меня уже все готово для этого), можно будет ограничить запись и чтение из этого диапазона.
Теперь будем компилировать полученные при парсинге файла инструкции в байт-код. Создадим еще один кусочек памяти. Можно было бы сразу посчитать размер, но так как все равно потом ресайзить, просто отведем максимальную длину инструкции всем элементам.
Отступлюсь и чуть подробнее расскажу про память. Когда-то совсем недавно, вся память была одним вектором байтиков. Это было просто и удобно, пока я не решил, что нужно добавить еще и видео-память. Логично, что кроме нее в будущем может пригодиться и место под кучу и подключаемые библиотеки и мало ли что еще, вроде еще каких-то устройств вывода. Значит, может быть еще несколько кусков памяти, которые должны адресоваться и быть доступны так же как и основная. Или не так же. Что тоже важно. Поэтому я выделил отдельную сущность MemoryContainer, которая умеет только читать и писать примитивные типы (инты и байты). Ядро же по положению указателя самостоятельно выбирает из какого контейнера читать и в какой писать. Это позволило мне сделать отдельные редакторы памяти и в будущем будет полезно для ACL, например при маппинге помечая область памяти как read-only.
Итак, компиляция. Тут все очень просто. Бежим по списку инструкций, вызывая функцию writeCode с аргументами текущей инструкции. Та, в свою очередь вызывает writeByte(opcode) и соответствующие типам аргументов функции для них. Они же, как уже говорилось, находят нужный участок памяти (здесь это, очевидно, секция кода), пишут нужное количество байт в память (то есть дергая ее интерфейс, она сама разберется, вдруг это вообще даже и не память, а устройство) и сдвигают указатель на нужное количество байт вперед. Все, инструкция записана. Если воспринимать эти вектора как настоящую память (что где-то под слоями абстракций так и есть), то это действительно похоже на то, как хранится код настоящей программы. Далее я обрезаю память по краю записанных инструкций и увеличиваю на размер стэка. Возможно, его стоило бы тоже вынести в отдельную секцию, но у меня есть какое-то ностальгическое чувство к возможности перезаписать код переполнением стэка. В заключение, запишем в регистры актуальные значения EIP и ESP.
Уже почти все готово, примапим в конец еще 256 байт “видео-памяти”. Запоминаем ее смещение и заодно пишем его в EDI, который потом (не реализовано еще), будет итерироваться по сдвигам других примапленых контейнеров.
Текущее состояние
Пока мы не принялись ничего выполнять, надо показать текущее состояние. Начнем с кода, который мы компилировали. Я загружаю его в редактор. Это сторонняя разработка, спасибо автору, называется Zep. Он умеет во что-то вроде Vim-mode, чем, собственно, и пленил меня, так как я немного нервничаю , когда приходится редактировать код в обычном редакторе. Увы, он активно конфликтовал с SFML (отказывался нормально обрабатывать клавиши), пришлось переписать весь код на SDL2. Честно сказать, хотя он и работает (не без глюков), мне почему-то все равно удобнее править код в емаксе. Но зато видно исходник, можно мониторить, правильно ли скомпилировалось и как было задумано.
Далее, уже упомянутые редакторы памяти. Редактировать надо редко (хотя для теста может быть удобно), но в первую очередь смотреть текущее состояние памяти. Это значительно переписанный компонент автора библиотеки ImGui, которую я использую для интерфейса. Основное изменение — подсветка текущей инструкции и ее параметров. Дело не хитрое, так как у нас известен и указатель (положение в памяти) и размеры аргументов. Просто подсвечиваем разными цветами. Для редактирования пришлось немного пожертвовать инкапсуляцией, но в целом, особо не страшно, главное, удобно. Редактор работает отдельно с MemoryContainer и ничего не знает ни о ядре ни о том, что собственно такое показывает.
Про табличку со значениями регистров особо рассказать и нечего. Так что осталось две самых интересных вещи: отладчик и вывод картинки. Начнем со второй, так как рассказывать про нее особенно и нечего. У нас есть память, есть маппинг байтов 0x00–0x07 на типичные “терминальные цвета”. Единственная проблема в том, что память — это 256 байт, то есть картинка 16*16 пикселей. Разглядывать ее в оригинальном размере — такое себе удовольствие. Поэтому картинка уже 256*256, а это не только несколько больше пикселей, но так как расположены они линейно (сюрприз, это одномерный массив, там нет икса и игрека), пришлось немного помучиться, чтобы правильно рисовать квадратики, раскрашивая каждый пиксель индивидуально. Если кто-то думает, что просто отрисовать 16*16 и отобразить в 256*256 было бы достаточно, он ошибается. Думаю, можно было бы как-то прикрутить подходящий алгоритм ресайза, но это было бы гораздо сложнее, чем написать 10 строчек цикла рисования.
Отладчик, на самом деле даже еще более скучный. Имея список инструкций, остается только записать их в виде таблички. Текущий указатель у нас тоже лежит в регистре EIP. Сложность была в декомпозиции выполнения кода так, чтобы можно было исполнять по одной инструкции. Здесь же уже совсем не сложно сделать точки останова, когда руки дойдут.
Исполнение кода
А вот тут ну вообще нечего писать. Все уже очевидно. Читаем байт, ищем спеку. Вызываем соответствующую функцию. Она зная спеку читает аргументы. Функции, которые написаны для каждой инструкции делает какую-то магию с аргументами, меняет их, читает регистры, перещелкивает флаги и так далее. И все по кругу. Вся работа виртуалки сводится к все тем же readByte/writeByte. Все это дело крутится до тех пор, пока не выполнится последняя инструкция. Ею, если код скомпилирован из файла, будет “INT FF”, то есть инструкция, которая вызовет соответствующее прерывание и выставит стейт ядра в END. Здесь выполнение прерывается.
Интерфейс написан так, чтобы самостоятельно отображать текущее состояние ядра. Однако, в текущей версии ядро выполняется в общем потоке, а значит блокирует отрисовку интерфейса. Это значит что будет отрисованы только начальное и конечное состояния. А также то, что если ядро уйдет в бесконечный цикл, интерфейс повиснет. Вынести все это в отдельный поток должно быть не сложно, но как-то лениво пока. Как и множество других вещей. Вроде обработки ошибок, файловых операций, имплементации остальных инструкций (сейчас их всего десяток) и еще миллиона других важных задач. Хотя какие задачи могут быть важными для игрушечной виртуалки?
Как всегда, исходники доступны на GitHub. Увы, там сейчас нет релизных версий, но, под линукс оно точно должно компилироваться без проблем. Бинарные сборки я планирую подготовить к английской версии этой статьи, буде таковая случится.