Идентификация функций
"Для некоторых людей программирование является такой же внутренней потребностью, подобно тому, как коровы дают молоко, или писатели стремятся писать
Николай Безруков
Функция
(так же называемая процедурой или подпрограммой) – основная структурная единица процедурных и объективно-ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов.
Строгого говоря, термин "функция" присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку. Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать, - это уже не суть важно. Ключевое свойство функции – возвращение управления на место ее вызова, а ее характерный признак – множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).
Откуда функция знает: куда следует возвратить управление? Очевидно, вызывающий код должен предварительно сохранить адрес возврата и вместе с прочими аргументами передать его вызываемой функции. Существует множество способов решения этой проблемы: можно, например, перед вызовом функции поместить в ее конец безусловный переход на адрес возврата, можно сохранить адрес возврата в специальной переменной и после завершения функции выполнить косвенный переход, используя эту переменную как операнд инструкции 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-разрядный код просто смешно!
___Порядок трансляции функций: Большинство компиляторов располагают функции в исполняемом файле в том же самом порядке, в котором они были объявлены в программе.