Если вы хоть раз отлаживали краш программы в Windows, вы наверняка сталкивались с тем, что отладчик внезапно останавливается и показывает сообщение вроде «First chance exception». Или видели в стеке вызовов функции вроде RtlDispatchException или KiUserExceptionDispatcher. Всё это — часть механизма Structured Exception Handling (SEH), который Windows использует для обработки аппаратных и программных исключений.
В этой статье я расскажу, как устроен SEH изнутри, зачем он нужен именно вам — разработчику или отладчику — и как его понимание помогает находить баги, писать надёжный код и не сойти с ума при анализе дампов.
- Зачем вообще нужен SEH
- Как Windows обрабатывает исключение: путь от процессора до вашего кода
- Что такое цепочка SEH и где она живёт
- Два ключевых механизма: SEH и VEH
- Как работает фильтр в __except
- EXCEPTION_RECORD и EXCEPTION_POINTERS: что вы получаете в фильтре
- SEH и C++ исключения: в чём разница
- Stack unwind: что происходит при раскрутке стека
- Практические сценарии использования
- Сценарий 1: Перехват краша и запись дампа
- Сценарий 2: Защита вызова нестабильного кода
- Сценарий 3: Обработка доступа к защищённой памяти
- Частые ошибки при работе с SEH
- SEH и отладка: что делать, когда отладчик останавливается на исключении
- SEH на x64: что изменилось
- Как лучше сделать: рекомендации
- Итог
Зачем вообще нужен SEH
Представьте ситуацию: ваша программа обращается к памяти по нулевому указателю. Без механизма обработки исключений процесс просто упал бы. Windows сохранила бы минидамп, и на этом всё. Но в реальности вы часто видите другое: программа ловит исключение, показывает сообщение об ошибке и продолжает работу. Или отладчик перехватывает исключение до того, как оно достигнет кода программы.
Именно это и делает SEH — даёт программе возможность перехватить исключительную ситуацию и решить, что с ней делать: исправить ошибку, записать в лог, корректно завершить работу или передать исключение выше по цепочке вызовов.
SEH в Windows — это не то же самое, что try/catch в C++, хотя синтаксически они похожи. Это низкоуровневый системный механизм, на котором строятся все высокоуровневые конструкции обработки исключений: и в C++, и в .NET, и в Rust.
Как Windows обрабатывает исключение: путь от процессора до вашего кода
Когда процессор сталкивается с проблемой — деление на ноль, доступ к недопустимому адресу, точка останова — он генерирует прерывание. Это аппаратное событие. Windows обрабатывает его через цепочку шагов:
- Процессор генерирует исключение. Для некоторых исключений (например, page fault) процессор сначала пытается обработать ситуацию сам через таблицу страниц. Если не получается — генерирует исключение.
- Ядро Windows перехватывает исключение. Сначала исключение попадает в ядро через IDT (Interrupt Descriptor Table). Ядро определяет, можно ли обработать исключение на уровне ядра, или его нужно передать в user mode.
- User-mode диспетчеризация. Если исключение нужно обработать в пользовательском режиме, система находит соответствующий обработчик через механизм SEH или VEH (Vectored Exception Handling).
- Отладчик получает первый шанс. Если процесс запущен под отладчиком, тот получает уведомление о исключении первым (first chance). Отладчик может обработать его или передать дальше.
- Цепочка SEH обходится. Если отладчик не обработал исключение, система ищет обработчик в цепочке SEH текущего потока.
- Если обработчик не найден — отладчик получает «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 — это не магия, а механизм с чёткими правилами. Исключение проходит через цепочку обработчиков, каждый из которых может его обработать или передать дальше. Отладчик получает первый шанс, ваш код — второй, и если никто не обработал — процесс завершается. Зная этот путь, вы можете встроить в него свою логику там, где это действительно нужно.
