Беляев Алексей
Когда приложение перестает работать в офисе у разработчика, найти ошибку и исправить ее не составляет труда. Когда же приложение отказывает у клиента, то трудно найти общий язык с огорченным пользователем, и понять что ты сделал не так...
Что такое ошибка? Отвечая на этот вопрос кратко, можно сказать, что ошибка – это отклонение от описанного поведения. Для разработчика это означает, что необходимо искать и исправлять причину этого отклонения. Для программиста контроля качества ПО это означает, что необходимо доработать тесты и включить их в базовый цикл тестирования приложения. Для руководства это означает увеличение времени и затрат на разработку продукта.
Ошибки бывают разные, одни воспроизводятся легко, другие трудно. На поиск одних тратится немного времени, на поиск других уходят дни. Основная проблема поиска ошибки зачастую связана с недостатком информации по ее воспроизведению или состоянии приложения в момент возникновения ошибки. Если бы разработчик имел информацию о том, какая строчка программы содержит ошибку, ему не составило бы труда исправить ее.
Единственный способ избежать ошибок в программах – это писать код без ошибок. Но человек не может не делать ошибок, поэтому в любой программе они есть. Единственное, что разработчик может постараться сделать – это минимизировать количество ошибок, а также облегчить их поиск и исправление. В этой статье рассматривается способ, помогающий ускорить поиск и исправление ошибок.
Windows и необработанные исключения
Когда в приложении, работающем под управлением ОС Windows (от 9х до ХР), возникает необработанное исключение, операционная система обрабатывает его, создает dump-файл и записывает в него информацию, анализируя которую можно восстановить состояние приложения и быстро найти ошибку. К информации, которую сохраняет операционная система, относится:
информация о потоках;
информация о загруженных модулях;
информация об исключении;
информация о системе;
информация о памяти.
Всю собранную информацию операционная система предлагает послать в Microsoft для последующего анализа. Учитывая полезность этой информации и выгоду, которую предоставляет владение ей, необходимо на этапе проектирования приложения заложить возможность для сбора информации о приложении во время возникновения исключения для последующего ее анализа. Тогда разработчики будут иметь больше информации для поиска и исправления ошибок.
Каким образом Windows XP определяет, что в приложении произошло необработанное исключение? Ответить на этот вопрос можно, если разобраться с механизмом структурированной обработки исключений (SEH). Все версии Windows, начиная с версии Windows 95 и Windows NT, поддерживают этот механизм обработки исключений, позволяющий операционной системе и приложению тесно взаимодействовать в случае возникновения исключительной ситуации. И если в каком-либо приложении возникает необработанное исключение, операционная система обрабатывает его и завершает приложение.
Структурированная обработка ошибок
Структурированная обработка исключений (SEH) – это предоставляемый системой сервис, вокруг которого библиотеки современных языков программирования реализуют свои собственные функции для работы с исключениями.
C++-программисты наверняка знакомы с SEH в основном по использованию конструкций __try ... __except. Встретив в теле функции конструкцию __try … __except, компилятор, поддерживающий SEH, генерирует код для регистрации обработчика исключения. Затем, после возникновения исключения, операционная система ищет подходящий обработчик. Если подходящий обработчик не найден, операционная система создает dump-файл и завершает работу приложения.
Таким образом, перед нами стоит задача – сделать так, чтобы после возникновения в приложении необработанного исключения вызывался наш обработчик.
Для решения этой задачи необходимо выяснить, как операционная система ищет обработчик исключения. В поисках ответа на этот вопрос я углублялся в документацию по механизму структурированных исключений, анализировал системный ассемблерный код, смотрел, что генерирует компилятор, когда встречает конструкцию __try … __except, но подходящего решения не находилось. Ни в SDK, ни в DDK я не нашел ничего, что могло бы ответить на этот вопрос. Анализируя код, генерируемый компилятором для конструкции __try … __except, я увидел, что для каждого нового обработчика исключений в стеке создается запись, которая помещается в связанный список. Вот пример простой функции, который поможет понятнее объяснить это:
void foo(){ __try { } __except(1) { }} |
Код, который был сгенерирован компилятором VC 7.0:
void foo(){00411DE0 push ebp 00411DE1 mov ebp,esp 00411DE3 push 0FFFFFFFFh 00411DE5 push 424140h 00411DEA push offset @ILT+390(__except_handler3) (41118Bh) 00411DEF mov eax,dword ptr fs:[00000000h] 00411DF5 push eax 00411DF6 mov dword ptr fs:[0],esp 00411DFD add esp,0FFFFFF38h 00411E03 push ebx 00411E04 push esi 00411E05 push edi 00411E06 lea edi,[ebp-0D8h] 00411E0C mov ecx,30h 00411E11 mov eax,0CCCCCCCCh 00411E16 rep stos dword ptr [edi] 00411E18 mov dword ptr [ebp-18h],esp __try00411E1B mov dword ptr [ebp-4],0 00411E22 mov dword ptr [ebp-4],0FFFFFFFFh 00411E29 jmp $L19329+0Ah (411E3Bh) { } __except(1)00411E2B mov eax,1 $L19330:00411E30 ret $L19329:00411E31 mov esp,dword ptr [ebp-18h] 00411E34 mov dword ptr [ebp-4],0FFFFFFFFh { }}00411E3B mov ecx,dword ptr [ebp-10h] 00411E3E mov dword ptr fs:[0],ecx 00411E45 pop edi 00411E46 pop esi 00411E47 pop ebx 00411E48 mov esp,ebp 00411E4A pop ebp 00411E4B ret |
Теперь давайте посмотрим, что делает этот ассемблерный код. В начале функции создается фрейм стека. В этом нет ничего необычного – такой код есть в большинстве функций, но вот несколько следующих инструкций показались мне очень интересными:
00411DE3 push 0FFFFFFFFh 00411DE5 push 424140h 00411DEA push offset @ILT+390(__except_handler3) (41118Bh) 00411DEF mov eax,dword ptr fs:[00000000h] 00411DF5 push eax 00411DF6 mov dword ptr fs:[0],esp |
Вначале в стек кладется -1 (как оказалось впоследствии, просто резервируется место в стеке), а затем в стек записывается адрес статической переменной и адрес обработчика исключения. Если присмотреться к последним трем инструкциям, то можно увидеть, что из памяти по адресу fs:[0] считывается какое-то число, и кладется в стек, а на его место заносится текущий указатель стека. В принципе ничего подозрительного тут нет, но если расположить эти три инструкции последовательно несколько раз, то станет заметно, что они формируют связанный список, причем первый элемент этого списка всегда указывает на предыдущий элемент. На выходе из функции находится код, который восстанавливает предыдущее значение переменной по адресу fs:[0] :
00411E3B mov ecx,dword ptr [ebp-10h] 00411E3E mov dword ptr fs:[0],ecx |
Таким образом, если функция имеет в себе конструкцию __try … __except, то компилятор создает в стеке запись о новом обработчике исключений и помещает информацию о ней в список обработчиков. Придя к такому выводу, я начал искать хоть какую-то информацию об обработчиках исключений и нашел публикацию, написанную Matt Pietrek-ом 7 летназад (A Crash Course on the Depths of Win32 Structured Exception Handling). В этой статье описана структура SEH, и подтверждаются выводы, сделанные путем анализа кода приведенной выше функции. Изучив эту статью и проверив написанное в ней, я обнаружил, что с тех пор в области обработки исключений практически ничего не изменилось.
Из статьи следует, что по адресу fs:[0], находится начало связанного списка зарегистрированных обработчиков исключения, элементами которого являются структуры типа _EXCEPTION_REGISTRATION, расположенные в стеке.
struct _EXCEPTION_REGISTRATION{ // указательнаследующуюзапись _EXCEPTION_REGISTRATION *prev; // обработчик исключения, созданный Runtime библиотекой SEHHandler handler; // указатель на структуру, описывающий блок __try…__except PSCOPETABLE scopetable; // уровень вложенности текущего блока try int trylevel; // указатель на следующую запись int _ebp;}; |
В этой структуре handler является процедурой обработки исключения. Прототип этой функции приведен ниже:
typedef int (*SEHHandler)(PEXCEPTION_RECORD, PEXCEPTION_REGISTRATION, PCONTEXT, void*); |
Как видите, функция обработчика исключения принимает 4 параметра. Первый параметр имеет тип PEXCEPTION_RECORD – это указатель на структуру, содержащую информацию об исключении. Вы можете найти объявление этой структуры в заголовочном файле winnt.h:
typedef struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; _EXCEPTION_RECORD *ExceptionRecord; PVOID ExceptionAddress; DWORD NumberParameters; ULONG_PTR ExceptionInformation [EXCEPTION_MAXIMUM_PARAMETERS];} EXCEPTION_RECORD; |
Описание наиболее значимых полей этой структуры приведено ниже:
ExceptionCode – тип исключения.
ExceptionFlags – флаг исключения.
ExceptionAddress – адрес участка кода, где возникло исключение.
Второй параметр функции содержит в себе указатель на структуру PEXCEPTION_REGISTRATION. Ее описание и назначение было приведено выше.
Третий параметр указывает на переменную типа PCONTEXT и несет информацию о состоянии регистров во время исключения.
Таким образом, механизм обработки исключений становится более или менее ясным, т.е. когда в приложении возникает исключение, операционная система берет текущий указатель по адресу fs:[0] и просматривает список обработчиков в поисках нужного обработчика исключения. Если обработчик найден, она передает ему управление. В противном случае операционная система выполняет свой обработчик, который вызывает функцию UnhandledExceptionFilter. Значит, для получения управления в случае возникновения необработанного исключения, нужно зарегистрировать свой обработчик и расположить его в вершине этого списка. Но мир программирования не был бы таким интересным, если бы все было так просто! Давайте пройдем дальше и посмотрим, что происходит во время старта приложения, и какую роль в обработке исключений играет runtime-библиотека.
Старт приложения и инициализация runtime
Когда операционная система загружает приложение, она считывает содержимое файла с диска в память, загружает все необходимые для работы приложения внешние библиотеки и инициализирует таблицу импорта адресами реальных функций. После этого загрузчик передает управление на точку входа приложения. В случае С++-приложений, написанных с использованием Microsoft Visual Studio 6.0 (7.x, 8.0), управление передается функции WinMainCRTStartup или wWinMainCRTStartup, в зависимости от версии runtime-библиотеки – UNICODE или Multi-Byte Character Set (MBCS). Эта функция подготавливает приложение к работе, инициализирует runtime, выполняет конструкторы всех статических переменных и передает управление на точку входа, определенную разработчиком. Если внимательно рассмотреть эту функцию, можно увидеть, что инициализация пользовательских статических переменных и передача управления на пользовательскую точку входа осуществляется внутри блока __try … __except. Углубившись в исследование фильтра исключений runtime-библиотеки, я обнаружил, что в случае возникновения необработанного исключения он вызывает функцию UnhandledExceptionFilter.