Как работает SEH в Windows — реальное объяснение для тех, кто отлаживает краши

Вы пишете приложение на C/C++ под Windows. Оно падает. Вы смотрите в отладчике — и видите кучу странных стеков: ntdll!RtlRaiseException, kernel32!UnhandledExceptionFilter, __except_handler4. Вы понимаете: это SEH. Но что это на самом деле? Почему ваш __try/__except срабатывает в одном случае и игнорируется в другом? Почему в 64-битной версии всё ведёт себя иначе? И зачем вообще это нужно, если есть try/catch в C++?

Это не теория. Это то, что нужно знать, когда вы пытаетесь понять, почему ваш драйвер падает на клиентском ПК, или почему в вашем кроссплатформенном приложении на Windows внезапно исчезает обработчик исключений. Я не буду рассказывать про «механизм обработки исключений» как в учебнике. Я покажу, как это работает на практике — и как это влияет на ваш код.

SEH — это не C++ исключения. Это системный механизм

Самое важное, что нужно понять: SEH — это часть ОС, а не язык программирования. Он работает на уровне процессора и ядра Windows. Даже если вы пишете на чистом C, без C++, SEH будет работать. Даже если вы используете компилятор, который не поддерживает try/catch — SEH всё равно есть. Он был в Windows ещё в 16-битной версии.

Когда ваша программа пытается обратиться к нулевому указателю, делит на ноль или вызывает недопустимую инструкцию — процессор генерирует исключение. Это не «ошибка», а событие, которое прерывает нормальное выполнение. Windows не просто «вылетает» — она ищет: «Кто может это обработать?»

SEH — это система, которая позволяет вашему коду перехватить это событие и попытаться восстановиться. Без SEH ваша программа просто убивалась бы с сообщением «Приложение перестало работать». SEH даёт шанс: «А может, я могу это исправить?»

Как это выглядит на уровне кода

В Visual C++ вы используете:

__try {
    int* p = nullptr;
    *p = 42; // Вызовет ACCESS_VIOLATION
}
__except (GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
    printf("Поймал доступ к нулю!\n");
}

Но что происходит под капотом?

  1. Компилятор при компиляции __try/__except вставляет в вашу функцию специальные структуры — exception registration records. Они записываются в стек и указывают на обработчик.
  2. Каждый поток в Windows имеет список этих записей — он называется Exception Handling Chain. Это связный список, где каждая запись — это структура, указывающая на обработчик и адрес, где он должен быть вызван.
  3. Когда происходит исключение, ядро Windows (через ntdll) проходит по этому списку — от последней зарегистрированной записи к первой (LIFO — как стек).
  4. Для каждой записи вызывается её обработчик. Обработчик возвращает одно из трёх значений:
    • EXCEPTION_CONTINUE_SEARCH — не моё, иду дальше.
    • EXCEPTION_EXECUTE_HANDLER — я обработал, прыгаем в блок __except.
    • EXCEPTION_CONTINUE_EXECUTION — исключение устранено, продолжаем с того же места (редко, только если вы знаете, что делаете).
  5. Если ни один обработчик не взял исключение — вызывается UnhandledExceptionFilter, и приложение падает.

Это не C++. Это системный вызов. Даже если вы используете try/catch в C++, компилятор может превратить его в SEH под капотом — особенно если вы используете исключения с throw в коде, который вызывает WinAPI.

Чем SEH отличается от C++ исключений

Многие думают, что try/catch в C++ — это то же самое, что SEH. Это не так.

Характеристика SEH (Structured Exception Handling) C++ Exceptions
Уровень Операционная система Язык программирования
Что ловит Все исключения: доступ к памяти, деление на ноль, системные ошибки Только те, что брошены через throw
Производительность Высокая при возникновении исключения, но низкая при обычном выполнении Высокая при обычном выполнении, но тяжелее при выбросе
Поддержка в C Да Нет
Совместимость с WinAPI Полная Нет — WinAPI не бросает C++ исключения
Приоритет обработки Сначала SEH, потом C++ Только если не перехвачено SEH

Пример: вы вызываете VirtualAlloc — она может вернуть NULL, но не бросает исключение. Если вы передадите неправильный указатель в WriteFile — это вызовет исключение EXCEPTION_ACCESS_VIOLATION. Это только SEH. C++ try/catch его не поймает.

А если вы используете throw std::exception() внутри __try — то SEH перехватывает это исключение, а потом передаёт его C++-обработчику. Это сложный, но важный нюанс.

64-битные системы — всё по-другому

В 64-битной Windows SEH работает иначе. Это не просто «усовершенствование» — это полная переработка.

В 32-битной версии обработчики хранятся в стеке — в виде структур, которые компилятор вставляет. В 64-битной — их нет. Вместо этого используется unwind metadata — таблицы, которые компилятор генерирует в отдельной секции PE-файла (обычно .pdata и .xdata).

Что это значит на практике?

  • Вы не можете динамически добавлять обработчики в 64-битном коде — всё фиксируется на этапе компиляции.
  • Ваш __try/__except работает, но только если компилятор знает о нём заранее. Динамическая генерация кода (например, через JIT) не будет работать с SEH в 64-битной системе без ручной регистрации метаданных.
  • Отладка сложнее: вы не увидите обработчики в стеке, как в 32-битной версии. В отладчике вы видите только __CxxFrameHandler3 — и это всё.

Если вы пишете драйвер или библиотеку, которая работает и в 32, и в 64 битах — вы должны понимать: ваш код с __try/__except может работать по-разному. В 64-битной версии компилятор может оптимизировать обработчики, и если вы полагаетесь на побочные эффекты (например, изменение переменных в __except), это может сломаться.

Частые ошибки — и почему они ломают всё

Вот что чаще всего ломает SEH в реальных приложениях:

  1. Использование __except для логики. Многие пишут:
    __except (GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
        // Пытаемся восстановить состояние
        return some_value;
    }

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

  2. Смешивание __try/__except и C++ try/catch без понимания порядка. Если вы обернули __try в try, а внутри __try бросаете исключение — порядок обработки неочевиден. SEH срабатывает первым. Если он не перехватывает — тогда C++.
  3. Использование __except в динамически загружаемых библиотеках. Если вы загружаете DLL с помощью LoadLibrary, а в ней есть __try/__except — и она крашится — обработчик может не сработать, потому что стек не был правильно инициализирован. Особенно актуально в плагинах.
  4. Неправильная работа с SetUnhandledExceptionFilter. Многие думают, что если они установили свой фильтр, то они могут «поймать всё». Но если в приложении есть несколько потоков, и один из них крашится в системной библиотеке — ваш фильтр может не сработать. Он работает только для необработанных исключений в основном потоке.
  5. Предположение, что SEH — это «безопасный» способ обработки ошибок. Это не так. SEH — это механизм для аварийного восстановления. Он не заменяет проверки ввода, валидацию, проверки указателей. Он — последняя линия обороны.

Когда SEH действительно нужен — и когда лучше обойтись без него

Вот сценарии, где SEH — ваш лучший друг:

  • Отладка крашей на клиенте. Вы пишете ПО, которое раздаётся тысячам пользователей. Один из них получает доступ к нулевому указателю. Вы хотите, чтобы приложение не просто вылетало, а сохраняло дамп памяти. SEH — единственный способ поймать это на уровне ОС и вызвать MiniDumpWriteDump.
  • Интеграция с WinAPI, которые не используют C++ исключения. Например, вы вызываете RegQueryValueEx — она не бросает исключения. Но если передать ей неверный указатель — вы получите EXCEPTION_ACCESS_VIOLATION. Только SEH может это поймать.
  • Работа с низкоуровневым кодом: драйверы, хуки, JIT-компиляторы, эмуляторы. Там вы не можете позволить себе «вылет» — нужно уметь перехватить сбой и попытаться восстановить.

А вот когда SEH — плохая идея:

  • Если вы пишете обычное приложение на C++ и используете только стандартные библиотеки — используйте try/catch. Это чище, понятнее, безопаснее.
  • Если вы делаете библиотеку, которую будут использовать другие — не используйте SEH в интерфейсе. Он не переносим. Даже если вы используете только Windows, другие разработчики могут не знать, как с ним работать.
  • Если вы пытаетесь «запихнуть» SEH как основной механизм обработки ошибок — это приведёт к нечитаемому коду. Каждый __try — это как «бомба замедленного действия»: вы не знаете, где она может взорваться.

Как правильно использовать SEH — практические рекомендации

Вот что я делаю на практике:

  1. Использую SEH только для логирования и дампов. Всё, что происходит внутри __except — это запись в лог, вызов MiniDumpWriteDump, и немедленный exit(). Никаких попыток «восстановить» состояние. Это не работает.
  2. Оборачиваю только внешние вызовы. Например:
    __try {
        result = SomeWinAPICall(param);
    } __except (EXCEPTION_EXECUTE_HANDLER) {
        LogCrash("SomeWinAPICall failed with code %d", GetExceptionCode());
        return false;
    }

    Это безопасно. Вы знаете, что исключение произошло именно в этой функции, и вы не нарушаете состояние внутренних переменных.

  3. В 64-битной версии проверяю, что компилятор не оптимизировал обработчики. В Visual Studio — включён флаг /EHa (asynchronous exception handling). Без него __try/__except может не работать в некоторых случаях (например, при вызове longjmp).
  4. Никогда не использую __finally для «очистки» в случае исключения. __finally выполняется всегда — даже если исключение не было обработано. Но если вы в нём меняете глобальное состояние — это может сломать всё. Используйте RAII (конструкторы/деструкторы) для очистки ресурсов — это надёжнее.
  5. Пишу свой обработчик только один раз — в главном потоке. И только для дампов. В других потоках — ничего не делаю. SEH в потоках — это ловушка. Если вы не знаете, что делаете — лучше оставить всё как есть.

Что делать, если вы видите краш с SEH

Если ваше приложение падает, и в дампе вы видите ntdll!RtlRaiseException — это значит, что исключение не было обработано. Что делать?

  1. Проверьте, есть ли в стеке __except обработчики. Если их нет — значит, вы не обернули вызов, который может крашиться.
  2. Проверьте, не используете ли вы __try/__except в динамически загружаемой DLL. В 64-битной системе это часто не работает.
  3. Проверьте, не вызываете ли вы WinAPI с неправильными параметрами. Например, передаёте NULL вместо указателя на буфер.
  4. Включите отладочные символы и смотрите на адрес исключения. Если это 0x00000000 — значит, обращение к нулевому указателю. Если 0x00000004 — возможно, вы обращаетесь к полю структуры через нулевой указатель.
  5. Используйте Application Verifier от Microsoft — он может поймать ошибки до того, как они приведут к исключению.

Итог — что делать прямо сейчас

Если вы пишете приложение под Windows и сталкиваетесь с крашами:

  • Если вы используете только C++ и стандартные библиотеки — забудьте про SEH. Используйте try/catch и RAII. Это проще, чище и безопаснее.
  • Если вы работаете с WinAPI, драйверами, плагинами или пишете ПО для клиентов — используйте SEH, но только для одного: записи дампов и логов. Никаких попыток «исправить» состояние. После этого — немедленно завершайте процесс.
  • В 64-битной версии — убедитесь, что компилятор включил /EHa и что ваш код не использует динамические обработчики.
  • Никогда не используйте SEH как основной механизм обработки ошибок. Это как использовать пожарный шланг, чтобы убрать каплю воды. Возможно, сработает. Но это неправильно.

SEH — это не инструмент для повседневного программирования. Это аварийная кнопка. И если вы её нажимаете часто — значит, вы делаете что-то не так в другом месте. Найдите причину, а не просто ловите последствия.

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

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