Тема изъезжена и уже не мало копий было сломано из-за неё. Так или иначе люди продолжают задаваться вопросом о том может ли приложение написанное на 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. Это равновероятно может быть как и чтение по неправильным адресам так и запись. Если же чтение по произвольным адресам является операцией не деструктивной, то запись может приводить к плачевным результатам таким как разрушением стека, кучи или даже самого кода.

Поделиться с друзьями
-->

Комментарии (10)


  1. apro
    07.07.2017 23:50

    1. kutelev
      08.07.2017 04:58

      Когда-то в прошлом я однозначно поглядывал на данное решение, деталей не помню. Как минимум отсутствует поддержка не десктопных платформ, вторым различие является то, что, как я понял, конвертироваться будут все аппаратные исключения где бы они не случились, а это не всегда то чего хочется. Хочется контролировать в каких секциях try мы этого хотим, а в каких нет. Да, это делает код кудрявее, зато даёт больше гибкости.


  1. khim
    07.07.2017 23:51

    Только не забывайте -fno-delete-null-pointer-checks и откажитесь от использования clang'а — а так всё хорошо.


    1. mayorovp
      10.07.2017 09:58

      А что не так с clang и no-delete-null-pointer-checks?


      1. khim
        10.07.2017 11:33

        Обращение по nullptr — это неопределённое поведение. То есть в правильно написанной программе его быть не должно. И компиляторы на это полагаются. То есть, например, если у вас есть что-то такое:

          int x = *p;
          if (p == nullptr) {
            std::cerr << "p is null" << std::endl;
          }
        

        то и clang и gcc могут этим воспользоваться и упростить программу, выкинув из неё if (и всё, что там внутри!) к чертям собачьим. Вообще любой код, про который компилятор может доказать, что он может быть выполнен, только если какая-то переменная оказывается равна nullptr и после этого разименовывается или участвует в арифметике может быть выкинут.

        У gcc есть опция, которая это отключает, у clang'а нет.


        1. mayorovp
          10.07.2017 12:03
          -2

          Не вижу проблем — до условного оператора выполнение не дойдет в любом случае.


          1. khim
            10.07.2017 12:45
            +4

            Не вижу проблем — до условного оператора выполнение не дойдет в любом случае.
            Вы это серьёзно?

            Хорошо, вот вам полный пример:
            #include <iostream>
            
            int __attribute__((noinline)) foo(int* p) {
              int x = *p;
              if (p == nullptr) {
                std::cerr << "p is null" << std::endl;
              }
              return x;
            }
            
            int main() {
              foo(nullptr);
              std::cerr << "Bwahaha" << std::endl;
            }
            


            $ g++ -O3 -std=c++11 test.cc -o test-bad
            $ g++ -O3 -std=c++11 -fno-delete-null-pointer-checks test.cc -o test-good
            $ test-bad
            Bwahaha
            $ test-good
            Segmentation fault (core dumped)
            $ g++ --version
            g++ (GCC) 5.2.0
            Copyright (C) 2015 Free Software Foundation, Inc.
            This is free software; see the source for copying conditions.  There is NO
            warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
            

            Всё ещё не видите проблемы и разницы между двумя вариантами?

            Полагаться на то, что обращение к nullptr вызовет исключение — очень плохая идея. Сегодня — оно срабатывает, а завтра — вдруг перестанет, причём «малые шевеления» кода могут приводить к тому, что эффект будет то появляться, то исчезать…


            1. mayorovp
              10.07.2017 13:37
              -1

              Смысл подхода, предложенного автором — не столько в том чтобы разрешить nullptr, сколько в том чтобы предотвратить падение программы. И это должен быть последний барьер, а не первый.


              Если падение программы предотвратил компилятор — ну и замечательно.


              PS если переменную x не забывать, а использовать — компилятор операцию чтения не убирает. Хотя стандартом такое поведение и не гарантируется, но выглядит оно очень логичным…


              1. khim
                10.07.2017 17:13
                +3

                Если падение программы предотвратил компилятор — ну и замечательно.
                Проблема в том, что компилятор мог «предотвратить падание программы» выкинув из неё кучу кода — что может приводить (и реально приводило) к дырам в безопасности. Если вашей программе очень-очень не стоит падат — то точно ли вам будет хорошо, если она вдруг сделает что-то не то?

                Грубо говоря: если ваша программа управляет ядреным реактором — то, понятно, очень не хочется чтобы она вдруг внезапно остановилась и реактор бы взорвался… но если компилятор «предотвратит падение» позволив вам выташить из реактора стержни — то кому будет хорошо от того, что ваша программ не упадёт?

                PS если переменную x не забывать, а использовать — компилятор операцию чтения не убирает.
                Посмотрите внимательно на код, а? Там переменная как раз обязательно используется и разименовывается. Да и функция помечена как noinline чтобы компилятор ненароком её не обработал как ему хочется. Что не спасло от того, что компилятор, исследовав код, обнаружил, что вызов этой функции можно выкинуть, так как её результат «на более высоком уровне абстракции» никому не нужен. А проверку — мы выкинули «за ненадобностью» ранее.

                Смысл подхода, предложенного автором — не столько в том чтобы разрешить nullptr, сколько в том чтобы предотвратить падение программы. И это должен быть последний барьер, а не первый.
                Для этого вполне достаточно следить за программой «снаружи» и перезапускать «в случае чего». Превращение обращения в память в выброс C++ исключения и обработка этого исключения превращает критическую ошибку в что-то, что является почти «штатной» проблемой и поощряет написание небезопасного кода.


                1. mayorovp
                  10.07.2017 17:33
                  -2

                  Все ваши аргументы относятся к программам вообще, независимо от того, используется ли там код из обсуждаемого тут поста.


                  PS к классу программ, которым "очень-очень не стоит падать", относятся не программы управления реактором, а оконные приложения. Их, конечно, часто тоже стоило бы компилировать без сложных оптимизаций — но и трагедии в неправильной работе пары из функций — нет. До тех пор, пока бинарник не обновляется, баг остается детерменированным, обрастает воркараундами и тулза остается работоспособной.


                  А вот падение программы посреди заполнения длинной формы, да еще и в третий раз подряд, как раз может быть серьезной проблемой.