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

Что обязан доказать Rust код перед вызовом указателя на функцию, полученного из FFI?

Что обязан доказать Rust-код перед вызовом указателя на функцию, полученного из FFI?

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

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

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

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

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

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

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

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

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

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

Перед вызовом нужно установить следующие факты:

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

Особенно важен ABI. Совпадение Rust-сигнатуры недостаточно: внешняя функция обычно должна быть описана с ABI, который обещан библиотекой, например extern "C". ABI определяет соглашение о передаче аргументов, возвращении значения и взаимодействии с машинным кодом.

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

Минимальный пример проверки нулевого указателя и явного ABI:

type Callback = unsafe extern "C" fn(i32) -> i32; unsafe fn invoke(callback: Option<Callback>) -> i32 { let callback = callback.expect("FFI вернул нулевой callback"); callback(7) }

Option<Callback> позволяет явно обработать нулевой указатель, если FFI-представление допускает его. Сам вызов остаётся unsafe, потому что тип не доказывает, что функция действительно существует, что она ещё загружена и что её внешний контракт соблюдён.

Безопасная обёртка может скрыть unsafe только после проверки всех условий, которые она способна проверить, и при наличии API-контракта для остальных. Если библиотека может вернуть произвольный адрес, выгрузить модуль во время использования callback или вызвать его из другого потока без синхронизации, обёртка должна либо запретить такие сценарии, либо выразить их требования в своём безопасном интерфейсе.

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

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

Второй вариант — описать FFI-функцию как возвращающую типизированный Option<unsafe extern "C" fn(i32) -> i32>, проверить отсутствие None, а время жизни callback связать с объектом загруженного плагина. Это требует более строгого контракта, зато сохраняет ABI в типе и позволяет не вызывать функцию после освобождения её кода.

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

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

  1. Достаточно ли совпадения сигнатуры функции без совпадения ABI?

Нет. Сигнатура описывает типы аргументов и результата на уровне Rust, но ABI определяет машинный способ вызова. Указатель на функцию с несовместимым ABI нельзя безопасно вызывать только потому, что визуально совпадают типы параметров.

  1. Почему проверка указателя на функцию не сводится к проверке на ноль?

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

  1. Можно ли считать типизированный указатель на функцию полностью безопасным для вызова?

Нет. Тип подтверждает форму вызова, но не истинность внешних предположений. Функция может требовать конкретный диапазон аргументов, владение контекстом, вызов только из определённого потока или запрет параллельных вызовов. Безопасная обёртка должна доказать и эти условия либо сама оставаться unsafe.