Программирование RustUnsafe и памятьИнженер по интеграции Rust и C++

В плагине C++ исключение может пройти через вызванный Rust callback. Какой ABI должен быть указан на границ...

В плагине C++ исключение может пройти через вызванный Rust callback. Какой ABI должен быть указан на границе, чтобы такой unwind не пересекал запрещённый контракт?

unsafe extern "C" {
    fn start(cb: extern "C" fn());
    fn cxx_operation();
}

extern "C" fn callback() {
    unsafe { cxx_operation() }
}

fn main() {
    unsafe { start(callback) }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Если исключение C++ действительно должно проходить через Rust-кадры, границу нужно объявить с ABI extern "C-unwind", а не extern "C". В примере это относится к функции, через которую исключение входит в Rust или выходит из него, и к callback, если unwind проходит через его вызов. Если исключения не должны пересекать границу, безопаснее перехватить их на стороне C++ и вернуть обычный код ошибки.

Исторический контекст

Обычный ABI C исторически описывает совместимый вызов функций, но не обещает возможность unwinding через границу. Для FFI это важно, потому что раскрутка стека должна быть согласована между вызывающей и вызываемой сторонами.

ABI C-unwind выделяет границы, через которые unwind разрешён контрактом. Это не делает обработку чужих исключений автоматически безопасной и не превращает C++-исключение в Rust-панику.

Постановка проблемы

Если C++ исключение пересечёт Rust-функцию, объявленную с обычным extern "C", нарушается ABI-контракт. Последствие — неопределённое поведение либо аварийное завершение, в зависимости от конкретной границы и реализации; полагаться на автоматическое корректное прохождение исключения нельзя.

Даже при использовании C-unwind Rust-код должен быть корректен во время раскрутки: нельзя оставлять нарушенные инварианты, удерживать ресурсы, освобождение которых зависит от обычного пути возврата, или рассчитывать, что Rust автоматически поймает исключение C++.

Подробное решение

Исправленный вариант должен явно разрешать unwind на соответствующих объявлениях:

unsafe extern "C-unwind" { fn start(cb: extern "C-unwind" fn()); fn cxx_operation(); } extern "C-unwind" fn callback() { unsafe { cxx_operation() } } fn main() { unsafe { start(callback) } }

extern "C-unwind" задаёт ABI-вариант, допускающий прохождение unwinding через эту границу. Это отличается от unsafe: unsafe требует проверки корректности вызова и типов, а C-unwind описывает допустимое поведение при раскрутке стека.

ABI нужно выбирать для каждой границы, через которую реально проходит unwind. Если start вызывает callback, а C++ исключение выходит из cxx_operation через callback и затем через start, обе соответствующие границы должны иметь совместимый unwind-контракт.

Практически предпочтительный вариант — не пропускать исключения через FFI вообще. C++-обёртка может перехватить исключение, записать диагностическую информацию и вернуть код ошибки; Rust тогда получает обычный результат Result или код состояния. Это проще тестировать и не связывает управление ресурсами с совместимостью механизмов unwinding разных языков.

C-unwind не гарантирует, что Rust сможет выполнить catch_unwind над исключением C++. Он лишь задаёт разрешённый ABI-контракт для прохождения unwind; преобразование исключения, его типобезопасная обработка и сохранение инвариантов остаются задачей интеграционного слоя.

Ситуация из практики

Плагин C++ вызывает callback Rust, а callback обращается к C++ API, которое может выбросить исключение. В первом варианте всё объявлено как extern "C". Он прост, но при выходе исключения через Rust-кадр нарушает контракт ABI.

Во втором варианте используется extern "C-unwind" на всех проходящих границах. Это соответствует сценарию unwinding, но требует убедиться, что используемые платформенный ABI, рантайм и все промежуточные функции действительно поддерживают такой путь. Кроме того, Rust-код не должен считать, что C++ исключение можно безопасно обработать как Rust-панику.

В выбранном решении C++-внешний слой перехватывает исключения и возвращает код ошибки, а Rust callback остаётся extern "C". Такой вариант обычно лучше: граница имеет простой контракт, ресурсы освобождаются обычным способом, а ошибки представлены явно. C-unwind оставляют только для архитектуры, где прохождение исключения через границу действительно является обязательным и полностью поддержано всеми участниками.

Что кандидаты часто упускают

  1. Достаточно ли заменить extern "C" на extern "C-unwind" только у функции callback?

    Нет, нужно изменить каждую ABI-границу, через которую проходит unwind. Если исключение покидает C++-функцию, входит в Rust callback, а затем проходит наружу через вызывающую FFI-функцию, все эти переходы должны иметь совместимый unwind-контракт. Изменение только одного объявления оставляет другую границу запрещённой.

  2. Означает ли C-unwind, что Rust может поймать C++ исключение через catch_unwind?

    Нет. catch_unwind предназначен для Rust-паник и не является универсальным адаптером для исключений C++. C-unwind разрешает пересечение ABI-границы, но не задаёт преобразование чужого объекта исключения в panic или тип Rust-ошибки.

  3. Почему перехват исключения до границы часто лучше, чем разрешённый unwind?

    При явном перехвате C++ сохраняется простой FFI-контракт: функция возвращает значение или код ошибки, а не передаёт управление через чужие стековые кадры. Это упрощает управление ресурсами, диагностику и поддержку разных компиляторов и библиотек. Цена — необходимость заранее определить формат ошибок и потеря возможности автоматически продолжить раскрутку через Rust.