Pull to refresh

С++ exception handling под капотом. Часть 3

Reading time14 min
Views15K
Original author: Nicolas Brailovsky
Продолжаем перевод серии статей об обработки исключений в C++

1 часть
2 часть

C++ exceptions под капотом: поиск верного landing pad


Это уже 15-я глава в нашей длинной истории. Мы уже изучили достаточно много о том, как работают исключения, и даже имеем написанную работающую собственную персональную функцию с небольшим количеством рефлексии, определяющей где находится catch-блок (landing pad в терминах исключений). В прошлой главе мы написали персональную функцию, которая может обрабатывать исключения, но она всегда подставляет только первый landing pad (т.е. первый же catch-блок). Давайте улучшим нашу персональную функцию, добавив возможность выбирать правильный landing pad в функции с несколькими catch-блоками.

Следуя моде TDD (test driven development), мы можем сначала построить тест нашего ABI. Улучшим нашу программу, throw.cpp, сделаем несколько try/catch блоков:

#include <stdio.h>
#include "throw.h"

struct Fake_Exception {};

void raise() {
    throw Exception();
}

void try_but_dont_catch() {
    try {
        printf("Running a try which will never throw.\n");
    } catch(Fake_Exception&) {
        printf("Exception caught... with the wrong catch!\n");
    }

    try {
        raise();
    } catch(Fake_Exception&) {
        printf("Caught a Fake_Exception!\n");
    }

    printf("try_but_dont_catch handled the exception\n");
}

void catchit() {
    try {
        try_but_dont_catch();
    } catch(Fake_Exception&) {
        printf("Caught a Fake_Exception!\n");
    } catch(Exception&) {
        printf("Caught an Exception!\n");
    }

    printf("catchit handled the exception\n");
}

extern "C" {
    void seppuku() {
        catchit();
    }
}

Перед тестированием, попробуйте подумать что случится в процессе запуска этого теста? Сфокусируйтесь на функции try_but_dont_catch: первый try/catch блок никогда не выбрасывает исключение, второй — пробрасывает, не отловив его. Покуда наш ABI немного туповат, первый catch-блок обработает исключение второго блока. Но что случится после того, как будет обработан первый catch? Выполнение продолжится с того места, где заканчивается первый catch/try, это опять прямо перед вторым try/catch блоком, который снова выбросит исключение, его снова обработает первый обработчик и так далее. Бесконечный цикл! Что ж, мы снова получили очень сложный while(true)!

Используем наши знания о полях начало/длина (start/length) в таблице LSDA для корректного выбора нашего landing pad. Для этого нам нужно знать, какой был IP, когда исключение было проброшено, и мы можем выяснить это с уже известными нам Unwind функцией: _Unwind_GetIP. Для того, чтобы понять, что _Unwind_GetIP возвращает, посмотрим пример:

void f1() {}
void f2() { throw 1; }
void f3() {}

void foo() {
L1:
    try{ f1(); } catch(...) {}
L2:
    try{ f2(); } catch(...) {}
L3:
    try{ f3(); } catch(...) {}
}

В данном случае, наша персональная функция будет вызвана в catch-блоке для f2, а стэк будет выглядит примерно так:

+------------------------------+
|   IP: f2  stack frame: f2    |
+------------------------------+
|   IP: L3  stack frame: foo   |
+------------------------------+

Обратите внимание, что IP будет установлен на L3, хотя исключение выброшено в L2. Это потому что IP указывает на следующую инструкцию, которая должна была быть выполнена. Это так же означает, что мы должны вычесть одну, если хотим получить IP где исключение было выброшено, иначе результат из _Unwind_GetIP может оказаться за пределами landing pad. Вернемся к нашей персональной функции:

_Unwind_Reason_Code __gxx_personality_v0 (
                             int version,
                             _Unwind_Action actions,
                             uint64_t exceptionClass,
                             _Unwind_Exception* unwind_exception,
                             _Unwind_Context* context)
{
    if (actions & _UA_SEARCH_PHASE)
    {
        printf("Personality function, lookup phase\n");
        return _URC_HANDLER_FOUND;
    } else if (actions & _UA_CLEANUP_PHASE) {
        printf("Personality function, cleanup\n");

        // Вычисление -- куда именно указывал IP
        // прямо перед тем, как было выброшено исключение
        uintptr_t throw_ip = _Unwind_GetIP(context) - 1;

        // Указатель на сырой LSDA
        LSDA_ptr lsda = (uint8_t*)_Unwind_GetLanguageSpecificData(context);

        // Чтрение заголовков LSDA
        LSDA_Header header(&lsda);

        // Чтение LSDA CS
        LSDA_CS_Header cs_header(&lsda);

        // Рассчет конца таблицы LSDA CS
        const LSDA_ptr lsda_cs_table_end = lsda + cs_header.length;

        // Цикл по всем записям таблицы CS
        while (lsda < lsda_cs_table_end)
        {
            LSDA_CS cs(&lsda);

            // Если тут нет LP, мы тут не можем обработать исключение, двигаемся дальше
            if (not cs.lp) continue;

            uintptr_t func_start = _Unwind_GetRegionStart(context);

            // Расчет области валидного IP для этого lp
            // Если LP может обрабатывать это исключение, тогда
            // IP для этого фрейма должен быть в этой области
            uintptr_t try_start = func_start + cs.start;
            uintptr_t try_end = func_start + cs.start + cs.len;

            // Проверка: корректный ли этот LP для текущего try блока
            if (throw_ip < try_start) continue;
            if (throw_ip > try_end) continue;

            // Если мы нашли landing pad для этого исключения; продолжаем выполнение
            int r0 = __builtin_eh_return_data_regno(0);
            int r1 = __builtin_eh_return_data_regno(1);

            _Unwind_SetGR(context, r0, (uintptr_t)(unwind_exception));
            // Напомню, что в этом коде напрямую зашит тип исключения;
            // Мы поправим это позже
            _Unwind_SetGR(context, r1, (uintptr_t)(1));

            _Unwind_SetIP(context, func_start + cs.lp);
            break;
        }

        return _URC_INSTALL_CONTEXT;
    } else {
        printf("Personality function, error\n");
        return _URC_FATAL_PHASE1_ERROR;
    }
}

}

Как обычно: актуальный код примера по ссылке.

Запустим еще раз и вуаля! Никаких более бесконечных циклов! Простые изменения позволили нам выбирать правильный landing pad. Далее мы попробуем научить нашу персональную функцию выбирать корректный фрейм стэка вместа первого.

C++ exceptions под капотом: поиск правильного catch-блока в landing pad


Мы уже написали персональную функцию, способную обрабатывать функции с более, чем одним landing pad. Теперь мы попытаемся распознать какой именно блок может обрабатывать определенные исключения, иными словами — какой catch-блок нам вызывать.

Конечно же, выяснить какой блок может обрабатывать исключение — задача не простая. Впрочем, вы действительно ждали чего-то другого? Основные проблемы прямо сейчас это:

  • Первое и основное: где и как мы можем найти принимаемые типы исключений этим catch-блоком.
  • Даже если мы сможем найти тип catch, как мы можем обработать catch (...)?
  • Для landing pad с несколькими catch-блоками, как мы можем узнать все возможные catch типы?
  • Взгляните на пример:

 struct Base {};
 struct Child : public Base {};
 void foo() { throw Child; }

 void bar()
 {
    try { foo(); }
    catch(const Base&){ ... }
 }

Мы должны проверять не только может ли текущий Landing Pad принимать текущее исключение, но и всех его родителей!

Сделаем нашу задачу чуть проще: будем работать с landing pads только с одним catch блоком, а так же скажем, что наследования у нас не существует. Тем не менее, как мы найдем принимаемые типы landing pad'а?

В общем то, это находится в части .gcc_except_table, который мы еще не анализировали: action table. Дизассемблируем на throw.cpp и посмотрим, что там, прямо после call site table, для нашей "try but dont catch" функии:

LLSDACSE1:
    .byte   0x1
    .byte   0
    .align 4
    .long   _ZTI14Fake_Exception
.LLSDATT1:

Не похоже, что тут много информации, но тут есть многообещающий указатель на что-то, что имеет название нашего исключение. Посмотрим на определение _ZTI14Fake_Exception:

_ZTI14Fake_Exception:
    .long   _ZTVN10__cxxabiv117__class_type_infoE+8
    .long   _ZTS14Fake_Exception
    .weak   _ZTS9Exception
    .section    .rodata._ZTS9Exception,"aG",@progbits,_ZTS9Exception,comdat
    .type   _ZTS9Exception, @object
    .size   _ZTS9Exception, 11

Мы нашли что-то очень интересное! Можете распознать это? Это std::type_info для структуры Fake_Exception!

Теперь мы знаем, что есть способ получить указатель на своего рода рефлексию для нашего исключения. Можем ли мы программно найти это? Посмотрим далее.

C++ exceptions под капотом: рефлексия типа исключения и чтение .gcc_except_table


Теперь мы знаем, где мы можем получить множество информации о исключении, читая локальное хранилище данных .gcc_except_table; что мы должны реализовать в персональной функции для определения правильного landing pad.

Мы оставили нашу реализацию ABI и погрузились в изучения ассеблера для .gcc_except_table, чтобы понять как мы можем найти типы исключений, которые мы можем обрабатывать. Мы обнаружили, что часть таблицы содержит список типов с необходимой нам информацией. Будем читать эту информацию в фазе очистки, но сперва давайте вспомним определение нашего LSDA заголовка:

struct LSDA_Header {
    uint8_t start_encoding;
    uint8_t type_encoding;

    // Смещение от конца заголовков до таблицы типов
    uint8_t type_table_offset;
};

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

struct LSDA_CS {
    // Смещение в функции откуда мы можем обрабатывать исключение
    uint8_t start;
    // Длина блока, который может обрабатыаться
    uint8_t len;
    // Landing pad
    uint8_t lp;
    // Смещение в action table + 1 (0 означает "нет действий")
    uint8_t action;
};

Посмотрите на последнее поле, "action". Это — смещение в action table. Это означает, что мы можем найти действие для специфичного CS (call site). Трюк в том, что для landing pads, в котором catch-блоки есть, action содержит смещение на таблицу типов, теперь мы можем использовать смещение для получения таблицы типов, которое можем получить из заголовков! Хватит разглагольствовать, лучше посмотрим в код:

// Указатель на начало чистого LSDA
LSDA_ptr lsda = (uint8_t*)_Unwind_GetLanguageSpecificData(context);

// Чтение заголовка LSDA
LSDA_Header header(&lsda);

const LSDA_ptr types_table_start = lsda + header.type_table_offset;

// Чтение LSDA CS
LSDA_CS_Header cs_header(&lsda);

// Рассчет конца таблицы LSDA CS
const LSDA_ptr lsda_cs_table_end = lsda + cs_header.length;

// Получение начала action tables
const LSDA_ptr action_tbl_start = lsda_cs_table_end;

// Первый call site
LSDA_CS cs(&lsda);

// cs.action -- это offset + 1; таким образом cs.action == 0
// означает что тут нет подходящий точек входа
const size_t action_offset = cs.action - 1;
const LSDA_ptr action = action_tbl_start + action_offset;

// Для landing pad с блоком catch the action table
// будет содержать index списка типов
int type_index = action[0];

// types_table_start указывает на конец таблицы, так что
// нам нужно инвентировать type_index. Это позволит найти ptr на
// std::type_info, определенную в нашем catch-блоке
const void* catch_type_info = types_table_start[ -1 * type_index ];
const std::type_info *catch_ti = (const std::type_info *) catch_type_info;

// Если все пойдет правильно, должно вывестить что-то типа Fake_Exception
printf("%s\n", catch_ti->name());

Этот код выглядит сложным из-за нескольких последовательных косвенных адресаций перед получением структуры type_info, но на практике он не делает ничего сложно, он лишь читает .gcc_except_table, который мы нашли при дизассемблировании.

Вывод типа исключения — большой шаг в правильном направлении. Так же наша персональная функция становится немного нагроможденной. Большинство сложностей чтения LSDA могут быть спрятаны под ковром, это не должно оказаться сильно накладным (имеется в виду — быть вынесеным в отдельную функцию).

Далее мы научимся сопоставлять тип обрабатываемого исключения с типом пробрасываемого.

C++ exceptions под капотом: получение правильного фрейма стэка


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

Конечно же, сначала нам нужно узнать тип исключения. Для этого, нам нужно его записывать, когда вызывается __cxa_throw:

void __cxa_throw(void* thrown_exception,
                 std::type_info *tinfo,
                 void (*dest)(void*))
{
    __cxa_exception *header = ((__cxa_exception *) thrown_exception - 1);

    // Мы должны сохранять тип в заголовке исключения, который получит _Unwind_
    // иначе мы не сможет получить его в процессе раскрутки
    header->exceptionType = tinfo;

    _Unwind_RaiseException(&header->unwindHeader);
}

И теперь мы можем прочитать тип исключения в нашей персональной функции и просто сравнить совпадение типов (имена исключений — C++ строки, так что простого "==" достаточно):

// Получение доступного для обработки типа исключения
const void* catch_type_info = lsda.types_table_start[ -1 * type_index ];
const std::type_info *catch_ti = (const std::type_info *) catch_type_info;

// Получение типа пробрасываемого исключения
__cxa_exception* exception_header = (__cxa_exception*)(unwind_exception+1) - 1;
std::type_info *org_ex_type = exception_header-&amp;gt;exceptionType;

printf("%s thrown, catch handles %s\n",
            org_ex_type->name(),
            catch_ti->name());

// Проверяем: совпадает ли тип обрабатываемого исключения
// с пробрасываемым
if (org_ex_type->name() != catch_ti->name())
    continue;

Посмотрите на гите последние изменения.

Хм, разумеется у нас возникнет проблема, сможете найти её сами? Если исключение пробрасывается в двух фазах и в первой мы хотим его обработать, во второй раз мы не можем сказать, что не хотим обрабатывать снова. Я не знаю, _Unwind обрабатывает этот кейс, об этом нет документации, скорее всего, возникнет неопределенное поведение, так что просто сказать, что мы обрабатываем все подряд — не достаточно.

Покуда мы научили персональную функцию узнавать, какой landing pad может обрабатывать исключение, мы врали Unwind о том, какое исключение может быть обработано, вместо этого мы говорим, что обрабатываем их все в нашей ABI 9. Правда в том, что мы не знаем — можем ли мы обработать его. Это просто исправить: мы можем сделать что-то типа того:

_Unwind_Reason_Code __gxx_personality_v0 (...)
{
    printf("Personality function, searching for handler\n");

    // ...

    foreach (call site entry in lsda)
    {
        if (call site entry.not_good()) continue;

        //  Мы нашли landing pad для данного исключения; продолжаем выполнение

        // Если мы в фазе поиска, говорим _Unwind_, что можем обработать
        if (actions & _UA_SEARCH_PHASE) return _URC_HANDLER_FOUND;

        // если мы не в фазе поиска, тогда в фазе _UA_CLEANUP_PHASE

        /* установка всего необходимого */

        return _URC_INSTALL_CONTEXT;
    }

    return _URC_CONTINUE_UNWIND;
}

Что мы получим, если запустим нашу персональную функцию? Падение! Кто бы сомневался. Помните нашу "падающую" функцию? Вот что должно отлавливать наше исключение:

void catchit() {
    try {
        try_but_dont_catch();
    } catch(Fake_Exception&) {
        printf("Caught a Fake_Exception!\n");
    } catch(Exception&) {
        printf("Caught an Exception!\n");
    }

    printf("catchit handled the exception\n");
}

К сожалению, наша персональная функция проверяет только первый тип ошибок, который landing pad может обрабатывать. Если мы удалим Fake_Exception catch-блок и попробуем снова: все наконец сработает правильно! Наша персональная функция может выбирать правильный catch-блок в правильном фрейме, поставляемый try-catch блоком с единственным catch-блоком.

В следующей главе мы улучшим его еще раз!

C++ exceptions под капотом: выбираем правильный catch из landing pad


19-я глава об исключениях в C++: мы написали персональную функцию, которая может читать LSDA, выбирать правильный landing pad, правильный стэк фрейм для обработки исключения, но пока еще затрудняемся с поиском правильной ветви catch. Для окончательной версии работающей персональной функции мы должны проверять типы исключений во всей таблице действии .gcc_except_table.

Помните action table? Посмотрим на нее еще раз, но теперь с несколькими catch-блоками:

# Call site table
.LLSDACSB2:
    # Call site 1
    .uleb128 ip_range_start
    .uleb128 ip_range_len
    .uleb128 landing_pad_ip
    .uleb128 (action_offset+1) => 0x3

    # Rest of call site table

# Action table start
.LLSDACSE2:
    # Action 1
    .byte   0x2
    .byte   0

    # Action 2
    .byte   0x1
    .byte   0x7d

    .align 4
    .long   _ZTI9Exception
    .long   _ZTI14Fake_Exception
.LLSDATT2:
# Types table start

Если мы собираемся считывать все поддерживаемые landing pad исключения в этом примере (этот LSDA для функции catchit, к слову), нам нужно сделать что-то вроде такого:

  • Получить смещение экшена из таблицы вызовов (не забудте, мы считываем offset + 1, а 0 означает — нет действий)
  • Перейти к action 2 по смещению, получить индекс типа 1. Таблица типов индексируется в обратном порядке (т.е. мы имеем указатель на её конец и должны получать доступ, используя -1 * index)
  • Перейти в types_table[-1], чтобы получить type_info для Fake_Exception
  • Fake_Exception не то исключение, которое было выброшено, получаем смещение к следующему действию (action) (0x7d)
  • Чтение 0x7d в uleb128 вернут -3, что с позиции, откуда мы читаем смещение, является тремя шагами назад
  • Чтение типа с индексом 2
  • Получение type_info для исключения Exception, которое на этот раз совпадает с пробрасываемым, так что мы можем устанавливать landing pad!

Это выглядит сложным, покуда у нас сново много косвенной адресации, но вы можете посмотреть итоговый код в репозитории. По ссылке вы найдете бонус в виде персональной функции, умеющей читать таблицу типов, определять какой catch-блок нам нужен (если тип — null, блок может обрабатывать все исключения подряд). Тут есть забавный побочный эффект: мы можем обрабатывать ошибки только выброшенные из C++ программ.

Наконец, мы знаем как пробрасываются исключения, как раскручивается стэк, как персональная функция выбирает корректный фрейм стэка и какой catch блок внутри landing pad выбирать, но у нас все еще есть проблема: запуск деструкторов. Что ж, далее изменим нашу персональную функцию, обеспечив поддержку RAII.

C++ exceptions под капотом: запуск деструкторов в раскрутке


Наш мини-ABI 11 версии умеет практически все из базовых возможностей в обработке исключений, но он все еще не может запускать деструктор. Это очень важная часть, если мы хотим писать безопасный код. Мы знаем, что нужные дестркуторы хранятся .gcc_except_table, так что нам нужно посмотреть код ассемблера еще немного.

# Call site table
.LLSDACSB2:
    # Call site 1
    .uleb128 ip_range_start
    .uleb128 ip_range_len
    .uleb128 landing_pad_ip
    .uleb128 (action_offset+1) => 0x3

    # Rest of call site table

# Action table start
.LLSDACSE2:
    # Action 1
    .byte   0
    .byte   0

    # Action 2
    .byte   0x1
    .byte   0x7d

    .align 4
    .long   _ZTI14Fake_Exception
.LLSDATT2:
# Types table start

В обычном landing pad, когда action имеет тип с индексом более, чем 0, мы можем получить индекс в таблицы типов и можем использовать его поиска необходимого catch блока. В противном случае, когда индекс — 0, нам необходимо запускать код очистки. Даже если landing pad не может обрабатывать исключения, он все еще способен выполнять очистку во время раскрутки. Разумеется, landing pad должен вызвать _Unwind_Resume после того, как очистка закончена, чтобы продолжить процесс раскрутки.

Я загрузил в мой гитхаб репозиторий новую и последнюю версию кода, но у меня плохие новости: помните наше читерство, когда мы сказали, что uleb128 == char? Когда мы начали добавлять код для деструкторов, смещения в .gcc_except_table становятся большими (под "большими" я подразумеваю, что они больше 127) и наша уловка больше не работает.

Для следующей версии нам стоит переписать наш считыватель LSDA для того, чтобы он корректно обрабатывал uleb128 код.

Даже не смотря на это, мы достигли своего! Написали мини-ABI, способный корректно обрабатывать исключения без помощи библиотеки libcxxabi!

Конечно же, есть еще что делать, например, обрабатывать ненативные для этого языка исключения, поддержка совместимости между компиляторами и линкерами. Может как-нибудь позже...

C++ exceptions под капотом: итоги


После 20-ти глав о низкоуровневой обработки исключений, настало время подведения итогов! Что мы узнали о том, как исключения выбрасываются и как они ловятся?

Оставим в стороне страшные подробности о чтении .gcc_except_table, что, вероятно, наибольшая часть этой статьи, мы можем заключить:

  • C++ компилятор в действительности делает очень мало работы, связанной с обработкой исключений, большее всего магии происходит в libstdc++
  • Вот несколько вещей, что делает компилятор:

    • Создает CFI информацию для раскрутки стэка
    • Он создает что-то, называемое .gcc_except_table с информацией о landing pads (try/catch блоки). Часть рефлексии.
    • Когда мы пишем throw, компилятор транслирует это в пару вызовов libstdc++, которые аллоцируют исключение и после этого запускают раскрутку

  • Когда исключение проброшено в рантайме, __cxa_throw делегирует раскрутку стэка библиотеке libstdc
  • В процессе раскрутки стэка вызывается специальная функция, поставляемая libstdc++ (называемая персональной функцией, personality routine), которая проверяет каждую функцию в стэке, может ли она обрабатывать исключение.
  • Если подходящего catch-блока не найдено, вызывается std::terminate
  • Если найден — раскрутка стэка вновь начинается с начала стэка
  • В процессе второго прохода, выполняется очистка
  • Персональная функция проверяет .gcc_except_table для текущего метода. Если в ней (таблице) есть действия по очистке, персональная функция "прыгает" в текущий фрейм стэка для запуска очистки этого метода
  • Как только раскрутчик наткнулся на фрейм стэка (считай, функцию), которая может обрабатывать это исключение, он прыгает в подходящий catch-блок
  • После выполнения catch-блока, очищается память, занятая исключением.

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

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

Даже если мы говорим о языках высокого уровня, когда пробрасывается исключение, мы не можем полагаться на наше понимание о нормальном выполнении кода: обычно он выполняется линейно с небольшими разветвлениями в виде if и switch операторов. С исключениями все иначе: код начинает выполняться в непонятном порядке, разворачивать стэк, прерывать выполнения функций и перестает следовать обычным правилам. Указатель на инструкцию меняется в каждом landing pad, стэк раскручивается без нашего контроля, в целом, под капотом происходит очень много магии.

В итоге — исключения сложны, потому что они ломают наше представление о естественном выполнении программы. Это не означает, что нам категорически запрещено их использовать, а лишь говорит о том, что нам нужно быть всегда осторожными при работе с ними!
Tags:
Hubs:
Total votes 33: ↑31 and ↓2+29
Comments4

Articles