惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Cisco Blogs
Schneier on Security
Schneier on Security
T
Tor Project blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tenable Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
T
Threat Research - Cisco Blogs
C
CERT Recently Published Vulnerability Notes
Security Latest
Security Latest
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
NISL@THU
NISL@THU
L
Lohrmann on Cybersecurity
Scott Helme
Scott Helme
Webroot Blog
Webroot Blog
Project Zero
Project Zero
Google Online Security Blog
Google Online Security Blog
The Last Watchdog
The Last Watchdog
Spread Privacy
Spread Privacy
Hacker News: Ask HN
Hacker News: Ask HN
PCI Perspectives
PCI Perspectives
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
W
WeLiveSecurity
Attack and Defense Labs
Attack and Defense Labs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
N
News | PayPal Newsroom
Help Net Security
Help Net Security
The Hacker News
The Hacker News
H
Heimdal Security Blog
O
OpenAI News
S
Security @ Cisco Blogs
N
News and Events Feed by Topic
Cyberwarzone
Cyberwarzone
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 叶小钗
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Tailwind CSS Blog
大猫的无限游戏
大猫的无限游戏
A
Arctic Wolf
I
Intezer
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
Security Affairs
P
Proofpoint News Feed
S
Secure Thoughts
腾讯CDC
Google DeepMind News
Google DeepMind News
量子位
罗磊的独立博客

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как Rust обрабатывает repr и ABI на границе с C: что ломается и почему
morett1m (Be · 2026-05-22 · via Все публикации подряд на Хабре

Уровень сложностиПростой

Время на прочтение12 мин

Охват и читатели332

Обзор

Я пишу 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% при первом пополнении.

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

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