Когда я ничего не понимаю

Или качаем низкоуровневые навыки

Alexey “Averrin” Nabrodov
6 min readDec 29, 2017

Некоторые вещи я упорно не понимаю.

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

Я тоже, хоть и в целом обычно работаю с высокоуровневыми языками и библиотеками, умею и с шестнадцатиричным редактором обращаться, и простенькие крякми ломал, и битовые операции понимаю, но… Но я не “вижу” ассемблер. Я вроде и понимаю отдельные инструкции, но не могу следить за потоком данных. Понимаю, что и как лежит в памяти, но стараюсь держаться как можно дальше от прямой работы с байтиками. Но при этом я буквально получаю удовольствие глядя на отладчик или хекс-редактор.

При очередном приступе любви к ассемблеру, после не слишком близкого знакомства с 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 все еще не равен нулю.

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

Если кому-то интересен код, то он как всегда на гитхабе. Продолжение туть.

--

--

No responses yet