Как работает SEH в Windows: разбираем structured exception handling на практике

Если вы хоть раз отлаживали краш программы в Windows, вы наверняка сталкивались с тем, что отладчик внезапно останавливается и показывает сообщение вроде «First chance exception». Или видели в стеке вызовов функции вроде RtlDispatchException или KiUserExceptionDispatcher. Всё это — часть механизма Structured Exception Handling (SEH), который Windows использует для обработки аппаратных и программных исключений.

В этой статье я расскажу, как устроен SEH изнутри, зачем он нужен именно вам — разработчику или отладчику — и как его понимание помогает находить баги, писать надёжный код и не сойти с ума при анализе дампов.

Зачем вообще нужен SEH

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

Именно это и делает SEH — даёт программе возможность перехватить исключительную ситуацию и решить, что с ней делать: исправить ошибку, записать в лог, корректно завершить работу или передать исключение выше по цепочке вызовов.

SEH в Windows — это не то же самое, что try/catch в C++, хотя синтаксически они похожи. Это низкоуровневый системный механизм, на котором строятся все высокоуровневые конструкции обработки исключений: и в C++, и в .NET, и в Rust.

Как Windows обрабатывает исключение: путь от процессора до вашего кода

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

  1. Процессор генерирует исключение. Для некоторых исключений (например, page fault) процессор сначала пытается обработать ситуацию сам через таблицу страниц. Если не получается — генерирует исключение.
  2. Ядро Windows перехватывает исключение. Сначала исключение попадает в ядро через IDT (Interrupt Descriptor Table). Ядро определяет, можно ли обработать исключение на уровне ядра, или его нужно передать в user mode.
  3. User-mode диспетчеризация. Если исключение нужно обработать в пользовательском режиме, система находит соответствующий обработчик через механизм SEH или VEH (Vectored Exception Handling).
  4. Отладчик получает первый шанс. Если процесс запущен под отладчиком, тот получает уведомление о исключении первым (first chance). Отладчик может обработать его или передать дальше.
  5. Цепочка SEH обходится. Если отладчик не обработал исключение, система ищет обработчик в цепочке SEH текущего потока.
  6. Если обработчик не найден — отладчик получает «second chance», и если он тоже не обрабатывает исключение, процесс завершается.

Что такое цепочка SEH и где она живёт

В каждом потоке Windows хранится цепочка записей обработчиков исключений. Эта цепочка — связный список, первый элемент которого находится в регистре потока. На x86 это регистр FS, на x64 — GS.

Каждая запись цепочки — это структура EXCEPTION_REGISTRATION_RECORD, которая содержит:

  • Указатель на следующую запись в цепочке (next).
  • Указатель на функцию-обработчик (handler).

Когда вы пишете __try { ... } __except(filter) { ... } в MSVC, компилятор автоматически создаёт такую запись и добавляет её в начало цепочки. При выходе из блока — удаляет.

На x86 это выглядит примерно так:

push offset _ExceptHandler    ; функция-обработчик
push fs:[0]                    ; текущая голова цепочки
mov  fs:[0], esp                ; новая запись стала головой

То есть стек используется как хранилище цепочки. На x64 механизм другой — там используется таблица UNWIND_INFO, которая компилируется в исполняемый файл, и цепочка в памяти потока не строится так же явно. Но логика диспетчеризации остаётся похожей.

Два ключевых механизма: SEH и VEH

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

Характеристика SEH (__try/__except) VEH (AddVectoredExceptionHandler)
Область действия Только внутри одного блока кода Весь поток (глобально для процесса, если первый параметр = 1)
Порядок вызова Локальный, обходится через цепочку в потоке Глобальный, вызывается до SEH
Приоритет при обработке После VEH-обработчиков Первым получает исключение
Тип фильтра Фильтр-выражение (Evaluate, Execute, Continue) Функция-фильтр возвращает EXCEPTION_CONTINUE_EXECUTION, EXCEPTION_CONTINUE_SEARCH или EXCEPTION_EXECUTE_HANDLER
Типичное применение Защита конкретного участка кода Логирование всех исключений, перехват для профилирования, защита от краша

Если коротко: SEH — это когда вы оборачиваете конкретный вызов в try/except. VEH — это когда вы хотите перехватывать все исключения в процессе, например, для записи в лог.

Как работает фильтр в __except

Выражение в __except(...) — это не просто условие. Оно определяет, что делать с исключением. Возможные значения:

  • EXCEPTION_EXECUTE_HANDLER (1) — выполнить блок except, исключение обработано.
  • EXCEPTION_CONTINUE_SEARCH (0) — пропустить этот обработчик, искать следующий в цепочке.
  • EXCEPTION_CONTINUE_EXECUTION (-1) — повторить инструкцию, вызвавшую исключение.

Это важно понимать, потому что фильтр — это полноценное выражение, которое может быть сколь угодно сложным. Например:

__try {
    result = p->value;
}
__except(FilterException(GetExceptionInformation())) {
    result = 0;
}

Функция FilterException может проверить код исключения, адрес, аргументы — всё, что хранится в EXCEPTION_RECORD. И на основе этого принять решение.

EXCEPTION_RECORD и EXCEPTION_POINTERS: что вы получаете в фильтре

Внутри фильтра и обработчика вы можете использовать GetExceptionInformation(), которая возвращает указатель на структуру EXCEPTION_POINTERS. Она содержит два указателя:

  • pExceptionRecord — указатель на EXCEPTION_RECORD, где лежат код исключения, адрес, флаги и дополнительные параметры.
  • pContextRecord — указатель на CONTEXT, где сохранены все регистры процессора на момент исключения.

Это даёт вам полную картину того, что произошло. Код исключения, например, 0xC0000005 — это access violation, 0xC0000094 — integer division by zero, 0xC00000FD — stack overflow. Адрес исключения показывает, какая именно инструкция упала. А контекст позволяет восстановить стек вызовов и состояние всех регистров.

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

SEH и C++ исключения: в чём разница

Многие путают SEH и С++ исключения. Это разные механизмы, хотя в MSVC они интегрированы.

Когда вы пишете throw std::runtime_error("oops"), компилятор C++ генерирует код, который использует SEH для раскрутки стека (stack unwinding). Код исключения C++ — это 0xE06D7363 (ASCII «.msc»). При обработке такого исключения система обходит цепочку обработчиков, ищет подходящий catch-блок и вызывает деструкторы локальных объектов.

Но аппаратные исключения — access violation, division by zero — это не C++ исключения. По умолчанию они не перехватываются catch(...). Чтобы они перехватывались, нужно использовать _set_se_translator или компилировать с флагом /EHa вместо /EHsc.

Это важный момент: если вы перехватываете ... в C++, аппаратное исключение без /EHa вы не поймаете. Программа упадёт, несмотря на catch(...).

Stack unwind: что происходит при раскрутке стека

Когда исключение перехватывается обработчиком, система должна корректно разрушить все локальные объекты в функциях между местом исключения и обработчиком. Это называется stack unwind.

На x64 раскрутка происходит на основе таблиц unwind info, которые компилируются в PE-файл. Там для каждой функции описано: где сохранён фрейм, где регистры, где стек, и где находится обработчик исключений для этой функции.

На x86 раскрутка происходит динамически через цепочку EXCEPTION_REGISTRATION_RECORD. Система проходит по цепочке, вызывает обработчики с флагом EXCEPTION_UNWIND, и те могут выполнять чистку (вызов деструкторов, освобождение ресурсов).

Если вы пишете на C и используете __try/__except, деструкторы не вызываются — это ваша ответственность. Если на C++ с /EHa — компилятор генерирует код для вызова деструкторов во время unwind.

Практические сценарии использования

Сценарий 1: Перехват краша и запись дампа

Самый частый кейс — установить VEH-обработчик, который при любом необработанном исключении пишет минидамп:

LONG WINAPI CrashHandler(EXCEPTION_POINTERS* pExInfo) {
    if (pExInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW) {
        // При stack overflow нужно быть очень осторожным с памятью
        // Лучше использовать заранее выделенный поток
    }
    WriteMinidump(pExInfo);
    return EXCEPTION_EXECINUE_SEARCH; // пусть процесс завершится
}

// При старте программы:
AddVectoredExceptionHandler(1, CrashHandler);

Обратите внимание: мы возвращаем EXCEPTION_CONTINUE_SEARCH, а не EXCEPTION_EXECUTE_HANDLER. Потому что мы не «обрабатываем» исключение — мы только логируем его и позволяем процессу упасть.

Сценарий 2: Защита вызова нестабильного кода

Если вы вызываете код, который может упасть (например, плагин или стороннюю библиотеку), оберните вызов в SEH:

__try {
    ThirdPartyFunction();
}
__except(FilterException(GetExceptionInformation())) {
    Log("ThirdPartyFunction crashed: code=0x%08X, addr=0x%p",
        GetExceptionInformation()->ExceptionRecord->ExceptionCode,
        GetExceptionInformation()->ExceptionRecord->ExceptionAddress);
    result = DEFAULT_RESULT;
}

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

Сценарий 3: Обработка доступа к защищённой памяти

Иногда SEH используют для проверки доступности памяти. Например, перед тем как прочитать указатель, проверить, валиден ли он:

__try {
    value = *pPointer;
}
__except(GetExceptionInformation()->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION
         ? EXCEPTION_EXECUTE_HANDLER
         : EXCEPTION_CONTINUE_SEARCH) {
    value = 0; // указатель невалиден
}

Это работает, но медленнее, чем нормальная проверка. Используйте такой подход только когда нормальная проверка невозможна — например, при работе с разделяемой памятью, которая может быть отмаплена в любой момент.

Частые ошибки при работе с SEH

  • Забывают про /EHa. Компилируют с /EHsc, ловят catch(...) и не понимают, почему access validation не перехватывается. Решение: компилировать с /EHa или использовать SEH напрямую.
  • Возвращают EXCEPTION_EXECUTE_HANDLER вместо EXCEPTION_CONTINUE_SEARCH. Исключение считается обработанным, хотя на самом деле вы только залогируете его. Программа продолжает работать в нестабильном состоянии.
  • Выделяют память в обработчике stack overflow. При stack overflow стек исчерпан. Любое выделение памяти или глубокий вызов приведёт к повторному исключению. Для обработки stack overflow используйте заранее выделенный поток с большим стеком.
  • Используют GetExceptionInformation вне фильтра. Информация о исключении доступна только внутри фильтра __except(...). Сохраните её, если нужно использовать в теле обработчика.
  • Не учитывают, что SEH не вызывает деструкторы в C. Если вы используете __try/__except в C-коде и выделяете ресурсы — вы должны освобождать их вручную при исключении.

SEH и отладка: что делать, когда отладчик останавливается на исключении

Если вы видите в отладчике «First chance exception» — это не ошибка. Это уведомление. Отладчик сообщает вам, что произошло исключение, и даёт вам решить: продолжить выполнение или остановиться.

Настройте отладчик так, чтобы он останавливался только на тех исключениях, которые вам интересны. В Visual Studio: Debug → Windows → Exception Settings. Там можно выбрать, на каких исключениях останавливаться, а какие пропускать.

Если исключение first chance не обрабатывается вашим кодом, оно становится second chance — и это уже гарантированный краш. Поэтому важно понимать разницу: first chance — это шанс обработать, second chance — это конец.

SEH на x64: что изменилось

На x64 подход к SEH кардинально изменился. Вместо динамической цепочки в стеке используются таблицы unwind info, которые компилируются в секцию .pdata PE-файла. Это даёт несколько преимуществ:

  • Нет накладных расходов на создание и удаление записей цепочки.
  • Раскрутка стека быстрее и предсказуемее.
  • Нет уязвимостей, связанных с подменой цепочки в стеке (SEH overwrite — классическая атака на x86).

На x64 вы не можете просто так добавить обработчик в цепочку через стек. Компилятор генерирует весь unwind info на этапе компиляции. Это делает систему более безопасной, но и менее гибкой — динамически добавлять обработчики нельзя.

Как лучше сделать: рекомендации

  • Для защиты от крашей используйте VEH + минидамп. Это самый надёжный способ получить информацию о падении в продакшене.
  • Для защиты конкретных вызовов используйте __try/__except. Но помните, что после перехвата аппаратного исключения состояние процесса может быть повреждено.
  • Компилируйте с /EHa, если хотите ловить аппаратные исключения через C++ catch. Но помните о последствиях для оптимизации и производительности.
  • Не злоупотребляйте SEH для контроля потока выполнения. Это механизм для исключительных ситуаций, а не для обычной логики. Используйте его только тогда, когда альтернатива — краш программы.
  • Обрабатывайте stack overflow отдельно. Выделите поток с большим стеком заранее и запускайте в нём обработку stack overflow исключений.

Итог

SEH — это фундамент, на котором построена обработка ошибок в Windows. Понимание того, как он работает, помогает не только писать более надёжный код, но и эффективно отлаживать краши, правильно настраивать отладчик и принимать решения о том, как обрабатывать ошибки в вашем приложении.

Главное запомнить: SEH — это не магия, а механизм с чёткими правилами. Исключение проходит через цепочку обработчиков, каждый из которых может его обработать или передать дальше. Отладчик получает первый шанс, ваш код — второй, и если никто не обработал — процесс завершается. Зная этот путь, вы можете встроить в него свою логику там, где это действительно нужно.

Оцените статью
PEFile — Безопасность и технологии простым языком