Win32™ SEH изнутри (ч.1)

Дата публикации 11 авг 2005

Win32™ SEH изнутри (ч.1) — Архив WASM.RU

Win32™ SEH изнутри

(листинг)

В своей основе, структурная обработка исключений - это сервис, предоставляемый системой. Вся документация по SEH, которую вы, вероятно, найдете, описывает одну лишь компиляторно-зависимую оболочку, созданную RTL вокруг реализации SEH операционной системы. Я здесь рассмотрю самые фундаментальные концепции SEH.

Эта статья предполагает, что вы уже знакомы с C++ и Win32

Из всех механизмов, предоставляемых операционными системами Win32®, возможно наиболее широко используемым, но не документированным является механизм структурной обработки исключений (он же Structured Exception Handling, или просто - SEH). Когда вы думаете о Win32 SEH, то, вероятно, вспоминаете термины подобные _try, _finally, и _except. Вы можете найти хорошее описания SEH в почти любой компетентной книге по Win32 (даже посредственной). Даже Win32 SDK имеет довольно законченный краткий обзор использования SEH с использованием _try, _finally, и _except.

Почему я, принимая во внимание всю эту документацию, утверждаю, что механизм SEH является не документированным? Утверждение основывается на том, что Win32 SEH - это механизм, предоставляемый операционной системой. Вся документация о SEH, которую вы, вероятно, найдете, описывает только компиляторно-зависимую обертку RTL (runtime-library) над механизмом SEH, реализованным на уровне операционной системы. В ключевых словах _try, _finally, или _except, нет ничего магического. Группы разработчиков из Microsoft, занимающиеся разработкой операционных систем и компиляторов, определили эти ключевые слова, и то, что они делают. Другие поставщики C++-компиляторов просто поддержали эту семантику. Механизм SEH уровня компилятора скрывает недружелюбность базового SEH уровня операционной системы, что позволило не обнародовать детали функционирования последнего.

Я получил много писем от людей, которым нужно было реализовать SEH уровня компилятора, и которые не смогли найти нужной информации в документации по подсистемам ОС. Было бы разумно с моей стороны просто указать им на исходники RTL Visual C++ или Borland C++, и забыть об этом. Увы, по какой то неизвестной причине, реализация SEH на уровне компилятора, похоже, является большой тайной. Ни Microsoft, ни Borland не предоставляют исходных кодов самого внутреннего уровня их поддержки SEH.

В этой статье я буду рассматривать SEH вплоть до самых фундаментальных концепций. При этом я отделю реализованное операционной системой от того, что обеспечивают компиляторы через генерацию объектного кода, и поддержку в RTL. Когда я буду рассматривать код ключевых функций ОС, я буду иметь в виду Intel-версию Windows NT 4.0. Однако, большая часть из описанного здесь, применима и к другим процессорным платформам.

Я собираюсь избегать проблем родного C++ механизма обработки исключений, в котором используется catch() вместо _except. В глубине, родной C++ механизм реализован подобно описанному здесь. Однако он имеет некоторые дополнительные сложности, которые усложнили бы восприятие тех концепций, которые я хочу здесь охватить.

При изучении невразумительных .h и .inc файлов для составления из отдельных частей того, что называется Win32 SEH, одним из лучших источников информации, как оказалось, являются заголовочные файлы IBM OS/2 (особенно BSEXCPT.H). Если вы знаете историю Microsoft, это не удивит вас. Механизмы SEH, описанные здесь были определены раньше, когда Microsoft еще работал над OS/2. По этой причине, вы увидите, что реализация SEH под Win32 и OS/2, очень похожа.

Введение в SEH

Так как подробности SEH, изложенные все сразу, могут испугать вас, я начну с простых вещей, и буду постепенно переходить к более сложным. Если вы никогда не работали c SEH, значит у вас нет предубеждений. Если раньше вы использовали SEH, постарайтесь забыть о таких словах как __try, GetExceptionCode и EXCEPTION_EXECUTE_HANDLER. Притворитесь, что это новая для вас тема. Глубоко вздохните. Готовы? Хорошо.

Представьте, что я сказал вам, что когда выполняющийся поток совершает ошибку, ОС дает вам возможность узнать об этом. Точнее, когда поток совершает ошибку, ОС вызывает определенную пользователем callback-функцию. Функциональность этой callback-функции в значительной степени не регламентирована, т.е. в процессе своей работы она может делать все, что вам угодно. Например, она может устранить причину ошибки, или же просто проиграть какой-нибудь wav-файл. Независимо оттого, что эта callback-функция делает, она должна возвратить значение, которое скажет системе, что делать дальше.

Учитывая, что ваша функция будет вызвана системой, после того как произойдет ошибка то, что она должна знать? Другими словами, какую информацию вам хотелось бы иметь об исключении? В действительности же, ваше мнение здесь не имеет значения, т.к. в Win32 все решено за вас. Вызываемая, при возникновении исключения, callback-функция выглядит примерно так:

EXCEPTION_DISPOSITION
 __cdecl _except_handler(
     struct _EXCEPTION_RECORD *ExceptionRecord,
     void * EstablisherFrame,
     struct _CONTEXT *ContextRecord,
     void * DispatcherContext
     );

Этот прототип, взятый из стандартного заголовочного файла Win32: EXCPT.H, сначала выглядит немного пугающе. Но, если вы будете изучать его не спеша, то все окажется не так уж сложно. Пока можете не обращать внимания на тип возвращаемого значения (EXCEPTION_DISPOSITION). Главное, что функция _except_handler имеет четыре параметра.

Первый параметр callback-функции _except_handler - указатель на структуру EXCEPTION_RECORD. Эта структура определена в WINNT.H, следующим образом:

typedef struct _EXCEPTION_RECORD {
     DWORD ExceptionCode;
     DWORD ExceptionFlags;
 struct _EXCEPTION_RECORD *ExceptionRecord;
     PVOID ExceptionAddress;
     DWORD NumberParameters;
     DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
 }  EXCEPTION_RECORD;

Поле ExceptionCode - содержит код, который в операционной системе закреплен за исключением. Вы можете посмотреть список различных кодов исключения в заголовочном файле WINNT.H. Для этого надо искать директивы #define, которые начинаются с "STATUS_". Например, код, всем известного, STATUS_ACCESS_VIOLATION - 0xC0000005. Наиболее полный набор кодов исключений можно найти в заголовочном файле NTSTATUS.H из Windows NT DDK. Четвертый элемент в структуре EXCEPTION_RECORD: ExceptionAddress - содержит адрес, где произошло исключение. На оставшиеся поля EXCEPTION_RECORD можно пока не обращать внимание.

Второй параметр функции _except_handler - содержит указатель на установочную структуру фрейма (establisher frame) принадлежащую этой callback-функции, т.е. на ту структуру, адрес которой заносят в FS:[0] при установке per-thread обработчика. Это жизненно важный параметр, но пока вы можете его игнорировать.

Третий параметр callback-функции _except_handler - содержит указатель на структуру CONTEXT. Эта структура содержит значения регистров в вызвавшем исключение потоке на момент возникновения исключения. На рис.1 показано, как определена эта структура в WinNT.H.

typedef struct _CONTEXT
 {
     DWORD ContextFlags;
     DWORD   Dr0;
     DWORD   Dr1;
     DWORD   Dr2;
     DWORD   Dr3;
     DWORD   Dr6;
     DWORD   Dr7;
     FLOATING_SAVE_AREA FloatSave;
     DWORD   SegGs;
     DWORD   SegFs;
     DWORD   SegEs;
     DWORD   SegDs;
     DWORD   Edi;
     DWORD   Esi;
     DWORD   Ebx;
     DWORD   Edx;
     DWORD   Ecx;
     DWORD   Eax;
     DWORD   Ebp;
     DWORD   Eip;
     DWORD   SegCs;
     DWORD   EFlags;
     DWORD   Esp;
     DWORD   SegSs;
 } CONTEXT;
Рисунок 1. Структура CONTEXT.

Кстати, эта же структура используется в API-функциях GetThreadContext и SetThreadContext.

Четвертый, заключительный, параметр callback-функции _except_handler называется DispatcherContext. Его также можно пока игнорировать.

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

Хотя меня постоянно подмывает, быстренько набросать вместе с вами типовую программку, которая показала бы callback-функцию _except_handler в действии, но для этого нам все еще кое-чего не хватает. В частности, откуда операционная система узнает, какую функцию вызвать при возникновении исключения? Ответ - есть еще одна структура, названная: EXCEPTION_REGISTRATION. Вы ещё не раз встретите её в этой статье, так что не проходите мимо её описания. Единственное место, где я смог найти формальное определение EXCEPTION_REGISTRATION, файл EXSUP.INC, находящийся в исходниках RTL из Visual C++:

_EXCEPTION_REGISTRATION struc
     prev    dd      ?
     handler dd      ?
 _EXCEPTION_REGISTRATION ends

Вы также можете увидеть, что в определении структуры NT_TIB, которое находится в заголовочном файле WINNT.H, на эту структуру ссылаются как на _EXCEPTION_REGISTRATION_RECORD. Увы, _EXCEPTION_REGISTRATION_RECORD нигде не определена, поэтому я был вынужден использовать определение этой структуры на ассемблере находящееся в файле EXSUP.INC. Это только один пример того, что я подразумевал ранее, когда я говорил, что SEH является не документированным механизмом.

В любом случае, давайте вернемся к рассматриваемому вопросу. Откуда операционная система узнает, какую функцию она должна вызвать при возникновении исключения? Структура EXCEPTION_REGISTRATION состоит из двух полей, первое, из которых вы можете пока игнорировать. Второе поле, handler, содержит указатель на callback-функцию обработчика исключений. Имеющаяся у нас теперь информация позволяет подойти немного ближе к ответу на наш вопрос, но теперь вопрос будем ставить так: каким образом операционная система находит нужный экземпляр структуры EXCEPTION_REGISTRATION?

Чтобы ответить на этот вопрос, полезно будет вспомнить, что SEH работает на поточно-зависимой основе. То есть каждый поток имеет свою собственную callback-функцию обработчика исключений (exception handler). В мае 1996 г. в своей колонке, я описал ключевую структуру данных Win32: блок информации потока (он же TEB или TIB). Некоторые поля этой структуры данных одинаковы в Windows NT, Windows 95, Win32s и OS/2. Первый DWORD в TIB'е - указатель на структуру EXCEPTION_REGISTRATION закрепленную за текущим потоком. На Intel Win32 платформе, регистр FS всегда указывает на текущий TIB. Таким образом, по адресу FS:[0] вы можете найти указатель на структуру EXCEPTION_REGISTRATION.

Рисунок 2. Схема вызова функции обработчика исключений операционной системой.

Наконец, сложив эти кирпичики вместе, я написал маленькую программку для демонстрации этого очень простого описания SEH на уровне ОС. На рис. 3 показан файл MYSEH.CPP, который имеет только две функции. Функция main использует три инлайновых ассемблерных блока. Первый блок создает в стеке структуру EXCEPTION_REGISTRATION используя две операции PUSH ("PUSH handler" и "PUSH FS:[0]"). PUSH FS:[0] сохраняет предыдущее значение FS:[0] как часть этой структуры, но в настоящее время это для нас не важно. Важно, что в стеке находится экземпляр 8-байтовой структуры EXCEPTION_REGISTRATION. Следующая операция (MOV FS:[0],ESP) делает так, чтобы первый DWORD в TIB’е указывал на новую структуру EXCEPTION_REGISTRATION.

//==================================================
// MYSEH - Мэт Питрек 1997
// Microsoft Systems Journal, Январь 1997
// FILE: MYSEH.CPP
// To compile: CL MYSEH.CPP
//==================================================
#define WIN32_LEAN_AND_MEAN
#include 
#include 

DWORD  scratch;

EXCEPTION_DISPOSITION
__cdecl
_except_handler(
    struct _EXCEPTION_RECORD *ExceptionRecord,
    void * EstablisherFrame,
    struct _CONTEXT *ContextRecord,
    void * DispatcherContext )
{
    unsigned i;

    // Сообщаем, что сработал наш обработчик исключений.
    printf( "Hello from an exception handler!\n" );

    // Изменяем значение регистра EAX в context record таким образом, чтобы оно
    // указывало на какое-либо, доступное для записи место в памяти.
    ContextRecord->Eax = (DWORD)&scratch;

    // Просим ОС еще раз попытаться выполнить вызвавшую исключение инструкцию.
    return ExceptionContinueExecution;
}

int main()
{
    DWORD handler = (DWORD)_except_handler;

    __asm
    {                      // Создаем структуру EXCEPTION_REGISTRATION:
        push    handler    // Адрес функции обработчика исключений.
        push    FS:[0]     // Адрес предыдущего EXECEPTION_REGISTRATION.
        mov     FS:[0],ESP // Добавляем в связанный список EXECEPTION_REGISTRATION.
    }

    __asm
    {
        mov     eax,0      // Обнуляем значение регистра EAX.
        mov     [eax], 1   // Чтобы преднамеренно вызвать исключение, делаем запись
                           // по нулевому адресу.
    }

    printf( " After writing!\n" );

    __asm
    {                       // Удаляем из связанного списка EXECEPTION_REGISTRATION.
        mov     eax,[ESP]   // Получаем указатель на предыдущий
                            // EXECEPTION_REGISTRATION.
        mov     FS:[0], EAX // Устанавливаем в начале списка предыдущий
                            // EXECEPTION_REGISTRATION.
        add     esp, 8      // Удаляем из стека структуру EXECEPTION_REGISTRATION.
    }
    return 0;
}
Рисунок 3. MYSEH.CPP

Если вас задаетесь вопросом: почему я создал экземпляр структуры EXCEPTION_REGISTRATION в стеке, вместо того, чтобы использовать для нее глобальную переменную, то на это есть серьезное основание. Когда вы используете синтаксис компилятора _try/_except, компилятор также создает в стеке экземпляр структуры EXCEPTION_REGISTRATION. Я просто показываю вам упрощенную версию того, что сделал бы компилятор, если бы вы использовали _try/_except. (Примечание переводчика: Edmond/HI-TECH по поводу возможности поместить экземпляр структуры EXCEPTION_REGISTRATION не в стек, а в глобальную переменную сказал следующее: Этого делать нельзя!!! Код ядра, который управляет SEH, проверяет, где находится экземпляр структуры EXCEPTION_REGISTRATION. Если эта структура находится не в стеке - он вызывает аварийное исключение.).

Вернемся к функции main, следующий ассемблерный блок преднамеренно вызывает ошибку, обнулив регистр EAX (MOV EAX, 0), и затем, использует значение этого регистра как адрес памяти, по которому следующая операция пытается произвести запись (MOV DWORD PTR [EAX], 1). Заключительный ассемблерный блок удаляет этот простой обработчик исключений: сначала он восстанавливает предыдущее содержимое FS:[0], и после этого удаляет с вершины стека запись EXCEPTION_REGISTRATION (ADD ESP, 8).

Теперь, представьте, что вы выполняете MYSEH.EXE, и способны видеть, что происходит. Когда выполняется инструкция "MOV DWORD PTR [EAX], 1", происходит исключение, вызванное нарушением прав доступа. Система смотрит на FS:[0] в TIB'е, и находит указатель на экземпляр структуры EXCEPTION_REGISTRATION. В этой структуре находится указатель на функцию обработчика исключений из MYSEH.CPP. После этого система помещает в стек четыре обязательных параметра (которые я описал ранее), и вызывает функцию _except_handler.

Код внутри _except_handler сначала, с помощью printf, выводит на экран надпись: “ Yo! I made it here!”. Затем _except_handler устраняет причину возникновения ошибки. Проблема, вызвавшая исключение заключается в том, что регистр EAX указывает на адрес памяти, по которому не может быть произведена запись (в данном случае, нулевой адрес). Исправление состоит в изменении значения регистра EAX в структуре CONTEXT, так чтобы он указывал на такое место в памяти, в которое разрешена запись. В этой простой программе переменная типа DWORD (scratch) создана только для этой цели. Последнее действие функции _except_handler - вернуть значение ExceptionContinueExecution, которое определено в стандартном файле EXCPT.H.

Когда ОС видит, что было возвращено значение ExceptionContinueExecution, она “понимает”, что вы исправили проблему, и команда, вызвавшая исключение, должна быть выполнена снова. Так как моя функция _except_handler скорректировала регистр EAX, чтобы он указывал на подходящую область памяти, со второй попытки инструкция "MOV DWORD PTR [EAX], 1" выполняется нормально, и функции main продолжает работать дальше. Видите, всё это было не слишком сложно, не так ли?

Погружаемся немного глубже

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

Помните структуру EXCEPTION_REGISTRATION, которую система использует, чтобы найти callback-функцию обработчика исключений? Первый член этой структуры, который я ранее игнорировал, называется prev. На самом деле это указатель на другую структуру EXCEPTION_REGISTRATION. Эта вторая структура EXCEPTION_REGISTRATION может иметь совершенно другую функцию обработчика. К тому же, его поле prev может указывать на третью структуру EXCEPTION_REGISTRATION, и так далее. Другими словами, мы имеем просто связанный список структур EXCEPTION_REGISTRATION. На начало этого списка всегда указывает первый DWORD в TIB (FS:[0] в машинах на основе Intel).

Что ОС делает с этим связанным списком структур EXCEPTION_REGISTRATION? Когда происходит исключение, система обходит этот список в поисках такой структуры EXCEPTION_REGISTRATION, обработчик исключений которой согласится обработать исключение. В случае MYSEH.CPP, обработчик согласился обработать исключение, вернув значение ExceptionContinueExecution. Обработчик исключений может также отказаться обрабатывать исключение. В этом случае ОС переходит к следующей структуре EXCEPTION_REGISTRATION, и просит её обработчик обработать исключение. Рис. 4 иллюстрирует данный процесс. Как только система находит обработчик исключений, который соглашается обработать исключение, она прекращает обход связанного списка структур EXCEPTION_REGISTRATION.

Чтобы увидеть пример обработчика исключений, который отказывается от обработки исключения, посмотрите MYSEH2.CPP на рис. 5. Чтобы не усложнять код, я немного схитрил, и использовал небольшой обработчик исключений уровня компилятора. Функция main всего лишь устанавливает блок _try/_except. В блоке _try - производится вызов функции HomeGrownFrame. Код этой функции очень похож на код предыдущей программы MYSEH. Она помещает EXCEPTION_REGISTRATION в стек, и делает так, чтобы FS:[0] указывал на вершину этой структуры. После установки нового обработчика, функция преднамеренно вызывает ошибку, производя запись в память по нулевому адресу:

*(PDWORD)0 = 0;

Функция обработчика исключений, снова названная _except_handler, сильно отличается от своей ранней версии. Код сначала распечатывает код и флажки исключения, взятые из структуры ExceptionRecord, указатель на которую функция получает в качестве параметра. Причина, по которой распечатываются флажки исключения, станет, ясна вам позже. Так как эта функция _except_handler не намерена обрабатывать исключение, она возвращает значение ExceptionContinueSearch. Это заставляет операционную систему продолжить поиск другого обработчика исключений в следующем EXCEPTION_REGISTRATION из связанного списка. А теперь, можете мне поверить, следующий установленный обработчик исключений - это обработчик из блока _try/_except в функции main. Блок _except просто распечатывает текст: "Caught the exception in main()".В данном случае обработка исключения также проста, как и его игнорирование.

Рисунок 4. Поиск нужного обработчика исключений. (Примечание переводчика: в оригинале этого рисунка есть ошибка. Там все обработчики отказываются от обработки исключения.)
//==================================================
 // MYSEH2 - Мэт Питрек 1997
 // Microsoft Systems Journal, Январь 1997
 // FILE: MYSEH2.CPP
 // To compile: CL MYSEH2.CPP
 //==================================================
 #define WIN32_LEAN_AND_MEAN
 #include 
 #include 
 
 EXCEPTION_DISPOSITION
 __cdecl
 _except_handler(
     struct _EXCEPTION_RECORD *ExceptionRecord,
     void * EstablisherFrame,
     struct _CONTEXT *ContextRecord,
     void * DispatcherContext )
 {
     printf( " Home Grown handler: Exception Code: %08X Exception Flags %X ",
              ExceptionRecord->ExceptionCode, ExceptionRecord->ExceptionFlags );
 
     if ( ExceptionRecord->ExceptionFlags & 1 )
         printf( " EH_NONCONTINUABLE" );
     if ( ExceptionRecord->ExceptionFlags & 2 )
         printf( " EH_UNWINDING" );
     if ( ExceptionRecord->ExceptionFlags & 4 )
         printf( " EH_EXIT_UNWIND" );
     if ( ExceptionRecord->ExceptionFlags & 8 )
         printf( " EH_STACK_INVALID" );
     if ( ExceptionRecord->ExceptionFlags & 0x10 )
         printf( " EH_NESTED_CALL" );
     printf( "\n" );
 
     // Отказываемся... Мы не хотим обрабатывать его... Позволяем кому-нибудь
     // другому сделать это.
     return ExceptionContinueSearch;
 }
 
 void HomeGrownFrame( void )
 {
     DWORD handler = (DWORD)_except_handler;

     __asm
     {                      // Создаем структуру EXCEPTION_REGISTRATION:
         push    handler    // Адрес функции нашего обработчика исключений.
         push    FS:[0]     // Адрес предыдущего EXECEPTION_REGISTRATION.
         mov     FS:[0],ESP // Добавляем в связанный список EXECEPTION_REGISTRATION.
     }
 
     *(PDWORD)0 = 0;        // Преднамеренно вызываем исключение. 
     printf( " I should never get here!\n" );
 
     __asm
     {                       // Удаляем EXECEPTION_REGISTRATION из связанного списка.
        mov     eax,[ESP]    // Получаем указатель на предыдущий
                             // EXECEPTION_REGISTRATION.
        mov     FS:[0], EAX  // Устанавливаем в начале списка предыдущий
                             // EXECEPTION_REGISTRATION.
        add     esp, 8       // Удаляем из стека структуру EXECEPTION_REGISTRATION.
     }
 }
 
 int main()
 {
     _try
     {
         HomeGrownFrame(); 
     }
     _except( EXCEPTION_EXECUTE_HANDLER )
     {
         printf( " Caught the exception in main()\n" );
     }
 
     return 0;
}
Рисунок 5. MYSEH2.CPP

Ключевая мысль: повышение контроля над выполнением программы. Когда обработчик отказывается обрабатывать исключение, он фактически отказывается решать, откуда, в конечном счете, будет продолжено выполнение программы. Тот обработчик, который соглашается обработать исключение, и решает, откуда продолжится выполнение программы после того, как будет завершена обработка исключения. Это очень важно, но не очевидно.

При использовании SEH, функция все равно может завершиться аварийно в том случае, если ее exception handler не обработает возникшее исключение. Например, в MYSEH2 минимальный обработчик в функции HomeGrownFrame не обрабатывает исключение. Так как кто-то дальше по цепочке обрабатывает исключение (функция main), printf после ошибочной инструкции никогда не выполняется. В некоторой степени, использование SEH напоминает использование функций setjmp и longjmp из RTL.

Если вы выполните MYSEH2, то увидите нечто удивительное в том, что она выводит на экран. Похоже, что функция _except_handler вызывается дважды. Учитывая, что вы сейчас знаете, причина ее первого вызова должна быть понятна. Но откуда взялся второй вызов?

 Home Grown handler: Exception Code: C0000005 Exception Flags 0
 Home Grown handler: Exception Code: C0000027 Exception Flags 2
                                              EH_UNWINDING
 Caught the Exception in main()

Есть очевидное различие: сравните две строки, которые начинаются с "Home Grown Handler:". В частности флажки исключения равны 0 в первый раз, и 2 во второй. Это подводит меня к понятию раскрутки (unwinding). Зайдём немного вперёд: если callback-функция обработчика отказывается обрабатывать исключение, она вызывается во второй раз. Однако это не происходит сразу же. Всё немного сложнее. Мне нужно будет уточнить сценарий исключения в последний раз.

Когда происходит исключение, система обходит список структур EXCEPTION_REGISTRATION, до тех пор пока не находит обработчик исключения. Как только он найдётся, ОС снова обходит список, до узла, который будет обрабатывать исключение. (Примечание переводчика: это утверждение может ввести вас в заблуждение! Лично у меня, после его прочтения, сложилось впечатление, что процессом раскрутки полностью управляет ОС. На самом деле, это не так. Процесс раскрутки инициируется обработчиком, взявшимся за обработку исключения. Более того, для проведения раскрутки, обработчику могут вообще не понадобиться услуги ОС, т.к. у него может иметься собственный код для ее реализации.) Во время этого второго прохода ОС вызывает каждый обработчик ещё раз, но на этот раз значение флагов исключения равно 2, что соответствует EH_UNWINDING (определение EH_UNWINDING есть в файле EXCEPT.INC, который находится в исходниках RTL Visual C++, но в Win32 SDK его эквивалента нет).

Для чего нужен EH_UNWINDING? Когда callback-функция обработчика вызывается во второй раз (с флагом EH_UNWINDING), ОС даёт функции-обработчику шанс провести очистку, которую ей надо сделать. Какую ещё очистку? Прекрасный пример - деструктор класса в C++. Если обработчик исключений отказывается обрабатывать исключение, тогда функция, которую он защищает, обычно, не завершается в штатном режиме. Давайте рассмотрим функцию, в которой создан объект C++-класса в виде локальной переменной. Спецификация C++ говорит, что деструктор обязан быть вызван. Второй вызов обработчика с флажком EH_UNWINDING как раз, и дает возможность функции, произвести очистку. В процессе очистки вызываются деструкторы и _finally-блоки.

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

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

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

Помогите! Некому обработать исключение!

До сих пор я подразумевал, что ОС всегда находит обработчик где-то в связанном списке структур EXCEPTION_REGISTRATION. А что случится в том случае, если это предположение окажется неверным? (Комментарий переводчика: Мэт так и не ответил на этот вопрос…) Но это практически никогда не случается потому, что ОС для каждого потока не явно устанавливает дефолтный обработчик. Дефолтный обработчик всегда является последним узлом в связанном списке, и всегда соглашается обработать исключение. Как я покажу позже, его работа несколько отличаются от обычной callback-функции обработчика.

Давайте теперь посмотрим, где именно система устанавливает свой дефолтный обработчик. Очевидно, что это должно делаться на ранней стадии выполнения потока, еще до того, как начнется выполнение пользовательского кода. Рис. 7 показывает написанный мной примерный псевдокод процедуры BaseProcessStart (BaseProcessStart - это внутренняя процедура Windows NT, находящаяся в KERNEL32.DLL). BaseProcessStart имеет только один параметр: стартовый адрес потока (thread's entry point). BaseProcessStart выполняется в контексте нового процесса, и вызывает точку входа, чтобы начать выполнение первого потока в процессе.

BaseProcessStart( PVOID lpfnEntryPoint )
 {
     DWORD retValue
     DWORD currentESP;
     DWORD exceptionCode;
 
     currentESP = ESP;
 
     _try
     {
         NtSetInformationThread( GetCurrentThread(),
                                 ThreadQuerySetWin32StartAddress,
                                 &lpfnEntryPoint, sizeof(lpfnEntryPoint) );
 
         retValue = lpfnEntryPoint();
 
         ExitThread( retValue );
     }
     _except(// код выражения-фильтра
             exceptionCode = GetExceptionInformation(),
             UnhandledExceptionFilter( GetExceptionInformation() ) )
     {
         ESP = currentESP;
 
         if ( !_BaseRunningInServerProcess )         // Обычный процесс
             ExitProcess( exceptionCode );
         else                                        // Сервис
             ExitThread( exceptionCode );
     }
 }
Рисунок 7. Псевдокод BaseProcessStart.

Заметьте, что вызов lpfnEntryPoint вложен в _try-блок. Это тот самый _try-блок, который заносит дефолтный обработчик в связанный список. Все прочие обработчики исключений, которые будут зарегистрированы позднее, в список будут вставлены перед этим обработчиком. Если происходит возврат из функции lpfnEntryPoint, значит выполнение потока завершено нормально. В этом случае, BaseProcessStart для завершения потока вызывает ExitThread.

С другой стороны, что произойдет, если поток вызовет исключение, которое не будет обработано пользовательскими обработчиками исключений? В этом случае, управление получит код находящийся после ключевого слова _except. В BaseProcessStart, этот код вызывает API UnhandledExceptionFilter, к которой я вернусь позже. Ключевой момент: API UnhandledExceptionFilter - это, по сути, дефолтный обработчик исключений.

Если UnhandledExceptionFilter возвращает значение EXCEPTION_EXECUTE_HANDLER, тогда в BaseProcessStart выполняется код в _except-блоке. Задачей этого кода является завершение текущего процесса, что он и делает, вызывая ExitProcess. Если немного подумать, это имеет смысл; общепринято что, если программа вызывает ошибку, и не обрабатывает ее, система завершает ее процесс. В псевдокоде вы видите подробности того, где и как это делается.

К вышесказанному осталось добавить только одно. Если поток, вызвавший ошибку, выполняется как сервис, выполняющийся в отдельном потоке, блок _except не вызывает ExitProcess - вместо этого он вызывает ExitThread. Ведь вы же не хотели бы завершать весь сервисный процесс, только потому, что один из сервисов совершил ошибку.

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

Рисунок 8. Диалог, который выдается при возникновении необработанного программой исключения (Unhandled Exception Dialog).

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

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

Из Microsoft Systems Journal. Январь 1997..

(продолжение статьи)

© Matt Pietrek / пер. Oleg_SK, SI

0 3.963
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532