Я пишу 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.

Правила такие:

  1. Каждое поле выравнивается по своему размеру (u8 по 1, u16 по 2, u32 по 4, u64 и f64 по 8)

  2. Если текущее смещение не кратно выравниванию следующего поля, добавляется padding

  3. Финальный размер структуры округляется вверх до выравнивания самого крупного поля

Посчитаем для нескольких примеров:

#[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% при первом пополнении.

Воспользоваться

Комментарии (2)


  1. black_warlock_iv
    22.05.2026 17:44

    Правильный подход в том, чтобы просто ловить панику через catch_unwind на FFI-границе

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


  1. vanxant
    22.05.2026 17:44

    очень мощная статья, спасибо!