Как компилятор C++ реализует обработку исключений

ОГЛАВЛЕНИЕ

Подробное обсуждение того, как VC++ реализует обработку исключений. Исходный код содержит библиотеку обработки исключений для VC++.

·         Загрузить файлы исходного кода - 19 Кб

Введение

Одна из революционных возможностей C++ по сравнению с традиционными языками – его поддержка обработки исключений. Она обеспечивает хорошую альтернативу традиционным методам обработки ошибок, являющимся неэффективными и подверженными ошибкам. Четкое разделение между нормальным кодом и кодом обработки ошибок делает программу очень аккуратной и удобной в сопровождении. В этой статье описано все, что требуется для выполнения обработки исключений компилятором. Предполагается общее понимание механизма обработки исключений и его синтаксиса. Была реализована библиотека обработки исключений для VC++, сопровождаемая этой статьей. Чтобы заменить обработчик исключений, предоставленный VC++, библиотечным обработчиком, вызовите следующую функцию:

install_my_handler();

После этого, любое исключение, которое возникнет внутри программы – от выбрасывания исключения до раскрутки стека, вызова блока захвата исключения и последующего возобновления выполнения – обрабатывается созданной библиотекой обработки исключений.

Стандарт C++, как и любой другой элемент в C++, ничего не говорит о том, как должна быть реализована обработка исключений. Это значит, что любой разработчик может использовать любую реализацию, какую сочтет нужным. Будет описана реализация этой возможности в VC++, но это будет хорошим учебным материалом и для тех, кто использует другие компиляторы или операционные системы. VC++ строит свою поддержку обработки исключений поверх структурной обработки исключений (SEH), предоставленной операционной системой Windows.


 

Структурная обработка исключений - обзор

Будут рассматриваться исключения, явно выбрасываемые или возникающие из-за таких условий, как деление на ноль или обращение к нулевому указателю. Когда возникает исключение, генерируется прерывание, и управление передается операционной системе. Операционная система, в свою очередь, вызывает обработчик исключения, просматривающий последовательность вызовов функций, начиная с текущей функции, из которой возникло исключение, и выполняет свою работу раскрутки стека и передачи управления. Можно написать свой собственный обработчик исключений и зарегистрировать его в операционной системе, чтобы он вызывался при исключении.

Windows определяет специальную структуру для регистрации, называемую EXCEPTION_REGISTRATION:struct EXCEPTION_REGISTRATION

{
   EXCEPTION_REGISTRATION *prev;
   DWORD handler;
};

Чтобы зарегистрировать ваш собственный обработчик исключений, создайте эту структуру и сохраните ее адрес в нулевом смещении сегмента, на который указывает регистр FS, как показывает следующая команда на языке псевдоассемблера:

mov FS:[0], exc_regp

поле prev обозначает связанный список структур EXCEPTION_REGISTRATION. При регистрации структуры EXCEPTION_REGISTRATION адрес ранее зарегистрированной структуры сохраняется в поле prev.

Как выглядит функция обратного вызова исключения? Windows требует, чтобы сигнатура обработчика исключения, определенная в EXCPT.h, представляла собой:

 EXCEPTION_DISPOSITION (*handler)(
    _EXCEPTION_RECORD *ExcRecord,
    void * EstablisherFrame,
    _CONTEXT *ContextRecord,
    void * DispatcherContext);

Пока можно игнорировать все параметры и тип возвращаемого значения. Следующая программа регистрирует обработчик исключения в операционной системе и генерирует исключение, пытаясь разделить на ноль. Это исключение захватывается обработчиком исключения, который делает немногое. Он лишь выводит сообщение и завершает работу.

 #include <iostream>
#include <windows.h>

using std::cout;
using std::endl;


struct EXCEPTION_REGISTRATION
{
   EXCEPTION_REGISTRATION *prev;
   DWORD handler;
};


EXCEPTION_DISPOSITION myHandler(
    _EXCEPTION_RECORD *ExcRecord,
    void * EstablisherFrame,
    _CONTEXT *ContextRecord,
    void * DispatcherContext)
{
    cout << "В обработчике исключения" << endl;
    cout << "Только демо. выходим..." << endl;
    exit(0);
    return ExceptionContinueExecution; //не дойдет досюда
}

int g_div = 0;

void bar()
{
    //инициализируем структуры EXCEPTION_REGISTRATION
    EXCEPTION_REGISTRATION reg, *preg = &reg;
    reg.handler = (DWORD)myHandler;
   
    //получаем текущую голову цепочки обработки исключений   
    DWORD prev;
    _asm
    {
        mov EAX, FS:[0]
        mov prev, EAX
    }
    reg.prev = (EXCEPTION_REGISTRATION*) prev;
   
    //регистрируем ее!
    _asm
    {
        mov EAX, preg
        mov FS:[0], EAX
    }

    //генерируем исключение
    int j = 10 / g_div;  //Исключение. Деление на 0.
}

int main()
{
    bar();
    return 0;
}

/*-------вывод-------------------
В обработчике исключения
Только демо. Выходим...
---------------------------------*/


Windows строго следит за соблюдением одного правила: структура EXCEPTION_REGISTRATION должна быть на стеке и должна быть в более младшем адресе ячейки памяти, чем ее предыдущий узел. Windows завершит процесс, если не обнаружит, что вышеупомянутое правило соблюдено.


 

Функции и стэк

Стэк – непрерывная область памяти, используемая для хранения локальных объектов функции. Точнее говоря, каждая функция имеет связанный с ней кадр стека, содержащий все локальные объекты функции, а также любые временные, генерируемые выражениями внутри функции. Это типичная картина. На самом деле, компилятор может хранить все или некоторые из объектов во внутренних регистрах для более быстрого доступа. Стэк – понятие, поддерживаемое на уровне процессора. Процессор предоставляет внутренние регистры и специальные инструкции для управления им.

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

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений

Компилятор использует регистр EBP для определения текущего активного кадра стека. В данном случае, выполняется виджет, и, как показывает рисунок, регистр EBP указывает на кадр стека виджета. Функция обращается к своим локальным объектам, связанным с указателем кадра. Компилятор разрешает во время компиляции все имена локальных объектов до некоторого фиксированного смещения от указателя кадра. Например, виджет обычно обращался бы к своей локальной переменной на некоторое фиксированное число байтов ниже указателя кадра, например, EBP-24.

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

Процессор поддерживает две операции для стека: поместить (протолкнуть) и извлечь (вытолкнуть). Рассмотрите:

pop EAX
означает прочитать 4 байта из ячейки, на которую указывает ESP, и увеличить (помните, стек растет сверху вниз в данном случае) ESP на 4 (в 32-битных процессорах). Аналогично,

push EBP
означает уменьшить ESP на 4 и затем записать содержимое регистра EBP в ячейку, на которую указывает ESP.

Когда компилятор компилирует функцию, он добавляет в начало функции некоторый код, называемый прологом, который создает и инициализирует кадр стека функции. Аналогично, он добавляет в конец функции код, называемый эпилогом, извлекающий кадр стека выходящей функции.

Обычно компилятор генерирует следующую последовательность для пролога:

Push EBP      ; сохраняем текущий указатель кадра в стек
Mov EBP, ESP  ; активируем новый кадр
Sub ESP, 10   ; вычитаем. устанавливаем ESP на конец кадра

Первый оператор сохраняет текущий указатель кадра EBP в стек. Второй оператор активирует кадр для вызываемой функции путем установки регистра EBP на ячейку, в которой он сохранил EBP вызывающей функции. И третий оператор устанавливает регистр ESP на конец текущего кадра путем вычитания из значения ESP полного размера всех локальных объектов и временных объектов, которые создаст функция. Во время компиляции компилятор знает тип и размер всех локальных объектов функции, поэтому он действительно знает размер кадра.

Эпилог делает обратное тому, что делает пролог. Эпилог должен удалить текущий кадр из стека:

Mov ESP, EBP   
Pop EBP         ; активируем кадр вызывающей функции
Ret             ; возвращаемся в вызывающую функцию

Он устанавливает ESP на ячейку, в которой сохранен указатель кадра его вызывающей функции (которая находится в ячейке, на которую указывает указатель кадра вызываемой функции), отправляет его в EBP, таким образом активируя кадр стека его вызывающей функции и затем выполняя возврат.

Когда процессор сталкивается с командой возврата, он делает следующее: он извлекает адрес возврата из стека и передает управление по этому адресу. Адрес возврата был помещен в стек, когда его вызывающая функция выполнила команду вызова, чтобы вызвать его. Команда вызова сначала помещает в стек адрес следующей команды, к которой должно быть возвращено управление, и затем переходит к началу вызываемой функции. Рисунок 3 показывает более детальный вид стека во время выполнения. Как показывает рисунок, параметры функции – тоже часть кадра стека функции. Вызывающая функция помещает аргументы вызываемой функции в стек. Когда функция возвращает значение, вызывающая функция удаляет аргументы вызываемой функции из стека путем прибавления размера аргументов к ESP, который известен во время компиляции:
Add ESP, args_size

Наоборот, вызываемая функция может удалять параметры путем задания полного размера параметров в команде возврата, которая известна во время компиляции. Команда ниже удаляет 24 байта из стека перед возвратом в вызывающую функцию, при условии, что полный размер параметров равняется 24:
Ret 24

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

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений


 

C++ и исключения

Вспомним, что в первом разделе было сказано о структуре EXCEPTION_REGISTRATION. Она используется для регистрации обратного вызова исключения в операционной системе, которая вызывает его, когда возникает исключение.

VC++ расширяет семантику этой функции путем добавления двух дополнительных полей в конце:


struct EXCEPTION_REGISTRATION
{
   EXCEPTION_REGISTRATION *prev;
   DWORD handler;
   int   id;
   DWORD ebp;
};

VC++, с несколькими исключениями, создает структуру EXCEPTION_REGISTRATION для каждой функции как ее локальную переменную. Последнее поле структуры перекрывает ячейку, на которую указывает указатель кадра EBP. Пролог функции создает эту структуру в ее кадре стека и регистрирует структуру в операционной системе. Эпилог возвращает EXCEPTION_REGISTRATION вызывающей функции. Значение поля идентификатора будет описано в следующих разделх.

Когда VC++ компилирует функцию, он создает два набора данных для функции:

a) Функция обратного вызова исключения.
b) Структура данных, содержащая важную информацию о функции, такую как блоки захвата исключений, их адреса, тип исключения, в захвате которого они заинтересованы, и т.д. Эта структура, названная funcinfo, будет подробно рассмотрена в следующем разделе.

Рисунок 4 показывает более полную картину того, как вещи выглядят во время выполнения при рассмотрении обработки исключений. Обратный вызов исключения виджета находится в голове цепочки исключений, на которую указывает FS:[0] (который был установлен прологом виджета). Обработчик исключения передает адрес структуры funcinfo виджета функции __CxxFrameHandler, которая просматривает эту структуру данных, чтобы увидеть, есть ли в функции какой-либо блок захвата исключения, заинтересованный в захвате текущего исключения. Если функция не найдет такого блока, она возвращает значение ExceptionContinueSearch обратно операционной системе. Операционная система извлекает следующий узел из списка обработки исключений и вызывает его обработчик исключения (который является обработчиком для вызывающей функции текущей функции).

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений

Это продолжается до тех пор, пока обработчик исключения не найдет блок захвата исключения, заинтересованный в захвате исключения, при этом исключение не возвращается обратно в операционную систему. Но до того, как обработчик вызовет блок захвата (он знает адрес блока захвата из структуры funcinfo, смотрите рисунок 4), он должен выполнить раскрутку стека: очистку кадров стека функций ниже кадра этой функции. Очистка кадра стека включает небольшую хитрость: обработчик исключения должен найти все локальные объекты функции, находящиеся в кадре во время исключения, и вызвать их деструкторы. Это подробно описано в следующем разделе.

Обработчик исключения поручает задачу очистки кадра обработчику исключения, связанному с этим кадром. Он начинает с начала списка обработки исключений, на который указывает FS:[0], и вызывает обработчик исключения в каждом узле, сообщая ему, что стек раскручивается. В ответ на это обработчик вызывает деструктор для всех локальных объектов в кадре и возвращает значение. Это продолжается, пока он не достигнет узла, совпадающего с самим собой.

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

После копирования исключения и активации кадра обработчик исключения вызывает блок захвата. Блок захвата возвращает ему адрес, по которому должно быть передано управление в функцию после блока try-catch. В этот момент, даже если раскрутка стека произошла, и кадры были очищены, они не были удалены, и они все еще физически занимают место в стеке. Причина этого в том, что обработчик исключений все еще выполняется, и как любая другая нормальная функция, он также использует стек для своих локальных объектов, его кадр размещается ниже кадра последней функции, из которой возникло исключение. Когда блок захвата возвращает значение, он должен уничтожить исключение. После этого момента обработчик исключения удаляет все кадры, включая свой собственный, путем установки ESP на конец кадра функции (в которую он должен передать управление) и передает управление в конец блока try-catch. Как он узнает, где находится конец кадра функции? Он никак не может узнать. Вот почему компилятор сохраняет его (через пролог функции) в кадре стека функции, чтобы обработчик исключений нашел его. Смотрите рисунок 4. Он на 16 байтов ниже указателя кадра стека EBP.

Блок захвата сам может выбросить новое исключение или повторно выбросить такое же исключение. Обработчик исключений должен наблюдать за этой ситуацией и предпринять соответствующее действие. Если блок захвата выбрасывает новое исключение, обработчик исключения должен уничтожить старое исключение. Если блок захвата устанавливает повторный выброс, то обработчик исключения должен распространить старое исключение.

Так как каждый поток имеет свой собственный стек, это означает, что каждый поток имеет свой собственный список структур EXCEPTION_REGISTRATION, на который указывает FS:[0].


 

C++ и исключения - продолжение

Рисунок 5 показывает схему структуры funcinfo. Имена могут отличаться от фактических имен, используемых компилятором VC++. На рисунке показаны только значимые поля. Структура таблицы раскрутки описана в следующем разделе.

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений

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

Сперва рассмотрим, как выполняется обнаружение блока try. Во время компиляции компилятор назначает каждому блоку try идентификатор начала и идентификатор конца. Эти идентификаторы также доступны для обработчика исключений через структуру funcinfo. Смотрите рисунок 5. Компилятор создает структуру данных tryblock для каждого блока try внутри функции.

В предыдущем разделе говорилось о VC++, расширяющем структуру EXCEPTION_REGISTRATION, чтобы включить в нее поле идентификатора. Эта структура присутствует в кадре стека функции. Смотрите рисунок 4. Во время исключения обработчик исключений читает этот идентификатор из кадра и проверяет структуру tryblock, чтобы увидеть, равняется ли этот идентификатор или находится между его идентификатором начала и идентификатором конца. Если это имеет место, то исключение возникло внутри этого блока try. В противном случае он ищет в следующей структуре tryblock в tryblocktable.

Кто записывает значение идентификатора в стек, и что должно быть записано в него? Компилятор добавляет в функцию в разных точках операторы, обновляющие значение идентификатора, чтобы отражать текущее состояние времени выполнения. Например, компилятор добавит в функцию оператор, который запишет начальный идентификатор блока try в кадр стека, в точке, в которой происходит вход в блок try.

Когда обработчик исключений нашел блок try, он может просмотреть таблицу catchblock, связанную с блоком try, чтобы увидеть, заинтересован ли какой-либо блок catch в захвате исключения. В случае вложенных блоков try, исключение, возникшее во внутреннем блоке try, также возникает во внешнем блоке try. Обработчик исключений сначала должен найти блоки catch для внутреннего блока try. Если ничего не найдено, то он ищет блоки catch внешнего блока try. Во время размещения структур в таблице tryblock VC++ помещает структуру внутреннего блока try перед наружным блоком try.

Как обработчик исключений будет определять (из структуры catchblock), заинтересован ли блок catch в захвате текущего исключения? Он делает это путем сравнения типа исключения с типом параметра блока catch. Рассмотрите:

void foo()
{
   try {
      throw E();
   }
   catch(H) {
      //.
   }
}

Блок catch захватывает исключение, если H и E имеют абсолютно одинаковый тип. Обработчик исключений должен сравнивать типы во время выполнения. Обычно такие языки, как C, не предоставляют никаких средств для определения типа объекта во время выполнения. C++ предоставляет механизм определения типа во время выполнения механизм (RTTI) и имеет стандартный метод сравнения типов во время выполнения. Он определяет класс type_info, определенный в стандартном заголовке <typeinfo>, представляющем тип во время выполнения. Второе поле структуры catchblock (смотрите рисунок 5) – указатель на структуру type_info, представляющую тип параметра блока catch во время выполнения. type_info имеет operator ==, сообщающий, принадлежат ли два типа к абсолютно такому же классу или нет. Обработчик исключений должен сравнить (вызвать operator ==) type_info параметра блока catch (доступен через структуру catchblock) с type_info исключения, чтобы определить, заинтересован ли блок catch в захвате текущего исключения.

Обработчик исключений знает о типе параметра блока catch из структуры funcinfo, но как он узнает о type_info исключения? Когда компилятор встречает такой оператор, как:

throw E();

Он создает структуру excpt_info strcuture для выброшенного исключения. Смотрите рисунок 6. Имена могут отличаться от фактических имен, используемых компилятором VC++, и были показаны только значимые поля. Как показано на рисунке, type_info исключения доступен через структуру excpt_info. К какой-то момент времени обработчик исключений должен уничтожить исключение (после вызова блока catch). Ему может потребоваться скопировать исключение (перед вызовом блока catch). Чтобы помочь обработчику исключений выполнить эти задачи, компилятор предоставляет обработчику исключения доступ к деструктору исключения, конструктору копирования исключения и его размеру через структуру excpt_info.

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений

Если тип параметра блока catch - базовый класс, а исключение - его производный класс, обработчик исключений все-таки должен вызвать этот блок catch. Но сравнение двух typeinfo блока захвата и исключения в этом случае дало бы ложь, так как они не одинакового типа. Класс type_info тоже не предоставляет никакой функции-члена или оператора, сообщающего, является ли один класс базовым классом другого. Несмотря на это, обработчик исключений должен вызвать этот блок catch. Чтобы помочь ему сделать это, компилятор сгенерировал больше информации для обработчика. Если исключение – производный класс, то etypeinfo_table (доступен через структуру excpt_info) содержит указатель etype_info (расширенный type_info, мое имя) для всех классов в иерархии. Обработчик исключений сравнивает type_info параметра блока catch со всеми type_info, доступными через структуру excpt_info. Если найдено какое-либо совпадение, то будет вызван блок catch.

Последний вопрос перед завершением этого раздела: Как обработчик исключений узнает об исключении и о структуре excpt_info? Ниже дан ответ на данный вопрос.

VC++ переводит оператор выброса в что-то вроде:

//throw E(); //компилятор создает структуру excpt_info для E.
E e = E();  //создание исключения в стеке
_CxxThrowException(&e, E_EXCPT_INFO_ADDR);


_CxxThrowException передает управление операционной системе (через программное прерывание, смотрите функцию RaiseException), передавая ей оба своих параметра. Операционная система упаковывает эти два параметра в структуру _EXCEPTION_RECORD во время подготовки к вызову обратного вызова исключения. Он начинает с головы списка EXCEPTION_REGISTRATION, на которую указывает FS:[0], и вызывает обработчик исключений в этом узле. Указатель на этот EXCEPTION_REGISTRATION также является вторым параметром обработчика исключений. Вспомните, что в VC++ каждая функция создает свой собственный EXCEPTION_REGISTRATION в своем кадре стека и регистрирует его. Передача второго параметра обработчику исключений делает важную информацию доступной для него, как поле идентификатора EXCEPTION_REGISTRATION (важно для отыскания блока catch). Она также сообщает обработчику исключения о кадре стека функции (полезно для очистки кадра стека) и позиции узла EXCEPTION_REGISTRATION в списке исключений (полезно для раскрутки стека). Первый параметр – указатель на структуру _EXCEPTION_RECORD, через которую доступны указатель исключения и его структура excpt_info. Сигнатура обработчика исключений, определенного в EXCPT.H, такова:

EXCEPTION_DISPOSITION (*handler)(
    _EXCEPTION_RECORD *ExcRecord,
    void * EstablisherFrame,
    _CONTEXT *ContextRecord,
    void * DispatcherContext);

Можно игнорировать два последних параметра. Тип возвращаемого значения - перечисление (смотрите EXCPT.H). Как сказано выше, если обработчик исключений не может найти блок catch, он возвращает значение ExceptionContinueSearch обратно в систему. Для данного обсуждения другие значения не важны. Структура _EXCEPTION_RECORD определена в WINNT.H как:

struct _EXCEPTION_RECORD
{
    DWORD ExceptionCode;
    DWORD ExceptionFlags;
    _EXCEPTION_RECORD *ExcRecord;
    PVOID   ExceptionAddress;
    DWORD NumberParameters;
    DWORD ExceptionInformation[15];
} EXCEPTION_RECORD;

Количество и вид элементов в массиве ExceptionInformation зависит от поля ExceptionCode. Если ExceptionCode обозначает исключение C++ (код исключения 0xe06d7363) (который будет иметь место, если исключение происходит из-за выбрасывания), то массив ExceptionInformation содержит указатель на исключение и на структуру excpt_info. Для других видов исключений он почти всегда не содержит никаких элементов. Другими видами исключений могут быть деление на ноль, нарушение прав доступа и т.д. Их значения можно найти в WINNT.H.

Обработчик исключений смотрит на поле ExceptionFlags структуры _EXCEPTION_RECORD, чтобы определить, какое действие предпринять. Если значение - EH_UNWINDING (определено в Except.inc), то для обработчика исключений это указание на то, что стек раскручивается, и что он должен очистить свой кадр стека и вернуться. Очистка включает обнаружение всех локальных объектов, находящихся в кадре в момент исключения, и вызов их деструкторов. Это описано в следующем разделе. В противном случае, обработчик исключений должен искать блок catch block в функции и вызвать его в случае обнаружения.



Очистка кадра стека

Стандарт C++ говорит, что когда стек раскручивается, должен быть вызван деструктор для всех локальных объектов, существующих в момент исключения. Рассмотрите:

int g_i = 0;
void foo()
{
   T o1, o2;
   {
       T o3;
   }
   10/g_i; //исключение возникает здесь
   T o4;
   //...
}


Когда возникает исключение, локальные объекты o1 и o2 существуют в кадре foo, в то время как время существования o3 закончилось. O4 вообще не был создан. Обработчик исключений должен знать об этом факте и должен вызвать деструктор для o1 и o2.

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

Другая специальная область в функции находится там, где создается или уничтожается локальный объект. Иначе говоря, компилятор присваивает каждому локальному объекту уникальный идентификатор. Когда компилятор встречает такое определение объекта:

void foo()
{
   T t1;
   //.
}

Он добавляет оператор после определения (после точки, когда объект будет создан) для записи значения его идентификатора в кадр:

void foo()
{
   T t1;
   _id = t1_id; //оператор, добавленный компилятором
   //.
}

Компилятор создает скрытую локальную переменную (обозначенную в коде выше как _id), которая перекрывается с полем id структуры EXCEPTION_REGISTRATION. Аналогично, он добавляет оператор перед вызовом деструктора для объекта, чтобы записать идентификатор предыдущей области.

Когда обработчик исключений должен очистить кадр, он читает значение идентификатора из кадра (поле id структуры EXCEPTION_REGISTRATION или 4 байта ниже указателя кадра, EBP). Этот идентификатор указывает, что код в функции вплоть до точки, которой соответствует текущий идентификатор, выполнился без каких-либо исключений. Все объекты выше этой точки были созданы. Деструкторы для всех или некоторых из объектов выше этой точки должны быть вызваны. Некоторые из этих объектов могли быть уничтожены, если они являются частью подблока. Деструкторы для них не должны вызываться.

Компилятор создает еще одну структуру данных для функции, unwindtable(мое имя), которая является массивом структур раскручивания. Эта таблица доступна через структуру funcinfo. Смотрите рисунок 5. Для каждой специальной области в функции есть одна структура раскручивания. Элементы структуры появляются в unwindtable в том же порядке, в каком их соответствующие области появляются в функции. Структура раскручивания, соответствующая объектам, представляет интерес (помните, каждое определение объекта указывает на специальную область и имеет связанный с ним идентификатор). Он содержит информацию для уничтожения объекта. Когда компилятор встречает определение объекта, он создает короткую подпрограмму, которой известен адрес объекта в кадре (или его смещение от указателя кадра), и уничтожает этот объект. Одно из полей структуры раскручивания содержит адрес этой подпрограммы:

typedef  void (*CLEANUP_FUNC)();

struct unwind

{

    int prev;

    CLEANUP_FUNC  cf;

};

структура раскручивания для блока try имеет нулевое значение для второго поля. Поле prev означает, что unwintable – связанный список структур раскручивания. Когда обработчик исключения должен очистить кадр, он читает текущий идентификатор из кадра и использует его как индекс в таблице раскручивания. Он читает структуру раскручивания по этому индексу и вызывает функцию очистки, как задается вторым полем структуры. Это уничтожает объект, соответствующий этому идентификатору. Затем обработчик читает предыдущую структуру раскручивания из таблицы раскручивания по индексу, указанному в поле prev. Это продолжается, пока не будет достигнут конец списка (prev равен -1). Рисунок 7 показывает, как таблица раскручивания может выглядеть для функции на рисунке.

Программирование, исходники, операционные системы - Как компилятор C++ реализует обработку исключений

Рассмотрим случай нового оператора:

T* p = new T();

Сначала система выделяет память для T и затем вызывает конструктор. Если конструктор выбрасывает исключение, то система должна освободить память, выделенную для этого объекта. Для достижения этого VC++ присваивает идентификатор каждому новому оператору для типа, имеющего нетривиальный конструктор. Есть соответствующая запись в таблице раскручивания, процедура очистки освобождает выделенный участок памяти. Перед вызовом конструктора она сохраняет идентификатор для выделения в структуре EXCEPTION_REGISTRATION. После того, как конструктор успешно выполнил возврат, она восстанавливает идентификатор предыдущей специальной области.

Более того, объект может быть частично создан, когда конструктор выбрасывает исключение. Если он имеет подобъекты-члены или подобъекты базового класса, и некоторые из них были созданы в момент исключения, для этих объектов должен быть вызван деструктор. Компилятор создает такой же набор данных для конструктора, как и для любой нормальной функции, чтобы выполнить эти задачи.

Обработчик исключений вызывает пользовательские деструкторы во время раскручивания стека. Деструктор может выбросить исключение. Стандарт C++ говорит, что во время раскручивания стека деструктор не вправе выбрасывать исключение. Если он это делает, система вызывает std::terminate.


 

Реализация

Этот раздел рассматривает три темы, не изложенные выше:

a) Установка обработчика исключений.
b) Блок Catch, повторно выбрасывающий исключение или выбрасывающий новое исключение.
c) Поддержка обработки исключений по потокам.

Инструкции по компоновке смотрите в файле Readme.txt в файлах исходников. Они также содержат демо проект.

Первая задача – установить библиотеку обработки исключений, или, иначе говоря, заменить библиотеку, предоставленную VC++. Из вышесказанного ясно, что VC++ предоставляет функцию __CxxFrameHandler, которая является точкой входа для всех исключений. Для каждой функции компилятор создает процедуру обработки исключений, которая вызывается, если исключение возникает внутри функции. Эта процедура передает указатель funcinfo функции __CxxFrameHandler.

Функция install_my_handler() вставляет в начало __CxxFrameHandler код, который переходит на my_exc_handler(). Но __CxxFrameHandler хранится в неизменяемой кодовой странице. Любая попытка записи в нее вызвала бы нарушение прав доступа. Поэтому первый шаг – изменить доступ страницы на чтение-запись при помощи функции VirtualProtectEx, предоставленной Windows API (интерфейс программирования приложений). После записи в память восстанавливается старая защита страницы. Функция записывает содержимое структуры jmp_instr в начало __CxxFrameHandler.

//install_my_handler.cpp

#include <windows.h>
#include "install_my_handler.h"

//обработчик исключений C++ по умолчанию
extern "C"
EXCEPTION_DISPOSITION  __CxxFrameHandler(
     struct _EXCEPTION_RECORD *ExceptionRecord,
     void * EstablisherFrame,
     struct _CONTEXT *ContextRecord,
     void * DispatcherContext
     );

namespace
{
    char cpp_handler_instructions[5];
    bool saved_handler_instructions = false;
}

namespace my_handler
{
    //Обработчик исключений, заменяющий обработчик C++ по умолчанию.
    EXCEPTION_DISPOSITION my_exc_handler(
         struct _EXCEPTION_RECORD *ExceptionRecord,
         void * EstablisherFrame,
         struct _CONTEXT *ContextRecord,
         void * DispatcherContext
         ) throw();

#pragma pack(1)
    struct jmp_instr
    {
        unsigned char jmp;
        DWORD         offset;
    };
#pragma pack()
   
    bool WriteMemory(void * loc, void * buffer, int size)
    {
        HANDLE hProcess = GetCurrentProcess();
       
        //меняем защиту страниц, содержащих область памяти
        //[loc, loc+size], на чтение-запись
        DWORD old_protection;
       
        BOOL ret;
        ret = VirtualProtectEx(hProcess, loc, size,
                         PAGE_READWRITE, &old_protection);
        if(ret == FALSE)
            return false;

        ret = WriteProcessMemory(hProcess, loc, buffer, size, NULL);
      
        //восстанавливаем старую защиту
        DWORD o2;
        VirtualProtectEx(hProcess, loc, size, old_protection, &o2);

        return (ret == TRUE);
    }

    bool ReadMemory(void *loc, void *buffer, DWORD size)
    {
        HANDLE hProcess = GetCurrentProcess();
        DWORD bytes_read = 0;
        BOOL ret;
        ret = ReadProcessMemory(hProcess, loc, buffer, size, &bytes_read);
        return (ret == TRUE && bytes_read == size);
    }

    bool install_my_handler()
    {
        void * my_hdlr = my_exc_handler;
        void * cpp_hdlr = __CxxFrameHandler;

        jmp_instr jmp_my_hdlr;
        jmp_my_hdlr.jmp = 0xE9;
        //фактически вычисляется смещение от __CxxFrameHandler+5
        //так как команда jmp имеет длину 5 байт.
        jmp_my_hdlr.offset = reinterpret_cast<char*>(my_hdlr) -
                    (reinterpret_cast<char*>(cpp_hdlr) + 5);
       
        if(!saved_handler_instructions)
        {
            if(!ReadMemory(cpp_hdlr, cpp_handler_instructions,
                        sizeof(cpp_handler_instructions)))
                return false;
            saved_handler_instructions = true;
        }

        return WriteMemory(cpp_hdlr, &jmp_my_hdlr, sizeof(jmp_my_hdlr));
    }

    bool restore_cpp_handler()
    {
        if(!saved_handler_instructions)
            return false;
        else
        {
            void *loc = __CxxFrameHandler;
            return WriteMemory(loc, cpp_handler_instructions,
                           sizeof(cpp_handler_instructions));
        }
    }
}

Диретива #pragma pack(1) в определении структуры jmp_instr говорит компилятору расположить члены структуры без какого-либо заполнения пробелами свободного места между ними. Без этой директивы размер этой структуры 8 байтов. Размер равен 5 байтам, когда эта директива определена.

Возвращаясь к обработке исключений, когда обработчик исключений вызывает блок catch, блок catch может выбросить исключение повторно или выбросить совершенно новое исключение. Если блок catch выбрасывает новое исключение, то обработчик исключений должен уничтожить предыдущее исключение перед продвижением вперед. Если блок catch решает выбросить исключение повторно, обработчик исключений должен распространить текущее исключение. В этот момент обработчик исключений должен разобраться с двумя вопросами: как обработчик исключений узнает, что исключение возникло внутри блока catch, и как он будет следить за старым? Перед тем, как обработчик вызовет блок catch, он сохраняет текущее исключение в объекте exception_storage и регистрирует специальный обработчик исключений, catch_block_protector. Объект exception_storage доступен путем вызова функции get_exception_storage():

exception_storage* p = get_exception_storage();
p->set(pexc, pexc_info);
register catch_block_protector
call catch block
//....

Если исключение (повторно) выброшено из блока catch, управление переходит к catch_block_protector. Теперь эта функция может извлечь предыдущее исключение из объекта exception_storage и уничтожить его, если блок catch выбросил новое исключение. Если блок catch повторно выбросил исключение (что можно выяснить при помощи просмотра первых двух записей массива ExceptionInformation, оба равны нулю. Смотрите код ниже), то обработчик должен распространить текущее исключение, скопировав его в массив ExceptionInformation. Следующий фрагмент показывает функцию catch_block_protector().

 //-------------------------------------------------------------------
// Если этот обработчик вызван, то исключение было (повторно) выброшено из блока catch.
// Обработчик исключения (my_handler) регистрирует этот
// обработчик перед вызовом блока catch. Его задача – определить,
// выбросил ли блок catch новое исключение и повторно выбросил исключение. Если
// блок catch выбросил новое исключение, он должен уничтожить
// объект предыдущего исключения, который был передан блоку catch. Если
// блок catch повторно выбросил исключение, то этот обработчик должен извлечь
// исходное исключение и сохранить его в ExceptionRecord, чтобы
// обработчики исключений могли использовать его.
//-------------------------------------------------------------------
EXCEPTION_DISPOSITION  catch_block_protector(
     _EXCEPTION_RECORD *ExceptionRecord,
     void * EstablisherFrame,
     struct _CONTEXT *ContextRecord,
     void * DispatcherContext
     ) throw()
{
    EXCEPTION_REGISTRATION *pFrame;
    pFrame = reinterpret_cast<EXCEPTION_REGISTRATION*>
   
    (EstablisherFrame);if(!(ExceptionRecord->ExceptionFlags & ( 
          _EXCEPTION_UNWINDING | _EXCEPTION_EXIT_UNWIND)))
    {
        void *pcur_exc = 0, *pprev_exc = 0;
        const excpt_info *pexc_info = 0, *pprev_excinfo = 0;
        exception_storage *p =
        get_exception_storage();  pprev_exc=
        p->get_exception();  pprev_excinfo=
        p->get_exception_info();p->set(0, 0);
        bool cpp_exc = ExceptionRecord->ExceptionCode == MS_CPP_EXC;
        get_exception(ExceptionRecord, &pcur_exc);
        get_excpt_info(ExceptionRecord, &pexc_info);
        if(cpp_exc && 0 == pcur_exc && 0 ==   pexc_info)
        //повторный выброс исключения
            {ExceptionRecord->ExceptionInformation[1] =
                reinterpret_cast<DWORD>
            (pprev_exc);ExceptionRecord->ExceptionInformation[2] =
                reinterpret_cast<DWORD>(pprev_excinfo);
        }
        else
        {
            exception_helper::destroy(pprev_exc, pprev_excinfo);
        }
    }
    return ExceptionContinueSearch;
}


Рассмотрим одну возможную реализацию функции get_exception_storage():

exception_storage* get_exception_storage()
{
    static exception_storage es;
    return &es;
}

Эта была бы прекрасная реализация, за исключением мира многопоточности. Рассмотрим случай, когда более одного потока захватывают этот объект и пытаются сохранить в нем объект исключения. Это будет ужасно. Каждый поток имеет свой собственный стек и собственную цепочку обработки исключений. Нужно отдельно взятый объект exception_storage для потока. Каждый поток имеет свой собственный объект, который создается, когда поток начинает свое существование, и разрушается, когда потк заканчивается. Windows предоставляет локальную память потока для этой цели. Локальная память потока позволяет каждому объекту иметь свою собственную закрытую копию объекта, доступную через глобальный ключ. С этой целью предоставляются функции TLSGetValue() и TLSSetValue().

Файл Excptstorage.cpp определяет фукцию get_exception_storage(). Этот файл скомпонован как DLL. Это связано с тем, что дает нам возможность знать всякий раз, когда поток создан или уничтожен. Каждый раз, когда поток создан или уничтожен, Windows вызывает каждую функцию DllMain() из DLL (которая загружена в адресное пространство этого процесса). Эта функция вызывается в поток, который был создан. Это позволяет инициализировать отдельные данные потока, объект exception_storage в данном случае.

//excptstorage.cpp

#include "excptstorage.h"
#include <windows.h>

namespace
{
    DWORD dwstorage;
}

namespace my_handler
{
    __declspec(dllexport) exception_storage* get_exception_storage() throw()
    {
        void *p = TlsGetValue(dwstorage);
        return reinterpret_cast<exception_storage*>(p);
    }
}


BOOL APIENTRY DllMain( HANDLE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    using my_handler::exception_storage;
    exception_storage *p;
    switch(ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        //Для первого основного потока этот вариант содержит неявный
        //DLL_THREAD_ATTACH, поэтому здесь нет DLL_THREAD_ATTACH для
        // первого основного потока.
        dwstorage = TlsAlloc();
        if(-1 == dwstorage)
            return FALSE;
        p = new exception_storage();
        TlsSetValue(dwstorage, p);
        break;
    case DLL_THREAD_ATTACH:
        p = new exception_storage();
        TlsSetValue(dwstorage, p);
        break;
    case DLL_THREAD_DETACH:
        p = my_handler::get_exception_storage();
        delete p;
        break;
    case DLL_PROCESS_DETACH:
        p = my_handler::get_exception_storage();
        delete p;
        break;
    }
    return TRUE;
}

 


 

Заключение

Как сказано выше, компилятор C++ и библиотека исключений времени выполнения, с поддержкой от операционной системы, взаимодействуют для выполнения обработки исключений.

Примечания

  •     На дату написания этой статьи выпущен Visual Studio 7.0. Библиотека обработки исключений компилировалась и тестировалась преимущественно в VC++ 6.0 на Windows 2000, работающей на процессоре пентиум. Она также испытывалась в VC++ 5.0 и бета-версии VC++ 7.0. Есть небольшое различие между 6.0 и 7.0. 6.0 сначала копирует исключение (или его ссылку) в кадр блока catch и затем выполняет раскрутку стека перед вызовом блока catch. Библиотека 7.0 сначала выполняет раскрутку стека. Поведение созданной библиотеки аналогично библиотеке 6.0 в этом отношении.
  •     Смотрите прекрасные статьи, написанные Мэттом Пиетреком, по структурной обработке исключений в MSDN,.
  •     Компилятор может не создавать никаких связанных с исключением данных для функции, не имеющей блока try и не определяющей никакого объекта, имеющего нетривиальный деструктор.