*-sys crate を作る


C++ユーザーの為のリンクの話1, 2 でプログラムのリンクに関する基礎的な事項を、Rust の Foreign Function Interface (FFI) で Rust から C で定義された関数を ABI 経由で呼び出す方法に付いて見ましたが、今度はこれを配布できる crate の形にしていきましょう。

crate として配布する際には考えることがいくつかあります。それを順番に見ていきましょう。

ライブラリをどうやって用意するか?

まずアーカイブなり共有ライブラリを用意しないことにはFFIを使ってその機能を使うことができません。これにはいくつか方法がありますが、どれも一長一短になります。

  • システムに既に存在しているものを使う
    • システムの OS やパッケージ管理方法毎に作業が必要
    • システムで単一のライブラリしか存在しないので異なる複数のバージョンのライブラリが使えない
    • 他のパッケージ管理システムによる恩恵に預かれる
  • ビルド済みのライブラリをバイナリで配布する
    • ライブラリを配布するHTTPサーバーが必要、ビルド時にHTTPアクセスが必要
    • 環境毎にバイナリが必要
    • Intel MKL の様にソースコードがされていない再配布可能なライブラリに対しても crate 側で制御できる
    • LLVM の様にビルドに負荷がかかる場合でも crate ユーザーには負荷がかからない
  • crate に C のコードを同梱して、Rustのコードをコンパイルするタイミングで同じように C のコードもコンパイルする
    • システムに C コンパイラや make, cmake 等が存在している事が必要
    • ビルドするため余分にコンパイル時間がかかる
    • ビルドしたライブラリを crate 側で完全に制御出来る

例えば Python の wheel はバイナリを同梱する事が可能で、また Julia の BinaryProvider.jl はビルド済みバイナリを配布するためのものです。これらの言語はインタプリタ実行でリンクを行わないので、最終的に共有ライブラリの形にする必要があるので、静的にリンクできる Rust とは事情が少し異なる事には注意です。

これについてはユーザーの環境によって最善な方法が異なるので可能な限り複数の方法をサポートするしかありません。

静的にリンクするか、動的にリンクするか

実行時に共有ライブラリをどうやってシステムから探してくるかについては C++ユーザーの為のリンクの話2 に簡単にまとめましたが、

  • システムのライブラリを使う場合は共有ライブラリ
    • そもそもアーカイブの形で提供されていない事も多い
    • ld-linux.so が何も設定しなくても見つけれるようにシステムの管理者が用意しているはず
  • コンパイルする場合は静的リンク
    • 自分でビルドしたライブラリを ld-linux.so が発見出きるようにする事は非常に面倒
    • ビルドした実行ファイルを配布する際に静的リンクしてあれば勝手に同梱される

apt の様な配布システムの外で共有ライブラリを用意するのは悪手で可能な限り避けるべきです。ビルド済みのライブラリを配布する場合はアーカイブとして配布して静的にリンクするようにしましょう。

cargo でライブラリをリンクする

前回 では rustc を使って直接リンクしてしました

$ rustc main.rs -L. -la

しかし Rust ユーザーのほとんどは cargo を使ってビルドするはずですね?上のコマンド引数を cargo 内で指定するにはいくつか方法があります。

一番簡単なものは #[link] 属性を extern "C" 句に追加する事です

#[link(name = "fftw")]
extern "C" {
  // libfftw.{a,so} に含まれる関数の定義を書く
}

これで cargo が -lfftw をリンカに追加してくれます。C の場合にも -L を使わずにリンクできるような、システムに含まれる共有ライブラリをリンクする場合にはこれを用いるのが良いでしょう。またこの際

別のさらに複雑な処理や複雑なリンクオプションを指定する必要がある場合には build.rs を書きます。これはビルド時に実行されるスクリプトを Rust で書いたもので、ライブラリや実行ファイル本体で無いので src 以下でなく Cargo.toml が置いてあるディレクトリに置くのが通例です。その上で

Cargo.toml
[package]
build = "build.rs"

の様に [package] 節で build 要素として指定します。

build.rs
// 以下は略、詳しくは https://github.com/rust-math/fftw/blob/master/fftw-src/build.rs
// fn download_archive_windows(out_dir: &Path) -> Result<()> 
// fn build_unix(out_dir: &Path)

fn main() {
    let out_dir = PathBuf::from(var("OUT_DIR").unwrap());
    if cfg!(target_os = "windows") {
        download_archive_windows(&out_dir).unwrap();
        println!("cargo:rustc-link-search={}", out_dir.display());
        println!("cargo:rustc-link-lib=libfftw3-3");
        println!("cargo:rustc-link-lib=libfftw3f-3");
    } else {
        build_unix(&out_dir);
        println!("cargo:rustc-link-search={}", out_dir.join("lib").display());
        println!("cargo:rustc-link-lib=static=fftw3");
        println!("cargo:rustc-link-lib=static=fftw3f");
    }
}

これは fftw-src cratebuild.rs の一部を抜き出したものです。cargo が fftw-src crate をビルドしようとするとき、まずこの main 関数が実行されます。これはフル機能の通常の Rust で、この際に使用する外部 crate は

Cargo.toml

[build-dependencies]
anyhow = "1.0.31"
cc = "1.0"
fs_extra = "~1.2.0"
ftp = "3.0"
zip = "0.5"

のように [build-dependencies] 節に書きます。FFTWはWindows向けにはビルド済みのバイナリを配布しているのでそれを取得するか、UNIX (macOS, Linux) 向けには C のソースコードをビルドします。cfg! マクロはコンパイル時の環境を得る為のもので、これも build.rs 中で使えるのでこれで Windows かどうかを判定しています。その後にある

build.rs
        println!("cargo:rustc-link-search={}", out_dir.join("lib").display());
        println!("cargo:rustc-link-lib=static=fftw3");

はより複雑なリンクオプションを cargo に伝えるためのものです。build.rsmain 関数は標準出力を cargo にキャプチャされた状態で実行され、その中で cargo: から始まる行があった場合、 cargo はその行を自分への命令だと認識します。対応している命令の表は ここ にあります。rustc-link-search は上の rustc コマンドの場合の -L に対応し、rustc-link-lib-l に対応します。rustc-link-lib にはさらに動的リンクをするのか静的リンクをするのかを記述できます。

途中で出てくる OUT_DIR 環境変数はビルド時に書き込んでも良いディレクトリが指定されています。典型的には /home/username/github.com/rust-math/fftw/target/debug/build/fftw-src-74faadd11ef2a1a9/out/ の様に target/ 以下のディレクトリが割り当てられます。この out/ のディレクトリには

output
cargo:rustc-link-search=/home/username/github.com/rust-math/fftw/target/debug/build/fftw-src-74faadd11ef2a1a9/out/lib
cargo:rustc-link-lib=static=fftw3
cargo:rustc-link-lib=static=fftw3f

のようなファイルが出来上がって build.rsmain の標準出力をキャプチャした結果が保存されています。デバッグに便利です。

C のヘッダから FFI の定義を生成する (rust-bindgen)

前回の記事では func_a のFFI側の定義を手動で書きましたが、これを自動的に生成してくれるのが rust-bindgen です。これは libclang を使って C のヘッダーファイルを解析するので(C言語として正しい文法である限り)正確に C API を読み取る事が可能で、これから Rust 用の FFI 定義を生成してくれます。

$ cargo install bindgen

bindgenのドキュメント では C header 中にある #ifdef の様な分岐を正確に読み取るため、コンパイル時に bindgen を実行することを推奨していますが、これだと crate のユーザーがコンパイル時に libclang を要求されてしまうので、少し不便です。その為あらかじめ特定の環境で bindgen を実行して生成した Rust コードを crate にそのまま追加してしまう方法を私は良く取っています。

使い方の例として fftw-sys の場合を見てみましょう

bindgen \
  --use-core \
  --with-derive-{default,eq,hash,ord} \
  --whitelist-type="^fftw.*" \
  --whitelist-var="^FFTW.*" \
  --whitelist-function="^fftw.*" \
  --blacklist-type="FILE" \
  --blacklist-type="_.*" \
  --blacklist-type="fftw.*_complex" \
  --blacklist-function="fftwl_.*" \
  --default-enum-style=rust \
  wrapper.h \
  > src/fftw.rs
wrapper.h
#include <fftw3.h>

wrapper.h という C ヘッダファイルを用意して、それに対して bindgen を実行しています。この例では自明な wrapper.h しか書いていませんが、これを libclang で読み取るので C として有効な記述は何を書いても良く、例えば #define を使って C で定義されているフラグを指定する等ができます。引数を順番に見ていきましょう

--user-core

これは std の代わりに core を使います。no_std な crate を作るときに使います。

--with-derive-*

構造体や enum に標準にある derive を差し込みます

fftw.rs
#[repr(u32)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum fftw_r2r_kind_do_not_use_me {
    FFTW_R2HC = 0,
    FFTW_HC2R = 1,
    FFTW_DHT = 2,
    FFTW_REDFT00 = 3,
    FFTW_REDFT01 = 4,
    FFTW_REDFT10 = 5,
    FFTW_REDFT11 = 6,
    FFTW_RODFT00 = 7,
    FFTW_RODFT01 = 8,
    FFTW_RODFT10 = 9,
    FFTW_RODFT11 = 10,
}

残念ながら現在は追加の custom derive (例えばserdeのSerialize) を差し込む機能はありません。なのでどうしても必要な時は sed などで頑張ります。

--whitelist-*, --blacklist-*

型(type), グローバル変数(var), 関数(function) に対して、それぞれ生成するものを whitelist/blacklist の形で正規表現で指定します。bindgen は C として見えている型、変数、関数の全てに対して定義を生成してしまうので、例えば #include <stdlib.h> が include 中で混入したら標準ライブラリの stdlib.h にある全ての関数の定義を生成します。これは望ましい挙動ではないので、可能な限り whitelist で調整します。Cには名前空間が無いため、典型的なライブラリではそのライブラリの名前を使った prefix を関数や構造体に付けているはずでそれ使うと上手く行きやすいです。上の例では FFTW は fftw_xxx という命名規則を持っている事を使っています。この際構造体や関数の定義に含まれているものも whitelist で自動的に追加されてしまうため、それを避ける場合は whitelist の後で blacklist で弾きます。

  --blacklist-type="FILE" \
  --blacklist-type="fftw.*_complex" \

これにより

fftw.rs
extern "C" {
    pub fn fftw_export_wisdom_to_file(output_file: *mut FILE);
}

のように FILE を使う関数が生成されますが、FILE の定義が生成されません。なのでこのファイルは単独ではコンパイル出来なくなりますが、このファイル src/fftw.rs は通常の Rust の module でなく、次の様に src/lib.rs 内で include! して使うので使えます

lib.rs
#![allow(non_camel_case_types)]  // 生成した定義が Rust の命名規則と合わない事による警告を抑制

// 中略

// fftw.rs 内で参照されている定義を置き換える
use libc::FILE;
pub use num_complex::Complex32 as fftwf_complex;
pub use num_complex::Complex64 as fftw_complex;

include!("fftw.rs"); // bindgen で生成したファイルをここに展開