Обработка многократно возникающих SIGSEGV-подобных ошибок

в 13:47, , рубрики: access violation, c++, longjmp, segmentation fault, seh, setjmp, sigsegv, veh, ненормальное программирование, метки:

Тема изъезжена и уже не мало копий было сломано из-за неё. Так или иначе люди продолжают задаваться вопросом о том может ли приложение написанное на C/C++ не упасть после разыменования нулевого указателя, например. Краткий ответ — да, даже на Хабре есть статьи на сей счёт.

Одним из наиболее частых ответов на данный вопрос является фраза "А зачем? Такого просто не должно случаться!". Истинные причины того почему люди продолжают интересоваться данной тематикой могут быть разные, одной из них может быть лень. В случая когда лениво или дорого проверять всё и вся, а исключительные ситуации случаются крайне редко можно, не усложняя кода, завернуть потенциально падающие фрагменты кода в некий try/catch который позволит красиво свернуть приложение или даже восстановится и продолжить работу как ни в чём не бывало. Наиболее ненормальным как раз таки может показаться желание снова и снова ловить ошибки, обычно приводящие к падению приложения, обрабатывать их и продолжать работу.

Итак попробуем создать нечто позволяющее решать проблему обработки SIGSEGV-подобных ошибок. Решение должно быть по максимуму кроссплатформенным, работать на всех наиболее распространённых десктопных и мобильных платформах в однопоточных и многопоточных окружениях. Так же сделаем возможным существование вложенных try/catch секций. Обрабатывать будем следующие виды исключительных ситуаций: доступ к памяти по неправильным адресам, выполнение невалидных инструкций и деление на ноль. Апофеозом будет то, что произошедшие аппаратные исключения будут превращаться в обычные C++ исключения.

Наиболее часто для решения аналогичным поставленной задачам рекомендуется использовать POSIX сигналы на не Windows системах, а на Windows Structured Exception Handling (SEH). Поступим примерно следующим образом, но вместо SEH будем использовать Vectored Exception Handling (VEH), которые очень часто обделены вниманием. Вообще, со слов Microsoft, VEH является расширением SEH, т.е. чем-то более функциональным и современным. VEH чем-то схож c POSIX сигналами, для того чтобы начать ловить какие либо события обработчик надо зарегистрировать. Однако в отличии от сигналов для VEH можно регистрировать несколько обработчиков, которые будут вызываться по очереди до тех пор пока один из них не обработает возникшее событие.

В довесок к обработчикам сигналов возьмём на вооружение пару setjmp/longjmp, которые позволят нам возвращаться туда куда нам хочется после возникновения аварийной ситуации и каким-либо способом обрабатывать эту самую исключительную ситуацию. Так же, чтобы наша поделка работала в многопоточных средах нам понадобится старый добрый thread local storage (TLS), который также доступен во всех интересующих нас средах.

Самое простое, что необходимо сделать чтобы просто не упасть в случае аварийной ситуации — это написать свой обработчик и зарегистрировать его. В большинстве случаев людям достаточно просто собрать необходимое количество информации и красиво свернуть приложение. Так или иначе обработчик сигналов регистрируется всем известным способом. Для POSIX-совместимых систем это выглядит следующим образом:

stack_t ss;
ss.ss_sp = exception_handler_stack;
ss.ss_flags = 0;
ss.ss_size = SIGSTKSZ;
sigaltstack(&ss, 0);

struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_ONSTACK;
sa.sa_handler = signalHandler;

for (int signum : handled_signals)
    sigaction(signum, &sa, &prev_handlers[signum - MIN_SIGNUM]);

Выше приведённый фрагмент кода регистрирует обработчик для следующий сигналов: SIGBUS, SIGFPE, SIGILL, SIGSEGV. Помимо этого с помощью вызова sigaltstack указываться, что обработчик сигнала должен запускаться на альтернативном, своём собственном, стеке. Это позволяет выживать приложению даже в условиях stack overflow, который легко может возникнуть в случае бесконечно рекурсии. Если не задать альтернативный стек, то подобного рода ошибки не возможно будет обработать, приложение будет просто падать, т.к. для вызова и выполнения обработчика просто не будет стека и с этим ничего нельзя будет сделать. Так же сохраняются указатели на ранее зарегистрированные обработчики, что позволит их вызывать, если наш обработчик поймёт, что делать ему нечего.

Для Windows код намного короче:

exception_handler_handle = AddVectoredExceptionHandler(1, vectoredExceptionHandler);

Обработчик один, он ловит сразу все события (не только аппаратные исключения надо сказать) и нет никакой возможности что-либо сделать со стеком как в Linux, например. Единица, подаваемая первым аргументом в функцию AddVectoredExceptionHandler, говорит о том, что наш обработчик должен вызываться первым, перед любыми другими уже имеющимися. Это даёт нам шанс быть первыми и предпринять необходимые нам действия.

Сам обработчик для POSIX систем выглядит следующим образом:

static void signalHandler(int signum)
{
    if (execution_context) {
        sigset_t signals;
        sigemptyset(&signals);
        sigaddset(&signals, signum);
        sigprocmask(SIG_UNBLOCK, &signals, NULL);
        reinterpret_cast<ExecutionContextStruct *>(static_cast<ExecutionContext *>(execution_context))->exception_type = signum;
        longjmp(execution_context->environment, 0);
    }
    else if (prev_handlers[signum - MIN_SIGNUM].sa_handler) {
        prev_handlers[signum - MIN_SIGNUM].sa_handler(signum);
    }
    else {
        signal(signum, SIG_DFL);
        raise(signum);
    }
}

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

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

static LONG WINAPI vectoredExceptionHandler(struct _EXCEPTION_POINTERS *_exception_info)
{
    if (!execution_context ||
        _exception_info->ExceptionRecord->ExceptionCode == DBG_PRINTEXCEPTION_C ||
        _exception_info->ExceptionRecord->ExceptionCode == 0xE06D7363L /* C++ exception */
    )
        return EXCEPTION_CONTINUE_SEARCH;

    reinterpret_cast<ExecutionContextStruct *>(static_cast<ExecutionContext *>(execution_context))->dirty = true;
    reinterpret_cast<ExecutionContextStruct *>(static_cast<ExecutionContext *>(execution_context))->exception_type = _exception_info->ExceptionRecord->ExceptionCode;
    longjmp(execution_context->environment, 0);
}

Как уже упоминалось выше VEH обработчик на Windows ловит много чего ещё помимо аппаратных исключений. Например при вызове OutputDebugString возникает исключение с кодом DBG_PRINTEXCEPTION_C. Подобные события мы обрабатывать не будем и просто вернём EXCEPTION_CONTINUE_SEARCH, что приведёт к тому что ОС пойдёт искать следующий обработчик, который обработает данное событие. Также мы не хотим обрабатывать C++ исключения, которым соответствует магический код 0xE06D7363L не имеющий нормального имени.

Как на POSIX-совместимых системах так и на Windows в конце обработчика вызывается longjmp, который позволяет нам вернуться вверх по стеку, до самого начала секции try и обойти её попав в ветку catch, в которой можно будет сделать все необходимые для восстановления работы действия и продолжить работу так как будто ничего страшного не произошло.

Для того, чтобы обычный C++ try начал ловить не свойственные ему исключительные ситуации необходимо в самое начало поместить небольшой макрос HW_TO_SW_CONVERTER:

#define HW_TO_SW_CONVERTER_UNIQUE_NAME(NAME, LINE) NAME ## LINE
#define HW_TO_SW_CONVERTER_INTERNAL(NAME, LINE) ExecutionContext HW_TO_SW_CONVERTER_UNIQUE_NAME(NAME, LINE); if (setjmp(HW_TO_SW_CONVERTER_UNIQUE_NAME(NAME, LINE).environment)) throw HwException(HW_TO_SW_CONVERTER_UNIQUE_NAME(NAME, LINE))
#define HW_TO_SW_CONVERTER() HW_TO_SW_CONVERTER_INTERNAL(execution_context, __LINE__)

Выглядит довольно кудряво, но по факту здесь делается очень простая вещь:

  1. Вызывается setjmp, который позволяет нам запомнить место где мы начали и куда нам надо вернуться в случае аварии.
  2. Если по пути выполнения случилось аппаратное исключение, то setjmp вернёт не нулевое значение, после того как где-то по пути был вызван longjmp. Это приведёт к тому, что будет брошено C++ исключение типа HwException, которое будет содержать информацию о том какого вида ошибка случилась. Брошенное исключение без проблем ловится стандартным catch.

Упрощённо приведённый выше макрос разворачивается в следующий псевдокод:

if (setjmp(environment))
    throw HwException();

У подхода setjmp/longjmp есть один существенный недостаток. В случае обычных C++ исключений, происходит размотка стека при которой вызываются деструкторы всех созданных по пути объектов. В случае же с longjmp мы сразу прыгаем в исходную позицию, никакой размотки стека не происходит. Это накладывает соответствующие ограничения на код, который находится внутри таких секций try, там нельзя выделять какие-либо ресурсы ибо есть риск их навсегда потерять, что приведёт к утечкам.

Ещё одним ограничением является то, что setjmp нельзя использовать в функциях/методах объявленных как inline. Это ограничение самого setjmp. В лучшем случае компилятор просто откажется собирать подобный код, в худшем он его соберёт, но полученный бинарный файл будет просто аварийно завершать свою работу.

Самым ненормальным действием, которое приходится принимать после обработки аппаратного исключения на Windows является необходимость вызова RemoveVectoredExceptionHandler. Если этого не сделать, то после каждого входа в наш обработчик VEH и выполнения longjmp там будет складываться ситуация как-будто наш обработчик был зарегистрирован ещё один раз. Это приводит к тому, что при каждой последующей аварийной ситуации обработчик будет вызываться всё больше и больше раз подряд, что будет приводить к плачевным последствиям. Данное решение было найдено исключительно путём многочисленных магических экспериментов и нигде никак не документировано.

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

Сам контекст исполнения оформлен в виде простого класса имеющего следующие конструктор и деструктор:

ExecutionContext::ExecutionContext() : prev_context(execution_context)
{
#if defined(PLATFORM_OS_WINDOWS)
    dirty = false;
#endif
    execution_context = this;
}

ExecutionContext::~ExecutionContext()
{
#if defined(PLATFORM_OS_WINDOWS)
    if (execution_context->dirty)
        RemoveVectoredExceptionHandler(exception_handler_handle);
#endif
    execution_context = execution_context->prev_context;
}

Данный класс имеет поле prev_context, которое даёт нам возможность создавать цепочки из вложенных секций try/catch.

Полный листинг описанного выше изделия доступен в GitHub'е:
https://github.com/kutelev/hwtrycatch

В доказательство того, что всё работает как описано имеется автоматическая сборка и тесты под платформы Windows, Linux, Mac OS X и Android:

https://ci.appveyor.com/project/kutelev/hwtrycatch
https://travis-ci.org/kutelev/hwtrycatch

Под iOS это тоже работает, но за неимением устройства для тестирования нет и автоматических тестов.

В заключение скажем, что подобный подход можно использовать и в обычном C. Надо лишь написать несколько макросов, которые будут имитировать работу try/catch из C++.

Так же стоит сказать, что использование описанных методов в большинстве случаев является очень плохой идеей, особенно, если учесть, что на уровне сигналов нельзя выяснить, что же привело к возникновению SIGSEGV или SIGBUS. Это равновероятно может быть как и чтение по неправильным адресам так и запись. Если же чтение по произвольным адресам является операцией не деструктивной, то запись может приводить к плачевным результатам таким как разрушением стека, кучи или даже самого кода.

Автор: Александр Кутелев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js