Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Роббинс Д. - Отладка приложений для Microsoft .NET и Microsoft Windows - 2004

.pdf
Скачиваний:
362
Добавлен:
13.08.2013
Размер:
3.3 Mб
Скачать

ГЛАВА 7 Усложненные технологии неуправляемого кода в Visual Studio .NET

281

 

 

Обратите внимание на строку 0012FEC4 = 0EA1644E в нижней части рис. 7 10. Эта строка отображает эффективный адрес. Текущая команда, в данном случае рас положенная по адресу 0x00411A28, ссылается на адрес 0x0012FEC4, указанный в левой части строки. Справа находится значение памяти по этому адресу, равное 0x0EA1644E. В окне Registers отображаются эффективные адреса только тех команд, которые осуществляют доступ к памяти. Так как процессоры x86 не позволяют, чтобы оба операнда были ссылками на память, то, наблюдая за строкой эффек тивного адреса, можно легко узнать, к какому адресу памяти обращается программа и какое значение там находится.

Если доступ к памяти невозможен, процессор генерирует или общую ошибку защиты (General Protection Fault, GPF), или ошибку страницы. GPF говорит о том, что вы пытаетесь обратиться к области памяти, к которой не имеете доступа. Ошибка страницы возникает при попытке доступа к отсутствующей в памяти стра нице. Если какая то строка ассемблерного кода вызывает ошибку, стоит поинте ресоваться, к какой области памяти она обращается (если, конечно, вообще об ращается). Это укажет вам на неправильные значения. Так, если ссылка на память равна [EAX], нужно посмотреть, какое значение содержится в регистре EAX. Если EAX содержит неверный адрес, нужно пролистать ассемблерный код вверх и опре делить, какая команда поместила в регистр EAX это ошибочное значение. Возможно, для обнаружения этой команды придется возвращаться на несколько вызовов. О перемещении по стеку вручную я расскажу ниже.

Кое-какие сведения о встроенном ассемблере Visual C++ .NET

Прежде чем я начну описывать ассемблерные команды, мне хотелось бы расска зать немного о встроенном ассемблере Visual C++. Как и большинство професси ональных компиляторов C++, компилятор Visual C++ позволяет включать ассемб лерные команды прямо в код C и C++. Использовать встроенный ассемблер обычно не рекомендуется, так как это ухудшает машинную независимость кода, но иног да это единственный выход. В главе 15 я покажу, как при помощи встроенного ассемблера устанавливать ловушки (hook) для импортируемых функций.

Выше я уже говорил, что вам не обязательно уметь писать программы на ас семблере, и я себе не противоречу. Изучение встроенного ассемблера — это не то же самое, что обучение разработке всей программы на MASM: инфраструктуру приложения все равно будет обеспечивать код C/C++. Можете считать встроен ный ассемблер программным эквивалентом функции Zoom (увеличить). Напри мер, в начале работы над растровым изображением вы рисуете крупными мазка ми; когда дело доходит до заключительных штрихов, вы увеличиваете изображе ние, чтобы можно было контролировать отдельные пикселы. То же самое имеет место и в случае встроенного ассемблера: вы «рисуете» программу крупными мазками C/C++ и увеличиваете ее, когда нужен контроль над отдельными ассемб лерными командами. Я рассматриваю именно встроенный ассемблер потому, что для описания странного синтаксиса директив MASM могло потребоваться бы бо лее 100 страниц; к тому же встроенный ассемблер гораздо проще для понимания. Наконец, встроенный ассемблер позволяет вам попрактиковаться с описываемы ми мной командами и своими глазами увидеть, что они делают.

282 ЧАСТЬ II Производительная отладка

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

NOP

Нет операции

Команда NOP не делает ничего. Компилятор иногда включает ее в функции, чтобы выполнить их выравнивание по определенной границе памяти.

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

void NOPFuncOne ( void )

{

__asm NOP __asm NOP

}

void NOPFuncTwo ( void )

{

__asm

{

NOP

NOP

}

}

Для пояснения ассемблерных операций, таких как доступ к параметрам и пе ременным, в этой главе я буду использовать встроенный ассемблер. Если вы же лаете увидеть, как работает каждая команда, запустите программу ASMer, входя щую в число файлов к книге. Эта программа включает все последующие ассемб лерные примеры.

Команды, которые нужно знать

Процессоры Intel поддерживают массу команд; так, глава документации Intel Instruc tion Set Reference для процессора Pentium Xeon занимает 854 страницы. Это не означает, что существует 854 команды, просто их описание занимает 854 стра ницы. К счастью, большинство этих команд не встречается в программах, рабо тающих в пользовательском режиме, поэтому вам не придется изучать их. Я опи шу только те, что часто используются генератором кода Microsoft, и ситуации, в которых вы будете обычно сталкиваться с ними. Рассказывая о командах, я буду описывать какую нибудь их группу и приводить поясняющий их фрагмент кода. Кроме того, весь код на ассемблере будет представлен в том виде, в каком он выводится в окно Disassembly среды Visual Studio .NET. Так вы сможете привык нуть к реальному ассемблеру, с которым будете работать.

ГЛАВА 7 Усложненные технологии неуправляемого кода в Visual Studio .NET

283

 

 

Манипуляции со стеком

PUSH

Поместить в стек слово или двойное слово

POP

Извлечь значение из стека

Процессоры Intel интенсивно используют стек. Другие процессоры, имеющие гораздо больше регистров, могут передавать параметры в функции в регистрах, но процессоры Intel вынуждены передавать большинство параметров через стек. Стек начинается в старшей области памяти и «растет» вниз. Обе эти команды неявно изменяют регистр ESP, содержащий адрес текущей вершины стека. После выпол нения команды PUSH значение регистра ESP уменьшается. В результате команды POP значение регистра ESP увеличивается.

Вы можете помещать в стек значения регистров, ячеек памяти и жестко зако дированные числа. При извлечении значения из стека оно обычно помещается в регистр. Стек процессора в первую очередь характеризуется тем, что представля ет собой структуру данных типа «последним вошел — первым вышел» (last in, first out; LIFO); если вы помещаете в стек три регистра, чтобы сохранить их значения, извлекать их нужно в обратном порядке:

void PushPop ( void )

{

__asm

{

//Сохранение значений регистров EAX, ECX и EDX. PUSH EAX

PUSH ECX PUSH EDX

//Некоторые действия, которые могут

//изменить значения указанных регистров.

//Восстановление ранее сохраненных регистров.

//Заметьте: они извлекаются из стека в порядке LIFO. POP EDX

POP ECX POP EAX

}

}

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

void SwapRegistersWithPushAndPop ( void )

{

__asm

{

//Обмен значений регистров EAX и EBX при помощи стека. Обратите

//внимание на последовательность команд PUSH и POP.

PUSH EAX

PUSH EBX

284 ЧАСТЬ II Производительная отладка

POP EAX

POP EBX

}

}

PUSHAD Поместить в стек все регистры общего назначения

POPAD Извлечь из стека все регистры общего назначения

Время от времени вы будете сталкиваться с этими командами при отладке сис темного кода. Вместо того чтобы писать целый ряд команд PUSH для сохранения всех регистров общего назначения и аналогичной последовательности команд POP для их извлечения, процессоры Intel позволяют сохранить и восстановить все регистры общего назначения при помощи команд PUSHAD и POPAD.

Очень распространенные простые команды

MOV

Перемещение данных

Команду MOV процессор выполняет чаще всего, потому что она позволяет переме стить значение из одного места в другое. Только что я показал, как поменять ме стами значения двух регистров командами PUSH и POP; сейчас я сделаю то же са мое командой MOV:

void SwapRegisters ( void )

{

__asm

{

//Регистр EAX выполняет функцию временного хранилища, поэтому

//я помещаю его в стек, чтобы не потерять его значение.

//Обмен значений между регистрами ECX и EBX.

PUSH

EAX

MOV

EAX , ECX

MOV

ECX , EBX

MOV

EBX , EAX

POP

EAX

}

}

SUB Вычитание

Команда SUB выполняет вычитание. При этом операнд источника вычитается из операнда приемника, и результат помещается в операнд приемника.

void SubtractExample ( void )

{

__asm

{

//Задаем значения регистров и выполняем вычитание. Этому примеру

//соответствует формула: EAX = Значение(EAX) Значение(EBX). MOV EAX , 5

MOV EBX , 2 SUB EAX , EBX

}

}

ГЛАВА 7 Усложненные технологии неуправляемого кода в Visual Studio .NET

285

 

 

После выполнения этого фрагмента кода в регистре EAX будет находиться значе ние 3, а в EBX — 2.

ADD Сложение

Команда ADD прибавляет операнд источника к операнду приемника и сохраняет результат в операнде приемника.

INT 3 Точка прерывания

INT 3 является в процессорах Intel командой точки прерывания. Компиляторы Microsoft используют эту команду для заполнения пространства между функция ми в файле. Это нужно для выравнивания разделов PE файлов (portable executable) в соответствии с ключом компоновщика /ALIGN, который по умолчанию имеет значение 4 кб. Идентификатор операции, т. е. шестнадцатеричное число, соответ ствующее команде INT 3, равен 0xCC, вот почему она используется для заполне ния, а также для инициализации переменных в стеке при помощи ключа /RTCs.

LEAVE Высокоуровневый выход из процедуры

Команда LEAVE восстанавливает при выходе из функции состояние процессора. Подробнее я расскажу об этом в следующем разделе.

Частая последовательность команд: вход в функцию и выход из функции

Вход и выход из большинства функций Windows и ваших программ выполняется одинаково. Код входа в функцию называется прологом, выхода — эпилогом; оба фрагмента компилятор генерирует автоматически. Код пролога выполняет дей ствия, обеспечивающие доступ к локальным переменным и параметрам. Область памяти для хранения локальных переменных и параметров называется кадром стека. Процессоры x86 не навязывают никакой схемы кадра стека, однако из за особенностей процессоров и некоторых команд ОС удобнее всего хранить ука затель кадра стека в регистре EBP.

__asm

{

// Стандартный пролог

 

PUSH

EBP

// Сохранение регистра кадра стека.

MOV

EBP , ESP

// Локальный кадр стека функции начинается по адресу ESP.

SUB

ESP , 20h

// В стеке выделяются 0x20

байт для хранения локальных

 

 

// переменных. Команда SUB

встречается, только когда

 

 

// функция имеет локальные

переменные.

}

Такой фрагмент часто встречается и в отладочных, и в заключительных ком поновках. Однако в некоторых функциях заключительных компоновок между командами PUSH и MOV могут располагаться и другие команды. Процессоры с не сколькими конвейерами — например, Pentium — могут декодировать несколько команд одновременно, поэтому оптимизатор пытается так организовать поток команд, чтобы задействовать все преимущества этой возможности.

286ЧАСТЬ II Производительная отладка

Взависимости от вида оптимизации, заданного при компиляции программы, в некоторых функциях в качестве указателя кадра стека может применяться не EBP, а другой регистр. Такие процедуры поддерживают то, что называется данными о кадре стека с отсутствующим указателем (frame pointer omission, FPO). Дизассем блированный код функций с данными FPO выглядит так, будто в них выполняет ся работа с данными. В следующем разделе я расскажу, как узнать такие функции.

Следующий распространенный эпилог аннулирует операции пролога, и именно с этим вариантом вы будете чаще всего встречаться в отладочных компоновках. Этот эпилог соответствует приведенному ранее прологу.

__asm

{

// Стандартный эпилог

MOV

ESP

, EBP

//

Восстановление

регистра стека.

POP

EBP

 

//

Восстановление

сохраненного регистра кадра стека.

}

Описанная выше команда LEAVE выполняется быстрее, чем пара MOV/POP, поэто му в заключительных компоновках эпилог может состоять только из нее. Коман да LEAVE идентична последовательности MOV/POP. В отладочные компоновки ком пиляторы по умолчанию включают пару MOV/POP. Интересно отметить, что процес соры x86 имеют аналогичную команду ENTER для пролога, но она выполняется медленнее, чем последовательность PUSH/MOV/ADD, поэтому компиляторы ее не ис пользуют.

Генерирование кода компиляторами во многом зависит от типа оптимизации. Если вы оптимизируете программу для получения минимального размера (см. гла ву 2), то во многих функциях будут задействованы стандартные кадры стека. Оп тимизация для скорости приведет к генерированию более замысловатых функ ций с данными FPO.

Манипуляции с указателями

LEA

Загрузить эффективный адрес

Команда LEA загружает в регистр приемника адрес операнда источника; это по чти всегда свидетельствует о доступе к локальной переменной. В следующем фраг менте приведены два примера использования LEA. В первом случае показано, как указателю присваивается адрес целочисленной переменной, во втором при по мощи команды LEA осуществляется получение адреса локального массива симво лов (тип char), после чего этот адрес передается в API функцию GetWindowsDirectory.

void LEAExamples ( void )

{

int * pInt ; int iVal ;

//Следующая пара команд идентична.

//коду pInt = &iVal; языка C.

__asm

{

ГЛАВА 7 Усложненные технологии неуправляемого кода в Visual Studio .NET

287

 

 

LEA EAX , iVal

MOV [pInt] , EAX

}

////////////////////////////////////////////////////////////////////

char szBuff [ MAX_PATH ] ;

//Другой пример доступа к указателю при помощи LEA. Эта

//последовательность команд идентична вызову на языке C

//функции GetWindowsDirectory ( szBuff , MAX_PATH ) ;. __asm

{

PUSH

104h

 

// Помещаем в

стек MAX_PATH как

второй параметр.

LEA

ECX ,

szBuff

// Получаем адрес переменной szBuff.

PUSH

ECX

 

//

Помещаем в

стек адрес szBuff

в качестве

 

 

 

//

первого параметра.

 

CALL

DWORD

PTR [GetWindowsDirectory]

 

}

}

Вызов процедур и возврат из них

CALL

Вызов процедуры

RET

Возврат из процедуры

Прежде чем я смогу начать обсуждение того, как и по каким адресам получать доступ к параметрам и локальным переменным, мне нужно рассказать о вызове функций и возврате из них. Команда CALL очень проста. При выполнении CALL в стек неяв но помещается адрес возврата из процедуры, поэтому если вы остановитесь в отладчике на первой команде вызванной процедуры и узнаете при помощи реги стра ESP значение, находящееся на вершине стека, оно и будет адресом возврата.

Операндом команды CALL может быть почти все, что угодно, и если вы получ ше изучите информацию в окне Disassembly, то сможете найти вызовы процедур при помощи регистров, ссылок на память, параметров и глобальных смещений. Если операндом CALL является ссылка на память, вызываемую процедуру можно определить по полю эффективного адреса окна Registers.

Вызов локальной функции — это прямой вызов по адресу. Однако довольно часто вам будут встречаться и вызовы через указатели; обычно это вызовы вирту альных функций или функций, импортируемых через таблицу адресов импорта (import address table, IAT). Если для изучаемого бинарного файла загружены сим волы, вы увидите что то похожее на первую команду CALL из приведенного ниже фрагмента CallSomeFunctions. Этот код показывает, что вызов осуществляется именно через IAT. Префикс __imp__ — вот что об этом говорит. Пример CallSomeFunctions также содержит вызов локальной функции. В комментариях я указываю, что мо жет быть выведено в окне Disassembly в зависимости от того, загружены символы или нет.

288 ЧАСТЬ II Производительная отладка

void CallSomeFunctions ( void )

{

__asm

{

//Вызов импортируемой функции GetLastError, не принимающей никаких

//параметров. Возвращаемое значение будет находиться в регистре EAX.

//Вызов выполняется через IAT, поэтому используется указатель.

CALL DWORD PTR [GetLastError]

//Если символы загружены, в окне Disassembly будет выведено:

//CALL DWORD PTR [__imp__GetLastError@0 (00402000)].

//Если символы не загружены, в окне Disassembly будет выведено:

//CALL DWORD PTR [00402000].

////////////////////////////////////////////////////////////////

//Вызов функции, находящейся в том же файле (локальной функции). CALL NOPFuncOne

//Если символы загружены, в окне Disassembly будет выведено:

//CALL NOPFuncOne (00401000).

//Если символы не загружены, в окне Disassembly будет выведено:

//CALL 00401000.

}

}

Команда RET выполняет возврат в вызывающую функцию при помощи адреса, находящегося на вершине стека, не выполняя при этом никакой проверки. Этот недочет используется в атаках типа «переполнение буфера», при которых адрес возврата изменяется так, чтобы функция возвращалась в код злоумышленника. Как вы можете представить, искаженный стек может содержать любые адреса возвра та. За командой RET иногда следует число. Оно показывает, сколько байт извлечь из стека, чтобы достичь соответствия числу параметров, помещенных в стек при вызове функции.

Соглашения вызова

При обсуждении команд CALL и RET я слегка затронул параметры. Для понимания параметров нужно разобраться с соглашениями вызова. Описанные мной в пре дыдущем разделе команды помогут вам освоить некоторые полезные отладочные приемы, но, чтобы перейти к подробному рассмотрению работы с окном Disas sembly, мне нужно объединить темы вызова процедур и соглашений вызова.

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

ГЛАВА 7 Усложненные технологии неуправляемого кода в Visual Studio .NET

289

 

 

Всего существует пять соглашений вызова, но часто употребляются только три: стандартный (__stdcall), объявление C (__cdecl) и вызов this. Стандартный вызов и объявление C вы можете указать сами, тогда как вызов this применяется авто матически к функциям C++, определяя передачу указателя this. Два других согла шения вызова — это быстрый вызов (__fastcall) и имеющее провокационное на звание соглашение naked1 . По умолчанию ОС Win32 не применяют соглашение бы строго вызова в программах, работающих в пользовательском режиме, потому что такой код не обладает машинной независимостью. Соглашение naked применяет ся, когда программист желает сам контролировать генерирование пролога и эпи лога, о чем я расскажу в главе 15.

Информация обо всех соглашениях вызова приведена в табл. 7 8. Если вы по мните, в начале этой главы я описывал схему расширения имен для установки точек прерываний в системных фукнциях. В табл. 7 8 вы увидите, что схема расшире ния имен определяется соглашением вызова.

Изменить соглашение вызова можно при объявлении и определении функции (см. пример). Кроме того, существуют ключи компилятора CL.EXE, позволяющие изменить соглашение вызова, используемое по умолчанию, но я не рекомендую этого делать и советую явно указывать соглашение вызова для каждой функции. Так вы облегчите жизнь программистам, которые будут отвечать за сопровожде ние приложения.

// Объявление функции с соглашением __stdcall:

void __stdcall ImAStandardCallFunction ( void ) ;

Если вы раньше не сталкивались с разными соглашениями вызова, вы, возможно, удивляетесь, почему их несколько. Различия между объявлением C и стандартным вызовом довольно тонки. При стандартном соглашении вызова стек очищает вызванная функция, поэтому она должна знать, сколько параметров ей ожидать. Следовательно, функция, использующая соглашение стандартного вызова, не мо жет иметь переменное число аргументов, как, скажем, printf. При вызове функ ции в стиле C стек очищается вызывающей функцией, поэтому переменный спи сок аргументов вполне допустим. Кроме того, при стандартном соглашении вы зова программа имеет меньший размер, чем при объявлении C. При объявлении C компилятор должен сгенерировать код очистки стека для каждого вызова фун кции. Если функция, объявленная в стиле C, вызывается из многих мест програм мы, то после каждого вызова будет располагаться код очистки стека, в результате чего размер программы увеличится. В то же время при стандартном соглашении вызова функции сами выполняют очистку стека (т. е. соответствующий код нахо дится в теле функции), поэтому после их вызовов компилятор не включает до полнительного кода. Именно поэтому абсолютное большинство системных фун кций Win32 использует соглашение стандартного вызова. Если вам захочется смутить какого нибудь программиста, утверждающего, что он досконально знает платформу Win32, спросите его, какие две функции Win32 не используют стан дартный вызов и какие соглашения применяются в их случае. Поначалу я не со бирался приводить ответ на этот вопрос, чтобы вы поискали его сами, но по

1 В переводе с английского — «голый». — Прим. перев.

290 ЧАСТЬ II Производительная отладка

том решил, что это будет некрасиво с моей стороны: это функции wsprintfA и

wsprintfW.

Табл. 7-8. Соглашения вызова

Согла-

Порядок

 

 

 

шение

передачи

Очистка

Расширение

 

вызова

аргументов

стека

имени

Примечания

__cdecl

Справа

Аргументы из

К имени функции

Применяется

 

налево.

стека удаляются

добавляется пре

по умолчанию

 

 

вызывающей

фикс из символа

функциями C и C++.

 

 

функцией.

подчеркивания:

 

 

 

Только это со

например, _Foo.

 

 

 

глашение под

 

 

 

 

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

 

 

 

 

менное число

 

 

 

 

аргументов

 

 

 

 

функций.

 

 

__stdcall

Справа

Вызванная

К имени функции

Используется почти

 

налево.

функция сама

добавляется пре

всеми системными

 

 

удаляет свои

фикс из символа

функциями; использу

 

 

аргументы

подчеркивания

ется по умолчанию внут

 

 

из стека.

и суффикс из сим

ренними функциями

 

 

 

вола @, за которым

Visual Basic.

 

 

 

следует десятич

 

 

 

 

ный размер списка

 

 

 

 

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

 

 

 

 

тах: например,

 

 

 

 

_Foo@12.

 

__fastcall

Первые два

Вызванная

К имени функции

Поддерживается

 

параметра

функция сама

добавляется пре

только процессорами

 

типа DWORD

удаляет свои

фикс из символа

с архитектурой Intel.

 

передаются

аргументы

@ и суффикс из

Это соглашение ис

 

в регистрах

из стека.

этого же символа,

пользуется по умолча

 

ECX и EDX;

 

за которым сле

нию компиляторами

 

остальные

 

дует десятичный

Borland Delphi.

 

параметры

 

размер списка

 

 

передаются

 

аргументов

 

 

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

 

в байтах: напри

 

 

 

 

мер, @Foo@12.

 

this

Справа налево.

Аргументы из

Нет.

Автоматически приме

 

Параметр

стека удаляются

 

няется к методам клас

 

this

вызывающей

 

сов C++, пока вы не ука

 

передается

функцией.

 

жете стандартное со

 

в регистре

 

 

глашение вызова. При

 

ECX.

 

 

объявлении методов

 

 

 

 

COM используется

 

 

 

 

стандартное соглаше

 

 

 

 

ние вызова.

naked

Справа налево.

Аргументы из

Нет.

Используется, когда

 

 

стека удаляются

 

программисту нужно

 

 

вызывающей

 

написать собственные

 

 

функцией.

 

пролог и эпилог.

 

 

 

 

 

Соседние файлы в предмете Программирование на C++