Программирование RustUnsafe и памятьИнженер по Rust и системному программированию

Объясните, чем опасен для C callback Rust с совпадающей сигнатурой, но обычным Rust ABI?

Объясните, чем опасен для C callback Rust с совпадающей сигнатурой, но обычным Rust ABI?

Проходите собеседования с ИИ помощником Hintsage

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

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

extern "C" задаёт соглашение о вызовах, но не делает сам callback безопасным: отдельно должны соблюдаться корректность аргументов, время жизни данных и правила взаимодействия с C.

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

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

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

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

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

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

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

Callback должен иметь тип функции с ABI C, а не обычный тип функции Rust:

type CCallback = extern "C" fn(i32); extern "C" fn on_event(value: i32) { println!("{value}"); } fn register(cb: CCallback) { // Передача cb в C-библиотеку } fn setup() { register(on_event); }

В типе extern "C" fn(i32) ABI является частью контракта типа. Функция fn(i32) с обычным Rust ABI не эквивалентна ей только потому, что у них совпадают типы параметров.

ABI решает лишь вопрос машинного вызова. Он не гарантирует, что C передаст допустимое значение, не вызовет callback после уничтожения связанного состояния или не нарушит ограничения потоков. Эти свойства должны быть описаны и проверены владельцем FFI-границы.

Если callback должен обращаться к состоянию Rust, обычно передают отдельный контекстный указатель, а C вызывает небольшой trampoline с ABI C. Trampoline восстанавливает состояние только при доказанной валидности указателя и соблюдении его времени жизни; захватывающую замыкание Rust нельзя напрямую представить как обычный C function pointer.

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

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

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

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

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

  1. Достаточно ли указать extern "C", чтобы callback стал безопасным?

Нет. extern "C" задаёт только соглашение о вызовах. C всё ещё может передать нулевой или недействительный указатель, вызвать callback после освобождения контекста, вызвать его из неожиданного потока или нарушить заявленные ограничения на повторный вход.

Безопасная оболочка Rust должна либо проверять эти условия, либо сделать их частью явно документированного unsafe-контракта. ABI-совместимость и безопасность данных — разные уровни гарантий.

  1. Можно ли передать в C замыкание Rust, если оно принимает те же аргументы, что и callback?

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

Обычно используют extern "C" trampoline и отдельный void*-подобный контекст. Нужно доказать, что контекст указывает на живой объект нужного типа, что он освобождается после прекращения всех вызовов, а доступ к нему допустим из потоков, которые использует C.

  1. Почему тест на одной платформе не подтверждает корректность обычного Rust ABI в C callback?

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

Корректность должна следовать из явно заданного ABI-контракта, а не из успешного запуска. Для callback, вызываемого C, это означает использование extern "C" на стороне Rust и соответствие полной сигнатуре и условиям вызова, согласованным с библиотекой.