Модуль 6.1 — Интерфейс внешних функций (FFI)
Упражнение 6.1.1: CRC на C
В этом упражнении мы вызовем из программы на Rust функцию вычисления контрольной суммы CRC, написанную на C.
Инструкции
Требования:
- Компилятор C
Шаги:
-
Добавьте build-зависимость
cc, добавив вCargo.tomlстроки:[build-dependencies] cc = "1.0" -
Создайте
build.rs(в той же папке, что иCargo.toml) со следующим содержимым:fn main() { println!("cargo:rerun-if-changed=crc32.h"); println!("cargo:rerun-if-changed=crc32.c"); cc::Build::new().file("crc32.c").compile("crc32"); }build.rs— это скрипт сборки, который cargo запускает перед компиляцией вашего крейта. Он найдёт C-код, скомпилирует его и подключит к исполняемому файлу, который создаёт Rust. -
В
main.rsобъявите extern-функцию (укажите типы аргументов и возвращаемого значения):#![allow(unused)] fn main() { extern "C" { fn CRC32( ... ) -> ...; // подсказка: https://doc.rust-lang.org/std/ffi/ } } -
Теперь создайте Rust-обёртку, которая вызывает extern-функцию:
#![allow(unused)] fn main() { fn crc32( ... ) -> ... { ... // (подсказки: `unsafe`, `.as_ptr()`, `.len()`) } } -
Вызовите обёртку на каком-нибудь примере входных данных:
fn main() { println!("{:#x}", crc32(b"abcde")); }В примере выше правильный вывод —
0x8587d865.
Упражнение 6.1.2: CRC на Rust
Теперь сделаем наоборот: используем написанную на Rust функцию вычисления CRC в программе на C.
Инструкции
Требования:
- Компилятор C
Шаги:
-
Добавьте в
Cargo.tomlследующее:[lib] name = "crc_in_rust" crate-type = ["staticlib"] -
Экспортируйте extern-функцию Rust в
lib.rs:#![allow(unused)] fn main() { #[unsafe(no_mangle)] pub extern "C" fn crc32(...) -> ... { ... crc32_rust(...) } } -
Создайте заголовочный файл C
crc_in_rust.h:#include <stdint.h> // uint32_t, uint8_t #include <stddef.h> // size_t uint32_t crc32(const uint8_t* data, size_t data_length); -
Создайте
main.cи используйте в нём функциюcrc32на Rust:#include "crc_in_rust.h" #include <stddef.h> // size_t #include <inttypes.h> // uint32_t, uint8_t, PRIx32 #include <stdio.h> // printf int main() { uint8_t data[] = "abcde"; size_t data_length = 5; uint32_t hash = crc32(data, data_length); printf("Hash: 0x%"PRIx32"\n", hash); return 0; } -
Задайте функции на Rust ту же сигнатуру, что и в заголовочном файле.
-
Соберите крейт Rust, а затем запустите программу.
Linux и macOS:
# Собираем main.c, связываем его с библиотекой и получаем исполняемый файл main $ clang main.c target/debug/libcrc_in_rust.a -lpthread -ldl -omain # Запускаем исполняемый файл $ ./main Hash: 0x8587d865Windows:
# Собираем main.c, связываем его с import-библиотекой DLL и получаем исполняемый файл main.exe ❯ clang main.c .\target\debug\crc_in_rust.lib -lkernel32 -lntdll -luserenv -lws2_32 -ldbghelp -o "main.exe" # Запускаем исполняемый файл ❯ .\main.exe Hash: 0x8587d865
Упражнение 6.1.3: QOI и bindgen
В этом упражнении мы используем bindgen, чтобы сгенерировать FFI-привязки к C-библиотеке. Bindgen читает заголовочный C-файл и на основе определений C создаёт функции, типы и константы для Rust.
Однако сгенерированный код, скорее всего, будет громоздким и не идиоматичным. Чтобы грамотно обернуть C-библиотеку, нужен хороший дизайн API и документация.
Справочная информация
Крейт image предоставляет функции для кодирования, декодирования и редактирования изображений на Rust. Он поддерживает множество форматов, например JPEG, PNG и GIF, а также QOI. QOI — это «Quite OK Image format», формат, который нацелен на быстрое кодирование и декодирование изображений при размере файла, сопоставимом с PNG. В этом упражнении мы проверим, даёт ли крейт image такие же результаты при декодировании QOI-изображений, как эталонная C-библиотека QOI.
Библиотека QOI на C — header-only, то есть реализации функций находятся прямо в заголовочном файле, а не в отдельном C-файле. Мы добавили отдельный C-файл, который подключает заголовок, чтобы было проще скомпилировать библиотеку и включить её в программу на Rust.
Генерация привязок
Требования:
- В системе установлен компилятор C
- Bindgen, который можно установить командой
cargo install bindgen-cli
Шаги:
-
Создайте привязки для Rust:
bindgen qoi.h -o src/bindings.rs -
Используйте скрипт
build.rs, чтобы скомпилировать и связатьqoi.h. Создайтеbuild.rsи вставьте:fn main() { cc::Build::new().file("qoi.c").compile("qoi"); // создаёт `qoi.a` }И добавьте в
Cargo.tomlтакой раздел:[build-dependencies] cc = "1" -
Создайте
src/lib.rsс содержимымpub mod bindings;. Это сделает модульbindingsдоступным вmain.rs. -
Выполните
cargo check, чтобы убедиться, что всё компилируется правильно.
Изучаем привязки
В сгенерированном файле bindings.rs мы находим такую сигнатуру для C-функции qoi_read из QOI:
#![allow(unused)]
fn main() {
extern "C" {
pub fn qoi_read(
filename: *const ::std::os::raw::c_char,
desc: *mut qoi_desc,
channels: ::std::os::raw::c_int,
) -> *mut ::std::os::raw::c_void;
}
}
Кое-что из этого можно заметить:
- Определение находится внутри блока
extern "C"и не имеет тела. Поэтому функция помечена как extern, и Rust ожидает, что её свяжут при сборке. - Функция помечена
pub, то есть её можно импортировать и использовать в других модулях (например, в нашемmain.rs). - По сигнатуре типов можно в общих чертах понять поведение функции:
filename— C-строка с именем QOI-файла, который нужно прочитать;descописывает метаданные изображения, и функция запишет их в структуруqoi_desc. Эту структуру тоже сгенерировал bindgen:#![allow(unused)] fn main() { #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct qoi_desc { pub width: ::std::os::raw::c_uint, pub height: ::std::os::raw::c_uint, pub channels: ::std::os::raw::c_uchar, pub colorspace: ::std::os::raw::c_uchar, } }channels— количество каналов изображения: 3 для RGB или 4 для RGBA (у последнего есть альфа-канал для прозрачности). В этом упражнении мы считаем, что у изображений есть альфа-канал.- Возвращаемое значение — void-указатель. Если функция успешно прочитала пиксельные данные из QOI-изображения, этот указатель должен указывать на них.
- Поскольку типы здесь — сырые C-типы, вызывать функцию напрямую из Rust неудобно.
Последнюю проблему решим, написав удобную Rust-обёртку вокруг сгенерированных привязок.
Пишем обёртку
Чтобы функцию qoi_read было проще использовать, напишем обёртку, которая принимает путь и возвращает буфер изображения:
#![allow(unused)]
fn main() {
fn read_qoi_image(filename: &Path) -> ImageBuffer<Rgba<u8>, &[u8]> {
todo!()
}
}
Чтобы реализовать эту обёртку, нужно решить несколько задач:
- Нужно превратить путь в C-строку. Подсказка: можно использовать
std::ffi::CString::new, чтобы создать C-строку из последовательности байтов. Удобнее всего получить байты пути, сначала получив из негоOsStr. Затем C-строку можно передать как указатель. - Нужно подготовить
qoi_desc. Эту структуру можно импортировать из привязок. Передайте в функцию изменяемую ссылку на экземпляр этой структуры. - После вызова
qoi_readнужно превратить возвращённый void-указатель в буфер изображения:- Сначала проверьте, не
is_null()ли возвращённый void-указатель. Если он нулевой, при чтении изображения что-то пошло не так. - Затем определите длину возвращённых пиксельных данных. Если считать, что у изображения есть альфа-канал, на каждый пиксель приходится 4 байта. Количество пикселей можно определить по структуре
qoi_desc. - Теперь можно превратить void-указатель в
&[u8]. Сначала приведите егоas *const u8, затем используйтеstd::slice::from_raw_partsс ранее вычисленной длиной. - Наконец, создайте буфер изображения с помощью
ImageBuffer::from_raw.
- Сначала проверьте, не
Чтобы опробовать обёртку, прочитаем QOI-изображение и сохраним его как PNG:
fn main() {
let image = read_qoi_image(Path::new("image.qoi"));
image.save("image.png").unwrap();
}
Если всё реализовано правильно, получится красивая картинка!
Теперь, когда мы умеем декодировать изображения эталонной C-библиотекой QOI, можно проверить, что крейт image даёт те же результаты, с помощью следующего модульного теста:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use crate::read_qoi_image;
use std::path::Path;
#[test]
fn test_qoi_read() {
let filename = "image.qoi";
let image = image::open(filename).unwrap().into_rgba8();
let my_image = read_qoi_image(Path::new(filename));
assert_eq!(image.width(), my_image.width());
assert_eq!(image.height(), my_image.height());
assert!(image.pixels().eq(my_image.pixels()));
}
}
}
Если добавить этот тест в main.rs и запустить cargo test, мы увидим:
running 1 test
test tests::test_qoi_read ... ok
Освобождение пиксельных данных
Работая с данными из C, мы сами отвечаем за освобождение памяти, когда перестаём её использовать. Некоторые C-библиотеки предоставляют отдельную функцию для очистки структур данных. Для QOI же нужно вызвать libc::free, чтобы освободить память, как указано в документации функции qoi_read:
Возвращённые пиксельные данные нужно освободить через
free()после использования.
Чтобы пользователь нашей обёртки не забыл освободить память, можно реализовать трейт Drop, который автоматически вызывает libc::free, когда переменная выходит из области видимости.
- Сначала создайте обёртку
struct MyImage<'a>(ImageBuffer<Rgba<u8>, &'a [u8]>);, которая хранит буфер изображения. - Затем реализуйте трейт
DropдляMyImage, чтобы освобождать память. Указатель нужно получить из буфера изображения и привести обратно к void-указателю:#![allow(unused)] fn main() { impl Drop for MyImage<'_> { fn drop(&mut self) { todo!(); // вызовите здесь libc::free, передав указатель на буфер изображения } } } - Чтобы обёрткой
MyImageбыло удобнее пользоваться, можно реализовать трейтDeref. Тогда методы внутреннего буфера изображения будут вызываться на ней напрямую:#![allow(unused)] fn main() { impl<'a> Deref for MyImage<'a> { type Target = ImageBuffer<Rgba<u8>, &'a [u8]>; fn deref(&self) -> &Self::Target { &self.0 } } } - Теперь измените функцию
read_qoi_image, чтобы она возвращала экземплярMyImage.
Неинициализированная память
Есть ещё один приём. Наша текущая функция инициализирует структуру qoi_desc нулями (или любыми значениями, которые вы записали при создании экземпляра). Это расточительно, потому что внешняя функция всё равно перезапишет эти значения. Поскольку внешняя функция связывается на этапе линковки, компилятор, вероятно, не имеет достаточно информации, чтобы оптимизировать эту инициализацию.
Для небольшой структуры вроде qoi_desc это не слишком важно. Однако для больших структур или массивов это может заметно повлиять на производительность.
Если посмотреть на LLVM IR, промежуточное представление, которое генерируется и оптимизируется до превращения в ассемблер, видно, что оптимизатор не убрал инициализацию структуры значениями. Здесь memset обнуляет desc перед вызовом qoi_read:
call void @llvm.memset.p0.i64(ptr noundef nonnull align 4 dereferenceable(10) %desc.i, i8 0, i64 10, i1 false), !noalias !142
%pointer.i = call noundef ptr @qoi_read(ptr noundef nonnull %t.0.i.i, ptr noundef nonnull %desc.i, i32 noundef 4) #17, !noalias !142
(LLVM IR можно сгенерировать командой cargo rustc --bin qoi-bindgen --release -- --emit=llvm-ir.)
Решение — использовать std::mem::MaybeUninit:
#![allow(unused)]
fn main() {
let mut desc = MaybeUninit::uninit();
let pointer = unsafe { qoi_read(filename.as_ptr(), desc.as_mut_ptr(), 4) };
let desc = unsafe { desc.assume_init() };
}
Тип MaybeUninit — это абстракция над неинициализированной памятью. Метод .uninit() выделяет кусок неинициализированной памяти, достаточный для хранения значения нужного типа (в нашем случае тип qoi_desc будет выведен автоматически).
Итоги
В этом упражнении мы увидели, как с помощью bindgen создать привязки к C-библиотеке. Сгенерированные привязки довольно неудобны в работе: они небезопасны (unsafe) и опираются на C-типы. Мы обсудили, как создать удобные обёртки вокруг сгенерированных привязок, которые берут на себя работу с C-типами и делают код безопаснее.