Введение
Мы все время от времени используем отладчик для отладки программ.
Отладчик можно использовать с C++, C#, Java и сотнями других языков.
Он может быть как внешним (WinDbg), так и встроенным в среду разработки (Visual Studio).
Но задумывались ли вы когда-нибудь, как работает отладчик? И вам повезло.
В этой серии статей мы разберемся внутри и снаружи, как работает отладка изнутри.
В этой статье рассматривается только написание отладчика для Windows. Без компиляторов, компоновщиков и других сложных систем.
Таким образом, мы сможем отлаживать только исполняемые файлы, поскольку будем писать внешний отладчик.
Эта статья потребует от читателя понимания основ многопоточности.
Как отладить программу:
- Запустите процесс с флагом DEBUG_ONLY_THIS_PROCESS или DEBUG_PROCESS;
- запустить цикл отладки, который будет перехватывать сообщения и события;
- Отладчик — это процесс/программа, которая будет отлаживать другой процесс;
- отлаживаемая программа (ОП) — это отлаживаемый процесс/программа;
- это отладчик, который подключается к OP. Отладчик также может подключаться к разным процессам (в разных потоках);
- Отлаживать можно только те процессы, которые были запущены из-под отладчика.
Таким образом, CreateProcess и цикл отладчика должны находиться в одном потоке;
- когда процесс отладчика завершается, он также завершает OP;
- Когда отладчик занят обработкой событий, он на некоторое время замораживает все потоки OP. Подробнее об этом позже;
Запуск процесса с флагом отладки
Запускаем процесс с помощью функции CreateProcess и в ее шестом параметре (dwCreationFlags) указываем флаг DEBUG_ONLY_THIS_PROCESS. Этот флаг сообщает Windows о необходимости подготовки запущенного процесса к отладке (события отладки, запуск/завершение процесса, исключения и т. д.).Более подробное объяснение чуть позже.
Обратите внимание, что мы будем использовать DEBUG_ONLY_THIS_PROCESS. Это значит, что мы хотим отлаживать только тот процесс, который запускаем, а не еще и сгенерированные им.
После этого вы должны увидеть в диспетчере задач новый процесс, но на самом деле он еще не стартовал.STARTUPINFO si; PROCESS_INFORMATION pi; ZeroMemory( &si, sizeof(si) ); si.cb = sizeof(si); ZeroMemory( &pi, sizeof(pi) ); CreateProcess ( ProcessNameToDebug, NULL, NULL, NULL, FALSE, DEBUG_ONLY_THIS_PROCESS, NULL,NULL, &si, &pi );
Вновь созданный процесс все еще заморожен.
Нет, мы не угадали, нам нужно не ResumeThread вызывать, а написать цикл отладки.
Цикл отладки
Цикл отладки — это сердце отладчика, построенное на основе функции WaitForDebugEvent. Он получает два параметра: указатель на структуру DEBUG_EVENT и таймаут (DWORD).Мы укажем INFINITE в качестве таймаута.
Эта функция содержится в kernel32.dll, поэтому нам не нужно подключать какие-либо дополнительные библиотеки.
BOOL WaitForDebugEvent(DEBUG_EVENT* lpDebugEvent, DWORD dwMilliseconds);
Структура DEBUG_EVENT включает в себя множество отладочной информации: код события, идентификатор процесса, идентификатор потока и информацию приложения о событии.
Как только WaitForDebugEvent завершится и вернет нам управление, мы получим сообщение отладчика, а затем вызовем ContinueDebugEvent, чтобы продолжить выполнение кода.
Ниже вы можете увидеть минимальный цикл отладки.
DEBUG_EVENT debug_event = {0};
for(;;)
{
if (!WaitForDebugEvent(&debug_event, INFINITE))
return;
ProcessDebugEvent(&debug_event); // User-defined function, not API
ContinueDebugEvent(debug_event.dwProcessId,
debug_event.dwThreadId,
DBG_CONTINUE);
}
Вызывая ContinueDebugEvent, мы просим ОС продолжить выполнение OP. dwProcessId и dwThreadId указывают нам процесс и поток.
Эти значения мы получили от WaitForDebugEvent. Последний параметр указывает, продолжать выполнение или нет. Этот параметр будет иметь значение только в том случае, если при отладке встречается исключение.
Мы рассмотрим это позже.
Что ж, сейчас мы просто будем использовать DBG_CONTINUE (другое возможное значение — DBG_EXCEPTION_NOT_HANDLED).
Получение событий отладки
В категории исключений имеется девять основных событий отладки и 20 вложенных событий.Давайте рассмотрим это, начав с самого простого.
Ниже представлена структура DEBUG_EVENT: struct DEBUG_EVENT
{
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
} u;
};
Когда WaitForDebugEvent успешно завершается, он заполняет эту структуру.
dwDebugEventCode указывает, какое событие отладки мы получили.
В зависимости от этого кода один из членов объединения u содержит информацию о событии.
Например, если dwDebugEventCode==OUTPUT_DEBUG_STRING_EVENT, то правильно будет заполнено только OUTPUT_DEBUG_STRING_INFO.
Обработка OUTPUT_DEBUG_STRING_EVENT
Чтобы вывести текст на вывод, разработчики обычно используют функцию OutputDebugString. В зависимости от используемого вами языка/платформы вы должны быть знакомы с макросами TRACE/ATLTRACE. Разработчики .NET могут быть знакомы с System.Diagnostics.Debug.Print/System.Diagnostics.Trace.WriteLine. Но все эти методы вызывают OutputDebugString, если объявлен макрос _DEBUG, и отладчик получает сообщение.
Когда получено отладочное сообщение, мы обрабатываем DebugString. Структура OUTPUT_DEBUG_STRING_INFO показана ниже: struct OUTPUT_DEBUG_STRING_INFO
{
LPSTR lpDebugStringData; // char*
WORD fUnicode;
WORD nDebugStringLength;
};
Поле nDebugStringLength содержит длину строки, включая завершающий нуль.
Поле fUnicode имеет нулевое значение, если строка имеет формат ANSI, и ненулевое значение, если это строка в формате Unicode. В этом случае мы должны прочитать nDebugStringLength x2 байта.
Внимание! lpDebugStringData содержит указатель на строку сообщения, но указатель ссылается на данные.
относительно памяти отлаживаемой программы , а не отладчик.
Чтобы прочитать данные из памяти другого процесса, нам нужно вызвать ReadProcessMemory и у нас должно быть на это разрешение.
Так как мы создали процесс для отладки, проблем с разрешением нет. case OUTPUT_DEBUG_STRING_EVENT:
{
CStringW strEventMessage; // Force Unicode
OUTPUT_DEBUG_STRING_INFO & DebugString = debug_event.u.DebugString;
WCHAR *msg=new WCHAR[DebugString.nDebugStringLength];
// Don't care if string is ANSI, and we allocate double.
ReadProcessMemory(pi.hProcess, // HANDLE to Debuggee
DebugString.lpDebugStringData, // Target process' valid pointer
msg, // Copy to this address space
DebugString.nDebugStringLength, NULL);
if ( DebugString.fUnicode )
strEventMessage = msg;
else
strEventMessage = (char*)msg; // char* to CStringW (Unicode) conversion.
delete []msg;
// Utilize strEventMessage
}
Что, если ОП завершит работу во время чтения памяти?
Ну, этого не произойдет Напомню, что отладчик замораживает все потоки OP во время выполнения отладочного сообщения.
Таким образом, процесс не сможет завершиться сам.
Никакой диспетчер задач (стандартный или нет) также не сможет завершить процесс.
Если мы попытаемся, наш отладчик получит событие EXIT_PROCESS_DEBUG_EVENT в следующем сообщении.
Обработка CREATE_PROCESS_DEBUG_EVENT
Событие появляется, когда ОП только запускается.Это должно быть первое сообщение, которое получит отладчик.
Для этого сообщения соответствующим полем DEBUG_EVENT будет CreateProcessInfo. Ниже вы можете увидеть саму структуру CREATE_PROCESS_DEBUG_INFO: struct CREATE_PROCESS_DEBUG_INFO
{
HANDLE hFile; // The handle to the physical file (.
EXE)
HANDLE hProcess; // Handle to the process
HANDLE hThread; // Handle to the main/initial thread of process
LPVOID lpBaseOfImage; // base address of the executable image
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpThreadLocalBase;
LPTHREAD_START_ROUTINE lpStartAddress;
LPVOID lpImageName; // Pointer to first byte of image name (in Debuggee)
WORD fUnicode; // If image name is Unicode.
};
Обратите внимание, что hProcess и hThread могут отличаться от тех, которые мы получаем в PROCESS_INFORMATION. Идентификаторы процесса и потока должны совпадать.
Каждый дескриптор, который вы получаете от Windows, отличается от остальных.
Для этого есть разные причины.
hFile, как и lpImageName, можно использовать для получения имени OP-файла.
Правда, название этого файла мы уже знаем, потому что запускали его.
Но нам важно знать расположение EXE или DLL, потому что при получении сообщения LOAD_DLL_DEBUG_EVENT было бы хорошо знать имя библиотеки.
Как вы можете прочитать в MSDN, lpImageName никогда не содержит полного имени файла и будет храниться в памяти OP. Более того, нет никакой гарантии, что в памяти ОП также будет содержаться полное имя файла.
Кроме того, имя файла может быть неполным.
Следовательно, имя файла мы получим из hFile.
Как получить имя файла из hFile
К сожалению, нам придется использовать метод, описанный в MSDN , который содержит около 10 вызовов функций.
Ниже приведен сокращенный вариант: case CREATE_PROCESS_DEBUG_EVENT:
{
CString strEventMessage =
GetFileNameFromHandle(debug_event.u.CreateProcessInfo.hFile);
// Use strEventMessage, and other members
// of CreateProcessInfo to intimate the user of this event.
}
Возможно, вы заметили, что я пропустил несколько полей в этой структуре.
В следующих частях мы рассмотрим все это подробно.
Обработка LOAD_DLL_DEBUG_EVENT
Это событие похоже на CREATE_PROCESS_DEBUG_EVENT и, как вы уже догадались, оно запускается, когда ОС загружает DLL. Это событие происходит каждый раз, когда загружается DLL, явно или неявно.Отладочная информация содержит только время загрузки DLL и ее виртуальный адрес.
Для обработки события мы используем поле LoadDll объединения.
Это тип LOAD_DLL_DEBUG_INFO. struct LOAD_DLL_DEBUG_INFO
{
HANDLE hFile; // Handle to the DLL physical file.
LPVOID lpBaseOfDll; // The DLL Actual load address in process.
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpImageName; // These two member are same as CREATE_PROCESS_DEBUG_INFO
WORD fUnicode;
};
Чтобы получить имя файла, мы будем использовать функцию GetFileNameFromHandle, ту же самую, которую мы использовали в CREATE_PROCESS_DEBUG_EVENT. Я покажу этот код, когда буду говорить о UNLOAD_DLL_DEBUG_EVENT. Событие UNLOAD_DLL_DEBUG_EVENT не содержит полной информации об имени библиотеки DLL.
Обработка CREATE_THREAD_DEBUG_EVENT
Это событие генерируется, когда OP создает новый поток.Подобно CREATE_PROCESS_DEBUG_EVENT, это событие создается перед запуском нового потока.
Чтобы получить информацию об этом событии, мы используем поле CreateThread. Структура CREATE_THREAD_DEBUG_INFO описана ниже: struct CREATE_THREAD_DEBUG_INFO
{
// Handle to the newly created thread in debuggee
HANDLE hThread;
LPVOID lpThreadLocalBase;
// pointer to the starting address of the thread
LPTHREAD_START_ROUTINE lpStartAddress;
};
Идентификатор потока доступен в DEBUG_EVENT::dwThreadId, поэтому мы можем легко отобразить всю информацию о потоке: case CREATE_THREAD_DEBUG_EVENT:
{
CString strEventMessage;
strEventMessage.Format(L"Thread 0x%x (Id: %d) created at: 0x%x",
debug_event.u.CreateThread.hThread,
debug_event.dwThreadId,
debug_event.u.CreateThread.lpStartAddress);
// Thread 0xc (Id: 7920) created at: 0x77b15e58
}
lpStartAddress – адрес запуска функции потока относительно ОП, а не отладчика; Мы просто показываем это для полноты.
Обратите внимание, что это событие не генерируется при запуске основного потока, а только тогда, когда основной поток создает новые потоки.
Обработка EXIT_THREAD_DEBUG_EVENT
Это событие вызывается, как только дочерний поток завершает работу и возвращает системе код возврата.Поле dwThreadId в DEBUG_EVENT содержит идентификатор завершающегося потока.
Чтобы получить дескриптор потока и другую информацию из CREATE_THREAD_DEBUG_EVENT, нам нужно сохранить эту информацию в каком-то массиве.
Для получения информации об этом событии мы используем поле ExitThread типа EXIT_THREAD_DEBUG_INFO: struct EXIT_THREAD_DEBUG_INFO
{
DWORD dwExitCode; // The thread exit code of DEBUG_EVENT::dwThreadId
};
Ниже приведен код обработчика событий: case EXIT_THREAD_DEBUG_EVENT:
{
CString strEventMessage;
strEventMessage.Format( _T("The thread %d exited with code: %d"),
debug_event.dwThreadId,
debug_event.u.ExitThread.dwExitCode); // The thread 2760 exited with code: 0
}
Обработка UNLOAD_DLL_DEBUG_EVENT
Разумеется, событие содержит информацию о выгрузке DLL из памяти ОП.Но это не так просто! Он генерируется только при вызове FreeLibrary, а не тогда, когда система сама выгружает библиотеку.
Для получения информации используйте UnloadDll (UNLOAD_DLL_DEBUG_INFO): struct UNLOAD_DLL_DEBUG_INFO
{
LPVOID lpBaseOfDll;
};
Как видите, нам доступен только базовый адрес библиотеки.
Поэтому я не рассказал вам сразу про код LOAD_DLL_DEBUG_EVENT. При загрузке DLL мы также получаем lpBaseOfDll. Вы можете использовать Map для хранения названия библиотеки помимо ее адреса.
Важно отметить, что не все события загрузки библиотеки получат собственное событие выгрузки.
Однако мы должны хранить все имена библиотек, поскольку LOAD_DLL_DEBUG_EVENT не дает нам информации о том, как библиотека была загружена.
Ниже приведен код для обработки обоих событий: std::map < LPVOID, CString > DllNameMap;
.
case LOAD_DLL_DEBUG_EVENT: { strEventMessage = GetFileNameFromHandle(debug_event.u.LoadDll.hFile); // Storing the DLL name into map. Map's key is the Base-address DllNameMap.insert( std::make_pair( debug_event.u.LoadDll.lpBaseOfDll, strEventMessage) ); strEventMessage.AppendFormat(L" - Loaded at %x", debug_event.u.LoadDll.lpBaseOfDll); } break; .
case UNLOAD_DLL_DEBUG_EVENT:
{
strEventMessage.Format(L"DLL '%s' unloaded.",
DllNameMap[debug_event.u.UnloadDll.lpBaseOfDll] ); // Get DLL name from map.
}
break;
Обработка EXIT_PROCESS_DEBUG_EVENT
Это одно из самых простых событий, и, как вы можете догадаться, оно вызывается, когда процесс ОП завершается.Это событие показывает нам, как завершился процесс: нормально или срочно (например, через диспетчер задач), или произошел сбой отлаживаемой программы.
Информацию получаем из EXIT_PROCESS_DEBUG_INFO ExitProcess; struct EXIT_PROCESS_DEBUG_INFO
{
DWORD dwExitCode;
};
Как только мы получим это событие, нам нужно разорвать цикл отладки и завершить поток отладки.
Для этого мы можем установить флаг, который будет сигнализировать об окончании отладки.
bool bContinueDebugging=true;
.
case EXIT_PROCESS_DEBUG_EVENT:
{
strEventMessage.Format(L"Process exited with code: 0x%x",
debug_event.u.ExitProcess.dwExitCode);
bContinueDebugging=false;
}
break;
Обработка EXCEPTION_DEBUG_EVENT
Это самое удивительное и сложное во всех событиях отладки.Из MSDN:
Это событие генерируется, когда в отлаживаемом процессе возникает исключение (возможно, деление на ноль, выход массива за пределы, выполнение инструкции int 3 или любое другое исключение, описанное в SEH).Описание обработки этого события требует отдельной статьи, чтобы рассказать об этом полностью (и даже хотя бы частично).Структура DEBUG_EVENT содержит структуру EXCEPTION_DEBUF_INFO. Это то, что описывает исключение.
Поэтому я пока расскажу вам об одном типе исключений.
Поле «Исключение» содержит информацию о только что произошедшем исключении.
Ниже вы можете увидеть описание структуры EXCEPTION_DEBUG_INFO: struct EXCEPTION_DEBUG_INFO
{
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
};
Поле ExceptionRecord содержит подробную информацию об исключении.
struct EXCEPTION_RECORD
{
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; // 15
};
Прежде чем мы углубимся в EXCEPTION_RECORD, я хотел бы обсудить с вами EXCEPTION_DEBUG_INFO::dwFirstChance.
Когда процесс находится в стадии отладки, отладчик всегда получает исключение до того, как его получит OP. Вы, должно быть, видели запись «Исключение первого шанса по адресу 0x00412882 в SomeModule» во время отладки приложения C++.
Это относится к исключению «Первый шанс».
Одни и те же исключения могут существовать или не существовать в исключениях второго шанса.
Когда OP выдает исключение, это рассматривается как второй шанс.
ОП может обработать это исключение или просто произойти сбой.
Эти исключения относятся не к исключениям C++, а к механизму Windows SEH. Подробнее я расскажу в следующей части статьи.
Отладчик первым получает сообщение об исключении (исключение первой возможности), это помогает ему обрабатывать исключение быстрее, чем OP. Некоторые библиотеки создают исключения первой возможности, чтобы помочь отладчику выполнить свою работу.
Еще немного о ContinueDebugEvent
Третий параметр этой функции (dwContinueStatus) нам важен только при получении исключения.Для других событий этот параметр игнорируется.
После получения исключения следует вызвать ContinueDebugEvent с помощью:
- DBG_CONTINUE, если исключение было успешно перехвачено отладчиком.
Больше ничего от отлаживаемой программы не требуется и она может нормально работать.
- DBG_EXCEPTION_NOT_HANDLED, если это исключение не обрабатывается (не может быть обработано) отладчиком.
Отладчик может только зафиксировать возникновение этого исключения.
Но поскольку мы только начинаем писать отладчик, давайте поиграем с безопасной рогаткой, а не с пистолетом, и вернем EXCEPTION_NOT_HANDLED. Исключением в этой статье является int 3 (точка останова), о котором мы поговорим позже.
Коды исключений
- EXCEPTION_ACCESS_VIOLATION
- EXCEPTION_ARRAY_BOUNDS_EXCEEDED
- EXCEPTION_BREAKPOINT
- EXCEPTION_DATATYPE_MISALIGNMENT
- EXCEPTION_FLT_DENORMAL_OPERAND
- EXCEPTION_FLT_DIVIDE_BY_ZERO
- EXCEPTION_FLT_INEXACT_RESULT
- EXCEPTION_FLT_INVALID_OPERATION
- EXCEPTION_FLT_OVERFLOW
- EXCEPTION_FLT_STACK_CHECK
- EXCEPTION_FLT_UNDERFLOW
- EXCEPTION_ILLEGAL_INSTRUCTION
- EXCEPTION_IN_PAGE_ERROR
- EXCEPTION_INT_DIVIDE_BY_ZERO
- EXCEPTION_INT_OVERFLOW
- EXCEPTION_INVALID_DISPOSITION
- EXCEPTION_NONCONTINUABLE_EXCEPTION
- EXCEPTION_PRIV_INSTRUCTION
- EXCEPTION_SINGLE_STEP
- EXCEPTION_STACK_OVERFLOW
Только EXCEPTION_BREAKPOINT: case EXCEPTION_DEBUG_EVENT:
{
EXCEPTION_DEBUG_INFO& exception = debug_event.u.Exception;
switch( exception.ExceptionRecord.ExceptionCode)
{
case STATUS_BREAKPOINT: // Same value as EXCEPTION_BREAKPOINT
strEventMessage= "Break point";
break;
default:
if(exception.dwFirstChance == 1)
{
strEventMessage.Format(L"First chance exception at %x, exception-code: 0xx",
exception.ExceptionRecord.ExceptionAddress,
exception.ExceptionRecord.ExceptionCode);
}
dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;
}
break;
}
Вы должны знать, что такое точка останова.
За пределами стандартной точки зрения точку останова можно вызвать с помощью API DebugBreak или с помощью инструкции ассемблера { int 3 }.
В .
NET его можно создать с помощью System.Diagnostics.Debugger.Break. Отладчик получит код STATUS_BREAKPOINT (тот же, что EXCEPTION_BREAKPOINT).
Отладчик обычно использует это событие для остановки текущего процесса и может показать исходный код того, где произошло событие.
Но поскольку наш отладчик только начинает разрабатываться, мы покажем пользователю только основную информацию без исходного кода.
Если точка останова будет вызвана в приложении, которое не находится под отладчиком, оно просто выйдет из строя.
Можно использовать следующую конструкцию: if ( !IsDebuggerPresent() )
AfxMessageBox(L"No debugger is attached currently.");
else
DebugBreak();
В заключение хотелось бы представить простейшее событие отладки: EXCEPTION_DEBUG_EVENT. Это событие будет происходить постоянно.
Отладчики, такие как Visual Studio, игнорируют это, а WinDbg — нет.
Заключение
Используйте любой отладчик для DebugMe.Вторая часть будет еще интереснее и она уже близко! УПД: Часть 2 Теги: #отладка #отладчик #C++ #отладчик #Windows #программирование #C++ #Системное программирование
-
Как Он Это Сделал?
19 Oct, 24 -
Тестирование Игры
19 Oct, 24 -
Руководители Редакции Рбк Покинули Компанию
19 Oct, 24 -
Как Мы Познакомились С Agile И Scrum
19 Oct, 24 -
Купишь Ли Ты Айфон?
19 Oct, 24 -
Я Против Вгтрк
19 Oct, 24