Когда я ничего не понимаю
Или качаем низкоуровневые навыки
Некоторые вещи я упорно не понимаю.
Полагаю, каждый программист в той или иной мере играл в хакера. Ломал шароварки, правил сейвы в играх, а то и писал демки или ковырял крякми. В общем, каждый так или иначе задумывался, как на самом деле работают те функции, которые мы вызываем.
Я тоже, хоть и в целом обычно работаю с высокоуровневыми языками и библиотеками, умею и с шестнадцатиричным редактором обращаться, и простенькие крякми ломал, и битовые операции понимаю, но… Но я не “вижу” ассемблер. Я вроде и понимаю отдельные инструкции, но не могу следить за потоком данных. Понимаю, что и как лежит в памяти, но стараюсь держаться как можно дальше от прямой работы с байтиками. Но при этом я буквально получаю удовольствие глядя на отладчик или хекс-редактор.
При очередном приступе любви к ассемблеру, после не слишком близкого знакомства с Cheat Engine, очень удачно подвернулась скидка на игру TIS-100. Это чудесная головоломка, играющая на ограничениях и многопоточности. Рекомендую попробовать. Она сложная, но неплохо демонстрирует проблемы, с которыми сталкиваются программисты. Это игра из той редкой породы, когда мне больше нравится думать как написать аналог этой игры, чем играть в нее.
Так вот, в этом я увидел возможность немного прокачаться. Лучший способ в чем-то разобраться — это или объяснить кому-то или действительно применить знания на практике. Что ж, пойдем напишем свой ассемблер.
Очевидно, что можно пойти простым путем: использовать переменные для регистров, высокоуровневые очереди, типы и прочее. Но это не интересно. А едва начав реализовывать все с нуля, понимаешь, искусственные ограничения не скоро захочется придумывать, и так весело.
Начинаем снизу
Честно говоря, не много заслуги написать построчный интерпретатор недоассемблера. Поэтому как чемпион по бросанию проектов, я решил начинать не с написания парсера, а с самых основ. Более того, раз интерпретация особого интереса не представляет, я буду делать что-то вроде виртуальной машины.
Заранее отмечу, что мои теоретические познания в низкоуровневом коде и тонкостях работы компьютера не особенно глубоки. Ну по крайней мере, не достаточны для того, чтобы взять и написать подобную штуку. Какие-то вещи я могу уверенно утверждать, хотя они не имеют отношения к реальности, а какие-то наоборот, упускать, хотя они очевидны. Ну хоть чему-нить научимся.
Итак, берем кусок памяти. Я же сказал, что писать будем на плюсах? Почему-то мне захотелось, чтобы вся виртуалка существовала только в этой песочнице. Так можно будет скидывать ее в файл и загружать из нее. Более того, это создает целый набор интересных ограничений. Места очень мало (в первой версии я держусь в границах 128 байт), поэтому приходится думать про подходящие типы данных, самому писать функции чтения и записи, изобретать свою адресацию, а в будущем еще и реализовывать стек.
Так вот, кусок памяти. Создаем unsigтed char[128]. Для собственного удобства я обернул это дело в подобие “seekable”-интерфейса. Так больше кода, но зато гораздо прозрачнее навигация. А нам много придется скакать по буферу. Особенно когда придется работать с числами, а не байтами. Если вы поняли, что я написал, переходите к следующему абзацу. Остальным сейчас попробую объяснить. Все вышенаписанное означает, что у меня есть массив из 128и байт, к которым я могу обращаться по номеру байта. Но так как редко когда приходится работать с одним отдельным байтом, а например оперировать числами (4 байта) или читать-писать более длинные последовательности, хранить адрес куда только что писали или откуда читали в той же функции не удобно. Поэтому я буду хранить “указатель” внутри обертки над буфером. И у меня есть функция, которая может передвигать этот указатель в нужное мне место. Это удобно потому что функции чтения и записи будут сдвигать указатель на нужное количество байт, что позволит вызывать несколько команд чтения или записи подряд, не передавая внутрь адрес.
Раз уж мы храним данные в бинарном виде, начать надо с заголовка. Это категорически не обязательно, но зачем отказываться от удовольствия. Сейчас для работы данные из заголовка не нужны, но они пригодятся, когда мы будем создавать виртуалку из дампа.
Сначала “подпись”: символы “VVM” и версия. Затем 4 байта для метаданных, из которых я заполнил пока один — адрес байта, где начинается код. Очевидно, не стоит до кода делать слишком большой отсутуп, но у меня пока вся адресация влезает в байт. Но полагаться на это я буду только здесь.
Затем идут три “регистра”. На сколько я знаю, настоящие регистры не имеют адресации, но у нас же игрушечная виртуальная машинка, да? Так как переменных у нас нет, регистры выполняют их функцию. Я сделал три, просто на всякий случай. С точки зрения байт-кода это будут просто адреса в памяти, в которые можно писать и читать. Чтобы туда влезали адреса, они хранят по 4 байта, то есть unsigned int. Да, как-то так получилось, что у меня там все беззнаковое. Решу при случае.
Дальше идет байтик с флагами. Наверное восьми мне хватит, но если что, не сложно добавить еще. Пока я использую один, исключительно для сравнения. И пока не использую маски, но уже на втором флаге надо будет начинать.
Второй же флаг я планирую использовать для того, чтобы говорить внешнему коду, когда пора читать из вывода. Чтобы сделать банальный “hello world”, нужно добавить возможность вывода. Я планирую реализовать это с помощью следующих четырех байт. Я буду писать туда и поднимать соответствующий флаг. Для стороннего обработчика это будет означать, что надо прочитать эту область памяти и сохранить это как вывод. Правда для этого придется внедрить в код виртуальной машины функцию-обработчик, которая будет вызываться на каждый такт.
Дальше идет код, и это самое интересное из того, что уже есть. Реализовал я всего несколько инструкций, но как видно на картинке выше, есть уже даже примитивный цикл и условия. Сейчас это устроено следующим образом: читаем байт опкода, в зависимости от того, какая это команда, дергаем соответствующую функцию. Эта функция уже сама знает, сколько данных дальше читать, то есть сколько у функции аргументов, какого они размера и является он адресом или значением. Однако, одна и та же инструкция может принимать разные типы аргументов. В настоящем ассемблере для этого есть REG-M/R байт, который содержит указание на регистр, из которого берутся данные, или тип аргумента и тд. У меня эта часть команды пока не реализована, но без нее достаточно печально, так что скоро придется заняться.
Инструкций пока реализовано всего-ничего. Это, естественно, MOV, которая пишет значение в адрес. Надо бы защитить некоторые диапазоны адресов, но так как у меня пока нет никакой обработки ошибок, это может затянуться. Зато это даже забавно, когда из программы можно переписать не только данные, но и сам код. Это одна из тех инструкций, где очень бы неплохо иметь указание, значение в первом аргументе или адрес. Но пока я не напишу парсер для инструкций, их указание может оказаться очень многословно. С другой стороны, все равно делать…
А пока MOV пишет первый аргумент-int в адрес, который передан вторым аргументом. Получается, что одна из самых используемых инструкций занимает девять байт. Если я добавлю еще один для указания типов аргументов, это может сэкономить довольно много места, особенно с учетом того, что адреса у меня пока легко умещаются в байт.
ADD и SUB мало отличаются от MOV, но к перезаписи значения добавляется его вычитывание и модификация.
Оставшиеся две несколько интереснее. Вместе они реализуют условный переход. За “условный” отвечает инструкция CMP, которая сравнивает переданное значение и то, что лежит по указанному адресу. Если они равны, то устанавливается один из флагов, о которых говорилось ранее. Вслед за ним идет “переход”, который проверяет этот флаг и если он соответствует условию, меняет указатель, который читает код, на указанный адрес.
Если вернуться к предыдущей картинке, то легко понять, что там происходит. Первая инструкция никак не влияет на выполнение программы, просто перезаписывает регистр EAX. Вторая устанавливает в регистре EBX значение 4. Следующая вычитает из этого значения единицу. Именно сюда мы будем возвращаться по условию, чтобы скинуть значение в EBX до нуля. Затем делаем сравнение с нулем. Эта инструкция меняет положение флага (который, кстати, называется ZF). И условный переход на вычитание, если EBX все еще не равен нулю.
В общем-то это пока все. Тут еще огромный простор для доработок, которыми я, надеюсь, еще позанимаюсь в достаточной для еще одной статьи степени. Сейчас в этот код вложено ленивых полтора дня, но он даже в таком скромном объеме уже вызывает некоторое уважение к себе. Не думаю, что этого будет достаточно для исчезновения той проблемы, о которой я писал в начале, но по крайней мере, я смогу прочувствовать те знания, которые у меня уже есть.
Если кому-то интересен код, то он как всегда на гитхабе. Продолжение туть.