сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · unsafe и производительность Rust

FFI и repr(C) в Rust

FFI: разговор с C

Практический блок для тех, кто интегрирует Rust в существующий стек. Спрашивают про repr(C), передачу строк и что делать с паникой на границе.

Стержень: у C свой контракт по раскладке, строкам и обработке ошибок, и всё это нужно соблюдать явно.

// Формулировки: «зачем repr(C)?», «как передать строку в C?», «что будет, если Rust-функция паникует внутри C?»

Раскладка и строки

Раскладка структур по умолчанию не определена: компилятор вправе переставлять поля ради уплотнения. Проверено: { u8, u32, u8 } занимает 8 байт в обычной раскладке и 12 с repr(C), который обязан сохранить порядок объявления. Для обмена с C нужен именно repr(C).

Строки - второй камень. String в Rust это указатель, длина и ёмкость без завершающего нуля, а C ждёт последовательность байт, оканчивающуюся нулём. Для перехода есть CString (владеющая, добавляет ноль) и CStr (заимствованный вид на чужую C-строку). Ноль внутри строки Rust сделает преобразование ошибкой, и это правильно.

// Тот же принцип с числами и указателями: типы берут из std::os::raw или libc, чтобы совпадали размеры на всех платформах.

repr(C)
раскладка структуры по правилам C
CString / CStr
строка с завершающим нулём: владеющая и заимствованная

Паника на границе и unsafe при вызове

Разворачивание паники через кадры стека C - неопределённое поведение: правила размотки у языков разные. Поэтому функции, экспортируемые наружу, пишут по шаблону: всё тело внутри catch_unwind, наружу отдаётся код ошибки. Современные версии ставят в extern "C" защиту, обрывающую процесс при попытке размотки, но полагаться на неё как на обработку ошибок не стоит.

Вызов extern-функции требует unsafe по простой причине: компилятор не видит тела. Он не может проверить ни сигнатуру, ни соглашение вызова, ни предположения о времени жизни переданных указателей - всё это утверждение автора объявления.

// Поэтому объявления по возможности генерируют: bindgen читает заголовки C и снимает целый класс ошибок в типах.

extern "C" { fn abs(i: i32) -> i32; }
let v = unsafe { abs(-5) };
catch_unwind
перехват паники, чтобы она не ушла через границу FFI (foreign function interface)
bindgen
генератор Rust-объявлений по заголовкам C

Как отвечать: «Что нужно учесть, экспортируя функцию Rust в C?»

Четыре вещи. Соглашение вызова - extern "C" и no_mangle, иначе имя не найдётся. Раскладка структур - repr(C), потому что по умолчанию порядок полей не гарантирован и компилятор их переставляет. Строки - не String, а CString с завершающим нулём, либо пара указатель и длина, если контракт такой. И паника: разворачивание через кадры C это UB, поэтому тело оборачиваю в catch_unwind и наружу отдаю код ошибки. Объявления для обратного направления по возможности генерирую через bindgen, чтобы не промахнуться в типах вручную.

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

На чём валятся

  • Забывают repr(C) и рассчитывают на порядок полей.
  • Передают &str туда, где ждут строку с завершающим нулём.
  • Оставляют панику без catch_unwind в экспортируемой функции.
  • Пишут extern-объявления руками и промахиваются в типах аргументов.
  • Считают, что unsafe при вызове нужен «для формальности», а не из-за невидимого компилятору тела.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 11, остальные разбираются в тренажёре.

  1. #rs_ffi1 / 5
    Rust-функция вызывается из C и паникует. Что произойдёт?
    A)Паника превратится в код возврата, и C увидит ошибку
    B)Разворачивание через границу FFI — UB; нужен catch_unwind
    C)Паника поднимется как исключение C++ и будет поймана вызывающим
    D)Процесс аккуратно завершится с сообщением в stderr
    показать ответ и разбор
    +B)Разворачивание через границу FFI — UB; нужен catch_unwind

    // разбор: Кадры стека C развернуть по правилам Rust нельзя — поведение не определено. Поэтому экспортируемые наружу функции пишут так: вся логика внутри catch_unwind, наружу отдаётся код ошибки, а паника не переходит границу. Современные версии ставят в extern "C" защиту, которая при попытке разворачивания обрывает процесс, но полагаться на неё как на обработку ошибок не стоит.

  2. #rs_ffi2 / 5
    Почему объявления extern "C" считаются unsafe при вызове?
    A)Компилятор не видит тело: контракт держит программист
    B)Они выполняются в отдельном процессе, и результат нужно проверять
    C)Внешние функции могут работать медленнее и блокировать поток
    D)Компилятор не умеет проверять типы аргументов для чужого ABI
    показать ответ и разбор
    +A)Компилятор не видит тело: контракт держит программист

    // разбор: Объявление extern — это утверждение автора: там есть такая функция с такой сигнатурой и такими требованиями к аргументам. Ошибиться можно в типе, в соглашении вызова, в предположениях о времени жизни указателей — и всё это компилятор проверить не в состоянии, чужой код ему недоступен. Отсюда unsafe на месте вызова.

  3. #rs_ffi3 / 5
    Что означает extern "C" в объявлении функции?
    A)Функция реализована на C и не может быть написана на Rust в этом же крейте
    B)Функция использует соглашение вызова C: порядок аргументов, регистры, очистка стека
    C)Функция компилируется отдельным объектным файлом и подключается на этапе компоновки
    D)Функция обязана принимать только типы фиксированного размера из стандартной библиотеки
    показать ответ и разбор
    +B)Функция использует соглашение вызова C: порядок аргументов, регистры, очистка стека

    // разбор: Соглашение вызова — это договор о том, как передаются аргументы и кто убирает за собой стек. Rust по умолчанию использует своё, нестабильное, поэтому для обмена с внешним миром указывают extern "C" — и на стороне импорта чужих функций, и на стороне экспорта своих (обычно вместе с #[no_mangle], чтобы имя не искажалось). Реализация при этом может быть и на Rust.

  4. #rs_ffi4 / 5
    Rust-функция принимает из C указатель на строку. Что нужно сделать перед использованием?
    A)Разыменовать указатель и привести результат к String напрямую через as
    B)Вызвать String::from_raw_parts, передав указатель и длину, полученную через strlen
    C)Скопировать байты в Vec фиксированного размера и добавить завершающий ноль вручную
    D)Проверить указатель на null, обернуть в CStr и превратить в &str с проверкой UTF-8
    показать ответ и разбор
    +D)Проверить указатель на null, обернуть в CStr и превратить в &str с проверкой UTF-8

    // разбор: У C-строки нет длины, есть завершающий ноль, и нет обещания, что содержимое — корректный UTF-8. Поэтому порядок такой: убедиться, что указатель не null, собрать CStr::from_ptr (это unsafe: доверяемся наличию нуля и сроку жизни), затем to_str с обработкой ошибки кодировки. Только после этого получается обычный &str, с которым можно работать безопасно.

  5. #rs_ffi5 / 5
    Rust отдаёт в C структуру с полем String. Что здесь не так?
    A)String — тип Rust с непрозрачной раскладкой, у C нет способа с ним работать
    B)String не выровнена по границе, требуемой соглашением вызова C
    C)String не реализует Copy, поэтому передать её по значению через границу не выйдет
    D)String хранит текст в UTF-16, что не совпадает с ожиданиями C-библиотеки
    показать ответ и разбор
    +A)String — тип Rust с непрозрачной раскладкой, у C нет способа с ним работать

    // разбор: String — это указатель, длина и ёмкость, привязанные к аллокатору Rust, и раскладка её не зафиксирована. Для C такой тип бессмыслен: он не знает ни устройства, ни как освободить память. Через границу передают простые типы: указатель на байты плюс длину либо CString с завершающим нулём, а освобождение оставляют той стороне, которая выделяла.

дальше

Теорию прочитали. Навык ставится повторением

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