FFI и repr(C) в Rust
Практический блок для тех, кто интегрирует 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, остальные разбираются в тренажёре.
- Rust-функция вызывается из C и паникует. Что произойдёт?A)Паника превратится в код возврата, и C увидит ошибкуB)Разворачивание через границу FFI — UB; нужен catch_unwindC)Паника поднимется как исключение C++ и будет поймана вызывающимD)Процесс аккуратно завершится с сообщением в stderr
показать ответ и разбор
+B)Разворачивание через границу FFI — UB; нужен catch_unwind// разбор: Кадры стека C развернуть по правилам Rust нельзя — поведение не определено. Поэтому экспортируемые наружу функции пишут так: вся логика внутри catch_unwind, наружу отдаётся код ошибки, а паника не переходит границу. Современные версии ставят в extern "C" защиту, которая при попытке разворачивания обрывает процесс, но полагаться на неё как на обработку ошибок не стоит.
- Почему объявления extern "C" считаются unsafe при вызове?A)Компилятор не видит тело: контракт держит программистB)Они выполняются в отдельном процессе, и результат нужно проверятьC)Внешние функции могут работать медленнее и блокировать потокD)Компилятор не умеет проверять типы аргументов для чужого ABI
показать ответ и разбор
+A)Компилятор не видит тело: контракт держит программист// разбор: Объявление extern — это утверждение автора: там есть такая функция с такой сигнатурой и такими требованиями к аргументам. Ошибиться можно в типе, в соглашении вызова, в предположениях о времени жизни указателей — и всё это компилятор проверить не в состоянии, чужой код ему недоступен. Отсюда unsafe на месте вызова.
- Что означает extern "C" в объявлении функции?A)Функция реализована на C и не может быть написана на Rust в этом же крейтеB)Функция использует соглашение вызова C: порядок аргументов, регистры, очистка стекаC)Функция компилируется отдельным объектным файлом и подключается на этапе компоновкиD)Функция обязана принимать только типы фиксированного размера из стандартной библиотеки
показать ответ и разбор
+B)Функция использует соглашение вызова C: порядок аргументов, регистры, очистка стека// разбор: Соглашение вызова — это договор о том, как передаются аргументы и кто убирает за собой стек. Rust по умолчанию использует своё, нестабильное, поэтому для обмена с внешним миром указывают extern "C" — и на стороне импорта чужих функций, и на стороне экспорта своих (обычно вместе с #[no_mangle], чтобы имя не искажалось). Реализация при этом может быть и на Rust.
- Rust-функция принимает из C указатель на строку. Что нужно сделать перед использованием?A)Разыменовать указатель и привести результат к String напрямую через asB)Вызвать String::from_raw_parts, передав указатель и длину, полученную через strlenC)Скопировать байты в Vec фиксированного размера и добавить завершающий ноль вручнуюD)Проверить указатель на null, обернуть в CStr и превратить в &str с проверкой UTF-8
показать ответ и разбор
+D)Проверить указатель на null, обернуть в CStr и превратить в &str с проверкой UTF-8// разбор: У C-строки нет длины, есть завершающий ноль, и нет обещания, что содержимое — корректный UTF-8. Поэтому порядок такой: убедиться, что указатель не null, собрать CStr::from_ptr (это unsafe: доверяемся наличию нуля и сроку жизни), затем to_str с обработкой ошибки кодировки. Только после этого получается обычный &str, с которым можно работать безопасно.
- Rust отдаёт в C структуру с полем String. Что здесь не так?A)String — тип Rust с непрозрачной раскладкой, у C нет способа с ним работатьB)String не выровнена по границе, требуемой соглашением вызова CC)String не реализует Copy, поэтому передать её по значению через границу не выйдетD)String хранит текст в UTF-16, что не совпадает с ожиданиями C-библиотеки
показать ответ и разбор
+A)String — тип Rust с непрозрачной раскладкой, у C нет способа с ним работать// разбор: String — это указатель, длина и ёмкость, привязанные к аллокатору Rust, и раскладка её не зафиксирована. Для C такой тип бессмыслен: он не знает ни устройства, ни как освободить память. Через границу передают простые типы: указатель на байты плюс длину либо CString с завершающим нулём, а освобождение оставляют той стороне, которая выделяла.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.