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

В прошлый раз мы рассмотрели, как исключение кидается, как ловится и что связывает два этих момента. Мы видели функции семейств __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. Что ж, давайте попробуем её установить!
Мы достоверно знаем одно: handler достаётся из структуры типа unw_proc_info_t. В ней содержится информация о функции, которая породила текущий стековый фрейм, а также дополнительная информация, необходимая для раскрутки стека.
Чтобы проследить связь между personality-рутиной и данной структурой, нам нужно понять, где она заполняется. Для этого придётся залезть внутрь библиотеки libunwind. Эта библиотека нужна для того, чтобы читать стековые фреймы функций. Мы посмотрим на то, как под капотом функционирует инициализация работы с этой библиотекой через её контекст, какая структура данных используется для того, чтобы указывать на стековые фреймы во время раскрутки, и как эти структуры заполняются.
Чтобы двинуться дальше, нам нужно понять базовые правила работы с libunwind. Мы можем сказать библиотеке, какой конкретно фрейм хотим прочитать с помощью структуры типа unw_cursor_t. Это своего рода указатель, который позволяет нам ходить по стеку. Непосредственно информацию о фрейме мы получаем из структуры unw_proc_info_t, её мы уже видели раньше. Сам cursor достаётся из так называемого контекста — структуры типа unw_context_t. С инициализации этой структуры начинается работа с 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?
_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. Её рассмотрение находится немного за пределами скоупа этой статьи, так что оставим её в качестве домашнего задания для самых пытливых умов.
Напомним себе код, с которого началось наше сегодняшнее расследование:
_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.
Почему бы не сделать промежуточное обобщение?
Прежде чем двигаться по стеку, нужно зафиксировать текущее состояние процессора — значения всех его регистров. Для этого используется структура unw_context_t. Она заполняется с помощью функции __unw_getcontext, которая написана на языке ассемблера. Её задача — просто скопировать текущие значения регистров в эту структуру.
Для перемещения по стековым фреймам используется структура unw_cursor_t. Курсор — это непрозрачный указатель. На самом деле внутри него скрыт объект класса UnwindCursor. Курсор создаётся прямо в буфере памяти структуры unw_cursor_t на основе контекста.
Для получения данных о функции, которая породила интересующий нас фрейм, используется структура unw_proc_info_t. Она заполняется функцией __unw_get_proc_info. Эта функция берёт наш курсор и извлекает из его внутренних данных (поля _info) информацию о текущей функции.
Главный вопрос: если конструктор курсора просто зануляет всё внутри, откуда в нём берётся информация о функции, например, адрес обработчика исключения — personality routine?

Для того, чтобы лучше сориентироваться в проблеме, стоит немного расширить тот ассемблерный код, который мы получили в самом начале из нашего тестового примера. Это не значит, что мы будем что-то добавлять в изначальные исходники на плюсах, о нет. Скорее, мы посмотрим на полную версию этого ассемблерного кода, содержащую не только код, непосредственно относящийся к нашим исходникам на плюсах, но и различные дополнительные директивы. Код приводить в тексте статьи не будем, так как уж слишком он получился объёмным. Приведём отдельные выкладки ниже, а полный код оставим здесь.
За счёт дополнительной обслуживающей информации кода стало намного больше. Нам интересны директивы семейства .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.
Кажется, пришло время промежуточного обобщения.
Когда компилятор превращает C++ код в ассемблер, он вставляет специальные директивы, начинающиеся с .cfi_ (Call Frame Information). Сами по себе эти директивы не выполняются процессором — это указания для компоновщика собрать из этих данных таблицы раскрутки.
Мы видели несколько таких директив:
.cfi_personality говорит: "Для этой функции обработчик исключений (personality routine) находится вот по этому адресу";.cfi_lsda указывает, где лежит Language Specific Data Area (LSDA) — персональные инструкции для этой функции, которые позволяют понять, что делать с исключением при рассмотрении конкретного стекового фрейма.После компиляции все .cfi-директивы превращаются в двоичные структуры, которые можно увидеть командой readelf -w. В них содержатся так называемые .eh_frame — общая информация о том, как раскручивать стек для каждой функции.
Но как конкретно personality-рутина читает lsda?

Сложность personality-рутины заключается в том, что она может делать много очень похожих вещей в похожих сценариях, которые, тем не менее, нужно различать. Добавьте к этому разные способы раскрутки стека и исключения, потенциально происходящие их других языков, и получите Франкенштейна. Тем не менее финальная цель у всех этих частей одна. От неё и оттолкнёмся.
Personality-рутина парсит lsda чтобы понять, что делать с исключением, находясь в контексте конкретного стекового фрейма. Это значит, что она должна уметь парсить стек, чтобы доставать из памяти данные, содержащиеся в lsda. Несмотря на такую низкоуровневую работу, концептуально эта рутина находится достаточно высоко в Itanium ABI — на уровне, определяющем функциональность, зависящую от конкретного языка.
В прошлой статье мы видели интерфейс из семейства _cxa*, который отвечал за аллокацию, бросок и поимку исключений. Эта функциональность относится только к языку С++ и сама по себе не относится к раскрутке стека, которая концептуально находится ниже неё. Personality-рутина относится к тому же уровню, на котором находится семейство _cxa*, и служит своего рода колбеком, который вызывается другими подсистемами при раскрутке стека.
Глобально контекстов раскрутки, в которых вызывается personality-рутина, два: бросок исключения и что-то ещё. Инициация первого происходит в функциях _Unwind_RaiseException и _Unwind_SjLj_RaiseException, тогда как за всё остальное (например, раскрутку стека при выходе из треда) отвечает _Unwind_ForcedUnwind. Бросок исключения, в свою очередь, происходит в два этапа: поиск нужного catch-блока и очистка стека. Всё это мы рассматривали в предыдущей статье.
За определение конкретного контекста отвечает перечисление _Unwind_Action. Из него видно, что во время выполнения рутины во второй фазе броска исключения могут произойти две вещи: либо очистка текущего фрейма, либо передача в catch-блок, который был предварительно найден во время первой фазы. Также отдельно предусматривается вариант окончания стека для случаев раскрутки без броска исключения.
Ну ладно, деваться некуда, смотрим код этой рутины. Давайте пока что не вдаваться в подробности парсинга 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 в 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)
------------------------
========================
Такие дела. Время обобщения!
Personality-рутина знает, как парсить lsda. Lsda, в свою очередь, содержит специфическую для каждого языка информацию о том, что должно происходить при броске исключения в конкретном стековом фрейме. Lsda, соответственно, генерируется компилятором и присоединяется к телу функции.
Сама personality-рутина вызывается из функций семейства _Unwind*, которые представляют собой драйверы раскрутки стека. Эти функции являются слоем, независимым от конкретного языка программирования. Таким образом, рутина является колбеком, логически относящимся к слою, специфическому для конкретного языка.
Также мы узнали о сущности под названием landing pad. Фактически это обобщение над catch-блоком, который, помимо самих исключений, может обрабатывать exception specifications и ситуации вызова деструкторов.
А как, собственно, этот 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