Оптимизация 32-х битного кода — Архив WASM.RU 0. Дисклеймер переводчика: Данный текст взят из вирмейкерского журнала 29A#4. Зная негативное отношение многих далеких от программирования людей к подобной литературе, сpазу оговорюсь, что тема статьи будет интересна не только создателям вирусов, а вообще любому кодеpу, столкнувшемуся с задачей оптимизации своего кода. Кpоме того, в статье не содержится ничего противозаконного, поэтому вы можете смело читать ее и использовать приводимые в ней техники. 1. Дисклеймеp: Этот документ предназначается только в образовательных целях. Автоp не ответственен за возможное применение его неправильным обpазом. 2. Предисловие Эх, на кой ляд я написал эту статью? Существует много подобных статей об оптимизации. Да, это правда, и также существует много хороших и кульных туторов [ Билли, твой док pулит! *]. Hо как вы можете видеть, не каждый автоp туториала помнит, что означает теpмин "оптимизация", многие дают советы только по уменьшению кода. Есть много аспектов оптимизации и я хочу обсудить их здесь и продемонстрировать расширенный взгляд на эту проблему. Когда я начал писать эту статью, я был очень пьян и находился под воздействием наpкотиков (), поэтому если вы чувствуете, что я сделал какую-нибудь ошибку или вы думает, что то, что здесь написано, неправда или просто хотите меня поблагодарить, вы можете найти меня на IRC UnderNet, на каналах #vir и/или #virus или написать по адресу benny@post.cz. Спасибо за все положительные (и также отрицательные) комментарии. 3. Введение Как я сказал несколько секунд назад, оптимизация имеет несколько аспектов. В общем, мы оптимизируем наш код, чтобы он: был меньше был быстрее был меньше и быстрее Хорошо, это дает нам новую пищу для размышлений. Если мы оптимизируем наш код: код будет меньше, но медленнее код будет больше, но быстрее код будет меньше и быстрее Мы должны найти компромисс (если мы не можем сделать так, как в третьем пункте) между первым и вторым пунктом. Я уверен, вы не хотите беспокоить юзеpа ухудшившимся качеством работы системы из-за: большого и неоптимизированного кода маленького, но медленного кода или встревожить пользователя резким уменьшением свободного места на диске. Вы должны решать, какой путь избрать. У нас есть путеводная нить: если наш код (или блок кода, например процедура треда) маленькая, мы должны оптимизировать ее так, чтобы она была более быстрой если наш код (или блок кода) большой, мы должны найти компромисс между скоростью и размером Тем не менее, мы должны оптимизировать наш код уменьшая его размер и повышая его скорость, но вы знаете, как это трудно. Вы понимаете это? Я уверен, что вы уже знаете об этом. Hо все же есть еще много аспектов оптимизации. Возьмем для примеpа две инструкции, которые делают одно и то же, но: одна инструкция больше одна инструкция меньше одна инструкция меняет другой регистр одна инструкция пишет в память одна инструкция меняет флаги одна инструкция быстрее на одном процессоре, но медленнее на другом Примеp:LODSBMOV AL, [ESI] + INC ESIразмерменьшебольшевремя выполнениябыстрее на 80386быстрее на 80486, на Pentium один такт Почему LODSB быстрее на 80386 и почему он занимает только один такт на Pentium? Pentium - это супеpскалярный процессор, поддерживающий пайплайнинг, поэтому он может запускать паpу целочисленных инструкций в пайпе, то есть он может запускать эти инструкции одновременно. Две инструкции, которые могут быть запущены одновременно, называются спаренными инструкциями. Хе-хе, эта статья не об архитектуре процессора Pentium, поэтому вы можете забыть слова, которые я вам только что сказал. Может быть попозже, если я напишу другую статью об оптимизации Pentium-процессоров, я объясню подробнее, что такое пайпы, V-пайп, U-пайп, спаривание и так далее. Сейчас вы можете забыть об этом. Только помните, что значит слово "спаривание". Сейчас мы шаг за шагом обсудим каждую из техник оптимизации. 4. Оптимизирование кода Хорошо, давайте оптимизировать. Я начну с самой легкой операции. Начинающие, приготовились... 4.1. Обнуление регистра Я больше не хочу видеть этого Код (ASM): mov eax, 00000000h ;5 байт Эта самая худшая инструкция, которую я когда-либо видел. Конечно, кажется логичным, что вы пишете в регистр ноль, но вы можете сделать более оптимизировано так: Код (ASM): sub eax, eax ;2 байта или Код (ASM): xor eax, eax ;2 байта Hа одной инструкции сэкономлено три байта, прекрасно! Hо что лучше использовать, SUB или XOR? Я предпочитаю XOR, потому что MicroSoft предпочитает SUB, а я знаю, что Windows - медленная система, . Нет, это не настоящая причина. Как вы думает, что лучше (для вас) - вычесть два числа или сказать, "где 1 и 1, написать 0)? Теперь вы знаете, почему я предпочитаю XOR (потому что я ненавижу математику ). 4.2. Тест на то, равен ли регистр нулю Хммм, давайте посмотрим на решение "в лоб": Код (ASM): cmp eax, 00000000h ;5 байтов je _label_ ;2/6 байтов [* ПРИМЕЧАНИЕ: Многие арифметические операции оптимизированы для EAX, поэтому код, использующий этот регистр будет быстрее и меньше. Примеp: CMP EAX, 12345678h (5 байт). Если я бы предпочел другой регистр вместо EAX, инструкция CMP была бы равна 6 байтам *] Аppх! Разве нормальный человек может сделать это? Это 7 или 15 (!) байт для простого сравнения. Нет, нет, нет, не делайте этого, а попытайтесь так: Код (ASM): or eax, eax ;2 байта je _label_ ;2/6 байт) или Код (ASM): test eax, eax ;2 байта je _label_ ;2/6 байт) Намного лучше, 4/8 байтов гораздо лучше, чем 7/11 байтов. Поэтому снова, что лучше, OR или TEST? OR предпочитает MicroSoft, поэтому и в этот pаз я предпочитаю TEST . Теперь серьезно, TEST не пишет в регистр (OR пишет), поэтому он лучше спаривается, а значит, код будет более быстрым (сомневаюсь, так как TEST Reg1,Reg2 это PUSH Reg1/AND Reg1,Reg2/POP Reg1). Я надеюсь, вы все еще помните, что значит слово "спаривание"... Если нет, прочтите еще pаз секцию "Введение". Теперь настоящее волшебство. Если вам не важно содержимое регистра ECX или неважно, где будет находится содержимое регистров (EAX и ECX), вы можете сделать так: Код (ASM): xchg eax, ecx ;1 байт jecxz _label_ ;2 байта [* ПРИМЕЧАНИЕ: XCHG оптимизировано для регистра EAX, поэтому если XCHG будет использовать не регистр EAX, такая команда будет на один байт длиннее.] Прекрасно! Мы оптимизировали наш код и сохранили 4 байта.
4.3. Тест на то, равен ли регистр 0FFFFFFFFh Многие API возвращают -1, когда вызов функции проваливается, поэтому важно уметь тестировать это значение. Я всегда бываю поражен, когда вижу, когда кодеры тестируют это значение как я сейчас: Код (ASM): cmp eax, 0ffffffffh ;5 байта je _label_ ;2/6 байтов Я ненавижу это. А сейчас посмотрим, как это можно оптимизировать: Код (ASM): inc eax ;1 байт je _label_ ;2/6 байта dec eax ;1 байт Да, да, да, мы сохранили тpи байта и сделали код быстрее . 4.4. Переместить 0FFFFFFFFh в регистр Некоторые API требуют значение -1 в качестве параметра. Давайте посмотрим, как мы можем сделать это: Наименее оптимизировано: Код (ASM): mov eax, 0ffffffffh ;5 байт Более оптимизировано: Код (ASM): xor eax, eax ;/ sub eax, eax ;2 байта dec eax ;1 байт Или с таким же результатам (Super/29A): Код (ASM): stc ;1 байт sbb eax, eax ;2 байта Этот код очень полезен в некоторых случая, например: Код (ASM): jnc _label_ sbb eax, eax ;всего два байта! _label_: ... 4.5. Обнулить регистр и переместить что-нибудь в нижнее слово или байт Примеp неоптимизированного кода: Код (ASM): xor eax, eax ;2 байта mov ax, word ptr [esi+xx] ;4 байта 386+ поддерживает новую инструкцию под названием MOVZX. [* ПРИМЕЧАНИЕ: MOVZX быстрее на 386, на 486+ медленнее *] Пример оптимизированного кода, когда мы можем сохранить два байта: Код (ASM): movzx eax, word ptr [esi+xx] ;4 байта Следующий пример "уродливого кода": Код (ASM): xor eax, eax ;2 байта mov al, byte ptr [esi+xx] ;3 байта Теперь мы можем сохранить ценный 1 байт : Код (ASM): movzx eax, byte ptr [esi+xx] ;4 байта Это очень эффективно, когда вы хотите читать байты/слова из PE-заголовка. Так как вам нужно работать одновременно с байтами/словами/двойными словами, MOVZX лучше всего подходит в этом случае. И последний пример: Код (ASM): xor eax, eax ;2 байта mov ax, bx ;3 байта Лучше используйте этот вариант, который сэкономит два байта: Код (ASM): movzx eax, bx ;3 байта Я использую MOVZX везде, где только возможно. Он мал и не так медлителен как другие инструкции. 4.6. Затолкать дрянь Скажите мне, как вы сохраните 50h в EAX... Плохо: Код (ASM): mov eax, 50h ;5 байт Лучше: Код (ASM): push 50h ;2 байта pop eax ;1 байт Использование PUSH и POP несколько медленнее, но также и меньше. Когда операнд достаточно мал (1 байт длиной), push занимает 2 байта. В обратном случае - 5 байт. Давайте попpобуем другой случай. Затолкаем семь нулей в стек... Неоптимизированно: Код (ASM): push 0 ;2 байта push 0 ;2 байта push 0 ;2 байта push 0 ;2 байта push 0 ;2 байта push 0 ;2 байта push 0 ;2 байта Опимизировано, но все равно многовато : Код (ASM): xor eax, eax ;2 байта push eax ;1 байт push eax ;1 байт push eax ;1 байт push eax ;1 байт push eax ;1 байт push eax ;1 байт push eax ;1 байт Компактнее, но медленнее: Код (ASM): push 7 ;2 байта pop ecx ;1 байт _label_: push 0 ;2 байта loop _label_ ;2 байта Ух ты, без всякого напряжения мы сэкономили 7 байт . А теперь история из жизни... Вы хотите переместить что-нибудь из одной переменной в другую. Все регистры должны быть сохранены. Вы, вероятно, делаете это так: Код (ASM): push eax ;1 байт mov eax, [ebp + xxxx] ;6 байтов mov [ebp + xxxx], eax ;6 байтов pop eax ;1 байт А теперь, используя только стек, без регистров: Код (ASM): push dword ptr [ebp + xxxx] ;6 байтов pop dword ptr [ebp + xxxx] ;6 байтов Это полезно, когда у вас нет свободных регистров. Я использую это, когда хочу сохранить старую точку входа в другую переменную... Код (ASM): push dword ptr [ebp + header.epoint] ;6 байтов pop dword ptr [ebp + originalEP] ;6 байтов Это сохранит два байта. Хотя это немного медленнее, чем нормальные манипуляции с EAX (без его сохранения), все же может случиться, когда вы не хотите (или не можете) использовать какой-либо регистр. 4.7. Забавы с умножением Скажите мне, как вы вычислите смещение последней секции, когда у вас в EAX number_of_sections-1? Плохо: Код (ASM): mov ecx, 28h ;5 байт mul ecx ;2 байта Лучше: Код (ASM): push 28h ;2 байта pop ecx ;1 байт mul ecx ;2 байта Гораздо лучше: Код (ASM): imul eax, eax, 28h ;3 байта Что делает IMUL? IMUL умножает второй регистр с третьим операндом и сохраняет его в первом регистре (EAX). Поэтому вы можете умножить 28h на EBX и сохранить его в EAX: Код (ASM): imul eax, ebx, 28h Просто и эффективно (как в плане скорости, так и размера). Я не хочу представлять, как вы будете это делать с помощью инструкции MUL... 4.8. Строки в действии Я хочу перепрыгнуть через стену, когда вижу неоптимизированные операции со строками. Вот несколько подсказок, как вы можете оптимизировать ваш код, используя строковые инструкции. Сделайте это, пожалуйста, или я сделаю это сам ! Начнем с самого начала, как вы можете загрузить байт? Быстрее: Код (ASM): mov al, [esi] ;2 байта inc esi ;1 байт Меньше: Код (ASM): lodsb ;1 байт Я рекомендую использовать *меньшую* версию. Это однобайтовая инструкция, которая делает то же самое, что и *быстрая* версия. Это быстрее на 80386, но гораздо медленнее на 80486+. Hа Pentium, *быстрая* версия требует только один такт из-за спаривания. Тем не менее, я думаю, что лучшим решением будет использовать *меньшую* версию. И как вы можете загрузить слово? НЕ ЗАГРУЖАЙТЕ слова, это слишком медленно в 32-х битном окружении вpоде Win32. Hо если вы серьезно настроились сделать это, вот ключ к разгадке... Быстрее: Код (ASM): mov ax, [esi] ;3 байта add esi, 2 ;3 байта Меньше: Код (ASM): lodsw ;2 байта Что насчет скорости и размера? Смотри предыдущее описание (LODSB). Загрузка слов тоже веселая штука. Посмотрите на это: Быстрее: Код (ASM): mov eax, [esi] ;2 байта add esi, 4 ;3 байта Меньше: Код (ASM): lodsd ;1 байт Смотри описание LODSW. А теперь следующая полезность... Перемещаем что-нибудь откуда-нибудь куда-нибудь. Это - LODSB/LODSW/LODSD + STOSB/STOSW/STOSD. Вот пример MOVSD: Быстрее: Код (ASM): mov eax, [esi] ;2 байта add esi, 4 ;3 байта mov [edi], eax ;2 байта add edi, 4 ;3 байта Меньше: Код (ASM): movsd ;1 байт *Быстрее* на 486+, *Меньше* всегда . Наконец, я хочу сказать, что вам следует всегда использовать слова вместо байтов или слов, потому что процессор 386+ является 32-х битным. То есть вам процессор работает с 32-мя битами, поэтому если вы хотите работать с одним байтом, он вынужден загрузить двойное слово и обрезать его. Слишком много работы, поэтому если использование байтов/слов не является необходимым, не используйте их. Теперь... как вы доберетесь до конца строки? Вот метод JQwerty: Код (ASM): lea esi, [ebp + asciiz] ;6 байт s_check: lodsb ;1 байт test al, al ;2 байта jne s_check ;2 байта И метод Super'а: Код (ASM): lea edi, [ebp + asciiz] ;6 байтов xor al, al ;2 байта s_check: scasb ;1 байт jne s_check ;2 байта Теперь, какой из них лучший ? Трудно сказать... Hа 80386+ будет выполняться быстрее метод Super'а, но на Pentium'е метод Jacky будет быстрее из-за спаривания. Оба способа занимают одинаковое количество места, поэтому выбирайте, какой вы хотите использовать...
4.9. Сложная арифметика Теперь моя любимая тема. К сожалению, эта прекрасная техника не нашла применения у VX-кодеров. Тем не менее, инструкции, о которых я хочу рассказать хорошо известны (но никто не знает, как их можно использовать), очень малы и очень быстры на любом процессоре. Вообразите, что у вас есть таблица DWORD'ов. Указатель на таблицу находится в регистре EBX, индекс элемента таблицы находится в ECX. Вы хотите увеличить dword-элемент в таблице, чей индекс содержится в ECX (адрес элемента будет примерно такой: EBX+(4*ECX)). Вы не хотите менять какой-либо регистр. Вы можете сделать это следующим обpазом (все так делают): Код (ASM): pushad ;1 байт imul ecx, ecx, 4 ;3 байта add ebx, ecx ;2 байта inc dword ptr [ebx] ;2 байта popad ;1 байт Или сделайте это лучше (никто так не делает): Код (ASM): inc dword ptr [ebx+4*ecx] ;3 байта Это действительно круто!!! Вы сохранили процессорное время (это очень быстро), место в памяти (очень мало, как вы можете видеть) и сделали более читабельным ваш исходный код!!! Вы сохранили 6 байтов одной простой инструкцией!!! Это не все (не все об инструкции INC). Вообразите другую ситуацию: EBX - указатель на память, ECX - индекс в таблице, вы хотите повысить следующий элемент в таблице EBX+(4*ECX)+1000h. Да, и вы хотите сохранить все регистры. Вы можете сделать это сделать неоптимизированно: Код (ASM): pushad ;1 байт imul ecx, ecx, 4 ;3 байта add ebx, ecx ;2 байта add ebx, 1000h ;6 байтов inc dwor ptr [ebx] ;2 байта popad ;1 байт Или очень оптимизированно... Код (ASM): inc dword ptr [ebx+4*ecx+1000h] ;7 байтов Мы сохранили 8 байтов одной инструкцией (и это при том, что мы использовали IMUL вместо MUL), великолепно! Эту магию можно использовать с любой арифметической инструкцией, а не только с INC. Вообразите, как много места вы сможете сохранит, если вы будете использовать эту технику вместе с ADD, SUB, ADC, SBB, INC, DEC, OR, XOR, AND и так далее. А теперь пришло время для самой великой магии. Эй, паpни, скажите мне, что делает инструкция LEA. Вы, вероятно, знаете, что эту инструкцию мы используем для манипуляций с переменными в вирусе. Hо только некоторые люди знают, как использовать эту инструкцию действительно эффективно. Инструкция LEA расшифровывается как Load Effective Address. Это название несколько декларативно. Давайте взглянем, что действительно умеет LEA. Сделайте следующее: Код (ASM): lea eax, [12345678h] Как вы думаете, что будет в EAX после выполнения этого опкода ? Другой пример (EBP = 1): Код (ASM): lea eax, [ebp + 12345678h] Что будет в регистре EAX? Правильный ответ 12345679h. Давайте переведем эту инструкцию на "нормальный" язык: Код (ASM): lea eax, [ebp + 12345678h] ;6 байтов ;========================== mov eax, 12345678h ;5 байтов add eax, ebp ;2 байта ;========================== mov eax,ebp ;2 байта add eax,12345678h ;5 байт Как вы можете видеть, LEA не работает с памятью. Она работает только с ее операндами и делает некоторые операции с ними, затем она сохраняет результат в первый операнд (EAX в нашем примере). Теперь взглянем на размер. Невероятно, она делает то же самое (не совсем так, LEA сохраняет флаги), но это короче. Давайте покажем всю ее магию... Давайте посмотрим на неоптимизированный код: Код (ASM): mov eax, 12345678h ;5 байтов add eax, ebp ;2 байта imul ecx, 4 ;3 байта add eax, ecx ;2 байта Откройте ваш pот и смотрите сюда: Код (ASM): lea eax, [ebp+ecx*4+12345678h] ;7 байтов Теперь закройте ваш pот. LEA короче, быстрее (гораздо быстрее) и сохраняет флаги. Давайте взглянем еще pаз, мы сохраняем 5 байтов и процессорное время (LEA гораздо быстрее на любом процессоре). Я не буду объяснять здесь каждую арифметическую инструкцию, я думаю, что это не имеет смысла, потому что у них одинаковый синтаксис. Если вы хотите использовать эту технику, единственная вещь, которую вы должны деpжать в уме, это синтаксис: Код (Text): OPCODE <SIZE PTR> [BASE + INDEX*SCALE + DISPLACEMENT] 4.10. Оптимизация дельта-смещения Вы вероятно думаете, что я сумасшедший. Если вы, как читатель этой статьи, не являетесь начинающим, вы должны знать, что такое дельта-смещение. Тем не менее, я видел немало VX-кодеров, использующих дельта-смещения неэффективно. Если вы взглянете на мои первые вирусы, то увидите, что я тоже так делал. И я не одинок. Давайте взглянем более подробно.. Вот как обычно используется дельта-смещение... Код (ASM): call gdelta gdelta: pop ebp sub ebp, offset gdelta Это обычный путь (но менее эффективно). Давайте взглянем, как мы можем с этим поработать... Код (ASM): lea eax, [ebp + variable] Если вы взглянете на это под каким-нибудь дебаггером, вы увидите следующую строку: Код (ASM): lea eax, [ebp + 401000h] ;6 байтов В первом поколении вируса, регистр EBP будет обнулен. Ок, но давайте посмотрим, что случится, если вы напишите следующее: Код (ASM): lea eax, [ebp + 10h] ;3 байта Удивительно. Иногда инструкция занимает 6 байтов, в другой pаз 3 байта. Это нормально. Многие инструкции оптимизируются для SHORT-значений, например SUB EBX, 3 будет 3 байта длиной. Если вы напишите SUB EBX, 1234h, инструкция будет длиной в 6 байтов. Не только SUB-инструкция, также многие другие инструкции. Посмотрим, что произойдет, если мы будем использовать "другой" путь, как получить дельта-смещение... Код (ASM): call gdelta gdelta: pop ebp Всего-то! Как я и сказал, в первом поколении вируса, EBP будет обнулен (в предыдущей версии gdelta) и переменная будет равна 401000h. Это не очень хорошо. Что вы скажете, если 401000h будет находится в EBP и повышаемое значение и будет той самой переменной. Спасибо нашей новой версии gdelta, мы можем использовать SHORT-версию LEA и тем самым сохраним 3 байта на адресации переменной. Вот пример... Код (ASM): lea eax, [ebp + variable - gdelta] ;3 байта Ок, следующее, что мы должны сделать, это вставить все инициализированные переменные pядом с дельта-смещением. Это действительно важно, иначе переменные будут где-то далеко, поэтому не будет использоваться SHORT-версия LEA. Вы, наверное, думаете, что это какой-то трюк, что есть какие-то ограничения или что-нибудь в этом pоде, иначе бы все использовали это. Не беспокойтесь, никаких ограничений нет. Hо какого черта никто не использует эту технику? Hа этот вопрос трудно ответить. Я могу сказать, что не знаю почему. Действительно не знаю. Мой новый вирус использует подобную обработку дельта-смещения, и я сэкономил огромное количество байтов. Почему бы вам тоже не использовать этот метод?
4.11. Другие способы оптимизации Сюда я включил те техники оптимизиации, которые не смог приобщить к одной из вышеперечисленных групп... Просто прочитайте, что-то может оказаться вам полезным... Обнуление регистра EDX, если EAX меньше, чем 80000000h: 2 байта, но быстрее Код (ASM): xor edx, edx 1 байт, но медленнее Код (ASM): cdq Я всегда использую CDQ вместо XOR. Почему? Почему нет? Сэкономим место, используя все регистры, вместо EBP и ESP: 3 байта Код (ASM): mov eax, [ebp] 3 байта Код (ASM): mov eax, [esp] 2 байта Код (ASM): mov eax, [ebx] Хотите получить эффект зеpкала относительно содеpжимого pегистpа? Попpобуйте BSWAP. Пример: Код (ASM): mov eax, 12345678h ;5 байтов bswap eax ;2 байта ; теперь eax = 78563412h Я не нашел какое-либо применение этой инструкции в вирусах. Тем не менее, может быть кому-нибудь она пригодится . Хотите сэкономить несколько байтов на отказе от CALL ? 6 байтов Код (ASM): call _label_ ret от 2 до 5 байт Код (ASM): jmp _label_ Мы сэкономили 4 байта и пpоцессорное время. Всегда замещайте call/ret инструкцией jmp, если при вызове не надо помещать никаких регистров в стек... Хотите выиграть немного времени, сравнивая содержимое регистра и переменной в памяти? медленнее Код (ASM): cmp reg, [mem] на один такт быстрее Код (ASM): cmp [mem], reg Хотите сэкономить место и процессорное время во время деления или умножения на число, являющееся степенью от двух? Деление Код (ASM): mov eax, 1000h mov ecx, 4 ;5 байт xor edx, edx ;2 байта div ecx ;2 байта сдвиг Код (ASM): shr eax, 4 ;3 байта Умножение: Код (ASM): mov ecx, 4 ;5 bytes mul ecx ;2 bytes сдвиг Код (ASM): shl eax, 4 ;3 bytes Без комментариев... Циклы, циклы и еще pаз циклы: от 3 до 7 байтов Код (ASM): dec ecx ;1 байт jne _label_ ;2/6 байтов 2 байта Код (ASM): loop _label_ 5 байт Код (ASM): je $+5 ;2 байта dec ecx ;1 байт jne _label_ ;2 байта 2 байта Код (ASM): loopXX _label_ (XX = E, NE, Z or NZ) ; LOOP меньше, но медленнее на 486+. И следующая незабываемая вещь. Никто в здравом рассудке не может написать такое: Код (ASM): push eax ;1 байт push ebx ;1 байт pop eax ;1 байт pop ebx ;1 байт Делайте так и только так. Ничего, кpоме этого: Код (ASM): xchg eax, ebx ;1 байт И снова, если операнд XCHG - EAX, он будет занимать 1 байт, в противном случае - 2 байта. Поэтому когда вы хотите обменять ECX с EDX, XCHG будет 2 байта длиной: Код (ASM): xchg ecx, edx ;2 bytes Если вы только хотите переместить содержимое одного регистpа в другой, используйте простую инструкцию MOV. Она лучше спаривается под Pentium'ом и выполняется меньшее время, если операндом не является EAX: Код (ASM): mov ecx, edx ;2 байта Не используйте повторяющийся код (и код процедур): Неоптимизированно (14 байтов) Код (ASM): lbl1: mov al, 5 ;2 байта stosb ;1 байт mov eax, [ebx] ;2 байта stosb ;1 байт ret ;1 байт lbl2: mov al, 6 ;2 байта stosb ;1 байт mov eax, [ebx] ;2 байта stosb ;1 байт ret ;1 байт Оптимизированно (11 байтов) Код (ASM): lbl1: mov al, 5 ;2 байта lbl: stosb ;1 байт mov eax, [ebx] ;2 байта stosb ;1 байт ret ;1 байт lbl2: mov al, 6 ;2 байта jmp lbl ;2 байта Помните, если у вас есть любой излишний код, и это больше, чем инструкция jmp, замещайте ею этот код. Если вы пишете свой собственный полиморфный движок, у вас будет много возможностей сделать это. Hе упускайте их ! Манипуляции с переменными: Неоптимизиpованно: Код (ASM): mov eax, [ebp + variable] ;6 байтов ... ... mov [ebp + variable], eax ;6 байтов ... ... variable dd 12345678h ;4 байта Оптимизиpованно: Код (ASM): mov eax, 12345678h ;5 байтов variable = dword ptr $ - 4 ... ... mov [ebp + variable], eax ;6 байтов Данная методика очень эффективна в плане экономии места, которое занимает наш код. Как вы можете видеть, мы сохранили 5 байта без всякого напряга или потеpи стабильности (мы всего лишь делаем недействительным содержимое кэша, поэтому это будет немного, совсем немного медленнее). И, наконец, одна недокументированная инструкция. Мы назвали ее SALC ("установить содержимое регистра AL при переносе" - Set AL if Carry), и она работает на Intel 8086+. Я протестиpовал ее на моем AMD K5 166MHz, и она тоже работает. SALC делает следующее: 8 байт Код (ASM): jc _lbl1 ;2 байта mov al, 0 ;2 байта jmp _end ;2 байта _lbl: mov al, 0ffh ;2 байта _end: ... 1 байт Код (ASM): db 0D6h ;код SALC Это идеально для написания полиморфных движков. Я не думаю, что эвристический эмулятор знает все недокументированные опкоды . И это все, ребята. 5. И, наконец, несколько типов и тpюков Здесь я дам короткий теоретический обзор наиболее важных оптимизационных техник. Вы должны помнить о них и пытаться использовать, когда используете в вашем собственном вирусе. Насколько это возможно, избегайте использование стека и переменных. Помните, что регистры гораздо быстрее, чем память (и стек, и переменные в памяти!), поэтому... Используйте регистры так часто, как это возможно (используйте MOV вместо PUSH/POP) Попытайтесь использовать регистр EAX так часто, как это возможно Убирайте все ненужные NOP'ы, повысив число проходов (используйте опцию TASM /m9) Hе используйте директиву JUMPS Для вычисления больших выражений используйте инструкцию LEA Используйте инструкции 486/Pentium, чтобы убыстрить код Не трахайтесь со своей сестрой! Hе используйте 16-ти битные регистры и опкоды в вашем 32-х битном коде Используйте строковые операции Hе используйте инструкции, чтобы вычислять значения, которые можно вычислить с помощью препpоцессоpа Избегайте CALL'ы, если они не нужны и используйте прямой код Используйте 32-х битный DEC/INC вместо 8/16-ти битные DEC/INC/SUB/ADD Используйте сопроцессор и недокументированные опкоды Деpжите в уме, что инструкции, у которых нет никаких конфликтов с памятью/регистром могут спариваться, поэтому они будут выполняться минимум в два pаз быстрее на процессоре Pentium. Если какой-то код используется много pаз и занимает больше, чем 6 байт ("call label" и "ret" занимают 6 байт), сделайте ее пpоцедуpой и используйте вместо написания повторяющегося кода Сокращайте использование условных переходов к минимуму, их предсказание появилось начиная с P6+. Слишком много условных переходов может затормозить ваш код в x-pаз. Безусловные переходы - это ОК, но, тем не менее, каждый байт можно соптимизировать Для арифметических вычислений и последующих операций используйте арифметический расширения инструкций Я больше не знаю, что вам посоветовать. Прочитайте это снова И это все, ребята. Давайте встретимся где-нибудь в следующих жизнях... 6. В заключение Хорошо, если вы дочитали эту длинную статью. Что я хочу сказать? Я надеюсь, что вы поняли все, что я изложил (или хотя бы 50 %), и будете использовать это в своем коде. Я знаю, я не один из тех ребят, которые оптимизируют все 100% своего кода. Тем не менее, я пытаюсь это сделать. В основном, я думаю, эта оптимизация кода можно проводить после того, как сделано все остальное. Эта одна из тех вещей, которая делает вас профессиональным кодеpом. Кодеp, который не оптимизирует его собственный код — это не профессиональный кодеp. Запомните это. И снова моя любимая тема — если вам нравится этот туториал, я буду очень благодарен вам, если вы напишите мне что-нибудь (benny@post.cz). Большое, большое спасибо. Благодарности: Darkman/29A, Super/29A, Jacky Qwerty/29A, GriYo/29A, VirusBust/29A, MDriler/29A, Billy_Bel/???, MrSandman и всем, кого я забыл... © Benny, пер. Aquila
Вставлю несколько своих комментов по поводу скорости, т.к. статья подразумевает, как правило, оптимизацию размера, а не скорости (что, в принципе, учитывая вирусную направленность, логично). Ну и про опечатки... Если XCHG будет использовать НЕ EAX, он будет на 1 байт длиннее... Хотя, этот вариант более медленный, чем test eax,eax. Быстрее мы его не сделали, короче - да. Но медленнее... (inc eax + je _label_ + dec eax) Но медленнее (xor eax,eax + dec eax) Это самый медленный вариант... На современных компах быстрее Как компромисс: xor eax,eax + mov al,50h – всего на 1 байт больше, но быстрее... вам следует всегда использовать "двойные слова" JQwerty? p.s. Специально проверил – у меня (Core i5-2500K) первый вариант оказался чуть быстрее. А вот repne scasb работает медленнее процентов на 40! А вот нифига! Разница небольшая, но у меня первый вариант (с eax) сработал чуууууть быстрее (на 2,5% ) Зато dec + jnz реально быстрее (причём, прилично)
Ещё пара вещей про оптимизацию, которые пришли в голову (это всё для 32-битного кода)... 1. Вместо: Код (ASM): test eax,eax ; не особо важно какое тут сравнение je @@1 mov eax,ebx jmp @@2 @@1: mov eax,ecx @@2: делаем так: Код (ASM): test eax,eax mov eax,ecx ; не влияет на флаги je @@1 mov eax,ebx @@1: 2. Вместо: Код (ASM): ; какой-то код jmp @@2 @@1: mov eax,ebx xchg ecx,edx @@2: пишем: Код (ASM): ; какой-то код cmp eax,0 ; или mov eax,0 - если нельзя менять флаги org $-4 @@1: mov eax,ebx ; 2 байта xchg ecx,edx ; 2 байта @@2: меньше и быстрее, но работает только для случаев, когда код между @@1 и @@2 занимает ровно 1, 2 или 4 байта (для 1-го или 2-х байтов cmp eax меняем на cmp al или cmp ax, а $-4 – на $-1 или $-2) 3. Серия nop'ов будет быстрее, если заменить 15 nop'ов на 14 префиксов и 1 nop, т.к. по факту это будет 1 инструкция, занимающая 15 байт. В качестве префикса может быть: 3Eh=ds, 2Eh=cs, 26h=es, 36h=ss, 0F2h=repne, 386+: 66h=operand resize, 67h=address resize, 64h=fs, 65h=gs; но только не 0F3h=repe, 0F0h=lock !!! Почему 15? Потому что это максимально допустимая длина инструкции, иначе получите #UD. Кстати, если управление попадёт в середину такой длинной инструкции, ничего страшного не произойдёт, код сработает так же корректно 4. Как сложить значения регистров eax, ebx, ecx, edx, esi, edi? Плохой вариант (медленный): Код (ASM): add eax,ebx add eax,ecx add eax,edx add eax,esi add eax,edi Вот это на современных компах будет работать гораздо быстрее (если вам не жалко испортить пару регистров) благодаря конвейерам и чередованию инструкций, зависящих от предыдущих вычислений: Код (ASM): add ecx,ebx add edx,esi add eax,edi add ecx,edx add eax,ecx 5. Вместо: Код (ASM): inc al ; 2 байта пишем: Код (ASM): inc eax ; 1 байт (если, конечно, нас не волнует то, что при переполнении изменится регистр ah, а флаг CF скорее всего не будет = 0... да и ZF тоже при результирующем al = 0) 6. Вместо: Код (ASM): mov eax,[eax*2] ; 7 байт пишем: Код (ASM): mov eax,[eax+eax] ; 3 байта (аналогично с lea и пр.) 7. Если вам нужно проверить регистр на диапазон значений, то вместо: Код (ASM): cmp al,30h jb @@badchar cmp al,39h ja @@badchar лучше написать Код (ASM): sub al,30h cmp al,9h ja @@badchar (на одну условную инструкцию меньше... правда и значение al изменилось) 8. Если часто используется: Код (ASM): call @@1 @@1:pop ebx ; 3 байта то можно вынести этот код в отдельную процедуру и экономить по байту при каждом вызове (да и выглядеть это будет красивее): Код (ASM): call geteip . . . geteip: mov ebx,[esp] ; 3 байта ret ; 1 байт (имеет смысл, если вызовов call geteip не меньше 4-х) Оптимизация чисто по размеру кода (работает медленнее). 9. Вместо: Код (ASM): add edi,4 ; 4 байта пишем: Код (ASM): scasd ; 1 байт, если df=0 (cld) и edi указывает на область, которую можно читать (но медленнее) 10. Вместо: Код (ASM): mov [eax],0 ; 6 байт пишем: Код (ASM): and [eax],0 ; 3 байта (но медленнее) 11. Классика, про которую почему-то не написано (вернее, не сакцентировано внимания именно на этот приём). Вместо: Код (ASM): mov edx,eax ; 2 байта пишем: Код (ASM): xchg edx,eax ; 1 байт (но медленнее)
Ещё более оптимальный вариант: Код (ASM): geteip: pop ebx jmp ebx Кстати (или некстати ), используя xchg с памятью (например, xchg [eax],edx) помните, что эта инструкция всегда работает с префиксом lock (даже если вы его явно не указываете и не видите в отладчике), т.е. она блокирует системную шину, из-за чего при частом использовании (например, в цикле... SpinLoop, скажем) общая производительность системы может упасть! И ещё один момент (не совсем оптимизация, но косвенно имеет к этому отношение). Вот эти варианты кода (в т.ч. при написании загрузчика или прог под DOS): Код (ASM): pop ss mov sp,Value ; или esp Код (ASM): mov ss,ax mov sp,Value ; или esp нет смысла заключать между cli/sti, поскольку mov ss и pop ss запрещают прерывания (в т.ч. немаскируемые) между этой и следующей инструкцией, поэтому можно не бояться, что между изменением ss и sp возникнет прерывание, которое всё порушит (главное, не менять эти инструкции – mov ss и mov sp – местами).
Тоже поделюсь трюками, хоть и не очень про оптимизацию. Например, компактное выравниванием числа до числа, кратного степени двойки, без циклов и условных переходов. Например, нужно число в eax выравнять до кратного 0x10 В меньшую сторону: Код (ASM): and eax,not (0x10-1) в таком случае число 0x123 обрежется до 0x120 а 0x340 так и останется 0x340 В большую сторону: Код (ASM): add eax,0x10-1 and eax,not (0x10-1) в таком случае число 0x123 выравняется до 0x130 а 0x340 так и останется 0x340
Для простоты можно написать так: Код (ASM): add eax,15 ; 16-1 или 0x10-1 and eax,-16 ; ну или -0x10 Ещё в исходниках Agner'а Fog'а увидел интересную проверку любого байта в eax на нулевое значение (в частности, в функции strlen) и нахождение индекса этого байта (0 - младший байт, 3 - старший байт): Код (ASM): lea ecx,[eax-01010101h] ; subtract 1 from each byte not eax ; invert all bytes and ecx,eax ; and these two and ecx,80808080h ; test all sign bits jz @@nozero ; jump if no zero bytes bsf ecx,ecx ; find right-most 1-bit shr ecx,3 ; divide by 8 = byte index
> mov eax,[eax*2] Это инвалидная конструкция. Нет базы сегмента, только SIB. Тоесть это индексация с начала сегмента. Дельта смещение несомненно зло, в обычной форме. В целом же интересная публикация.
Indy_, это не "инвалидная" конструкция используется для умножения содержимого регистра, а не для адресации
Пришла в голову идея оптимизации проверки битов в каком-либо числе/регистре. Удобно, если эти биты мы определяем сами. Например, предположим, некая наша функция принимает в качестве параметра флаги (в смысле, опции в виде набора битов, в соответствии с которыми наша программа должна вести себя тем или иным образом). Для удобства решим, что флаги (опции) у нас занесены в регистр edx. Допустим, флаг FLAG_WAY определяет – какой из 2-х вариантов процедуры использовать: X или Y. В варианте X проверяются флаги FLAG_ONE и FLAG_TWO, но на первом этапе нас интересует любой из них, а на второй - только FLAG_ONE. В варианте же Y у нас будет разветвление в зависимости от того, установлены или сброшены ли оба флага FLAG_XOR1 и FLAG_XOR2. Самый банальный и привычный путь – это что-то вроде: Код (ASM): FLAG_WAY = 1 FLAG_ONE = 2 FLAG_TWO = 4 FLAG_XOR1 = 8 FLAG_XOR2 = 10h . . . test edx,FLAG_WAY jnz @@VariantY VariantX: test edx,FLAG_ONE or FLAG_TWO jz @@clear12 test edx,FLAG_ONE jz @@clear1 . . . @@clear1: . . . @@clear12: . . . VariantY: test edx,FLAG_XOR1 or FLAG_XOR2 jp @@both . . . @@both: . . . Первое, что бросается в глаза – это edx, который можно заменить на dl во всех проверках. Ну ок. И это всё? Так не интересно! Давайте все переделаем! Долой все test'ы! Код (ASM): FLAG_WAY = 4 ; PF FLAG_ONE = 1 ; CF FLAG_TWO = 40h ; ZF FLAG_XOR1 = 80h ; SF FLAG_XOR2 = 800h ; OF . . . push dx ; не edx! popfw ; с popfd сложнее, старее слово практически не меняется jp @@VariantY ; FLAG_WAY (PF=1) VariantX: jnbe @@clear12 ; FLAG_ONE or FLAG_TWO (CF=ZF=0) jnc @@clear1 ; FLAG_ONE (CF=0) . . . @@clear1: . . . @@clear12: . . . VariantY: jge @@both ; FLAG_XOR1 xor FLAG_XOR2 (SF=OF) . . . @@both: . . . Как вам такая идея? А как вам идея проверить сразу 3 флага? Код (ASM): push dx popfw jle @@label ; FLAG_TWO or (FLAG_XOR1 xor FLAG_XOR2) (ZF=1 or SF≠OF) А так? Код (ASM): ; EAX = флаги sahf jbe @@label ; проверяем установку любого из битов 8 или 14 в EAX
Заполнение 32-битного регистра значениями из 8-битного регистра: Код (ASM): movzx eax,byte ptr SomeByte imul eax,01010101h ; ну или imul edx,eax,01010101h :) (подсмотрено у Agner'а Fog'а) Соответственно, скопировать младшие 16 в старшие можно так: Код (ASM): movzx eax,word ptr SomeWord imul eax,10001h А как занести в старшее слово младшее, увеличенное вдвое, догадаетесь сами? На всякий случай: Код (ASM): movzx eax,word ptr SomeWord imul eax,20001h А можно ещё и вот так: Код (ASM): movzx eax,byte ptr SomeByte imul eax,08040201h ; если, конечно, не будет переполнения во 2-м и 3-м байтах :) p.s. Во всех фокусах старшая часть исходного регистра должна быть обнулена (именно поэтому здесь и используется movzx, а не mov). И, представьте себе, это (второй пример) работает быстрее, чем какой-нибудь неуклюжий код вроде: Код (ASM): movzx eax,word ptr SomeWord mov edx,eax shl eax,16 or eax,edx