Привет, Хабр!

Откройте почти любую статью про FFI на Rust. С вероятностью девять из десяти там написано: «паника, вышедшая за границу с C, — это неопределённое поведение». Эту фразу повторяют пять лет, я и сам её цитировал, в том числе в одной из прошлых статей. Она перестала быть правдой почти два года назад: с Rust 1.81, вышедшего в сентябре 2024-го, такая паника гарантированно и предсказуемо завершает процесс.

Причём это не единственный протухший совет. Второй я поймал на себе буквально недавно. Есть присказка, живущая в статьях лет десять: поставь panic = "abort" — и бинарник похудеет процентов на десять. Написана она в релиз-нотах 1.10 за июль 2016 года, я повторил её в чужом пулреквесте, а потом сообразил, что ни разу не проверял. Собрал одну программу тремя способами, посмотрел в size -A и получил шесть десятых процента вместо десяти. Причём в сборке, где раскрутки быть не может по определению, преспокойно лежали одиннадцать килобайт таблиц, существующих исключительно ради раскрутки.

Но всё это финал истории, а начинается она с того, что паника — вовсе не «программа крякнулась». Это полноценный механизм исключений на той же машинерии, что и в C++. Раскрутка идёт не в один проход, а в два. Каждое исключение метится восемью байтами b"MOZ\0RUST", доставшимися в наследство от Mozilla, где Rust когда-то жил. catch_unwind ловит не через personality routine, как принято думать, а через отдельный компиляторный интринсик. А ещё есть дыра, про которую почти не пишут, потому что вылезает она только при раскрутке между двумя независимо собранными копиями стандартной библиотеки — и защищает от неё канарейка, живущая прямо внутри исключения.

Куда делись проценты

Вот что показали три сборки одной и той же программы:

                              весь файл    .text     .eh_frame   .gcc_except_table
panic = "unwind" (дефолт)       448 792    252 835      20 984         11 096
panic = "abort"                 446 016    251 683      20 660         10 964
abort + force-unwind-tables=no  445 888    251 683      20 564         10 964

Между первой строкой и последней 2904 байта на бинарник в 449 килобайт. Таблицы обработчиков при этом сдвинулись на сто с небольшим байт и никуда не делись.

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

// lib.rs
pub struct Conn(pub String);
impl Drop for Conn {
    fn drop(&mut self) { if self.0.is_empty() { println!("x"); } }
}

#[inline(never)]
pub fn work(n: usize) -> usize {
    let c = Conn(format!("conn-{n}"));
    let v: Vec<String> = (0..n).map(|i| format!("{i}")).collect();
    let s: usize = v.iter().map(|x| x.len()).sum();
    if s == usize::MAX { panic!("nope {}", c.0); }
    s
}
rustc --crate-type=lib -O -Cpanic=unwind --emit=obj -o o-unwind.o lib.rs
rustc --crate-type=lib -O -Cpanic=abort  --emit=obj -o o-abort.o  lib.rs
size -A o-unwind.o | grep -E '^\.(text|eh_frame|gcc_except)'

Тут стало видно всё сразу. При unwind:

.text..work                             736
.gcc_except_table..work                  60
.text..drop_glue::<Vec<String>>         130
.text..drop_glue::<Conn>                104
.gcc_except_table..drop_glue::<Conn>     12
.eh_frame                               320

При abort:

.text..work                             692
.eh_frame                               144

Таблицы обработчиков исчезли полностью, это ожидаемо. А вот чего я не ожидал: пропали обе функции drop glue, ещё 234 байта. Они существовали только ради пути очистки. В счастливом сценарии компилятор дропает поля прямо по месту, а отдельная внеконтурная функция нужна была раскрутчику, которому надо прыгнуть в неё из landing pad. Убрали раскрутку — надобность отпала.

По моему коду экономия вышла процентов сорок. По готовому бинарнику — ноль целых шесть. Те двадцать килобайт .eh_frame и одиннадцать килобайт .gcc_except_table принадлежат не мне, а стандартной библиотеке, которая приезжает с рустапом уже собранной в режиме unwind. Флаг в моём Cargo.toml на неё не влияет никак.

Второе наблюдение из того же дампа: .eh_frame при abort не исчезла, а всего лишь ужалась с 320 байт до 144, и добавление -Cforce-unwind-tables=no довело её до 104.

Почему таблицы вернулись

Бэктрейсы при panic=abort работали в Rust 1.22 и сломались в 1.23, когда компилятор перестал генерировать unwind-таблицы в этом режиме. Ломались они не абы как, раскрутчик на Linux ходит по стеку именно по этим таблицам, а бэктрейс печатает дефолтный панический хук.

Нет таблиц — нет бэктрейса, и человек с abort в релизном профиле получал аварийное завершение без единой строчки о том, где оно случилось. В 1.45 приделали костыль -Cforce-unwind-tables=yes.

Прожив так 8 лет, в Rust 1.92 от декабря 2025 года решение развернули: unwind-таблицы генерируются по умолчанию даже при panic=abort.

Поэтому если ставите abort ради размера, одного этого больше не хватает:

[profile.release]
panic = "abort"
RUSTFLAGS="-Cforce-unwind-tables=no" cargo build --release

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

cargo +nightly build --release \
  -Zbuild-std=std,panic_abort \
  -Zbuild-std-features=panic_immediate_abort \
  --target x86_64-unknown-linux-gnu

panic_immediate_abort выкидывает не только раскрутку, но и всю машинерию форматирования панических сообщений, а она в маленьких бинарниках весит больше самих таблиц.

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

Разобравшись, что именно исчезает из бинарника, логично посмотреть, откуда оно там берётся.

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

Всё начинается в MIR. На каждый вызов, который потенциально может запаниковать, компилятор строит две дороги: основную и путь очистки. Смотреть можно прямо на стабильном канале:

rustc --crate-type=lib --emit=mir -o out.mir lib.rs

кусок вывода для функции с одним String и одним типом с деструктором:

bb0: {
    _3 = String::new() -> [return: bb1, unwind continue];
}
bb1: {
    _2 = C(move _3);
    _4 = g(copy _1) -> [return: bb2, unwind: bb6];
}
bb3: {
    _7 = AddWithOverflow(copy _4, copy _5);
    assert(!move (_7.1: bool), "attempt to compute `{} + {}`, which would overflow",
           copy _4, move _5) -> [success: bb4, unwind: bb6];
}
bb6 (cleanup): {
    drop(_2) -> [return: bb7, unwind terminate(cleanup)];
}

Тут интересно каждое слово.

unwind continue означает «здесь дропать нечего, пропусти фрейм дальше». unwind: bb6 — ссылка на блок очистки, тот самый будущий landing pad, внутри которого идут дропы живых локалей в обратном порядке создания.

А unwind terminate(cleanup) в конце блока очистки отвечает на вопрос, что делать, если паника случится прямо во время уборки. В скобках стоит причина, и причин всего две. Чтобы увидеть вторую, соберём ту же функцию с extern "C":

_3 = String::new() -> [return: bb1, unwind terminate(abi)];
bb7 (cleanup): {
    terminate(abi);
}

terminate(abi) означает «раскрутка попыталась пересечь границу ABI, которая её не допускает». Ставит эти терминаторы отдельный проход MIR с говорящим именем AbortUnwindingCalls: он обходит все вызовы, которые могут размотаться, и подменяет их блок очистки на блок с аварийным завершением. Именно этот проход превращает обещание «в режиме abort ни одна Rust-функция не разматывается» в машинный код, и он же отвечает за поведение extern "C", до которого мы дойдём.

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

Дальше по конвейеру каждое такое ребро разворачивается в landing pad на уровне LLVM-IR и в реальный кусок машинного кода рядом с основной функцией, а рядом ложатся те самые таблицы, по которым раскрутчик потом ищет, какой landing pad какому диапазону адресов соответствует.

Раз вся эта обвязка существует всегда, естественно спросить: во что она обходится, когда паники нет?

Сколько стоит паника: 1,5 наносекунды и 2,7 микросекунды

Я накидал небольшой бенчмарк и померил три вещи: обычный Result с ошибкой, панику с перехватом через catch_unwind и catch_unwind вокруг кода, который ни разу не паникует.

use std::hint::black_box;
use std::panic::{self, AssertUnwindSafe};
use std::time::Instant;

#[inline(never)]
fn via_result(x: u64) -> Result<u64, String> {
    if x % 2 == 0 { Err(format!("bad {x}")) } else { Ok(x * 2) }
}

#[inline(never)]
fn via_panic(x: u64) -> u64 {
    if x % 2 == 0 { panic!("bad {x}") }
    x * 2
}

fn main() {
    let n: u64 = 200_000;
    panic::set_hook(Box::new(|_| {}));   // глушим печать, меряем механизм

    let t = Instant::now();
    for i in 0..n { let _ = black_box(via_result(black_box(i))); }
    let d_res = t.elapsed();

    let t = Instant::now();
    for i in 0..n {
        let _ = panic::catch_unwind(AssertUnwindSafe(|| black_box(via_panic(black_box(i)))));
    }
    let d_panic = t.elapsed();

    let t = Instant::now();
    for i in 0..n {
        let _ = panic::catch_unwind(AssertUnwindSafe(|| black_box(via_panic(black_box(i | 1)))));
    }
    let d_happy = t.elapsed();

    println!("{:.1} / {:.1} / {:.1} нс на итерацию",
        d_res.as_nanos() as f64 / n as f64,
        d_panic.as_nanos() as f64 / n as f64,
        d_happy.as_nanos() as f64 / n as f64);
}

Цифры:

Result, половина вызовов с ошибкой:        ~38–46 нс на итерацию
catch_unwind, половина вызовов паникует:  ~1370 нс на итерацию
catch_unwind, паник нет вообще:              ~1,5 нс на итерацию

Отсюда две вещи.

Первая: catch_unwind на счастливом пути стоит полторы наносекунды. Это буквально ничего, и страшилки про «обёртка в catch_unwind замедлит горячий цикл» можно закрывать. Дорого не поставить перехватчик, дорого через него пролететь.

Вторая: одна паника с раскруткой обходится примерно в 2,7 микросекунды. Это в 30 раз дороже, чем вернуть Result с ошибкой, причём в Result-варианте ещё и String аллоцируется на каждой ошибке. Вот вам количественный ответ на вопрос, почему паника не годится как механизм управления потоком.

Откуда берутся микросекунды, станет понятно, как только посмотрим, что раскрутчик делает со стеком.

Раскрутка не идёт по стеку. Она идёт по нему дважды

Когда вы пишете panic!, рантайм зовёт UnwindRaiseException. Эта функция не бежит по кадрам прогонять деструкторы. Она проходит стек два раза.

Первый проход — разведка. Раскрутчик шагает по кадрам и у каждого спрашивает personality routine: ты ловишь это исключение? Деструкторы не запускаются, стек не трогается, идёт чистый опрос. Как только кто-то отвечает «ловлю», проход заканчивается.

Найденный кадр надо запомнить, и вот тут пригождаются те самые private-поля в структуре исключения. Раскрутчик кладёт туда адрес кадра-обработчика, чтобы во втором проходе понять, где остановиться. Не запас на будущее, а рабочая память между фазами.

Второй проход — уборка. Раскрутчик начинает заново, с самого верха, и теперь на каждом кадре реально прогоняет дропы через landing pad. Дойдя до запомненного кадра, он передаёт туда управление, и на этом всё: в Rust этим кадром оказывается catch_unwind, где из исключения достают payload.

Зачем ходить дважды? Чтобы иметь возможность не начинать. Если в первом проходе обработчик не нашёлся, второго не будет вовсе — программа умрёт на нетронутом стеке. В отладчике вы увидите картину на момент аварии, а не огрызок после того, как половину кадров размотали. В Rust это выглядит так: UnwindRaiseException возвращает код ошибки, хотя по контракту возвращаться не должна никогда, и std роняет процесс с сообщением failed to initiate panic.

Во что это обходится

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

                 глубина 0    глубина 50   глубина 200
с деструкторами    3,53 мкс     43,21 мкс    165,58 мкс
без деструкторов   2,73 мкс     16,96 мкс     58,31 мкс

Рост линейный по глубине — ровно то, что предсказывает модель с двумя обходами. Наклон даёт примерно 810 наносекунд на кадр, если в нём есть что дропать, и 278 наносекунд, если дропать нечего.

Вторая цифра поинтереснее. Почти триста наносекунд за кадр, в котором не происходит ничего. Обычный вызов функции стоит около наносекунды.

Уходит это на разбор метаданных. По каждому кадру надо найти запись с описанием раскрутки, а это двоичный поиск по таблице. Потом её надо выполнить: описание восстановления регистров — это байткод, который интерпретируется. Потом personality разбирает таблицу диапазонов, где числа закодированы побайтово. И всё это по два раза, по разу на проход.

Отсюда и микросекунды. Раскрутка не прыгает по адресам, она на каждом кадре читает и интерпретирует таблицы.

В обычной программе до сценария «обработчика нет» дело не доходит, и вот почему.

Ваш main обёрнут в catch_unwind, и написали это не вы

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

21: std::sys::backtrace::__rust_begin_short_backtrace::<fn(), ()>
22: std::rt::lang_start::<()>::{closure#0}
...
29: std::panicking::catch_unwind::<isize, std::rt::lang_start_internal::{closure#0}>
30: std::panic::catch_unwind::<std::rt::lang_start_internal::{closure#0}, isize>
31: std::rt::lang_start_internal
32: main

Ваш main не вызывается напрямую. Между ним и настоящей точкой входа сидит lang_start_internal, и он оборачивает всё содержимое программы в catch_unwind. Это тот самый обработчик, который в первой фазе отвечает «ловлю» и не даёт раскрутке уйти в никуда.

Он же объясняет код возврата. Паника в главном потоке не роняет процесс по SIGABRT, она доезжает до этой обёртки, та возвращает Err, и рантайм завершает программу с кодом 101. Обычный выход, просто с необычным числом.

С потоками устроено так же. thread::spawn заворачивает ваше замыкание в свой catch_unwind, и вот почему паника в потоке убивает поток, а не программу. Причём тот самый Box<dyn Any + Send>, который вы отдали в panic!, никуда не пропадает, обёртка складывает его и возвращает вам через join() как Err. Вы буквально получаете обратно свой payload, проехавший через весь раскрутчик.

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

И раз уж речь о бэктрейсе, снимается он в момент panic!(), до начала раскрутки, в panic_with_hook. Иначе никак, после второй фазы половины стека физически уже нет.

Теперь посмотрим, что именно летит по этому стеку.

MOZ\0RUST: восемь байт, которые недавно научились читаться

Каждое исключение в Itanium ABI несёт восьмибайтовый идентификатор класса, и байты эти поделены не абы как. Первые четыре — производитель, последние четыре — язык. У GCC получается GNUC плюс C++\0. У Rust:

// library/panic_unwind/src/gcc.rs
const RUST_EXCEPTION_CLASS: uw::_Unwind_Exception_Class
    = u64::from_ne_bytes(*b"MOZ\0RUST");

Производитель MOZ, добитый нулём до четырёх байт, и язык RUST. Так что нуль внутри — не разделитель, а просто padding в поле, которое короче отведённого места. Mozilla, где Rust когда-то жил, до сих пор прописана в заголовке каждого исключения.

Нужна эта метка ровно для одного: понять, чьё исключение приехало. Раскрутка ходит по стеку без разбора языков, и в один кадр Rust вполне может прилететь исключение C++. Personality сравнивает эти восемь байт со своей константой, и если не совпало — трогать содержимое нельзя, можно только прогнать свои деструкторы и пропустить гостя дальше.

А ещё недавно эти восемь байт стало видно в дампах. Раньше константа была записана шестнадцатеричным литералом и на машине с обратным порядком байт ложилась в память задом наперёд: в дампе вместо MOZ\0RUST вы видели TSUR\0ZOM. В Rust 1.83 от 28 ноября 2024 года её переписали на from_ne_bytes — специально, чтобы класс исключения читался глазами.

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

gdb -q ./target/debug/my_app
(gdb) break _Unwind_RaiseException
(gdb) run
(gdb) x/8c $rdi        # первый аргумент на x86-64 SysV едет в rdi

Увидите M O Z \000 R U S T. На компиляторе до 1.83 те же байты лежали бы наоборот.

А собирается исключение перед отправкой вот так:

// library/panic_unwind/src/gcc.rs
let exception = Box::new(Exception {
    _uwe: uw::_Unwind_Exception {
        exception_class: rust_exception_class(),
        exception_cleanup: Some(exception_cleanup),
        private: [core::ptr::null(); uw::unwinder_private_data_size],
    },
    canary: &CANARY,
    cause: data,           // Box<dyn Any + Send>, ваш payload
});
let exception_param = Box::into_raw(exception) as *mut uw::_Unwind_Exception;
uw::_Unwind_RaiseException(exception_param)

UnwindException идёт первым не случайно. Раскрутчик знает только про него и работает с указателем на начало структуры, а всё, что Rust дописал следом, для него невидимый хвост. exception_cleanup — колбэк на случай, когда исключение больше никому не нужно, он освобождает Box. В private раскрутчик держит своё состояние между фазами, мы про это говорили. А canary разберём отдельно.

Отдельного взгляда заслуживает private. Мы уже знаем, что раскрутчик держит там своё состояние между двумя проходами, и напрашивается вопрос: сколько ему на это нужно места.

Ответ зависит от того, где вы собираете.

// library/unwind/src/libunwind.rs
#[cfg(target_arch = "x86")]
pub const unwinder_private_data_size: usize = 5;

#[cfg(all(target_arch = "x86_64",
          not(any(target_os = "windows", target_os = "cygwin"))))]
pub const unwinder_private_data_size: usize = 2;

#[cfg(all(target_arch = "x86_64",
          any(target_os = "windows", target_os = "cygwin")))]
pub const unwinder_private_data_size: usize = 6;

Два указателя на x86-64 Linux, шесть на x86-64 Windows, пять на x86. Структура исключения физически разного размера на разных платформах, потому что под капотом там libunwind, libgcc-unwind или SEH, и каждой реализации нужен свой объём рабочей памяти. На Linux хватает адреса кадра-обработчика, SEH хранит заметно больше. Rust тут ничего не изобретает и принимает чужой формат как есть.

Из этих трёх строчек следует вполне практическая вещь. Линковать библиотеку, собранную под MSVC, с приложением, собранным под MinGW, если раскрутка ходит между ними, — плохая затея. Обе стороны считают, что говорят на одном ABI, но у них разъезжается сам размер структуры, которую они друг другу передают.

Что лежит в lang item eh_personality

#[lang = "eh_personality"] — та самая функция, адрес которой компилятор записывает в таблицы раскрутки для каждого фрейма, где возможна паника. У Rust для Itanium-платформ она в library/std/src/sys/personality/gcc.rs:

unsafe extern "C" fn rust_eh_personality(
    version: c_int,
    actions: uw::_Unwind_Action,        // _UA_SEARCH_PHASE | _UA_CLEANUP_PHASE | ...
    exception_class: uw::_Unwind_Exception_Class,
    exception_object: *mut uw::_Unwind_Exception,
    context: *mut uw::_Unwind_Context,
) -> uw::_Unwind_Reason_Code

Сама она почти ничего не делает и на большинстве платформ переадресует в __gcc_personality_v0 из libgcc, который умеет читать LSDA. LSDA (Language-Specific Data Area) — формат таблиц, где для каждого фрейма записаны диапазоны «адрес инструкции → landing pad», лежит он в .gcc_except_table рядом с .eh_frame. Эти секции мы и считали в начале статьи.

readelf -SW ./target/release/my_app | grep -E '\.eh_frame|\.gcc_except'
readelf -wf ./target/release/my_app | head -50   # инструкции восстановления регистров

На MSVC personality устроена иначе, через SEH с EXCEPTION_RECORD и DISPATCHER_CONTEXT, но снаружи интерфейс тот же, и раскрутка там тоже двухфазная.

Главное про её роль: сама она деструкторы не запускает. Она только сообщает раскрутчику, что в этом фрейме на текущем адресе есть landing pad, передавай управление туда. А landing pad оказывается обычным Rust-кодом, сгенерированным из MIR-блока очистки: обычные вызовы drop_in_place::<T>, обычные пролог и эпилог, обычные регистры. С точки зрения процессора между нормальным выполнением и выполнением во время раскрутки разницы нет, просто прыгнули в другую часть функции.

Раз personality не ловит, встаёт вопрос, кто же тогда ловит.

catch_unwind работает не через personality

Популярное заблуждение: catch_unwind вешает свой personality и работает как обработчик исключения. Это не так.

catch_unwind пользуется компиляторным интринсиком core::intrinsics::catch_unwind, который в LLVM-IR разворачивается в специальный регион: на Itanium в нечто setjmp-подобное, на MSVC — в полноценный блок SEH. Внутри library/std/src/panicking.rs это обёрнуто во внутреннюю функцию, а её уже дёргает публичный catch_unwind.

Граница между стандартной библиотекой и паническим рантаймом проходит по горстке служебных символов: __rust_start_panic отправляет исключение, __rust_panic_cleanup достаёт payload обратно, __rust_foreign_exception вызывается, когда прилетело что-то чужое. Отсюда, кстати, и вся конструкция с двумя отдельными крейтами panic_unwind и panic_abort, которые подставляются на этапе линковки: стратегия выбирается не условной компиляцией внутри std, а подменой реализации этих символов.

Из устройства catch_unwind вылезают два факта, которые стоит знать до того, как на неё опереться.

Первый: она ловит очень мало. Не ловит std::process::abort(), не ловит сигналы вроде SIGSEGV и SIGABRT, не ловит переполнение стека, потому что оно приходит сигналом, не ловит панику при panic = "abort", потому что там раскрутки физически нет. Это узкий спасательный люк на одну конкретную границу, а не try/catch.

Второй: она ловит любую раскрутку с классом MOZ\0RUST, включая ту, что прилетела из другого крейта Rust. И вот тут пора вернуться к канарейке.

Две std в одном процессе, или зачем в исключении канарейка

Представьте, что в одном процессе живут две независимо собранные Rust-библиотеки, у каждой своя статически влинкованная копия стандартной библиотеки, и одна вызывает другую через FFI с раскруткой. Исключение из первой приходит во вторую с классом MOZ\0RUST. По классу — родное.

Только payload внутри собран чужой копией std: другой аллокатор, другой vtable, потенциально другой layout. Достать его и привести к Box<dyn Any + Send> нельзя, это прямой путь к порче памяти.

Ровно от этого и стоит канарейка:

// library/panic_unwind/src/gcc.rs
static CANARY: u8 = 0;

pub unsafe fn cleanup(ptr: *mut u8) -> Box<dyn Any + Send> {
    let exception = ptr as *mut uw::_Unwind_Exception;
    if (*exception).exception_class != rust_exception_class() {
        uw::_Unwind_DeleteException(exception);
        super::__rust_foreign_exception();
    }
    let exception = exception.cast::<Exception>();
    // читаем только поле canary: остальное может принадлежать чужому Rust-исключению
    let canary = (&raw const (*exception).canary).read();
    if !core::ptr::eq(canary, &CANARY) {
        super::__rust_foreign_exception();
    }
    let exception = Box::from_raw(exception);
    exception.cause
}

CANARY — обычная статическая переменная, и весь смысл в том, что её адрес уникален для конкретной копии std в конкретном образе. Совпал — исключение наше, payload доставать можно.

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

Отдельная и до сих пор не закрытая история — смешение панических режимов. Cargo следит за единой стратегией внутри дерева, но std приезжает собранной в режиме unwind и при этом линкуется в проекты с panic=abort. На стыке с extern "C-unwind" из этого растёт soundness-баг, из-за которого в своё время из стандартной библиотеки вычистили все внутренние использования C-unwind. Отсюда простое правило: если у вас в дереве живут динамические библиотеки на Rust и раскрутка ходит между ними, собирайте их одним компилятором и одной стратегией.

UnwindSafe: трейт, который существует, чтобы вы притормозили

catch_unwind требует от замыкания UnwindSafe, а от всего, что внутри по ссылке, — RefUnwindSafe. Маркеры без методов, и вся их задача в том, чтобы заставить компилятор ругнуться, если вы тащите через границу что-то, у чего после пойманной паники может оказаться сломан инвариант.

К примеру у вас &mut Vec<i32>, вы кладёте туда элементы, и на середине шестого прилетает паника. Вы её поймали и держите в руках вектор с пятью валидными элементами и обещанием, которое сами себе давали, скажем, что в нём только чётные числа. Часть кода отработала, часть нет, а проверить ваш инвариант компилятор не умеет, поэтому требует решения.

use std::panic::{self, AssertUnwindSafe};

let mut budget = Budget::new();
let result = panic::catch_unwind(AssertUnwindSafe(|| {
    budget.spend(100);
    risky_operation(&mut budget);   // запаникует — budget в полуобновлённом состоянии
    budget.spend(50);
}));
// дальше проверяем budget.invariant() руками: мы обещали

Если раскрутка ломает не логику, а память, то есть была бы прямо unsound, для этого есть abort_unwind: выполняет замыкание и аварийно завершает процесс, если из него полезла раскрутка.

Во многих статьях abort_unwind подан как стабилизированный. Пробуем на 1.97:

error[E0658]: use of unstable library feature `abort_unwind`
  = note: see issue #130338 for more information

На стабильном канале тот же эффект даёт обёртка через extern "C", которая с 1.81 сама превращает раскрутку в аварийное завершение:

fn abort_unwind<F: FnOnce() -> R, R>(f: F) -> R {
    extern "C" fn call<F: FnOnce() -> R, R>(f: F) -> R { f() }
    call(f)
}

Соблазна написать вместо этого catch_unwind с process::abort() в ветке ошибки лучше избежать. Придётся добавлять AssertUnwindSafe, то есть глушить проверку, которая тут как раз бессмысленна: после аварийного завершения сломанные инварианты некому наблюдать. Да и в логи вы получите голое Aborted вместо внятного panic in a function that cannot unwind.

Что изменилось в панике за два года: 1.81, 1.88 и границы FFI

Дальше три изменения, прошедшие мимо большинства статей.

Паника в Drop больше не приговор

Классическая формулировка: паника внутри Drop во время раскрутки означает мгновенную смерть процесса, потому что двух одновременно живых исключений Itanium ABI не допускает.

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

В Rust 1.88 от 26 июня 2025 года поведение привели к тому, что ABI разрешал изначально:

struct Guard;
impl Drop for Guard {
    fn drop(&mut self) {
        let r = panic::catch_unwind(AssertUnwindSafe(|| panic!("вторая паника")));
        eprintln!("  [Drop] поймал внутри себя: {}", r.is_err());
    }
}

fn main() {
    panic::set_hook(Box::new(|_| {}));
    let outer = panic::catch_unwind(|| { let _g = Guard; panic!("первая паника"); });
    println!("внешний catch_unwind вернул Err: {}", outer.is_err());
}
  [Drop] поймал внутри себя: true
внешний catch_unwind вернул Err: true
код возврата: 0

Обе паники отработали, обе пойманы, процесс жив.

Теперь уберём внутренний перехват и дадим второй панике вырваться:

impl Drop for Guard {
    fn drop(&mut self) { panic!("вторая паника, наружу"); }
}
thread 'main' (512) panicked at esc.rs:6:59:
первая

thread 'main' (512) panicked at esc.rs:3:26:
вторая паника, наружу

thread 'main' (512) panicked at library/core/src/panicking.rs:233:5:
panic in a destructor during cleanup
thread caused non-unwinding panic. aborting.

Код возврата 134. И вот тут стоит вернуться на несколько разделов назад: помните unwind terminate(cleanup) в дампе MIR? Это он и сработал. Причина в скобках у терминатора определяет, какое сообщение вы увидите. Для границы ABI компилятор ставит terminate(abi), и текст будет другой, про функцию, которая не умеет разматываться.

Заодно обратите внимание, что обе первые паники прошли через хук и напечатались целиком. Раскрутка не обрывается на второй панике, она доходит до момента выхода из деструктора и только там упирается.

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

thread panicked while processing panic. aborting.

Логика понятная, ведь хук работает на пути обработки уже случившейся аварии, и рекурсия там никому не помогает.

К тому же обычная паника даёт код возврата 101, потому что её ловит обёртка вокруг main и программа завершается штатно. Оба разобранных аварийных случая дают 134, то есть SIGABRT. В логах CI по одному этому числу видно, упало у вас штатно или сломалась сама раскрутка.

У хука с 1.81 другой тип аргумента

core::panic::PanicInfo получает #[panic_handler] в no_std, а ваш хук получает std::panic::PanicHookInfo. Раньше это был один тип, тащивший компромиссы обоих сценариев.

panic::set_hook(Box::new(|info: &panic::PanicHookInfo<'_>| {
    let msg = info.payload_as_str().unwrap_or("<не строка>");
    let loc = info.location().map(|l| format!("{}:{}", l.file(), l.line()));
    eprintln!("паника: {msg} в {loc:?}");
}));

Payload приезжает как Box<dyn Any + Send>, и раньше приходилось вручную пробовать downcast_ref::<&str>(), потом downcast_ref::<String>(), потому что panic!("текст") кладёт &'static str, а panic!("{x}") — уже String. Метод делает обе попытки за вас.

Побочный эффект разделения типов: если тащить glob-импортом сразу core и std, оба макроса panic! окажутся в области видимости. На это ругается отдельный линт, он есть в списке rustc -W help:

ambiguous-panic-imports  warn  detects ambiguous core and std panic imports

И ещё одно про формат вывода, чтобы вы не сверяли свои логи с чужими. Он уже дважды переезжал: в 1.73 сообщение перенесли на отдельную строку и сняли кавычки, а в свежих версиях к имени потока добавился его идентификатор:

thread 'main' (562) panicked at plain.rs:1:13:

А через extern "C" паника перестала быть UB

Как было раньше: пишете функцию с extern "C", внутри panic!, раскрутка идёт вверх и упирается в кадр, собранный из C. Ни LSDA, ни personality, ни landing pads. Раскрутчик этого не понимает и шагает дальше, читая на очередном кадре мусор вместо unwind-информации. Дальше как повезёт: порча стека, прыжок по случайному адресу, тихое повреждение данных.

Чинили в два захода, а многие запомнили только первый. В 1.71 (июль 2023) стабилизировали extern "C-unwind" — отдельный ABI для случая, когда раскрутка на той стороне правда есть. Существующие ABI при этом сознательно не тронули и в релиз-нотах прямо написали, что через них раскрутка пока остаётся UB. И только в 1.81 (сентябрь 2024) закрыли дыру: паника, дошедшая до границы extern "C", теперь принудительно завершает процесс.

Смотрим:

extern "C" fn boom() { panic!("паника уехала за границу"); }

fn main() {
    println!("зовём extern \"C\" с паникой внутри...");
    boom();
    println!("сюда мы не доедем");
}

Паник будет две:

thread 'main' (739) panicked at ffi.rs:1:24:
паника уехала за границу
...
thread 'main' (739) panicked at library/core/src/panicking.rs:225:5:
panic in a function that cannot unwind
...
  18: core::panicking::panic_cannot_unwind
  19: ffi::boom

thread caused non-unwinding panic. aborting.

Сначала полностью отрабатывает ваша паника: своё сообщение, свой хук, свой бэктрейс. Раскрутка стартует нормально, потому что внутри функции она законна. И только на границе срабатывает panic_cannot_unwind. Код возврата 134.

Это тот же терминатор, что и в разделе про Drop, только с другой причиной. Там был terminate(cleanup) и сообщение про деструктор, здесь terminate(abi) и сообщение про функцию, которая не умеет разматываться. По тексту в логе всегда видно, на что вы напоролись.

Что вы получаете:

ABI

panic = "unwind"

panic = "abort"

extern "C"

паника → аварийное завершение

паника → аварийное завершение

extern "C-unwind"

раскрутка идёт сквозь границу

аварийное завершение на границе

Правая нижняя клетка удивляет: выбрали ABI, который умеет разматываться, а всё равно конец. Так и задумано, ведь в режиме abort компилятор помечает nounwind вообще все функции, включая объявленные с C-unwind.

Если хотите не теряться, а вернуть C-стороне код ошибки, ловите панику сами:

#[unsafe(no_mangle)]
pub extern "C" fn rust_compute(input: i32) -> i32 {
    match std::panic::catch_unwind(|| risky(input)) {
        Ok(v) => v,
        Err(_) => i32::MIN,
    }
}

А для взаимодействия с C++ берите C-unwind и следите, чтобы приложение собиралось с panic = "unwind". Иначе получите ту самую правую нижнюю клетку.


Если коротко

catch_unwind можно ставить куда угодно, он стоит полторы наносекунды. А вот сама паника — 2,7 микросекунды на пустом стеке, и дальше линейно по глубине: на двухстах кадрах уже под шестьдесят. Для управления потоком не годится, для аварии в запросе вполне.

panic = "abort" ради размера в одиночку не работает, потому что таблицы в вашем бинарнике почти целиком принадлежат std. Нужен -Cforce-unwind-tables=no, а по-настоящему — пересборка std с panic_immediate_abort.

На FFI-границе extern "C" больше не UB, паника просто убивает процесс с внятным сообщением. Нужна раскрутка сквозь границу — C-unwind и обязательно panic = "unwind" в приложении.

Паника в Drop разрешена, если поймать её внутри деструктора. В хуке — нет, там мгновенная смерть.

И общее: все острые места там, где встречаются две сборки, договорившиеся об ABI, но не о его реализации. Две копии std в процессе, MSVC против MinGW, std в unwind внутри проекта на abort. Сама раскрутка работает годами и вопросов не вызывает.

Ну а если делать что-то одно — соберите свой проект тремя способами и гляньте в size -A. Пять минут, а картинка в голове меняется сильно.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

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


  1. crymans
    03.09.2026 08:39

    Мощный ресёрч, особенно про canary и две копии std в одном процессе.

    Есть небольшое уточнение по поводу FFI и extern "C". Вы пишете, что в 1.81 границу закрыли окончательно и теперь это принудительный abort. Но разве это спасает от ситуации, когда Сишный код, вызванный из Rust, сам бросает longjmp (или плюсовый код кидает эксепшен через extern "C" обратно в раст)? Насколько я помню, Itanium ABI в таких случаях все равно может сломать стек, если на пути окажутся фреймы раст без правильного landing pad. То есть C-unwind обязателен не только для проброса паники наружу, но и для безопасного пролета чужого unwind внутрь раст кода?