Отладка приложений

         

Циклы


 LOOP цикл по счетчику ЕСХ

Нельзя слишком сосредотачиваться на инструкции LOOP, потому что компиляторы Microsoft генерируют их не так много. Однако в некоторых частях ядра операционной системы (которые выглядят так, как если бы разработчики Microsoft написали их на языке ассемблера) их иногда можно увидеть. Применять инструкцию LOOP довольно просто. Установите ЕСХ равным числу шагов цикла, и затем выполните блок кода. Сразу после блока кода разместите инструкцию LOOP. Если ЕСХ не равен нулю, то она выполнит декремент ЕСХ и затем перейдет к вершине блока. Когда ЕСХ достигает нуля, инструкция LOOP пропускается (и выполняется следующая за ней инструкция).

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

void LoopingExample ( int q )

 {

// С-код:



// for ( ; q < 10 ; q++ )

// {

// printf ( "q = %d\n" , q ) ;

// }

char szEmt[] = "q = %d\n" ;

_asm

{

JMP LE_CompareStep // При первом проходе выполнить

// прямую проверку 10. 

LE_IncrementStep:

INC q // Инкремент q. 

LE_CompareStep:

CMP q , OAh // Сравнить q с 10.

JGE LE_End // Если q >= 10, эта функция выполнена.

MOV ЕСХ , DWORD PTR [q] // Переместить значение q в ЕСХ.

PUSH ЕСХ // Поместить значение в стек.

LEA ЕСХ , szFmt // Получить форматную строку.

PUSH ЕСХ // Поместить форматную строку в стек.

CALL DWORD PTR [printf] // Напечатать текущую итерацию.

ADD ESP , 8 // Очистить стек.

JMP LE_IncrementStep // Инкремент q, и начать сначала.

 LE_End: // Цикл выполнен.

}



Дополнительные инструкции


Инструкции, описанные в этом разделе, выполняют манипуляции сданными и указателями, сравнение и проверку, переходы и ветвления, циклы и манипуляции со строками.



Доступ через регистр FS


Регистру FS в операционных системах Win32 отведена специальная роль: в нем хранится указатель на блок информации потока (Thread Information Block — TIB). TIB называют также блоком среды потока (Thread Environment Block — ТЕВ). TIB содержит все специфические данные, которые позволяют операционной системе выполнять прямой доступ к потоку. Эти специфические поточные данные включают все цепочки структурированной обработки исключений (SEH), локальное хранилище потока и другую необходимую внутреннюю информацию. Подробные сведения о SEH-цепочках можно найти в главе 9. Пример с локальным хранилищем потока рассмотрен в главе 15 при обсуждении Memstress-расширений.

Блок TIB хранится в специальном сегменте памяти, и когда операционной системе нужен доступ к TIB, она переводит содержимое регистра FS и смещение в нормальный линейный адрес. Инструкция, обращающаяся к регистру FS, может реализовать одну из следующих операций: создание или уничтожение SEH-кадра, обращение к блоку TIB или к локальному хранилищу потока.



Доступ к параметрам, глобальным и локальным переменным


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

int g_iVal = 0 ;

void AccessGlobalMemory ( void )

_asm 

{

// Установить в глобальной переменной значение 48,059.

MOV g_iVal , OBBBBh

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

// MOV DWORD PTR [g_iVal (00403060)],OBBBBh.

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

// MOV DWORD PTR [00403060],OBBBBh. 

}

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

void AccessParameter ( int iParam )

 {

_asm 

{

// Переместить значение iParam value в регистр ЕАХ.

MOV ЕАХ , iParam

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

// MOV ЕАХ,DWORD PTR [iParam].

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

// MOV ЕАХ,DWORD PTR [ЕВР+8].

 }

 } 

Если при отладке оптимизированного кода отображаются ссылки, которые имеют положительное смещение от регистра стека ESP, значит это функция, которая имеет FPO-данные. Поскольку во время жизни функции содержимое ESP может изменяться, то работа с параметрами немного затрудняется.
При работе с оптимизированным кодом нужно сохранять след элементов, помещаемых в стек, потому что ссылка [ESP+20H] в этой функции может быть такой же, как и предыдущая [ESP+SH]. В процессе отладки, при выполнении пошагового прохода через операции языка ассемблера оптимизированного кода, всегда можно заметить, где расположены параметры. Если используются стандартные кадры, локальные переменные имеют отрицательные смещения от ЕВР. Как показано в предыдущем разделе, инструкция SUB резервирует место в стеке. Следующий код содержит пример установки нового значения в локальной переменной:

void AccessLocalVariable ( void ) 

{

int iLocal ;

_asm

{

// Установить в локальную переменную значение 23. 

MOV iLocal ,'017h

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

// MOV DWORD PTR [iLocal],017h.

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

 // MOV [EBP-4],017h. 

}

 }

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

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

void AccessLocalsAndParamsExample ( int * pParaml , int * pParam2 } 

{

int iLocal1 = 3 ;

int iLocal2 = 0x42 ;

iLocal1 = *pParaml ;



iLocal2 = *pParam2 ; 

}

// Дизассемблерный код AccessLocalsAndParamsExample 

//с адресами стандартного пролога функции

00401097 PUSH EBP

00401098 MOV EBP , ESP

0040109A SUB ESP , 8

// int iLocal1 = 3 ;

0040109D MOV DWORD PTR [EBP-8h] , 3

// int iLocal2 = 0x42 ;

004010A4 MOV DWORD PTR [EBP-4h] , 42h

// iLocal1 = *pParaml ;

004010AB MOV EAX , DWORD PTR [EBP+8h]

004010AE MOV ECX , DWORD PTR [EAX]

004010BO MOV DWORD PTR [EBP-08h] , ECX

// iLocal2 = *pParam2 ;

004010B3 MOV EDX , DWORD PTR [EBP+OCh]

004010B6 MOV EAX , DWORD PTR [EDX]

004010B8 MOV DWORD PTR [EBP-4h] , EAX

// Стандартный эпилог функции

004010BB MOV ESP , EBP

004010BD POP EBP

004010BE RET

}

Если точка прерывания устанавливается в начале функции AccessLocalsAndParamsExample (по адресу 0x00401097), то будут отображены

значения стека и регистров, как показано на рис. 6.2.

Рис. 6.2. Стек перед прологом функции AccessLocalsAndParamsExample

Первые три инструкции языка ассемблера в AccessLocalsAndParamsExaraple составляют пролог функции. После выполнения пролога устанавливаются указатели стека (ESP) и базы (ЕВР), доступ к параметрам выполняется через положительные смещения от ЕВР, а к локальным переменным — через отрицательные смещения от ЕВР. На рис. 6.3 показаны значения указателей стека и базы после выполнения каждой инструкции пролога.

Рис. 6.3. Стек в течение и после выполнения пролога функции AccessLocalsAndParamsExample



Endians


Термин "Endianness" описывает свойство CPU, которое определяет порядок хранения частей многобайтовых данных в памяти. Для Intel CPU это свойство обозначают как "Little Endian", что означает, что младший байт (т. е. конец) многобайтового значения хранится в памяти первым. Например, значение 0x1234 хранится в памяти как 0x34 0x12. Важно помнить об этом при просмотре памяти в отладчике. Чтобы получить правильные значения, нужно выполнить преобразование самостоятельно. Если окно Memory используется для просмотра одного из узлов связного списка, и следующим значением указателя является 0x12345678, то в окне это значение будет показано в байтовом формате как 0x78 0x56 0x34 0x12.

Для любопытных: термин "Endian" пришел из "Путешествий Гулливера" Джонатана Свифта, а компьютерное его значение — из RFC-запроса Дэнни Коена (Danny Cohen, 1980) относительно упорядочивания байтов. Полную историю можно найти в статье Дэнни Коена по адресу:

http://www.op.net/docs/RFCs/ien-137.

RFC — Request for Comments (Запросы на комментарии и предложения). — Пер



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


Все инструкции Intel CPU имеют следующий основной формат:

[префикс] инструкция [операнды]

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

Инструкция с единственным операндом:

XXX источник

Инструкция с двумя операндами (разделяемыми запятой):

XXX получатель, источник

Операнд-источник может быть регистром, обращением (ссылкой) к памяти или непосредственным (жестко закодированным) значением. Операнд-получатель может быть регистром или обращением к памяти. Intel CPU не допускают, чтобы и источник, и получатель были обращениями к памяти.

Обращения к памяти — это такие операнды, которые помещаются в квадратные скобки. Например, обращение к памяти [0040l29Ah] означает "получить значение, размещенное в ячейке памяти Ох0040129А". С помощью суффикса b в языке ассемблера указывается шестнадцатеричное число. Запись [0040i29Ah] — означает то же самое, что и доступ через указатель к целому числу в языке С (*pivai)- Обращения к памяти могут храниться в регистрах. Например, [ЕАХ] означает "получить значение, хранящееся в памяти по адресу, указанному в регистре ЕАХ". Другое часто используемое обращение к памяти указывает адрес, добавляя (шестнадцатеричное) смещение к значению регистра. Например, [ЕАХ+ОСh] означает "добавить смещение ОхС к адресу, хранящемуся в ЕАХ, и получить значение, хранящееся в памяти по этому адресу". 

 Эту операцию в С называют "разыменованием указателя", т. е. извлечением из памяти значения по адресу, хранящемуся в указателе. — Пер

Допустимы также обращения к памяти, включающие вычисление адреса по содержимому нескольких регистров (например,[ЕAX+ЕBX*2]), но такая форма оказывается довольно сложной для приложений.


Часто встречается обращение, которому предшествует спецификатор размера указателя, дифференцирующий размеры обращений к памяти. Размеры указателя специфицируются как BYTE PTR, WORD PTR и DWORD PTR (для ссылок размером в байт, слово и двойное слово, соответственно). Можно также представлять их, как приведение типов в C++. Если дизассемблер не указывает размера указателя, то он принимается равным двойному слову.

Иногда применяется прямое обращение к памяти в инструкции, т. е. в нем можно видеть непосредственный адрес соответствующего участка памяти. Например, обращение [ЕВХ] — это просто адрес памяти, содержащийся в регистре ЕВХ, и, чтобы просмотреть его содержимое (т. е. содержащийся в нем адрес), можно просто открыть окно Memory и ввести значение ЕВХ. Однако в других случаях невозможно вычислить ссылку без выполнения сложного шестнадцатеричного умножения. К счастью, окно Registers показывает, на какую память собирается сослаться инструкция.

Обратите внимание на строку "0012F988 = 0012F9D4" в нижней части рис. 6.1, отображающую эффективный адрес. Текущая инструкция (располагающаяся в данном случае по адресу Ox5F42D8B8) ссылается на адрес Ox0012F988 (левая сторона строки). Правая часть строки — это значение Ox0012F9D4, располагающееся по адресу Ox0012F988. Эффективный адрес в окне Registers показывают только те инструкции, которые выполняют обращение к памяти. Поскольку CPU x86 допускают лишь один операнд с обращением к памяти, то, прослеживая эффективный адрес, можно увидеть, к какой ячейке памяти вы собираетесь выполнить доступ и какое значение в ней расположено.

Если доступ к памяти неправомерен, то CPU генерирует исключение (типа "General Protection Fault (GPF — общая ошибка защиты)" или "ошибка страницы"). GPF указывает, что приложение пыталось получить доступ к памяти, к которой оно доступа не имело. Ошибка страницы свидетельствует о попытке получить доступ к позиции памяти, которой не существует. Просматривая строку ассемблера, на которой происходит аварийный останов, обратите внимание на ту ее часть, где находится ссылка на память.Из нее можно узнать, какие значения были недействительными. Например, если этот ссылочный операнд выглядит как [ЕАХ], то нужно просмотреть значение регистра ЕАХ в окне Registers. Если ЕАХ содержит недействительный адрес, необходимо стартовать обратное сканирование листинга ассемблера, чтобы увидеть, какая инструкция устанавливает в ЕАХ неправильное значение. Учтите, для того чтобы найти эту инструкцию, может потребоваться обратный проход через несколько вызовов. Далее (в разделе "Окна Memory и Disassembly" этой главы) показано, как можно пройти стек вручную.



Инструкции, которые нужно знать


Существует много различных инструкций для Intel CPU; справочный раздел с описанием набора инструкций для Intel Pentium Pro содержит 467 страниц. Это не означает, конечно, что этих инструкций 467; такой объем занимает их описание. К счастью, многие из инструкций не применяются в программах пользовательского режима, так что вам они не нужны. Здесь рассмотрены только инструкции, которые часто используются, и ситуации, в которых они обычно бывают нужны. При этом сначала описывается пара инструкций, а затем демонстрируются сценарии, в которых они применяются.



Инструкции переходов и ветвлений


 JMP  абсолютный переход

Как указано в названии, JMP передает управление по абсолютному адресу.

 JE        переход, если равно  JL        переход, если меньше чем  JG       переход, если больше чем  JNE     переход, если не равно  JGE     переход, если больше или равно  OLE    переход, если меньше или равно

От инструкций СМР и TEST немного пользы, если программист не имеет возможности воздействовать на их результаты. Условные переходы позволяют выполнять соответствующие ветвления программы. Показанные выше инструкции — это наиболее общие условные переходы, с которыми вы встретитесь в окне Disassembly, хотя всего существует более трех десятков (точнее — 31) различных условных переходов, многие из которых выполняют те же самые действия за исключением того, что в мнемонике используется слово "NOT". Например, инструкция JLE (переход, если меньше или равно) имеет тот же код операции, что JNG (переход, если не больше чем). Работая с другим дизассемблером (не из отладчика Visual C++), можно увидеть иные инструкции. Чтобы расшифровывать все инструкции переходов, можно найти jcc-коды в руководствах Intel.

В следующем примере инструкции условных переходов расположены в том же порядке, как в табл. 6.4. Один из условных переходов немедленно следует за инструкциями СМР и TEST. Оптимизированный код может содержать несколько инструкций, разбросанных между проверкой и переходом, но эти инструкции никогда не изменяют флажков.

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

void JumpExamples ( int i )

{

// Здесь показан оператор С-кода. Заметьте, что условие записано как

// "i > 0", но компилятор генерирует противоположное условие.
Код

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

// что генерирует компилятор.

// Разные методы оптимизации генерируют различный код.

// if ( i > 0 )

// {

// printf ( "i > 0\n" ) ;

// }

char szGreaterThan[] = "i > 0\n" ;

_asm

{

CMP i , 0 // Сравнить i с 0 вычитанием (i - 0).

 JLE JE_LessThanOne // Если i меньше чем или равно 0, перейти

// к метке.

PUSH i // Поместить параметр в стек. 

LEA ЕАХ , szGreaterThan // Поместить в стек форматную строку. 

PUSH ЕАХ CALL DWORD PTR [printf] // Вызвать printf. Заметьте, что printf,

// вероятно, приходит из DLL, потому что

// вызов выполняется через указатель.

ADD ESP ,8 // для printf действует соглашение _cdecl, 

поэтому

// нужно очистить стек в вызывающей

// программе.

 JE_LessThanOne: //Во встроенном ассемблере можно

// перейти к любой С-метке.

}

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

// Взять абсолютное значение параметра и снова проверить.

// С-код:

// int у = abs ( i ) ;

// if. ( у >=5 )

// {

// printf ( "abs(i) >= 5\n" ) ;

// }

// else 

// {

// printf ( "abs(i) < 5\n" ) ;

// }

char szAbsGTEFive [] = "abs(i) >= 5\n" ; 

char szAbsLTFive[] = "abs(i) < 5\n" ;

 _asm 

{

MOV EBX , i // Переместить значение i в ЕВХ. 

СМР ЕВХ , 0 // Сравнить ЕВХ с 0 (ЕВХ - 0). 

JG JE_PosNum // Если результат больше 0, то ЕВХ 

// содержит положительное значение.

NEG ЕВХ // Преобразовать отрицательное в положительное. 

JE_PosNum:

СМР ЕВХ , 5 // Сравнить ЕВХ с 5 (ЕВХ - 5).

JL JE_LessThan5 // Переход, если меньше 5.

LEA ЕАХ , szAbsGTEFive // Получить в ЕАХ указатель на правильную

// форматную строку.

JMP JE_DoPrintf // Перейти к вызову printf. 

JE_LessThan5:

LEA ЕАХ , szAbsLTFive // Получить в ЕАХ указатель на правильную

// форматную строку.

 JE_DoPrintf:

PUSH ЕАХ // Поместить строку в стек. 



CALL DWORD PTR [printf] // Напечатать ее.

 ADD ESP , 4 .. // Восстановить стек.

 } 

}

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

 JА     переход, если выше  JBE   переход, если ниже или равно  JC     переход, если есть перенос  JNC   переход, если нет переноса  JNZ   переход, если не О  JZ     переход, если О Эти инструкции условных переходов не столь обычны как те, что были перечислены выше, но их можно увидеть в окне Disassembly. Необходимо разбираться в их условиях интуитивно, по имени перехода.



Изучайте ASM-файлы


Чтобы увидеть смешанный код — исходный и ассемблера, нужно с помощью Visual C++ сгенерировать ассемблерные листинги для исходных файлов. Если вы укажете ключ /FAS в редактируемое поле Project Options на вкладке C++ диалогового окна Project Settings, то компилятор сгенерирует ASM-файл для каждого исходного файла. Можно не генерировать ASM-файлы при каждом построении, но они могут быть поучительны, позволяя видеть генерируемый компилятором код. При наличии ASM-файлов не требуется каждый раз запускать приложение, когда возникает необходимость просмотреть программу в кодах языка ассемблера.

Сгенерированные файлы почти готовы для компиляции с помощью макроассемблера Microsoft (Microsoft Macro Assembler — MASM), но они могут быть довольно сложными для чтения. Многие файлы состоят из директив MASM, но главные части файлов показывают С-код вместе с кодом языка ассемблера под каждой С-конструкцией. После усвоения материала этой главы читатели не должны испытывать никаких проблем при чтении ASM-файлов.



Код мусора


Когда в результате аварийного останова вы оказываетесь в окне Disassembly, необходимо определить, действительно ли там отображается реальный код (это иногда довольно трудно). Вот некоторые советы:

 оказалось, что для просмотра кодов инструкций ассемблера полезно включение режима Code Bytes (в контекстном меню окна Disassembly);  если в окне Disassembly отображается ряд идентичных инструкций ADD BYTE PTR [EAX], AL, это не есть правильный код ассемблера. Вы видите ряд нулей;  если отображаются символы, но добавленные к ним смещения — очень большие числа, в общем случае превосходящие 0x1000, то вы, вероятно, вне секции кода. Однако очень большие числа могут также означать, что отлаживается модуль, который не содержит доступных частных (private) символов;  если вы видите группу инструкций, которые не описаны в этой главе, значит вы, вероятно, видите данные;  если дизассемблер Visual C++ не может дизассемблировать инструкцию, то в качестве кода операции он показывает "???".


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

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

Например, для того чтобы повторно выполнить функцию без немедленного аварийного останова, необходимо обеспечить такое изменение выполнения, чтобы стек оставался сбалансированным. Ниже приводится двукратное обращение к функции, расположенной по адресу 0x00401005.

00401032 PUSH EBP

00401033 MOV EBP , ESP 

00401035 PUSH 404410h 

0040103А CALL 00401005h

0040103F ADD ESP , 4

00401042 POP EBP

00401043 RET

Дважды проходя через дизассемблирование, необходимо удостовериться, что при выполнении инструкции ADD по адресу 0x0040103F не нарушает баланс стека. Как было показано ранее при обсуждении различных соглашений о вызовах, данный фрагмент ассемблерного кода показывает обращение к _cded-функции (потому что инструкция ADD расположена прямо после ее вызова). Чтобы повторно выполнять функцию, следует установить указатель инструкций на 0x00401035, гарантируя, что операция PUSH выполнится должным образом.



Манипуляции с данными


AND    логическое И  OR         логическое ИЛИ-(включающее)

Инструкции AND и OR выполняют поразрядные операции, которые должны быть знакомы каждому, потому что они являются основой для манипуляции с разрядами.

 NOT    отрицание с поразрядным дополнением до 1   NEG    отрицание с поразрядным дополнением до 2

Инструкции NOT и NEG иногда вызывают некоторое замешательство, потому что по виду они похожи, но, конечно, не выполняют одну и ту же операцию. Инструкция NOT — поразрядная операция, которая устанавливает каждую двоичную 1 в 0 и каждый двоичный 0 в 1. Инструкция NEG выполняет вычитание операнда из 0. Следующий фрагмент кода показывает различия между этими двумя инструкциями:

void NOTExample { void )

 {

_asm {

MOV EAX , OFFh

MOV EBX , 1

NOT EAX // EAX теперь содержит OFFFFFFOOh.

NOT EBX // ЕВХ теперь содержит OFFFFFFFEh. 

}

void NEGExample ( void ) (

_asm 

{

MOV EAX , OFFh MOV EBX , 1

NEG EAX // EAX теперь содержит OFFFFFFOlh ( 0 - OFFh ). 

NEC EBX // EBX теперь содержит OFFFFFFFFh ( 0 - 1 ). 

}

 XOR логическое ИЛИ (исключающее)

Инструкция XOR — это самый быстрый способ обнулить значение. XOR с двумя операндами установит каждый разряд в 1, если одинаковые разряды в каждом операнде различны. Если все разряды одинаковы, то результат равен 0. Поскольку операция

XOR ЕАХ,ЕАХ

выполняется быстрее, чем

MOV EAX,0

(потому что первая занимает меньшее количество машинных тактов), компиляторы Microsoft используют XOR для обнуления регистров.

 INC      инкремент (увеличение) на 1   DEC     декремент (уменьшение) на 1

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

 SHL           сдвиг влево, умножение на 2   SHR      сдвиг вправо, деление на 2 При двоичных манипуляциях поразрядные сдвиги выполняются быстрее, чем соответствующие инструкции умножения и деления в CPU x86. Эти инструкции родственны поразрядным С-операциям « и », соответственно.

 DIV    беззнаковое деление   MUL       беззнаковое умножение Эти простые с виду инструкции фактически немного странны. Обе инструкции выполняют беззнаковые действия на регистре ЕАХ. Но вывод неявно использует регистр EDX. Старшие байты двойного слова и большие по величине множители помещаются в регистр EDX. Инструкция DIV сохраняет остаток в EDX, а частное — в ЕАХ. Обе инструкции оперируют значением из ЕАХ, используя в качестве второго операнда только значения из регистра или из памяти.

 IDIV    деление со знаком   IUML      умножение со знаком Эти инструкции подобны инструкциям DIV и MUL, за исключением того, что они обращаются с операндами как со знаковыми значениями. Старшие байты двойного слова и большие по величине множители помещаются в регистр EDX. Инструкция IDIV сохраняет остаток в EDX, а частное — в ЕАХ. Обе инструкции оперируют значением из ЕАХ, используя в качестве второго операнда только значения из регистра или из памяти. Инструкция IMUL иногда имеет три операнда. Первый операнд — целевой (в нем сохраняется результат), а два последних — исходные операнды. В наборе инструкций х86 IMUL — единственная инструкция с тремя операндами.

 LOCK   устанавливает сигнальный префикс LOCK# LOCK — не реальная инструкция, а скорее префикс к другим инструкциям. Префикс LOCK сообщает CPU, что действие с памятью, к которой обращается следующая инструкций, должно быть атомарной операцией, когда CPU, выполняющий такую инструкцию, блокирует шину памяти и тем самым запрещает любому другому CPU в системе обращаться к этой памяти.

 MOVSX    пересылка с расширением знакового разряда   MDVZX    пересылка с расширением нулем Эти две инструкции копируют значения меньшего размера в значения большего размера и указывают, как в больших значениях заполняются старшие разряды. MOVSX указывает, что знаковое значение исходного операнда будет расширено на все верхние разряды регистра-приемника (т. е. регистра результата). MOVZX заполняет, верхние разряды регистра-приемника нулями. Эти две инструкции предназначены для наблюдений за значениями при прослеживании ошибок в знаках.



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


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

LEA загружает целевой регистр адресом исходного операнда. Следующий фрагмент кода содержит два примера с инструкцией LEA. Первый показывает, как следует назначать адрес целому указателю, а второй — как извлекать адрес локального символьного массива с помощью инструкции LEA и передавать адрес как параметр API-функции GetwindowsDirectory.

void LEAExamples ( void ) 

{

int * pint ;

int iVal ;

// Следующие инструкции эквивалентны С-коду

// pint = siVal ;.

_asm

{

LEA EAX , iVal

MOV [pint] , EAX

}

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

char szBuff [ MAX_PATH ] ;

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

// Эта последовательность инструкций идентична С-коду

// GetWindowsDirectory ( szBuff , МАХ_РАТН ) ;.

_asm

{

PUSH 104h // Поместить МАХ_РАТН в стек как

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

LEA ЕСХ , szBuff // Получить адрес szBuff. 

PUSH ECX // Поместить адрес szBuff в стек как

// первый параметр.

CALL DWORD PTR [GetWindowsDirectory]

 } 

}



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


PUSH поместить слово или двойное слово в стек POP извлечь значение из стека

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

В стек можно помещать значения регистров, адреса ячеек памяти или жестко закодированные числа. Извлеченный элемент стека обычно перемещается в регистр. Ключевой характеристикой стека CPU является организация его данных в виде очереди LIFO (Last In, First Out). Если в стек помещаются значения трех регистров, то извлекать их нужно в обратном порядке, как это делает функция PushPop:

void PushPop ( void ) 

{

_asm 

{

// Сохранить значения регистров ЕАХ, ЕСХ и EDX. , 

PUSH ЕАХ 

PUSH ЕСХ 

PUSH EDX

// Здесь нужно выполнить некоторые операции, которые могут 

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

// Восстановить ранее сохраненные регистры. Обратите внимание, 

// что они удаляются из стека в LIFO-порядке. 

POP EDX .

 POP ECX 

POP EAX 

}

Хотя существуют гораздо более эффективные способы обмена значений, инструкции PUSH и POP позволяют обменять значения регистров. Обмен происходит, когда программист изменяет порядок pop-инструкций:

void SwapRegistersWithPushAndPop ( void ) 

{

_asm 

{

// Обменять значения регистров EAX и ЕВХ , используя стек.

PUSH EAX

PUSH EBX

POP EAX

POP EBX 

}

 PUSHAD поместить в стек значения всех регистров общего назначения   POPAD извлечь из стека значения всех регистров общего назначения

С этими инструкциями можно иногда столкнуться при отладке системного кода. Они эквивалентны длинным цепочкам PUSH-инструкций для сохранения всех общих регистров и pop-инструкций — для их восстановления.



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


Intel CPU обладают большими возможностями для манипуляций со строками, т. е. поддерживают обработку больших участков памяти в отдельной инструкции. Все рассмотренные здесь строчные инструкции имеют несколько мнемоник, которые можно найти в справочных руководствах Intel. Однако окно Disassembly в Visual C++ всегда дизассемблирует строчные инструкции только в те формы, которые показаны ниже. Все эти инструкции могут работать на областях памяти размером в байт, слово и двойное слово.

 MOVS    перемещают данные из строки в строку

Инструкция MOVS перемещает адрес памяти из регистра ESI в регистр EDI. Эта инструкция работает только на значениях, на которые указывают ESI и EDI. Инструкцию MOVS можно представить себе как реализацию С-функции memcpy. Окно Disassembly из Visual C++ всегда показывает размер операции со спецификатором размера, так сразу видно, сколько памяти было перемещено. После того как перемещение заканчивается, регистры ESI и EDI инкрементируются или декрементируются, в зависимости от флажка направления (DF) в регистре EFLAGS (отображаемого как поле UP в окне Registers Visual C++). Если поле UP равно 0, то регистры инкрементируются, а если равно 1, регистры декрементируются. Величина инкремента и декремента зависит от размера памяти, с которой работает операция: 1 — для байт, 2 — для слов и 4 — для двойных слов.

 SCAS   сканировать строку

Инструкция SCAS сравнивает значение по адресу памяти, указанному в регистре EDI, со значением в регистрах AL, АХ или ЕАХ (в зависимости от требуемого размера). Результаты сравнения устанавливают значения различных флажков в регистре EFLAGS. Установки флажков — те же, что показаны в табл. 6.4. Если вы сканируете строку в поисках NULL-терминатора (пустого указателя), то инструкцию SCAS можно использовать, чтобы дублировать возможности С-функции strien. Подобно инструкции MOVS, SCAS выполняет автоинкремент или автодекремент регистра EDI.

 STOS   сохранить строку

Инструкция STOS сохраняет значение регистров AL, АХ или ЕАХ (в зависимости от требуемого размера) по адресу, указанному регистром EDI.
Инструкция STOS похожа на С- функцию memset. Подобно инструкциям MOVS и SCAS, инструкция STOS автоинкрементирует или автодекрементирует регистр EDI.

 CMPS   сравнить строки Инструкция CMPS сравнивает два строчных значения и устанавливает соответствующие флажки в EFLAGS. Тогда как SCAS выполняет сравнения символов в единственной строке, CMPS проходит символы в двух строках. Инструкция CMPS похожа на С-функцию memcmp. Подобно остальным строчным манипуляторам, инструкция CMPS сравнивает значения различных размеров, а также выполняет автоинкремент и автодекремент указателей обеих строк.

 ВЕР           повторять по счетчику в EСХ  REPE         повторять, пока равно или счетчик ЕСХ не станет равен 0  REPNE      повторять, пока не равно или счетчик ЕСХ не станет равен 0 Строчные инструкции, хотя и удобны, но не много стоят, когда манипулируют элементом только один раз. Префиксы повторения позволяют выполнять строчные инструкции заданное (в ЕСХ) количество раз или пока не будет выполнено указанное условие. При пошаговом проходе окна Disassembly командой Step Into выполнение такой инструкции повторяется необходимое число раз. Если же при этом используется команда Step Over, то такая инструкция не выполняется повторно. При отладке можно с помощью команды Step Into проверять строки в регистрах ESI или EDI. Другой прием: при поиске аварийного останова в строчной инструкции с префиксом повторения нужно взглянуть на регистр ЕСХ, чтобы увидеть, на какой итерации случился останов.

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

void MemCPY ( char * szSrc , char * szDest , int iLen ) 

{

_asm 

{

MOV ESI , szSrc // Установить исходную строку. 



MOV EDI , szDest // Установить целевую строку ng.

 MOV ЕСХ , iLen // Установить длину копирования.

// Копировать немедленно! 

REP MOVS BYTE PTR [EDI] , BYTE PTR [ESI]

 } 

}

int StrLEN (char * szSrc ) 

{

int iReturn ; _asm 

{

XOR EAX , EAX // Обнулить ЕАХ.

MOV EDI , szSrc // Поместить проверяемую строку в EDI.

 MOV ECX , 0FFFFFFFFh // Максимальное число проверяемых

// символов.

 REPNE SCAS BYTE PTR [EDI] // Сравнивать, пока ЕСХ не станет

// равным 0 или не будет найден конец 

// строки .

СМР ЕСХ ,0 // Если ЕСХ равен 0, то

 JE StrLEN_NoNull // в строке не был найден NULL. 

NOT ECX // Преобразовать ЕСХ в положительное число,

// поскольку он был просчитан.

DEC ЕСХ ' // Подсчет для Попадания на NULL. 

MOV EAX , ЕСХ // Возврат счета. 

JMP StrLen_Done .// Возврат. StrLENJNoNull:

MOV EAX , OFFFFFFFFh // Поскольку NULL не был найден,

И возвратить -1. 

StrLEN_Done: 

}

_asm MOV iReturn , EAX ;

 return ( iReturn ) ; 

}

void MemSET ( char * szDest , irit iVal , int iLen ) 

{  _asm



MOV EAX , iVal // EAX содержит полное значение.

 MOV EDI , szDest // Переместить строку в EDI.

 MOV ECX , iLen // Переместить счет в ЕСХ. 

REP STOS BYTE PTR [EDI] // Заполнить память. 



}

int MemCMP ( char * szMeml , char * szMem2 , int iLen )

 {

int iReturn ;

_asm

{

MOV ESI , szMeml // ESI содержит первый блок памяти.

 MOV EDI , szMem2 // EDI содержит второй блок памяти.

 MOV ECX , iLen // Максимальное число байт для сравнения

// Сравнить блоки памяти. 

REPE CMPS BYTE PTR. 

[ESI], BYTE PTR [EDI] 

JL MemCMP_LessThan // Если szSrc < szDest 

JG MemCMP_GreaterThan // Если szSrc > szDest

// Блоки памяти равны.

XOR EAX', EAX // Возвратить 0.

 JMP MemCMP_Done

MemCMP_Les sThan:

MOV EAX , 0FFFFFFFFh // Возвратить -1.

 JMP MemCMP_Done

 MemCMP_GreaterThan:

 MOV EAX , 1 // Возвратить 1.

JMP MemCMP_Done

 XemCMP_Done: 

}

_asm MOV iReturn , 

EAX return ( iReturn ) ;

}



Навигация


К счастью, окно Disassembly предлагает несколько эффективных способов навигации в подчиненном отладчике.

Первый путь для достижения определенной позиции в подчиненном отладчике — через диалоговое окно Go To, открываемое командой Go To меню Edit или нажатием клавиш <Ctrl>+<G>. Если адрес перехода известен, то его можно ввести в поле Enter address expression и переходить в нужное место в коде прямо по этому адресу. Диалоговое окно Go To поддерживает также интерпретацию символов и контекстной информации, поэтому можно переходить к областям памяти, даже не зная точного адреса.

Например, если имеются символы, загруженные для KERNEL32.DLL, и требуется перейти к коду функции LoadLibrary в окне Disassembly, то для этого следует ввести в диалоговом окне Go То строку

{,, kerne!32}_LoadLibraryA@4

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

Для того чтобы вернуться обратно туда, где находится указатель инструкции, просто сделайте правый щелчок мышью в окне Disassembly и введите команду Show Next Statement. Эта команда также доступна в окнах исходного кода.



Общая последовательность: вход и выход из функции


Большинство функций Windows и в пользовательских программах выполняют вход и выход в одной и той же манере. Для входа в функцию устанавливается пролог, а для выхода — эпилог (компилятор генерирует их автоматически). При установке пролога код получает доступ к локальным переменным и параметрам функции. Объекты доступа называются кадром стека (stack frame). Хотя CPU x86 явно не определяет никакой схемы стекового кадра, операционным системам легче всего использовать для хранения указателя на этот кадр регистр ЕВР (этому способствует конструкция CPU и формат некоторых его инструкций).

_asm

{

// Установка стандартного пролога.

PUSH EBP // Сохранить содержимое регистра стекового кадра.

MOV EBP , ESP // Установить в ЕВР адрес стекового кадра локальной

 // функции.

SUB ESP , 20h // Отвести в стеке 0x20 байт для локальных

// переменных. Инструкция SUB появляется, только 

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

}

Эта последовательность является общей как для отладочных, так и для выпускных построений (финальных версий). Однако в некоторых функциях выпускных построений можно увидеть группу инструкций, помещенных между PUSH и MOV. CPU с множественными конвейерами (например, из семейства Pentium) могут расшифровывать сразу несколько инструкций одновременно, и чтобы воспользоваться этим преимуществом, оптимизатор попытается установить поток инструкций.

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

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

_asm 

{

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

MOV ESP , ЕВР // Восстановить стековое значение.

POP EBP // Восстановить сохраненное значение регистра -.

// стекового кадра. 

}

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

Выбор компиляторами способа генерации кода во многом зависит от того, как оптимизирована программа — по скорости или по размеру. Если установлена оптимизация по размеру, как было настоятельно рекомендовано в главе 2, большинство функций преимущественно используют стандартные стековые кадры. Оптимизация по скорости приводит к более сложной FPO-генерации.

 FPO (Frame Pointer Omission — пропуск указателя кадра). — Пер.



Общие конструкции языка ассемблера


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



Окна Memory и Disassembly


Окна Memory и Disassembly имеют симбиозные отношения. Пытаясь определить, что делает последовательность операций языка ассемблера в окне Disassembly, надо держать окно Memory открытым, чтобы можно было видеть и адреса, и значения. Инструкции языка ассемблера работают в памяти, а память воздействует на выполнение этих инструкций. Окна Disassembly и Memory вместе позволяют наблюдать динамику этих взаимоотношений. Само по себе, окно Memory — просто море чисел, особенно когда происходит аварийный отказ. Однако, комбинируя эти два окна, можно начать вычисления, связанные некоторыми неприятными проблемами аварийных отказов. Совместное использование этих окон наиболее важно при отладке оптимизированного кода, когда прохождение стека отладчика затруднено. Чтобы разрешить аварийную ситуацию, необходимо пройти стек вручную. На первом этапе прохождения стека нужно знать, по каким адресам памяти загружены ваши двоичные файлы. В отладчике Visual C++ 6 добавлено диалоговое окно Module List, отображающее все двоичные файлы, загруженные вашей программой. Оно показывает также имя модуля, путь к модулю в дереве каталогов, порядок загрузки и, самое важное, диапазон адресов загрузки модуля. Поскольку это окно является модальным, лучше записать имена модулей и их загрузочные адреса, потому что эта информация понадобится в будущем неоднократно. Сравнивая элементы стека со списком диапазонов адресов, можно получить некоторое представление о том, какие элементы адресованы в ваших модулях.

Просмотрев диапазоны адресов загрузки, откройте окна Memory и Disassembly. В окне Memory введите в поле Address регистр стека ESP и покажите значения в формате двойного слова, щелкая правой кнопкой мыши в пределах окна и выбирая команду Long Hex Format в контекстном меню. Используя либо свой список адресов загрузки, либо диалоговое окно Module List, начните просмотр чисел в окне Memory слева направо и сверху вниз.

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

Если регистр ESP не содержит ничего, что напоминало бы адрес модуля, выведите дамп регистра ЕВР в окно Memory и выполните те же действия. Освоившись с языком ассемблера, вы сможете просматривать дизассемблерный код, окружающий адрес аварийного останова. Изучение "криминальной" аварийной ситуации позволит вам понять, где бы мог быть расположен адрес возврата — в ESP или в ЕВР.

Поговорим немного сначала о достоинствах, а затем и о недостатках окна Memory. Во-первых, окно Memory — единственное место, где можно просматривать символьные строки, превышающие 255 знаков. Кроме того, окно Memory позволяет просматривать любую переменную или участок памяти.

Теперь о недостатках. Первый состоит в том, что одновременно с отладчиком Visual C++ можно просматривать только один участок памяти, и это ограничение обойти нельзя. Во-вторых, отображаемые данные, которые вы просматриваете, могут резко перемещаться по экрану. Это случается главным образом при изменении формата памяти. Оказалось, что лучше всего выполнять правый щелчок мыши только на адресе, который необходимо видеть (в окне Memory) — и нигде больше. Очевидно, окно Memory запоминает то места, где оно показывает текущую строку адреса. Например, если строка текущего адреса — десятая от вершины окна, а пользователь вводит новый адрес в поле Address, то новый адрес будет отображен в десятой строке. Если при выполнении правого щелчка мыши в окне изменяется формат памяти, окно Memory перемещает строку текущего адреса к позиции правого щелчка, и результат может быть непредсказуемым.

История отладочной войны

Что может быть не так в функции GlobalUnlock?

Ведь она просто разыменовывает указатель.

Сражение

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


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

Результат

Во-первых, не было уверенности, что кто-то еще использует функции дескрип-торной памяти (handle-based memory) GlobalAlloc, GlobalLock, GlobalFree и

GlobalUnlock) в \Л/1п32-програм|»ировании. Однако после просмотра в коде дизассемблера сторонних управляющих элементов стало понятно, что их автор, очевидно, перенес их из 16-разрядной кодовой базы. Первая гипотеза состояла в том, что эти элементы неправильно взаимодействовали с API-функциями де-скрипторной памяти.

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

В некоторой точке после закрытия диалогового окна Print, мы заметили, что стартовавшая функция GlobalAlloc возвращала значения дескриптора, которые завершались нечетными цифрами, например, 5. Поскольку дескрипторная память в Win32 нуждается в разыменовании указателя, чтобы преобразовать дескриптор в значения памяти, я сразу же понял, что наткнулся на критическую ситуацию. Каждое распределение памяти в Win32 должно заканчиваться шест-надцатеричными цифрами 0, 4, 8 или С, потому что все указатели должны быть выровнены на двойное слово. Значения же дескрипторов, выходящие из GlobalAlloc, были явно (и довольно значительно) искажены.

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


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

Проверка исходного кода показала, что приложение было полным 32-разрядным Windows-приложением и ничего не делало с дескрипторной памятью. Тогда мы проверили функцию печати — ее код выглядел безупречно.

Было решено сузить область дублирования аварии. После нескольких прогонов оказалось, что для аварийного останова нужно было лишь открыть диалоговое окно Print и изменить ориентацию печатной страницы, а после закрытия окна Print надо было просто повторно открыть его. Аварийный останов происходил вскоре после второго закрытия окна. Ориентация страницы, вероятно, просто изменяла байт где-то в памяти, что и служило причиной аварии.

Хотя при начальном чтении код выглядел безупречно, я просматривал каждую строку в обратном порядке и перепроверял ее по документации MSDN. Через 10 минут ошибка была найдена. Команда сохраняла структуру данных PRINTDLG, используемую для инициализации диалогового окна Print, с помощью API-функции PrintDlg. Третье поле в этой структуре — hDevMode — является значением дескрипторной памяти, которую выделяет диалоговое окно Print. Ошибка состояла в том, что разработчики использовали это значение памяти как регулярный указатель и должным образом не разыменовывали дескриптор или вызывали функцию GlobalLock для дескриптора. Изменяя значения в структуре DEVMODE, на самом деле они выполняли запись в глобальную таблицу дескрипторов процесса. Эта таблица является участком памяти, в котором хранятся все распределения динамической памяти дескрипторов. При случайной записи в глобальную таблицу дескрипторов обращение к GlobalAlloc использует неправильные смещения, а значения, вычисленные по такой таблице, приводили к тому, что функция GlobalAlloc возвращала некорректные указатели.



Уроки

Урок первый — нужно тщательно читать документацию. Если в документации говорится, что структура данных есть "перемещаемый глобальный объект памяти", то память предназначена для дескрипторов, и необходимо должным образом разыменовывать этот дескриптор памяти или использовать на нем функцию GlobalLock. Хотя Windows 3.1 сильно устарела, некоторые 16-разрядные компоненты все еще входят в Win32 API, и следует обращать на них внимание.

Урок второй — глобальная таблица дескрипторов хранится в перезаписываемой памяти. Лично я считаю, что такая важная структура операционной системы должна храниться в памяти "только-для-чтения". Почему Microsoft не защитил эту память? Могу предположить, что дело в следующем. Технически память дескрипторов используется только для обратной совместимости, и 32-разрядные Windows-приложения должны бы были использовать специфические для Win32 типы памяти. Защита глобальной таблицы дескрипторов потребовала бы двух переключений контекста (из режима пользователя в режим ядра) на каждом вызове функции дескрипторной памяти. Поскольку эти контекстные переключения очень дороги (в смысле времени их обработки), можно понять, почему Microsoft не защитил глобальную таблицу дескрипторов от записи.

Урок последний — мы потратили слишком много времени, сосредоточившись на стороннем элементе управления. Всего мне потребовалось около семи часов, чтобы найти ошибку. Однако тот факт, что ошибка могла дублироваться только при открытии диалогового окна Print, которая пришла из кода приложения, должен был предупредить меня, что проблема была "ближе к дому" (а не где-то на стороне).





Окно Disassembly


Теперь, немного познакомившись с языком ассемблера, рассмотрим свойства окна Disassembly отладчика Visual C++, облегчающие выполнение отладочных работ, и способы уменьшения времени, проводимого в окне Disassembly.



Основы CPU


Довольно продолжительное время мы живем в окружении набора команд процессоров компании Intel, уходящего корнями в CPU 8086, который Intel впервые выпустил в 1978 году. Во времена MS-DOS и 16-разрядной операционной системы Windows язык ассемблера казался немного странным и трудным (из-за методики работы CPU с памятью — через 64 Кбайтные блоки, называемые сегментами). К счастью, сегодня иметь дело с языком ассемблера намного легче, потому что в Microsoft Windows 98 и 2000 CPU имеет прямой доступ к полному адресному пространству.

Язык ассемблера, который я представлю в этой главе, имеет дело с основным набором 32-разрядных команд, совместимых со всеми CPU архитектуры х86 от компаний Intel и AMD (Advanced Micro Devices). Продвинутые свойства процессоров Intel Pentium, например, ММХ в обычной практике не применяются, потому что Windows использует относительно немного таких свойств. Здесь мы не будем углубляться в действительно неприятные аспекты форматов инструкций языка ассемблера, такие как байты ModR/M и SIB, которые указывают способ доступа к памяти. Не будут рассмотрены также инструкции с плавающей точкой. Операции на модуле с плавающей точкой Intel CPU (FPU) подобны обычным инструкциям. Главные их отличия состоят в том, что FPU имеет собственный набор регистров, а инструкции с плавающей точкой используют стековую архитектуру регистров.

Напомним один ключевой момент — CPU x86 обладают большой гибкостью и реализуют много способов выполнения аналогичных операций. К счастью, компиляторы Microsoft проделывают большую работу по выбору самого быстрого способа выполнения операции и многократному использованию этой конструкции везде, где она применима, распознавая, таким образом, в какой секции кода ее легче выполнить. В данной главе описаны наиболее широко используемые инструкции, которые можно встретить в программах на языке ассемблера. Если этот материал вдохновит читателя на более подробное изучение семейства CPU компании Intel — а я надеюсь, что это так и будет — ему придется загрузить PDF1-файлы трехтомного руководства разработчиков архитектуры программного обеспечения компании Intel ("Intel Architecture Software Developer's Manual") с узла www.intel.com. Intel даже предлагает эти руководства в свободно распространяемой книжной форме.

1 PDF — Portable Document Format. Формат переносимых (мобильных) документов компании Adobe. — Пер.



Полный пример


Представив все важные части языка Intel-ассемблера, обратимся к полному примеру одной из API-функций операционных систем Win32. В листинге 6.2 показан полностью прокомментированный дизассемблерный код функции IstrcpyA из библиотеки KERNEL32.DLL пакета обслуживания Service Pack 4 операционной системы Windows NT 4. Функция IstrcpyA копирует одну строку в другую. Эту функция выбрана потому, что она показывает понемногу все, что обсуждалось до сих пор в этой главе, а также потому, что цель этой функции легко понять. Комментарии, выделенные точками с запятой, делают их настолько подробными, насколько это возможно.

Листинг 6-2. IstrcpyA- полный пример на языке ассемблера

; Прототип функции:

; LPTSTR Istrcpy ( LPTSTR IpStringl , LPCTSTR lpString2 )

IstrcpyA:

; Начать подготовку к установке SEH-кадра.

77F127E6: MOV EAX , ES:[00000000h]

; Установить регулярный кадр стека.

77F127EC: PUSH EBP

77F127ED: MOV EBP , ESP

; Продолжить установку SEH-кадра.

77F127EF: PUSH OFFh

77F127F1: PUSH 77F3CD48h

77F127F6: PUSH _except_handler3

77F127FB: PUSH EAX

77F127FC: MOV DWORD PTR FS:[00000000h] , ESP

; Сохранить 12 байтов для локальных переменных.

77F12803: SUB ESP , 00Ch

; Сохранить значения регистра, которые будут разрушены

; как часть этой функции

77F12806: PUSH EBX

77F12807: PUSH ESI

77F12808: PUSH EDI

Сохранить текущую вершину стека в локальной переменной.

Эта строка - также часть установки SEH. 

7F12809: MOV DWORD PTR [EBP-018h] , ESP

Инициализировать эту локальную переменную к 0. Эта строка указывает,

что функция входит в блок _try. 

77F1280C: MOV DWORD PTR [EBP-004h] , 00000000h

Первый шаг после установки должен получить длину строки

копирования. Строка копирования - второй параметр.

Переместить второй параметр (строку, которая будет скопирована) в EDI.

77F12813: MOV EDI , DWORD PTR [EBP+OOCh]

Istrcpy будет просматривать 4,294,967,295 байтов до NULL-терминатора.

EDX используется позже со значением -1, а здесь он инициализируется.

Помните, что REPNE SCAS использует регистр ЕСХ как счетчик цикла.


 77F12816: MOV EDX , FFFFFFFFh

 77F1281B: MOV ЕСХ , EDX

; Обнуление EAX, так что SCAS будет искать символ NULL. 

77F1281D: SUB EAX , EAX ; Поиск символа NULL.

77F1281F: REPNE SCAS BYTE PTR [EDI]

; Поскольку ЕСХ считает в обратном направлении, переключить все биты так, 

; чтобы длина строки оказалась в ЕСХ. Эта длина включает символ NULL.

 77F12821: NOT ЕСХ

;Поскольку REPNE SCAS инкрементирует также и EDI, вычесть длину строки

;из EDI так, чтобы EDI указывал обратно, на начало строки.

 77F12823: SUB EDI , ЕСХ

Держать длину строки в ЕАХ. 

77F12825: MOV ЕАХ , ЕСХ

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

;операндом строчных инструкций.

77F12827: MOV ESI , EDI

;Переместить первый параметр (целевую строку) в EDI. 

77F12829: MOV EDI , DWORD PTR [EBP+008h]

;Длина строки была подсчитана в байтах. Делите длину строки на 4,

;чтобы получить число двойных слов (DWORDS). Если число символов

;нечетное, REPE MOVS не будет копировать их всех.

;Любые остающиеся байты

;копируются прямо после REPE MOVS. 

77F1282C: shr ЕСХ , 002h

;Копировать строку второго параметра в строку первого. 

77F1282F: REPE MOVS DWORD PTR [EDI] , DWORD PTR [ESI]

;Переместить сохраненную длину строки в ЕСХ. 

77F12831: MOV ЕСХ , ЕАХ

;Операция AND счетчика с числом 3, чтобы получить

;остающиеся байты для копирования 

77F12833: AND ЕСХ , 00Зh

;Копировать остающиеся байты из строки в строку.

 77F12836: REPE MOVS BYTE PTR [EDI] , BYTE PTR [ESI]

Istrcpy возвращает первый параметр, поэтому переместить

;возвращаемое значение в ЕАХ 

77F12838: MOV ЕАХ , DWORD PTR [EBP+008h]

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

;покидает этот блок try/except. 

77F1283B: MOV DWORD PTR [EBP-004h] , EDX

;Функция завершена; выйдите и переместитесь домой. 

77F1283E: JMP 77F12852h

;Если вы просмотрите эту функцию, то заметите, что фактически нет

;инструкции, которая переходит или ветвится по адресу 0x77F12840.


Этот

; адрес является частью фильтра исключений кадра SEH. Фильтр исключений -

;это часть кода, которая сообщает возврату из SEH, что нужно делать.

;Здесь фильтр исключений эквивалентен функции

;_except (EXCEPTION_EXECUTE_HANDLER). Код возврата должен выполнить

;обработчик, который расположен справа от инструкции RET. Подробные

;сведения о фильтрах исключений можно найти в MSDN

;или в книге Джеффри Рихтера

;"Программирование приложений для Microsoft Windows" (Jeffrey Richter,

;"Programming Applications for Microsoft Windows".-

;Microsoft Press, 1999)

77F12840: MOV EAX , 00000001h

77F12845: RET

; Следующие три инструкции — блок исключения для функции.

; Восстановить стек, сохраненный ранее.

77F12846: MOV ESP , DWORD PTR [EBP-018h]

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

; покидает этот блок try/except.

77F12849: MOV DWORD PTR [EBP-004h] , FFFFFFFFh

; Установить 0 в качестве неуспешного возвращаемого значения.

77F12850: XOR ЕАХ , ЕАХ

; Получить предыдущий SEH-кадр.

77F12852: MOV ECX , DWORD PTR [EBP-010h]

; Восстановить предварительно сохраненный в стеке EDI.

77F12855: POP EDI

; Отменить SHE-кадр.

"7F12856: MOV DWORD PTR FS: [00000000h] , ECX

; Восстановить предварительно сохраненный в стеке ESI.

77F1285D: POP ESI

; Восстановить EBI, предварительно сохраненный в стеке.

77F1285E: POP EBX

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

77F1285F: MOV ESP , ЕВР

77F12861: POP EBP

; Возврат в вызывающую программу и очистка 8 байтов стека.

; Istrcpy is a __sdtcall function.

77F12862: RET 00008h



Пример соглашений о вызовах


В листинге 6-1 показан пример всех соглашений о вызовах из окна Disassembly отладчика Visual C++. В нем объединены все инструкции, рассмотренные до настоящего момента, и соглашения о вызовах. Исходный код примера (CALLING.CPP) находится на сопровождающем компакт-диске.

Для облегчения просмотра код листинга 6-1 имеет отладочную структуру; кроме того, код фактически ничего не делает. Каждая функция просто вызывается с подходящим соглашением о вызове. Обратите особое внимание на то, как размещены параметры в функциях, и как очищается стек. Чтобы сделать листинг более легким для чтения, между функциями вставлены инструкции NOP.

Листинг 6-1 Пример соглашений о вызовах

6: // Строки , передаваемые каждой функции -

7: static char * g_szStdCall = "_stdcall";

8: static char * g_szCdeclCall = "_cdecl";

9: static char * g_szFastCall = "_fastcall" ;

10: static char * g_szNakedCall = "_naked" ;

11:

12: // extern "С" отключает всю декорацию имен C++ .

13: extern "С"

14: {

15: .

16: // _cdecl-функция

17: void CDeclFunction { char * szString ,

18: unsigned long ulLong ,

19: char chChar ) ;

20:

21: // stdcall-функция

22: void _stdcall StdCallFunction ( char * szString ,

23: unsigned long ulLong ,

24: char chChar ) ;

25: // _fastcall-функция

26: void _fastcall FastCallFunction ( char * szString ,

27: unsigned long ulLong ,

28: char chChar ) ;

29:

30: /'/ "Голая" функция. Нет спецификатора ни для определения,

31: //ни для декларирования функции.

32: int NakedCallFunction ( char * szString ,

33: unsigned long ulLong ,

34: ' char chChar ) ;

35: }

36:

37: void main ( void )

38: {

00401000 55 push ebp

00401001 8B EC mov ebp,esp

00401003 53 push ebx

00401004 56 push esi

00401005 57 push edi

39: // Вызвать каждую функцию для генерации кода. Я разделяю

40: // каждую функцию парой NOP-байтов, чтобы облегчить чтение

41: // кода дизассемблера

42: _asm NOP _asm NOP

00401006 90 nор


00401007 90 nор
43: CDeclFunction ( g_szCdeclCall , 1 , 'а' ) ;
00401008 6А 61 push 61h
0040100A 6A 01 push 1
0040100C Al 14 30 40 00 mov eax,[g_szCdeclCall (00403014)]
00401011 50 push eax
00401012 E8 45 00 00 00 call CDeclFunction (0040105с)
00401017 83 C4 ОС add esp,OCh
44: _asm NOP _asm NOP
0040101A 90 nор
0040101B 90 nор
45: StdCallFunction ( g_szStdCall , 2 , 'b' ) ;
0040101C 6A 62 push 62h
0040101E 6A 02 push 2
00401020 8B OD 10 30 40 00 mov ecx,dword ptr
[g_szStdCall (00403010)]
00401026 51 push ecx
00401027 E8 3D 00 00 00 call StdCallFunction (00401069)
46: _asm NOP _asm NOP
0040102C 90 nор
0040102D 90 nор
47: FastCallFunction ( g_szFastCall , 3 , 'c' ) ;
0040102E 6A 63 push 63h
00401030 BA 03 00 00 00 mov edx,3
00401035 8B OD 18 30 40 00 mov ecx,dword ptr
[g_szFastCall (00403018)]
0040103В Е8 38 00 00 00 call FastCallFunction (00401078)
48: _asm NOP _asm NOP
00401040 90 nор
00401041 90 nор
49: NakedCallFunction ( g_szNakedCall , 4 , 'd' ) ;
00401042 6A 64 , push 64h
00401044 6A 04 push 4
00401046 8B 15 1C 30 40 00 mov edx,dword ptr
[g-_szNakedCall (0040301с)]
0040104C 52 push edx
0040104D E8 40 00 00 00 call NakedCallFunction (00401092) 
00401052 83 C4 ОС add esp,OCh 
50: _asm NOP _asm NOP
00401055 90 nор
00401056 90 nор
51:
52: }
00401057 5F pop edi
00401058 5E pop esi
00401059 5B pop ebx
0040105A 5D pop ebp
0040105В СЗ ret
53:
54: void CDeclFunction ( char * szString ,
55: unsigned long ulLong ,
56: char chChar )
57: {
0040105C 55 push ebp
0040105D 8B EC mov ebp,esp
0040105F 53 push ebx
00401060 56 push esi
00401061 57 push edi 58: _asm NOP _asm NOP
00401062 90 nор
00401063 90 nор 
59: }
00401064 5F pop edi
00401065 5E pop esi
00401066 5B pop ebx
00401067 5D pop ebp
00401068 C3 ret
60:
61: void _stdcall StdCallFunction ( char * szString ,
62: unsigned long ulLong ,
63: char chChar )
64: {
00401069 55 push ' ebp
 0040106A-8B EC mov ebp,esp 
0040106C 53 push ebx


 0040106D 56 push esi 
0040.106E 57 push edi 
65: _asm NOP _asm NOP 
0040106F 90 nор
00401070 90 nор 
66: }
00401071 5F pop edi
00401072 5E pop esi
00401073 5B pop ebx
00401074 5D pop ebp
00401075 C2 ОС 00 ret OCh
67:
68: void _fastcall FastCallFunction ( char * szString ,
69: unsigned long ulLong ,
70: char chChar )
71: {
00401078 55 push ebp
00401079 8B EC mov ebp,esp
 0040107B 83 EC 08 sub , esp,8
 0040107E 53 push ebx 
0040107F 56 push esi
00401080 57 push edi
00401081 89 55 F8 mov dword ptr [ebp-8],edx 
00401084 89 4D FC mov dword ptr [ebp-4],ecx 
72: _asm NOP _asm NOP
00401087 90 nор
00401088 90 nор 
73: }
00401089 5F pop edi 
0040108A 5E pop esi 
0040108В 5В pop ebx
 0040108C 8В Е5 , mov esp,ebp
0040108Е 5D pop ebp
0040108F C2 04 00 ret 4
74:
75: _declspec(naked) int NakedCa11Function ( char * szString ,
76: unsigned long ulLong ,
77: . char chChar )
78: {
00401092 90 nор
00401093 9.0 nор
79: _asm NOP _asm NOP
80: // Голые функции должны явно выполнять возврат.
81: _asm RET
00401094 СЗ ret

Просмотр параметров в стеке


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

С тех пор как Visual Basic 5 позволил получать "родной" код (native code), я хотел посмотреть, как работает "родная" компиляция. Каталог Visual Basic включал файлы LINK.EXE и С2.ЕХЕ. Эти две программы являются также частью Visual C++, и было любопытно посмотреть, как их использует Visual Basic, как вообще работает компиляция. Как можно понять из названия, LINK.EXE связывает объектные файлы и производит выполняемый двоичный файл. С2.ЕХЕ довольно сложен. В системе Visual C++ файл С2.ЕХЕ — это генератор кода, который производит машинный код.

Из интегрированной среды разработки (IDE) Visual C++ я открыл VB6.EXE как программу для отладки. Поскольку были загружены символы, нужно было установить точку прерывания на {,, kernel32}_CreateProcessA@40. Запустив Visual Basic, я создал простой проект, установил свойства 'Проекта для создания "родного" кода и выбрал команду File|Make из IDE Visual Basic. Точка прерывания на _CreateProcessA@4о,обеспечивает управление отладчика при запуске или С2.ЕХЕ, или LINK.EXE.

В Windows 2000 RC2 точка прерывания на _CreateProcessA@40 останавливает отладчик на адресе 0x77E8D7E6, когда инструкция, подлежащая выполнению (PUSH EBP), устанавливает стандартный кадр стека. Поскольку точка останова находится на первой инструкции функции createProcess, вершина стека содержит параметры и адрес возврата. Затем при помощи команды View|Debug Wmdows|Memory я открыл окно Memory и ввел в поле Address строку ESP, которая является именем регистра указателя стека, чтобы просмотреть содержимое стека.

По умолчанию данные в окне Memory отображаются в байтовом формате. Поиск при этом может быть довольно утомительным. Щелчок правой кнопкой мыши в окне Memory позволяет выбрать формат: byte, short hex (2 байта или WORD) и long hex (4 байта или DWORD).


На рис. 6.4 показан стек в окне Memory отладчика в начале точки прерывания на функции CreateProcess. Первое значение — адрес возврата для инструкции OxFB6B3F6J 10 следующих — Параметры фуyrwbb CreateProcess (СМ. табл. 6.5). Параметры функции CreateProcess занимают 40 байт, а каждый параметр имеет длину 4 байта. Стек растет от старших адресов памяти к младшим, а параметры помещаются в стек справа налево, поэтому параметры появляются в окне Memory в том же порядке, как в определении функции.

Рис. 6.4. Стек в окне Memory отладчика Visual C++

Просматривать индивидуальные значения первых двух параметров можно двумя способами. Первый состоит в том, чтобы использовать окно Memory, переключая его в байтовый формат и рассматривая конкретный адрес. Второй, более легкий способ, состоит в том, чтобы буксировать (мышью) адрес, который требуется рассмотреть, в окно Watch. В окне Watch для просмотра адреса следует использовать оператор приведения типов. Например, чтобы просмотреть параметр ipAppiicationName в примере, надо поместить в окно Watch строку <char*)Oxooi2EAC4. Работает любой способ просмотра, и оба должны показать следующие значения:

0х0012ЕАС4 "c:\vb\C2.EXE"

0х0012ЕВС4 "С@ -11 "e:\temp\VB815574

-f "c:\junk\vb\Forml.frm _W 3 _Gy _G5

-GS4096 _dos _Z1

-Fo"c:\junk\vb\Forml.OBJ" _Zi _QIfdiv

-ML _basic"

Таблица 6.5. Параметры, которые VB6.EXE передает В функцию CreateProcess

Значение

Тип

Параметр

0x001 2ЕАС4

LPCTSTR

IpApplicationName

0x0012EBC4

LPTSTR

IpCommandLine

0x00000000

LPSECURITY_ATTRIBUTES

IpProcessAttributes

0x00000000

LPSECURITY_ATTRIBUTES

IpThreadAttributes

0x00000001

BOOL

blnheritHandles

0x08000000

DWORD

dwCreationFlags

0x00000000

LPVOID

IpEnvi r onment

0x00000000

LPCTSTR

IpCurrentDirectory

0x001 2EA3C

LPSTARTUPINFO

IpStartupInfo

0x001 2EC60

LPPROCESS_INFORMATION

IpProcessInformation

Получение предшествующих параметров не составило труда, потому что функция была остановлена на первой инструкции, прежде чем она поместила в стек дополнительные элементы. Для проверки параметров в середине функции нужно проделать немного больше работы. Например, найти положительные смещения относительно ЕВР. Иногда же лучше просто открыть окно Memory и начать просмотр.



Регистры


Именно через регистры передается приложению каждый бит данных, который оно обрабатывает, и знание роли каждого регистра поможет вам распознать неудачный участок кода. CPU x86 имеют восемь регистров общего назначения (ЕАХ, ЕВХ, ЕСХ, EDX, ESI, EDI, ESP и ЕВР), шесть сегментных регистров (CS, DS, ES, SS, FS и GS), указатель инструкций (команд) EIP и регистр флагов (EFLAGS). Есть и другие регистры, такие как регистры отладки и управляющие машинные регистры, но это регистры специального назначения, и вы не встретитесь с ними при нормальной отладке в режиме пользователя. Все регистры общего назначения, перечисленные в табл. 6.1, являются 32-разрядными. Заметьте, что некоторые из них допускают мнемонические обозначения для доступа к различным частям полного 32-разрядного регистра. Единственный сегментный регистр, представляющий интерес для данной главы, — это регистр FS, который содержит блок информации потока (TIB), содержащий описание текущего выполняемого потока. Используются и другие сегментные регистры, но операционная система конфигурирует их таким образом, что они оказываются прозрачными по отношению к нормальной операции. Указатель инструкции содержит адрес текущей выполняющийся инструкции.

Таблица 6.1. Регистры общего назначения

32-разрядный регистр

16-разрядный доступ

Доступ к младшему байту (биты 0-7)

Доступ к старшему байту (биты 8-1 5)

Специфика использования

ЕАХ

АХ

AL

АН

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

ЕВХ

ВХ

BL

ВН

Здесь хранится базовый адрес объекта в памяти

ЕСХ

СХ

CL

СН

Эти регистры используются счетчиками инструкций циклов

EDX

DX

DL

DH

Здесь хранятся 32 старших бита 64-битных значений

ESI

SI

Здесь хранится исходный адрес инструкций перемещения или сравнения в памяти

EDI

DI

Здесь хранится целевой адрес инструкций перемещения или сравнения в памяти

ESP

SP

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


EBP

ВР





Указатель база/кадр. Этот регистр содержит адрес стекового кадра для процедуры

Регистр флагов EFLAGS содержит флажки состояний и флажки управления. Биты EFLAGS устанавливаются различными инструкциями, чтобы указать результат их выполнения. Например, бит ZF (Zero Flag — нулевой флажок) равен 1, если результатом выполнения инструкции является нуль (0). В главе 4 описан перевод CPU в пошаговый режим (single-step mode), который включал установку бита TF (Trap Rag— флажок трассировки) в регистре EFLAGS. На рис. 6.1 приведено окно регистров Registers отладчика Visual C++. Окно Registers показывает регистр EFLAGS как EFL. Заметьте, что регистры с плавающей точкой в окне Registers не отображены. Их можно скрыть щелчком правой кнопки мыши в окне Registers и сбросом пункта Floating-Point Registers в раскрывшемся контекстном меню.

В табл. 6.2 описаны флажки, показанные в окне Registers. К сожалению, документация Visual C++ не раскрывает значения этих флажков, а мнемоника, применяемая для них в системе Visual C++, не соответствует мнемонике Intel, так что при ссылках на документацию Intel нужна соответствующая трансляция.

Рис. 6.1. Окно Registers из Visual C++

С окном Registers связана одна незначительная особенность: при обновлении флажков цвет значения регистра EFL не изменяется (в отличие от обычных регистров, значения которых при обновлениях выделяются другим цветом). Но необходимость просматривать индивидуальные значения флажков возникает довольно редко. Для облегчения разметки изменяющихся флажков можно сделать следующее: нажать кнопку New Text File (в панели Standard) и открыть новый временный файл. Затем скопировать (в буфер обмена) существующие флажки из окна Registers и вставить их в окно временного текста, чтобы сравнить их значения до и после изменения.

Таблица 6.2. Значения флажков окна Registers

Флажок окна Registers

Значение

Мнемоника в руководстве Intel

Примечания

OV

Overflow Flag (флажок переполнения)

OF

Устанавливается в 1, если операция закончилось целым переполнением или потерей значимости

UP

Direction Flag (флажок направления)

DF

Устанавливается в 1, если строчная инструкция выполняет обработку в направлении от самого высокого до самого низкого адреса (автодекремент). 0 означает, что строчная инструкция выполняет обработку в противоположном направлении: от самого низкого до самого высокого адреса (автоприращение)

El

Interrupt Enable Flag (флажок разрешения прерываний)

IF

Устанавливается в 1, если прерывания разрешены. Этот флажок будет всегда включен (1) в пользовательском режиме отладчика

PL

Sign Flag (флажок знака)

SF

Отображает самый старший (знаковый) бит результата инструкции. Устанавливается в 0 для положительных значений или в 1 — для отрицательных

ZR

Zero Flag (нулевой флажок)

ZF

Устанавливается в 1, если результатом инструкции является 0 (нуль). Этот флажок важен для инструкций сравнения

AC

Auxiliary Carry Flag (вспомогательный флажок переноса)

AF

Устанавливается в 1, если двоично-десятичная (BCD) операция генерирует служебный перенос или заем старшего разряда

PE

Parity Flag ' (флажок четности)

PF

Устанавливается в 1, если наименее значимый (младший) байт результата содержит четное число бит, установленных в 1

CY

Carry Flag (флажок переноса)

CF

Устанавливается в 1, если арифметическая операция генерирует перенос или заем из наиболее значащего (знакового) бита результата. Устанавливается в 1 также при переполнении в операциях целочисленной арифметики без знака

Еще одно важное свойство окна Registers — в нем можно редактировать значения регистров. Для этого нужно поместить курсор на первую цифру изменяемого числа (справа от знака равенства для соответствующего регистра) и ввести новое значение.



Регистры и окно Watch


Окно Watch отладчика Visual C++ "знает", как нужно декодировать мнемонику всех регистров в их значения. Поэтому, просматривая, например, инструкцию, манипулирующую со строками, можно ввести в окно Watch строку (char*)@EDi, чтобы просматривать данные в формате, который легче читать.



В этой главе представлен язык



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

Самые общие простые инструкции


 MOV переместить

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

SwapRegisters ( void )

{  _asm

 {

// Регистр EAX используется как временное хранилище.

 // Обмен значений регистров ЕСХ и ЕВХ. 

MOV ЕАХ , ЕСХ

MOV ECX , EBX 

MOV EBX , EAX 

}

 SUB вычитание

Инструкция SUB реализует операцию вычитания. Значение исходного операнда (второго) вычитается из значения целевого (первого) операнда, а результат сохраняется в целевом операнде.

 ADD сложение

Инструкция ADD добавляет значение исходного операнда (второго) к значению целевого операнда (первого) и сохраняет результат в целевом операнде.

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

Инструкция INT 3 — это команда прерывания для Intel CPU. Компиляторы Microsoft используют эту инструкцию как заполнитель между функциями в файле. Подобное заполнение поддерживает выравнивание РЕ-секций (Portable Executable sections), базирующееся на ключе /ALIGN компоновщика (по умолчанию такое выравнивание производится по границе 4 Кбайтовых областей).

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

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



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


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

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

Существует всего пять соглашений о вызовах, но только три из них — наиболее общие: стандартный вызов (__stdcall), С-декларация (__cdecl) и this-вызов. Стандартный вызов и С-декларацию программист может указывать самостоятельно, а this-вызов применяется в С-программе автоматически, когда он задает способ передачи этого указателя. Два других соглашения о вызовах — это быстрый вызов (_fastcalll) и так называемые "голые" (naked) соглашения о вызовах. По умолчанию операционные системы Win32 не используют соглашение быстрого вызова в коде пользовательского режима, потому что оно не переносимо на другие CPU. Соглашение о "голых" вызовах применяется для программирования драйверов виртуальных устройств (VxD) и в тех случаях, когда программист хочет самостоятельно управлять прологом и эпилогом (об этом будет рассказано в главах, 12 к 14).

В табл. 6.3 перечисляются все соглашения о вызовах. Вспомним описание схемы декорирования1 имен для установки точек прерывания на системных функциях из главы 5. Из табл. 6.3 ясно, что схему декорирования имен диктует соглашение о вызовах.

 "Декорированное" имя в C++ — это генерируемая компилятором строка, содержащая, кроме собственно имени, символы, используемые компилятором или компоновщиком для получения информации о типе.
— Пер.

Читатель, никогда не встречавшийся с соглашениями о вызовах, может задаться вопросом: почему существуют различные их типы?. Различия между вызовами _cdecl и _stdcall довольно тонкие. При стандартном вызове вызываемая функция очищает стек, поэтому она должна точно "знать" количество ожидаемых параметров. В связи с этим функция стандартного вызова не может иметь переменного числа аргументов (как, например, printf). Поскольку для функций _cdeci стек очищает вызывающая программа, функции с переменным числом аргументов допустимы. Стандартный вызов используется по умолчанию для системных функций Win32, а также для функций языка Visual Basic.

Таблица 6.3. Соглашения о вызовах

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

Передача параметров

Поддержка стека

Декорирование имен

Замечания

_cdecl

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

Аргументы из стека удаляет вызывающая программа. Это единственное соглашение о вызовах, которое допускает переменные списки аргументов

Символ подчеркивания в качестве префикса перед именами функций, как в_Роо

Используется по умолчанию для функций С и C++

_ stdcall

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

Свои собственные аргументы из стека удаляет сама вызванная функция

Символ подчеркивания в качестве префикса перед именами функций и суффикс @ , за которым следует десятичное число байт в списке аргументов, как в_Роо@12

Используется почти всеми системными функциями и, по умолчанию, внутренними функциями Visual Basic

_ fastcall

Два первых DWORD-параметра передаются в регистрах ЕСХ и EDX; остальные передаются справа налево

Аргументы из стека удаляет вызывающая функция

Префикс @ перед именем и суффикс @ после него, за которым следует десятичное число байт в списке аргументов, как в ®Foo@12

Применяется только в Intel CPU. Это соглашение о вызовах используется по умолчанию для компиляторов Borland Delphi

this

Справа налево. Параметр this передается в регистре ЕСХ

Аргументы из стека удаляет вызывающая функция

Нет

Используется автомат-ски методами классов C++, если не указан стандартный вызов. Все СОМ-методы объявляются со стандартным вызовом




naked

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

Аргументы из стека удаляет вызывающая функция

Нет

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



и специальные приемы, которые могут



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

Создание или уничтожение SEH-кадра


Первые инструкции после установки кадра стека часто похожи на следующий фрагмент, который является стандартным кодом, начинающим _try-блок. Первый узел в цепочке SEH-обработчиков расположен в TIB со смещением 0. В показанном ниже фрагменте кода дизассемблера компилятор помещает в стек значение данных и указатель на функцию _except_ handlers. Первая инструкция MOV обращается к TIB. Смещение 0 указывает, что узел добавляется к вершине цепочки исключений. Две последних инструкции показывают, каким образом код перемещает фактический узел в цепочку

PUSH 004060d0

PUSH 004014a0

MOV EAX , FS:[00000000]

PUSH EAX

MOV DWORD PTR FS:[0] , ESP

Хотя этот пример довольно прост и понятен, компилятор не всегда производит такой аккуратный код. Иногда он расширяет область создания SEH-кадра. В зависимости от флажков генерации кода и оптимизации, компилятор перемещает окружающие инструкции так, чтобы усилить преимущества конвейерной обработки CPU. Следующий пример дизассемблерного кода, в котором загружены символы библиотеки KERNEL32.DLL, демонстрирует запуск функции IsBadReadPtr из Microsoft Windows NT 4.

MOV EAX , FS:[00000000h]

PUSH EBP

MOV EBP , ESP

PUSH 0FFh

PUSH 77F3DlE8h

PUSH _except_handler3

PUSH EAX

MOV EAX , [BaseStaticServerData];

MOV DWORD PTR FS:[0000000h] , ESP

Как показывает следующий фрагмент кода, уничтожение SEH-кадра намного проще, чем его создание. Основной момент, который нужно запомнить, это то, что запись FS: [0] означает доступ к SEH.

MOV ЕСХ , DWORD PTR [EBP-lOh] . 

MOV DWORD PTR FS:[0] , ECX

Организация доступа к TIB

Значение в FS:[18] является линейным адресом структуры TIB. В следующем фрагменте кода реализация GetCurrentThreadid (из Windows 2000) получает сначала линейный адрес TIB-блока и затем, в позиции со смещением 0x24 (в Т1В-блоке) — фактический идентификатор (ID) потока.

GetCurrentThreadid:

MOV EAX , FS:[00000018h]

MOV EAX , DWORD PTR [EAX+024h]

RET

Доступ к локальному хранилищу потока

Локальное хранилище потока (Thread Local Storage — TLS) — это механизм Win32, который позволяет каждому потоку (в многопоточной ситуации) иметь свой собственный экземпляр глобальных переменных. Указатель на массив локального хранилища потока размещается в структуре TIB со смещением 0х2С. Следующий фрагмент кода дизассемблера показывает, как получить доступ к указателю локального хранилища потока.

MOV ЕСХ , DWORD PTR FS:[2Ch] 

MOV EDX , DWORD PTR [ECX+EAX*4]



Сравнение и проверка


СМР  сравнить два операнда

Инструкция СМР сравнивает первый и второй операнды, вычитая второй из первого, сбрасывая результаты и устанавливая соответствующие флаги в регистре EFLAGS. Можно представлять инструкцию СМР как условную часть С-оператора if. В табл. 6.4 приведены различные флаги и значения, которым они соответствуют при выполнении инструкции СМР.

Таблица 6.4. Результирующие значения и установки флажков инструкции СМР

Результат (сравнения первого операнда со вторым)

Установки флажков регистра EFLAGS

Установки флажков руководства Intel

Равно 

Меньше чем

ZR = 1 

PL != OV

ZF=1 

SF != OF

Больше чем

ZR = 0 and PL = 0V

ZF = 0 и SF = OF

Не равно

ZR = 0

ZF = 0

Больше чем или равно

PL = OV

SF = OF

Меньше чем или равно

ZR = 1 или PL != 0V

ZF = 1 или SF != OF

 TEST  логическое сравнение

Инструкция TEST выполняет поразрядную операцию "логическое И" над своими операндами и устанавливает флажки PL, ZR и РЕ (SF, ZF и PF для руководств Intel) соответственно. Инструкция TEST проверяет, было ли установлено разрядное значение.



Ссылки на структуры и классы


Поскольку структуры и классы играют значительную роль в Windows-разработках, потратим некоторое время на обсуждение проблем доступа к соответствующей памяти. Хотя со структурами и классами удобно иметь дело на языках высокого уровня, на уровне языка ассемблера они, вообще говоря, не существуют. На языках высокого уровня структура и класс — это просто краткие способы указывать смещения в "блобе" памяти.

"Блоб" (Binary Large Object — BLOB). Во-первых, этим термином разработчики баз данных обозначают любой произвольный битовый блок, который следует сохранить в БД, например картинку или звуковой файл. Существенно, что BLOB является объектом, который не может быть интерпретирован средствами СУБД. Во-вторых, употребляется в значении глагола (to blob) и в этом случае переводится как "послать огромный e-mail", обычно в ответ на нанесенное серьезное оскорбление. Часто используется в качестве умеренной угрозы. Например: "If that program crashes again, I'm going to BLOB the core dump to you" (Если эта программа опять "грохнется", я тебе вышлю дамп ядра по мэйлу). См. Файл Жаргона (The Jargon File). -Ред.

Компиляторы, в основном, размещают память для пользовательских структур и классов так, как указано в спецификациях. Иногда компилятор дополняет поля заполнителями, чтобы сохранять их на естественных границах памяти, которые для CPU x86 кратны 4 или 8 байтам.

Ссылки на структуры и классы обозначаются регистром и смещением памяти. В структуре Mystruct, приведенной ниже, комментарии справа показывают смещение каждого элемента от начала структуры. За определением MyStruct расположены различные способы доступа к полям структуры.

typedef struct tag_MyStruct

{

DWORD dwFirst ; // 0-байтное смещение 

char szBuff[ 256 ] ; // 4-байтное смещение 

int iVal ; // 260-байтное смещение

} MyStruct , * PMyStruct ;

void FillStruct ( PMyStruct pSt ) 

{

char szName[] = "Pam\n" ;

_asm

{

MOV EAX , pSt // Поместить pSt в EAX.
Ниже используются прямые

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

// на что они похожи в дизассемблере. 

// Встроенный ассемблер позволяет применять

 // нормальные ссылки формата <struct>.<field>. 

// С-код: pSt->dwFirst = 23 ;

 MOV DWORD PTR [EAX] , 17h.

 // С-код: pSt->iVal = 0x33 ; 

MOV DWORD PTR [EAX + 0104h] , 0x33

 // С-код: strcpy ( pSt->szBuff , szName ) ; 

LEA ECX , szName // Поместить szName в стек.

 PUSH ECX .

LEA ECX , [EAX + 4] //. Получить доступ к полю szBuff.

 PUSH ECX 

CALL strcpy

ADD ESP ,8 // strcpy есть _cdecl функция.

// С-код: pSt->szBuff[ 1 ] = 'A' ; 

MOV BYTE PTR [EAX + 5] , 41h

 // С-код: printf ( pSt->szBuff ) ;

MOV EAX , pSt // Получить pSt обратно. EAX был разрушен

// при обращении к strcpy. 

LEA ECX , [EAX + 4]

 PUSH ECX

CALL DWORD PTR [printf]

ADD ESP , 4 // printf есть _cdecl-функция.

 } 

 }



Встроенный ассемблер Visual C++


Прежде чем перейти к изучению инструкций языка ассемблера, поговорим немного о встроенном ассемблере Visual C++. Подобно большинству "профессиональных компиляторов C++, компилятор Visual C++ позволяет внедрить инструкции языка ассемблера прямо в строки исходного кода С и C++. Хотя в общем случае использование языка встроенного ассемблера не рекомендуется, потому что он ограничивает мобильность кода, иногда это единственный способ выполнить задачу. В главах 12 и 14 будет показано, как можно заполучить в программу импортированные функции, используя язык встроенного ассемблера.

В начале говорилось, что не обязательно знать, как писать программы на языке ассемблера, и здесь нет противоречия. Применять встроенный ассемблер — не то же самое, что писать на ассемблере полную программу — код C/C++ обеспечивает инфраструктуру приложения. Встроенный ассемблер облегчает понимание примеров и позволяет экспериментировать с инструкциями, наблюдая за их действием.

Представим первую инструкцию: 

NOP нет операции

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

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

void NOPFuncOne ( void ) 

{

_Asm NOP

 _Asm NOP 

}

void NOPFUncTwo ( void ) 

{

_Asm 

{

NOP

 NOP 

}

 }

В этой главе встроенный ассемблер используется для иллюстрации операций доступа к параметрам и переменным. Все рассмотренные ниже примеры с языком ассемблера содержатся в программе ASMer на сопровождающем компакт-диске.



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


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

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

Теперь, поговорив о том, как выглядят процедуры, посмотрим, как нужно их вызывать и возвращаться из них. Инструкция CALL проста. Она неявно помещает адрес возврата в стек, так что, остановившись на первой инструкции вызванной процедуры и посмотрев на ESP, на вершине стека можно увидеть адрес возврата.

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

Вызов локальной функции будет прямым обращением по определенному адресу. Однако чаще можно видеть вызовы, выполняющиеся через указатели, которые, в общем случае, являются обращениями к импортированным функциям через таблицу адресов импорта (Import Address Table — IAT). Если загружены символы двоичного файла, через который выполняется пошаговый проход, то вы увидите нечто вроде первой инструкции CALL, показанной ниже в примере функции CallSomeFunctions. Этот код указывает, что вызов выполняется через IAT (префикс _imp_ является страшной тайной!). Пример функции CallSomeFunctions также показывает, как нужно вызвать локальную функцию.

void CaiiSomeFunctions ( void } 

{

_asm 

{

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

// параметров. Регистр ЕАХ будет содержать возвращаемое значение.

// Это вызов через 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 иногда следует фиксированное число, которое определяет, сколько байт нужно извлечь из стека, чтобы учесть все параметры, помещенные в стек и переданные функции

.