Я пишу FFI-код на Rust уже несколько лет и за это время понял одну неприятную вещь: на FFI-границе всё, что может сломаться, ломается молча. Компилятор не предупреждает, тесты проходят, а данные на C-стороне оказываются просто мусором. Или, что хуже, оказываются почти правильными, первые три поля совпадают, а четвёртое сдвинуто на два байта, и ошибка всплывает через недели.
Большинство этих проблем связано с двумя вещами: как структура лежит в памяти (layout) и как данные передаются при вызове функции (ABI). В чистом Rust-коде об этом можно не думать, компилятор всё решает за вас. Но на границе с C эти детали становятся вашей ответственностью.
repr(Rust) vs repr(C): почему одна и та же структура лежит в памяти по-разному
Возьмём простую структуру:
struct Sensor { active: bool, // 1 байт temperature: f64, // 8 байт id: u16, // 2 байта }
У этой структуры по дефолту repr(Rust), который даёт компилятору полную свободу в расположении полей. Компилятор может переставить поля в любом порядке, вставить padding где хочет, и конкретное расположение не гарантировано.
Зачем ему эта свобода? Чтобы минимизировать размер структуры.
Если бы поля шли в порядке объявления (как в C), расклад был бы такой:
active: смещение 0, размер 1 7 байт padding (temperature требует выравнивания по 8) temperature: смещение 8, размер 8 id: смещение 16, размер 2 6 байт padding (до кратности 8, выравнивание структуры по f64) Итого: 24 байта, из них 13 — padding
Больше половины структуры — пустое место. Rust-компилятор видит это и переставляет поля: сначала temperature (8 байт), потом id (2 байта), потом active (1 байт).
Получается:
temperature: смещение 0, размер 8 id: смещение 8, размер 2 active: смещение 10, размер 1 5 байт padding (до кратности 8) Итого: 16 байт, из них 5 — padding
16 вместо 24. На одной структуре разница копеечная, но если у вас вектор из миллиона таких структур (датчики в IoT-системе, пиксели в изображении, записи в логе), 8 мегабайт экономии влияют на cache hit rate.
Проблема возникает, когда вы передаёте эту структуру в C.
C-компилятор не знает, что Rust переставил поля. C-сторона ожидает поля в порядке объявления C-структуры: active на смещении 0, temperature на 8, id на 16. Rust положил temperature на 0. C-код читает первые 8 байт как bool, получает число вроде 3.14, и вместо true/false видит весь этот мусор. И ни один компилятор об этом не скажет, потому что extern-блок это ваше обещание, и компилятор ему верит.
Как работает repr(C): правила padding-а по шагам
#[repr(C)] говорит компилятору: располагай поля строго в порядке объявления, вставляй padding по правилам C.
Правила такие:
Каждое поле выравнивается по своему размеру (u8 по 1, u16 по 2, u32 по 4, u64 и f64 по 8)
Если текущее смещение не кратно выравниванию следующего поля, добавляется padding
Финальный размер структуры округляется вверх до выравнивания самого крупного поля
Посчитаем для нескольких примеров:
#[repr(C)] struct A { x: u8, // смещение 0 (выравнивание 1, 0 % 1 == 0 ✓) y: u32, // смещение ? (выравнивание 4, 1 % 4 != 0 → padding 3 байта) // смещение 4 z: u8, // смещение 8 (выравнивание 1, 8 % 1 == 0 ✓) } // Текущий размер: 9 // Выравнивание структуры: 4 (по u32) // Финальный размер: 12 (9 → 12, 3 байта trailing padding)
Проверяем:
assert_eq!(std::mem::size_of::<A>(), 12); assert_eq!(std::mem::align_of::<A>(), 4);
Другой пример, с f64:
#[repr(C)] struct B { a: u8, // 0 // 7 байт padding b: f64, // 8 c: u8, // 16 d: u16, // 17 → padding 1 → 18 } // Размер: 20, выравнивание 8 → финальный размер 24
assert_eq!(std::mem::size_of::<B>(), 24);
Тут Rust без repr(C) сэкономил бы: переставил бы b первым, потом d, потом a и c, и получил бы 16 байт вместо 24.
С-эквивалент:
#include <stdint.h> #include <stddef.h> typedef struct { uint8_t a; double b; uint8_t c; uint16_t d; } B; // sizeof(B) == 24, offsetof(B, a) == 0, offsetof(B, b) == 8 // offsetof(B, c) == 16, offsetof(B, d) == 18
Если числа совпадают на обеих сторонах, layout совместим. Если нет, у вас порча данных, и симптомы проявятся где-то далеко от причины.
Есть один способ быстро проверить: напишите static_assert на размер и смещения на обеих сторонах. В Rust:
const _: () = assert!(std::mem::size_of::<B>() == 24); const _: () = assert!(std::mem::offset_of!(B, a) == 0); const _: () = assert!(std::mem::offset_of!(B, b) == 8);
В C:
_Static_assert(sizeof(B) == 24, "B size mismatch"); _Static_assert(offsetof(B, a) == 0, "B.a offset mismatch"); _Static_assert(offsetof(B, b) == 8, "B.b offset mismatch");
Если на одной из сторон assert сработает, вы узнаете об ошибке при компиляции, а не в продакшене.
repr(transparent): обёртка, которая должна исчезнуть
В Rust часто делают newtype-обёртки для типобезопасности:
struct Meters(f64); struct Seconds(f64); // Нельзя случайно перепутать метры с секундами fn speed(distance: Meters, time: Seconds) -> f64 { distance.0 / time.0 }
Для чистого Rust-кода это работает конечно супер, но для FFI есть проблема. Без специального указания компилятор рассматривает Meters как структуру (aggregate type-kind), а не как f64 (scalar type-kind). А на уровне calling convention aggregate и scalar передаются по-разному.
На x86-64 с System V ABI (Linux, macOS) одиночный f64 передаётся в регистре XMM0. Структура с одним f64 в большинстве случаев тоже передаётся в XMM0 (System V ABI достаточно умный), но это зависит от деталей реализации. На других платформах (некоторые ARM ABI, embedded) структуру могут передать через стек, а скаляр через регистр. Если C-функция ожидает double в регистре, а получает его на стеке, она прочитает мусор из регистра.
repr(transparent) решает эту проблему:
#[repr(transparent)] struct Meters(f64);
Теперь Meters имеет тот же ABI, что f64. Тот же размер, выравнивание, type-kind, способ передачи через calling convention. C-функция, ожидающая double, получит Meters корректно на любой платформе.
repr(transparent) можно ставить на структуру с одним полем, содержащим данные (non-ZST). Дополнительные ZST-поля вроде PhantomData допустимы:
use std::marker::PhantomData; #[repr(transparent)] struct Handle<T> { raw: u64, _phantom: PhantomData<T>, } // Handle<T> имеет тот же ABI, что u64
Стандартная библиотека использует repr(transparent) для UnsafeCell<T>, ManuallyDrop<T>, MaybeUninit<T> и других обёрток, которые должны быть ABI-прозрачными.
Enum через FFI: тут всё сложнее, чем кажется
C-enum и Rust-enum называются одинаково, но устроены совершенно по-разному. C-enum это набор целочисленных констант. Rust-enum это tagged union, который может содержать данные в вариантах.
Fieldless enum
Для enum без данных repr(C) делает размер таким же, как у C-enum на целевой платформе:
#[repr(C)] enum Status { Ok = 0, Error = 1, Pending = 2, }
Обычно это int (4 байта на большинстве платформ), но точный размер зависит от C-компилятора. И вот тут ловушка: GCC с флагом -fshort-enums может сделать enum однобайтовым, если значения помещаются в один байт. MSVC всегда использует int. Если Rust-сторона с repr(C) считает Status четырёхбайтовым, а C-сторона с -fshort-enums считает его однобайтовым, при передаче через FFI три байта окажутся лишними, и следующее поле в структуре будет прочитано с неправильного смещения.
Безопаснее зафиксировать размер явно:
#[repr(u8)] enum Status { Ok = 0, Error = 1, Pending = 2, }
Теперь размер гарантированно 1 байт, независимо от флагов C-компилятора. На C-стороне используйте uint8_t:
typedef uint8_t Status; #define STATUS_OK 0 #define STATUS_ERROR 1 #define STATUS_PENDING 2
Выглдит некрасиво, зато size mismatch невозможен.
Есть ещё одна ловушка, которую часто забывают. В C переменную типа enum можно установить в любое значение: Status s = 42; — валидный C-код. В Rust создание enum-значения, которого нет среди вариантов, это undefined behavior. Если C-сторона передаёт произвольный u8, а Rust-сторона принимает его как Status, и значение окажется 42, всё ломается.
Компилятор Rust предполагает, что enum содержит только допустимые значения, и может генерировать код, который зависит от этого предположения.
Безопасный подход: принимайте через FFI целое число и конвертируйте вручную:
impl Status { fn from_u8(raw: u8) -> Option<Status> { match raw { 0 => Some(Status::Ok), 1 => Some(Status::Error), 2 => Some(Status::Pending), _ => None, } } } #[no_mangle] pub extern "C" fn handle_status(raw: u8) -> i32 { match Status::from_u8(raw) { Some(status) => process(status), None => -1, // невалидное значение } }
Enum с данными (tagged union)
Rust-enum с данными через FFI передать можно, если на обеих сторонах layout совпадает. С repr(C) Rust раскладывает его как пару: дискриминант (целое число) + union из вариантов.
#[repr(C)] enum Message { Text(u32), // длина текста Binary(u32, u32), // длина + checksum Ping, }
В C это эквивалентно:
typedef enum { MSG_TEXT, MSG_BINARY, MSG_PING } MessageTag; typedef union { struct { uint32_t length; } text; struct { uint32_t length; uint32_t checksum; } binary; // ping не имеет данных } MessagePayload; typedef struct { MessageTag tag; // padding до выравнивания payload (если нужно) MessagePayload payload; } Message;
Руками синхронизировать два определения затея так себе. Добавили вариант в Rust, забыли добавить в C — компилятор промолчит, данные поедут. Используйте cbindgen: он генерирует C-заголовки из Rust-кода автоматически.
Nullable pointer optimization
У Rust есть одна гарантия, которая делает Option<&T> бесплатным. Если тип не может быть NULL (ссылки, Box<T>, extern "C" fn()), то Option над ним представляется как обычный указатель. None = NULL (все нули), Some(ref) = сам указатель. Дискриминанта нет, дополнительного байта нет.
// Оба типа занимают ровно 8 байт на 64-битной платформе: assert_eq!(std::mem::size_of::<*const u8>(), 8); assert_eq!(std::mem::size_of::<Option<&u8>>(), 8);
Это не просто оптимизация, а гарантия из спецификации. Option<&T>, Option<&mut T>, Option<Box<T>>, Option<extern "C" fn()> — все имеют тот же layout и ABI, что и соответствующий сырой указатель, и все FFI-safe.
На практике же это означает, что C-функцию, возвращающую nullable pointer, можно описать так:
extern "C" { // C: Item* find_item(int id); // может вернуть NULL fn find_item(id: i32) -> Option<&'static Item>; }
И Option здесь не добавляет overhead: C возвращает указатель, Rust интерпретирует NULL как None, ненулевой указатель как Some. Без дополнительных проверок на уровне layout.
Calling conventions: что значит extern "C" на разных платформах
extern "C" задаёт calling convention: в каких регистрах передаются аргументы, кто чистит стек после вызова, как возвращается результат. На разных платформах это разные правила.
System V AMD64 ABI (Linux, macOS, FreeBSD)
Первые шесть целочисленных аргументов передаются в регистрах: RDI, RSI, RDX, RCX, R8, R9. Первые восемь float/double в XMM0–XMM7. Остальное на стеке. Возвращаемое значение в RAX (целое) или XMM0 (float). Вызывающий (caller) чистит стек.
Если аргумент — структура, ABI смотрит на её размер и содержимое. Структура до 16 байт, содержащая только целые или только float, передаётся в регистрах (разбивается на 8-байтовые куски). Структура больше 16 байт передаётся через стек (caller аллоцирует место и передаёт указатель). Это значит, что маленькая repr(C) структура из двух u64 передаётся так же эффективно, как два отдельных u64, а структура из трёх u64 передаётся через стек.
Microsoft x64 ABI (Windows)
Первые четыре аргумента в RCX, RDX, R8, R9 (целые) или XMM0–XMM3 (float). Четыре, не шесть, это уже различие с System V. Плюс обязательный shadow space: вызывающий выделяет 32 байта на стеке для первых четырёх аргументов (даже если они в регистрах). Зачем? Чтобы вызываемая функция могла «пролить» (spill) регистровые аргументы на стек, если ей нужно.
Структуры передаются иначе: структура размером 1, 2, 4 или 8 байт передаётся в регистре как целое число. Структура другого размера передаётся по указателю (caller копирует на стек, передаёт указатель).
stdcall (32-битный Windows)
Win32 API использует stdcall: аргументы на стеке, вызываемый (callee) чистит стек. extern "C" на 32-битном Windows означает cdecl, где стек чистит caller. Если вы вызываете Win32-функцию через cdecl вместо stdcall, стек портится: caller попытается очистить стек, который уже очистил callee. Одни и те же данные снимутся со стека дважды, и при возврате из функции стек сдвинут на N байт.
Ошибка проявляется не в месте вызова, а где-то потом, когда программа попытается использовать данные на стеке, которые уже сдвинуты. Отлаживать это тяжеловато.
В Rust для этого есть extern "system":
extern "system" { fn MessageBoxW( hWnd: *mut std::ffi::c_void, text: *const u16, caption: *const u16, mb_type: u32, ) -> i32; }
extern "system" автоматически выбирает правильный calling convention: stdcall на 32-битном Windows, обычный C на 64-битном Windows и Linux. Если бы вы написали extern "C" для Win32 API на 32-битном Windows, получили бы порчу стека.
Типы, которые выглядят одинаково, но имеют разный размер
C-шный int — классика. Его размер зависит от платформы. На большинстве современных систем 4 байта, но формально стандарт C гарантирует только «не менее 16 бит». long ещё интереснее: на Linux x86-64 это 8 байт, на Windows x86-64 — 4 байта. Одно ключевое слово, разный размер на разных ОС при одной архитектуре.
Rust предоставляет типы-алиасы в std::os::raw: c_int, c_long, c_char и так далее. Они должны совпадать с C-типами на целевой платформе да и обычно совпадают. Но если C-библиотека скомпилирована другим компилятором или с нестандартными флагами, гарантий нет.
Безопасный подход для новых API: используйте sized-типы. i32 вместо c_int, i64 вместо c_long. На C-стороне int32_t и int64_t из <stdint.h>. Размер зафиксирован, сюрпризов не будет.
Для существующих библиотек, у которых API использует int и long, это не вариант: нужно использовать c_int и c_long и надеяться, что тулчейны согласны о размерах.
bool
bool в C (C99) и bool в Rust оба занимают 1 байт, но допустимые значения различаются. В Rust bool — это 0 или 1, любое другое значение — UB. В C bool — это 0 (false) или ненулевое (true), и _Bool x = 42; — валидный код (значение нормализуется в 1).
Если C-сторона передаёт bool, который оказался 2 или 255 (что в C допустимо для некоторых реализаций до C99), Rust-сторона получит UB. Для FFI безопаснее использовать u8 или c_int и конвертировать вручную:
#[no_mangle] pub extern "C" fn set_flag(raw: u8) { let flag: bool = raw != 0; // ... }
Указатели и NULL
C-указатель может быть NULL. Rust-ссылка не может быть NULL никогда. Если C-функция возвращает указатель, который иногда NULL, принимайте его как *const T, а не &T:
extern "C" { fn find_user(id: i32) -> *const User; } fn safe_find(id: i32) -> Option<&'static User> { let ptr = unsafe { find_user(id) }; if ptr.is_null() { None } else { unsafe { Some(&*ptr) } } }
Если написать сигнатуру как fn find_user(id: i32) -> &'static User, а C вернёт NULL, Rust получит ссылку на адрес 0. Компилятор вправе предполагать, что ссылка валидна, и может генерировать код, который разыменовывает её без проверки. Результат зависит от оптимизаций: может крашнуться, может испортить данные, может воркать до следующего обновления компилятора.
Паника через FFI-границу
Если Rust-функция, вызванная из C, паникует, и паника попытается раскрутить стек через C-фреймы, это UB. C-фреймы не содержат информации для раскрутки стека (DWARF-таблиц), и что произойдёт дальше — непредсказуемо. Может крашнуться сразу, может тихо испортить данные, может прокатить на одной версии libunwind и сломаться на другой.
Правильный подход в том, чтобы просто ловить панику через catch_unwind на FFI-границе:
use std::panic::catch_unwind; #[no_mangle] pub extern "C" fn process(data: *const u8, len: usize) -> i32 { let result = catch_unwind(|| { let slice = unsafe { std::slice::from_raw_parts(data, len) }; do_something(slice) }); match result { Ok(value) => value, Err(_) => -1, } }
С Rust 1.71 стабилизировался ABI extern "C-unwind", который разрешает раскрутку через FFI-границу. Это нужно для C++, который тоже использует DWARF unwinding для исключений. Если Rust-код вызывается из C++ и может паниковать, extern "C-unwind" позволяет панике пройти через C++-фреймы (вызвав C++-деструкторы по пути).
#[no_mangle] pub extern "C-unwind" fn cpp_callback() { panic!("пройдёт через C++-фреймы"); }
Но если между Rust-фреймами — чистые C-фреймы без поддержки раскрутки (скомпилированные с -fno-exceptions), раскрутка через них по-прежнему опасна. C-unwind не делает произвольный C-код совместимым с раскруткой, он только разрешает её на границе.
repr(packed): убираем padding, получаем новые проблемы
#[repr(C, packed)] убирает весь padding между полями:
#[repr(C, packed)] struct TcpHeader { src_port: u16, // смещение 0 dst_port: u16, // смещение 2 seq_num: u32, // смещение 4 (а не 8!) ack_num: u32, // смещение 8 data_offset: u8, // смещение 12 flags: u8, // смещение 13 window: u16, // смещение 14 checksum: u16, // смещение 16 urgent: u16, // смещение 18 } // Размер: 20 байт, как в спецификации TCP
Без packed seq_num (u32, выравнивание 4) начинался бы со смещения 4, и layout совпал бы со спецификацией. Но если бы первые поля были расположены иначе, packed гарантирует, что padding-а нет.
Проблема packed в том, что поля оказываются невыровненными. seq_num на смещении 4 случайно выровнен (4 кратно 4), но при других раскладах u32 может оказаться на нечётном смещении. На x86 невыровненный доступ работает, но медленнее. На ARM, MIPS и других архитектурах чтение невыровненного u32 вызывает аппаратное исключение, и программа падает.
Хуже того, в Rust взятие ссылки на невыровненное поле — это UB. &header.seq_num создаёт &u32, а компилятор вправе предполагать, что ссылка выровнена по 4. Он может сгенерировать SIMD-инструкцию или оптимизированный load, который требует выравнивания.
Правильный способ через read_unaligned:
let seq = unsafe { std::ptr::read_unaligned(&raw const header.seq_num) };
&raw const создаёт сырой указатель без промежуточной ссылки. read_unaligned генерирует побайтовое чтение, которое работает на любой архитектуре.
На практике для packed-структур полезно написать методы-обёртки:
impl TcpHeader { fn seq_num(&self) -> u32 { unsafe { std::ptr::read_unaligned(&raw const self.seq_num) } } fn set_seq_num(&mut self, val: u32) { unsafe { std::ptr::write_unaligned(&raw mut self.seq_num, val) } } }
Выглядит напыщенно, но защищает от UB и работает на всех платформах.
bindgen и cbindgen: ручная синхронизация не работает
Самый опасный момент FFI: два определения одного и того же на двух языках. C-заголовок говорит, что функция принимает uint32_t, const char*, size_t. Rust-сторона объявляет u32, *const c_char, usize. Если кто-то поменяет C-сторону (добавит аргумент, поменяет тип), а Rust-сторону забудет обновить, компилятор промолчит.
Ошибка проявится как порча стека (аргументы считаны не оттуда), порча данных (тип интерпретирован не так), или segfault (указатель указывает не туда).
bindgen генерирует Rust-определения из C-заголовков:
bindgen library.h -o src/bindings.rs
Обычно это встраивают в build.rs:
// build.rs fn main() { println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") .generate() .expect("unable to generate bindings"); let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("couldn't write bindings"); }
cbindgen делает обратное — генерирует C-заголовки из Rust-кода:
cbindgen --lang c --output include/library.h
При каждой сборке определения генерируются заново из одного источника правды. Рассинхронизация невозможна.
Будущее
В 2025 году в Rust Project Goals появился исследовательский проект по безопасному линкованию раздельно скомпилированного кода. Обсуждается возможность нового ABI (extern "crabi", repr(crabi)), который будет стабильным и при этом поддержит больше Rust-типов, чем repr(C): кортежи, срезы, Result, строки. Все это не ближайшая перспектива, но направление движения такое: дать Rust стабильный ABI без потери тех типов, которые делают его Rust-ом.
Пока этого нет, repr(C) + extern "C" остаётся единственным стабильным способом связать Rust-код с чем угодно, включая другой Rust-код из другой версии компилятора.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

black_warlock_iv
Самый правильный подход — включить
panic = "abort", ибо раскрутка стека — это фича с отрицательной ценностью, и она не должна использоваться никогда и нигде. Но если это невозможно (библиотечный код), то тогда да, надо ловить.