Пиши на Rust — запускай везде. Взаимодействие Rust и C

Предлагаю читателям «Хабрахабра» перевод поста «Rust Once, Run Everywhere» из блога Rust за авторством Alex Crichton. Сам я некоторое время уже интересуюсь этим языком, а в связи со скорым релизом версии 1.0 хотел бы продвигать его по своим скромным возможностям. Ничего своего, к сожалению, сейчас у меня написать не получается, но когда-то я занимался переводами, так что решил вспомнить давнее дело. Перевод этого поста на Хабре я не нашёл, так что решил сделать свой.
Некоторые термины, которые обозначают уникальные для Rust-а концепции (ownership, borrowing, lifetime parameter), я не знал, как лучше перевести на русский, так что постарался подобрать наиболее подходящие по смыслу и более-менее понятные для русскоязычной аудитории слова. Любые предложения-улучшения принимаются.


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

Для взаимодейсвтия с другими языками Rust предоставляет FFI (foreign function interface — интерфейс вызова внешних функций). Следуя основным принципам Rust-а, FFI даёт абстракцию с нулевой ценой, так что стоимость вызова функций между Rust и C такая же, как и стоимость вызова C-функций из кода на C. При этом можно использовать такие свойства языка, как управление владением и заимствованием, чтобы создать безопасный протокол для управления указателями и другими ресурсами. Обычно такие протоколы существуют только в виде документации к C API (это в лучшем случае), Rust же делает такие требования явными (а их выполнение гарантируется самим компилятором — прим. перев.)

В этом посте мы рассмотрим, каким образом можно инкапсулировать небезопасный интерфейс вызовов C функций в безопасную абстракцию.
И хотя основной разговор о взаимодействии с C, интеграция Rust-а с другими языками (например Ruby и Python) настолько же проста.

Rust работает с C


Начнём с простого примера: вызов кода на C из Rust-а, а затем покажем, что Rust не накладывает никаких дополнительных расходов. Вот простая программа на C, которая удваивает любое число на входе:

int double_input(int input) {
    return input * 2;
}

Чтобы вызвать эту функцию из Rust-а, можно написать такой код:

extern crate libc;

extern {
    fn double_input(input: libc::c_int) -> libc::c_int;
}

fn main() {
    let input = 4;
    let output = unsafe { double_input(input) };
    println!("{} * 2 = {}", input, output);
}

И всё! Можете сами попробовать этот пример с кодом из GitHub-а — просто зачекаутите его и выполните cargo run из каталога с проектом. Из кода видно, что никаких дополнительных телодвижений для вызова C-функции не требуется, только описать её сигнатуру. Вскоре мы увидим, что сгенерированный машинный код так же не содержит никаких дополнительных расходов. Однако, есть несколько мелких (но коварных — прим. перев.) деталей в этой программе на Rust-е, так что сперва разберём каждую часть более детально.

Во-первых, мы видим extern crate libc. Крейт libc содержит множество опеределений типов для FFI, которые полезны для работы с C, при этом гарантируется, что типы на границе вызовов между Rust и C согласованы друг с другом.

Идём дальше:

extern {
    fn double_input(input: libc::c_int) -> libc::c_int;
}

В Rust-е это объявление внешней функции. Можно думать про этот код, как про аналог заголовочного файла в C. Здесь компилятор узнаёт о входных и выходных параметрах функции. Как видно, эта сигнатура совпадает с определением функции на C.

Дальше у нас идёт основной код программы:

fn main() {
    let input = 4;
    let output = unsafe { double_input(input) };
    println!("{} * 2 = {}", input, output);
}

Здесь виден один из самых важных аспектов FFI в Rust-е — блок unsafe. Компилятор ничего не знает о реализации функции double_input(), так что он изначально предполагает, что при вызове любой внешней функции безопасность управления памятью может нарушиться. Блок unsafe даёт программисту возможность взять на себя ответственность по обеспечению безопасности работы с памятью: таким образом вы обещаете, что вызов этой функции не нарушит целостность памяти, так что основные гарантии Rust-а останутся выполненными. Кажется, что это слишком строгие ограничения, но Rust даёт достаточно средств для пользователей API, чтобы не беспокоиться о блоках unsafe (чуть позже этот момент будет раскрыт).

Теперь, когда мы увидели, как делать вызов C-функции из Rust-а, посмотрим, действительно ли Rust не накладывает никаких дополнительных расходов на этот вызов. Почти все языки программирования могут так или иначе вызывать код C, но часто это сопровождается как минимум дополнительными преобразованиями типов, а иногда и более сложными операциями, во время выполнения программы. Чтобы увидеть, что на самом деле делает Rust, посмотрим на ассемблерный код, выданный Rust-компилятором, для вызова функции double_input():

mov    $0x4,%edi
callq  3bc30 <double_input>

И это всё! Как здесь видно, вызов C-функции из Rust-а требует только размещения аргументов и одной интрукции вызова, точно так же, как если бы вызов был из кода C.

Безопасные абстракции


Большинство возможностей Rust-а завязаны на концепцию владения данными, и FFI не исключение. Когда создаётся привязка для C-библиотеки в Rust-е, вы не только получаете нулевые накладные расходы, но и можете гарантировать большую безопасность работы с памятью, чем в самом C! Привязки могут использовать принципы владения и заимствования Rust-а для строгого контроля над правилами использованием API, которые обычно описаны только в заголовочных файлах C в виде комментариев.

Например, представьте себе библиотеку для работы с tar-архивами. Эта библиотека предоставляет функцию для чтения содержимого каждого файла архива, что-то вроде этой:

// Gets the data for a file in the tarball at the given index, returning NULL if
// it does not exist. The `size` pointer is filled in with the size of the file
// if successful.
const char *tarball_file_data(tarball_t *tarball, unsigned index, size_t *size);


Эта фукнция делает неявные предположения о том, как она будет использована: возвращаемый указатель char* не может пережить входной параметр tarball_t *tarball. Привязки к этому API на Rust-е могут выглядеть так:

pub struct Tarball { raw: *mut tarball_t }

impl Tarball {
    pub fn file(&self, index: u32) -> Option<&[u8]> {
        unsafe {
            let mut size = 0;
            let data = tarball_file_data(self.raw, index as libc::c_uint,
                                         &mut size);
            if data.is_null() {
                None
            } else {
                Some(slice::from_raw_parts(data as *const u8, size as usize))
            }
        }
    }
}


Здесь указателем *mut tarball_t владеет структура Tarball, которая отвечает за очистку памяти и ресурсов, так что мы уже имеем полные знания о времени жизни памяти, выделенной под tar-архив. К тому же метод file() возвращает заимствованный срез, время жизни которого неявно связано со временем жизни самой структуры Tarball (аргумент &self). Таким образом Rust показывает, что возвращаемый срез может использоваться только пока жива в памяти структура с архивом, статически гарантируя, что багов с висячими указателями не будет (что легко допустить в самом C). (Если вы не знакомы с заимствованием в Rust-е, советую почитать пост Yehuda Katz о владении.)

Здесь главная особенность привязок в Rust-е, это их безопасность, то есть пользователь этого API в Rust-е уже не должен использовать блок unsafe для их вызова! И хотя реализация сама по себе здесь не безопасна (из-за использования FFI), интерфейс к ней, благодаря заимствованным указателям, гарантирует безопасность работы с памятью для любого кода на Rust-е, который его использует. То есть, компилятор Rust-а статически гарантирует, что просто незовможно вызвать segfault при использовании этого API из кода на Rust-е. И не забываем: всё это не несёт никаких дополнительных накладных расходов! Все типы C в Rust-е представляются без каких либо дополнительных требований к памяти.

Сообщество Rust-а уже создало приличный набор безопасных привязок для существующих C-библиотек, в том числе OpenSSL, libgit2, libdispatch, libcurl, sdl2, Unix APIs и libsodium. И этот список на crates.io очень быстро пополняется, так что очень может быть, что ваша любимая C-библиотека либо уже имеет привязки на Rust-е, либо они будут скоро написаны.

С работает с Rust


Несмотря на гарантии по безопасности использования памяти, Rust не использует сборщик мусора или среду выполнения, Rust-код может быть вызван из C без всякой специальной подготовки. То есть нет накладных расходов не только на вызовы C из Rust-а, но и на вызовы Rust-а из С!

Возьмём пример, обратный предыдущему. Как и раньше, весь код доступен на GitHub-е. Во-первых, код на Rust-е:

#[no_mangle]
pub extern fn double_input(input: i32) -> i32 {
    input * 2
}

Как и раньше, здесь нет ничего сложного, но некоторые заковыристые особенности стоит рассмотреть. Во-первых, мы пометили нашу функцию атрибутом #[no_mangle]. Это сигнал компилятору, что не нужно искажать имя функции double_input. Rust применяет декорирование имён похожее на то, что используется в C++, для гарантирования уникальности имён между различными библиотеками, а этот атрибут позволит не гадать нам, какое имя компилятор дал функции, при её вызове из C (а декорированное имя может быть вроде такого: double_input::h485dee7f568bebafeaa).

Дальше у нас идёт определение функции, и самая интересная вещь здесь — ключевое слово extern. Это особая форма для указания ABI функции, которая делает её совместимой с вызовом функций из C.

Ну и наконец, если вы посмотрите на Cargo.toml, то увидите, что эта библиотека собирается не как обычная Rust-библиотека (rlib), а как статическая, что в Rust-е называется «staticlib». Всё это даёт возможность статически скомпоновать код на Rust-е с программой на C.

Теперь, когда мы разобрались в библиотекой на Rust-е, напишем программу на C, которая будет её использовать.

#include <stdint.h>
#include <stdio.h>

extern int32_t double_input(int32_t input);

int main() {
    int input = 4;
    int output = double_input(input);
    printf("%d * 2 = %d\n", input, output);
    return 0;
}

Здесь видно, что в C, как и в Rust-е, нужно объявить внешнюю функцию double_input(), теперь написанную на Rust-е.

Кроме этой детали, всё остальное уже работает! Если запустить make из каталога из GitHub-а, то этот пример скомпилируется и соберётся в один статический исполнимый файл, который при запуске выдаст в консоль 4 * 2 = 8.

Отсутствие сборщика мусора и среды времени выполнения позволяют очень просто интегрировать C с Rust-ом. Внешний C-код не должен делать никаких телодвижений для настройки окружения Rust-а, так что переходы между Rust-кодом и C очень дешёвые.

После C


FFI в Rust-е, как мы увидели, не даёт практически никаких накладных расходов, а система владения позволяет писать безопасные при работе с памятью привязки к C-библиотекам для Rust-а. Однако, даже если вы не используете C, вам всё равно повезло! Те же самые принципы позволяют вызывать Rust-код из Python-а, Ruby, JavaScript-а и многих других языков.

Иногда, программируя на этих языках, возникает необходимость ускорить критически важные компоненты, но раньше для этого приходилось опускаться до C, тем самым отказываясь от безопасности управления паматью, высокоуровневых абстракций и эргономики этих языков.

Однако тот факт, что Rust легко интегрируется с C, означает, что он так же хорошо подходит и для такого использования. Одна из первых фирм, использующих Rust в продакшене — Skylight — смогла почти мгновенно улучшить производительность и уменьшить использование памяти коллекциями данных, просто перейдя на Rust, при этом весь Rust-код опубликован как Ruby-гем.

Переход от языков проде Python-а и Ruby к C для оптимизации производительности, часто довольно трудоёмкий процесс, и сложно дать гарантии, что при этом программа на упадёт так, что отладить её будет очень тяжело. Rust же не только предоставляет FFI с нулевыми издержками, но и делает возможным сохранить те же гарантии безопасности, что и исходный язык. В длительной перспективе, это должно дать возможность программистам на этих языках опуститься ближе к железу и добавить толику системного программирования для увеличения производительности там, где это нужно.

FFI — лишь один из многих инструментов в копилке Rust-а, но это главный компонент к переходу на Rust, так как он даёт очень просто интегрироваться с существующим сейчас кодом. Я лично буду очень рад увидеть, как достоинства Rust-а приходят в как можно больше проектов!
  • +38
  • 21k
  • 8
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 8
    +1
    Очень хочу попробовать написать несколько DXE-драйверов на Rust, но пока совершенно не хватает времени на то, чтобы сделать нормальный интерфейс хотя бы к UEFI BootServices и RuntimeServices. Плюс язык новый и малознакомый, плюс хитрое calling convention, плюс строки в UCS2 вместо UTF8, и в итоге получается какой-то хлам, а не драйвер, и проще дальше на С писать.
    Вопрос к тем, кто уже пробовал писать на Rust действительно системные вещи (bare-metal, к примеру): сильно сложно жить без стандартной библиотеки? Компилятор не вставляет memset и memcpu самостоятельно?
      0
      Во-первых все строки на расте в utf-8. По поводу memcpy и memset париться обычно не нужно: раст не даёт использовать неинициализированные переменные (это ошибка компиляции), а значения типов, поддерживающие копирование (реализующие трейт Copy), копируются автоматически. Не копируемые значения перемещаются де факте тоже копированием, но только при передаче за пределы функции, а уже перемещённые значения использовать нельзя, компилятор не даст.

      Без стандартной либы жить можно. Можно посмотреть на rustboot для примера. Можно выключить stdlib и оставить только core, тогда будет доступ к самой низкоуровневой функциональности.
        0
        Вот именно что в UTF8. А в UEFI — в UCS2, в итоге строковые литералы придется делать через макросы, и простого L«String» уже недостаточно.
        По поводу memset и memcpu — в С бывают случаи, когда их вызовы могут быть вставлены компилятором автоматически (к примеру, при передаче по значению структуры, размер которой не кратен никакому доступному регистру), а потом при линковке вдруг оказывается, что таких функций нет. Когда я столкнулся с таким поведением впервые — был немало удивлен, поэтому и спрашиваю.
          0
          Я больше прикладной программист, пришёл в раст из питона и скалы, так что про такие детали не в курсе, но вопрос интересный.
            +2
            А вот и ответ на него:
            libcore is built on the assumption of a few existing symbols:

            memcpy, memcmp, memset — These are core memory routines which are often generated by LLVM. Additionally, this library can make explicit calls to these functions. Their signatures are the same as found in C. These functions are often provided by the system libc, but can also be provided by the rlibc crate.

            rust_begin_unwind — This function takes three arguments, a fmt::Arguments, a &str, and a u32. These three arguments dictate the panic message, the file at which panic was invoked, and the line. It is up to consumers of this core library to define this panic function; it is only required to never return.

            Поведение такое же, как в C, и тоже придется либо реализовывать самому, либо использовать библиотеку rlibc, либо обернуть уже доступные gRS->CopyMem и gRS->SetMem.
              0
              Ну, судя по всему, автор boot2rust решил эту проблему собственной реализацией memset.
        +2
        Нормально жить без стандартной библиотеки, libcore вполне хватает для почти всего, что можно вообще сделать без аллокации на куче. Хотелось бы, конечно, шаблонные кастомные аллокаторы у libstd, как в STL, но Box пока еще не устаканился, может позже увидим и такую фичу.
        +1
        Опередили вы меня :) я как раз собирался её переводить.

        Посмотрите существующие переводы на хабре, чтобы разобраться в переводе терминологии: habrahabr.ru/post/256941, habrahabr.ru/post/256211, habrahabr.ru/post/254961, habrahabr.ru/post/243315, habrahabr.ru/post/237199.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое