Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Как работают исключения в С++ на Linux?

Как работают исключения в С++ на Linux?

16 Янв 2026

Наш любимый язык позволяет использовать мощные инструменты, при этом абстрагируя нас от деталей реализации. Хотя исключения и пользуются дурной славой у многих программистов, детальный разбор механизма их работы может значительно расширить понимание того, как С++ работает на самом деле. Этим и займёмся!

Прежде чем погружаться в дебри механизма исключений, стоит выбрать удобную точку зрения на то, как они могут работать. В целом, таких точек зрения может быть две. Реализовать их можно с помощью landing pad или с помощью frame handler.

Простите, что? Действительно, начало получилось каким-то резким. Давайте попробуем ещё раз.

Исключения можно делать с помощью таблиц и с помощью связанных списков. М-да... понятнее как будто не стало. Хорошо, ещё раз...

Реализация исключений может следовать Itanium ABI, а может не следовать. Чёрт, снова непонятно.

Да, друзья, всё это очень непросто! Разные платформы могут реализовывать исключения по-разному, а в рамках одной платформы могут существовать разные подходы, имеющие свои плюсы и минусы. Слона (бедолагу), как говорится, едят по частям. Чтобы не получить несварение желудка, такой же тактики, пожалуй, будем придерживаться и мы.

Эту статью отведём под мир Linux. Более того, чтобы не засорять интернеты талмудами текста, статью эту мы разобьём на несколько отдельных, каждая из которых будет посвящена своей теме. В начале статьи со временем образуется оглавление, ведущее на другие части. Если его там нет, значит, новые открытия пока что не случились!

Остальные платформы, как до них дойдёт дело, рассмотрим в отведённых под них материалах.

Каждое рассмотрение, включая это, будет сопровождаться дотошным исследованием кода, который обеспечивает функционирование исключений. Мы будем, где возможно, опираться на библиотеку libcxx от LLVM, а в остальных случаях — на libstdc++ от GCC. Чтобы не засорять место выкладками кода, будем аннотировать информацию ссылками на конкретные места кода в их репозитории.

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

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

Ну что, за дело?

101 для нетерпеливых

Давайте сделаем небольшую затравку, чтобы вы не закрыли эту статью слишком быстро. Мы быстренько опишем, как в мире операционной системы Linux обстоят дела с реализацией исключений. Займёт это буквально пару предложений, а обстоятельное описание самые пытливые умы смогут найти сразу после.

Итак, исключения можно сделать через генерацию дополнительного кода, исполняемого внутри try ... catch-блоков, либо через генерацию метаданных. Генерация в обоих случаях происходит при трансляции исходного кода на язык ассемблера. Таким образом, синтаксис try ... catch-блока превращается в набор структур данных и вызовов функций, конкретный вид которых зависит от выбранного способа реализации.

При броске исключения поток исполнения "прыгает" из одного места программы в другое. Какой нелюбимый механизм нашего любимого языка обеспечивает произвольные переходы исполнения в определённые места? Правильно, это старина goto. Только, конечно, с прыжками за пределы функции он нам не поможет, поэтому придётся использовать его сидящего на стероидах сводного брата — setjmp/longjmp.

Прежде чем куда-то прыгнуть, стоило бы озаботиться вопросом: "А куда?" Нам бы не хотелось оказаться на месте известной путешественницы в неизвестных местах из "Волшебника Страны Оз", правильно? Для ответа на вопрос мы заводим связанный список, каждый элемент которого хранит в себе информацию о контексте фрейма, например, состояние регистров. Поиск нужного catch-блока происходит с помощью обхода этого списка.

Часто такая реализация называется portable exceptions. Конечно, в этом случае механизм зависит не от специфических таблиц под специфичную платформу. Он завязывается на механизм setjmp/longjmp, который, как правило, работает схожим образом на всех Unix-based платформах. Нужно сгенерировать только вызовы функций setjmp/longjmp (возможно, даже в форме интринcиков компилятора) и механизма, реализующего связанный список.

Вы можете сказать: "Эй, это же придётся звать эти функции даже тогда, когда я не бросаю исключений!" Ну... да? Из-за этого увеличивается стоимость всего исполнения.

Поэтому большое распространение получила вторая реализация исключений — через метаданные.

Метаданные чаще называют "таблицами исключений" (exception handling tables), но первое название автору нравится больше. Оно лучше иллюстрирует то, что происходит под капотом этого механизма. Саму реализацию механизма исключений поверх метаданных называют "zero-cost exception handling". Zero-cost звучит притягательно, верно?

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

Название "zero-cost" крайне занимательно. Конечно, следование пути выполнения, обозначенного броском исключения, предусматривает дополнительные траты. Как минимум нам нужно вызвать деструкторы стековых переменных (ведь это гарантируется стандартом С++). Да и бесплатно доехать до нужной остановки, на которой исключение обработается, не получится.

Но обычно всё-таки подразумевается, что броски исключения — это, извините за каламбур, исключительная ситуация. Что происходит, когда поток исполнения идёт по обычному пути? Правильно, мы не тратим дополнительные ресурсы на поддержание связанного списка. Не платить за то, что мы не используем — старый девиз сторонников нашего любимого языка.

За исключением того, что реализация исключений через таблицы исключений может приводить к пессимизации времени исполнения программы, даже если броски исключений происходят редко или не происходят вовсе. Это очень важный момент, который очень легко упустить из внимания и ещё легче мистифицировать. К вопросу пессимизации мы, надеюсь, ещё вернёмся в других статьях.

Вот вам и описание внутрянки исключений на Linux! Кажется, не так уж и страшно. Если же вам, дорогой читатель, требуется больше деталей (за которыми, мы уверены, вы сюда и пришли), просим следовать дальше.

Внутрянка

Первое, с чего нужно начать рассмотрение реализации исключений, это то, что исключения — не платформенно-зависимая вещь. Исключения — это вещь компиляторо-зависимая. Несмотря на то, что стандарт С++ чётко описывает правила работы исключений на уровне языка, имплементации языка имеют право самостоятельно выбирать реализации этого механизма.

Поэтому разумно, как мне кажется, смотреть на реализацию исключений как на слоистую вещь. Итак, из чего состоит наш "лучок"?

Сверху, конечно же, идёт стандарт С++. Он просто говорит: "Вот вам красивый синтаксис, пишите try ... catch, кидайте что угодно, я вам гарантирую RAII и порядок вызовов деструкторов". Как это работает, стандарту не важно. Важно, что может и не может писать конечный пользователь, то есть я — программист!

Ниже стандарта идёт так называемый Itanium C++ ABI. Почему он так называется, мы поговорим позже. Сейчас лишь отметим, что этот слой состоит из двух других. Первый, назовём его C++-specific, в который непосредственно транслируются конструкции из стандарта С++. Второй слой, в свою очередь, отвечает за поиск нужного catch-блока и вызов деструкторов уничтожаемых по ходу дела объектов. Этот второй слой можно назвать независимым от реализации конкретного языка. Кроме случаев, когда language-specific вещи там всё-таки встречаются.

Нам нужно найти место, в которое доставляется исключение, и вызвать деструкторы по ходу этой доставки. В рамках этих двух действий происходит так называемая "раскрутка стека". Это когда некая система — в нашем случае это С++ runtime — шарится по низкоуровневому представлению функций (стековые фреймы), проходя по ним в порядке, обратном пути выполнения. Реализуется это на Linux в контексте исключений двумя способами: DWARF и SJLJ. Это последний слой нашего лучка.

Ну что, к деталям?

Стандарт С++

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

Itanium

Как говорится, пошла жара! На этом слое происходит как раз та подкапотная магия, которая и двигает весь механизм исключений в плюсах (и не только в них). Чтобы не обжечься и не cгореть от обилия приколов слишком быстро, предлагаем двигаться постепенно.

Сначала мы посмотрим на специфичный для C++ слой, который отвечает за бросок и поимку исключений. Обычно он называется Itanium C++ ABI, cxxabi или типа того. После этого перейдём к независимой — с нюансами — от языков части ABI, которая позволяет находить нужные catch-блоки, вызывать деструкторы и выполнять другие полезные фокусы. Его часто обозначают как Base level Itanium ABI или Unwinder. Стоит отметить, что формально этот слой также относится к Itanium ABI.

Причём здесь Itanium, вы спросите? Так, простите, сложилось исторически. Это была одна из первых 64-битных платформ от Intel и HP. Хотя AMD64 в итоге победила в противостоянии 64-битных архитектур, описание ABI и низкоуровневой реализации C++ успели сделать именно для Itanium. Народ оценил, и она прижилась. С правками под каждую систему, само собой. В итоге название Itanuim ABI закрепилось за подобной спецификацией в Linux-мире. У Windows, правда, как обычно, своя атмосфера.

Саму процессорозависимую спецификацию можно найти тут, наглядное описание работы таблиц исключений в компиляторе aC++ от HP посмотреть здесь, а более актуальное описание того, как это сейчас всё это добро реализуется на Linux, прочитать там.

Чтобы не переписывать просто так спецификацию ABI, возьмём небольшой пример и используем его, чтобы постепенно залезть глубже в дебри реализации С++ runtime.

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

Itanium C++ ABI

Познакомимся с нашим подопытным:

int bar()
{
    throw -1;
}

int foo()
{
    try {
        return bar();
    }
    catch (...) {
        return -1;
    }
}

int main()
{
    return foo();
}

В нём есть:

  • функция, которая кидает исключение;
  • функция, которая ловит исключение;
  • функция main, запускающая наш пример.

В дальнейшем мы будем так или иначе основывать рассмотрение исключений на этом коде.

Скомпилируем это! Используем Clang 21.1.0 для x86-64, самый последний на момент написания статьи:

bar():
        push    rbp
        mov     rbp, rsp
        mov     edi, 4
        call    __cxa_allocate_exception@PLT
        mov     rdi, rax
        mov     dword ptr [rdi], -1
        mov     rsi, qword ptr [rip + typeinfo for int@GOTPCREL]
        xor     eax, eax
        mov     edx, eax
        call    __cxa_throw@PLT
foo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        call    bar()
        mov     dword ptr [rbp - 24], eax
        jmp     .LBB1_1
.LBB1_1:
        mov     eax, dword ptr [rbp - 24]
        mov     dword ptr [rbp - 4], eax
        jmp     .LBB1_4
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 16], rcx
        mov     dword ptr [rbp - 20], eax
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        mov     dword ptr [rbp - 4], -1
        call    __cxa_end_catch@PLT
.LBB1_4:
        mov     eax, dword ptr [rbp - 4]
        add     rsp, 32
        pop     rbp
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     dword ptr [rbp - 4], 0
        call    foo()
        add     rsp, 16
        pop     rbp
        ret
DW.ref.__gxx_personality_v0:
        .quad   __gxx_personality_v0

При беглом взгляде мы видим некоторые интересные вещи. В наших функциях появились вызовы новых функций, откуда-то затесался typeinfo, а в самом низу произошло определение какого-то непонятного символа __gxx_personality_v0.

Давайте остановимся на функции bar. В нашем С++ коде она фактически делает одну вещь: вызывает бросок исключения с помощью выражения throw -1. Мы видим, как в ассемблерном коде это выражение раскрылось в вызове функций __cxa_allocate_exception и __cxa_throw. Мы эти функции не писали, значит, компилятор о них что-то знал заранее. Значит, это либо интринcики, либо библиотечные функции.

Разобраться нам помогут два источника: спецификация С++ ABI и исходники С++ runtime. Лично автору больше нравится копаться в libcxx из LLVM, поэтому за основу возьмём его.

Действительно, мы можем найти определения этих функций в исходниках libcxx. Функция __cxa_allocate_exception, согласно своему названию, аллоцирует место на куче под исключение.

Когда мы пишем throw MyException(42);, компилятор не просто копирует объект куда-то и надеется на лучшее. Поскольку бросок исключения может происходить во многих функциях, потоках и даже языках, среде выполнения необходим надёжный, самодостаточный объект, содержащий как само исключение, так и всё необходимое для его обработки.

Поэтому __cxa_allocate_exception легче получить только размер фактического объекта, что-то с ним сделать и отдать указатель на область памяти, в которую потом будет записан объект искомого исключения.

push    rbp
mov     rbp, rsp
mov     edi, 4
call    __cxa_allocate_exception@PLT

Отвечать за запись объекта в область памяти будет уже клиентский код — тот, что компилятор сгенерировал для нас. Но смотрите, функция аллоцирует намного больше памяти, чем нужно для хранения самого исключения:

char *raw_buffer =
    (char *)__aligned_malloc_with_fallback(header_offset + actual_size);

Это нужно по нескольким причинам.

Во-первых, рантайму позднее понадобится метаинформация о брошенном исключении, чтобы понимать, что с ним вообще делать. За это отвечает структура __cxa_exception. Она содержит информацию о типе исключения (std::type_info), указатель на деструктор (ведь объект нужно как-то уничтожать в будущем), всякие счётчики, обработчики и прочие приколы. В конце этой структуры идёт некий _Unwind_Exception. Пока сделаем вид, что его не видели.

Во-вторых, функция учитывает правила выравнивания конкретной платформы. Некоторые процессоры очень требовательны: объекты должны начинаться с адресов, кратных 8, 16 или более. Функция округляет общий размер в большую сторону, чтобы объект исключения был правильно выровнен.

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

__cxa_exception
Unwind_Exception 
thrown object (int в нашем случае)

Если память аллоцировать не удалось, здравствуй std::terminate! Но если всё идёт по плану, то вся выделенная память зануляется. После этого клиенту возвращается указатель на место, где должен быть создан объект исключения. Важно, что возвращаемый указатель не является началом выделенного блока, а указывает на точное место, где должен находиться объект исключения. Заголовок __cxa_exception лежит в памяти прямо перед ним.

Кстати, весёлый факт: libstdc++ (библиотека из GCC) умеет швыряться исключениями даже когда память на куче закончилась. Достигли они этого благодаря былинной реализации arena pool allocator. Всем интересующимся качественным производительным кодом крайне советуем ознакомиться, не пожалеете!

Хорошо. Место под исключение мы получили. Что дальше? Посмотрим ещё раз на сгенерированный код:

call    __cxa_allocate_exception@PLT
mov     rdi, rax
mov     dword ptr [rdi], -1
mov     rsi, qword ptr [rip + typeinfo for int@GOTPCREL]
xor     eax, eax
mov     edx, eax
call    __cxa_throw@PLT

Мы видим вызов функции __cxa_throw. По коду видно, как перед её вызовом мы формируем три аргумента. Сначала записываем наш объект исключения в полученное под него место. Потом получаем указатель на typeinfo. В процессе доставки исключения нам необходимо обладать информацией о его типе, и что может нам помочь с этим лучше, чем RTTI! У __cxa_throw есть и третий аргумент, который в нашем случае выставляется в ноль. Этот аргумент — указатель на деструктор исключения. Использовать оператор delete нельзя, ведь мы изначально не создавали объект через вызов оператора new, поэтому приходится передавать указатель на деструктор. У встроенного типа int отдельного деструктора нет, поэтому и передавать нечего.

Описанная нами сигнатура соответствует той, что мы видим в исходниках. В ней происходит обращение к объекту __cxa_eg_globals — локальному для каждого треда хранилища стека исключений, достигших своего catch-блока, и счётчика ещё не обработанных исключений. После этого происходит инициализация __cxa_exception. Интересный момент — выставление поля referenceCount. Оно не описано в спецификации Itanium ABI, так как появилось много позже — при выходе С++11 для поддержки std::exception_ptr. В конце вызывается одна из двух функций: _Unwind_SjLj_RaiseException или _Unwind_RaiseException.

Подробнее о них мы поговорим позже, сейчас же нужно знать только то, что они ни в коем случае не должны возвращать поток исполнения. Если это произошло, значит, что-то пошло очень сильно не так, и это прямой путь к std::terminate.

Весёлый факт: всё это значит, что исключением можно бросить, в принципе, вообще любой объект, так сказать, без исключений. Ну вы поняли.

Отлично, мы разобрались, как происходит бросок исключения. Конечно, мы пока не знаем, как конкретно происходит его доставка, но всему своё время. Пока что давайте посмотрим на второй доступный нам способ взаимодействия с исключениями: их поимку.

Если мы посмотрим на ассемблерный код функции foo, то сразу же заметим странную вещь: код между коммандой jmp .LBB1_4 и самой меткой LBB1_4 не исполняется.

        jmp     .LBB1_4
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 16], rcx
        mov     dword ptr [rbp - 20], eax
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        mov     dword ptr [rbp - 4], -1
        call    __cxa_end_catch@PLT
.LBB1_4:

Действительно, мы его перепрыгиваем каждый раз при последовательном выполнении команд от начала до конца функции. Если мы сделаем несколько мыслительных кульбитов и вовсе выкинем этот код из ассемблера, то в сухом остатке получим нормальный путь выполнения функции foo — вызов функции bar и возврат её значения:

foo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        call    bar()
        mov     dword ptr [rbp - 4], eax
        mov     eax, dword ptr [rbp - 4]
        add     rsp, 32
        pop     rbp
        ret

Всё будет работать как надо, кроме одного малюсенького момента: исключения в этой реализации обрабатываться не будут. Конечно, ведь мы вырезали реализацию нашего catch-блока! Но как нам в него попасть, если нормальный путь исполнения постоянно его перепрыгивает?

Вообще, мы сможем ответить на этот вопрос чуть позже, когда спустимся ещё ниже по нашей слоистой структуре механизма исключений. Давайте пока что сконцентрируемся на том, что происходит, когда в catch-блок мы уже попали. А происходят вызовы двух новых функций __cxa_begin_catch и __cxa_end_catch.

call    __cxa_begin_catch@PLT
; ....
call    __cxa_end_catch@PLT

Функция __cxa_begin_catch получает параметром указатель. По её коду видно, что этот указатель преобразуется в указатель на нечто под названием _Unwind_Exception. Откуда этот указатель взялся изначально, пока что загадка. Давайте просто примем на веру, что он у нас есть. Стоит также отметить, что если бы мы кидали исключение посложнее, чем просто int (то есть в catch-блоке ловился бы объект с копирующим конструктором), то перед __cxa_begin_catch добавился бы ещё вызов __cxa_get_exception_ptr. Разбор этого поведения оставим вам на домашнее задание.

Первым делом __cxa_begin_catch пытается достать уже известный нам __cxa_exception путём сдвига относительно _Unwind_Exception. Ранее вы видели, как две управляющие структуры исключения лежат в памяти рядом с самим объектом. После этого функция увеличивает счётчик __cxa_exception.handlerCount — счётчик обработчиков, в которых исключение всё ещё находится. Следом получаем __cxa_eh_globals, добавляем текущее исключение на вершину стека и уменьшаем количество ещё не пойманных исключений. Помните, при броске исключений мы тоже работали с этой структурой и делали обратную операцию с полем uncaughtExceptions.

Весёлый факт: эта функция умеет работать и с исключениями, происходящими из других языков, хотя подобный функционал и не предусмотрен стандартом С++.

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

Прежде чем мы двинемся рассматривать слой, отвечающий за доставку исключения к его catch-блоку, давайте на секунду в этом самом блоке задержимся. Что происходит, если мы перекидываем пойманное исключение? Давайте заменим в нашем изначальном коде возврат значения return -1; на throw; внутри catch-блока и снова транслируем в ассемблер:

foo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        call    bar()
        mov     dword ptr [rbp - 16], eax
        jmp     .LBB1_1
.LBB1_1:
        mov     eax, dword ptr [rbp - 16]
        add     rsp, 16
        pop     rbp
        ret
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 8], rcx
        mov     dword ptr [rbp - 12], eax
        mov     rdi, qword ptr [rbp - 8]
        call    __cxa_begin_catch@PLT
        call    __cxa_rethrow@PLT
        jmp     .LBB1_8
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 8], rcx
        mov     dword ptr [rbp - 12], eax
        call    __cxa_end_catch@PLT
        jmp     .LBB1_5
.LBB1_5:
        jmp     .LBB1_6
.LBB1_6:
        mov     rdi, qword ptr [rbp - 8]
        call    _Unwind_Resume@PLT
        mov     rdi, rax
        call    __clang_call_terminate
.LBB1_8:

__clang_call_terminate:
        push    rbp
        mov     rbp, rsp
        call    __cxa_begin_catch@PLT
        call    std::terminate()@PLT

Ассемблерный код функции foo стал ещё больше. Появился вызов функции __cxa_rethrow. Её цель — отменить действие __cxa_begin_catch и заново бросить исключение.

mov     rcx, rax
mov     eax, edx
mov     qword ptr [rbp - 8], rcx
mov     dword ptr [rbp - 12], eax
mov     rdi, qword ptr [rbp - 8]
call    __cxa_begin_catch@PLT
call    __cxa_rethrow@PLT

Фактически она делает то же самое, что и наша старая знакомая __cxa_throw — бросает исключение. Разница в том, что в этот раз отводить память под новый объект не нужно: остаётся только правильно обновить уже знакомые нам __cxa_exception и __cxa_eh_globals. В конце всё также вызывается _Unwind_SjLj_RaiseException или _Unwind_RaiseException. В самом-самом конце, если мы туда вдруг каким-то чудом попали, вызывается std::terminate.

Казалось бы, после вызова __cxa_rethrow в ассемблере быть ничего не должно, но вот незадача: там есть прыжок на лейбл LBB1_8, после которого идёт провал в код под лейблом __clang_call_terminate. Тот, в свою очередь, вызывает std::terminate. Вероятно, это просто дополнительный safeguard от компилятора, не будем на нём долго задерживаться.

call    __cxa_rethrow@PLT
    jmp     .LBB1_8
    ;...
.LBB1_8:
__clang_call_terminate:
    push    rbp
    mov     rbp, rsp
    call    __cxa_begin_catch@PLT
    call    std::terminate()@PLT

Между лейблами LBB1_6 и LBB1_8 есть ещё немного кода. В него мы "некоторым образом" попадаем в случаях, когда текущая функция не может обработать исключение, и catch-блок нужно искать в других местах, из которых эта самая функция вызывалась. Функция _Unwind_Resume, о которой сейчас мы тоже не будем говорить, отвечает как раз за это. Если она вдруг вернула управление (чего быть не должно), происходит прыжок на метку __clang_call_terminate, а что происходит в ней, мы уже знаем.

.LBB1_6:
    mov     rdi, qword ptr [rbp - 8]
    call    _Unwind_Resume@PLT
    mov     rdi, rax
    call    __clang_call_terminate
.LBB1_8:

Как мы уже видели, функция __cxa_end_catch умеет различать не только C++ исключения и исключения из внешних языков, но ещё и брошенные и переброшенные исключения. Всё для того, чтобы в процессе работы с ними не ломалось ничего, кроме веры разработчика в светлое будущее.

Да, кажется, нужно сделать небольшой recap.

Мы разобрали внутреннюю механику обработки исключений в C++ на примере простого кода. Он включает функцию bar, которая бросает исключение, функцию foo, ловящую его, и main, эту foo вызывающую. Этот код мы перевели в ассемблер с помощью компилятора Clang 21.1.0 для x86-64. Мы проанализировали ассемблерный код и исходники libcxxabi и увидели, как компилятор и runtime реализуют логику броска и поимки исключения, опираясь на Itanium C++ ABI.

В bar бросок исключения преобразуется в два ключевых вызова:

  • __cxa_allocate_exception выделяет память на куче не только под само исключение (в нашем случае — int), но и под заголовок __cxa_exception. Эта структура содержит данные о брошенном исключении: std::type_info, указатель на деструктор, счётчики и _Unwind_Exception. Память выравнивается по платформенным требованиям, заполняется нулями. Если аллокация проваливается, вызывается std::terminate;
  • __cxa_throw, в свою очередь, инициализирует __cxa_exception, обновляет thread-local хранилище __cxa_eh_globals, содержащее стек пойманных исключений и счётчик непойманных. Завершается она вызовом _Unwind_RaiseException или _Unwind_SjLj_RaiseException. Если они вернули управление, то вызывается std::terminate.

Мы также увидели, как поимка исключения раскрывается в несколько вызовов:

  • __cxa_begin_catch получает указатель на _Unwind_Exception, инкрементирует счётчик обработчиков в __cxa_exception, добавляет исключение в стек __cxa_eh_globals и уменьшает счётчик непойманных исключений, после чего возвращает указатель на оригинальное исключение для использования в catch-блоке. Для получения нетривиальных объектов исключения может добавляться вызов функции __cxa_get_exception_ptr;
  • __cxa_end_catch уменьшает счётчик обработчиков, удаляет исключение со стека __cxa_eh_globals, вызывает деструктор и освобождает память.

Если в catch-блоке встречается выражение throw;, то текущее исключение перебрасывается (rethrow). В этом случае в ассемблере появляется вызов функции __cxa_rethrow: она обновляет __cxa_exception и __cxa_eh_globals, а затем вызывает уже знакомые _Unwind_RaiseException или _Unwind_SjLj_RaiseException. Если catch-блока нет, то для продолжения процесса поиска нужного обработчика используется _Unwind_Resume.

Itanium Unwind ABI

При предыдущем рассмотрении подноготной механизма обработки исключений в С++ мы, желая того или нет, встретили несколько странных вещей. Мы видели несколько функций и одну структуру с префиксом _Unwind. У нас остались вопросы о том, как конкретно управление попадает в catch-блок. И что, чёрт возьми, такое этот символ __gxx_personality_v0, который ты, автор, полностью проигнорировал? Давайте разбираться.

Сущность _Unwind_SjLj_RaiseException выглядит страшно, поэтому пока что отложим её в сторону. Из вещей, которые мы уже видели, остаются _Unwind_Exception, _Unwind_RaiseException и _Unwind_Resume. Давайте разберёмся с первым.

Структура _Unwind_Exception фактически нужна для нескольких интересных вещей. Рантайму нужно дать понять, с каким типом исключения он имеет дело: нативным или внешним. Не то чтобы стандарт С++ позволял ловить исключения, происходящие из других языков, но тем не менее у низкоуровневого механизма есть возможности для их обработки. Также на случай, если исключение происходит не из языка, в котором оно ловится, у структуры есть поле exception_cleanup, которое содержит в себе указатель на функцию очистки исключения. Она очистит память, выделенную под такое внешнее исключение. Также в _Unwind_Exception есть два приватных поля, выделенные под нужды рантайма. Что с ними делать, спецификация не рассказывает. Забегая вперёд, что с ними делает LLVM, мы ещё увидим.

Перейдём к _Unwind_RaiseException. Как мы помним, она вызывается из __cxa_throw и __cxa_rethrow и представляет из себя основной драйвер поиска нужного catch-блока и доставки до него исключения. У неё один параметр — указатель на искомый _Unwind_Exception. Как видно из кода, глобально там происходят две вещи: вызов функций unwind_phase1 и unwind_phase2. Мы помним, что сама _Unwind_RaiseException не должна возвращать исполнение. Видимо, после выполнения unwind_phase2 мы больше не в Канзасе.

Также в самом начале функции мы видим следующий код:

unw_context_t uc;
unw_cursor_t cursor;
__unw_getcontext(&uc);

Это обращение к libunwind — библиотеке, которая отвечает за раскрутку стека. Раскрутка стека — это процесс, в котором рантайм последовательно смотрит на содержание стековых фреймов. Он начинает с самого последнего фрейма, в нашем случае это фрейм функции _Unwind_RaiseException, и далее — рекурсивно, проходя по фрейму каждой функции, которая на момент раскрутки ещё не вернула управление. Автор опирается на то, что читателю известно, что такое стековый фрейм. Если нет, то предложим ему исходить из того, что раскрутка стека — это просмотр того, что происходит в конкретный момент исполнения в каждой функции, вызов которой привёл нас к конкретной точке исполнения.

Что ж, смотрим внутрь функции unwind_phase1. Да, количество кода стало резко увеличиваться, но нам нужно обращать внимание только на наиболее интересные моменты. Во-первых, мы видим объявление цикла while (true). В этом цикле мы проходим вверх по стеку, о чём говорит строчка int stepResult = __unw_step(cursor);. Нам интересно объявление переменной unw_proc_info_t frameInfo;.

unw_proc_info_t — это структура, несущая в себе важную для раскрутки стека информацию о текущей функции. Там есть указатели на адреса начала и конца функции, на что-то под названием "language specific data area" и поле handler. В конечном итоге выполнение функции unwind_phase1 сводится к вызову этого самого handler.

_Unwind_Personality_Fn p = get_handler_function(&frameInfo);
//...
_Unwind_Reason_Code personalityResult =(*p)(
    1, _UA_SEARCH_PHASE, exception_object->exception_class,
    exception_object, (struct _Unwind_Context *)(cursor));

Сейчас нам стоит запомнить, что одним из его аргументов является _UA_SEARCH_PHASE, а вернуть он может значения:

  • _URC_HANDLER_FOUND. Тогда unwind_phase1 сохраняет stack pointer последнего отсмотренного фрейма и возвращает управление с кодом успеха;
  • _URC_CONTINUE_UNWIND. Тогда цикл while продолжается на следующем фрейме;
  • или какое-то другое значение, которое, в свою очередь, приводит к возврату кода ошибки из unwind_phase1.

Пока что больше мы ничего сказать не можем. Идём дальше!

А дальше unwind_phase2. Помимо всяких штук для безопасности вроде использования теневого стека, эта фаза делает схожие с первой вещи. Тут также есть unw_proc_info_t и handler. На этот раз handler вызывается либо с аргументом _UA_CLEANUP_PHASE, либо _UA_CLEANUP_PHASE | _UA_HANDLER_FRAME в случае, когда раскрутка дошла до фрейма, который был сохранён при удачном завершении unwind_phase1.

После вызова handler мы либо продолжаем раскрутку стека, либо возвращаемся с ошибкой (при нормальных обстоятельствах этого не должно быть), либо делаем одну очень интересную вещь. Обратите внимание, что происходит в блоке case(_URC_INSTALL_CONTEXT):

__unw_phase2_resume(cursor, framesWalked);
return _URC_FATAL_PHASE2_ERROR;

Здесь идёт вызов __unw_phase2_resume, после которого происходит возврат ошибки _URC_FATAL_PHASE2_ERROR. Складываем два плюс два и приходим к выводу, что __unw_phase2_resume не должен возвращать управление и, скорее всего, там и происходит прыжок, который мы так долго искали!

Сожалеем, но __unw_phase2_resumeмакрос. Мы знаем, никто не любит макросы кроме тех, кто их любит. Нужно немножко потерпеть, скоро это закончится. Имплементация этого макроса зависит от двух вещей: включён ли теневой стек и платформы. Если теневой стек используется, то, о-ля-ля, мы видим ассемблерные вставки под разные платформы. И в этих ассемблерных вставках действительно присутствуют инструкции, производящие прыжок потока исполнения.

Если же дополнительные security прибамбасы нам не нужны, то просто зовётся функция __unw_resume_with_frames_walked, которая зовёт __unw_resume, а та в свою очередь AbstractUnwindCursor::jumpto. Что интересно, jumpto — виртуальная функция! Намедни при чтении исходников автора изрядно позабавило, что виртуальные функции есть даже в такой низкоуровневой библиотеке. Реализацию встречаем чуть ниже. Надо же, шаблоны!

Оттуда мы попадаем в платформенно-зависимую реализацию (на машине автора — x86-64), в которой вызывается __libunwind_Registers_x86_64_jumpto. В этой функции уже нет никаких ассемблерных вставок, ведь она целиком написана на языке ассемблера. И там действительно происходит восстановление контекста искомого фрейма и прыжок туда.

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

Прежде чем мы отправимся узнавать, что это за handler такой, давайте быстренько разберёмся с последней функцией из семейства _Unwind_*, а именно с _Unwind_Resume. Помните, как она появилась, когда мы перебрасывали исключения в catch-блоке?

В функции _Unwind_Resume происходят уже знакомые нам вещи. В целом она напоминает реализацию _Unwind_RaiseException, за тем исключением, что первая фаза не производится, а дело идёт сразу ко второй. Это выглядит логично, ведь нужный handler мы уже нашли, и осталось только двигаться к нему от одного фрейма к другому.

Но появился один нюанс: помимо вызова уже знакомой unwind_phase2 в тексте фигурирует и некая unwind_phase2_forced. Что она форсирует и чем отличается от обычной версии?

Можем немного сжулить и поискать, где ещё, используется эта unwind_phase2_forced. В процессе поиска мы неминуемо наткнёмся на функцию _Unwind_ForcedUnwind. Она очень похожа на _Unwind_RaiseException за тем исключением, что первая фаза не происходит вовсе, а вторая реализована именно через unwind_phase2_forced.

Если заглянуть в документацию имплементации спецификации Itanium ABI, можно увидеть следующий пример работы функции _Unwind_ForcedUnwind:

Процедура setjmp сохраняет состояние для восстановления (включая frame pointer) в своём обычном месте. Процедура longjmp_unwind вызывает _Unwind_ForcedUnwind, передавая ей функцию остановки (stop function), которая сравнивает текущий frame pointer с сохранённым ранее frame pointer.

Это даёт нам небольшую подсказку о происходящем внутри. Функция _Unwind_ForcedUnwind используется в случаях, когда нужно раскрутить стек, но бросать классическое С++ исключение не нужно. Более того, в комментарии к её исходникам говорится, что она не используется (в runtime у — прим. авт.) C++. А где используется?

Например, эта штука может использоваться при выходе из треда. Мы видим это, например, в реализации pthread в библиотеке glibc. Обиталище сей функции находится здесь. Подробный разбор nptl находится далеко за пределами нашей статьи, поэтому на этом остановимся.

Интересный факт: в реализации libstdc++ от GCC в рамках forced unwind используется "особое исключение" __forced_unwind. Таким образом, различные структуры в стандартной библиотеке С++ от этого вендора могут отличать случаи принудительной раскрутки стека от остальных. На самом деле броска исключения не происходит: в personality-рутине просто выставляется соответствующий typeid. Самого С++ исключения, равно как и структуры __cxa_exception, не образуется.

if (actions & _UA_FORCE_UNWIND)
{
    throw_type = &typeid(abi::__forced_unwind);
}
else if (foreign_exception)
{
    throw_type = &typeid(abi::__foreign_exception);
}

Погодите, что за personality-рутина?

Прежде чем мы ответим на этот вопрос, давайте сделаем небольшой recap. Мы углубились в низкоуровневые механизмы обработки исключений в C++ на основе Itanium ABI, сосредоточившись на функциях и структурах из семейства _Unwind_*.

Мы разобрали структуру _Unwind_Exception, которая позволяет отличать нативные исключения от внешних и хранить специфичные для runtime данные. Посмотрели, как _Unwind_RaiseException запускает две фазы раскрутки стека: первую фазу для поиска подходящего handler и вторую фазу для полноценной раскрутки до него. Также мы видели, как прыжок в блок catch происходит через платформенно-зависимый ассемблерный код.

Мы также рассмотрели функцию _Unwind_Resume для повторного броска исключения и функцию _Unwind_ForcedUnwind для принудительной раскрутки стека без использования C++ исключений, что происходит, например, при выходе из треда в pthreads.

В итоге наше исследование привело нас к некоторой новой сущности — personality-рутине.

Вместо заключения

Друзья, мы читали, мы читали, наши глазки подустали. Пора сделать перерыв ненадолго.

Нам ещё предстоит разобраться с тем, что из себя представляет эта personality-рутина, как runtime определяет, попал ли он в правильный catch-блок, и как вызываются деструкторы. Также мы пока что полностью опустили семейство функций _Unwind_SjLj*. В конце концов, мы даже на толику не затронули темы из главы "101 для нетерпеливых": что же это за такие таблицы исключений и SjLj-списки?

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

А пока, как обычно, El Psy Kongroo.

Последние статьи:

Опрос:

book gost

Дарим
электронную книгу
за подписку!

Популярные статьи по теме


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

Следующие комментарии next comments
close comment form