Трюки от Крыса Крис Касперски Хакер, номер #107, стр. 107-120-1 Кто-то в шутку сказал, что программисты в среднем тратят 10% времени на написание программы и 90% — на ее отладку. Разумеется, это преувеличение, и правильно спроектированная программа должна отлаживать себя сама или по крайней мере автоматизировать этот процесс. Сегодняшний выпуск трюков, как ты уже догадался, посвящен магии отладки. Трюк 1: «обрамление» отладочного кодаМногие программисты используют для «обрамления» отладочного кода директивы условной трансляции (пример использования которых приведен чуть ниже), в результате чего отладочный код автоматически удаляется из release-версии продукта: Распространенный, но неудобный способ «обрамления» отладочного кода#define _DEBUG_ // debug info is enabled … #ifdef _DEBUG_ pritnf("output debug info\n"); #endif Однако, это не самый продвинутый вариант, и при желании его можно существенно оптимизировать, заменив директиву препроцессора #ifdef оператором if(0): Оптимизированный способ «обрамления» отладочного кода#define _DEBUG_ 1 // debug info is enabled … if(_DEBUG_) { pritnf("output debug info\n"); } Если _DEBUG_ == 0, то выражение if(_DEBUG_) превращается в «мертвый код», автоматически детектируемый и удаляемый практически всеми оптимизирующими компиляторами. Кстати говоря, оператор if(0) выгодно использовать для временного отключения части кода, что обычно делается с помощью комментариев. Однако при многократном включении/отключении большого количества строк, приходится тратить кучу времени на их комментирование, вставляя оператор «//» в начало каждой строки. Теоретически весь блок кода можно отключить с помощью оператора «/* - - - */», но воспользоваться этой возможностью удается далеко не всегда. Увы! Язык Си/Си++ не поддерживает вложенных комментариев последнего типа, и, если они встречаются в отключаемом коде, мы получаем сообщение об ошибке. С другой стороны, код, отключенный посредством комментариев, в продвинутых средах разработки отмечается другим цветом (например, серым), а потому намного более нагляден, чем оператор if(0), который никак не выделяется в листинге. Поэтому однажды отключенный код рискует отправиться в забвение, и, чтобы этого не произошло, рекомендуется использовать директиву #pragma message, выводящую сообщение при компиляции о том, что такой-то участок кода временно отключен. Трюк 2: условные точки останова - своими рукамиПрактически все современные отладчики поддерживают условные точки останова, однако их возможности довольно ограниченны. В частности, мы не можем вызывать API-функции, и потому даже такая простая задача, как остановка отладчика в определенном потоке, превращается в головоломку, для решения которой приходится прибегать к анализу регистра FS и прочим шаманским трюкам. Лишь немногие отладчики позволяют загружать условные точки останова из текстового файла, который легко редактировать в своем любимом IDE с отступами, переносами строки и прочими атрибутами форматирования. А без форматирования мало-мальски сложное условие останова становится практически нечитаемым, и его приходится отлаживать вместе с отлаживаемой программой. Вот такая, значит, рекурсия получается. Между тем если мы не хакаем двоичный файл, то намного удобнее внедрять точку останова непосредственно в сам исходный текст! На x86-платформе для этого достаточно вызвать ассемблерную инструкцию int 0x3. Естественно, это решение не универсально и к тому же системно зависимо, однако системно-зависимый код можно вынести в макрос/отдельную функцию. «Ручные» точки останова сохраняются вместе с самой программой, что отвязывает нас от отладчика, и мы можем попеременно использовать SoftICE, OllyDebugger и Microsoft Visual C++, например. Кстати говоря, даже если на целевой машине отладчик вообще не установлен, точки останова, внедренные в программу, приведут к вызову Доктора Ватсона. Это, конечно, не отладчик, но все же лучше, чем совсем ничего. Пример использования рукотворных условных точек останова#define BREAK1_ENABLED 1 #define BREAK1_TEXT "arg1 and arg2 are equal" #define break_in __asm int 0x3 foo(int arg1, int arg2) { #ifdef BREAK1_ENABLED if (arg1 == arg2) break_in; #pragma message("BREAKPOINT:" BREAK1_TEXT __FILE__) #endif } Трюк 3: мистическое исчезновение ошибокНекоторые виды ошибок мистическим образом исчезают при запуске программы под отладчиком, и можно дебажить программу хоть до посинения, но так и не получить никакого результата. На самом деле прикладная программа практически не имеет никаких шансов определить, находится ли она под отладкой или нет. Исключение составляют специальные антихакерские приемы и пошаговое исполнение плюс ошибки синхронизации. Более вероятная причина исчезновения ошибок заключается в том, что вместе с генерацией отладочной информации компилятор отрубает оптимизатор и выполняет ряд дополнительных действий, изменяющих логику поведения программы (например, инициализирует переменные). Чтобы не спугнуть ошибки, необходимо отлаживать release-версию программы. Вот так прямо в ассемблерных кодах и отлаживать. А как быть, если мы хотим подняться на уровень исходных текстов?! К сожалению, в общем случае это невозможно. Но тут есть одна хитрость, существенно упрощающая нам жизнь. Используя предопределенный макрос __LINE__, мы без труда заставим компилятор генерировать информацию о номерах строк, автоматически внедряемых в программу. Конечно, это совсем не то же самое, что отладка на уровне исходных текстов, но все-таки какая-то зацепка уже появляется. Правильно расставив директивы __LINE__, мы легко сориентируемся, в какой части программы сейчас находится ошибка (правда, при этом следует помнить, что компилятор может переупорядочивать машинные команды по своему усмотрению, и потому номера строк, определенные при помощи __LINE__, не всегда соответствуют действительности и могут быть с некоторым сдвигом). Самое замечательное, что эта задача поддается автоматизации. Не составит большого труда написать плагин для OllyDebugger, распознающий внедренные номера строк и выводящий соответствующий фрагмент исходного текста на экран. Рассмотрим следующий пример: Простейший пример программы, автоматически внедряющей номера строк исходного текста в свою release-версию // макрос для внедрения номеров строк #define XX dbgline(__LINE__); // служебная функция для внедрения номеров строк static dbgline(int line) { char buf[1024]; sprintf(buf,"%x\n",line); OutputDebugString(buf); } main() { XX // вывести номер строки (в данном случае == 15) printf("hello, world!\n"); XX // вывести номер строки (в данном случае == 17) } Мы определяем макрос XX, вызывающий функцию dbgline() и передающий ей номер строки в качестве аргумента, что приводит к генерации следующего машинного кода: PUSH __LINE__/CALL dbgline(), который можно найти и автоматически, используя __LINE__ в качестве опорной метки. Естественно, если программа занимает более одного файла, необходимо воспользоваться макросом __FILE__, который здесь не показан для упрощения. А чтобы оптимизирующий компилятор не заинлайнил dbgline, мы объявляем ее как static. API-функция OutputDebugString() не является обязательной и просто вываливает номера строк, отображаемых отладчиком в специальном окне. Это на тот случай, если мы совсем не разбираемся в ассемблере. Кстати, дизассемблерный листинг приведенной программы выглядит так: .text:00401000 _mai proc near .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push 15 ; номер текущей строки .text:00401005 call sub_401026 ; gdbline .text:0040100A add esp, 4 .text:0040100D push offset aHelloWorld ; "hello, world!\n" .text:00401012 call _printf .text:00401017 add esp, 4 .text:0040101A push 17 ; номер текущей строки .text:0040101C call sub_401026 ; gdbline .text:00401021 add esp, 4 .text:00401024 pop ebp .text:00401025 retn .text:00401025 _main endp |