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

Вебинар: Тимлид: ожидания, реальность и внутренние вопросы - 15.04

>
>
>
Как работает выбор catch-блока при...

Как работает выбор catch-блока при обработке исключений

31 Мар 2026

Если таблетка знает, что лечить, может, и исключение само может понять, когда нужно остановиться в своём путешествии по стеку? В прикладном программировании часто хватает и подобного описания, но иногда хочется деталей. Что ж, разберёмся в них!

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

С прошлого раза остался ряд вопросов. Мы оставили за бортом рассмотрение SjLj исключений, а также приостановили описание zero-cost исключений на самом интересном — на personality-рутине. В этой статье стартуем аккурат с того, где закончили, чтобы ответить на вопрос: как исключение понимает, что попало в нужный catch-блок?

Как и в прошлый раз, будем опираться на библиотеку libcxx от LLVM. Компилятор возьмём тот же — Clang 21.1.0 для x86-64.

В предыдущей серии

В рамках нашего путешествия по недрам мы наткнулись на две функции — _Unwind_RaiseException и _Unwind_ForcedUnwind, и подробно рассмотрели алгоритмы их работы. Если вторая, связанная с принудительной раскруткой стека, представляла для нас меньший интерес в связи с отсутствием прямой связи с нашей темой, то первая открыла дорогу к пониманию процесса доставки исключения сквозь стек программы. Она исполнялась в две фазы: поиск нужного catch-блока и непосредственно доставка исключения, сопровождающаяся вызовом деструкторов автоматических переменных.

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

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

В коде переменная frameInfo типа unw_proc_info_t используется, чтобы получить некоторый handler — функцию, которая определяет, когда стоит остановить раскрутку стека. Мы видим, как вызов функции p возвращает некоторый результат. Этот результат используется вызывающим драйвером раскрутки стека для того, чтобы понять, что с этой раскруткой делать дальше.

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

В прошлой статье мы видели некоторый символ __gxx_personality_v0, определение которого появилось в ассемблерном коде, который Clang сгенерировал из нашего тестового проекта. У нас есть стойкое ощущение, что этот символ имеет отношение к handler, но пока что мы не можем доказать это.

Давайте вспомним код этого проекта:

int bar()
{
    throw -1;
}

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

int main()
{
    return foo();
}

Переведём его в ассемблерный код. С ним и будем работать.

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, 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
        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
        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

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

Что если мы возьмём, и просто поищем символ __gxx_personality_v0 в коде библиотеки libcxx? Ну... мы его найдём. Круто... спасибо...

Страшно и непонятно, но самое главное — мы пока что не видим прямой связи между этой функцией и тем самым handler. Что ж, давайте попробуем её установить!

libunwind

Мы достоверно знаем одно: handler достаётся из структуры типа unw_proc_info_t. В ней содержится информация о функции, которая породила текущий стековый фрейм, а также дополнительная информация, необходимая для раскрутки стека.

Сверхбыстрое введение в libunwind

Чтобы проследить связь между personality-рутиной и данной структурой, нам нужно понять, где она заполняется. Для этого придётся залезть внутрь библиотеки libunwind. Эта библиотека нужна для того, чтобы читать стековые фреймы функций. Мы посмотрим на то, как под капотом функционирует инициализация работы с этой библиотекой через её контекст, какая структура данных используется для того, чтобы указывать на стековые фреймы во время раскрутки, и как эти структуры заполняются.

Чтобы двинуться дальше, нам нужно понять базовые правила работы с libunwind. Мы можем сказать библиотеке, какой конкретно фрейм хотим прочитать с помощью структуры типа unw_cursor_t. Это своего рода указатель, который позволяет нам ходить по стеку. Непосредственно информацию о фрейме мы получаем из структуры unw_proc_info_t, её мы уже видели раньше. Сам cursor достаётся из так называемого контекста — структуры типа unw_context_t. С инициализации этой структуры начинается работа с libunwind.

Контекст libunwind

Инициализации работы с libunwind в нашем случае происходит в _Unwind_RaiseException — мы познакомились с этой функцией в прошлой статье. Напомним, что она является входом в драйвер раскрутки стека при броске исключения и вызывается практически сразу после его получения. Происходит это в слое абстракции, описанном функциями семейства _cxa.

Инициализация работы с libunwind в этой функции выглядит следующим образом:

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

Мы видим два новых для нас типа: unw_context_t и unw_cursor_t. Их определения схожи: в них содержатся какие-то данные в массивах с длинами, определяемыми на этапе компиляции. Сказать, что конкретно там лежит, мы не можем, ведь данные хранятся в массиве 64-битных беззнаковых целых чисел. Это значит, что указанные типы — непрозрачные обёртки над какими-то другими структурами, которые, в свою очередь, являются деталями реализации библиотеки. Но мы можем найти, где эти данные заполняются!

Заполнение структуры unw_context_t, как видно из кода всё той же функции _Unwind_RaiseException, происходит в функции __unw_getcontext.

Ух ты, в этот раз до ассемблерного кода мы добрались очень быстро! В нём происходят достаточно прямолинейные вещи, а именно — сохранение состояния регистров в объекте структуры unw_context_t, указатель на которую мы предварительно любезно в эту функцию и передали. Имплементация функции приведена для различных платформ, чтобы правильно заполнять массив внутри этой структуры.

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

Окей, где заполняется структура unw_cursor_t?

Курсор libunwind

_Unwind_RaiseException проходит в две фазы, о которых мы подробно говорили в предыдущей статье. Посмотрим на вступление первой фазы:

static _Unwind_Reason_Code
unwind_phase1(unw_context_t *uc,
              unw_cursor_t *cursor,
              _Unwind_Exception *exception_object)
{
  __unw_init_local(cursor, uc);
  ....
}

Мы видим, как указатели на контекст и курсор передаются сверху, из функции _Unwind_RaiseException. Мы также видим функцию __unw_init_local, в которую передаются эти указатели. В принципе, с этого момента можно сказать "пошла жара", если вам очень хочется. В функции происходит определение типа регистров платформы, на которой всё это безобразие происходит (на самом деле, это определение произошло ещё на этапе компиляции), а также создание нового объекта типа UnwindCursor через оператор placement new аккурат в нашем массиве внутри структуры типа unw_cursor_t.

// Use "placement new" to allocate UnwindCursor in the cursor buffer.
new (reinterpret_cast<UnwindCursor<LocalAddressSpace, REGISTER_KIND> *>(cursor))
    UnwindCursor<LocalAddressSpace, REGISTER_KIND>(
        context, LocalAddressSpace::sThisAddressSpace
    );

Кстати, если помните, с типом UnwindCursor мы уже познакомились в предыдущей статье. Там мы видели, как он отвечает за прыжок в нужное место программы через функцию jumpto. Вот нам и понадобилось познакомится с ним поближе! В любом случае, его конструктор найти тоже не сложно. В нём, по большей части, нам интересно только создание переменной-члена _registers из указателя на тип unw_context_t. Конструктор лежит неподалёку, а сам тип определяется на предыдущем шаге.

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

Информация о процедуре в libunwind

Напомним себе код, с которого началось наше сегодняшнее расследование:

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

Раз уж мы теперь знаем, откуда происходит курсор, то можем постараться выяснить, откуда берётся эта переменная frameInfo.

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

unw_proc_info_t frameInfo;
if (__unw_get_proc_info(cursor, &frameInfo) != UNW_ESUCCESS)
{
  // ....
}

Внутри __unw_get_proc_info происходит приведение указателя на unw_cursor_t к указателю на AbstractUnwindCursor, после чего вызывает один из её методов — getInfo. Мы видели ранее, как экземпляр конкретного типа UnwindCursor создаётся внутри курсора. AbstractUnwindCursor — виртуальный интерфейс к объектам этого класса.

Внутри же метода getInfo происходит копирование существующего внутри класса курсора экземпляра unw_proc_info_t в нашу искомую переменную. Но погодите... Я не помню, чтобы мы видели, как что-то записывалось в этот внутренний член-переменную. Более того, я помню, как в конструкторе она напрямую занулялась.

Что мы пропустили?

Адрес нашего заветного handler не может равняться нулю. Да и из регистров адрес какого-то хендлера появится не может. Согласитесь, до этого момента всё, что мы видели, касалось именно сохранения состояния регистров. Не знаю, как вы, но автор не помнит, чтобы System V ABI — или какой-то иной, раз уж пошло дело — описывал отдельный регистр для какого-то там хэндлера. Значит, это информация лежит в другом месте.

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

И тут нам нужно сделать выбор: разбирать, как происходит парсинг бинарей на GNU/Linux для целей поиска информации для раскрутки стека, или остановиться. Учитывая, что непосредственно тему этой статьи это не затрагивает, думаю, стоит принять волевое решение до таких дебрей не опускаться. Тем не менее разобрать общие моменты всё-таки надо, так как, на минуточку, именно это делает table based исключения, в общем-то, table based.

Обобщение N1

Почему бы не сделать промежуточное обобщение?

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

Для перемещения по стековым фреймам используется структура unw_cursor_t. Курсор — это непрозрачный указатель. На самом деле внутри него скрыт объект класса UnwindCursor. Курсор создаётся прямо в буфере памяти структуры unw_cursor_t на основе контекста.

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

Главный вопрос: если конструктор курсора просто зануляет всё внутри, откуда в нём берётся информация о функции, например, адрес обработчика исключения — personality routine?

ELF, DWARF и прочие ксеносы

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

.cfi директивы

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

Они не исполняют код, но формируют так называемые unwind tables, также известные как exception frames. Самих таблиц в ассемблерном файле нет: они формируются компилятором в финальном бинаре. Увидеть их можно с помощью, например, команды readelf -w.

$ readelf -w main
Contents of the .eh_frame section:

00000000 0000000000000014 00000000 CIE
  Version:               1
  Augmentation:          "zR"
  Code alignment factor: 1
  Data alignment factor: -8
  Return address column: 16
  Augmentation data:     1b
  DW_CFA_def_cfa: r7 (rsp) ofs 8
  DW_CFA_offset: r16 (rip) at cfa-8
  DW_CFA_nop
  DW_CFA_nop

00000018 0000000000000014 0000001c FDE cie=00000000
pc=00000000000010a0..00000000000010c6
  DW_CFA_advance_loc: 4 to 00000000000010a4
  DW_CFA_undefined: r16 (rip)
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop

00000030 0000000000000024 00000034 FDE cie=00000000
pc=0000000000001020..0000000000001090
  DW_CFA_def_cfa_offset: 16
  DW_CFA_advance_loc: 6 to 0000000000001026
  DW_CFA_def_cfa_offset: 24
  DW_CFA_advance_loc: 10 to 0000000000001030
  DW_CFA_def_cfa_expression ; ...
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop

Они позволяют раскрутчику стека восстанавливать состояние стековых фреймов, имея только значение счётчика команд. Выглядит подозрительно похоже на информацию для отладчика в сборках для отладки, потому что так и есть. Отладчик делает очень схожие вещи. Формат таблиц описывается в стандарте DWARF, поэтому рассматриваемый нами способ реализации исключений иногда называется DWARF-based.

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

Нам же интересны несколько конкретных аспектов. Обратите внимание на пролог функции foo (той, что ловит исключение в нашем примере):

foo():
.Lfunc_begin1:
        .loc    1 7 0
        .cfi_startproc
        .cfi_personality 155, DW.ref.__gxx_personality_v0
        .cfi_lsda 27, .Lexception0

Особую ценность для рассмотрения представляют две директивы: .cfi_personality и .cfi_lsda.

Раз мы работаем с LLVM, то обратимся к их документации. В ней мы найдём, помимо прочих интересных вещей, ссылку на другую документацию, как раз описывающую нужные нам .cfi-директивы. Директива .cfi_personality, как видно из описания, описывает, откуда раскрутчику стека брать personality-рутину. Обратите внимание, в нашем коде эта инструкция содержит в себе символ DW.ref.__gxx_personality_v0. Это тот самый символ, происхождение которого для нас оставалось загадкой со времён первой статьи.

Посмотрите на код в самом низу ассемблерного файла:

DW.ref.__gxx_personality_v0:
        .quad   __gxx_personality_v0
        .ident  "clang version 21.1.0
        .section        ".note.GNU-stack","",@progbits
        .addrsig
        .addrsig_sym bar()
        .addrsig_sym __cxa_allocate_exception
        .addrsig_sym __cxa_throw
        .addrsig_sym foo()
        .addrsig_sym __gxx_personality_v0
        .addrsig_sym __cxa_begin_catch
        .addrsig_sym __cxa_end_catch
        .addrsig_sym __clang_call_terminate
        .addrsig_sym _ZSt9terminatev
        .addrsig_sym _Unwind_Resume
        .addrsig_sym _ZTIi
        .section        .debug_line,"",@progbits

Мы видим, как адрес функции __gxx_personality_v0 сохраняется в памяти. Далее идёт объявление секции с символами, которые уже встречались ранее.

Директива в .cfi_lsda, в свою очередь, описывает местоположение language specific data area. Что это? Проследуйте к символу, который находится ближе к концу функцииfoo:

GCC_except_table1:
.Lexception0:
        .byte   255
        .byte   155
        .uleb128 .Lttbase0-.Lttbaseref0
.Lttbaseref0:
        .byte   1
        .uleb128 .Lcst_end0-.Lcst_begin0
; ...

Поздравляю, мы наконец-то нашли то, что называется exception handling tables. Строго говоря, то, что мы видим — это language specific data area (lsda), а exception handling tables уже находятся внутри неё. Этих таблиц несколько, и мы посмотрим на них чуть позже.

Окей, мы всё ещё не знаем, как конкретно работает personality-рутина и lsda, но как минимум мы нашли места, где определяются их символы. В случае с lsda мы также нашли конкретное место её расположения.

Чтение бинарей

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

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

На текущем этапе стоит остановиться. Если мы возьмёмся описывать, как конкретно происходит чтение DWARF-структур, то статья может не закончиться. Выше мы уже привели хорошие ресурсы, которые дадут общее представление о теме. Long story short, всё упирается в функцию getInfoFromFdeCie. Она действительно заполняет поле _info, используя информацию, полученную из unwind tables, в том числе адреса personality-рутины и lsda.

Обобщение N2

Кажется, пришло время промежуточного обобщения.

Когда компилятор превращает C++ код в ассемблер, он вставляет специальные директивы, начинающиеся с .cfi_ (Call Frame Information). Сами по себе эти директивы не выполняются процессором — это указания для компоновщика собрать из этих данных таблицы раскрутки.

Мы видели несколько таких директив:

  • .cfi_personality говорит: "Для этой функции обработчик исключений (personality routine) находится вот по этому адресу";
  • .cfi_lsda указывает, где лежит Language Specific Data Area (LSDA) — персональные инструкции для этой функции, которые позволяют понять, что делать с исключением при рассмотрении конкретного стекового фрейма.

После компиляции все .cfi-директивы превращаются в двоичные структуры, которые можно увидеть командой readelf -w. В них содержатся так называемые .eh_frame — общая информация о том, как раскручивать стек для каждой функции.

Но как конкретно personality-рутина читает lsda?

Personality-рутина, lsda, два вида раскрутки

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

Personality-рутина парсит lsda чтобы понять, что делать с исключением, находясь в контексте конкретного стекового фрейма. Это значит, что она должна уметь парсить стек, чтобы доставать из памяти данные, содержащиеся в lsda. Несмотря на такую низкоуровневую работу, концептуально эта рутина находится достаточно высоко в Itanium ABI — на уровне, определяющем функциональность, зависящую от конкретного языка.

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

Глобально контекстов раскрутки, в которых вызывается personality-рутина, два: бросок исключения и что-то ещё. Инициация первого происходит в функциях _Unwind_RaiseException и _Unwind_SjLj_RaiseException, тогда как за всё остальное (например, раскрутку стека при выходе из треда) отвечает _Unwind_ForcedUnwind. Бросок исключения, в свою очередь, происходит в два этапа: поиск нужного catch-блока и очистка стека. Всё это мы рассматривали в предыдущей статье.

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

Алгоритм personality-рутины

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

Самый простой случай, если во второй фазе мы доехали до того места, которое в первой фазе сами же отметили как фрейм с валидным catch-блоком:

if (actions == (_UA_CLEANUP_PHASE | _UA_HANDLER_FRAME) &&
    native_exception) {
    // Reload the results from the phase 1 cache.
    __cxa_exception* exception_header =
        (__cxa_exception*)(unwind_exception + 1) - 1;
    results.ttypeIndex = exception_header->handlerSwitchValue;
    results.actionRecord = exception_header->actionRecord;
    results.languageSpecificData = exception_header->languageSpecificData;
    set_landing_pad(results, exception_header->catchTemp);
    results.adjustedPtr = exception_header->adjustedPtr;
    set_registers(unwind_exception, context, results);
    if (results.ttypeIndex < 0) {
      exception_header->catchTemp = 0;
    }
    return _URC_INSTALL_CONTEXT;
}

Фактически здесь происходит получение информации, которую рутина приобрела на предыдущей фазе, и возврат значения _URC_INSTALL_CONTEXT. Благодаря ему функция unwind_phase2 поймёт, что пора прыгать в catch-блок. Место приземления можно определить по состоянию курсора, который был передан при вызове рутины и выставлен в функции set_registers.

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

scan_eh_tab(results, actions, native_exception, unwind_exception, context);

Код сразу после написан немного контринтуитивно:

if (results.reason == _URC_CONTINUE_UNWIND ||
    results.reason == _URC_FATAL_PHASE1_ERROR)
    return results.reason;

if (actions & _UA_SEARCH_PHASE)
{
    assert(results.reason == _URC_HANDLER_FOUND);
    if (native_exception) {
        __cxa_exception* exc = (__cxa_exception*)(unwind_exception + 1) - 1;
        exc->handlerSwitchValue = static_cast<int>(results.ttypeIndex);
        exc->actionRecord = results.actionRecord;
        exc->languageSpecificData = results.languageSpecificData;
        get_landing_pad(exc->catchTemp, results);
        exc->adjustedPtr = results.adjustedPtr;
    }
    return _URC_HANDLER_FOUND;
}

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

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

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

После этого остаётся один вариант: мы во второй фазе, и нам нужно что-то сделать в текущем стековом фрейме:

assert(actions & _UA_CLEANUP_PHASE);
assert(results.reason == _URC_HANDLER_FOUND);

Чем это может быть? Учитывая, что в валидный catch-блок мы прыгнули бы в самом начале функции, то остаются два варианта: либо вызвать деструкторы стековых переменных, либо обработать exception specifications:

set_registers(unwind_exception, context, results);
if (results.ttypeIndex < 0) {
  __cxa_exception* exception_header =
        (__cxa_exception*)(unwind_exception + 1) - 1;
  exception_header->catchTemp = 0;
}
return _URC_INSTALL_CONTEXT;

Для наших целей достаточно последний строки. Значение _URC_INSTALL_CONTEXT говорит рантайму, что нужно передать управление в том место, которое нашла рутина. Это место называется landing pad и представляет собой код, который обрабатывает или исключение, или exception specification, или вызывает деструкторы.

Структура lsda

Парсинг lsda в LLVM происходит в функции scan_eh_tab. Её работу разобрать интересно, но вместо этого видится более практичным объяснить с различной степенью наглядности, что конкретно находится в этой lsda.

Lsda включает exception handling tables, как мы выяснили ранее. Название как будто бы подсказывает, что таблиц этих несколько. Действительно, спецификация подтверждает наличие нескольких таблиц.

Первая — call sites table — список записей, содержащих в себе указатели на начало и конец последовательности инструкций, внутри которых находится та инструкция, из которой вылетело исключение. Имея адрес этой инструкции (раскрутчик его восстанавливает из DWARF таблиц, которые мы рассмотрели выше), можно понять, какую запись нужно использовать. Сам call site представляет собой код, находящейся либо внутри try-блока, либо вне его. Его можно назвать своего рода критической секцией, в отношении которой применяются отдельные действия во время раскрутки стека. Список возможных действий идёт после call sites table, а на нужное действие указывает индекс, который хранится вместе с указателями на начало и конец конкретного call site.

Сразу после call site table идёт action table — список действий, относящихся к тому или иному call site. Хотя она и называется таблицей, фактически представляет собой связанный список. Помимо указателя на следующую ноду, каждый элемент содержит в себе так называемый type index — многофункциональное поле. Оно имеет целый тип. Если он равен нулю, значит, в текущем стековом фрейме происходит вызов деструкторов. Управление нужно передать в код, содержащий вызовы, которые для нас сгенерировал компилятор. Если он больше нуля, значит, речь идёт о catch-блоке, и нужно проверить, соответствует ли тип catch-блока типу исключения. В таком случае число буквально является индексом в таблицу типов, которые поддерживаются catch-блоками этого фрейма (сама таблица идёт после текущей). Если индекс отрицательный, значит, речь идёт об exception specification, и нужно понять, может ли функция бросать исключение такого типа.

После action table находится type table — таблица, содержащая ссылки на std::type_info типов, поддерживаемых catch-блоками текущего фрейма. Эта таблица как раз индексируется положительными индексами, содержащимися в нодах связанного списка action table.

Где-то рядом — в зависимости от реализации — находится список ссылок на std::type_info типов, перечисленных в exception specification. Если вдруг вы пользуетесь старыми стандартами, позволяющими использование этих спецификаций, то обработка неподходящих типов будет происходить именно тут. Логика обработчика будет инвертирована: в сгенерированном коде, в который приземляется исключение при нарушении спецификации, будет вызываться __cxa_call_unexpected. В ней, в конечном итоге, происходят вещи, аналогичные ныне почившей функции std::unexpected.

Структура этого безобразия выглядит примерно так:

========================
Lsda header
LPStart Encoding
LPStart (optional)
TType Encoding
TType Offset (optional)
Call-Site Encoding
Call-Site Table Length
========================
Call-Site Table
------------------------
start of a call site
length of a call site
landingPad (handler) offset (from landingPad base)
actionEntry (1-based offset)
------------------------
------------------------
start of a call site
length of a call site
landingPad (handler) offset (from landingPad base)
actionEntry (1-based offset)
------------------------
========================
Action Table
------------------------
ttypeIndex = 3
nextOffset = -3 
------------------------
------------------------
ttypeIndex = 2
nextOffset = -3 
------------------------
------------------------
ttypeIndex = 1
nextOffset = 0
------------------------
========================
Type Table (RTTI)
------------------------
index 1 ──> typeinfo(float)
------------------------
------------------------
index 2 ──> typeinfo(int)
------------------------
------------------------
index 3 ──> typeinfo(T)
------------------------
========================

Обобщение N3

Такие дела. Время обобщения!

Personality-рутина знает, как парсить lsda. Lsda, в свою очередь, содержит специфическую для каждого языка информацию о том, что должно происходить при броске исключения в конкретном стековом фрейме. Lsda, соответственно, генерируется компилятором и присоединяется к телу функции.

Сама personality-рутина вызывается из функций семейства _Unwind*, которые представляют собой драйверы раскрутки стека. Эти функции являются слоем, независимым от конкретного языка программирования. Таким образом, рутина является колбеком, логически относящимся к слою, специфическому для конкретного языка.

Также мы узнали о сущности под названием landing pad. Фактически это обобщение над catch-блоком, который, помимо самих исключений, может обрабатывать exception specifications и ситуации вызова деструкторов.

А как, собственно, этот landing pad работает?

Последняя миля и landing pad

Немного модифицируем код для иллюстративных целей:

#include <stdlib.h>

int bar() throw (int)
{
    return (rand() % 2) ? throw -1 : -666;
}

int foo()
{
    try {
        return bar();
    }
    catch(float) {
        return 69;
    }
    catch(int) {
        return 42;
    }
    catch (...) {
        //skiped intentionally
    }
    return 666;
}

int main()
{
    return foo();
}

Ассемблер можно посмотреть ниже. По ссылке лежит то же самое, но с дополнительными директивами компилятора.

bar():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        call    rand@PLT
        mov     ecx, 2
        cdq
        idiv    ecx
        mov     byte ptr [rbp - 9], 0
        cmp     edx, 0
        je      .LBB0_2
        mov     edi, 4
        call    __cxa_allocate_exception@PLT
        mov     rdi, rax
        mov     qword ptr [rbp - 8], rdi
        mov     byte ptr [rbp - 9], 1
        mov     dword ptr [rdi], -1
        mov     rsi, qword ptr [rip + typeinfo for int@GOTPCREL]
        xor     eax, eax
        mov     edx, eax
        call    __cxa_throw@PLT
        jmp     .LBB0_8
.LBB0_2:
        jmp     .LBB0_3
.LBB0_3:
        mov     eax, 4294966630
        add     rsp, 32
        pop     rbp
        ret
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 24], rcx
        mov     dword ptr [rbp - 28], eax
        cmp     dword ptr [rbp - 28], 0
        jge     .LBB0_7
        mov     rdi, qword ptr [rbp - 24]
        call    __cxa_call_unexpected@PLT
.LBB0_7:
        mov     rdi, qword ptr [rbp - 24]
        call    _Unwind_Resume@PLT
.LBB0_8:

foo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        call    bar()
        mov     dword ptr [rbp - 32], eax
        jmp     .LBB1_1
.LBB1_1:
        mov     eax, dword ptr [rbp - 32]
        mov     dword ptr [rbp - 4], eax
        jmp     .LBB1_9
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 16], rcx
        mov     dword ptr [rbp - 20], eax
        mov     eax, dword ptr [rbp - 20]
        mov     dword ptr [rbp - 36], eax
        mov     ecx, 3
        cmp     eax, ecx
        jne     .LBB1_5
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        movss   xmm0, dword ptr [rax]
        movss   dword ptr [rbp - 28], xmm0
        mov     dword ptr [rbp - 4], 69
        call    __cxa_end_catch@PLT
        jmp     .LBB1_9
.LBB1_5:
        mov     eax, dword ptr [rbp - 36]
        mov     ecx, 2
        cmp     eax, ecx
        jne     .LBB1_7
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        mov     eax, dword ptr [rax]
        mov     dword ptr [rbp - 24], eax
        mov     dword ptr [rbp - 4], 42
        call    __cxa_end_catch@PLT
        jmp     .LBB1_9
.LBB1_7:
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        call    __cxa_end_catch@PLT
        mov     dword ptr [rbp - 4], 666
.LBB1_9:
        mov     eax, dword ptr [rbp - 4]
        add     rsp, 48
        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

Сначала рассмотрим этот небольшой момент с функцией bar:

.LBB0_3:
        mov     eax, 4294966630
        add     rsp, 32
        pop     rbp
        ret
        mov     rcx, rax
        mov     eax, edx
        mov     qword ptr [rbp - 24], rcx
        mov     dword ptr [rbp - 28], eax
        cmp     dword ptr [rbp - 28], 0
        jge     .LBB0_7
        mov     rdi, qword ptr [rbp - 24]
        call    __cxa_call_unexpected@PLT

Он интересен тем, что представляет собой обработку exception specifications. Если эта функция попробует выкинуть исключение типа, отличного от обозначенного в спецификации, то personality-рутина, прочитав lsda, скажет раскрутчику стека передать управление именно сюда. В обычной ситуации вызов __cxa_call_unexpected ваша программа, скорее всего, не переживёт.

Второй интересный момент находится в функции foo. Он как раз отвечает за определение конкретного catch-блока, в котором происходит обработка исключения. Видите ли, lsda вернёт рантайму указатель на landing pad, но landing pad представляет собой обработчик в целом. В случае с catch-блоком это начало секции catch-блоков, идущих после определённого try-блока, но не конкретный обработчик.

Возьмём, к примеру, обработчик исключения типа int:

.LBB1_5:
        mov     eax, dword ptr [rbp - 36]
        mov     ecx, 2
        cmp     eax, ecx
        jne     .LBB1_7
        mov     rdi, qword ptr [rbp - 16]
        call    __cxa_begin_catch@PLT
        mov     eax, dword ptr [rax]
        mov     dword ptr [rbp - 24], eax
        mov     dword ptr [rbp - 4], 42
        call    __cxa_end_catch@PLT
        jmp     .LBB1_9
; ...
.LBB1_9:
        mov     eax, dword ptr [rbp - 4]
        add     rsp, 48
        pop     rbp
        ret

Помимо уже привычных вещей вроде вызова __cxa_begin_catch и __cxa_end_catch мы можем увидеть с первого взгляда необычный код:

        mov     eax, dword ptr [rbp - 36]
        mov     ecx, 2
        cmp     eax, ecx
        jne     .LBB1_7

В этом коде происходит условный прыжок, если что-то не равно числу 2. 2 чего? Почему не 3?

Вспомните, как в разделе выше мы разбирали таблицы внутри lsda. В ней была type table, которая индексировалась числами, содержащимися в записях action table. Так вот, 2 в нашем ассемблерном коде — это этот индекс, который рантайм услужливо сохранил внутри брошенного исключения (в структуре __cxa_exception) при вызове personality-рутины. Если бы изначально бросалось исключение другого типа, но всё ещё попадающее в один из присутствующих catch-блоков, то в структуре был бы сохранён индекс с другим значением, определяющим нужный тип.

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

Это конец?

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

Всё ещё остаётся незатронутым вопрос о том, как же работают исключения поверх механизмаsetjump / longjump. Пожалуй, звучит как неплохой повод вернуться к этой теме в будущем!

Если у вас появилось, что написать в комментариях, то мне будет невероятно приятно это почитать! Надеюсь, там и увидимся!

El Psy Kongroo.

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

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

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