Практическое руководство по FFI с использованием bindgen (часть 1 из 2)

Сегодня я хочу углубиться в одну из трудностей, с которыми мы столкнулись, пытаясь переписать наш код Python для Интернета вещей в Rust: в частности, FFI или интерфейс внешних функций - бит, который позволяет Rust взаимодействовать с другими языками. Когда год назад я пытался написать код Rust для интеграции с библиотеками C, существующие документы и руководства часто давали противоречивые советы, и мне приходилось самостоятельно проделывать этот процесс. Это руководство предназначено для того, чтобы помочь будущим разработчикам Rustace проработать процесс переноса библиотек C на Rust и познакомить читателя с наиболее частыми проблемами, с которыми мы столкнулись при этом.

В этом руководстве мы собираемся обсудить, как предоставить Rust функции библиотеки C с помощью bindgen. Мы также немного поговорим об ограничениях этого автоматического набора инструментов и о том, как проверить свою работу. Справедливое предупреждение: правильная реализация FFI - это Rust Hard Mode. Если вы новичок в Rust, пожалуйста не начинайте здесь. Поработайте над книгой, напишите практический код и вернитесь, когда вы полностью освоитесь с программой проверки заимствований.

Мотивация

Чтобы сделать резервную копию, мне нужно объяснить, почему мы в Dwelo вообще нуждались в этом.

Для нашего проекта перезаписи мы хотели интегрироваться с поставляемой поставщиком C-библиотекой, которая отвечала бы за связь с нашим чипом Z-Wave через стандартный протокол, указанный производителем, через последовательный порт. Этот протокол последовательной связи был сложным и трудным для правильной реализации, а также подвергался строгим временным ограничениям - байты, отправляемые в последовательный порт, по существу передавались по радио напрямую. Отправка неправильных байтов в неправильное время может привести к полному зависанию радиочипа. Существовал справочный документ длиной в несколько сотен страниц, содержащий спецификацию передачи и подтверждений, логику повторной передачи, обработку ошибок, временные интервалы и так далее. Исходный код Python реализовал этот протокол с нуля (неправильно), и эта реализация представляла значительную часть ошибок в нашем устаревшем стеке. Вдобавок к этому поставщик радиочипсета отказывался от сертификации, если мы не смогли продемонстрировать, что реализовали протокол правильно. По совпадению предоставленные справочные библиотеки (реализованные на C) гарантированно соответствовали спецификации. Совершенно очевидно, что код производителя на языке C казался кратчайшим путем к успеху в бизнесе.

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

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

Биндген

Наиболее часто используемым инструментом для генерации подписей Rust из заголовков C является bindgen. Наша цель - создать bindings.rs файл, представляющий общедоступный API библиотеки (его общедоступные функции, структуры, перечисления и т. Д.). Мы настроим наш ящик для включения этого файла. После сборки ящика мы можем импортировать этот ящик в любой проект для вызова функций нашей библиотеки C.

Что вам понадобится:

  • Функционирующая cargo установка. Я предполагаю, что если вы вообще компилируете код на Rust, у вас есть это.
  • Работающий компилятор C и pkg-config для разрешения зависимостей.
  • Заголовочные файлы, соответствующие библиотечным функциям, которые вы хотите использовать.
  • Если у вас есть исходный код - отлично; в этом примере предполагается, что вы собираете библиотеку из исходного кода. В противном случае вам понадобится путь к статической или динамической библиотеке, на которую вы ссылаетесь, если он не находится в вашем системном пути.
  • Количество терпения, соответствующее размеру API библиотеки.

Установить инструмент bindgen из командной строки так же просто, как:

cargo install bindgen

На моем ноутбуке Debian мне также нужно было вручную apt install clang, хотя ваш опыт может отличаться.

Установка вашего ящика

Наш новый ящик с библиотекой будет содержать грязную работу по созданию и экспорту небезопасных функций встроенной библиотеки C. Опять же, оставьте все безопасные оболочки для другого ящика - это не только ускоряет компиляцию, но также позволяет ̶m̶a̶s̶o̶c̶h̶i̶s̶t̶s̶ другим авторам ящиков минимально импортировать и использовать только необработанные привязки C. Стандартное соглашение об именах Rust для ящиков FFI - lib<XXXX>-sys.

Мы собираемся создать build.rs файл, который будет использоваться с cc ящиком для компиляции и связывания наших экспортов bindgen. Давайте поместим исходный код нашей библиотеки в подкаталог с именем src, а связанные файлы включения в подкаталог с именем include. Затем давайте убедимся, что наш Cargo.toml настроен:

[package]
name = "libfoo-sys"
version = "0.1.0"
links = "foo"
build = "build.rs"
edition = "2018"
[dependencies]
libc = "0.2"
[build-dependencies]
cc = { version = "1.0", features = ["parallel"] }
pkg-config = "0.3"

Затем мы заполним файл build.rs. Следующее будет выглядеть немного странно - мы пишем программу на Rust, которая выводит скрипт на стандартный вывод; Cargo будет напрямую использовать этот скрипт для создания нашего ящика.

Если вы связываетесь с уже скомпилированной библиотекой, которая гарантированно находится в системном пути, ваш build.rs может быть таким простым:

fn main() {
   println!("cargo:rustc-link-lib=foo");
}

Однако в большинстве случаев вам нужно хотя бы использовать какую-то конфигурацию пакета, чтобы убедиться, что библиотека действительно установлена ​​и компоновщик может ее найти. Во многих случаях ваша библиотека достаточно мала, чтобы ее можно было построить как статическую библиотеку самим Cargo. pkg-config crate помогает с конфигурацией библиотеки и зависимостей, а cc выполняет грязную работу по созданию кода C из Cargo. Оба ящика выполняют этапы настройки и сборки, прежде чем вывести строки, необходимые для груза. В нашем примере исходный код использует zlib, поэтому мы используем pkg-config, чтобы найти и импортировать подходящую версию. В приведенном ниже примере кода также показано, как добавлять флаги компилятора и определения препроцессора.

fn main() {
    pkg_config::Config::new()
        .atleast_version("1.2")
        .probe("z")
        .unwrap();
    let src = [
        "src/file1.c",
        "src/otherfile.c",
    ];
    let mut builder = cc::Build::new();
    let build = builder
        .files(src.iter())
        .include("include")
        .flag("-Wno-unused-parameter")
        .define("USE_ZLIB", None);
    build.compile("foo");
}

Наконец, вам понадобится файл src/lib.rs для компиляции наших привязок. Здесь мы отключим предупреждения для соглашений об именах C, которые не соответствуют Rust, а затем просто добавим макрос в наш сгенерированный файл:

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
use libc::*;
include!("./bindings.rs");

Создание привязок

В то время как руководство пользователя bindgen, кажется, направляет вас к созданию привязок на лету в build.rs, на практике вам нужно будет отредактировать сгенерированный вывод, прежде чем помещать его в ящик. Создание одного или нескольких файлов через командную строку и передача вывода в ваш репозиторий предоставят вам наибольший контроль.

Первоначальная попытка генерации может выглядеть примерно так:

bindgen include/foo_api.h -o src/bindings.rs

Для настоящего заголовка с более чем несколькими вызовами API это, к сожалению, приведет к созданию гораздо большего количества определений, чем мы хотим или нуждаемся. Командная строка, которая генерировала часть bindings.rs для нашего проекта в Dwelo, оказалась чем-то близким к этому:

bindgen include/foo_api.h -o src/bindings.rs '.*' --whitelist-function '^foo_.*' --whitelist-var '^FOO_.*' -- -DUSE_ZLIB

Убедить генератор предоставить вам только то, что необходимо, а не запретить неопределенные символы, - это процесс проб и ошибок. Рассмотрите возможность генерации поэтапно и объединения результатов.

Это мощный, но не идеальный

Когда вы передаете заголовок bindgen, он вызывает препроцессор Clang, а затем жадно преобразует каждое определение символа, которое видит. Вам нужно будет внести изменения в командную строку и реорганизовать полученный результат.

Оригинальные дополнения Makefile / CMake

После -- в bindgen командной строке вы можете добавить любые флаги, которые вы обычно добавляете в компилятор при построении для библиотеки. Иногда это будут дополнительные пути включения, а иногда они будут необходимы, когда заголовки имеют #ifdef защищенные определения. Для нашей библиотеки поставщиков отсутствие определения OS_LINUX скрывает набор необходимых нам символов. (Что, вы думали, устаревший код будет использовать стандартные определения компилятора, такие как __linux__, вместо того, чтобы что-то придумывать? Извините, час комедии идет вниз по коридору и вверх по лестнице.) Если ваш сгенерированный вывод содержит загадочно отсутствующие функции, проверьте свои определяет.

Заголовки, включающие стандартные заголовки

Bindgen очень агрессивно генерирует определения для каждого доступного символа в выводе препроцессора, даже генерируя определения для транзитивных зависимостей, специфичных для системы, которые вам не нужны. Это означает, что если ваш заголовок включает stddef.h или time.h (или включает другой заголовок, который включает), вы получите кучу лишнего мусора в сгенерированном выводе. Еще хуже при компиляции кода C ++, поскольку компиляторы C ++, очевидно, должны экспортировать каждый символ, используемый из std, даже если это не нужно или нежелательно.

Ваш ящик должен открывать только то, что есть в API библиотеки, а не то, что было в файлах системных заголовков или стандартной библиотеке, в которой вы создавали свою генерацию. Это неприятно, особенно если функции и константы вашей библиотеки не следуют никаким соглашениям об именах. Единственный способ обойти это - регулярное выражение белого списка и множество проб и ошибок.

Препроцессор #defines

#define FOO_ANIMAL_UNDEFINED 0
#define FOO_ANIMAL_WALRUS 1
#define FOO_ANIMAL_DROP_BEAR 2
/* Argument should be one of FOO_ANIMAL_XXX */
void feed(uint8_t animal);

Это выглядит надуманным, но это запутанная версия шаблона, широко распространенного в нашей библиотеке C.

В C это работает нормально, потому что, когда вы включаете заголовок в свой источник, вы можете просто использовать что-то вроде FOO_ANIMAL_WALRUS напрямую, когда функция вызывает его. Компилятор C неявно преобразует литерал 1 в uint8_t, и код работает. Конечно, первоначальный автор должен создать enum typedef для ясности и использовать его, но он этого не сделал, и это все еще законный код C, с которым мы должны иметь дело.

pub const FOO_ANIMAL_UNDEFINED: u32 = 0;
pub const FOO_ANIMAL_WALRUS: u32 = 1;
pub const FOO_ANIMAL_DROP_BEAR: u32 = 2;
extern "C" {
    pub fn feed(animal: u8);
}

Хотя bindgen достаточно умен, чтобы распознавать символы как константы, все же остается несколько проблем. Во-первых, bindgen должен угадывать тип для каждого FOO_ANIMAL_XXX. В данном случае, видимо, угадано u32 (что не только не соответствует параметру нашей функции, но и технически неверно). Это приводит к другой проблеме: Rust потребует от нас явного преобразования FOO_ANIMAL_WALRUS в u8 при вызове feed. Не очень эргономично, правда? Чтобы исправить это, нам нужно изменить типы сгенерированных констант, чтобы они соответствовали определению функции. Мы исправим проблему с перечислением позже в безопасной оболочке.

Некоторые структуры должны быть непрозрачными

Наша поставляемая библиотека передает указатель на объект контекста почти для каждой функции, кроме инициализации. (Давайте назовем его пока foo_ctx_t.) Это широко используемый шаблон и вполне разумный. Но из-за ошибки реализации наш заголовочный файл определяет foo_ctx_t вместо прямого объявления. К сожалению, это приводит к утечке внутреннего содержимого foo_ctx_t. Затем эта утечка транзитивно заставляет нас узнать и определить кучу других зависимых типов, которые нам не важны.

В Rust не допускается отдельное объявление и определение структур. В отличие от C, мы не можем просто объявить foo_ctx_t в Rust, не предоставив для него определения, и компилятор Rust должен распознать имя foo_ctx_t, чтобы использовать указатель на него в качестве аргумента функции. Но мы можем использовать обходные пути, чтобы не определять его полностью. Ни один из них не идеален, но на момент написания этой статьи есть две альтернативы, которые, по крайней мере, функциональны на практике.

Мы можем заменить определение структуры типом перечисления, не имеющим вариантов, что для удобства приведет к ошибке компиляции, если вы случайно попытаетесь создать ее или использовать как что-либо, кроме цели указателя. Это расстраивает сторонников чистоты типов, потому что мы технически лжем компилятору, но он работает:

pub enum foo_ctx_t {}

Или мы можем заменить его внутренности частным полем типа нулевого размера. Это то, что bindgen делает по умолчанию, и это нормально, если вы не полагаетесь на mem::size_of:

pub struct foo_ctx_t {
    _unused: [u8; 0],
}

Const-правильность

Bindgen преобразует константные указатели C в Rust const * и недекорированные указатели C в mut *. Если исходный код был правильным, это нормально работает. В противном случае это может вызвать головную боль позже при попытке создать безопасную оболочку. По возможности исправьте библиотеку.

Пример ниже можно легко использовать внутри небезопасного блока Rust с нормальной (неизменной) ссылкой на time_t и изменяемой ссылкой на tm:

// Generated from <time.h>
extern "C" {
    pub fn gmtime_r(_t: *const time_t, _tp: *mut tm) -> *mut tm;
}

Технически вам не нужно изменять библиотеку C, чтобы изменить указатель на const * во внешнем определении Rust. Фактически, в таблице символов для библиотек C нет даже списка параметров, поэтому компоновщик Rust вообще не имеет возможности подтвердить правильность параметров вашей функции (к счастью, это не относится к символам C ++). Если вы изменяете типы указателей Rust, вы несете ответственность за проверку того, что инварианты для константных указателей действительно верны для библиотеки.

Острые края

Если ваши функции возвращают значения для ошибок, сделайте себе одолжение и убедитесь, что аннотация #[must_use] прикреплена к каждой из них. Это, по крайней мере, даст некоторое представление, если вызывающие абоненты забудут проверить возвращаемое значение на наличие ошибок, и это поможет позже, когда мы заключим все в безопасные слои.

Напишите README.md файл с подробным описанием того, как именно вы вызвали bindgen, и зафиксируйте его в репозитории. Поверьте, вы захотите это позже, когда поймете, что чего-то не хватает.

Добавьте пару модульных тестов на вменяемость, затем попробуйте запустить cargo test. Bindgen помогает создать несколько собственных тестов, чтобы убедиться, что сгенерированные выравнивания структур в порядке. Вы также можете запустить cargo doc --open в своем ящике, чтобы получить общее представление о том, что вы экспортируете, и дважды убедиться, что вы случайно не открыли неправильные вещи.

Все это, как говорится, эти ручные шаги необходимы, потому что bindgen делает все, что в его силах, с имеющейся у него информацией. Процесс генерации выявит все небольшие структурные проблемы в вашей библиотеке C.

Когда вы все закончите, надеюсь, у вас останется не такой уж и отвратительный пакет Rust, который предоставляет доступ к API исходной библиотеки через небезопасный Rust. Вы на полпути! Далее мы поговорим о том, как взять эти привязки и защитить их эргономичными и безопасными оболочками, чтобы код нашего приложения не мог использовать их неправильно.