Фундаментальные основы хакерства

       

Идентификация функций


"Для некоторых людей программирование является такой же внутренней потребностью, подобно тому, как коровы дают молоко, или писатели стремятся писать

Николай Безруков

Функция

(так же называемая процедурой или подпрограммой) – основная структурная единица процедурных и объективно-ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов.

Строгого говоря, термин "функция" присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку. Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать, - это уже не суть важно. Ключевое свойство функции – возвращение управления на место ее вызова, а ее характерный признак – множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).

Откуда функция знает: куда следует возвратить управление? Очевидно, вызывающий код должен предварительно сохранить адрес возврата и вместе с прочими аргументами передать его вызываемой функции. Существует множество способов решения этой проблемы: можно, например, перед вызовом функции поместить в ее конец безусловный переход на адрес возврата, можно сохранить адрес возврата в специальной переменной и после завершения функции выполнить косвенный переход, используя эту переменную как операнд инструкции jump,… Не останавливаясь на обсуждении сильных и слабых сторон каждого метода, отметим, что компиляторы в подавляющем большинстве случаев используют специальные машинные команды CALL и RET

соответственно предназначенные для вызова и выхода из функции.

Инструкция CALL закидывает адрес следующей за ней инструкции на вершину стека, а RET стягивает и передает на него управление. Тот адрес, на который указывает инструкция CALL, и есть адрес начала функции.
А замыкает функцию инструкция RET

(но, внимание: не всякий RET обозначает конец функции! подробнее об этом см. "Идентификация значения, возращенного функцией").

Таким образом, распознать функцию можно двояко: по перекрестным ссылкам, ведущим к машинной инструкции CALL и по ее эпилогу, завершающемуся инструкцией RET. Перекрестные ссылки и эпилог в совокупности позволяют определить адреса начала и конца функции. Немного забегая вперед (см. "Идентификация локальных стековых переменных") заметим, что в начале многих функций присутствует характерная последовательность команд, называемая эпилогом, которая так же пригодна для идентификации функций. А теперь расскажем обо всем этом поподробнее.

::Перекрестные ссылки. Просматривая дизассемблерный код, находим все инструкции CALL – содержимое их операнда и будет искомым адресом начала функции. Адрес не виртуальных функций, вызываемых по имени, вычисляется еще на стадии компиляции и операнд инструкции CALL

в таких случаях представляет собой непосредственное значение. Благодаря этому адрес начала функции выявляется простым синтаксическим анализом: ищем контекстным поиском все подстроки "CALL" и запоминаем (записываемым) непосредственные операнды.

Рассмотрим следующий пример:

func();



main(){

int a;

func();

a=0x666;

func();

}

func(){

int a;

a++;

}

Листинг 6 Пример, демонстрирующий непосредственный вызов функции

Результат его компиляции должен выглядеть приблизительно так:

.text:00401000      push   ebp

.text:00401001      mov    ebp, esp

.text:00401003      push   ecx

.text:00401004      call   401019

.text:00401004 ; Вот мы выловили инструкцию call c

непосредственным операндом,

.text:00401004 ; представляющим собой адрес начала функции. Точнее - ее смещение

.text:00401004 ; в кодовом сегменте (в данном случае в сегменте ".text")

.text:00401004 ; Теперь можно перейти к строке ".text:00401019" и, дав функции



.text:00401004 ; собственное имя, заменить операнд инструкции call

на конструкцию

.text:00401004 ; "call offset Имя функции"

.text:00401004 ;

.text:00401009      mov    dword ptr [ebp-4], 666h

.text:00401010      call   401019

.text:00401010 ; А вот еще один вызов функции! Обратившись к строке ".text:401019"

.text:00401010 ; мы увидим, что эта совокупность инструкций уже определена как функция

.text:00401010 ; и все, что потребуется сделать, – заменить call

401019 на

.text:00401010 ; "call offset Имя функции"

.text:00401010

.text:00401015      mov     esp, ebp

.text:00401017      pop     ebp

.text:00401018      retn

.text:00401018 ; Вот нам встретилась инструкция возврата из функции, однако, не факт

.text:00401018 ; что это действительно конец функции – ведь функция может иметь и

.text:00401018 ; и несколько точек выхода. Однако, смотрите: следом за ret

.text:00401018 ; расположено начало функции "моя функция", отождествленное по

.text:00401018 ; операнду инструкции call.

.text:00401018 ; Поскольку, функции не могут перекрываться, выходит, что данный ret -

.text:00401018 ; конец функции!

.text:00401018 ;

.text:00401019      push    ebp

.text:00401019 ; На эту строку ссылаются операнды нескольких инструкций call.

.text:00401019 ; Следовательно, это – адрес начала функции.

.text:00401019 ; Каждая функция должна иметь собственное имя – как бы нам ее назвать?

.text:00401019 ; Назовем ее "моя функция" :-)

.text:00401019 ;

.text:0040101A      mov    ebp, esp     ; <-

.text:0040101C      push   ecx          ; <-

.text:0040101D      mov    eax, [ebp-4] ; <-

.text:00401020      add    eax, 1       ; <- Это – тело "моей функции"

.text:00401023      mov    [ebp-4],eax  ; <-

.text:00401026      mov    esp, ebp     ; <-

.text:00401028      pop    ebp          ; <-

.text:00401029      retn

.text:00401029; Конец "моей функции"

Листинг 7

Как мы видим, все очень просто.


Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями (см. "Идентификация виртуальных функций"), однако, в любом случае компилятор должен каким-то образом сохранить адрес функции в коде, значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на "подследственную" инструкцию CALL точку останова и, дождавшись всплытия отладчика, посмотреть по какому адресу она передаст управление.

Рассмотрим следующий пример:

func();

main(){

int (a*)();

a=func;

a();

}

Листинг 8 Пример, демонстрирующий вызов функции по указателю

Результат его компиляции должен в общем случае выглядеть так:

.text:00401000      push   ebp

.text:00401001      mov    ebp, esp

.text:00401003      push   ecx

.text:00401004      mov    dword ptr [ebp-4], 401012

.text:0040100B      call   dword ptr [ebp-4]

.text:0040100B ; Вот инструкция CALL, осуществляющая косвенный вызов функции

.text:0040100B ; по адресу, содержащемуся в ячейке [EBP-4].

.text:0040100B ; Как знать – что же там содержится? Прокрутим экран дизассемблера

.text:0040100B ; немного вверх, пока не встретим строку "mov dword

ptr [ebp-4],401012"

.text:0040100B ; Ага! Значит, управление передается по адресу ".text: 401012", -

.text:0040100B ; это и есть адрес начала функции!

.text:0040100B ; Даем функции имя и заменяем "mov dword ptr

[ebp-4], 401012" на

.text:0040100B ; "mov dword ptr [ebp-4], offset

Имя функции"

.text:0040100B ;

.text:0040100E      mov    esp, ebp

.text:00401010      pop    ebp

.text:00401011      retn

Листинг 9

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



func_1();

func_2();

func_3();

main()

{

int x;

int a[3]={(int) func_1,(int) func_2, (int) func_3};

int (*f)();

for (x=0;x < 3;x++)

{

f=(int (*)()) a[x];

f();

}

}

Листинг 10 Пример, демонстрирующий вызов функции по указателю с комплексным вычислением целевого адреса

Результат его дизассемблирования в общем случае должен выглядеть так:

.text:00401000      push   ebp

.text:00401001      mov    ebp, esp

.text:00401003      sub    esp, 14h

.text:00401006      mov    [ebp+0xC], offset sub_401046

.text:0040100D      mov    [ebp+0x8], offset sub_401058

.text:00401014      mov    [ebp+0x4], offset sub_40106A

.text:0040101B      mov    [ebp+0x14], 0

.text:00401022      jmp    short loc_40102D

.text:00401024      mov    eax, [ebp+0x14]

.text:00401027      add    eax, 1

.text:0040102A      mov    [ebp+0x14], eax

.text:0040102D      cmp    [ebp+0x14], 3

.text:00401031      jge    short loc_401042

.text:00401033      mov    ecx, [ebp+0x14]

.text:00401036      mov    edx, [ebp+ecx*4+0xC]

.text:0040103A      mov    [ebp+0x10], edx

.text:0040103D      call   [ebp+0x10]

.text:0040103D ; Так-с, косвенный вызов функции. А что у нас в [EBP+0x10]?

.text:0040103D ; Поднимаем глаза на строку вверх – в [EBP+0x10] у нас значение EDX.

.text:0040103D ; А чем равен сам EDX? Прокручиваем еще одну строку вверх – EDX

равен

.text:0040103D ; содержимому ячейки [EBP+ECX*4+0xC]. Вот дела! Мало, что нам надо

.text:0040103D ; узнать содержимое этой ячейки, так еще предстоит вычислить ее адрес!

.text:0040103D ; Чему равен ECX? Содержимому [EBP+0x14]. А оно чему равно?

.text:0040103D ; "Сейчас выясним…" бормочем мы себе под нос, прокручивая экран

.text:0040103D ; дизассемблера вверх. Ага, нашли, - в строке 0x40102A в него

.text:0040103D ; загружается содержимое EAX! Какая радость! И долго мы по коду так

.text:0040103D ; блуждать будем?

.text:0040103D ; Конечно, можно затратив неопределенное количество времени и усилий



.text:0040103D ; реконструировать весь ключевой алгоритм целиком (тем более, что мы

.text:0040103D ; практически подошли к концу анализа), но где гарантия, что при этом

.text:0040103D ; не будут допущены ошибки?

.text:0040103D ; Гораздо быстрее и надежнее загрузить исследуемую программу в

.text:0040103D ; отладчик, установить бряк на строку "text:0040103D" и,

.text:0040103D ; дождавшись всплытия отладчика, посмотреть: что у нас расположено

.text:0040103D ; в ячейке [EBP+0х10]. Отладчик будут всплывать трижды, причем каждый

.text:0040103D ; раз показывать новый адрес! Заметим, что определить этот факт в

.text:0040103D ; дизассемблере можно только после полной реконструкции алгоритма!

.text:0040103D ; Однако не стоит по поводу мощи отладчика питать излишних иллюзий!

.text:0040103D ; Программа может тысячу раз вызывать одну и ту же функцию, а на

.text:0040103D ; тысяче первый – вызвать совсем другую! Отладчик бессилен это

.text:0040103D ; определить. Ведь вызов такой функции может произойти в

.text:0040103D ; непредсказуемый момент,например, при определенном сочетании времени,

.text:0040103D ; данных, обрабатываемых программой и текущей фазы Луны. Ну не будем же

.text:0040103D ; мы целую вечность гонять программу под отладчиком?

.text:0040103D ; Дизассемблер – дело другое. Полная реконструкция алгоритма позволит

.text:0040103D ; однозначно и гарантированно отследить все адреса косвенных вызовов.

.text:0040103D ; Вот потому, дизассемблер и отладчик должны скакать в одной упряжке!

.text:0040103D ;

.text:00401040      jmp    short loc_401024

.text:00401042

.text:00401042      mov    esp, ebp

.text:00401044      pop    ebp

.text:00401045      retn

Самый тяжелый случай представляют "ручные" вызовы функции командой JMP с предварительной засылок в стек адреса возврата. Вызов через JMP в общем случае выглядит так: "PUSH ret_addrr/JMP func_addr", где "ret_addrr"

и "func_addr" – непосредственные или косвенные адреса возврата и начала функции соответственно. (Кстати, заметим, что команды PUSH и JPM не всегда следует одна за другой, и порой бывают разделены другими командами)



Возникает резонный вопрос – чем же там плох CALL, и зачем прибегать к JMP? Дело в том, что функция, вызванная по CALL, после возврата управления материнской функции всегда передает управление команде, следующей за CALL. В ряде случаев (например, при структурной обработке исключений) возникает необходимость после возврата из функции продолжать выполнение не со следующей за CALL командой, а совсем с другой ветки программы. Тогда-то и приходится "вручную" заносить требуемый адрес возврата и вызывать дочернею функцию через JMP.

Идентифицировать такие функции (особенно если они не имею пролога – см. "Пролог") очень сложно – контекстный поиск ничего не даст, поскольку команд JMP, использующихся для локальных переходов, в теле любой программы очень и очень много – попробуй-ка, проанализируй их все! Если же этого не сделать – из поля зрения выпадут сразу две функции – вызываемая функция и функция, на которую передается управление после возврата. К сожалению, быстрых решений этой проблемы не существует – единственная зацепка – вызывающий JMP практически всегда выходит за границы функции, в теле которой он расположен. Определить же границы функции можно по эпилогу (см. "Эпилог").

Рассмотрим следующий пример:

funct();

main()

{

__asm

{

LEA ESI, return_addr

PUSH ESI

JMP funct

return_addr:

}

}

Листинг 11 Пример, демонстрирующий "ручной" вызов функции инструкцией JPM

Результат его компиляции в общем случае должен выглядеть так:

.text:00401000      push   ebp

.text:00401001      mov    ebp, esp

.text:00401003      push   ebx

.text:00401004      push   esi

.text:00401005      push   edi

.text:00401006      lea    esi, [401012h]

.text:0040100C      push   esi

.text:0040100D      jmp    401017

.text:0040100D ; Смотрите – казалось бы тривиальный условный переход, - что в нем

.text:0040100D ; такого? Ан, нет! Это не простой переход, - это замаскированный

.text:0040100D ; вызов функции! Откуда это следует? А давайте перейдем по адресу



.text:0040100D ; 0x401017 и посмотрим

.text:0040100D      ; .text:00401017    push   ebp

.text:0040100D ; .text:00401018   mov    ebp, esp

.text:0040100D ; .text:0040101A   pop    ebp

.text:0040100D ; .text:0040101B   retn

.text:0040100D ;                      ^^^^

.text:0040100D ; Как вы думаете, куда этот ret возвращает управление? Естественно,

.text:0040100D ; по адресу, лежащему на верхушке стека. А что у нас лежит на стеке?

.text:0040100D ; PUSH EBP из строки 401017 обратно выталкивается инструкцией POP

.text:0040100D ; из строки 40101B, так… возвращаемся назад, к месту безусловного

.text:0040100D ; перехода и начинаем медленно прокручивать экран дизассемблера вверх

.text:0040100D ; отслеживая все обращения к стеку. Ага, попалась птичка! Инструкция

.text:0040100D ; PUSH ESI из строки 401000C закидывает на вершину стека содержимое

.text:0040100D ; регистра ESI, а он сам, в свою очередь, строкой выше принимает

.text:0040100D ; "на грудь" значение 0x401012 – это и есть адрес начала функции,

.text:0040100D ; вызываемой командой "JMP" (вернее, не адрес, а смещение, но это не

.text:0040100D ; принципиально важно).

.text:0040100D ;

.text:00401012      pop    edi

.text:00401013      pop    esi

.text:00401014      pop    ebx

.text:00401015      pop    ebp

.text:00401016      retn

Листинг 12

Автоматическая идентификация функций посредством IDA Pro. Дизассемблер IDA Pro способен анализировать операнды инструкций CALL, что позволяет ему автоматически разбивать программу на функции. Причем, IDA вполне успешно справляется с большинством косвенных вызовов! С комплексными вызовами и "ручными" вызовами функций командой JMP

она, правда, совладеть пока не в состоянии, но это не повод для огорчения – ведь подобные конструкции крайне редки и составляют менее процента от "нормальных" вызов функций, тех, которые IDA без труда распознает!

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



push  ebp

mov   ebp, esp

sub   esp, xx

Листинг 13 Обобщенный код пролога функции

В общих чертах назначение пролога сводиться к следующему: если регистр EBP

используется для адресации локальных переменных (как часто и бывает), то перед его использованием он должен быть сохранен в стеке (иначе вызываемая функция "сорвет крышу" материнской), затем в EBP копируется текущее значение регистра указателя вершины стека (ESP) – происходит, так называемое, открытие кадра стека, и значение ESP

уменьшается на размер области памяти, выделенной под локальные переменные.

Последовательность PUSH EBP/MOV EBP,ESP/SUB ESP,xx

может служить хорошей сигнатурой для нахождения всех функций в исследуемом файле, включая и тех, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDA Pro, однако, оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESP и используют EBP

как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUB ESP, xxx – последовательность слишком короткая для использования ее в качестве сигнатуры функции, - увы. Более подробный рассказ об эпилогах функций нас ждет впереди (см. "Идентификация локальных стековых переменных"), поэтому, во избежание никому не нужного дублирования, не будем здесь на нем останавливаться.

::Эпилог. В конце своей жизни функция закрывает кадр стека, перемещая указатель вершины стека "вниз", и восстанавливает прежнее значение EBP (если только оптимизирующий компилятор не адресовал локальные переменные через ESP, используя EBP

как обычный регистр общего назначения). Эпилог функции может выглядеть двояко: либо ESP увеличивается на нужное значение командой ADD, либо в него копируется значение EBP, указывающие на низ кадра стека:

pop     ebp             mov     esp, ebp

add     esp, 64h        pop     ebp

retn                    retn

Эпилог 1                                             Эпилог 2



Листинг 14 Обобщенный код эпилога функции

Важно отметить: между командами POP EBP/ADD ESP, xxx

и MOV ESP,EBP/POP EBP

могут находиться и другие команды – они не обязательно должны следовать вплотную друг к другу. Поэтому, для поиска эпилогов контекстный поиск непригоден – требуется применять поиск по маске.

Если функция написана с учетом соглашение PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это осуществляется инструкцией RET n, где n – количество байт, снимаемых из стека после возврата. Функции же, соблюдающие Си-соглашение, предоставляют очистку стека вызывающему их коду и всегда оканчиваются командой RET. API-функции Windows представляют собой комбинацию соглашений Си и PASCAL – аргументы заносятся в стек справа налево, но очищает стек сама функция (подробнее обо всем этом см. "Идентификация аргументов функций").

Таким образом, RET может служить достаточным признаком эпилога функции, но не всякий эпилог – это конец. Если функция имеет в своем теле несколько операторов return (как часто и бывает) компилятор в общем случае генерирует для каждого из них свой собственный эпилог. Посмотрите – находится ли за концом эпилога новый пролог или продолжается код старой функции? Не забывайте и о том, что компиляторы обычно не помещают в исполняемый файл код, никогда не получающий управления. Т.е. у функции будет всего один эпилог, а все, находящееся после первого return, будет выброшено как ненужное:

int func(int a)         push    ebp

{                       mov     ebp, esp

mov     eax, [ebp+arg_0]

return a++;       mov     ecx, [ebp+arg_0]

a=1/a;            add     ecx, 1

return a;         mov     [ebp+arg_0], ecx

pop     ebp

}                       retn

Листинг 15 Пример, демонстрирующий выбрасывание компилятором кода, расположенного за безусловным оператором return

Напротив, если внеплановый выход из функции происходит при срабатывании некоторого условия, – такой return будет сохранен компилятором и "окаймлен" условным переходом, прыгающим через эпилог.



int func(int a)

{

if (!a) return a++;

return  1/a;

}

Листинг 16 Пример, демонстрирующий функцию с несколькими эпилогами

                 push    ebp

                 mov     ebp, esp

                 cmp     [ebp+arg_0], 0

                 jnz     short loc_0_401017

                 mov     eax, [ebp+arg_0]

                 mov     ecx, [ebp+arg_0]

                 add     ecx, 1

                 mov     [ebp+arg_0], ecx

                 pop     ebp

                 retn

; Да, это ^^^^^^^^^^^^^^ -- явно эпилог функции, но,

; смотрите: следом идет продолжение кода функции, а

; вовсе не новый пролог!

 loc_0_401017:                           ; CODE XREF: sub_0_401000+7^j

; Данная перекрестная ссылка, приводящая нас к условному переходу,

; говорит о том, что этот код – продолжение прежней функции, а отнюдь не

; начало новой, ибо "нормальные" функции вызываются не jump, а CALL!

; А если это "ненормальная" функция? Что ж, это легко проверить – достаточно

; выяснить: лежит ли адрес возврата на вершине стека или нет? Смотрим –

; нет, не лежит, следовательно, наше предположение относительно продолжения

; кода функции верно.

                 mov     eax, 1

                 cdq

                 idiv    [ebp+arg_0]

 loc_0_401020:                           ; CODE XREF: sub_0_401000+15^j

                 pop     ebp

                 retn

Листинг 17

Специальное замечание: начиная с 80286-процессора, в наборе команд появились две инструкции ENTER и LEAVE, предназначенные специально для открытия и закрытия кадра стека. Однако они практически никогда не используются современными компиляторами. Почему? Причина в том, что ENTER и LEAVE очень медлительны, намного медлительнее PUSH EBP/MOV EBP,ESP/SUB ESB, xxx

и MOV ESP,EBP/POP EBP. Так, на Pentium ENTER выполняется за десять тактов, а приведенная последовательность команд – за семь. Аналогично, LEAVE требует пять тактов, хотя туже операцию можно выполнить за два (и даже быстрее, если разделить MOV ESP,EBP/POP EBP какой-нибудь командой).


Поэтому, современный читатель никогда не столкнется ни с ENTER, ни с LEAVE. Хотя, помнить об их назначении будет нелишне (мало ли, вдруг придется дизассемблировать древние программы, или программы, написанные на ассемблере, – не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их "ручная оптимизация" заметно уступает компилятору по производительности).

"Голые" (naked) функции.

Компилятор Microsoft Visual C++ поддерживает нестандартный квалификатор "naked", позволяющий программистам создавать функции без пролога и эпилога. Без пролога и эпилога вообще! Компилятор даже не помещает в конце функции RET и это придется делать вручную, прибегая к ассемблерной вставке "__asm{ret}" (Использование return не приводит к желаемому результату).

Вообще-то, поддержка naked-функций задумывалась исключительно для написания драйверов на чистом Си (ну, почти чистом, с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность "ручного" создания функций, не беспокоясь, что их непредсказуемым образом "изуродует" компилятор.

Для нас же, кодокопателей, в первом приближении это обозначает, что в программе может встретиться одна (или несколько) функций, не содержащих ни пролога, ни эпилога. Ну и что в этом страшного? Оптимизирующие компиляторы так же выкидывают пролог, а от эпилога оставляют один лишь RET, - но функции элементарно идентифицируются по вызывающей их инструкции CALL.

Идентификация встраиваемых (inline) функций. Самый эффективный способ избавится от накладных расходов на вызов функций – не вызывать их. В самом деле – почему бы ни встроить код функции непосредственно в саму вызывающую функцию? Конечно, это ощутимо увеличит размер (и тем ощутимее, чем из больших мест функция вызывается), но зато значительно увеличит скорость выполнения программы (и тем значительнее, чем чаще "развернутая" функция вызывается).



Чем плоха "развертка" функций для исследования программы? Прежде всего – она увеличивает размер "материнской" функции и делает ее код менее наглядным, - вместо "CALL\TEST EAX,EAX\JZ xxx" с бросающимся в глаза условным переходом, – теперь куча ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться!

Вспомним: мы уже сталкивались с таким приемом при анализе crackme02:

mov    ebp, ds:SendMessageA

push   esi

push   edi

mov    edi, ecx

push   eax

push   666h

mov    ecx, [edi+80h]

push   0Dh

push   ecx

call   ebp ; SendMessageA

lea    esi, [esp+678h+var_668]

mov    eax, offset aMygoodpassword ; "MyGoodPassword"

loc_0_4013F0:                     ; CODE XREF: sub_0_4013C0+52j

mov    dl, [eax]

mov    bl, [esi]

mov    cl, dl

cmp    dl, bl

jnz    short loc_0_401418

test   cl, cl

jz     short loc_0_401414

mov    dl, [eax+1]

mov    bl, [esi+1]

mov    cl, dl

cmp    dl, bl

jnz    short loc_0_401418

add    eax, 2

add    esi, 2

test   cl, cl

jnz    short loc_0_4013F0

 

loc_0_401414:                     ; CODE XREF: sub_0_4013C0+3Cj

xor    eax, eax

jmp    short loc_0_40141D

 

loc_0_401418:                     ; CODE XREF: sub_0_4013C0+38j

sbb    eax, eax

sbb    eax, 0FFFFFFFFh

loc_0_40141D:                     ; CODE XREF: sub_0_4013C0+56j

test   eax, eax

push   0

push   0

jz     short loc_0_401460

Листинг 18

Встроенные функции не имеют ни собственного пролога, ни эпилога, их код и локальные переменные (если таковые имеются) полностью "вживлены" в вызывающую функцию, – результат компиляции выглядит в точности так, как будто бы никакого вызова функции и не было. Единственная зацепка – встраивание функции неизбежно приводит к дублированию ее кода во всех местах вызова, а это хоть с трудом, но можно обнаружить. "С трудом" – потому, что встраиваемая функция, становясь частью вызывающей функции, "в сквозную" оптимизируется в контексте последней, что приводит к значительным вариациям кода.


Рассмотрим такой пример:

#include <stdio.h>

__inline int max( int a, int b )

{

if( a > b ) return a;

return b;

}

int main(int argc, char **argv)

{

printf("%x\n",max(0x666,0x777));

printf("%x\n",max(0x666,argc));

printf("%x\n",max(0x666,argc));

return 0;

}

Листинг 19 Пример, демонстрирующий, сквозную оптимизацию встраиваемых функций

Результат его компиляции в общем случае должен выглядеть так:

push   esi

push   edi

push   777h                ;

ß

код 1-го

вызова max

; Компилятор вычислил значение функции max

еще на этапе компиляции и

; вставил его в программу, избавившись от лишнего вызова функции

push   offset aProc ; "%x\n"

call   printf

mov    esi, [esp+8+arg_0]

add    esp, 8

cmp    esi, 666h           ; ß код 2-го вызова max

mov    edi, 666h           ; ß код 2-го вызова max

jl     short loc_0_401027  ; ß код 2-го вызова max

mov    edi, esi            ; ß код 2-го вызова max

loc_0_401027:                     ; CODE XREF: sub_0_401000+23j

push   edi

push   offset aProc ; "%x\n"

call   printf

add    esp, 8

cmp    esi, 666h           ; ß код 3-го вызова max

jge    short loc_0_401042  ; ß код 2-го вызова max

mov    esi, 666h           ; ß код 2-го вызова max

; Смотрите – как изменился код функции! Во-первых, нарушилась очередность

; выполнения инструкций – было "CMP

-> MOV

– Jx", а стало "CMP

-> Jx, MOV"

; А во-вторых, условный переход JL

загадочным образом превратился в JGE!

; Впрочем, ничего загадочного тут нет – просто идет сквозная оптимизация!

; Поскольку, после третьего вызова функции max

переменная argc, размещенная

; компилятором в регистре ESI, более не используется, у компилятора появляется

; возможность непосредственно модифицировать этот регистр, а не вводить

; временную переменную, выделяя под нее регистр EDI



; (см. "Идентификация переменных -> регистровых и временныех

переменныех")

loc_0_401042:                     ; CODE XREF: sub_0_401000+3Bj

push   esi

push   offset aProc ; "%x\n"

call   printf

add    esp, 8

mov    eax, edi

pop    edi

pop    esi

retn  

Листинг 20

Смотрите, - при первом вызове компилятор вообще выкинул весь код функции, вычислив результат ее работы еще на стадии компиляции (действительно, 0x777 всегда больше 0x666 и не за чем тратить процессорные такты на их сравнение). А второй вызов очень мало похож на третий, несмотря на то, что в обоих случаях функции передавались один и те же аргументы! Тут не то, что поиск по маске (не говоря уже о контекстном поиске), человек не разберется – одна и та же функция вызывается или нет!

 Модели памяти и 16-разрядные компиляторы. Под "адресом" функции в данной главе до настоящего момента подразумевалось исключительно ее смещение в кодовом сегменте. Плоская (flat) модель памяти 32-разрядной Windows 9х\NT "упаковывает" все три сегмента – сегмент кода, сегмент стека и сегмент данных – в единое четырех гигабайтное адресное пространство, позволяя вообще забыть о существовании сегментов.

Иное дело – 16-разрядные приложения для MS-DOS и Windows 3.x. В них максимально допустимый размер сегментов составляет всего лишь 64 килобайта, чего явно недостаточно для большинства приложений. В крошечной (tiny) модели памяти сегменты кода, стека и данных так же расположены в одном адресном пространстве, но в отличие от плоской модели это адресное пространство чрезвычайно ограничено в размерах, и мало-мальски серьезное приложение приходится рассовывать по нескольким сегментам.

Теперь для вызова функции уже не достаточно знать ее смещение, – требуется указать еще и сегмент, в котором она расположена. Однако сегодня об этом рудименте старины можно со спокойной совестью забыть. На фоне грядущей 64-разрядной версии Windows, подробно описывать 16-разрядный код просто смешно!

___Порядок трансляции функций: Большинство компиляторов располагают функции в исполняемом файле в том же самом порядке, в котором они были объявлены в программе.


Содержание раздела