Как стать автором
Обновить

Пишем свой отладчик под Windows [часть 2]

Время на прочтение 24 мин
Количество просмотров 21K
Автор оригинала: Ajay Vijayvargiya


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

Предисловие


Эта статья является продолжением предыдущей части «Пишем свой отладчик под Windows». Очень важно, чтобы Вы её прочитали и поняли. Без полного понимания того, что написано в первой части, Вы не сможете понять эту статью и оценить весь материал целиком.
Единственный момент, оставшийся неупомянутым в предыдущей статье, это то, что наш отладчик может отлаживать только машинный код. У Вас не получиться начать отладку управляемого (managed) кода. Может быть, если будет четвёртая часть статьи, я в ней также рассмотрю отладку и управляемого кода.
Я бы хотел показать Вам несколько важных аспектов отладки. Они будут включать в себя показ исходного кода и стека вызовов (callstack), установки точек останова (breakpoints), входа внутрь исполняемой функции (step into), присоединение отладчика к процессу, установка системного отладчика по умолчанию и некоторые другие.

Список задач:
  • Начало отладки с функции main()
  • Получение стартового адреса процесса
  • Установки точки останова на стартовом адресе
  • Остановка на брейкпоинте, отмена инструкции
  • Прекращение отладки и ожидание действий пользователя
  • Продолжение отладки по команде пользователя

  • CDebuggerCore – класс интерфейса отладки

  • Получение исходных кодов и номеров строк
  • Установка пользовательских точек останова
  • Трассировка кода (step-in, step-out, step-over)
  • Условные точки останова
  • Отладка запущенного процесса
  • Отсоединение от процесса, завершение или ожидание?
  • Отладка упавшего процесса
  • Подключение отладчика вручную


Начнём отладку!


Итак, что Вы делаете, когда хотите отладить Вашу программу? Ну, по большей части мы нажимаем F5 для начала отладки приложений и отладчик Visual Studio будет останавливать выполнение программы в тех местах, где Вы установили точки останова (в т.ч. условные). Клик по кнопке «Повторить» в диалоговом окне «Debug Assertion Failed» также открывает исходные код в нужном месте и останавливает выполнение. Вызов DebugBreak или инструкция _asm int 3 делают тоже самое. И это малая часть вариантов «как отладить приложение».
Редко или время от времени мы начинаем отладку с самого начала, нажав F11 (step-into), и VS начинает отладку с функций main/wmain или WinMain/wWinMain (или их же с префиксом _t). Что ж, это логический стартовый адрес отлаживаемого процесса. Я называю его «логическим» потому что это не настоящий стартовый адрес, который также известен как входной точкой (entry point) модуля. Для консольных приложений это функция mainCRTStartup, которая потом вызывает функцию main и отладчик Visual Studio начинает с main. У библиотек Dll тоже может быть своя точка входа. Если хотите знать немного больше, читайте информацию по флагу /ENTRY.
Всё это значит, что мы должны приостановить выполнение программы на входной точке приложения и позволить разработчику продолжить отладку. Да, я сказал «приостановить» выполнение программы на входной точке модуля – процесс уже запущен и если мы его не приостановим, он где-нибудь завершится. Стек вызовов (изображение ниже) появится, как только Вы нажмёте F11.

Что нам надо сделать для приостановки процесса на точке входа?
В двух словах:
  1. Получить стартовый адрес процесса
  2. Изменить инструкцию по этому адресу – например, заменить её на инструкцию точки останова (_asm int 3)
  3. Обработать остановку программы, как только выполнение дойдёт до этой точки останова, восстановить оригинальную инструкцию
  4. Остановить выполнение, показать стек вызовов, регистры и исходный код, если возможно
  5. Продолжить выполнение по запросу пользователя

Всего пять пунктов! Но задача, на самом деле, не из лёгких.

Получение стартового адреса процесса


Начальный адрес точки входа и логической* точки входа (функция main/WinMain) – это такие дебри! Прежде чем рассказать Вам в двух словах об этих понятиях, позвольте мне дать Вам наглядное представление об этом. Но первое, что Вы должны понять: первая инструкция по данному адресу — это точка начала выполнения программы и отладчик работает только с этим адресом.
*[Этот термин придуман мной и имеет отношение только к этой статье!]
Вот так выглядит функция WinMain в дизассемблированном виде в Visual Studio (с аннотациями):

Вы можете перейти к такому же виду, запустив отладку, щёлкнув правой кнопкой мыши и выбрав «к дизассемблированному коду». Байты кода не отображаются по умолчанию (подсвечены зелёным), но Вы можете их включить через контекстное меню.
Расслабьтесь! Вам не нужно понимать инструкции ни на машинном языке, ни на каком-либо диалекте ассемблера! Это просто для иллюстрации. В приведённом выше примере, 0x00978F10 – стартовый адрес, а 8B FF является первой инструкцией. Нам просто надо заменить её на инструкцию точки останова. Мы знаем, что такая функция API называется DebugBreak, но на таком низком уровне мы не можем её использовать. Для x86 инструкция точки останова _asm int 3. Она имен код 0xCC (204).
Получается, что нам просто надо заменить значение байта 8B на СС и всё! Когда программа будет запущена, в этом месте будет сгенерировано исключение EXCEPTION_DEBUG_EVENT с кодом EXCEPTION_BREAKPOINT. Мы знаем, что именно мы это сделали и после этого обрабатываем это исключение так, как надо нам. Если Вы не поняли этот параграф, прошу Вас в последний раз, прочитайте сначала первую часть статьи [http://habrahabr.ru/post/154847/].
Инструкции x86 не фиксированной длины, но кого это волнует? Нам не надо смотреть, сколько байт (1, 2, N) занимает инструкция. Мы просто меняем первый байт. Первый байт инструкции может быть чем угодно, а не только 8B. Но мы должны гарантировать, что как только придёт время продолжить выполнение программы, мы восстановим оригинальный байт.
Небольшая ремарка для тех, кто всё знает и для тех, кто что-то не знает. Во-первых, точки останова не единственный способ остановить выполнение программы на начальном адресе. Более правильной альтернативой являются одноразовые точки останова, которые мы чуть позже обсудим. Во-вторых, инструкция CC не единственная инструкция точки останова, но для нас её хватит за глаза.
Есть небольшие сложности с получением стартового адреса, но чтобы удержать Ваш интерес, позвольте мне сразу показать C++ код для получение стартового адреса. Член lpStartAddress в структуре CREATE_PROCESS_DEBUG_INFO содержит в себе стартовый адрес. Мы можем прочитать эту информацию пока обрабатываем самое первое событие отладки:

// This is inside Debugger-loop controlled by WaitForDebugEvent, ContinueDebugEvent
switch(debug_event.dwDebugEventCode)
{
   case CREATE_PROCESS_DEBUG_EVENT:
   {
        LPVOID pStartAddress = (LPVOID)debug_event.u.CreateProcessInfo.lpStartAddress;
        // Do something with pStartAddress to set BREAKPOINT.
   ...
...


Тип CREATE_PROCESS_DEBUG_INFO::lpStartAddress – это LPTHREAD_START_ROUTINE, и я думаю, что Вы знаете, что это (указатель на функцию). Но, как я уже говорил, есть некоторые сложности со стартовым адресом. Короче говоря, этот адрес является относительным того, куда загрузился образ приложения в памяти. Чтобы мне быть более убедительным, позвольте мне показать Вам output утилиты dumpbin с опцией /headers:

dumpbin /headers DebugMe.exe
...
OPTIONAL HEADER VALUES
             10B magic # (PE32)
            8.00 linker version
            A000 size of code
            F000 size of initialized data
               0 size of uninitialized data
           11767 entry point (00411767) @ILT+1890(_wWinMainCRTStartup)
           1000 base of code


Этот адрес (00411767) хранится в lpStartAddress во время отладки нашего приложения. Но когда я запустил отладку из под Visual Studio, адрес wWinMainCRTStartup отличался от этого (@ILT не имеет к этому никакого отношения).
Таким образом, позвольте мне отложить обсуждение тонкостей получения стартового адреса и просто использовать функцию GetStartAddress(), код которой будет показан позже. Она будет возвращать точный адрес, где следует установить точку останова!

Изменение инструкции на стартовом адресе на инструкцию точки останова


Как только мы получим стартовый адрес, изменение инструкции в этом месте на точку останова (CC) вполне тривиально. Нам нужно сделать:
  1. Считать один байт по этому адресу и сохранить его
  2. Записать на его место байт 0xCC
  3. Очистить кэш инструкций
  4. Продолжить отладку

Сейчас Вы должны задаваться двумя важными вопросами:
  1. Как считать, записать и сбросить инструкции?
  2. Когда нам это делать?

Позвольте мне ответить сначала на второй вопрос. Мы будем считывать, записывать и сбрасывать инструкции в процессе обработки события CREATE_PROCESS_DEBUG_EVENT (или, на Ваше усмотрение, в момент EXCEPTION_BREAKPOINT). Когда процесс начинает загружаться, мы получаем настоящий стартовый адрес (я имею ввиду адрес CRT-Main), считываем первую инструкцию по этому адресу, сохраняем её и записываем на это место байт 0xCC. Затем мы вызываем у нашего отладчика ContinueDebugEvent().
Для лучшего понимания, позвольте показать Вам код:

DWORD dwStartAddress = GetStartAddress(m_cProcessInfo.hProcess, m_cProcessInfo.hThread);    
BYTE cInstruction;
DWORD dwReadBytes;

// Read the first instruction    
ReadProcessMemory(m_cProcessInfo.hProcess, (void*)dwStartAddress, &cInstruction, 1, &dwReadBytes);

// Save it!
m_OriginalInstruction = cInstruction;
 
// Replace it with Breakpoint
cInstruction = 0xCC;
WriteProcessMemory(m_cProcessInfo.hProcess, (void*)dwStartAddress,&cInstruction, 1, &dwReadBytes);
FlushInstructionCache(m_cProcessInfo.hProcess,(void*)dwStartAddress,1);


Немного о коде:
• M_cProcessInfo является членом нашего класса, которая есть ни что иное, как PROCESS_INFORMATION, заполненная функцией CreateProcess.
• Функция GetStartAddress() возвращает стартовый адрес процесса. Для юникодного приложения с пользовательским интерфейсом это адрес функции wWinMainCRTStartup();
• Затем мы вызываем ReadProcessMemory для получения байта, находящегося по стартовому адресу и сохранения его значения
• После этого записываем по этому адресу инструкцию точки останова (0xCC) с помощью функции WriteProcessMemory
• В заключении, вызываем FlushInstructionCache, чтобы CPU прочитал новую инструкцию, а не какую-либо закэшированную старую. CPU, конечно, может и не закэшеровать инструкцию, однако Вы всегда обязательно должны вызывать FlushInstructionCache.
Учтите, что ReadProcessMemory требует PROCESS_VM_READ прав. Кроме этого, WriteProcessMemory требует PROCESS_VM_READ | PROCESS_VM_OPERATION – все эти разрешения предоставляются отладчику как только он передаёт флаг отладки в CreateProcess. Таким образом, нам ничего не надо делать и чтение/запись всегда будут успешны (при допустимых адресах памяти, конечно!).

Обработка инструкции точки останова и восстановление оригинальной инструкции


Как Вы знаете, инструкция точки останова (EXCEPTION_BREAKPOINT) это тип исключения, которое приходит с событием отладки EXCEPTION_DEBUG_EVENT. Мы обрабатываем события отладки с помощью структуры EXCEPTION_DEBUG_INFO. Код ниже поможет Вам вспомнить и понять:

// Inside debugger-loop
switch(debug_event.dwDebugEventCode)
{
   case EXCEPTION_DEBUG_EVENT:
   {

        EXCEPTION_DEBUG_INFO & Exception = debug_event.u.Exception; // Out of union
        // Exception.ExceptionCode would be the actual exception code.
...


Операционная система будет всегда посылать одну инструкцию точки останова отладчику, которая будет индикатором того, что процесс загружается. Именно поэтому Вы можете «установить инструкцию точки останова на начальном адресе» на самом первом исключении точки останова. Это гарантирует, что все точки останова после первой Ваши.
Независимо от того, где находятся Ваши точки останова, Вам всё равно необходимо игнорировать первое событие точки останова. Хотя отладчики, например, WinDbg, будут показывать Вам эту точку останова, но отладчик Visual Studio проигнорирует эту точку останова и начнёт выполнение с логического начала программы (main/WinMain, а не CRT-Main).
Таким образом, код обработки прерывания будет выглядеть так:

// 'Exception' is the same variable declared above
switch(Exception.ExceptionRecord.ExceptionCode)
 {

 case EXCEPTION_BREAKPOINT:
  if(m_bBreakpointOnceHit) // Would be set to false, before debugging starts
  {
     // Handle the actual breakpoint event
  }
  else
  {
     // This is first breakpoint event sent by kernel, just ignore it.
     // Optionally display to the user that first BP was ignored.
     m_bBreakpointOnceHit = true; 
  }
  break;
...


Вы также можете использовать else-часть для установки точки останова вместо её установки во время события старта процесса. В любом случае, основная обработка события точки останова происходит в if-части. Нам надо обработать точку останова, которую мы поместили по стартовому адресу.
Становится сложно и интригующе – сконцентрируйтесь, читайте внимательно, сядьте расслабленно. Если у Вас не было перерыва, пока Вы читали эту статью, сделайте его!
Говоря простыми словами, событие точки останова произошло там, где мы его поместили. Теперь мы просто прерываем выполнение, показываем стек вызовов (и другую полезную информацию), возвращаем оригинальную инструкцию и ждём каких-либо действий от пользователя для продолжения отладки.
На уровне ассемблера или машинного кода, когда событие точки останова было сгенерировано и отправлено отладчику, инструкция уже была выполнена, хотя она и была размеров всего в один байт. Указатель инструкции уже подвинулся на этот самый байт.
Таким образом, в дополнение к записи оригинальной инструкции по нашему адресу, мы также должны поправить регистры процессора. Получить и установить регистры конкретно нашего процесса мы можем с помощью функций GetThreadContext и SetThreadContext. Обе функции принимают структуру контекста. Строго говоря, члены этой структуры зависят от архитектуры процессора. Поскольку эта статья посвящена архитектуре x86, мы будем следовать такому же определению структуры, которое можно найти в заголовочном файле winnt.h.
Вот как мы можем получить контекст потока:

CONTEXT lcContext;
lcContext.ContextFlags = CONTEXT_ALL;
GetThreadContext(m_cProcessInfo.hThread, &lcContext);
 Окей, мы получили его. Что теперь?
В регистре EIP содержится адрес следующей инструкции для выполнения. Он представлен членом Eip структуры CONTEXT. Как я уже упоминал раннее, EIP продвинулся вперёд и мы должны вернуть его обратно. К счастью для нас, нам надо всего лишь переместить его ровно на один байт, так как инструкция точки останова равна по длине одному байту. Именно это и делает код ниже:
lcContext.Eip --; // Move back one byte
SetThreadContext(m_cProcessInfo.hThread, &lcContext);


EIP – это адрес, по которому процессор будет считывать следующую инструкцию и выполнять её. Вам надо иметь права THREAD_GET_CONTEXT и THREAD_SET_CONTEXT для успешного выполнения этих функций, и они у Вас уже есть.
Позвольте мне ненадолго переключиться на другую тему: восстановление оригинальной инструкции! Для записи оригинальной инструкции в запущенном процессе мы должны вызвать WriteProcessMemory, а за ней FlushInstructionCache. Вот как это делается:

DWORD dwWriteSize;
WriteProcessMemory(m_cProcessInfo.hProcess, StartAddress, &m_cOriginalInstruction, 1,&dwWriteSize);
FlushInstructionCache(m_cProcessInfo.hProcess,StartAddress, 1);



Оригинальная инструкция восстановлена. Можем вызывать ContinueDebugEvent. Что мы сделали:
  1. GetThreadContext, уменьшить EIP на один, SetThreadContext.
  2. Восстановить оригинальную инструкцию
  3. Продолжить отладку

Что ж, а где стек вызовов? Регистры? Исходный код? И когда программа завершится? Всё это будет без взаимодействия с пользователем!

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


Чтобы отобразить стек вызовов, нам нужно загрузить символы отладки, которые хранятся в существующих *.PDB файлах. Набор функций из DbgHelp.dll поможет нам загрузить символы, перечислить исходный код файлов, трассировать стек вызовов и многое другое. И всё это будет рассмотрено позже.
Для отображения регистров CPU, мы просто должны отобразить актуальные данные из структуры CONTEXT. Чтобы отобразить 10 регистров как в отладчике Visual Studio (Debug -> Windows -> Registers или Alt+F5) Вы можете использовать следующий код:

CString strRegisters;


strRegisters.Format(
  L"EAX = %08X\nEBX = %08X\nECX = %08X\n"
  L"EDX = %08X\nESI = %08X\nEDI = %08X\n"
  L"EIP = %08X\nESP = %08X\nEBP = %08X\n"
  L"EFL = %08X",
  lcContext.Eax, lcContext.Ebx, lcContext.Ecx,
  lcContext.Edx, lcContext.Esi, lcContext.Edi,
  lcContext.Eip, lcContext.Esp, lcContext.Ebp,
  lcContext.EFlags
  );


И всё! Отображайте этот текст в соответствующее окно.
Чтобы приостановить выполнение программы, пока пользователь на даст соответствующую команду (Continue, Step-in, Stop Debugging и другие), мы не должны вызывать ContinueDebugEvent. Так как поток отладки и поток GUI разные, мы просто просим GUI-поток отобразить актуальную информацию и замораживаем поток отладки, пока не придёт какое-либо «событие», например, от пользователя.
Запутались? Слово «событие» взято в кавычки, так как это ни что иное, как событие, создаваемое функцией CreateEvent. Чтобы приостановить выполнение программы, мы вызываем WaitForSingleObject (в потоке отладчика). Чтобы возобновить работу потока отладчика мы просто вызываем SetEvent из GUI-потока. Конечно, в зависимости от Ваших предпочтений, Вы можете использовать другие технологии для синхронизации потоков. Этот пункт даёт только общее представление о реализации функции «приостановить выполнение – продолжить выполнение».
Теперь, благодаря этим рассуждениям, мы можем написать логику кода:
  1. GetThreadContext, уменьшить EIP на один, SetThreadContext
  2. Вернуть оригинальную инструкцию с помощью WriteProcessMemory, FlushInstructionCache
  3. Отобразить актуальные значения регистров
  4. С помощью функций символьной информации *.PDB файлов, отобразить исходный код и номер строки (если возможно)
  5. С помощью функций трассировки стека вызовов и функций символьной информации, получить стек вызовов и отобразить его
  6. Ожидание реакции пользователя
  7. Выполнение события, запрошенного пользователем (Continue, Step, Stop, …)
  8. Вызов ContinueDebugEvent

Удивлены? Отлично! Я надеюсь, что Вы наслаждаетесь отладкой!
Один важный момент, который стоит упомянуть – поток, который отлаживается не может быть первичным потоком отлаживаемого, но он должен вызывать инструкцию точки останова. До сих пор, мы всё ещё обрабатываем первое событие точки останова для приостановки выполнения программы. Но те восемь этапов, что я перечислил выше, будут применяться ко всем событиям отладки (из любого потока отлаживаемого), которые могут приостановить выполнение программы.
Существуют ещё небольшие сложности с изменением EIP. Позвольте мне рассказать о самой проблеме, а решение к ней я Вам покажу позже. Точка останова может быть установлена пользователем, и мы точно также заменяем инструкции по эти адресам на CC (конечно же, сохраняя оригинальные инструкции). Как только выполнение программы доходит до очередной точки останова, мы просто возвращаем инструкцию и выполняем те 8 шагов, что я описал выше. Достаточно подробно? Что ж, если мы так сделаем, то выполнение программы приостановится в этом месте только на один раз, а если мы не вернём оригинальную инструкцию, то получится полнейшая путаница!

В любом случае, позвольте мне продолжить!
Ах, да! Исходный код! Я знаю, Вы до смерти уже хотите узнать, как это делается!
Любой образ *.EXE и *.DLL может иметь отладочную информацию, поставляемую с ним в *.PDB файле. Немного об этом:
  • Отладочная информация будет доступна только если при компиляции у компоновщика был выставлен флаг /DEBUG. В Visual Studio Вы можете это изменить в свойствах проекта (Linker- > Debugging -> Generate Debug Info).
  • Флаг /DEBUG не значит, что EXE/DLL будет собрана в отладочной конфигурации. Макросы препроцессора _DEBUG/DEBUG отрабатывают во время компиляции. А вот остальное уже во время линковки.
  • Это означает, что даже в конфигурации Release образ может содержать отладочную информацию, а в конфигурации Debug может не содержать.
  • Файл с расширением *.PDB хранит отладочную информацию и обычно имеет имя <название_программы>.pdb, но его можно переименовать с помощью опций компоновщика. Файл содержит всю информацию об исходном коде: функции, классы, типы и многое другое.
  • Компоновщик помещает небольшой кусочек информации о *.PDB файле в заголовок образа EXE/DLL. Так как эта информация помещается в заголовок, то это не влияет на производительность файла, только размер файла увеличивается на несколько байт/килобайт.

Чтобы получить отладочную информацию, мы должны использовать Sym* функции, находящиеся внутри DbgHelp.Dll. Эта библиотека является самым важным компонентом отладки на уровне исходного кода. Она также содержит функции трассировки стека вызовов и для получения информации об образе EXE/DLL. Для их использования необходимо подключить Dbghelp.h и DbgHelp.lib.
Чтобы получить информацию об отладке, необходимо инициализировать обработчик символов для данного процесса. Так как наш целевой процесс – это debugee, мы инициализируем его с идентификатором debugee. Для инициализации обработчика символов, нам надо вызвать функцию SymInitialize:

BOOL bSuccess = SymInitialize(m_cProcessInfo.hProcess, NULL, false);


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

'Debugger.exe': Loaded 'C:\Windows\SysWOW64\msvcrt.dll', Cannot find or open the PDB file
'Debugger.exe': Loaded 'C:\Windows\SysWOW64\mfc100ud.dll', Symbols loaded.


Visual Studio 2010 не удалось найти символы для msvcrt.dll. А библиотека mfc100ud.dll имеет свои отладочные символы, поэтому Visual Studio смогла их загрузить. По сути это означает, что для библиотек MFC Visual Studio будет показывать символьную информацию, исходный код, имена классов/функций, стек выховов и т.п. Для явной загрузки символов для соответствующих библиотек/exe-файлов, мы вызываем функцию SymLoadModule64/SymLoadModuleEx.
Где и когда мы должны вызывать эти функции? Мне потребовалось очень много времени, пока я пытался инициализировать и загрузить отладочную информацию до отладочного цикла (т.е. до какого-либо события отладки, но после CreateProcess). Это не сработало. Это надо делать при обработке CREATE_PROCESS_DEBUG_EVENT. Так как мы отказываемся от автоматической загрузки символов у зависимых модулей, нам надо вызывать функцию SymLoadModule64/Ex для только что загрузившегося EXE-файла. Для приходящих событий LOAD_DLL_DEBUG_EVENT, нам также необходимо вызывать эту функцию. В зависимости от настроек модуля, мы либо сможем показать пользователю отладочную информацию, либо нет.
Ниже Вы можете увидеть пример кода загрузки отладочной информации при обработке события загрузки библиотеки. Функция GetFileNameFromHandle описана в предыдущей части статьи.

case LOAD_DLL_DEBUG_EVENT:
   {
    CStringA sDLLName;
    sDLLName = GetFileNameFromHandle(debug_event.u.LoadDll.hFile);
 
    DWORD64 dwBase = SymLoadModule64 (m_cProcessInfo.hProcess, NULL, sDLLName,
     0, (DWORD64)debug_event.u.LoadDll.lpBaseOfDll, 0);

    strEventMessage.Format(L"Loaded DLL '%s' at address %x.", 
                            sDLLName, debug_event.u.LoadDll.lpBaseOfDll);  
...


Конечно, подобный код будет и при загрузке процесса. Небольшая оговорка: успешная инициализация отладочной информации и успешная её загрузка не означает, что будет доступен исходный код! Нам надо вызвать SymGetModuleInfo64 для загрузки информации из *.PDB, если она доступна. Вот как это делается:

// Code continues from above
IMAGEHLP_MODULE64 module_info;
module_info.SizeOfStruct = sizeof(module_info);
BOOL bSuccess = SymGetModuleInfo64(m_cProcessInfo.hProcess,dwBase, &module_info);

// Check and notify
if (bSuccess && module_info.SymType == SymPdb)
{
     strEventMessage += ", Symbols Loaded";
}

else
{
     strEventMessage +=", No debugging symbols found.";
}


Я очень благодарен Jochen Kalmbach за его прекрасную статью о трассировке стека, которая помогла мне в поиске информации об исходном коде и трассировке стека.
Когда тип символа SymPdb, у нас есть информация об исходном коде. *.PDB содержит только информацию об исходном коде, сам исходный код (файлы *.h и *.cpp) должнен быть доступен по указанному пути! *.PDB содержит названия символов, имена файлов, номера строк и многое другое. Трассировка стека (без обзора исходного кода) вполне возможна, если у нас есть имена функций.
Наконец, по приходу события точки останова, мы можем получить стек вызовов и показать его. Для этого нам необходимо вызвать функцию StackWalk64. Ниже Вы можете посмотреть на урезанный пример кода, в котором используется эта функция. Пожалуйста, для полного понимания, прочтите статью Jochen Kalmbach, о которой я говорил.

void RetrieveCallstack(HANDLE hThread)
{
   STACKFRAME64 stack={0};
   // Initialize 'stack' with some required stuff.
  
   StackWalk64(IMAGE_FILE_MACHINE_I386, m_cProcessInfo.hProcess, hThread, &stack,
               &context, _ProcessMemoryReader, SymFunctionTableAccess64,
               SymGetModuleBase64, 0);
...


STACKFRAME64 – это структура данных, которая содержит адреса, откуда извлекается информация о стеке вызовов. Как пишет Jochen, для x86 нам необходимо инициализировать эту структуру перед вызовом функции StackWalk64:

CONTEXT context;
 context.ContextFlags = CONTEXT_FULL;
 GetThreadContext(hThread, &context);

 // Must be like this
 stack.AddrPC.Offset = context.Eip; // EIP - Instruction Pointer
 stack.AddrPC.Mode = AddrModeFlat;
 stack.AddrFrame.Offset = context.Ebp; // EBP
 stack.AddrFrame.Mode = AddrModeFlat;
 stack.AddrStack.Offset = context.Esp; // ESP - Stack Pointer
 stack.AddrStack.Mode = AddrModeFlat;


При вызове StackWalk64, первая константа определяет тип машины, который x86. Следующий аргумент – идентификатор отлаживаемого процесса. Третий – идентификатор потока, в котором мы будем получать стек вызовов (не обязательно основной поток). Четвёртый параметр – это самый важный для нас параметр. Пятый – контекст структуры, имеющий необходимые адреса для инициализации. Функция _ProcessMemoryReader – это объявленная нами функция, которая ничего не делает, кроме вызова ReadProcessMemory. Другие две Sym* функции из DbgHelp.dll. Последний параметр тоже является указателем на функцию, но он нам не нужен.
Для трассировки стека вызовов определённо нужен цикл, пока трассировка не закончится. Пока открыты такие вопросы, как: недействительный стек вызовов, бесконечный стек вызовов и некоторые другие, я решил сделать по-простому: вызывать функцию до тех пор, пока возвращаемый адрес не станет NULL, или пока StackWalk64 не выполнится аварийно. Ниже показано, как мы будем получать стек вызовов (получение имён функций будет чуть позже):

BOOL bSuccess;
do
{
    bSuccess = StackWalk64(IMAGE_FILE_MACHINE_I386, ... ,0);
    if(!bTempBool)        

       break;

    // Symbol retrieval code goes here.
    // The contents of 'stack' would help determining symbols.
    // Which would put information in a vector.

}while ( stack.AddrReturn.Offset != 0 );


Отладочный символ имеет несколько свойств:
  • Имя модуля (exe или dll)
  • Имя символа – декорированное или не декорированное
  • Тип символа: функция, класс, параметр, локальная переменная и т.п.
  • Виртуальный адрес символа

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

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

IMAGEHLP_MODULE64 module={0};
module.SizeOfStruct = sizeof(module);
SymGetModuleInfo64(m_cProcessInfo.hProcess, (DWORD64)stack.AddrPC.Offset, &module);

Переменная module.ModuleName будет содержать в себе имя модуля, без расширения или пути. Поле module.MoadedImageModule будет содержать в себе полное имя файла. Module.LineNumbers будет указывать, доступна ли информация по строкам или нет (1 – доступна). Там же есть ещё несколько полезных полей структуры.
После этого мы получаем имя функции для этого стека, используя функцию SymGetSymFromAddr64 или SymFromAddr. Первая функция возвращает информацию через структуру PIMAGEHLP_SYMBOL64, которая содержит в себе 6 полей, а вторая возвращает информацию (к слову, более подробную) через SYMBOL_INFO. Обе принимают четыре аргумента, из которых три одинаковы, и последний аргумент – указатель на структуру. Ниже показан пример первой функции:
IMAGEHLP_SYMBOL64 *pSymbol;
DWORD dwDisplacement;
pSymbol = (IMAGEHLP_SYMBOL64*)new BYTE[sizeof(IMAGEHLP_SYMBOL64)+MAX_SYM_NAME];

memset(pSymbol, 0, sizeof(IMAGEHLP_SYMBOL64) + MAX_SYM_NAME);
pSymbol->SizeOfStruct = sizeof(IMAGEHLP_SYMBOL64); // Required
pSymbol->MaxNameLength = MAX_SYM_NAME;             // Required
 
SymGetSymFromAddr64(m_cProcessInfo.hProcess, stack.AddrPC.Offset, 
                   &dwDisplacement, pSymbol); // Retruns true on success


Немного об этом странном коде:
  • Имя символа может быть переменной блины. Таким образом, нам надо выделить достаточно большой буфер для этой переменной. Предопределённый макрос MAX_SYM_NAME имеет значение 2000.
  • Структура IMAGEHLP_SYMBOL64 может иметь разный размер в библиотеке DbgHlp.dll и при компиляции. Поэтому мы должны указать явно её размер при инициализации (стандартный механизм защиты от разных версий структур — прим. пер.), SizeOfStruct должен быть проинициализирован до того, как мы начнём его использовать. Назначение MaxNameLength довольно очевидно.
  • Самыми важными полями структуры для нас являются: Name (строка, завершающаяся нулём) и Address, который является виртуальным адресов символа (включая базовый адрес модуля).

Использование SymFromAddr и инициализация SYMBOL_INFO практически такая же, и я предпочитаю использовать эту новую функцию. Хотя сейчас она не может нам дать никакой дополнительной информации, потом я поясню все поля этой структуры по мере надобности.
Наконец, чтобы завершить работу со стеком вызовов, нам надо получить путь к исходному коду и номер строки. Напоминаю, что *.PDB файл содержит эту информацию только если загрузка отладочный символов прошла успешно. Также PDB содержит только информацию об исходном коде, а не исходный код.
Для получения информации с номером строки, нам надо использовать SymGetLineFromAddr64 и получать её через структуру IMAGEHLP_LINE64. Эта функция принимает 4 аргумента, и первые три совпадают с вышеописанной функцией. Надо только инициализировать структуру правильным размером. Это выглядит следующим образом:

IMAGEHLP_LINE64 line;
line.SizeOfStruct = sizeof(line);

bSuccess = SymGetLineFromAddr64(m_cProcessInfo.hProcess, 
                               (DWORD)stack.AddrPC.Offset, 
                               &dwDisplacement, &line);

if(bSuccess)
{
   // Use line.FileName, and line.LineNumber
}


Функции отладочных символов или какие-либо другие функции из DbgHlp.dll не поддерживают загрузку исходного кода и его отображение. Нам надо делать это самим. Если информация о строке или файл исходного кода недоступен, мы не сможем показать исходный код.
На момент написания статьи я ещё не решил, что будет отображаться, если исходный код недоступен. Мы можем показывать набор инструкций, но x86-инструкции имеют нефиксированный размер. Мы можем показывать просто последовательность байт (например, «55 04 FF 76 78 AE…») в одну строку. Или мы можем дизассемблировать инструкции и показывать результат. Хоть у меня и есть модуль по дизассемблированию x86-кода, он понимает не все инструкции.
На текущий момент, я показал Вам важные шаги для остановки отладки по определённому адресу. Они включают в себя получение базового адреса, установка точки останова по этому адресу, обработка события прерывания по точке останова, возвращение оригинальной инструкции, получение значений регистров, стек вызовов, исходный код и базовая информация о том, как отображать отладочную информацию в UI. Я также уточнил, что нам необходимо использовать события Windows для прерывания и продолжения приостановленного отлаживаемого приложения в соответствии с запросом пользователя.

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


Как уже было сказано раннее, чтобы приостановить отлаживаемое приложение, мы просто не вызываем ContinueDebugEvent. Архитектура отладчиков подразумевает, что все потоки отлаживаемого приложения также приостановлены.
Вот примерный вид приостановки и возобновления отладки (UT – пользовательский поток, DT – поток отладчика):
[UT] Пользователь инициализирует поток отладки с помощью пользовательского интерфейса. Эта операция не останавливает отрисовку UI.
[DT] Инициализирует событие отладки через CreateEvent.
[DT] Отладчик запускает отлаживаемое приложение через CreateProcess и входит в отладочный цикл.
[DT] Отладчик достигает точки останова, показывает информацию и приостанавливает выполнение.
[DT] Отладчик использует WaitForSingleObject для поддержания DT в приостановленном состоянии
[UT] Пользователь выполняет какие-либо действия, связанные с отладкой (Продолжить, Остановить и Войти в функцию)
[UT] Отладчик вызывает соответствующие функции для возобновления выполнения и SetEvent для пробуждения DT
[DT] Продолжает выполнение, анализирует действия пользователя и продолжает отладочный цикл или прекращает отладку.
Всё будет немного понятнее, когда я покажу Вам интерфейс (класс) отладочного ядра. Если у Вас хорошая память и/или Вы любознательны, то должны были заметить, что я не рассказал ещё пару вещей:
Актуальный код для получения базового адреса (функция GetStartAddress!). напоминаю, CREATE_PROCESS_DEBUG_INFO::lpStartAddress – это стартовый адрес, но он не всегда корректен. И как обрабатывать точки останова, на которых надо остановиться не один раз? Тот код, который написан сейчас устанавливает точки останова, которые срабатывают единожды, так как они восстанавливают оригинальный адрес для нормального функционирования программы.
В любом случае, после того, как я описал DbgHelp.dll и Sym* функции, я могу показать Вам функцию получения стартового адреса процесса. Имя функции SymFromName и она принимает имя символа, а возвращает SYMBOL_INFO. Предыдущая похожая функция SymGetSymFromName64, которая возвращает информацию через PIMAGEHLP_SYMBOL64. Используя код ниже, мы можем получить адрес wWinMainCRTStartup, используя функцию SymFromName:

DWORD GetStartAddress( HANDLE hProcess, HANDLE hThread )
{
   SYMBOL_INFO *pSymbol;
   pSymbol = (SYMBOL_INFO *)new BYTE[sizeof(SYMBOL_INFO )+MAX_SYM_NAME];
   pSymbol->SizeOfStruct= sizeof(SYMBOL_INFO );
   pSymbol->MaxNameLen = MAX_SYM_NAME;
   SymFromName(hProcess,"wWinMainCRTStartup",pSymbol);

   // Store address, before deleting pointer  
   DWORD dwAddress = pSymbol->Address;

   delete [](BYTE*)pSymbol; // Valid syntax!

   return dwAddress;
}


Конечно, она извлекает адрес только wWinmainCRTStartup, которая может и не быть отправной точкой программы. Что ж, это выходит за рамки нашей статьи, как, например, определение, не является ли EXE файл битым, точно ли он 32-разрядный, собран как неуправляемый код, Unicode или ANSI сборка или тому подобное.
А что насчёт пользовательских точек останова? Чуть позже я об этом напишу.

CDebuggerCore — The debugging-interface class


Я написал абстрактный класс, который имеет только несколько чисто виртуальных функций, необходимых для отладки. В отличии от предыдущей статьи, которая была тесно интегрирована в пользовательский интерфейс и MFC, я сделал этот класс независимым ни от чего. Я использовал нативные идентификаторы Windows, STL и CString класс в этом классе. Обратите внимание, что можно использовать CString из не-MFC приложений через подключение <atlstr.h>. Не надо компоновать программу с MFC-библиотеками, достаточно только одного заголовочного файла. Если CString Вас всё же смущает, замените его на свой любимый класс строки и всё.
Вот базовый скелет CDebuggerCore:

class CDebuggerCore

{
    HANDLE m_hDebuggerThread;    // ИHandle to debugger thread
    HANDLE m_heResumeDebugging;  // Set by ResumeDebugging

    PROCESS_INFORMATION m_cProcessInfo;
    // Other member not shown for now.

public:
    // Asynchronous call to start debugging
    // Spawns a thread, that has the debugging-loop, which
    // calls following virtual functions to notify debugging events.
    int StartDebugging(const CString& strExeFullPath);

    // How the user responded to continue debugging, 
    // it may also include stop-debugging.
    // To be called from UI-thread
    int ResumeDebugging(EResumeMode);

    // Don't want to listen anything! Terminate!
    void StopDebugging();
 
protected:
    // Abstract methods 
    virtual void OnDebugOutput(const TDebugOutput&) = 0;
    virtual void OnDllLoad(const TDllLoadEvent&) = 0;


    virtual void OnUpdateRegisters(const TRegisters&) = 0;
    virtual void OnUpdateCallStack(const TCallStack&) = 0;

    virtual void OnHaltDebugging(EHaltReason) = 0;
};


Некоторым читателям этот класс может не понравиться. Но для того, чтобы объяснить, как работает код отладчика, я должен написать сам код!
Так как этот класс является абстрактным, он должен быть базовым для какого-то другого класса и все виртуальные методы (On*) должны быть перегружены. Само собой разумеется, эти виртуальные функции вызываются из основного класса в зависимости от разных событий отладки. Ни одна виртуальная функция не требует возвращаемого значения, поэтому Вы можете оставить реализацию пустой.
Предположим, что Вы создали класс CDebugger, унаследованный от CDebuggerCore, и реализовали все виртуальные функции. Тогда Вы можете начать отладку с этого кода:

// In header, or at some persist-able location
CDebugger theDebugger;

// The point in code where you ask it to start debugging:
theDebugger.StartDebugging("Path to executable");


Который просто инициализирует необходимые переменные для установки состояния отладки, создаёт обработчик событий, о котором я писал выше, и запускает поток отладчика. После этого метод завершится – это означает, что StartDebugging асинхронный.
Фактически, отладочный цикл находится в методе DebuggerThread (в этом коде его нет). Он запускает исполняемый файл через CreateProcess, входит в отладочный цикл и контролирует его ход с помощью функций WaitForDebugEvent и ContinueDebugEvent. При возникновении какого-либо события отладки, этот метод вызывает один из соответствующих виртуальных методов On*. Например, если пришло событие OUTPUT_DEBUG_STRING_EVENT, он вызывает OnDebugOutput со строковым параметром. И для других событий отладки он вызывает соответствующие методы. А наследуемый класс уже обрабатывает все события должным образом.
Для некоторых отладочных событий, которые прерывают отладку, например, событие точки останова, отладочный цикл сначала вызовет соответствующий On* метод, а потом вызывает HaltDebugging с соответствующим кодом. Эта функция описана как private для CDebuggerCore, и объявлена следующим образом:

// Enum
enum EHaltReason
{
     // Reason codes, like Breakpoint
};

// In CDebuggerCore
private:
   void HaltDebugging(EHaltReason);


Описание этого метода ниже:

void CDebuggerCore::HaltDebugging(EHaltReason eHaltReason)
{
   // Halt the debugging
   OnHaltDebugging(eHaltReason);

   // And wait for it, until ResumeDebugging is called, which would set the event
   WaitForSingleObject(m_heResumeDebugging,INFINITE);
}


Поскольку отладочный цикл точно знает точную причину остановки, он передаёт её в HaltDebugging, который делегирует её в OnHaltDebugging. Переопределение OnHaltDebugging ложится полностью на плечи разработчика, и он уже сам решает, как необходимо обработать то или иное событие. Поток интерфейса не замораживается, но ждёт дальнейшей реакции пользователя. DT приостанавливается.
При правильном UI, таким как: меню, горячие клавиши и т.п. поток UI вызывает ResumeDebugging c соответствующим режимом возобновления (например, «Продолжить выполнение программы (Continue)», «Войти в функцию (StepIp)» или «Остановить отладку (Stop)»). Метод ResumeDebugging, который принимает в качестве аргумента флаг EResumeMode, устанавливает переменную член класса в этот флаги потом вызывает SetEvent для сигнализации о событии. Это продолжит выполнения потока отладки.
Теперь, когда HaltDebugging вернула значение, отладочный цикл проверяет, какое действие совершил пользователь. Для этого надо проверить переменную m_eResumeMode, которая была установлена ResumeDebugging и продолжить отладку; или завершит отладку, если пришёл соответствующий код. Просто для примера, EResumeMode выглядит примерно так:

// What action was initiated by user to resume 
enum EResumeMode
{
 Continue, // F5
 Stop,     // Shift+F5
 StepOver, // F10
 // More .. 
};


В заключительной части будет:
  • Получение исходных кодов и номеров строк
  • Установка пользовательских точек останова
  • Трассировка кода (step-in, step-out, step-over)
  • Условные точки останова
  • Отладка запущенного процесса
  • Отсоединение от процесса, завершение или ожидание?
  • Отладка упавшего процесса
  • Подключение отладчика вручную
Теги:
Хабы:
+45
Комментарии 2
Комментарии Комментарии 2

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн