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

Идентификация аргументов функций


То, что пугает зверя, не пугает человека.

Фрэнк Херберт "Ловец душ"

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

Существует три способа передачи аргументов функции: через стек, через регистры и комбинированный

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

Сами же аргументы могут передаваться либо по значению, либо по ссылке. В первом случае функции передается копия

соответствующей переменной, а во втором – указатель

на саму переменную.

::соглашения о передаче параметров. Для успешной совместной работы вызывающая функция должна не только знать прототип вызываемой, но и "договориться" с ней о способе передачи аргументов: по ссылке или значению, через регистры или через стек? Если через регистры – оговорить какой аргумент в какой регистр помещен, а если через стек – определить порядок занесения аргументов и выбрать "ответственного" за очистку стека от аргументов после завершения вызываемой функции.

Неоднозначность механизма передачи аргументов – одна из причин несовместимости различных компиляторов. Казалось, почему бы ни заставить всех производителей компиляторов придерживаться какой-то одной схемы? Увы, это решение принесет больше проблем, чем решит.

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



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

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

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

Си-соглашение (обозначаемое __cdecl) предписывает засылать аргументы в стек справа налево в порядке их объявления, а очистку стека возлагает на плечи вызывающей функции. Имена функций, следующих Си-соглашению, предваряются символом прочерка "_", автоматически вставляемого компилятором. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.

Паскаль-соглашение

(обозначаемое PASCAL) { >>> сноска В настоящее время ключевое слово PASCAL считается устаревшим и выходит из употребления, вместо него можно использовать аналогичное соглашение WINAPI} предписывает засылать аргументы в стек слева направо в порядке их объявления, и возлагает очистку стека на саму вызывающую функцию.

Стандартное соглашение (обозначаемое __stdcall) является гибридом Си- и Паскаль- соглашений.


Аргументы засылаются в стек справа налево, но очищает стек сама вызываемая функция. Имена функций, следующих стандартному соглашению, предваряются символом прочерка "_", а заканчиваются суффиксом "@", за которым следует количество байт передаваемых функции. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.

Соглашения быстрого вызова: Предписывает передавать аргументы через регистры. Компиляторы от Microsoft и Borland поддерживают ключевое слово __fastcall, но интерпретируют его по-разному, а WATCOM С++ вообще не понимает ключевого слова __fastcall, но имеет в "арсенале" своего лексикона специальную прагму "aux", позволяющую вручную выбрать регистры для передачи аргументов (подробнее см. "соглашения о быстрых вызовах – fastcall"). Имена функций, следующих соглашению __fastcall, предваряются символом "@", автоматически вставляемым компилятором.

Соглашение по умолчанию: Если явное объявление типа вызова отсутствует, компилятор обычно использует собственные соглашения, выбирая их по своему усмотрению. Наибольшему влиянию подвергается указатель this, - большинство компиляторов при вызове по умолчанию передают его через регистр. У Microsoft это – ECX, у Borland – EAX, у WATCOM – либо EAX, либо EDX, либо и то, и другое разом. Остальные аргументы так же могут передаться через регистры, если оптимизатор посчитает, что так будет лучше. Механизм передачи и логика выборки аргументов у всех разная и наперед непредсказуемая, - разбирайтесь по ситуации.

::цели и задачи.

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

Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция - мы имеем c cdecl, в противном случае – либо с stdcall, либо с PASCAL.


Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI) и stdcall, поэтому, неопределенность по-прежнему остается. Впрочем, порядок передачи аргументов ничего не меняет – имея в наличии и вызывающую, и вызываемую функцию между передаваемыми и принимаемыми аргументами всегда можно установить взаимно однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен - см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему.

Другое дело – библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов!

::определение количества и типа передачи аргументов. Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а так же – неявно через глобальные переменные.

Если бы стек использовался только для передачи аргументов, подсчитать их количество было относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию заталкивания PUSH, не торопитесь идентифицировать ее как аргумент. Узнать количество байт, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байт, выталкиваемых из стека после завершения функции!

Если функция следует соглашению stdcall (или PASCAL) она наверняка очищает стек командой RET n, где n и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция "ADD ESP,n" – где n искомое значение в байтах, но возможны и вариации – отложенная очистка стека



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

Логично предположить, что количество занесенных в стек байт равно количеству выталкиваемых – иначе после завершения функции стек окажется несбалансированным, и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда: количество аргументов равно количеству переданных байт, деленному на размер машинного слова { >>> сноска Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам} Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип double, отъедающий восемь байт, или символьную строку, переданную не по ссылке, а по непосредственному значению, - она "скушает" столько байт, сколько захочет… К тому же засылаться в стек строка (как и структура данных, массив, объект) может не командой PUSH, а с помощью MOVS! (Кстати, наличие MOVS – явное свидетельство передачи аргумента по значению)

Если я не успел окончательно вас запутать, то попробуем разложить по полочкам тот кавардак, что образовался в нашей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байт определяется весьма неуверенно. С типом передачи полный мрак. Позже (см. "Идентификация констант и смещений") мы к этому еще вернемся, а пока вот пример: PUSH 0x40404040/CALL MyFuct: 0x404040

– что это: аргумент передаваемый по значению (т.е. константа 0x404040) или указатель на нечто, расположенное по смещению 0x404040

(и тогда, стало быть, передача происходит по ссылке)? Определить невозможно, не правда ли?

Но не волнуйтесь, нам не пришли кранты – мы еще повоюем! Большую часть проблем решает анализ вызываемой функции.


Выяснив, как она манипулирует переданными ей аргументами, мы установим и их тип и количество! Для этого нам придется познакомиться с адресацией аргументов в стеке, но прежде чем приступить к работе, рассмотрим в качестве небольшой разминки следующий пример:

#include <stdio.h>

#include <string.h>

struct XT{

char s0[20];

int x;

};

void MyFunc(double a, struct XT xt)

{

printf("%f,%x,%s\n",a,xt.x,&xt.s0[0]);

}

main()

{

struct XT xt;

strcpy(&xt.s0[0],"Hello,World!");

xt.x=0x777;

MyFunc(6.66,xt);

}

Листинг 56 Демонстрация механизма передачи аргументов

Результат его компиляции компилятором Microsoft Visual C++ с настройками по умолчанию выглядит так:

main   proc near           ; CODE XREF: start+AFp

var_18       = byte ptr -18h

var_4        = dword      ptr -4

push   ebp

mov    ebp, esp

sub    esp, 18h

; Первый PUSH явно относится к прологу функции, а не к передаваемым аргументам

push   esi

push   edi

; Отсутствие явной инициализации регистров говорит о том, что, скорее всего,

; они просто сохраняются в стеке, а не передаются как аргументы,

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

; но и через регистры ESI и EDI, то их засылка в стек вполне может

; преследовать цель передачи аргументов следующей функции

push   offset aHelloWorld ; "Hello,World!"

; Ага, а вот здесь явно имеет место передача аргумента – указателя на строку

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

имеет место, - см. "Идентификация констант")

; Хотя теоретически возможно временное сохранение константы в стеке для ее

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

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

; хитрости и засылка константы в стек всегда является передаваемым аргументом

lea    eax, [ebp+var_18]

; в EAX заносится указатель на локальный буфер



push   eax

; EAX

( указатель на локальный буфер) сохраняется в стеке.

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

; можно не сомневаться, что все последующие заносы чего бы то ни было в стек –

; так же аргументы

call   strcpy

; Прототип функции strcpy(char

*, char

*) не позволяет определить порядок

; занесения аргументов, однако, поскольку все библиотечные Си-функции

; следует соглашению cdecl, то аргументы заносятся справа налево

; и исходный код выглядел так: strcpy(&buff[0],"Hello,World!")

; Но, может быть, программист использовал преобразование, скажем, в stdcall?

; Крайне маловероятно, – для этого пришлось бы перекомпилировать и саму

; strcpy

– иначе откуда она бы узнала, что порядок занесения аргументов

; изменился? Хотя обычно стандартные библиотеки поставляются с исходными

; текстами их перекомпиляцией практически никто и никогда не занимается

add    esp, 8

; Выталкиваем 8 байт из стека. Из этого мы заключаем, что функции передавалось

; два машинных слова аргументов и, следовательно, PUSH ESI

и PUSH EDI

не были

; аргументами функции!

mov    [ebp+var_4], 777h

; Заносим в локальную переменную константу 0x777. Это явно константа, а не

; указатель, т.к. у Windows в этой области памяти не могут храниться никакие

; пользовательские данные

sub    esp, 18h

; Резервирование памяти для временной переменной. Временные переменные

; в частности создаются при передаче аргументов по значению, поэтому,

; будем готовы к тому, что следующий "товарищ" – аргумент

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

mov    ecx, 6

; Заносим в ECX константу 0х6. Пока еще не известно зачем.

lea    esi, [ebp+var_18]

; Загружаем в ESI указатель на локальный буффер, содержащий скопированную

; строку "Hello, World!"

mov    edi, esp

; Копируем в EDI указатель на вершину стека

repe movsd

; вот она – передача строки по значению.


Строка целиком копируется в стек,

; отъедая от него 6*4 байт.

; (6 – значение счетчика ECX, а 4 – размер двойного слова – movsD)

; следовательно, этот аргумент занимает 20 (0x14) байт стекового пространства –

; эта цифра нам пригодится при определении количества аргументов по количеству

; выталкиваемых байт.

; В стек копируются данные с [ebp+var_18], до [ebp+var_18-0x14], т.е.

; с var_18 до var_4. Но ведь в var_4 содержится константа 0x777!

; следовательно, она будет передана функции вместе со строкой.

; Это позволяет нам воссоздать исходную структуру:

; struct x{

; char s0[20]

; int x

; }

; да, функции, выходит, передается структура, а не одна строка!

push   401AA3D7h

push   0A3D70A4h

; Заносим в стек еще два аргумента. Впрочем, почему именно два?

; Это вполне может быть и один аргумент типа int64 или double

; Определить – какой именно по коду вызывающей функции не представляется

; возможным

call   MyFunc

; Вызов MyFunc. Прототип функции установить, увы, не удается... Ясно только,

; что первый (слева? справа?) аргумент – структура, а за ним идут либо два int

; либо один int64 или double

; Уточнить ситуацию позволяет анализ вызываемой функции, но мы это отложим

; на потом, - до того как изучим адресацию аргументов в стеке

; Пока же придется прибывать в полной неопределенности

add    esp, 20h

; выталкиваем 0x20 байт. Поскольку, 20 байт (0x14) приходится на структуру

; и 8 байт – на два следующих аргумента, получаем 0x14+0x8=0x20, что

; и требовалось доказать.

pop    edi

pop    esi

mov    esp, ebp

pop    ebp

retn

sub_401022   endp

aHelloWorld  db 'Hello,World!',0     ; DATA XREF: sub_401022+8o

align 4

Листинг 57

Результат компиляции компилятором Borland C++ будет несколько иным и довольно поучительным. Рассмотрим и его:

_main        proc near           ; DATA XREF: DATA:00407044o

var_18       = byte ptr -18h

var_4        = dword      ptr -4

push   ebp



mov    ebp, esp

add    esp, 0FFFFFFE8h

; Ага! Это сложение со знаком минус. Жмем в IDA

<-> и получаем ADD ESP,-18h

push   esi

push   edi

; Пока все идет как в предыдущем случае

mov    esi, offset aHelloWorld    ; "Hello,World!"

; А вот тут начинаются различия! Вызов strcpy

как корова языком слизала –

; нету его и все! Причем, компилятор даже не развернул функцию,

; подставляя ее на место вызова, а просто исключил сам вызов!

lea    edi, [ebp+var_18]

; Заносим в EDI указатель на локальный буфер

mov    eax, edi

; Заносим тот же самый указатель в EAX

mov    ecx, 3

repe movsd

movsb

; Обратите внимание: копируется 4*3+1=13 байт. Тринадцать, а вовсе не

; двадцать, как следует из объявления структуры. Это компилятор так

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

; не инициализированный "хвост"

mov    [ebp+var_4], 777h

; Заносим в локальную переменную константу 0x777

push   401AA3D7h

push   0A3D70A4h

; Аналогично. Мы не может определить: чем являются эти два числа –

; одним или двумя аргументами.

lea    ecx, [ebp+var_18]

; Заносим в ECX указатель на начало строки

mov    edx, 5

; Заносим в EDX константу 5 (пока не понятно зачем)

loc_4010D3:                       ; CODE XREF: _main+37j

push   dword ptr [ecx+edx*4]

; Ой, что это за кошмарный код? Давайте подумаем, начав раскручивать его

; с самого конца. Прежде всего – чему равно ECX+EDX*4? ECX – указатель на

; буфер и с этим все ясно, а вот EDX*4 == 5*4 == 20.

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

; даже не на конец, а на переменную ebp+var_4 (0x18-0x14=0x4).

; Подумаем – если это указатель на саму var_4, то зачем его вычислять таким

; закрученным макаром? Вероятнее всего мы имеем дело со структурой...

; Далее – смотрите, команда push засылает в стек двойное слово,

; хранящееся по этому указателю

dec    edx



; Уменьшаем EDX... Вы уже почувствовали, что мы имеем дело с циклом?

jns    short loc_4010D3

; вот – этот переход, срабатывающий пока EDX

не отрицательное число,

; подтверждает наше предположение о цикле.

; Да, такой вот извращенной конструкций Borland

передает аргумент - структуру

; функции по значению!

call   MyFunc

; Вызов функции... смотрите – нет очистки стека! Да, это последняя вызываемая

; функция и очистки стека не требуется – Borland

ее и не выполняет...

xor    eax, eax

; Обнуление результата, возращенного функцией. Borland

так поступает с void

; функциями – они у него всегда возвращают ноль,

; точнее: не они возвращают, а помещенный за их вызовом код, обнуления EAX

pop    edi

pop    esi

; Восстанавливаем ранее сохраненные регистры EDI

и ESI

mov    esp, ebp

; восстанавливаем  ESI, - вот почему стек не очищался после вызова последней

; функции!

pop    ebp

retn

_main        endp

Листинг 58

Обратите внимание – по умолчанию Microsoft C++ передает аргументы справа налево, а Borland C++ - слева направо! Среди стандартных типов вызов нет такого, который, передавая аргументы слева направо, поручал бы очистку стека вызывающей функции! Выходит, что Borland C++ использует свой собственный, ни с чем не совместимый тип вызова!

::адресация аргументов в стеке. Базовая концепция стека включает лишь две операции – занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу – это что-то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы – если нужен, скажем, третий по счету элемент, почему бы ни вытащить из стека напрямую, не снимая первые два? Стек это не только "стопка", как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажите класть очередной элемент?), и размер элементов, мы сможем вычислить смещению любого из элементов, после чего не составит никакого труда его прочитать.



Попутно отметим один из недостатков стека – как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например, двойные слова. Если же требуется занести один байт (скажем, аргумент типа char), то приходится расширять его до двойного слова и заносить его целиком. Аналогично, если аргумент занимает четыре слова (double, int64) на его передачу расходуется два стековых элемента!

Помимо передачи аргументов стек используется и для сохранения адреса возврата из функции, что требует в зависимости от типа вызова функции (ближнего или дальнего) от одного до двух элементов. Ближний (near) вызов действует в рамках одного сегмента, - в этом случае достаточно сохранить лишь смещение команды, следующей за инструкций CALL. Если же вызывающая функция находится в одном сегменте, а вызываемая в другом, то помимо смещения приходится запоминать и сам сегмент, чтобы знать в какое место вернуться. Поскольку адрес возврата заносится после аргументов, то относительно вершины стека аргументы оказываются "за" ним и их смещение варьируется в зависимости от того: один элемент занимает адрес возврата или два. К счастью, плоская модель памяти Windows NT\9x позволяет забыть о моделях памяти как о страшном сне и всюду использовать только ближние вызовы.

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

arg_offset = N*size_element+size_return_address

где N

– номер аргумента, считая от вершины стека, начиная с нуля, size_element – размер одного элемента стека, в общем случае равный разрядности сегмента (под Windows NT\9x – четыре байта), size_return_address – размер в байтах, занимаемый адресом возврата (под Windows NT\9x – обычно четыре байта).



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

Поскольку, перед копированием в EBP текущего значения ESP, старое значение EBP приходится сохранять в том же самом стеке, в приведенную формулу приходится вносить поправку, добавляя к размеру адреса возврата еще и размер регистра EBP (BP в 16-разрядном режиме, который все еще жив на сегодняшний день).

С точки зрения хакера главное достоинства такой адресации аргументов в том, что, увидев где-то в середине кода инструкцию типа "MOV EAX,[EBP+0x10]", можно мгновенно вычислить к какому именно аргументу происходит обращение. Однако оптимизирующие компиляторы для экономии регистра EBP адресуют аргументы непосредственно через ESP. Разница принципиальна! Значение ESP не остается постоянным на протяжении выполнения функции и изменяется всякий раз при занесении и снятии данных из стека, следовательно, не остается постоянным и смещение аргументов относительно ESP. Теперь, чтобы определить к какому именно аргументу происходит обращение, необходимо знать: чему равен ESP в данной точке программы, а для выяснения этого все его изменения приходится отслеживать от самого начала функции! Подробнее о такой "хитрой" адресации мы поговорим потом (см. "Идентификация локальных стековых переменных"), а для начала вернемся к предыдущему примеру (надо ж его "добить") и разберем вызываемую функцию:

MyFunc proc near           ; CODE XREF: main+39p

arg_0        = dword      ptr  8

arg_4        = dword      ptr  0Ch

arg_8        = byte ptr  10h

arg_1C       = dword      ptr  24h

; IDA

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

; не стоит безоговорочно этому доверять, – если один аргумент (например, int64)

; передается в нескольких машинных словах, то IDA

ошибочно примет его не за один,

; а за несколько аргументов!



; Поэтому, результат, полученный IDA, надо трактовать так: функции передается не менее

; четырех аргументов. Впрочем, и здесь не все гладко! Ведь никто не мешает вызываемой

; функции залезать в стек материнской так далеко, как она захочет! Может быть,

; нам не передавали никаких аргументов вовсе, а мы самовольно полезли в стек и

; стянули что-то оттуда. Хотя это случается в основном вследствие программистских

; ошибок из-за путаницы с прототипами, считаться с такой возможностью необходимо.

; (Когда ни будь вы все равно с этим встретитесь, так что будьте готовы)

; Число, стоящее после 'arg', выражает смещение аргумента относительно начала

; кадра стека.

; Обратите внимание: сам кадр стека смещен на восемь байт относительно EBP

-

; четыре байта занимает сохраненный адрес возврата, и еще четыре уходят на сохранение

; регистра EBP.

push   ebp

mov    ebp, esp

lea    eax, [ebp+arg_8]

; получение указателя на аргумент.

; Внимание: именно указателя на аргумент, а не изволение аргумента-указателя!

; Теперь разберемся – на какой именно аргумент мы получаем указатель.

; IDA

уже вычислила, что этот аргумент смещен на восемь байт относительно

; начала кадра стека. В оригинале выражение, заключенное в скобках выглядело

; как ebp+0x10 – так его и отображает большинство дизассемблеров. Не будь IDA

; такой умной, нам бы пришлось постоянно вручную отнимать по восемь байт от

; каждого такого адресного выражения (впрочем, с этим мы еще поупражняемся)

;

; Логично: на вершине то, что мы клали в стек в последнею очередь.

; Смотрим вызывающую функцию – что ж мы клали-то?

; (см. вариант, откомпилированный Microsoft Visual C++)

; Ага, последними были те два непонятные аргумента, а перед ними в стек

; засылалась структура, состоящая из строки и переменной типа int

; Таким образом, EBP+ARG_8 указывает на строку

push   eax

; Засылаем в стек полученный указатель.

; Похоже, что он передается очередной функции.

mov    ecx, [ebp+arg_1C]

; Заносим в ECX содержимое аргумента EBP+ARG_1C.


На что он указывает?

; Вспомним, что тип int находится в структуре по смещению 0x14 байт от начала,

; а ARG_8 – и есть ее начало. Тогда, 0x8+0x14 == 0x1C.

; Т.е. в ECX заносится значение переменной типа int, члена структуры

push   ecx

; Заносим полученную переменную в стек, передавая ее по значению

; (по значению – потому что ECX хранит значение, а не указатель)

mov    edx, [ebp+arg_4]

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

push   edx

; ...и, вновь заталкиваем в стек, передавая его очередной функции.

mov    eax, [ebp+arg_0]

push   eax

; Берем второй непонятный аргумент и пихаем его в стек.

push   offset aFXS  ; "%f,%x,%s\n"

call   _printf

; Опа! Вызов printf с передачей строкой спецификаторов! Функция, printf,

; как известно, имеет переменное число аргументов, тип и количество которых

; как раз и задают спецификаторы.

; Вспомним, – сперва в стек мы заносили указатель на строку, и действительно,

; крайний правый спецификатор "%s" обозначает вывод строки.

; Затем в стек заносилась переменная типа int

и второй справа спецификатор

; есть %x – вывод целого в шестнадцатеричной форме.

; А вот затем... затем идет последний спецификатор %f, в то время как в стек

; заносились два аргумента.

; Заглянув в руководство программиста по Microsoft Visual C++, мы прочтем,

; что спецификатор %f выводит вещественное значение, которое в зависимости от

; типа может занимать и четыре байта (float), и восемь (double).

; В нашем случае оно явно занимает восемь байт, следовательно, это double

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

; cdecl MyFunc(double a, struct B b)

; Тип вызова cdecl – т.е. стек вычищала вызывающая функция. Вот только, увы,

; подлинный порядок передачи аргументов восстановить невозможно. Вспомним,

; Borland C++ так же вычищал стек вызывающей функцией, но самвовольно изменил

; порядок передачи параметров.

; Кажется, если программа компилилась Borland C++, то мы просто изменяем



; порядк арументов на обратный – вот и все. Увы, это не так просто. Если имело

; место явное преобразование типа функции в cdecl, то Borland C++ без лишней

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

; порядка аргументов дало бы неверный резлуьтат!

; Впрочем, подлинный порядок следования аргументов в прототипе функции

; не играет никакой роли. Важно лишь связать передаваемые и принимаемые

; аргументы, что мы и сделали.

; Обратите внимание: это стало возможно лишь при совместом анализе и вызываемой

; и вызывающей функуий! Анализ лишь одной из них ничего бы не дал!

; Примечание: никогда не следует безоговорочно полагаться на достоверность

; строки спецификаторов. Посколкьу, спецификаторы формируются вручную самим

; программистом, тут возможны ошибки, под час весьма трудноуловимые и дающие

; после компиляции чрезвычайно загадочный код!

; Подробнее об этом рассказывается в статье

; "неизвестная уявзимость ошибка printf", помещенный в главу "Приложения"

add    esp, 14h

pop    ebp

retn

MyFunc endp

Листинг 59

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

Начнем с изучения стандартного соглашения о вызове – stdcall. Рассмотрим следующий пример:

#include <stdio.h>

#include <string.h>

__stdcall MyFunc(int a, int b, char *c)

{

return a+b+strlen(c);

}

main()

{

printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));

}

Листинг 60 Демонстрация stdcall

Результат его компиляции Microsoft Visual C++ с настройками по умолчанию должен выглядеть так:



main   proc near           ; CODE XREF: start+AFp

push   ebp

mov    ebp, esp

push   offset aHelloWorld ; const char *

; Заносим в стек указатель на строку aHelloWorld.

; Заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что

; это – самый правый аргумент, передаваемый функции. Следовательно,

; перед нами вызов типа stdcall или cdecl, но не PASCAL.

; Обратите внимание – строка передается по ссылке, но не по знаниючению.

push   777h         ; int

; Заносим в стек еще один аргумент - константу типа int.

; (IDA начиная с версии 4.17 автоматически определяет ее тип).

push   666h         ; int

; Передаем функции последний, самый левый аргумент, – константу типа int

call   MyFunc

; Обратите внимание – после вызова функции отсутствуют команды очистки стека

; от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул

; к отложенной очистке, то скорее всего, стек очищает сама вызываемая функция,

; значит, тип вызова – stdcall (что, собственно, и требовалось доказать)

push   eax

; Передаем возвращенное функцией значение следующей функции как аргумент

push   offset asc_406040 ; "%x\n"

call   _printf

; ОК, эта следующая функция printf, и строка спецификаторов показывает,

; что переданный аргумент имеет тип int

add    esp, 8

; Выталкивание восьми байт из стека – четыре приходятся на аргумент типа int

; остальные четыре – на указатель на строку спецификаторов

pop    ebp

retn

main   endp

; int __cdecl MyFunc(int,int,const char *)

MyFunc       proc near           ; CODE XREF: sub_40101D+12p

; С версии 4.17 IDA автоматически восстанавливает прототипы функций, но делает это

; не всегда правильно. В данном случае она допустила грубую ошибку – тип вызова

; никак не может иметь тип cdecl, т.к. стек вычищает вызываемая функция! Сдается, что

; вообще не предпринимает никаких попыток анализа типа вызова, а берет его из настроек

; распознанного компилятора по умолчанию.



; В общем, как бы там ни было, но с результатами работы IDA

следует обращаться

; очень осторожно.

arg_0        = dword      ptr  8

arg_4        = dword      ptr  0Ch

arg_8        = dword      ptr  10h

push   ebp

mov    ebp, esp

push   esi

; Это, как видно, сохранение регистра в стеке, а не передача его функции, т.к.

; регистр явным образом не инициализировался ни вызывающей, ни вызываемой

; функцией.

mov    esi, [ebp+arg_0]

; Заносим в регистр ESI последней занесенный в стек аргумент

add    esi, [ebp+arg_4]

; Складываем содержимое ESI с предпоследним занесенным в стек аргументом

mov    eax, [ebp+arg_8]

; Заносим в в EAX пред- предпоследний аргумент и…

push   eax          ; const      char *

; …засылаем его в стек.

call   _strlen

; Поскольку strlen ожидает указателя на строку, можно с уверенностью

; заключить, что пред- предпоследний аргумент – строка, переданная по ссылке.

add    esp, 4

; Вычистка последнего аргумента из стека

add    eax, esi

; Как мы помним, в ESI хранится сумма двух первых аргументов,

; а в EAX – возвращенная длина строки. Таким образом, функция суммирует

; два своих аргумента с длиной строки.

pop    esi

pop    ebp

retn   0Ch

; Стек чистит вызываемая функция, следовательно, тип вызова stdcall

или PASCAL.

; Будем считать, что это stdcall, тогда прототип функции выглядит так:

; int MyFunc(int a, int b, char *c)

;

; Порядок аргументов вытекает из того, что на вершине стека были две

; переменные типа int, а под ними строка. Поскольку на верху стека лежит

; всегда то, что заносилось в него в последнюю очередь, а по stdcall

; аргументы заносятся справа налево, мы получаем именно такой порядок

; следования аргументов

MyFunc       endp

Листинг 61

А теперь рассмотрим, как происходит вызов cdecl функции. Изменим в предыдущем примере ключевое слово stdcall на cdecl:

#include <stdio.h>

#include <string.h>

__cdecl MyFunc(int a, int b, char *c)



{

return a+b+strlen(c);

}

main()

{

printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));

}

Листинг 62 Демонстрация cdecl

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

main         proc near           ; CODE XREF: start+AFp

push   ebp

mov    ebp, esp

push   offset aHelloWorld ; const char   *

push   777h         ; int

push   666h         ; int

; Передаем функции аргументы через стек

call   MyFunc

add    esp, 0Ch

; Смотрите: стек вычищает вызывающая функция. Значит, тип вызова cdecl,

; поскольку, все остальные предписывают вычищать стек вызываемой функции.

push   eax

push   offset asc_406040 ; "%x\n"

call   _printf

add    esp, 8

pop    ebp

retn

main         endp

; int __cdecl MyFunc(int,int,const char *)

; А вот сейчас IDA правильно определила тип вызова. Однако, как уже показывалось выше,

; она могла и ошибиться, поэтому полагаться на нее не стоит.

MyFunc       proc near           ; CODE XREF: main+12p

arg_0        = dword      ptr  8

arg_4        = dword      ptr  0Ch

arg_8        = dword      ptr  10h

; Поскольку, как мы уже выяснили, функция имеет тип cdecl, аргументы передаются

; справа налево и ее прототип выглядит так: MyFunc(int arg_0, int arg_4, char *arg_8)

push   ebp

mov    ebp, esp

push   esi

; Сохраняем ESI в стеке

mov    esi, [ebp+arg_0]

; Заносим в ESI аргумент arg_0 типа int

add    esi, [ebp+arg_4]

; Складываем его с arg_4

mov    eax, [ebp+arg_8]

; Заносим в EAX указатель на строку

push   eax          ; const      char *

; Передаем его функции strlen через стек

call   _strlen

add    esp, 4

add    eax, esi

; Добавляем к сумме arg_0 и arg_4 длину строки arg_8

pop    esi

pop    ebp

retn

MyFunc       endp

Листинг 63

Прежде, чем перейти к вещам по настоящему серьезным, рассмотрим на закуску последний стандартный тип – PASCAL:

#include <stdio.h>



#include <string.h>

// Внимание! Microsoft Visual C++ уже не поддерживает тип вызова PASCAL

// вместо этого используйте аналогичный ему тип вызова WINAPI, определенный в файле

// <windows.h>.

#if defined(_MSC_VER)

#include <windows.h>

// включать windows.h только если мы компилируется Microsoft Visual C++

// для остальных компиляторов более эффективное решение – использование ключевого

// слова PASACAL, если они, конечно, его поддерживают. (Borland

поддерживает)

#endif

// Подобный примем программирования может и делает листинг менее читабельным,

// но зато позволяет компилировать его не только одним компилятором!

#if defined(_MSC_VER)

WINAPI

#else

__pascal

#endif

MyFunc(int a, int b, char *c)

{

return a+b+strlen(c);

}

main()

{

printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));

}

Листинг 64 Демонстрация вызова PASCAL

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

; int __cdecl main(int argc,const char **argv,const char *envp)

_main        proc near           ; DATA XREF: DATA:00407044o

push   ebp

mov    ebp, esp

push   666h         ; int

push   777h         ; int

push   offset aHelloWorld ; s

; Передаем функции аргументы. Заглянув в исходный текст, мы заметим, что

; аргументы передаются слева направо. Однако если исходных текстов нет,

; установить этот факт невозможно! К счастью, подлинный прототип функции

; не важен.

call   MyFunc

; Функция не вычищает за собой стек! Если это не результат оптимизации –

; ее тип вызова либо PASCAL, либо stdcall. Ввиду того, что PASACAL уже вышел

; из употребления, будем считать, что имеем дело с stdcall

push   eax

push   offset unk_407074 ; format

call   _printf

add    esp, 8

xor    eax, eax

pop    ebp

retn

_main        endp

; int __cdecl MyFunc(const char   *s,int,int)

; Ага! IDA вновь дала неправильный результат! Тип вызова явно не cdecl!



; Однако, в остальном прототип функции верен, вернее, не то что бы он верен

; (на самом деле порядок аргументов обратный), но для использования – пригоден

MyFunc       proc near           ; CODE XREF: _main+12p

s            = dword      ptr  8

arg_4        = dword      ptr  0Ch

arg_8        = dword      ptr  10h

push   ebp

mov    ebp, esp

; Открываем кадр стека

mov    eax, [ebp+s]

; Заносим в EAX указатель на строку

push   eax          ; s

call   _strlen

; Передаем его функции strlen

pop    ecx

; Очищаем стек от одного аргумента, выталкивая его в неиспользуемый регистр

mov    edx, [ebp+arg_8]

; Заносим в EDX аргумент arg_8 типа int

add    edx, [ebp+arg_4]

; Складываем его с аргументом arg_4

add    eax, edx

; Складываем сумму arg_8 и arg_4 с длиной строки

pop    ebp

retn   0Ch

; Стек чистит вызываемая функция. Значит, ее тип PASCAL

или stdcall

MyFunc       endp

Листинг 65

Как мы видим, идентификация базовых типов вызов и восстановление прототипов функции – занятие несложное. Единственное, что портит настроение – путаница с PASCAL и stdcall, но порядок занесения аргументов в стек не имеет никакого значения, разве что в особых случаях, один из которых перед вами:

#include <stdio.h>

#include <windows.h>

#include <winuser.h>

// CALLBACK процедура для приема сообщений от таймера

VOID CALLBACK TimerProc(

  HWND hwnd,     // handle of window for timer messages

  UINT uMsg,     // WM_TIMER message

  UINT idEvent,  // timer identifier

  DWORD dwTime   // current system time

)

{

// Бибикаем всеми пиками на все голоса

MessageBeep((dwTime % 5)*0x10);

// Выводим время в секундах, прошедшее с момента пуска системы

printf("\r:=%d",dwTime / 1000);

}

main()

// Да, это консольное приложение, но оно так же может иметь цикл выборки сообщений

// и устанавливать таймер!

{

int a;



MSG msg;

// Устанавливаем таймер, передавая ему адрес процедуры TimerProc

SetTimer(0,0,1000,TimerProc);

// Цикл выборки сообщений. Когда надоест – жмем Ctrl-Break и прерываем его

while (GetMessage(&msg, (HWND) NULL, 0, 0))

{

TranslateMessage(&msg);

DispatchMessage(&msg);

}

}

Листинг 66 Пример, демонстрирующий тот случай, когда требуется точно отличать PASCAL от stdcall

Откомпилируем этот пример так: "cl pascal.callback.c USER32.lib" и посмотрим, что из этого получилось:

main         proc near           ; CODE XREF: start+AFp

; На сей раз IDA не определила прототип функции. Ну и ладно...

Msg          = MSG ptr -20h

; IDA

распознала одну локальную переменную и даже восстановила ее тип, что радует

push   ebp

mov    ebp, esp

sub    esp, 20h

push   offset TimerProc ; lpTimerFunc

; Передаем указатель на функцию TimerProc

push   1000         ; uElapse

; Передаем время задержки таймера

push   0            ; nIDEvent

; В консольных приложениях аргумент nIDEvent

всегда игнорируется

push   0            ; hWnd

; Окон нет, передаем NULL

call   ds:SetTimer

; Win32 API

функции вызываются по соглашению stdcall

– это дает возможность,

; зная их прототип,(а он описан в SDK) восстановить тип и назначение аргументов

; в данном случае исходный текст выглядел так:

; SetTimer(NULL, BULL, 1000, TimerProc);

loc_401051:                       ; CODE XREF: main+42j

push   0            ; wMsgFilterMax

; NULL – нет

фильтра

push   0            ; wMsgFilterMin

; NULL – нет

фильтра

push   0            ; hWnd

; NULL

– нет окон в консольном приложении

lea    eax, [ebp+Msg]

; Получаем указатель на локальную переменную msg

-

; тип этой переменной определяется, кстати, только на основе прототипа

; функции GetMessageA

push   eax          ; lpMsg

; Передаем указатель на msg

call   ds:GetMessageA



; Вызываем

функцию GetMessageA(&msg, NULL, NULL, NULL);

test   eax, eax

jz     short loc_40107B

; Проверка на получение WM_QUIT

lea    ecx, [ebp+Msg]

; В ECX – указатель на заполненную структуру MSG…

push   ecx          ; lpMsg

; …передаем

его функции TranslateMessage

call   ds:TranslateMessage

; Вызываем

функцию TranslateMessage(&msg);

lea    edx, [ebp+Msg]

; В EDX – указатель на msg…

push   edx          ; lpMsg

; …передаем его функции DispatchMessageA

call   ds:DispatchMessageA

; Вызов

функции DispatchMessageA

jmp    short loc_401051

; Цикл

выборки сообщений

loc_40107B:                       ; CODE XREF: main+2Cj

; Выход

mov    esp, ebp

pop    ebp

retn

main         endp

TimerProc    proc near           ; DATA XREF: main+6o

; Прототип TimerProc в следствие ее неявного вызова операционной системой

; не был автоматически восстановлен IDA, - этим придется заниматься нам

; Мы знаем, что TimerProc передается функции SetTimer.

; Заглянув в описание SetTimer (SDK

всегда должен быть под рукой!) мы найдем

; ее

прототип:

;

;VOID CALLBACK TimerProc(

;  HWND hwnd,     // handle of window for timer messages

;  UINT uMsg,     // WM_TIMER message

;  UINT idEvent,  // timer identifier

;  DWORD dwTime   // current system time

;)

;

; Остается разобраться с типом вызова. На сей раз он приниципиален, т.к. не имеея

; кода вызывающей функции (он расположен глубоко в недрах операционной системы),

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

; порядок передачи.

; Выше уже говорилось, что все CALLBACK

функции следуют соглашению PASCAL.

; Не путайте CALLBACK-функции с Win32 API-функциями! Первые вызывает сама

; операционная система, а вторые – прикладная программа.

;

; ОК, тип вызова этой функции – PASCAL. Значит, аргументы заносятся слевно направо,

; а стек чистит вызываемая функция (убедитесь, что это действительно так).



arg_C        = dword      ptr  14h

; IDA

обнаружила только один аргумент, хотя, судя по прототипу, их передается четыре.

; Почему? Очень просто – функция использовала всего один аргумент, а к остальным и

; не обращалась. Вот IDA и не смогла их восстановить!

; Кстати, что это за аргумент? Смотрим: его смещение равно 0xC. А на вершине стека то,

; что в него заталкивалось в последнюю очередь. Внизу, соответственно, наоборот.

; Постой, постой, что за чертовщина?! Выходит, dwTime

был занесен в стек в первую

; очередь?! (Мы-то, имея исходный текст, знаем, что arg_C – наверняка dwTime).

; Но ведь соглашение PASCAL диктует противоположный порядок занесения аргументов!

; Что-то здесь не так... но ведь программа работает (запустите ее, чтобы проверить)

; А в SDK написано, что CALLBACK – аналог FAR PASACAL. С FAR-ом понятно, в Win9x\NT

; все вызовы ближние, но вот как объяснить инверсию засылки аргументов?!

; Сдаетесь?(Нет, не сдавайтесь, попытайтесь найти решение сами – иначе какой интерес?)

; Тогда загляните в <windef.h> и посмотрите, как там определен тип PASCAL

;

; #elif (_MSC_VER >= 800) || defined(_STDCALL_SUPPORTED)

; #define CALLBACK    __stdcall

; #define WINAPI      __stdcall

; #define WINAPIV     __cdecl

; #define APIENTRY    WINAPI

; #define APIPRIVATE  __stdcall

; #define PASCAL      __stdcall

;

; Нет, ну кто бы мог подумать!!! Вызов, объявленный как PASCAL, на самом деле

; представляет собой stdcall! И CALLBACK – так же определен, как stdcall.

; Наконец-то все объяснилось! Теперь, если вам скажут, что CALLBACK

– это PASCAL

; вы можете усмехнуться и сказать, что еж тоже птица, правда гордая – пока не пнешь

; не полетит! (Оказывается, копания в дебрях include-файлов могут приносить пользу)

; Кстати, это извращения с перекрытием типов создают большую проблему при подключении

; к Си-проекту модулей, написанных в среде, поддерживающей PASACAL-соглашения о вызове

; функций. Поскольку в Windows PASCAL никакой не PASCAL, а stdcall – ничего работать



; соответственно не будет! Правда, есть еще ключевое слово __pascal, которое не

; перекрывается, но и не поддерживается последними версиями Microsoft Visual C++.

; Выход состоит в использовании ассемблерных вставок или переходе на Borland C++

; он, как и многие другие компиляторы, соглашение PASACAL

до сих пор исправно

; поддерживает.

;

; Итак, мы выяснили, что аргументы CALLBACL-функциям передаются справа налево, но

; стек вычищает сама вызываемая функция, как и положено по stdcall

соглашению.

push   ebp

mov    ebp, esp

mov    eax, [ebp+arg_C]

; заносим в EAX аргумент dwTime.

; Как мы получили его? Смотрим – перед ним в стеке лежат три аргумента

; каждый из которых размеров в 4 байта, тогда 4*3=0xC

xor    edx, edx

; Обнуляем EDX

mov    ecx, 5

; Присваиваем ECX значение 5

div    ecx

; Делим dwTime (он в EAX) на 5

shl    edx, 4

; В EDX – остаток от деления, циклическим сдвигом умножаем его на 0x10

; точнее, умножаем его на 24

push   edx          ; uType

; Передаем полученный результат функции MessageBeep.

; Заглянув в SDK, мы найдем, что MessageBeep принимает одну из констант:

; NB_OK, MB_ICONASTERISK, MB_ICONHAND и т.д., но там ничего не сказано о том,

; какое непосредственное значение каждое из них принимает.

; Зато сообщается, что MessageBeep описана в файле <WINUSER.h>

; Открываем его и ищем контекстным поиском MB_OK:

;

; #define MB_OK                       0x00000000L

; #define MB_OKCANCEL                 0x00000001L

; #define MB_ABORTRETRYIGNORE         0x00000002L

; #define MB_YESNOCANCEL              0x00000003L

; #define MB_YESNO                    0x00000004L

; #define MB_RETRYCANCEL              0x00000005L

;

; #define MB_ICONHAND                 0x00000010L

; #define MB_ICONQUESTION             0x00000020L

; #define MB_ICONEXCLAMATION          0x00000030L

; #define MB_ICONASTERISK             0x00000040L

;

; Есть хвост у Тигры! Смотрите: все, интересующее нас константы, равны:



; 0x0, 0x10, 0x20, 0x30, 0x40. Теперь становится понятным смысл программы

; Взяв остаток, полученный делением количества миллисекунд, прошедших с минуты

; включения системы на 5, мы получаем число в интервале от 0 до 4. Умножая его

; на 0x10, - 0x0, 0x0x10 – 0x40.

call   ds:MessageBeep

; Бибикаем на все лады

mov    eax, [ebp+arg_C]

; Заносим в EAX dwTime

xor    edx, edx

; Обнуляем EDX

mov    ecx, 3E8h

; В десятичном 0x3E8 равно 1000

div    ecx

; Делим dwTime на 1000 – т.е. переводим миллисекунды в секунды и…

push   eax

; …передаем его функции printf

push   offset aD    ; "\r:=%d"

call   _printf

add    esp, 8

; printf("\r:=%d")

pop    ebp

retn   10h

; Выходя – гасите свет, т.е. чистите за собой стек!

TimerProc    endp

Листинг 67

Важное замечание о типах, определенных в <WINDOWS.H>! Хотя об этом уже говорилось в комментариях к предыдущему листингу, повторение не будет лишним, хотя бы уже потому, что не все читатели вчитываются в разборы дизассемблерных текстов.

Итак, CALLBACK и WINAPI функции следуют соглашению о вызовах PASCAL, но сам PASACAL определен в <WINDEF.H> как stdcall (а на некоторых платформах и как cdecl). Таким образом, на платформе INTEL все Windows-функции следуют соглашению: аргументы заносятся справа налево, а стек вычищает вызываемая функция.

Давайте для знакомства в PASCAL-соглашением создадим простенькую PASCAL программу и дизассемблируем ее (это, не обозначает, что PASCAL-вызовы встречаются только в PASCAL-программах, но так будет справедливо):

USES WINCRT;

Procedure MyProc(a:Word; b:Byte; c:String);

begin

WriteLn(a+b,' ',c);

end;

BEGIN

MyProc($666,$77,'Hello,Sailor!');

END.

Листинг 68 Демонстрация PASCAL-вызова

Результат компиляции компилятором "Turbo Pascal for Windows" должен выглядеть так:

PROGRAM             proc near

call   INITTASK

; Вызов INITTASK из KRNL386.EXE для инициализации 16-разрядной задачи



call   @__SystemInit$qv ; __SystemInit(void)

; Инициализация модуля SYSTEM

call   @__WINCRTInit$qv

; __WINCRTInit(void)

; Инициализация модуля WinCRT

push   bp

mov    bp, sp

; Пролог функции в середине функции!

; Вот такой он, Turbo-PASCAL!

xor    ax, ax

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Проверка стека на переполнение

push   666h

; Обратите внимание – передача аргументов идет слева направо

push   77h ; 'w'

mov    di, offset aHelloSailor    ; "Hello,Sailor!"

; В DI – указатель на строку "Hello, Sailor"

push   ds

push   di

; Смотрите: передается не ближний (NEAR), а дальний (FAR) указатель –

; т.е. и сегмент, и смещение строки.

call   MyProc

; Стек чистит вызываемая функция.

leave

; Эпилог функции – закрытие кадра стека.

xor    ax, ax

call   @Halt$q4Word ; Halt(Word)

; Конец программы!

PROGRAM             endp

MyProc       proc near           ; CODE XREF: PROGRAM+23p

; IDA

не определила прототип функции. Что ж, сделаем это сами!

var_100             = byte ptr -100h

; Локальная переменная. Судя по тому, что она находится на 0x100 байт выше кадра

; стека, сдается, что это массив их 0x100 байт. Поскольку, максимальная длина строки

; в PASACAL как раз и равна 0xFF байтам. Похоже, это буфер, зарезервированный под

; строку.

arg_0        = dword      ptr  4

arg_4        = byte ptr  8

arg_6        = word ptr  0Ah

; Функция принимает три аргумента

push   bp

mov    bp, sp

; Открываем кадр стека

mov    ax, 100h

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Проверяем – если ли в стеке необходимые нам 100 байт для локальных переменных

sub    sp, 100h

; Резервируем пространство под локальные переменные

les    di, [bp+arg_0]

; получаем указатель на самый правый аргумент

push   es

push   di

; Смотрите – передаем дальний указатель на аргумент arg_0, причем его



; сегмент из стека даже не извлекался!

lea    di, [bp+var_100]

; Получаем указатель на локальный буфер

push   ss

; Заносим его сегмент в стек

push   di

; Заносим смещение буфера в стек

push   0FFh

; Заносим макс. длину строки

call   @$basg$qm6Stringt14Byte    ; Store      string

; Копируем строку в локальный буфер (значит, arg_0 – это строка).

; Правда, совершенно непонятно зачем. Неужто нельзя пользоваться ссылкой?

; Дурной-дурной этот Turbo-Pascal!

; Да что делать – в самом Паскале строки передаются по значению :-(

mov    di, offset unk_1E18

; Получаем указатель на буфер вывода

; Тут надобно познакомимся с системой вывода Паскаля – она весьма разительно

; отличается от Си.

; Во-первых, левосторонний порядок засылки аргументов в стек не позволяет

; организовать поддержку процедур с переменным числом аргументов

; (во всяком случае, без дополнительных ухищрений)

; Но ведь WriteLn и есть процедура с переменным числом параметров. Разве нет?!

; Вот именно, что нет!!! Никакая это не процедура, а оператор!

; Компилятор еще на стадии компиляции разбивает ее на множество вызовов

; процедур для вывода каждого аргумента по отдельности. Поэтому,

; в откомпилированном коде каждая процедура примет фиксированное количество

; аргументов. В нашем случае их будет три: первая для вывода суммы двух

; чисел – этим занимается процедура WriteLongint, вторая – для вывода символа

; пробела в символьной форме – этим занимается WriteChar

и, наконец, последняя

; для вывода строки – WriteSting

; Размышляем далее – под Windows непосредственно вывести строку в окно и тут же

; забыть о ней нельзя, т.к. окно в любой момент может потребовать перерисовки –

; операционная система не сохраняет его содержимого – в графической среде

; при высоком разрешении это привело бы к большим затратам памяти.

; Код, выводящий строку, должен уметь повторять свой вывод по запросу.

; Каждый, кто хоть раз программировал под Windows, наверняка помнит, что весь



; вывод приходилось помещать в обработчик сообщения WM_PAINT.

; Turbo Pascal

же позволяет обращаться к Windows-окном точно так,

; как с консолью. А раз так – он должен где-то хранить все, ранее выведенное

; на экран. Поскольку, локальный переменные умирают вместе с завершением

; их процедуры, то для хранения буфера они не годятся. Остается либо куча, либо

; сегмент данных. Pascal использует последнее – указатель на такой буфер мы

; только что получили.

; Далее, для повышения производительности вывода Turbo-Pascal реализует

; простейший

кэш. Функции WriteLingint, WriteChar, WriteString сливают

; результат своей деятельности в символьном виде в этом самый буфер, а в конце

; следует вызов WriteLn, выводящий содержимое буфера в окно.

; Run-time systems следит за его перерисовками и при необходимости повторяет

; вывод уже без участия программиста.

push   ds

push   di

; Заносим адрес буфера в стек

mov    al, [bp+arg_4]

; Тип аргумента arg_4 - Byte

xor    ah, ah

; Обнуляем старший байт регистра ah

add    ax, [bp+arg_6]

; Складываем arg_4 с arg_6. Поскольку, al было предварительно расширено до AX

; то arg_6 имеет тип Word, т.к. при сложении двух чисел разного типа PASCAL

; расширяет их до большего из них.

; Кроме того, вызывающая процедура передает с этим аргументом значение 0x666,

; что явно не влезло бы в Byte.

xor    dx, dx

; Обнуляем DX…

push   dx

; …и заносим его в стек.

push   ax

; Заносим в стек сумму двух левых аргументов

push   0

; Еще один ноль!

call   @Write$qm4Text7Longint4Word ; Write(var f; v: Longint; width: Word)

; Функция WriteLongint имеет следующий прототип

; WriteLongint(Text far &, a: Longint, count:Word); где -

; Text far & - указатель на буфер вывода

; a          - выводимое длинное целое

; count             - сколько переменных выводить (ноль – одна переменная)

;

; Значит, в нашем случае мы выводим одну переменную – сумму двух аргументов.



; Маленькое дополнение – функция WriteLongint

не следует соглашению PASCAL

; т.к. не до конца чистит за собой стек, оставляя указать на буфер в стеке.

; На этот шаг разработчики компилятора пошли для увеличения производительности:

; раз указатель на буфер будет нужен и другим функциям

;(по крайней мере одной из них – WriteLn), зачем его то стягивать, то опять

; лихорадочно запихивать?

; Если вы загляните в конец функции WriteLongint, вы обнаружите там RET

6,

; т.е. функция выпихивает два аргумента – два машинных слова на Longint

и один

; Word на count.

; Вот такая милая маленькая техническая деталь. Маленькая-то она, маленькая,

; но как сбивает с толку!

; (особенно, если исследователь не знаком с системой ввода-вывода Паскаля)

push   20h ; ' '

; Заносим в стек следующий аргумент, передаваемый функции WriteLn

; (указатель на буфер все еще находится в стеке).

push   0

; Нам надо вывести только одни символ

call   @Write$qm4Text4Char4Word ; Write(var f;c: Char; width:Word)

lea    di, [bp+var_100]

; Получаем указатель на локальную копию переданной функции строки

push   ss

push   di

; Заносим ее адрес в стек

push   0

; Выводить только одну строку!

call   @Write$qm4Textm6String4Word ; Write(var f; s: String; width: Word)

call   @WriteLn$qm4Text ; WriteLn(var f: Text)

; Кажется, функции не передаются никакие параметры, но на самом деле на вершине

; стека лежит указатель на буфер и ждет своего "звездного часа"

; после завершения WriteLn он будет снят со стека

call   @__IOCheck$qv ; Exit if error

; Проверка операции вывода на успешность

leave

; Закрываем кадр стека

retn   8

; Выталкиваем восемь байт со стека. ОК, теперь мы знаем все необходимое для

; восстановления прототипа нашей процедуры. Он выглядит так:

; MyProc(a:Byte, b:Word, c:String);

MyProc       endp

Листинг 69

Да, хитрым оказался Turbo-PASCAL! Анализ откомпилированной с его помощью программы преподнес нам один очень важный урок – никогда нельзя быть уверенным, что функция выталкивает все переданные ей аргументы из стека, и уж тем более нельзя определять количество аргументов по числу снимаемых из стека машинных слов!



::соглашения о быстрых вызовах – fastcall. Какой бы непроизводительной передача аргументов через стек ни была, а типы вызовы stdcall и cdecl стандартизированы и хочешь – не хочешь, а их надо соблюдать. Иначе, модули, скомпилированные один компилятором (например, библиотеки), окажутся не совместимы с модулями, скомпилированными другими компиляторами. Впрочем, если вызываемая функция компилируется тем же самым компилятором, что и вызывающая, - придерживаться типовых соглашений ни к чему и можно воспользоваться более эффективной передачей аргументов через регистры.

Многие начинающие программисты удивляются: а почему передача аргументов через регистры до сих пор не стандартизирована и вряд ли когда будет стандартизирована вообще? Ответ: кем бы она могла быть стандартизирована? Комитетами по стандартизации Си и Си++? Нет, конечно! – все платформенно – зависимые решения оставляются на откуп разработчикам компиляторов – каждый из них волен реализовывать их по-своему или не реализовывать вообще. "Хорошо, уговорили", - не согласится иной читатель, "но что мешает разработчикам компиляторов одной конкретной платформы договориться об общих соглашениях. Ведь договорились же они передавать возвращенное функцией значение через [E]AX:[[E]DX], хотя стандарт о конкретных регистрах вообще никакого понятия не имеет".

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



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

Существует такой термин как "единица трансляции", - в зависимости от реализации компилятор может либо транслировать весь текст программы целиком (что весьма накладно, т.к. придется хранить в памяти все дерево синтаксического разбора), либо транслировать каждую функцию по отдельности, сохраняя в памяти лишь ее имя и ссылку на сгенерированный для нее код. Компиляторы первого типа крайне редки, во всяком случае для ОС Windows я не встречал ни одного такого Си\Cи++ компилятора (хотя и слышал о таких). Компиляторы второго типа более производительны, требуют меньше памяти, проще в реализации, словом, всем хороши, за исключением органической неспособности к "сквозной" оптимизации, - каждая функция оптимизируется "персонально" и независимо от другой. Поэтому, подобрать оптимальные регистры для передачи аргументов компилятор не может, поскольку он не знает, как с ними манипулирует вызываемая функция. Поскольку, функции транслируются независимо, им приходится придерживаться общих соглашений, даже если это и невыгодно.

Таким образом, зная "почерк" конкретного компилятора, восстановить прототип функции можно без труда.

::Borland C++ 3.x –  передача аргументов осуществляется через регистры: AX (AL), DX (DL), BX (BL), а, когда регистры кончаются, аргументы начинают засылаться в стек, заносясь в него слева направо и выталкиваясь самой вызываемой функцией (a la stdcall).

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


Каждый аргумент снимает со стопки столько регистров, сколько ему нужно, а когда стопка исчерпается – тогда придется отправляться в стек. Исключение составляет тип long int, всегда передаваемый через DX:AX (причем, в DX передается старшее слово), а если это невозможно – то через стек.

Если каждый аргумент занимает не более 16-ти бит (как обычно и происходит), то первый слева аргумент помещается в AX (AL), второй – в DX (DL), третий – в BX (BL). Если же первый слева аргумент представляет тип long int, он снимает со стопки сразу два регистра – DX:AX, тогда второму аргументу остается регистр BX (BL), а третьему – и вовсе ничего (и тогда он передается через стек). Когда же long int передается вторым аргументом, он отправляется в стек, т.к. необходимый ему регистр AX уже занят первым аргументом, третий же аргумент передается через DX. Наконец, будучи третьим слева аргументом, long int идет в стек, а первые два аргумента передаются через AX (AL) и DX (DL) соответственно.

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

тип

предпочтения







char

AL

DL

BL

int

AX

DX

BX

long int

DX:AX

ближний указатель

AX

DX

BX

дальний указатель

stack

float

stack

double

stack

Таблица 2 Порядок предпочтений Borland C++ 3.x при передаче аргументов по соглашению fastcall

::Microsoft C++ 6.0 – ведет себя аналогично компилятору Borland C++ 3.x за исключением того, что изменяет порядок предпочтений кандидатов для передачи указателей, выдвигая на первое место BX. И это – правильно, ибо ранние микропроцессоры 80x86 не поддерживали косвенную адресацию ни через AX, ни через DX и переданное функции значение все равно приходилось перепихивать либо в BX, либо в SI или DI.



тип

предпочтения







char

AL

DL

BL

int

AX

DX

BX

long int

DX:AX

ближний указатель

BX

AX

DX

дальний указатель

stack

float

stack

double

stack

Таблица 3 Порядок предпочтений Microsoft C++ 6. x при передаче аргументов по соглашению fastcall

::Borland C++ 5.x – очень похож на своего предшественника – компилятор Borland C++ 3.x, за исключением того, что вместо регистра BX отдает предпочтение регистру CX, и аргументы типа int и long int помещает в любой из подходящих 32-разрядных регистров, а не DX:AX. Как, впрочем, и следовало ожидать при переводе компилятора с 16- на 32-разрядный режим.

тип

предпочтения







char

AL

DL

CL

int

EAX

EDX

ECX

long int

EAX

EDX

ECX

ближний указатель

EAX

EDX

ECX

дальний указатель

stack

float

stack

double

stack

Таблица 4 Порядок предпочтений Borland C++ 5.x при передаче аргументов по соглашению fastcall

::Microsoft Visual C++ 4.x – 6.x: при возможности передает первый слева аргумент в регистре ECX, второй – в регистре EDX, а все остальные через стек. Вещественные значения и дальние указатели всегда передаются через стек. Аргумент типа __int64 (нестандартный тип, 64-разрядное целое, введенный Microsoft) всегда передается через стек.

Если __int64 – первый слева аргумент, то второй аргумент передается через ECX, а третий – через EDX. Соответственно, если __int64 – второй аргумент, то первый передается через ECX, а третий – через EDX.

тип

предпочтения







char

CL

DL

--

int

ECX

EDX

--

__int64

stack

long int

ECX

--

ближний указатель

ECX

EDX

--

дальний указатель

stack

--

float

stack

--

double

stack

--

<


Таблица 5 Порядок предпочтений Microsoft Visual C++ 4.x – 6.x при передаче аргументов по соглашению fastcall

::WATCOM C. Компилятор от WATCOM сильно отличается от компиляторов от Borland и Microsoft. В частности, он не поддерживает ключевого слова fastcall (что, кстати, приводит к серьезным проблемам совместимости), но по умолчанию всегда стремиться передавать аргументы через регистры. Вместо общепринятой "стопки предпочтений" WATCOM жестко закрепляет за каждым аргументом свой регистр: за первым - EAX, за вторым - EDX, за третьим -EBX, за четвертым – ECX, причем, если какой-то аргумент в указанный регистр поместить не удается, он и все остальные аргументы, находящиеся правее него, помещаются в стек! В частности, типы float и double по умолчанию помещаются в стек основного процессора, что "портит всю малину"!

тип

аргумент









char

AL

DL

BL

CL

int

EAX

EDX

EBX

ECX

long int

EAX

EDX

EBX

ECX

ближний указатель

ECX

EDX

EBX

ECX

дальний указатель

stack

stack

stack

stack

float

stack CPU

stack CPU

stack CPU

stack CPU

stack FPU

stack FPU

stack FPU

stack FPU

double

stack CPU

stack CPU

stack CPU

stack CPU

stack FPU

stack FPU

stack FPU

stack FPU

Таблица 6 Схема передачи аргументов компилятором WATCOM по умолчанию

При желании программист может "вручную" задать собственный порядок передачи аргументом, прибегнув к прагме aux, имеющий следующий формат: "#pragma aux имя функции parm [перечь регистров]". Список допустимых регистров для каждого типа аргументов приведен в следующей таблице:

тип

допустимые регистры

char

EAX

EBX

ECX

EDX

ESI

EDI

int

EAX

EBX

ECX

EDX

ESI

EDI

long int

EAX

EBX

ECX

EDX

ESI

EDI

ближний указатель

EAX

EBX

ECX

EDX

ESI

EDI

дальний указатель

DX:EAX

CX:EBX

CX:EAX

CX:ESI

DX:EBX

DI:EAX

CX:EDI

DX:ESI

DI:EBX

SI:EAX

CX:EDX

DX:EDI

DI:ESI

SI:EBX

BX:EAX

FS:ECX

FS:EDX

FS:EDI

FS:ESI

FS:EBX

FS:EAX

GS:ECX

GS:EDX

GS:EDI

GS:ESI

GS:EBX

GS:EAX

DS:ECX

DS:EDX

DS:EDI

DS:ESI

DS:EBX

DS:EAX

ES:ECX

ES:EDX

ES:EDI

ES:ESI

ES:EBX

ES:EAX

float

8087

???

???

???

???

???

double

8087

EDX:EAX

ECX:EBX

ECX:EAX

ECX:ESI

EDX:EBX

EDI:EAX

ECX:EDI

EDX:ESI

EDI:EBX

ESI:EAX

ECX:EDX

EDX:EDI

EDI:ESI

ESI:EBX

EBX:EAX

<


Таблица 7 Допустимые регистры для передачи различных типов аргументов в WATCOM C

Несколько пояснений – во-первых, аргументы типа char передаются не в 8-, а в 32- разрядных регистрах, во-вторых, бросается в глаза неожиданно больше число возможных пар регистров для передачи дальнего указателя, причем сегмент может передаваться не только в сегментных регистрах, но и 16-разрядных регистрах общего назначения.

Вещественные аргументы могут передаваться через стек сопроцессора – для этого достаточно лишь указать '8087' вместо названия регистра и обязательно скомпилировать программу с ключом –7 (или –fpi, -fpu87), показывая компилятору, что инструкции сопроцессора разрешены. В документации по WATCOM сообщается, что аргументы типа double могут так же передаваться и через пары 32-разрядных регистров общего назначения, но мне, увы, не удалось заставить компилятор генерировать такой код. Может быть, я плохо знаю WATCOM или глюк какой. Так же, мне не встречалось ни одной программы, в которой вещественные значения передавались бы через регистры общего назначения. Впрочем, это уже никому не нужные тонкости. (Подробнее о передаче вещественных аргументов рассказывается в одноименном разделе данной главы).

Таким образом, при исследовании программ, откомпилированных компилятором WATCOM, необходимо быть готовыми к тому, что аргументы могут передаваться практически в любых регистрах, какие заблагорассудится программисту.

::идентификация передачи и приема регистров. Поскольку, вызываемая и вызывающая функция вынуждены придерживаться общих соглашений при передаче аргументов через регистры, компилятору приходится помещать аргументы в те регистры, в каких их ожидает вызываемая функция, а не в какие ему "удобно". В результате, перед каждой функций, следующей соглашению fastcall, появляется код, "тасующий" содержимое регистров строго определенным образом. Каким – это уже зависит от конкретного компилятора. Наиболее популярные схемы передачи аргументов уже были рассмотрены выше, не будем здесь возвращаться к этому вопросу.


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

Анализом кода вызывающей функции не всегда можно распознать передачу аргументов через регистры (ну, разве что их инициализация будет слишком наглядна), поэтому, приходится обращаться непосредственно к вызываемой функции. Регистры, сохраняемые в стеке сразу после получения управления функцией, в подавляющем большинстве случаев не являются регистрами, передающими аргументы и из списка "кандидатов" их можно вычеркнуть. Среди оставшихся смотрим – есть ли такие, содержимое которых используется без явной инициализации. В первом приближении через эти регистры функция и принимает аргументы. При детальном же рассмотрении проблемы всплывает несколько оговорок. Во-первых, через регистры могут передаваться (и очень часто передаются) неявные аргументы функции – указатель this, указатели на виртуальные таблицы объекта и т.д. Во-вторых, если криворукий программист, надеясь, что значение переменной после объявления должно быть равно нулю, забывает об инициализации, а компилятор помещает ее в регистр, то при анализе программы она может быть принята за аргумент функции, передаваемый через регистр. Самое интересное: что этот регистр может по случайному стечению обстоятельств явно инициализироваться вызывающей функций. Пусть, например, программист перед этим вызывал некоторую функцию, возвращаемого значения которой (помещаемого компилятором в EAX) не использовал, а компилятор поместил неинициализированную переменную в EAX.


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

Впрочем, если откинуть "клинические" случаи, в передаче аргументов через регистры не сильно усложняет анализ, в чем мы сейчас и убедимся.

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

#include <stdio.h>

#include <string>

#if defined(__BORLANDC__) || defined (_MSC_VER)

// Эта ветка компилируется только компиляторами Borland C++ и Microsoft C++,

// поддерживающими ключевое слово fastcall

__fastcall

#endif

// Функция MyFunc с различными типами аргументов для демонстрации механизма

// их

передачи

MyFunc(char a, int b, long int c, int d)

{

#if defined(__WATCOMC__)

// А эта ветка специально предназначена для WATCOM C.

// прагма aux принудительно задает порядок передачи аргументов через

// следующие регистры: EAX ESI EDI EBX

#pragma aux MyFunc parm [EAX] [ESI] [EDI] [EBX];

#endif

return a+b+c+d;

}

main()

{

printf("%x\n",MyFunc(0x1,0x2,0x3,0x4));

return 0;

}

Листинг 70

Результат компиляции этого примера компилятором Microsoft Visual C++ 6.0 должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

push   ebp

mov    ebp, esp

push   4

push   3

; аргументы, которым не хватило регистров, передаются через стек, заносясь

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

; (т.е. все происходит как по stdcall

соглашению)

mov    edx, 2

; Через EDX передается второй слева аргумент.

; Легко определить его тип – это int.



; Т.е. это явно не char, но и не указатель (2-странное значение для указателя)

mov    cl, 1

; Через cl передается первый слева аргумент типа char

;(лишь у переменных типа char размер 8 бит)

;

call   MyFunc

; Уже можно восстановить прототип функции MyFunc(char, int, int, int)

; Да, мы ошиблись и тип long int приняли за int, но, поскольку в компиляторе

; Microsoft Visual C++ эти типы идентичны, такой ошибкой можно пренебречь

push   eax

; Передаем полученный результат функции printf

push   offset asc_406030 ; "%x\n"

call   _printf

add    esp, 8

xor    eax, eax

pop    ebp

retn

main         endp

MyFunc       proc near           ; CODE XREF: main+Ep

var_8        = dword      ptr -8

var_4        = byte ptr –4

arg_0        = dword      ptr  8

arg_4        = dword      ptr  0Ch

; Через стек функции передавались лишь два аргумента и их успешно распознала IDA

push   ebp

mov    ebp, esp

sub    esp, 8

; Резервируем 8 байт для локальных переменных

mov    [ebp+var_8], edx

; Регистр EDX не был явно инициализирован до того загрузки в

; локальную переменную var_8. Значит, он используется для передачи аргументов!

; Поскольку эта программа была скомпилирована компилятором Microsoft Visual C,

; а он, как известно, передает аргументы в регистрах ECX:EDX можно сделать

; вывод, что мы имеем дело со вторым, считая слева, аргументом функции

; и где-то ниже по тексту нам должно встретиться обращение к ECX

– первому

; слева аргументу функции.

; (хотя не обязательно – первый аргумент функцией может и не использоваться)

mov    [ebp+var_4], cl

; Действительно, обращение к CL не заставило должно себя ждать. Поскольку,

; через CL передается тип char, то, вероятно, первый аргумент функции – char.

; Некоторая неуверенность вызвана тем, что функция может просто обращаться

; к младшему байту аргумента типа int, скажем.

; Однако, посмотрев на код вызывающей функции, мы можем убедиться, что



; функции передается именно char, а не int.

; Попутно отметим глупость компилятора – стоило ли передавать аргументы через

; регистры, чтобы тут же заслать их в локальные переменные!

; Ведь обращение к памяти сжирает всю выгоду от быстрого вызова!

; Такой "быстрый" вызов быстрым даже язык не поворачивается назвать.

movsx  eax, [ebp+var_4]

; В EAX загружается первый слева аргумент, переданный через CL, типа char

; со знаковым расширением до двойного слова. Значит, это signed char

; (т.е. char по умолчанию для Microsoft Visual C++)

add    eax, [ebp+var_8]

; Складываем EAX со вторым слева аргументом

add    eax, [ebp+arg_0]

; Складываем результат предыдущего сложения с третьим слева аргументом,

; переданным через стек…

add    eax, [ebp+arg_4]

; …и все это складываем с четвертым аргументом, так же переданным через стек.

mov    esp, ebp

pop    ebp

; Закрываем кадр стека

retn   8

; Чистим за собой стек, как и положено по fastcall

соглашению

MyFunc       endp

Листинг 71

А теперь сравним это с результатом компиляции Borland C++:

; int __cdecl main(int argc,const char **argv,const char *envp)

_main        proc near           ; DATA XREF: DATA:00407044o

argc         = dword      ptr  8

argv         = dword      ptr  0Ch

envp          = dword      ptr  10h

push   ebp

mov    ebp, esp

push   4

; Передаем аргумент через стек. Скосив глаза вниз, мы обнаруживаем явную

; инициализацию регистров ECX, EDX, AL. Для четвертого аргумента регистров

; не хватило и его пришлось передавать через стек. Значит, четвертый слева

; аргумент функции – 0x4

mov    ecx, 3

mov    edx, 2

mov    al,  1

; Этот код не может быть ничем иным, как передачей аргументов через регистры

call   MyFunc

push   eax

push   offset unk_407074 ; format

call   _printf

add    esp, 8

xor    eax, eax

pop    ebp

retn

_main        endp



MyFunc       proc near           ; CODE XREF: _main+11p

arg_0        = dword      ptr  8

; через стек функции передавался лишь один аргумент

push   ebp

mov    ebp, esp

; Открываем кадр стека

movsx  eax, al

; Borland

сгенерировал более оптимальный код, чем Microsoft, не помещая

; регистр в локальную переменную и экономя тем самым память. Впрочем, если бы

; был задан соответствующий ключ оптимизации, Microsoft Visual C++ поступил

; точно так же.

; Обратите внимание еще и на то, что Borland

обрабатывает аргументы

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

; в то время как Microsoft Visual C++ поступает наоборот.

add    edx, eax

add    ecx, edx

; Регистры EDX и CX не были инициализированы, значит, в них функции были

; переданы аргументы.

mov    edx, [ebp+arg_0]

; Загружаем в EDX последний аргумент функции, переданный через стек…

add    ecx, edx

; …складываем еще раз

mov    eax, ecx

; Передаем в EAX (в EAX функция возвращает результат своего завершения)

pop    ebp

retn   4

; Вычищаем за собой стек

MyFunc        endp

Листинг 72

Наконец, результат компиляции WATCOM C должен выглядеть так:

main_        proc near           ; CODE XREF: __CMain+40p

push   18h

call   __CHK

; Проверка стека на переполнение

push   ebx

push   esi

push   edi

; Сохраняем регистры в стеке

mov    ebx, 4

mov    edi, 3

mov    esi, 2

mov    eax, 1

; Смотрите, аргументы передаются через те аргументы, которые мы указали!

; Более того, отметьте, что первый аргумент типа char

передается через

; 32-разрядный регистр EAX! Такое поведение WATCOM-а чрезвычайно

; затрудняет восстановление прототипов функций! В данном случае присвоение

; регистрам значений происходит согласно порядку объявления аргументов

; в прототипе функции, считая справа. Но так, увы, бывает далеко не всегда.

call   MyFunc

push   eax



push   offset unk_420004

call   printf_

add    esp, 8

xor    eax, eax

pop    edi

pop    esi

pop    ebx

retn

main_        endp

MyFunc       proc near           ; CODE XREF: main_+21p

; Функция не принимает через стек ни одного аргумента

push   4

call   __CHK

and    eax, 0FFh

; Обнуление старших двадцати четырех бит вкупе с обращением к регистру

; до его инициализации наводит на мысль, что через EAX

передается тип char

; какой это аргумент мы сказать не можем, увы...

add    esi, eax

; Регистр ESI не был инициализирован нашей функцией, следовательно, через

; него передается аргумент типа int. Можно предположить, что это – второй

; слева аргумент в прототипе функции, т.к. (если ничто не препятствует),

; регистры в вызывающей функции инициализируются согласно их порядку

; перечисления в прототипе, считая справа, а выражения вычисляются

; слева направо.

; Разумеется, подлинный порядок следования аргументов некритичен, но

; все-таки приятно, если удается его восстановить

lea    eax, [esi+edi]

; Опаньки, выдерем Тигре хвост с корнем! Вы думаете, что в EAX

загружается

; указатель? А ESI и EDI переданные функции – так же указатели? EAX

с его

; типом char становится очень похожим на индекс...

; Увы! Компилятор WATCOM слишком хитер и при анализе программ,

; скомпилированных с его помощью, очень легко впасть в грубые ошибки.

; Да, EAX это указатель, в том смысле, что LEA

используется для вычисления

; суммы ESI и EDI, но обращения к памяти по этому указателю не происходит

; ни в вызывающей, ни в вызываемой функции. Следовательно, аргументы функции

; не указатели, а константы!

add    eax, ebx

; Аналогично – EDX содержит в себе аргумент, переданный функции.

; Итак, прототип функции должен быть выглядеть так:

; MyFunc(char a, int b, int c, int d)

; Однако, порядок следования аргументов может быть и иным...

retn

MyFunc       endp

Листинг 73

Как мы видим, в передаче аргументов через регистры ничего особенного сложно нет, можно даже восстановить подлинный прототип вызываемой функции.


Однако ситуация, рассмотренная выше, достаточно идеализирована, и в реальных программах передача одних лишь непосредственных значений встречается редко. Давайте же теперь, освоившись с быстрыми вызовами, дизассемблируем более трудный пример:

#if defined(__BORLANDC__) || defined (_MSC_VER)

__fastcall

#endif

MyFunc(char a, int *b, int c)

{

#if defined(__WATCOMC__)

#pragma aux MyFunc parm [EAX] [EBX] [ECX];

#endif

return a+b[0]+c;

}

main()

{

int a=2;

printf("%x\n",MyFunc(strlen("1"),&a,strlen("333")));

}

Листинг 74 Трудный пример с fastcall

Результат компиляции Microsoft Visual C++ должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

var_4        = dword      ptr -4

push   ebp

mov    ebp, esp

; Открываем кадр стека

push   ecx

push   esi

; Сохраняем регистры в стеке

mov    [ebp+var_4], 2

; Присваиваем локальной переменной var_4 типа int

значение 2.

; Тип определяется на основе того, что переменная занимает 4 байта

; (подробнее см. "Идентификация локальных стековых переменных")

push   offset a333  ; const      char *

; Передаем функции strlen указатель на строку "333".

; Аргументы функции MyFunc как и положено передаются справа налево

call   _strlen

add    esp, 4

push   eax

; Здесь – либо мы сохраняем возвращенное функцией значение в стеке,

; либо передаем его следующей функции.

lea    esi, [ebp+var_4]

; В ESI заносим указатель на локальную переменную var_4

push   offset a1    ; const      char *

; Передаем функции strlen указатель на строку "1"

call   _strlen

add    esp, 4

mov    cl, al

; Возвращенное значение копируется в регистр CL, а ниже инициализируется EDX.

; Поскольку, ECX:EDX

используются для передачи аргументов fastcall-функциям,

; инициализация этих двух регистров перед вызовом функции явно не случайна!

; Можно предположить, что через CL



передается крайний левый аргумент типа char

mov    edx, esi

; В ESI содержится указатель на var_4, следовательно, второй аргумент функции,

; типа int, заносимый в EDX, передается по ссылке.

call   MyFunc

; Предварительный прототип функции выглядит так:

; MyFunc(char *a, int *b, inc c)

; Откуда взялся аргумент с? А помните, выше в стек был затолкнут EAX

и

; ни до вызова функции, ни после так и не вытолкнут? Впрочем, чтобы

; убедится в этом окончательно, требуется посмотреть сколько байт со стека

; снимает вызываемая функция

; Обратите так же внимание и на то, что значения, возвращенные функцией strlen,

; не заносилось в локальные переменные, а передавались непосредственно MyFunc.

; Это наводит на мысль, что исходный код программы выглядел так:

; MyFunc(strlen("1"),&var_4,strlen("333"));

; Хотя, впрочем, не факт, - компилятор мог оптимизировать код, выкинув

; локальную переменную, если она нигде более не используется. Однако,

; во-первых, судя по коду вызываемой функции компилятор работает без

; оптимизации, а во-вторых, если значения, возвращенные функциями strlen,

; используются один единственный раз в качестве аргумента MyFunc, то помещение

; их в локальную переменную – большая глупость, только затуманивающая суть

; программы. Тем более, что исследователю важно не восстановить подлинный

; исходный код, а понять его алгоритм.

push   eax

push   offset asc_406038 ; "%x\n"

call   _printf

add    esp, 8

pop    esi

mov    esp, ebp

pop    ebp

; Закрываем кадр стека

retn

main         endp

MyFunc       proc near           ; CODE XREF: main+2Ep

var_8        = dword      ptr -8

var_4        = byte ptr -4

arg_0        = dword      ptr  8

; Функция принимает один аргумент – значит, это и есть тот EAX, занесенный в стек

push   ebp

mov    ebp, esp

; Открываем кадр стека

sub    esp, 8

; Резервируем восемь байт под локальные переменные



mov    [ebp+var_8], edx

; Поскольку, EDX используется без явной инициализации, очевидно,

; через него передается второй слева аргумент функции.

; (согласно соглашению fastcall компилятора Microsoft Visual C++)

; Из анализа кода вызывающей функции мы уже знаем,

; что в EDX помещается указатель на var_4, следовательно,

; var_8 теперь содержит указатель на var_4.

mov    [ebp+var_4], cl

; Через CL передается самый левый аргумент функции типа char

и тут же

; заносится в локальную переменную var_4.

movsx  eax, [ebp+var_4]

; Переменная var_4 расширяется до signed int.

mov    ecx, [ebp+var_8]

; В регистр ECX загружается содержимое указателя var_8, переданного через EDX.

; Действительно, как мы помним, через EDX

функции передавался указатель.

add    eax, [ecx]

; Складываем EAX (хранит первый слева аргумент функции) с содержимым

; ячейки памяти, на которую указывает указатель ECX

(второй слева аргумент).

add    eax, [ebp+arg_0]

; А вот и обращение к тому аргументу функции, что был передан через стек

mov    esp, ebp

pop    ebp

; Закрываем кадр стека

retn   4

; Функции был передан 1 аргумент через стек

MyFunc       endp

Листинг 75

Просто? Просто! Тогда рассмотрим результат творчества Borland C++, который должен выглядеть так:

; int __cdecl main(int argc,const char **argv,const char *envp)

_main        proc near           ; DATA XREF: DATA:00407044o

var_4        = dword      ptr -4

argc         = dword      ptr  8

argv         = dword      ptr  0Ch

envp         = dword      ptr  10h

push   ebp

mov    ebp, esp

; Открываем кадр стека

push   ecx

; Сохраняем ECX... Постойте! Это что-то новое! В прошлых примерах Borland

; никогда не сохранял ECX при входе в функцию. Очень похоже, что через ECX

; функции был передан какой-то аргумент, и теперь она передает его другой

; функции через стек.

; Увы, каким бы убедительным такое решение ни выглядело оно неверно!



; Компилятор просто резервирует под локальные переменные четыре байта. Почему?

; Из чего это следует? Смотрите: IDA

распознала одну локальную переменную var_4

; но память под нее явно не резервировалась, во всяком случае команды SUB ESP,4

; не было. Постой-ка, постой, но ведь PUSH ECX

как раз и приводит к уменьшению

; регистра ESP на четыре! Ох, уж эта оптимизация!

mov    [ebp+var_4], 2

; Заносим в локальную переменную значение 2

push   offset a333  ; s

; Передаем функции strlen указатель на строку "333"

call   _strlen

pop    ecx

; Выталкиваем аргумент из стека

push   eax

; Здесь – либо мы передаем возращенное функцией strlen

значение следующей

; функции как стековый аргумент, либо временно сохраняем EAX

в стеке

; (позже выяснится, что справедливо последнее предположение)

push   offset a1    ; s

; Передаем функции strlen указатель на строку "1"

call   _strlen

pop    ecx

; Выталкиваем аргумент из стека

lea    edx, [ebp+var_4]

; Загружаем в EDX смещение локальной переменной var_4

pop    ecx

; Что-то выталкиваем из стека, но что именно? Прокручивая экран

; дизассемблера вверх, находим, что последним в стек заносился EAX,

; содержащий значение, возвращенное функцией strlen("333").

; Теперь оно помещается в регистр ECX

; (как мы помним, Borland передает через него второй слева аргумент)

; Попутно отметим для любителей fastcall-а: не всегда он приводит к одидаемому

; ускорению вызова, - у Intel 80x86 слишком мало регистров и их то и дело

; приходится сохранять в стеке.

; Передача аргумента через стек потребовала бы всего одного обращения: PUSH EAX

; здесь же мы наблюдаем два – PUSH EAX

и POP ECX!

call   MyFunc

; При восстановлении прототипа функции не забудьте о регистре EAX, - он

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

; strlen. Поскольку, компилятор Borland C++ 5.x

использует следующий список



; предпочтений: EAX, EDX, ECX

можно сделать вывод, что в EAX

передается первый

; слева аргумент функции, а два остальных в EDX

и ECX

соответственно.

; Обратите внимание и на то, что Borland C++, в отличие от Microsoft Visual C++

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

; значение всех функций, "выдергивая" их справа налево, и только

; потом переходит к переменным и константам.

; И в этом есть свой здравый смысл – функции

; изменяют значение многих регистров общего назначения и, до тех пор пока не

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

; через регистры.

push   eax

push   offset asc_407074 ; format

call   _printf

add    esp, 8

xor    eax, eax

; Возвращаем нулевое значение

pop    ecx

pop    ebp

; Закрываем кадр стека

retn

_main        endp

MyFunc       proc near           ; CODE XREF: _main+26p

push   ebp

mov    ebp, esp

; Открываем кадр стека

movsx  eax, al

; Расширяем EAX до знакового двойного слова

mov    edx, [edx]

; Загружаем в EDX содержимое ячейки памяти, на которую указывает указатель EDX

add    eax, edx

; Складываем первый аргумент функции с переменной типа int, переданной

; вторым аргументом по ссылке

add    ecx, eax

; Складываем третий аргумент типа int

с результатом предыдущего сложения

mov    eax, ecx

; Помещаем результат обратно в EAX

; Глупый компилятор, не проще ли было переставить местами аргументы предыдущей

; команды?

pop    ebp

; Закрываем кадр стека

retn

MyFunc       endp

Листинг 76

А теперь рассмотрим результат компиляции того же примера компилятором WATCOM C, у которого всегда есть чему поучиться:

main_        proc near           ; CODE XREF: __CMain+40p

var_C        = dword      ptr -0Ch

; Локальная переменная

push   18h

call   __CHK

; Проверка стека на переполнение

push   ebx

push   ecx



; Сохранение модифицируемых регистров

; Или – быть может, резервирование памяти под локальные переменные?

sub    esp, 4

; Вот это уж точно явное резервирование памяти под одну локальную переменную,

; следовательно, две команды PUSH, находящиеся выше, действительно сохраняют

; регистры.

mov    [esp+0Ch+var_C], 2

; Занесение в локальную переменную значения 2

mov    eax, offset a333 ; "333"

call   strlen_

; Обратите внимание – WATCOM передает функции strlen указатель на строку

; через регистр!

mov    ecx, eax

; Возращенное функцией значение копируется в регистр ECX.

; WATCOM

знает, что следующий вызов strlen

не портит этот регистр!

mov    eax, offset a1      ; "1"

call   strlen_

and    eax, 0FFh

; Поскольку strlen возвращает тип int, здесь имеет место явное преобразование

; типов: int -> char

mov    ebx, esp

; В EBX заносится указатель на переменную var_C

call   MyFunc

; Какие же аргументы передавались функции? Во-первых, EAX

– вероятно крайний

; левый аргумент, во-вторых, EBX – явно инициализированный перед вызовом

; функции, и, вполне возможно, ECX, хотя последнее и не обязательно.

; ECX

может содержать и регистровую переменную, но в таком случае вызываемая

; функция не должна к нему обращаться.

push   eax

push   offset asc_42000A ; "%x\n"

call   printf_

add    esp, 8

add    esp, 4

; А еще говорят, что WATCOM – оптимизирующий компилятор! А вот две команды

; объединить в одну, он увы не смог!

pop    ecx

pop    ebx

retn

main_        endp

MyFunc       proc near           ; CODE XREF: main_+33p

push   4

call   __CHK

; Проверка стека

and    eax, 0FFh

; Повторное обнуление 24-старших бит. WATCOM-у следовало бы определиться:

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

; подобный "дублеж" упрощает восстановление прототипов функций



add    eax, [ebx]

; Складываем EAX типа char и теперь расширенное до int с переменной типа int

; переданной по ссылке через регистр EBX

add    eax, ecx

; Ага, вот оно обращение к ECX, - следовательно, этот регистр использовался

; для передачи аргументов

retn

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

; MyFunc(char EAX, int *EBX, int ECX)

; Обратите внимание, что восстановить его удалось лишь совместным анализом

; вызываемой и вызывающей функций!

MyFunc       endp

Листинг 77

::передача вещественных значений. Кодоломатели в своей массе не очень-то разбираются в вещественной арифметике, избегая ее как огня. Между тем, в ней нет ничего сверхсложного и освоить управление сопроцессором можно буквально за полтора-два дня. Правда, с математическими библиотеками, поддерживающими вычисления с плавающей запятой, справиться намного труднее, (особенно если IDA не распознает имен их функций), но какой компилятор сегодня пользуется библиотеками? Микропроцессор и сопроцессор монтируются на одном кристалле, и сопроцессор, начиная с 80486DX (если мне не изменяет память), доступен всегда, поэтому, прибегать к его программной эмуляции нет никакой нужды.

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

Сегодня все кардинально изменилось. Вычисления с плавающей точкой, выполняемые сопроцессором параллельно с работой основной программы, даже быстрее целочисленных вычислений, обсчитываемых основным процессором. И программисты, окрыленные такой перспективой, стали лепить вещественные типы данных даже там, где раньше с лихвой хватало целочисленных. (Например, если a=b/c*100, то, изменив порядок вычислений a=b*100/c, мы можем обойтись и типами int).


Современным исследователям программ без значения команд сопроцессора очень трудно обойтись.

Сопроцессоры 80x87 поддерживают три вещественных типа данных: короткий 32-битный, длинный 64-битный и расширенный

80-битный, соответствующие следующим типам языка Си: float, double и long double. {>>> сноска Внимание: Стандарт ANSI С не оговаривает точного представления указанных выше типов и это утверждение справедливо только для платформы PC, да и то не для всех реализаций}

тип

размер

диапазон значений

предпочтительные типы передачи

float

4 байта

10-38...10+38

регистры CPU, стек CPU, стек FPU

double

8 байт

10-308...10+308

регистры CPU, стек CPU, стек FPU

long double

10 байт

10-4932...10+4932

стек CPU, стек FPU

real[3]

6 байт

2.9*10-39...1.7*10+38

регистры CPU, стек CPU, стек FPU

Таблица 8 Основная информация о вещественных типах сопроцессоров 80x87

Аргументы типа float и double могут быть переданы функции тремя различными способами: через регистры общего назначения основного процессора, через стек основного процессора и через стек сопроцессора. Аргументы типа long double потребовали бы для своей передачи слишком много регистров общего назначения, поэтому, в подавляющем большинстве случаев они заталкиваются в стек основного процессора или сопроцессора.

Первые два способа передачи нам уже знакомы, а вот третий – это что-то новенькое! Сопроцессор 80x87 имеет восемь восьмидесятибитных регистров, обозначаемых ST(0), ST(1), ST(2), ST(3), ST(4), ST(5), ST(6) и ST(7), организованных в форме кольцевого стека. Это обозначает, что большинство команд сопроцессора не оперируют номерами регистров, а в качестве приемника (источника) используют вершину стека. Например, чтобы сложить два вещественных числа сначала необходимо затолкнуть их в стек сопроцессора, а затем вызывать команду сложения, суммирующую два числа, лежащих на вершине стека, и возвращающую результат свой работы опять-таки через стек.


Существует возможность сложить число, лежащее в стеке сопроцессора с числом, находящимся в оперативной памяти, но непосредственно сложить два числа из оперативной памяти невозможно!

Таким образом, первый этап операций с вещественными типами – запихивание их в стек сопроцессора. Эта операция осуществляется командами из серии FLDxx, перечисленных с краткими пояснениями в таблице 9. В подавляющем большинстве случаев используется инструкция "FLD источник", заталкивающая в стек сопроцессора вещественное число из оперативной памяти или регистра сопроцессора. Строго говоря, это не одна команда, а четыре команды в одной упаковке с опкодами 0xD9 0x0?, 0xDD 0x0?, 0xDB 0x0? и 0xD9 0xCi, для загрузки короткого, длинного, расширенного типов и регистра FPU соответственно, где ? – адресное поле, уточняющие в регистре или в памяти находится операнд, а 'i' – индекс регистра FPU.

Отсутствие возможности загрузки вещественных чисел из регистров CPU, обессмысливает их использование для передачи аргументов типа float, double или long double. Все равно, чтобы затолкать эти аргументы в стек сопроцессора, вызываемая функция будет вынуждена скопировать содержимое регистров в оперативную память. Как ни крути, от обращения к памяти не избавишься. Вот поэтому-то, регистровая передача вещественных типов крайне редка и в подавляющем большинстве случаев они, как и обычные аргументы, передаются через стек основного процессора или через стек сопроцессора. (Последнее умеют только продвинутые компиляторы, в частности WATCOM, но не Microsoft Visual C++ и не Borland C++).

Впрочем, некоторые "избранные" значения могут загружаться и без обращений к памяти, в частности, существуют команды для заталкивания в стек сопроцессора чисел ноль, один, ? и некоторые другие – полный список приведен в таблице 9.

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



Команда

Назначение

FLD источник

Заталкивает вещественное число из источника на вершину стека сопроцессора

FSTP приемник

Выталкивает вещественное число из вершины стека сопроцессора в приемник

FST приемник

Копирует вещественное число из вершины стека сопроцессора в приемник

FLDZ

Заталкивает ноль на вершину стека сопроцессора

FLD1

Заталкивает единицу на вершину стека сопроцессора

FLDPI

Заталкивает на вершину стека сопроцессора число ?

FLDL2T

Заталкивает на вершину стека сопроцессора двоичный логарифм десяти

FLDL2E

Заталкивает на вершину стека сопроцессора двоичный логарифм числа e

FLDLG2

Заталкивает на вершину стека сопроцессора десятичный логарифм двух

FLDLN2

Заталкивает на вершину стека сопроцессора натуральный логарифм двух

FILD источник

Заталкивает целое число из источника на вершину стека сопроцессора

FIST приемник

Копирует целое число с вершины стека сопроцессора в приемник

FISTP приемник

Выталкивает целое число с вершины стека сопроцессора в приемник

FBLD источник

Заталкивает десятичное число из приемника на вершину стека сопроцессора

FBSTP приемник

Копирует десятичное число с вершины стека сопроцессора в приемник

FXCH ST(индекс)

Обмен значениями между вершиной стека сопроцессора и регистром ST(индекс)

Таблица 9 Основные команды сопроцессора, применяющиеся для передачи/приема аргументов

Типы double

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


Синтаксис ассемблера скрывает эти различия, позволяя программисту абстрагироваться от тонкостей реализации (а еще говорят, что ассемблер – язык низкого уровня), и мало кто знает, что FADD [float] и FADD [double] это разные машинные инструкции с опкодами 0xD8 ??000??? и 0xDC ??000??? соответственно. Плохая новость, помет Тигры! Анализ дизассемблерного листинга не дает никакой информации о вещественных типах – для получения этой информации приходится спускаться на машинный уровень, вгрызаясь в шестнадцатеричные дампы инструкций.

В таблице 10 приведены опкоды основных команд сопроцессора, работающих с памятью. Обратите внимание, что с вещественными значениями типа long double непосредственные математические операции невозможны – прежде их необходимо загрузить в стек сопроцессора.

Команда

Тип

короткий (float)

длинный (double)

расширенный (long double)

FLD

0xD9 ??000???

0xDD ??000???

0xDB ??101???

FSTP

0xD9 ??011???

0xDD ??011???

0xDB ??111???

FST

0xD9 ??010???

0xDD ??010???

нет

FADD

0xD8 ??000???

0xDC ??000???

нет

FADDP

0xDE ??000???

0xDA ??000???

нет

FSUB

0xD8 ??100???

0xDC ??100???

нет

FDIV

0xD8 ??110???

0xDC ??110???

нет

FMUL

0xD* ??001???

0xDC ??001???

нет

FCOM

0xD8 ??010???

0xDC ??010???

нет

FCOMP

0xD8 ??011???

0xDC ??011???

нет

Таблица 10 Опкоды основных команд сопроцессора. Второй байт опкода представлен в двоичном виде. Знак вопроса обозначает любой бит.

Замечание о вещественных типах языка Turbo Pascal. Вещественные типы языка Си вследствие его машиноориентированности совпадают с вещественными типами сопроцессора, что логично. Основной же вещественный тип Turbo Pascal-я, - Real, занимает 6 байт и противоестественен для машины. Поэтому, при вычислениях через сопроцессор он программно переводится в Extended тип (long double в терминах Си). Это "съедает" львиную долю производительности, но других типов встроенная математическая библиотека, призванная заменить собой сопроцессор, увы - не поддерживает.


При наличии же "живого" сопроцессора появляются чисто процессорные типы Single, Double, Extended и Comp, соответствующие float, double, long double и __int64.

Функциям математической библиотеки, обеспечивающий поддержу вычислений с плавающей запятой, вещественные аргументы передаются через регистры: в AX, BX, DX помещается первый слева аргумент, а в CX, SI, DI – второй (если он есть). Системные функции сопряжения с интерфейсом процессора (в частности, функции преобразования Real в Extended) принимают аргументы через регистры, а результат возвращают через стек сопроцессора. Наконец, прикладные функции и процедуры получают вещественные аргументы через стек основного процессора.

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

Что ж, теперь мы общих чертах представляем себе как происходит передача вещественных аргументов и горим нетерпением увидеть как это происходит "в живую". Для начала возьмем тривиальный пример:

#include <stdio.h>

float MyFunc(float a, double  b)

{

#if defined(__WATCOMC__)

#pragma aux MyFunc parm [8087];

// Компилить с ключом -7

#endif

return a+b;

}

main()

{

printf("%f\n",MyFunc(6.66,7.77));

}

Листинг 78 Демонстрация передачи функции вещественных аргументов

Результат компиляции Microsoft Visual C++ должен выглядеть так:

main   proc near           ; CODE XREF: start+AFp



var_8        = qword      ptr -8

; Локальная переменная, занимающая судя по всему 8 байт

push   ebp

mov    ebp, esp

; Открываем кадр стека

push   401F147Ah

; К сожалению IDA не может представить операнд в виде числа с плавающей запятой

; К тому же у нас нет возможности определить, что это именно вещественное число

; Его тип может быть каким угодно: и int, и указателем

; (кстати, оно очень похоже на указатель).

push   0E147AE14h

push   40D51EB8h

; "Черновой" вариант прототипа выглядит так: MyFunc(int a, int b, int c)

call   MyFunc

add    esp, 4

; Хвост Тигра! Со стека снимается только одно машинное слово, тогда как

; ложится туда три!

fstp   [esp+8+var_8]

; Стягиваем со стека сопроцессора какое-то вещественное число. Чтобы узнать

; какое, придется нажать <ALT-O>, выбрать в открывшемся меню пункт

; "Text representation", и в

нем в окно "Number of opcode bytes" ввести

; сколько знакомест отводится под опкод команд, например, 4.

; Тут же слева от FSTP появляется ее машинное представление - DD 1C 24

; По таблице 10 определяем тип данных с которым манипулирует эта команда.

; Это – double. Следовательно функция возвратила в через стек сопроцессора

; вещественное значение.

; Раз функция возвращает вещественные значения, вполне возможно, что она их и

; принимает в качестве аргументов. Однако, без анализа MyFunc

подтвердить это

; предположение невозможно.

push   offset aF    ; "%f\n"

; Передаем функции printf указатель на строку спецификаторов, предписывая ей

; вывести одно вещественное число. Но... при этом мы его не заносим в стек!

; Как же так?! Прокручиваем окно дизассемблера вверх, параллельно с этим

; обдумывая все возможные пути разрешения ситуации.

; Внимательно рассматривая команду "FSTP [ESP+8+var_8]" попытаемся вычислить

; куда же она помещает результат своей работы.

; IDA

определила var_8 как "qword ptr

–8", следовательно [ES+8-8] эквивалентно



; [ESP], т.е. вещественная переменная стягивается прямо на вершину стека.

; А что у нас на вершине? Два аргумента, переданных MyFunc

и так и не

; вытолкнутых из стека. Какой хитрый компилятор! Он не стал создавать локальную

; переменную, а использовал аргументы функции для временного хранилища данных!

call   _printf

add    esp, 0Ch

; Выталкиваем со стека три машинных слова

pop    ebp

retn

main   endp

MyFunc       proc near           ; CODE XREF: sub_401011+12p

var_4        = dword      ptr -4

arg_0        = dword      ptr  8

arg_4        = qword      ptr  0Ch

; Смотрим – IDA обнаружила только два аргумента, в то время как функции передавалось

; три машинных слова! Очень похоже, что один из аргументов занимает 8 байт...

push   ebp

mov    ebp, esp

; Открываем кадр стека

push   ecx

; Нет, это не сохранение ECX – это резервирование памяти под локальную

; переменную. Т.к. на том месте, где лежит сохраненный ECX

находится

; переменная var_4.

fld    [ebp+arg_0]

; Затягиваем на стек сопроцессора вещественную переменную, лежащую по адресу

; [ebp+8] (первый слева аргумент). Чтобы узнать тип этой переменной, смотрим

; опкод инструкции FLD - D9 45 08. Ага, D9 – значит, float

; Выходит, первый слева аргумент – float.

fadd   [ebp+arg_4]

; Складываем arg_0 типа float со вторым слева аргументом типа... Вы думаете,

; раз первый был float, то и второй так же будет float-ом?

; А вот и не обязательно! Лезем в опкод - DC 45 0C, значит, второй аргумент

; double, а

не float!

fst    [ebp+var_4]

; Копируем значение с верхушки стека сопроцессора

;(там лежит результат сложения) в локальную переменную var_4.

; Зачем? Ну... мало ли, вдруг бы она потребовалась?

; Обратите внимание – значение не стягивается, а копируется! Т.е. оно все еще

; остается в стеке. Таким образом, прототип функции MyFunc

выглядел так:

; double MyFunc(float a, double b);

mov    esp, ebp

pop    ebp   



; Закрываем кадр стека

retn

MyFunc       endp

Листинг 79

Поскольку результат компиляции Borland C++ 5.x практически в точности идентичен уже рассмотренному выше примеру от Microsoft Visual C++ 6.x, не будем терять на него время и сразу перейдем к разбору WATCOM C (как всегда – у WATCOM-а есть чему поучиться):

main_        proc near           ; CODE XREF: __CMain+40p

var_8        = qword      ptr -8

; локальная переменная на 8 байт

push   10h

call   __CHK

; Проверка стека на переполнение

fld    ds:dbl_420008

; Закидываем на вершину стека сопроцессора переменную типа double,

; взимаемую из сегмента данных.

; Тип переменной успешно определила сама IDA, предварив его префиксом 'dbl'.

; А если бы не определила – тогда бы мы обратились к опкоду команды FLD.

fld    ds:flt_420010

; Закидываем на вершину стека сопроцессора переменную типа float

call   MyFunc

; Вызываем MyFunc с передачей двух аргументов через стек сопроцессора,

; значит, ее прототип выглядит так: MyFunc(float a, double b).

sub    esp, 8

; Резервируем место для локальной переменной размеров в 8 байт

fstp   [esp+8+var_8]

; Стягиваем с вершины стека вещественное типа double

; (тип определяется размером переменной).

push   offset unk_420004

call   printf_

; Ага, уже знакомый нам трюк передачи var_8 функции printf!

add    esp, 0Ch

retn

main_        endp

MyFunc       proc near           ; CODE XREF: main_+16p

var_C        = qword      ptr -0Ch

var_4        = dword      ptr –4

; IDA

нашла две локальные переменные

push   10h

call   __CHK

sub    esp, 0Ch

; Резервируем место под локальные переменные

fstp   [esp+0Ch+var_4]

; Стягиваем с вершины стека сопроцессора вещественное значение типа float

; (оно, как мы помним, было занесено туда последним).

; На всякий случай, впрочем, можно удостоверится в этом, посмотрев опкод

; команды FSTP - D9 5C 24 08.


Ну, раз, 0xD9, значит, точно float.

fstp   [esp+0Ch+var_C]

; Стягиваем с вершины стека сопра вещественное значение типа double

; (оно, как мы помним, было занесено туда перед float).

; На всякий случай удостоверяемся в этом, взглянув на опкод команды FSTP.

; Он есть - DD 1C 24. 0xDD – раз 0xDD, значит, действительно, double.

fld    [esp+0Ch+var_4]

; Затаскиваем на вершину стека наш float

обратно и…

fadd   [esp+0Ch+var_C]

; …складываем его с нашим double. Вот, а еще говорят, что WATCOM C

; оптимизирующий компилятор! Трудно же с этим согласится, раз компилятор

; не знает, что от перестановки слагаемых сумма не изменяется!

add    esp, 0Ch

; Освобождаем память, ранее выделенную для локальных переменных

retn

MyFunc       endp

dbl_420008      dq 7.77                 ; DATA XREF: main_+A^r

flt_420010      dd 6.6599998            ; DATA XREF: main_+10^r

Листинг 80

Настала очередь компилятора Turbo Pascal for Windows 1.0. Наберем в текстовом редакторе следующий пример:

USES WINCRT;

Procedure MyProc(a:Real);

begin

WriteLn(a);

end;

VAR

a: Real;

b: Real;

BEGIN

a:=6.66;

b:=7.77;

MyProc(a+b);

END.

Листинг 81 Демонстрация передачи вещественных значений компилятором Turbo Pascal for Windows 1.0

А теперь, тяпнув с Тигрой пивка для храбрости, откомпилируем его без поддержки сопроцессора (так и происходит с настройками по умолчанию).

PROGRAM             proc near

call   INITTASK

call   @__SystemInit$qv ; __SystemInit(void)

; Инициализация модуля SYSTEM

call   @__WINCRTInit$qv

; __WINCRTInit(void)

; Инициализация модуля WINCRT

push   bp

mov    bp, sp

; Открываем кадр стека

xor    ax, ax

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Проверяем есть ли в стеке хотя бы ноль свободных байт

mov    word_2030, 0EC83h

mov    word_2032, 0B851h

mov    word_2034, 551Eh

; Инициализируем переменную типа Real.


Что это именно Real

мы пока, конечно,

; знаем только лишь из исходного текста программы.

; Визуально отличить эту серию команд от трех переменных типа Word

невозможно.

mov    word_2036, 3D83h

mov    word_2038, 0D70Ah

mov    word_203A, 78A3h

; Инициализируем другую переменную типа Real

mov    ax, word_2030

mov    bx, word_2032

mov    dx, word_2034

mov    cx, word_2036

mov    si, word_2038

mov    di, word_203A

; Передаем через регистры две переменные типа Real

call   @$brplu$q4Realt1 ; Real(AX:BX:DX)+=Real(CX:SI:DI)

; К счастью, IDA "узнала" в этой функции оператор сложения и даже

; подсказала нам ее прототип.

; Без ее помощи нам вряд ли удалось понять что делает эта очень длинная и

; запутанная функция.

push   dx

push   bx

push   ax

; Передаем возращенное значение процедуре MyProc

через стек,

; следовательно, ее прототип выглядит так: MyProc(a:Real).

call   MyProc

pop    bp

; Закрываем кадр стека

xor    ax, ax

call   @Halt$q4Word ; Halt(Word)

; Прерываем выполнение программы

PROGRAM             endp

MyProc       proc near           ; CODE XREF: PROGRAM+5Cp

arg_0        = word ptr  4

arg_2        = word ptr  6

arg_4        = word ptr  8

; Три аргумента, переданные процедуре, как мы уже выяснили на самом деле представляют

; собой три "дольки" одного аргумента типа Real.

push   bp

mov    bp, sp

; Открываем кадр стека

xor    ax, ax

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Есть ли в стеке ноль байт?

mov    di, offset unk_2206

push   ds

push   di

; Заталкиваем в стек указатель на буфер для вывода строки

push   [bp+arg_4]

push   [bp+arg_2]

push   [bp+arg_0]

; Заталкиваем все три полученные аргумента в стек

mov    ax, 11h

push   ax

; Ширина вывода – 17 символов

mov    ax, 0FFFFh

push   ax

; Число точек после запятой – max

call     @Write$qm4Text4Real4Wordt3 ; Write(var f; v: Real; width, decimals: Word)



; Выводим вещественное число в буфер unk_2206

call   @WriteLn$qm4Text ; WriteLn(var f: Text)

; Выводим строку из буфера на экран

call   @__IOCheck$qv ; Exit if error

pop    bp

retn   6

MyProc       endp

Листинг 82

А теперь, используя ключ '/$N+' задействуем команды сопроцессора и посмотрим: как это скажется на код:

PROGRAM             proc near

call   INITTASK

call   @__SystemInit$qv ; __SystemInit(void)

; Инициализируем

модуль System

call   @__InitEM86$qv      ; Initialize software emulator

; Врубаем

эмулятор сопроцессора

call   @__WINCRTInit$qv ; __WINCRTInit(void)

; Инициализируем модуль WINCRT

push   bp

mov    bp, sp

; Открываем кадр стека

xor    ax, ax

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Проверка стека на переполнение

mov    word_21C0, 0EC83h

mov    word_21C2, 0B851h

mov    word_21C4, 551Eh

mov    word_21C6, 3D83h

mov    word_21C8, 0D70Ah

mov    word_21CA, 78A3h

; Пока мы не можем определить тип инициализируемых переменных.

; Это с равным успехом может быть и WORD

и Real

mov    ax, word_21C0

mov    bx, word_21C2

mov    dx, word_21C4

call   @Extended$q4Real ; Convert Real   to Extended

; А вот теперь мы передаем word_21C0, word_21C2 и word_21C4 функции,

; преобразующий Real в Extend с загрузкой последнего в стек сопроцессора,

; значит, word_21C0 – word_21C4 это переменная типа Real.

mov    ax, word_21C6

mov    bx, word_21C8

mov    dx, word_21CA

call   @Extended$q4Real ; Convert Real   to Extended

; Аналогично – word_21C6 – word_21CA – переменная типа Real

wait

; Ждем-с пока сопроцессор не закончит свою работу

faddp  st(1), st

; Складываем два числа типа extended, лежащих на вершине стека сопроцессора

; с сохранением результата в том же самом стеке.

call   @Real$q8Extended ; Convert Extended to Real

; Преобразуем Extended в Real

; Аргумент передается через стек сопроцессора, а возвращается в



; регистрах AX BX DX.

push   dx

push   bx

push   ax

; Регистры AX, BX

и DX

содержат значение типа Real,

; следовательно прототип процедуры выглядит так:

; MyProc(a:Real);

call   MyProc

pop    bp

xor    ax, ax

call   @Halt$q4Word ; Halt(Word)

PROGRAM             endp

MyProc       proc near           ; CODE XREF: PROGRAM+6Dp

arg_0        = word ptr  4

arg_2        = word ptr  6

arg_4        = word ptr  8

; Как мы уже помним, эти три аргумента – на самом деле один аргумент типа Real

push   bp

mov    bp, sp

; Открываем кадр стека

xor    ax, ax

call   @__StackCheck$q4Word ; Stack overflow check (AX)

; Проверка стека на переполнение

mov    di, offset unk_2396

push   ds

push   di

; Заносим в стек указатель на буфер для вывода строки

mov    ax, [bp+arg_0]

mov    bx, [bp+arg_2]

mov    dx, [bp+arg_4]

call   @Extended$q4Real ; Convert Real   to Extended

; Преобразуем Real в

Extended

mov    ax, 17h

push   ax

; Ширина вывода 0х17 знаков

mov    ax, 0FFFFh

push   ax

; Количество знаков после запятой – все что есть, все и выводить

call     @Write$qm4Text8Extended4Wordt3 ; Write(var f; v: Extended{st(0); width decimals: Word)

; Вывод вещественного числа со стека сопроцессора в буфер

call   @WriteLn$qm4Text ; WriteLn(var f: Text)

; Печать строки из буфера

call   @__IOCheck$qv ; Exit if error

pop    bp

retn   6

MyProc       endp

Листинг 83

 

::соглашения о вызовах thiscall и соглашения о вызове по умолчанию.

В Си++ программах каждая функция объекта неявно принимает аргумент this – указатель на экземпляр объекта, из которого вызывается функция. Подробнее об этом уже рассказывалось в главе "Идентификация this", поэтому не будет здесь повторяться.

По умолчанию все известные мне Си++ компиляторы используют комбинированное соглашение о вызовах – передавая явные аргументы через стек (если только функция не объявлена как fastcall), а указать this через регистр с наибольшим предпочтением (см.


таблицы 2 - 7).

Соглашения же cdecl и stdcall предписывают передать все аргументы через стек, включая неявный аргумент this, заносимый в стек в последнюю очередь – после всех явных аргументов (другими словами, this – самый левый аргумент).

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

#include <stdio.h>

class MyClass{

 public:

void           demo(int a);

// прототип demo в действительности выглядит так demo(this, int a)

void __stdcall demo_2(int a, int b);

// прототип demo_2 в действительности выглядит так demo_2(this, int a, int b)

void __cdecl   demo_3(int a, int b, int c);

// прототип demo_2 в действительности выглядит так demo_2(this, int a, int b, int c)

};

// Реализзация функция demo, demo_2, demo_3 для экономии места опущена

main()

{

MyClass *zzz = new MyClass;

zzz->demo();

zzz->demo_2();

zzz->demo_3();

}

Листинг 84 Демонстрация передачи неявного аргумента - this

Результат компиляции этого примера компилятором Microsoft Visual C++ 6.0 должен выглядеть так (показана лишь функция main, все остальное не представляет на данный момент никакого интереса):

main         proc near           ; CODE XREF: start+AFp

push   esi

; Сохраняем ESI в стеке

push   1

call   ??2@YAPAXI@Z ; operator new(uint)

; Выделяем один байт для экземпляра объекта

mov    esi, eax

; ESI

содержит указатель на экземпляр объекта

add    esp, 4

; Выталкиваем аргумент из стека

mov    ecx, esi

; Через ECX функции Demo передается указатель this.

; Как мы помним, компилятор Microsoft Visual C++ использует регистр ECX

; для передачи самого первого аргумента функции.

; В данном случае этим аргументом и является указатель this.

; А компилятор Borland C++ 5.x передал бы this через регистр EAX, т.к.

; он отдает ему наибольшее предпочтение (см. таблицу 4)

push   1

; Заносим в стек явный аргумент функции. Значит, это не fastcall-функция,

; иначе бы данный аргумент был помещен в регистр EDX.


Выходит,

; мы имеем дело с типом вызова по умолчанию.

call   Demo

push   2

; Заталкиваем в стек первый справа аргумент

push   1

; Заталкиваем в стек второй справа аргумент

push   esi

; Заталкиваем в стек неявный аргумент this.

; Такая схема передачи говорит о том, что имело место явное преобразование

; типа функции в stdcall или cdecl. Прокручивая экран дизассемблера немного

; вниз, мы видим, что стек вычищает вызываемая функция, значит, она следует

; соглашению stdcall.

call   demo_2

push   3

push   2

push   1

push   esi

call   sub_401020

add    esp, 10h

; Раз функция вычищает за собой стек сама, то она имеет либо тип по умолчанию,

; либо -- cdecl. Передача указателя this через стек подсказывает, что истинно

; второе предположение.

xor    eax, eax

pop    esi

retn

main   endp

Листинг 85

 

::аргументы по умолчанию.

Для упрощения вызова функций с "хороводом" аргументов в язык Си++ была введена возможность задания аргументов по умолчанию. Отсюда возникает вопрос – отличается ли чем ни будь вызов функций с аргументами по умолчанию от обычных функций? И кто инициализирует опущенные аргументы вызываемая или вызывающая функция?

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

Докажем это на следующем примере:

#include <stdio.h>

MyFunc(int a=1, int b=2, int c=3)

{

printf("%x %x %x\n",a,b,c);

}

main()

{

MyFunc();

}

Листинг 86 Демонстрация передачи аргументов по умолчанию

Результат его компиляции будет выглядеть приблизительно так (для экономии места показана только вызывающая функция):

main         proc near           ; CODE XREF: start+AFp

push   ebp

mov    ebp, esp

push   3

push   2

push   1

; Как видно, все опущенные аргументы были переданы функции

; самим компилятором



call   MyFunc

add    esp, 0Ch

pop    ebp

retn

main         endp

Листинг 87

 

:: техника исследования механизма передачи аргументов неизвестным компилятором. Огромное многообразие существующих компиляторов и постоянное появление новых не позволяет привести здесь всеохватывающую таблицу, расписывающую характер каждого из компиляторов. Как же быть, если вам попадается программа, откомпилированная компилятором, не освещенным данной книгой?

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

Хуже, когда компилятор не опознается или же достать его копию нет никакой возможности. Тогда придется кропотливо тщательно исследовать взаимодействие вызываемой и вызывающей функций.


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