Хакер - Погружение в ассемблер. Сокращаем размер программы
hacker_frei
Антон Карев
Содержание статьи
- Резьба по ассемблерному коду
- Читаем данные из памяти по-новому
- Копируем данные, не используя циклы
- Сравниваем строки, не используя циклы
- Меняем местами значения двух регистров
- Выполняем восьмибитные операции экономно
- Знакомимся с двоично-десятичным кодом
- Умножаем и делим на 10 экономно
- Еще несколько полезных трюков
- Выводы
Из этой статьи ты узнаешь несколько трюков, которые помогут тебе сокращать размер ассемблерных программ. Попутно окунешься в настроение «Клуба моделирования железной дороги» Массачусетского технологического института, где такие трюки в свое время ценились особенно высоко.
РЕЗЬБА ПО АССЕМБЛЕРНОМУ КОДУ
Надеюсь, ты знаешь книгу Стивена Леви «Хакеры: герои компьютерной революции». Если нет, обязательно прочти! Сейчас мы с тобой поностальгируем по тем славным временам, которые описывает Леви. В частности, вспомним, чем пионеры хакерства занимались в «Клубе моделирования железной дороги» Массачусетского технологического института и как они кодили.
Хакеры тех времен, корпя над своими программами, пытались выжать из ассемблерных инструкций все, что только возможно, чтобы в итоге программа стала максимально компактной. Попытки отрезать часть инструкций от компьютерной программы без воздействия на конечный результат стали для хакеров навязчивой идеей.
Иногда такая резьба по ассемблерному коду принимала состязательный характер — своеобразное соревнование мачо, призванное доказать себе и другим, что совершенству нет предела. Ты отрезал две инструкции или даже одну? Получи бурные аплодисменты братьев по духу. Ты пересмотрел проблему с нуля, с неожиданного угла зрения и разработал новый алгоритм, который сократил программу на целый блок команд? Испытай катарсис и получи еще более бурные аплодисменты!

Особое рвение хакеры проявляли к оптимизации подпрограммы для печати десятичных чисел. За несколько месяцев они изготовили целую кучу вариаций. С чего вдруг такой интерес именно к этой задаче?
С того, что в MIT действовало негласное правило: «Если ты собственноручно написал подпрограмму печати десятичных чисел, значит, знаешь о компьютере достаточно, чтобы называть себя в некотором роде программистом. Причем, если у тебя ушло на эту подпрограмму около сотни ассемблерных инструкций, значит, ты беспроглядный глупец, хотя и программист. А если написал действительно хорошую и короткую процедуру меньшего размера, то можешь попробовать называть себя хакером».
В конечном счете, попеременно убирая инструкции то в одном, то в другом месте, хакеры допилили процедуру печати десятичных чисел до пятидесяти с небольшим инструкций.
Дальше дело приняло серьезный оборот. Поиск лучшего решения превратился в нечто большее, чем просто состязание, — в поиск святого Грааля. Однако, сколько бы сил ни было потрачено, никому не удавалось преодолеть барьер из пятидесяти команд. И когда практически все уже смирились с тем, что это невозможно, один из хакеров догадался посмотреть на решение задачи под другим углом. В итоге его версия подпрограммы уместилась в 46 ассемблерных инструкций.
До этого знаменательного часа все считали, что оптимальный алгоритм для подпрограммы печати десятичных чисел — это последовательное вычитание, при котором использовались таблицы степеней числа 10. Однако, как оказалось, задачу можно решить и без такой таблицы. Леви, к сожалению, не приводит ассемблерный код в своей книге, поэтому познакомиться с этим шедевром у нас не получится.
Но не спеши расстраиваться. Сейчас я покажу тебе свою версию такой подпрограммы. Она у меня уместилась в 12 инструкций (и 23 байта).

Кому‑то может показаться, что столько заморачиваться ради того, чтобы сократить размер программы, в наши дни уже не имеет смысла. Однако такой навык очень пригождается, когда пишешь какой‑нибудь шелл‑код или редактируешь скомпилированный бинарник. И в том и в другом случае нужно уметь обходиться как можно меньшим количеством ассемблерных инструкций. И сейчас я покажу несколько трюков, которые помогут тебе сокращать размер своих ассемблерных программ.
ЧИТАЕМ ДАННЫЕ ИЗ ПАМЯТИ ПО-НОВОМУ
Во всех предыдущих уроках мы читали память, ссылаясь на нужную нам ячейку через регистр BX. Примерно вот так.

Но то же самое можно сделать и вот так.

Инструкция lodsb говорит процессору 8088: «Загрузи байт из адреса, на который указывает DS:SI, и сохрани этот байт в регистр AL. И затем увеличь SI на единицу (если флаг направления сброшен в 0)».
Еще у 8088 есть инструкция lodsw, которая работает почти как lodsb, только загружает из DS:SI слово (сдвоенный байт), сохраняет результат в регистр AX и увеличивает SI на 2.
КОПИРУЕМ ДАННЫЕ, НЕ ИСПОЛЬЗУЯ ЦИКЛЫ
Зная о существовании инструкций lodsb/lodsw и их противоположностей stosb/stows, мы можем написать подпрограмму для копирования области памяти.

Этот внутренний цикл занимает всего четыре байта. Но у процессора 8088 есть инструкции movsb и movsw, которые делают ту же самую операцию, но при этом не используют регистр AL или AX.

Теперь внутренний цикл занимает три байта. Но и это не предел! Мы можем сделать все то же самое без инструкции loop.

Обрати внимание, что movsb — это две инструкции в одной: lodsb и stosb. И аналогично в movsw скомбинированы lodsw и stosw. При этом movsb/movsw не используют регистры AL/AX, что весьма приятно.
СРАВНИВАЕМ СТРОКИ, НЕ ИСПОЛЬЗУЯ ЦИКЛЫ
У 8088 есть инструкции для сравнения строк (cmps) и инструкция для сравнения регистра AX или AL с содержимым памяти (scas).
Эти инструкции выставляют флаги в регистре флагов, так что после их выполнения можно использовать условные переходы.
Инструкция cmpsb выполняет сравнение двух байт — того, на который указывает DS:SI, и того, на который указывает ES:DI, — и после этого увеличивает оба индексных регистра на единицу: SI, DI (или уменьшает на единицу, если флаг направления установлен в единицу).
Инструкция cmpsw делает то же самое, но только не с байтами, а со словами (сдвоенными байтами) и уменьшает или увеличивает индексные регистры не на 1, а на 2.
Обрати внимание, что и та и другая инструкция не пользуется регистрами AL и AX, то есть наш самый ходовой регистр остается нетронутым. Это очень хорошо.
Инструкция scasb сравнивает AL с байтом, на который указывает ES:DI, затем увеличивает DI на единицу (или уменьшает, если флаг направления установлен в единицу).
Инструкция scasw делает то же самое, но только с регистром AX и уменьшает или увеличивает индексные регистры не на 1, а на 2.
Перед этими четырьмя инструкциями можно ставить префикс repe/repne, что значит «продолжать выполнять данную инструкцию до тех пор, пока не будет выполнено условие завершения» (E значит equal, равно, NE — not equal, не равно).
МЕНЯЕМ МЕСТАМИ ЗНАЧЕНИЯ ДВУХ РЕГИСТРОВ
Допустим, в регистре AX записана четверка, а в DX семерка. Как поменять местами значения регистров?
Вот первое, что приходит на ум.

Такой код занимает четыре байта. Неплохо, но, может быть, есть вариант покороче? Еще на ум приходит что‑то вроде такого, со вспомогательным регистром.

Но такой код занимает даже еще больше памяти — шесть байт. Размышляя дальше, мы можем задействовать хитрый трюк с исключающим ИЛИ, без вспомогательного регистра.

Но только этот вариант кода занимает столько же байтов, как и предыдущий вариант. Так что выглядит он, конечно, изящно, но особых преимуществ не дает.
А теперь внимание! У процессора 8088 есть специальная инструкция, которая как раз и предназначена для обмена регистров. Обрати внимание, когда один из двух ее операндов — это регистр AX, она занимает один байт, в противном случае — два байта.

ВЫПОЛНЯЕМ ВОСЬМИБИТНЫЕ ОПЕРАЦИИ ЭКОНОМНО
Если выполняешь несколько восьмибитных операций с константами, лучше используй регистр AL. Большинство арифметических и логических инструкций (в том варианте, когда один операнд — это регистр, а другой — это константа) получаются короче, если ты используешь регистр AL. Например, add al, 0x10 занимает два байта, тогда как add bl, 0x10 занимает три байта. И само собой, чем больше инструкций в твоей цепочке преобразований, тем больше байтов ты сэкономишь.
С 16-битными регистрами такая же история: с регистром AX арифметические и логические инструкции получаются короче. Например: add ax, 0x1010 (три байта), add bx, 0x1010 (четыре байта).
Однако, когда в логической или арифметической инструкции один из операндов — это короткая константа в диапазоне –128..127, то инструкция оптимизируется до трех байт.

ЗНАКОМИМСЯ С ДВОИЧНО-ДЕСЯТИЧНЫМ КОДОМ
Когда тебе позарез надо работать именно с десятичными числами, а не с шестнадцатеричными, но при этом не хочется делать сложные преобразования между двумя системами счисления, используй двоично‑десятичный код. Что это за код? Как в нем записываются числа? Смотри. Допустим, у тебя есть десятичное число 2389. В двоично‑десятичном коде оно выглядит как 0x2389. Уловил смысл?
Для работы с двоично‑десятичным кодом в процессоре 8088 предусмотрены инструкции daa и das. Инструкция daa используется после add, а инструкция das — после sub.
Например, если в регистре AL записано 0x09 и ты добавишь 0x01 к этому значению, то там окажется 0x0a. Но когда ты выполнишь инструкцию daa, она скорректирует AL до значения 0x10.

УМНОЖАЕМ И ДЕЛИМ НА 10 ЭКОНОМНО
У процессора 8088 есть две любопытные инструкции: AAD/AAM. Изначально они задумывались для того, чтобы распаковывать двухциферные десятичные числа из AH (0–9) и AL (0–9). Обе инструкции занимают по два байта.
Инструкция AAD выполняет вот такую операцию:
AL = AH*10+AL
AH = 0
А вот что выполняет инструкция AAM:
AH = AL/10
AL = AL%10
Эти две инструкции позволяют сберечь драгоценные байты, когда тебе надо 8-битное число умножить или поделить на 10.
ЕЩЕ НЕСКОЛЬКО ПОЛЕЗНЫХ ТРЮКОВ
Инициализируй числа при помощи XOR. Если тебе надо сбросить в 0 какой‑то 16-битный регистр, то короче всего это сделать так (на примере регистра DX).

Инкрементируй AL, а не AX. Везде, где это возможно, пиши inc al вместо inc ax. А где это возможно? Там, где ты уверен, что AL не выйдет за пределы 255. То же самое с декрементом. Если ты уверен, что AL никогда не будет меньше нуля, лучше пиши dec al, а не dec ax. Так ты сэкономишь один байт.

Перемещай AX через XCHG. Если тебе надо скопировать AX в какой‑то другой регистр, то пиши вот так: xchg ax, reg. Инструкция xchg занимает всего один байт, тогда как mov reg, ax — два.
Вместо cmp ax, 0 используй test ax, ax. Так ты сэкономишь один байт.

Возвращай результат через регистр флагов. Когда пишешь подпрограмму, которая должна возвращать только значения True и False, пользуйся регистром флагов. Для этого внутри подпрограммы применяй инструкции clc и sec, чтобы сбрасывать и устанавливать флаг переноса. И потом после выполнения подпрограммы используй jc и jnc — для обработки результата функции. Иначе придется потратить кучу байтов на инструкции присваивания вроде mov al, 0 и mov al, 1 и на инструкции сравнения вроде test al, al, and al, al, or al, al или cmp al, al.
ВЫВОДЫ
Ну вот, теперь ты знаешь несколько трюков, которые помогут тебе делать свои ассемблерные программы более компактными. Этот урок был последним из нашего вводного курса «Погружение в ассемблер». Если ты прилежно вчитывался во все предыдущие статьи и собственноручно щупал ассемблерные программы из них, можешь считать, что твое знакомство с ассемблером прошло успешно. Но само собой, чтобы освоить ассемблер, недостаточно просто прочитать несколько статей и перепечатать из них с десяток чужих программ. Здесь, как и в любом другом деле, нужна постоянная практика и общение с единомышленниками.
Если же ты хочешь еще немного материала, чтобы углубить и закрепить знания, можешь вернуться к моим более ранним статьям — «Floppy Bird», где мы пишем миниатюрную игру, и «МикроБ», где я показываю, как создать свой интерпретатор бейсика на ассемблере. Поэкспериментируй с этими программами, и ты сделаешь еще один большой шаг в сторону освоения ассемблера.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei