Исключения в Windows x64. Как это работает. Часть 4
Опираясь на материал, описанный в первой, второй и третьей частях данной статьи, мы продолжим обсуждение темы обработки исключений в Windows x64.
Описываемый материал требует знания базовых понятий, таких, как пролог, эпилог, кадр функции и понимания базовых процессов, таких, как действия пролога и эпилога, передача параметров функции и возврат результата функции. Если читатель не знаком с вышеперечисленным, то перед прочтением рекомендуется ознакомиться с материалом из первой части данной статьи. Если читатель не знаком со структурами PE образа, которые задействуются в процессе обработки исключения, тогда перед прочтением рекомендуется ознакомиться с материалом из второй части данной статьи. Также, если читатель не знаком с процессом поиска и вызова обработчиков исключений, рекомендуется ознакомиться с третьей частью данной статьи.
Приводимое описание относится к реализации в Windows, и, следовательно, не следует полагать, что прилагаемая к статье реализация данного механизма будет в точности совпадать с ней, хотя концептуально различий нет. Детали прилагаемой реализации в статье рассматриваться не будут, если об этом не будет сказано явно. Поэтому предполагается, что эти детали, по необходимости, следует изучить самостоятельно.
К статье прилагается реализация механизма, которая находится в папке exceptions хранилища git по этому адресу.
1. Раскрутка стека
В процессе обработки ошибок может возникнуть ситуация, когда необходимо напрямую вернуть управление одной из предыдущих функций, минуя промежуточные функции. Т.е. возврат управления будет выполнен не посредством обычного возврата из функции в вызывающую функцию, которая в свою очередь тоже должна будет выполнить такой возврат, а посредством изменения состояния процессора таким образом, чтобы сразу после изменения он продолжил выполнять целевую функцию. На рисунке 1 изображен пример такой ситуации, где стрелкой указано направление роста стека.
Рисунок 1
На примере выше стек состоит из кадров четырех функций, где функция Main вызвала Func1, Func1 вызвала Func2, а Func2 вызвала Func3. Поэтому, например, если функции Func3 требуется вернуть управление функции Main, тогда она воспользуется функцией RtlUnwind/RtlUnwindEx, которая экспортируется модулем ntdll.dll в пользовательском пространстве и модулем ntoskrnl.exe в пространстве ядра. Прототип функции RtlUnwindEx изображен ниже, на рисунке 2.
Рисунок 2
Параметр TargetFrame принимает адрес кадра той функции, до которой следует раскрутить стек. Параметр TargetIp принимает адрес инструкции, с которой продолжится выполнение после раскрутки. Параметр ExceptionRecord принимает указатель на EXCEPTION_RECORD структуру, которая будет передаваться обработчикам при раскрутке. Параметр ReturnValue записывается в RAX регистр процессора, т.е. сразу после передачи управления соответствующей функции регистр RAX будет содержать значение этого параметра. Параметр ContextRecord содержит указатель на CONTEXT структуру, которая используется функцией RtlUnwindEx при раскрутке функций и определении целевого состояния процессора после раскрутки. Параметр HistoryTable принимает указатель на структуру, которая используется для кэширования поиска. Формат этой структуры вы сможете найти в winnt.h.
Параметр TargetFrame является необязательным. Если его значение равно NULL, тогда функция RtlUnwindEx выполняет так называемую раскрутку при выходе (exit unwind), где раскручиваются кадры всех функций стека. В этом случае параметр TargetIp игнорируется. Параметр ExceptionRecord является необязательным, и если он равен NULL, тогда функция RtlUnwindEx инициализирует свою структуру EXCEPTION_RECORD, где поле ExceptionCode будет содержать STATUS_UNWIND значение, поле ExceptionRecord будет содержать NULL, поле ExceptionAddress будет содержать указатель на инструкцию функции RtlUnwindEx, а поле NumberParameters будет содержать 0. Параметр HistoryTable является необязательным.
Прототип функции RtlUnwind отличается лишь тем, что он не принимает два последних параметра.
Ниже, на рисунке 3, изображен пример работы функции RtlUnwind.
Рисунок 3
На рисунке выше изображен пример программы, состоящей из четырех функций: _tmain, Func1, Func2, Func3. Функция _tmain вызывает функцию Func1, функция Func1 вызывает Func2, а функция Func2 вызывает Func3. Функции Func1, Func2, Func3 возвращают булево значение. Функция Func3 выполняет виртуальную раскрутку трех предыдущих функций с целью: найти адрес кадра функции _tmain; найти адрес инструкции, с которой будет продолжено выполнение, и в данном примере адрес будет указывать на инструкцию сразу после инструкции вызова функции Func1. Справа от исходного кода изображен ассемблерный код _tmain и Func3 функций, адреса инструкций которых являются абсолютными. Справа от ассемблерного кода изображены состояния процессора и стеки вызовов для трех случаев: сверху изображено состояние процессора и стек вызовов сразу перед вызовом функции Func1; посередине изображено состояние процессора и стек вызовов сразу перед вызовом функции RtlUnwind; внизу изображено состояние процессора после выполнения функции RtlUnwind. Указатели инструкций этих состояний сопоставляются с ассемблерными инструкциями посредством уникальных номеров. Следует обратить внимание на последний случай, где RAX регистр принял значение параметра ReturnValue, а стек вызов сократился до одной функции, т.е. кадры функций Func1, Func2 и Func3 более не существуют в стеке. Поскольку значение RAX после раскрутки не нулевое, функция _tmain выведет сообщение на экран. В обычном случае, т.е. если бы раскрутка не выполнялась, это сообщение не будет выведено, т.к. функция Func3 возвращает false. Также следует обратить внимание на то, что цикл поиска указателя кадра функции _tmain выполняет четыре итерации, когда раскручиваемых функций всего три. Это связано с ранее обсуждаемыми особенностями функции RtlVirtualUnwind. Дело в том, что после вызова функции RtlVirtualUnwind параметры HandlerData и EstablisherFrame примут соответствующие значения для той функции, для которой выполнялась виртуальная раскрутка, когда параметр ContextRecord будет отражать состояние процессора сразу после вызова раскрученной функции. Следовательно, на третьей итерации цикла функция RtlVirtualUnwind вернет в параметр EstablisherFrame указатель кадра для функции Func1, когда параметр ContextRecord будет отражать состояние процессора сразу после вызова функции Func1. Поэтому требуется выполнить дополнительную итерацию, чтобы определить указатель кадра функции _tmain.
Функция RtlUnwind/RtlUnwindEx, также, до раскрутки стека, последовательно вызывает обработчики раскрутки всех функций, начиная с самой себя и до функции, которая является целевой, включительно. Поскольку функция RtlUnwind/RtlUnwindEx не имеет обработчиков исключений/раскрутки, то в процессе виртуальной раскрутки она будет просто пропущена и, следовательно, не будет никаких побочных эффектов. С другой стороны, это накладные расходы, т.к. чтобы найти кадр функции, которая вызвала функцию RtlUnwind/RtlUnwindEx, необходимо выполнить дополнительную виртуальную раскрутку. Процесс вызова обработчиков и изменение состояния процессора в целях передачи управления одной из предыдущих функций и является так называемой раскруткой.
Ниже на рисунке 4 изображена блок-схема функции RtlUnwindEx.
Рисунок 4
В начале своей работы функция получает нижний и верхний лимиты стека. Далее функция захватывает текущее состояние процессора посредством вызова функции RtlCaptureContext. Таким образом, структура CONTEXT будет отражать состояние процессора сразу после вызова функции RtlCaptureContext. Эта же структура используется в качестве первоначального состояния процессора, с которого начинается виртуальная раскрутка функций. Функция RtlUnwindEx в процессе своей работы использует две структуры CONTEXT: одна отражает состояние процессора в момент выполнения функции, для которой выполняется вызов обработчика (здесь и далее — текущий контекст); другая отражает состояние процессора сразу после возврата из этой функции (здесь и далее — предыдущий контекст). Это необходимо из-за ранее обсуждаемых особенностей функции RtlVirtualUnwind. Также функция RtlUnwindEx, как это уже ранее обозначалось, инициализирует структуру EXCEPTION_RECORD для последующей передачи обработчикам раскрутки, если соответствующий параметр не был передан при вызове функции.
Далее функция формирует первоначальное значение поля ExceptionFlags для структуры EXCEPTION_RECORD. Это значение хранится в локальной переменной и изначально не хранится в поле самой структуры. Функция устанавливает флаг EXCEPTION_UNWINDING, и если адрес кадра целевой функции не был передан функции, тогда функция также устанавливает флаг EXCEPTION_EXIT_UNWIND. Таким образом, флаг EXCEPTION_UNWINDING для обработчиков означает, что выполняется раскрутка, а флаг EXCEPTION_EXIT_UNWIND означает, что раскручиваются кадры всех функций.
Далее функция посредством функции RtlLookupFunctionEntry получает адрес PE образа и указатель на RUNTIME_FUNCTION структуру функции этого образа, обработчик которой необходимо вызвать (здесь и далее — текущая функция). Адрес одной из инструкций этой функции извлекается из текущего контекста. На первой итерации это будет адрес инструкции самой функции RtlUnwindEx. Если функция RtlLookupFunctionEntry не вернула указатель, тогда считается, что текущая функция, для которой выполнялась попытка вызова её обработчика, — простая, и, следовательно, функция не имеет кадра. Т.к. простые функции не выделяют память в стеке, значение их RSP будет указывать на адрес возврата, следовательно, для таких функций функция RtlUnwindEx извлекает этот адрес, копирует его значение в текущий контекст и увеличивает значение поля Rsp текущего контекста на 8. Теперь текущий контекст отражает состояние процессора в момент выполнения следующей по стеку выше функции. Затем функция продолжит свою работу, начиная с получения адреса PE образа и указателя на RUNTIME_FUNCTION структуру, для адреса новой инструкции, уже для следующей по стеку выше функции.
Для кадровых функций функция RtlLookupFunctionEntry вернет указатель на RUNTIME_FUNCTION структуру. В таком случае вызывается функция RtlVirtualUnwind с целью определить указатель кадра текущей функции, а также адрес ее обработчика и указатель на данные для этого обработчика. Перед вызовом функции RtlVirtualUnwind функция RtlUnwindEx скопирует текущий контекст в предыдущий. Это выполняется с той целью, чтобы сохранить состояние процессора, описывающее момент выполнения текущей функции на тот случай, если функция окажется целевой. Уже неоднократно упоминалось, что функция RtlVirtualUnwind возвращает адрес кадра той функции, которая выполнялась в переданном состоянии процессора, когда по возврату из функции RtlVirtualUnwind состояние будет описывать следующую по стеку выше функцию. Следовательно, когда функции RtlUnwindEx потребуется возобновить выполнение целевой функции, невозможно будет использовать то состояние процессора, которое вернула функция RtlVirtualUnwind, т.к. оно будет отражать выполнение той функции, которая вызвала целевую функцию. Сразу после вызова функции RtlVirtualUnwind, функция RtlUnwindEx выполнит проверку указателя кадра раскрученной функции на выход за пределы лимита стека. Также функция проверит, располагается ли кадр текущей функции в стеке выше, чем кадр целевой функции, что в свою очередь будет означать, что функция RtlUnwindEx пропустила кадр целевой функции вследствие повреждения стека, повреждения .pdata секции и т.п. В обоих случаях функция сгенерирует исключение STATUS_BAD_STACK. В противном случае, если функция RtlVirtualUnwind не вернула адрес обработчика, то функция RtlUnwindEx поменяет местами текущий и предыдущий контексты, если текущая функция не являлась целевой. Таким образом, следующая по стеку выше функция станет текущей. Далее, функция продолжит свою работу, начиная с получения адреса PE образа и указателя на RUNTIME_FUNCTION структуру, для адреса новой инструкции, уже для следующей по стеку выше функции.
Если функция RtlVirtualUnwind вернула адрес обработчика для текущей функции, тогда ее обработчик необходимо вызвать. Перед его вызовом функция RtlUnwindEx установит флаг EXCEPTION_TARGET_UNWIND в том случае, если текущая функция является целевой. Таким образом, обработчик этой функции сможет определить, что его соответствующая функция является функцией, управление которой передается. Затем функция RtlUnwindEx обновит содержимое поля ExceptionFlags структуры EXCEPTION_RECORD из своей локальной копии. Обработчик исключения впервые обсуждался в разделе 3 второй части данной статьи, а его прототип изображен на рисунке 5. Перед вызовом обработчика функция, как и функция RtlDispatchException, обсуждаемая в разделе 2.2 третьей части данной статьи, подготавливает структуру DISPATCHER_CONTEXT, которая активно используется в случаях вложенных исключений (nested exception) и активной раскрутки (collided unwind). Определение самой структуры также изображено на рисунке 17 в разделе 2.2 третьей части данной статьи. Поля этой структуры инициализируются так же, как и в случае с функцией RtlDispatchException, с тем исключением, что поле TargetIp будет содержать значение соответствующего параметра переданного функции RtlUnwindEx, т.е. адрес инструкции, с которого будет возобновлено выполнение после раскрутки; поле ContextRecord будет содержать указатель на структуру CONTEXT, которая описывает состояние процессора в момент выполнения текущей функции, а не следующей по стеку выше; поле ScopeIndex содержит текущее значение локальной переменной и будет более подробно рассмотрено при обсуждении конструкций try/except и try/finally.
Обработчик, как и в случае с функцией RtlDispatchException, не вызывается напрямую, и вместо этого используется вспомогательная функция RtlpExecuteHandlerForUnwind, которая принимает такие же параметры, как и сам обработчик, а также возвращает такое же значение. Данная функция фактически является оберткой над функцией обработчика раскрутки и используется для того, чтобы перехватывать исключения, возникшие в процессе выполнения самого обработчика. Ассемблерное представление функции представлено ниже на рисунке 5.
Рисунок 5
Как отражено на рисунке, сначала функция выделяет память в стеке для регистровых переменных и одной переменой, сохраняет указатель на переданную DISPATCHER_CONTEXT структуру в этой переменной и вызывает обработчик исключения, адрес которого хранится в поле LanguageHandler структуры DISPATCHER_CONTEXT. Также обратите внимание на присутствие заполнителя тела функции. Его роль такая же, как и для функции RtlpExecuteHandlerForException. Ассемблерное представление функции обработчика исключения представлено ниже на рисунке 6.
Рисунок 6
Как отражено на рисунке, обработчик копирует контекст предыдущего процесса раскрутки в структуру DISPATCHER_CONTEXT текущего процесса поиска обработчика или раскрутки. Это позволяет продолжить поиск обработчика с того места, где была ранее прервана раскрутка, или продолжить ранее прерванную раскрутку. Также это позволяет пропустить вызов обработчиков тех функций, для которых такой вызов уже был выполнен во время предыдущей раскрутки. Следует также отметить, что вызов обработчиков возобновляется с той функции, на которой был прерван процесс раскрутки. Т.е. для таких функций обработчик будет вызван повторно. Более подробное пояснение этому будет дано во время обсуждения конструкций try/except и try/finally.
После того, как структура DISPATCHER_CONTEXT была подготовлена, функция RtlUnwindEx вызывает соответствующий обработчик. Сразу после вызова обработчика функция сбрасывает флаги EXCEPTION_COLLIDED_UNWIND и EXCEPTION_TARGET_UNWIND.
Если обработчик вернул ExceptionContinueSearch, то функция поменяет местами текущий и предыдущий контексты, если текущая функция не являлась целевой. Таким образом, следующая по стеку выше функция станет текущей. Далее функция продолжит свою работу, начиная с получения адреса PE образа и указателя на RUNTIME_FUNCTION структуру, для адреса новой инструкции, уже для следующей по стеку выше функции.
Если обработчик вернул ExceptionCollidedUnwind, то это означает, что в процессе раскрутки была обнаружена другая активная раскрутка, в контексте которой возникло исключение. В этом случае структура DISPATCHER_CONTEXT функции RtlUnwindEx будет содержать контекст прерванной раскрутки, т.к. он был скопирован обработчиком функции RtlpExecuteHandlerForUnwind. Следовательно, функция обновит текущий контекст из поля ContextRecord структуры DISPATCHER_CONTEXT, посредством функции RtlVirtualUnwind получит предыдущий, установит флаг EXCEPTION_COLLIDED_UNWIND и вызовет обработчик, в контексте которого ранее возникло исключение, и в зависимости от его возвращаемого результата выполнит ранее описанные действия.
Во всех остальных случаях функция RtlUnwindEx сгенерирует исключение STATUS_INVALID_DISPOSITION.
На каждой итерации перед получением адреса PE образа и указателя на RUNTIME_FUNCTION структуру функция посредством функции RtlpIsFrameInBounds проверяет, что указатель кадра функции, для которой выполнялась попытка вызова ее обработчика, находится в пределах лимита стека и не является указателем кадра целевой функции. Если такая проверка дает положительный результат, то работа функции продолжается. Иначе, если указатель кадра выходит за пределы лимита и указатель не является адресом кадра целевой функции, значит либо выполнялась раскрутка при выходе, и в процессе раскрутки ни один из обработчиков не остановил этот процесс, либо указатель кадра функции не был найден вследствие повреждения стека, повреждения .pdata секции и т.п. В таком случае функция RtlUnwindEx породит исключение, чтобы предоставить возможность отладки, но не в целях его обработки. Во всех остальных случаях работа функции завершится, т.к. найден кадр целевой функции. В этом случае в поле Rax текущего контекста будет записано значение переданного параметра ReturnValue, а в поле Rip этого же контекста будет записано значение переданного параметра TargetIp, если кодом исключения не является код STATUS_UNWIND_CONSOLIDATE. Т.к. этот случай не имеет непосредственного отношения к обсуждаемой теме, данный код не будет обсуждаться в данной статье. Здесь следует только отметить, что для раскрутки с таким кодом функцией RtlRestoreContext будет вызван обработчик перед возобновлением работы, и если поле Rip будет обновлено, обработчик получит неверное представление о состоянии процессора. Далее функция RtlUnwindEx вызывает функцию RtlRestoreContext, которой она передает два параметра: текущий контекст и указатель на структуру EXCEPTION_RECORD, который был либо передан функции RtlUnwindEx, либо передается указатель на локально сформированную структуру. К моменту вызова функции RtlRestoreContext обработчики раскрутки всех функций в стеке, начиная с его вершины и до целевой функции включительно, были вызваны. Функция RtlRestoreContext не возвращает управления, т.к. применяет к процессору новое состояние.
Стоит отметить, что проверка указателя кадра на каждой итерации является ошибкой в реализации, т.к. следует проверять указатель стека из текущего контекста, а не указатель кадра. В первую очередь данная проверка выполняется сразу после виртуальной раскрутки текущей функции. И если результат проверки отрицательный, то функция сгенерирует исключение. Следовательно, данная проверка на каждой итерации никогда не даст отрицательного результата, а поскольку не проверяется указатель стека, функция в процессе своей работы может выйти за его пределы. Данная ошибка сохраняется до сих пор.
2. Конструкции try/except и try/finally
С точки зрения операционной системы, как это уже было рассмотрено при описании обработки исключений и раскрутки стека, сама обработка всегда является обычным вызовом соответствующей функции. Конструкции try/except и try/finally являют собой механизм, который позволяет во время разработки размещать код обработки исключений прямо в теле функции. Следовательно, поскольку код обработки размещается непосредственно в теле, эти части кода не могут быть вызваны операционной системой напрямую. Чтобы обеспечить корректное функционирование этих конструкций, компилятор генерирует вспомогательную информацию, которой пользуются вызываемые операционной системой обработчики исключений. Ранее упоминалось, что всю обработку исключений условно можно поделить на две фазы. Фаза поиска и передача управления обработчикам исключений обсуждаемых конструкций и является второй фазой. Такое разделение необходимо, поскольку разные языки программирования по-разному обрабатывают исключения; таким образом, сама операционная система абстрагирована от понимания разнообразия механизмов разных языков программирования.
Компилятор C/C++ резервирует функцию __C_specific_handler. Именно эта функция отвечает за поиск и передачу управления соответствующей конструкции. Сама функция должна быть реализована программистом. Такой подход позволяет абстрагировать компилятор от понимания работы самой операционной системы и адаптировать исполняемый образ к любой среде исполнения, например, к подсистеме Win32, к среде исполнения ядра Windows или к любой другой среде. Также реализация этой функции экспортируется модулем ntdll.dll в пользовательском пространстве и модулем ntoskrnl.exe в пространстве ядра. Поставляемые Windows SDK и WDK содержат библиотеки, которые импортируют эту функцию из соответствующего модуля. Поле ExceptionHandlerAddress структуры EXCEPTION_HANDLER будет содержать указатель на эту функцию, когда поле LanguageSpecificData этой же структуры будет содержать структуру SCOPE_TABLE, которая описывает расположение всех конструкций в теле функции. Прототип функции изображен на рисунке 5 в разделе 3 второй части данной статьи. Определение структуры SCOPE_TABLE представлено ниже, на рисунке 7.
Рисунок 7
Поле Count содержит количество конструкций в теле функции и, следовательно, количество элементов ScopeRecord в структуре. Компилятор генерирует для каждой конструкции соответствующий ScopeRecord элемент структуры, который в свою очередь описывает расположение соответствующей конструкции в теле функции, а также расположение его обработчиков. Элементы ScopeRecord сортируются в следующем порядке: невложенные конструкции следуют друг за другом в порядке их появления в коде, когда вложенные конструкции всегда следуют перед конструкцией, в которую они вложены. Поле BeginAddress элемента ScopeRecord содержит адрес начала try блока. Поле EndAddress содержит адрес инструкции, следующей за последней инструкцией, заключенной в try блок. Поле JumpTarget, если не равно нулю, содержит адрес первой инструкции кода, заключенной в except блок. Код except блока следует сразу после кода, заключенного в try блок. Поле HandlerAddress содержит адрес функции фильтра except блока. Несмотря на то, что фильтр исключения заключается в скобках после except выражения, код фильтра генерируется компилятором в виде отдельной функции, прототип которой изображен ниже, на рисунке 8.
Рисунок 8
Функция принимает два параметра. Первый параметр содержит указатель на структуру, определение которой приведено ниже, на рисунке 9. Второй параметр содержит указатель кадра функции, в которой располагается соответствующая конструкция. Этот указатель используется фильтром в том случае, если во время фильтрации необходимо получить доступ к локальным переменным функции, в которой располагается соответствующая конструкция.
Рисунок 9
Как это отражено на рисунке выше, структура содержит два указателя. Первый указывает на структуру, описывающую причину исключения, второй — на структуру, описывающую состояние процессора в момент возникновения исключения.
Функция фильтра возвращает следующие значения: EXCEPTION_EXECUTE_HANDLER, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_CONTINUE_EXECUTION. Первое значение означает, что требуется передать управление обработчику исключения, для которого была вызвана функция фильтра. Также это значение может быть закодировано непосредственно в поле HandlerAddress. В таком случае конструкция не имеет фильтра, и передача управления обработчику исключения этой конструкции выполняется всегда. Второе значение указывает на то, что следует продолжить поиск обработчика исключения. Третье значение означает, что следует прервать поиск и возобновить выполнение прерванного потока.
Если поле JumpTarget равно нулю, тогда данная конструкция является finally конструкцией, и код, заключенный в finally блоке, следует сразу после кода, заключенного в try блок. В этом случае поле HandlerAddress содержит адрес функции, которая по своему содержимому повторяет код, заключенный в finally блок. Прототип этой функции изображен на рисунке 10.
Рисунок 10
Поскольку код, заключенный в finally блок, выполняется независимо от того, возникало исключение или нет, то в случае, если исключение имело место, этот код не может быть вызван напрямую, т.к. он располагается в теле функции. И поскольку во время раскрутки вызывать этот код необходимо, компилятор дублирует код, заключенный в finally блок, в виде отдельной функции. Первый параметр является булевым значением, означающим, что код finally блока выполняется из-за ненормального завершения кода, заключенного в try блок (т.е. в процессе его выполнения возникло исключение). Второй параметр содержит указатель кадра функции, в которой располагается соответствующая конструкция. Этот указатель используется функцией так же, как и функцией фильтра исключения – доступ к локальным переменным функции, в которой располагается соответствующая конструкция. Функция не возвращает никаких значений.
В тех случаях, когда в процессе выполнения кода, заключенного в try блоке, не возникло исключения, выполняется код finally блока, который следует сразу после кода try блока.
Все адреса в структуре SCOPE_TABLE являются адресами относительно начала образа.
Ниже, на рисунке 11, изображен пример структуры SCOPE_TABLE, которую сгенерирует компилятор.
Рисунок 11
На рисунке выше изображен пример программы, _tmain функция которой включает в себя try/except и try/finally конструкции. Слева от исходного кода изображено ассемблерное представление функций: _tmain, функции фильтра нижней try/except конструкции и функции, дублирующей код, заключенный в finally блок. Функции перечислены снизу вверх. Адреса ассемблерных инструкций являются абсолютными. Зелеными маркерами сопоставляется код, заключенный в блоки, с его ассемблерными эквивалентами. Следует обратить внимание на то, что блок кода с маркером 2 в ассемблерном представлении встречается дважды: в теле функции _tmain и в самой верхней функции. Последнее является дубликатом кода, заключенного в блок finally. Также следует обратить внимание на присутствие инструкции nop после инструкции вызова функции FailExecution в блоке кода с маркером 1. Данная инструкция также является заполнителем, как и в случаях с функциями шлюзов, функцией RtlpExecuteHandlerForException и функцией RtlpExecuteHandlerForUnwind. Если заполнитель будет отсутствовать, то при проверке инструкции на принадлежность к той или иной конструкции может быть сделано ошибочное предположение об ее принадлежности. В данном случае будет сделано ошибочное предположение о том, что инструкция вызова функции FailExecution не принадлежит блоку кода с маркером 1, т.к. функция RtlVirtualUnwind после раскрутки вернет адрес не на инструкцию вызова функции FailExecution, а на инструкцию сразу после нее. По этой причине компилятор добавляет заполнитель после инструкции вызова функции, если та в свою очередь является последней инструкцией в блоке. Если инструкция вызова является не последней инструкцией в блоке, тогда такого заполнителя не будет.
В левой части рисунка изображены структуры, которые сгенерирует компилятор. Вверху изображен элемент таблицы функций, ниже него изображена структура UNWIND_INFO, на которую ссылается этот элемент. Несмотря на то, что структура EXCEPTION_HANDLER не является частью структуры UNWIND_INFO, на рисунке она представлена как часть этой структуры, т.к. если она присутствует, то следует сразу после структуры UNWIND_INFO. Ниже структуры UNWIND_INFO изображено более подробное представление структуры EXCEPTION_HANDLER, ниже него изображено более подробное представление поля LanguageSpecificData этой структуры, в котором размещается структура SCOPE_TABLE. В самом низу последовательно изображены элементы ScopeRecord массива этой структуры. Все адреса в сгенерированных структурах являются относительными. Также эти адреса сопоставляются с адресами ассемблерного кода посредством уникальных номеров.
Более подробно стоит остановиться на элементах ScopeRecord массива. Элемент 0 описывает расположение блока с маркером 1. Поле HandlerAddress этого элемента содержит адрес функции, дублирующей код finally блока с маркером 2. Поле JumpAddress содержит 0, т.к. это finally блок. Элемент 1 описывает расположение блока с маркером 3. Поле HandlerAddress этого элемента содержит значение 1, что в свою очередь означает, что конструкция не имеет фильтра, и при возникновении исключения следует всегда передавать управление коду блока с маркером 4. Поле JumpAddress содержит адрес начала блока с маркером 4. Элемент 2 описывает расположение блока с маркером 5. Поле HandlerAddress этого элемента содержит адрес функции фильтра, код которого заключен в скобки после ключевого слова except. Ассемблерное представление функции фильтра располагается посередине, между функцией _tmain и функцией, дублирующей finally блок. Как изображено на рисунке, функция фильтра вызывает функцию ExceptionFilter, которая принимает указатель на структуру, описывающую контекст исключения. Поле JumpAddress содержит адрес начала блока с маркером 6.
Несмотря на то, что функция __C_specific_handler не представлена на рисунке, поле ExceptionHandlerAddress структуры EXCEPTION_HANDLER, содержит адрес этой функции. Эта функция будет вызвана операционной системой во время поиска обработчика исключения или во время раскрутки стека. Следовательно, реализация этой функции отвечает за интерпретацию структуры SCOPE_TABLE, вызов фильтров, вызов finally блоков и передачу управления except блокам.
Блок-схема функции __C_specific_handler изображена ниже, на рисунке 12.
Рисунок 12
В начале своей работы функция получает: адрес начала PE образа; относительный адрес инструкции, принадлежащий телу функции, для которой обработчик был вызван; указатель на структуру SCOPE_TABLE. В зависимости от выполняемой операции (поиск обработчика или раскрутка) работа функции варьируется.
Если выполняется поиск обработчика, то функция подготавливает структуру EXCEPTION_POINTERS, указатель на которую передается фильтрам соответствующих конструкций. Затем функция последовательно сканирует ScopeRecord элементы структуры SCOPE_TABLE и проверяет, принадлежит ли ранее полученный адрес инструкции какой-либо конструкции. Если принадлежит, тогда также проверяется, является ли конкретная конструкция try/except конструкцией, и, если нет, то она просто игнорируется, и проверяется следующий элемент. В противном случае вызывается фильтр этой конструкции. Если фильтр вернул EXCEPTION_CONTINUE_SEARCH, то данная конструкция игнорируется, и проверяется следующий элемент. Если фильтр вернул EXCEPTION_CONTINUE_EXECUTION, то функция завершает свою работу и возвращает ExceptionContinueExecution, чтобы указать операционной системе прекратить поиск обработчика и возобновить выполнение прерванного потока. Если фильтр вернул EXCEPTION_EXECUTE_HANDLER, то функция вызывает функцию RtlUnwind, которой в качестве кадра целевой функции указывается кадр функции, обработчик которой был вызван; в качестве адреса инструкции, с которой будет продолжено выполнение, передается адрес первой инструкции except блока; а также передается код исключения, который будет содержаться в RAX регистре сразу после передачи управления целевой функции. Функция RtlUnwind перед передачей управления последовательно вызовет обработчики всех промежуточных функций.
Если выполняется раскрутка, тогда функция ведет себя иначе. Сначала функция получает относительный адрес инструкции, с которой будет возобновлено выполнение. Затем функция последовательно сканирует ScopeRecord элементы структуры SCOPE_TABLE и проверяет, принадлежит ли ранее полученный адрес инструкции какой-либо конструкции. Если принадлежит, тогда также проверяется, является ли конкретная конструкция try/finally конструкцией. Если является, тогда вызывается ее обработчик, который по сути является дубликатом кода, заключенного в finally блок. Перед вызовом функция увеличит значение поля ScopeIndex структуры DISPATCHER_CONTEXT на единицу. Значение параметра AbnormalTermination при вызове этого обработчика всегда является TRUE. Следовательно, макрос AbnormalTermination всегда будет возвращать TRUE для этих блоков, вызванных таким образом. Для кода finally блока, располагающегося в теле самой функции, этот же макрос всегда будет возвращать FALSE. В этих случаях компилятор явно подставляет это значение. Иначе говоря, макрос AbnormalTermination возвращает TRUE только тогда, когда выполняется раскрутка. Практически, вследствие исключения. Если конструкция не является try/finally, тогда проверяется, не является ли адрес начала except блока адресом, с которого будет продолжено выполнение. И, если является, тогда работа функции завершается. Такая проверка необходима потому, что конструкция try/except может быть вложена в другую конструкцию try/finally, как это изображено ниже, на рисунке 13.
Рисунок 13
Как видно из рисунка, если такую проверку не выполнить, то во время раскрутки будет вызван finally блок внешней конструкции. А это недопустимо.
Если функция выполняет раскрутку для целевой функции, т.е. флаг EXCEPTION_TARGET_UNWIND установлен в поле ExceptionFlags структуры EXCEPTION_RECORD, тогда она выполняет дополнительную проверку перед вызовом обработчика. Суть проверки заключается в том, чтобы определить, не принадлежит ли адрес, с которого будет продолжено выполнение, самой конструкции, а не ее обработчику. И если принадлежит, тогда работа функции завершается. Подобная ситуация может быть только в случае использования в пределах finally блоков операторов goto, которые указывают за пределы этих блоков. Данная ситуация изображена ниже, на рисунке 14.
Рисунок 14
Как видно из рисунка, если такую проверку не выполнить, то во время раскрутки будет вызван finally блок внешней конструкции. А это недопустимо. Также, если функция не является целевой, тогда данная проверка не нужна.
Следует отметить, что в обоих случаях (и в случае поиска обработчика, и в случае выполнения раскрутки) сканирование элементов начинается не с начала, а с элемента, номер которого хранится в поле ScopeIndex структуры DISPATCHER_CONTEXT. Как это уже было отмечено, функция перед вызовом обработчика try/finally конструкции увеличивает значение поля ScopeIndex структуры DISPATCHER_CONTEXT на единицу. Ранее упоминалось, что если в процессе поиска обработчика или выполнения раскрутки будет обнаружен незавершенный процесс раскрутки, то продолжение поиска обработчика или выполнения раскрутки будет возобновлено с прерванного места. При этом обработчик функции, который породил исключение, будет вызван повторно, когда обработчики остальных функций вызваны не будут. В такой ситуации недопустимо, чтобы обработчики конструкций, которые уже были вызваны, оказались вызваны повторно. Эта ситуация изображена ниже, на рисунке 15.
Рисунок 15
На рисунке выше изображен стек вызова функций, слева от которого изображена стрелка направления его роста, а справа изображена часть кода функции Func1. Функции RtlDispatchException и RtlUnwindEx хоть и вызывают обработчики функций посредством функций RtlpExecuteHandlerForException и RtlpExecuteHandlerForUnwind, но в стеке вызовов эти функции для краткости не присутствуют. Функция Func1 вызвала функцию Func2, которая в свою очередь вызвала функцию Func3, которая породила исключение. Как только функция RtlDispatchException получала управление, она последовательно вызвала обработчики для функций: сначала Func3, затем Func2 и в конечном счете Func1. Обработчик функции Func1 нашел конструкцию, которая может обработать исключение, и вызвал функцию RtlUnwind для передачи управления обработчику этой конструкции. Функция RtlUnwind в свою очередь вызвала RtlUnwindEx, которая последовательно вызывала обработчики для функций сначала Func3, затем Func2 и в конечном счете Func1. Обработчик функции Func1 вызвал обработчик самого вложенного finally блока, который в свою очередь породил новое исключение. Как только функция RtlDispatchException получила управление, она последовательно вызывала обработчики предыдущих функций. Один из этих обработчиков окажется обработчиком функции RtlpExecuteHandlerForUnwind, которую вызывает функция RtlUnwindEx при передаче управления обработчику раскручиваемой функции. Обработчик функции RtlpExecuteHandlerForUnwind скопирует контекст раскрутки из RtlUnwindEx функции в функцию RtlDispatchException и, после возврата управления ей, поиск обработчика продолжится с того места, где была прервана раскрутка. Поскольку функция RtlUnwindEx ранее раскрутила функции Func3 и Func2, их обработчики вызываться не будут. Но поскольку функция Func1 породила исключение, она не была раскручена функцией, и, следовательно, ее обработчик будет вызван. Поскольку функция __C_specific_handler во время раскрутки увеличивает значение поля ScopeIndex структуры DISPATCHER_CONTEXT на единицу перед вызовом обработчика конструкции, то в скопированном контексте это поле будет равно 1. Следовательно, когда функция __C_specific_handler будет вызвана для функции Func1 вновь, поиск конструкции начнется с конструкции с индексом 1. Таким образом конструкция, породившая исключение, будет пропущена. Возобновление раскрутки выполняется аналогичным образом. Несмотря на то, что операционная система и компилятор абстрагированы друг от друга, наличие поля ScopeIndex в структуре DISPATCHER_CONTEXT является нарушением этой абстракции.
В завершение обсуждения try/except и try/finally конструкций стоит описать принцип работы макроса GetExceptionCode. Использование этого макроса возможно только в except блоках. Этот макрос читает содержимое регистра RAX, а при описании функции __C_specific_handler упоминалось, что передача управления конкретному except блоку выполняется посредством функции RtlUnwind, которая принимает параметр, значение которого будет записано в RAX регистре после передачи управления. Через этот параметр передается код возникшего исключения.
Также стоит описать принцип работы макроса GetExceptionInformation. Использование этого макроса возможно только в выражениях, заключенных в скобках после ключевого слова except. Поскольку в действительности выражение, заключенное в скобки (иначе говоря, фильтр), является отдельной функцией, которая принимает два параметра, данный макрос получает значение первого параметра. При описании функции __C_specific_handler упоминалось, что функция фильтра принимает два параметра, где первый параметр является указателем на структуру, описывающую исключение и состояние процессора в момент возникновения исключения.
3. Недостатки реализации механизма
Одним из недостатков данного механизма является использование в finally блоках операторов goto, которые указывают за пределы этих блоков. В этом случае компилятор C/C++, вместо прямой передачи управления, использует зарезервированную функцию _local_unwind, прототип которой изображен ниже, на рисунке 16.
Рисунок 16
Первый параметр функции принимает адрес кадра функции, до которой следует раскрутить стек, когда второй принимает адрес инструкции, которой следует передать управление после раскрутки. Сама функция должна быть реализована программистом. Также реализация этой функции экспортируется модулем ntdll.dll в пользовательском пространстве и модулем ntoskrnl.exe в пространстве ядра. Поставляемые Windows SDK и WDK содержат библиотеки, которые импортируют эту функцию из соответствующего модуля. Реализация самой функции очень простая, она вызывает функцию RtlUnwind, которой передает два своих параметра. Остальные параметры функции RtlUnwind при вызове обнулены.
Использование компилятором функции _local_unwind вместо прямой передачи управления в первую очередь связано с невозможностью передать управление в произвольное место функции в том случае, если finally блок был вызван в результате раскрутки. В таком случае передать управление в нужное место функции возможно только посредством нового процесса раскрутки. Такой подход имеет побочные эффекты. Оператор goto, в своей основе, передает прямое управление, когда раскрутка приводит к вызову finally блоков. Следовательно, до фактической передачи управления будут вызваны finally блоки, которые могут изменить контекст самой функции. Microsoft не рекомендует использовать оператор goto таким образом, а компилятор выдаст соответствующее предупреждение.
Заключение
В данной части статьи мы закончили обсуждение механизма обработки исключений. Необходимость в его реализации пришла из практики. И в первую очередь применяется в boot-time гипервизоре с целью упростить и ускорить разработку. В процессе реализации возникало множество проблем, которые были устранены, а сама статья, в первую очередь, нацелена на облегчение понимания тех, кто также заинтересован в подобных разработках.