C ABI と bindgen

このページの目次

Rust と C が互いに呼び出すためには、呼び出し規約、型のレイアウト、リソースのセマンティクスを同時に整合させる必要があります。関数のシグネチャからは引数がポインタであることは分かりますが、通常、それが指す要素数、有効期間、解放元、そしてコールバックが別のスレッドから実行される可能性があるかどうかは分かりません。

bindgen は宣言とレイアウトの自動生成を担い、安全なラッパーは外部契約を Rust が維持できる不変条件に変換します。生成された関数に unsafe を付けるだけでは、安全なラッパーにはなりません。

C ヘッダーとビルド設定対象 ABI・マクロ定義・フィールド配置・公開シンボル1生のRustバインディングextern 宣言、裸のポインタ、および C 互換型2手動による安全な抽象化の構築長さ、所有権、ライフサイクル、スレッド、およびエラー規約の検証3安全なRustの呼び出し元不変条件が維持されているインターフェースを通じてのみ操作可能4安全なFFIラッパー: シグネチャは契約の一部に過ぎないbindgen は宣言を生成できますが、ポインタのシグネチャからはすべてのセマンティクスを推論できません。

3つの「互換性」をそれぞれ見る

extern "C" は対象プラットフォームの C 呼び出し規約を指定し、repr(C) は構造体などの型の C レイアウト規則を指定し、C 型エイリアスは対象プラットフォームの基礎となる型と一致します。これらは互いに代替できません。

C インターフェースの型Rust での表現確認すべき点
int、longcore::ffi::c_int、c_long対象のエイリアスを使用し、ホストの幅をハードコードしない
size_tbindgen が生成する型(通常は usize)対象の ABI と生成オプション
const char**const c_charNUL 終端文字列か、長さ付きのバイト領域か
void**mut c_void所有権、実際のオブジェクト型、および null 許容性
関数ポインタ(NULL 可)Option<extern "C" fn(...)> など引数、戻り値の型、unsafe、およびスレッド規約
C enum通常は整数型に定数、または生成された新しい型列挙された項目だけでなく、あり得るすべての入力値

一般的な 64 ビット Unix では LP64、64 ビット Windows では LLP64 が使用されるため、long の幅が異なります。解決策は実際の C long に一致させることです。Windows で強制的に i64 に置き換えると、移植性を修復するのではなく宣言を直接壊してしまいます。

repr(C) はフィールド内部型のデフォルトのレイアウトを再帰的に変更しません。String や Vec を repr(C) 構造体に含めても、C が理解できるコンテナにはなりません。フィールドの互換性、アラインメント、パッキング、およびコンパイルオプションを一緒に確認する必要があります。C enum の基底型はコンパイラのオプションによって影響を受ける可能性があり、また C では Rust の列挙型で定義されていない整数値を保持できます。無効な Rust の discriminant を直接構築すると未定義動作になります。Rust 型レイアウト

完全な bindgen プロジェクト

以下では、バイトスライスを同期的に読み取る C 関数を例に契約を説明します。この関数はポインタを保持せず、n=0 の場合は p にアクセスしません。n>0 の場合、呼び出し元は少なくとも n バイトの読み取り可能領域を提供する必要があります。結果はすべてのバイトの排他的論理和(XOR)です。

Cargo.toml:

[package]
name = "ffi-demo"
version = "0.1.0"
edition = "2024"

[build-dependencies]
bindgen = "0.72"
cc = "1"

wrapper.h:

#ifndef DEMO_WRAPPER_H
#define DEMO_WRAPPER_H
#include <stddef.h>
#include <stdint.h>
uint8_t demo_xor(const uint8_t *p, size_t n);
#endif

demo.c:

#include "wrapper.h"
uint8_t demo_xor(const uint8_t *p, size_t n)
{
    uint8_t result = 0;
    for (size_t i = 0; i < n; ++i)
        result ^= p[i];
    return result;
}

build.rs:

use std::{env, path::PathBuf};

fn main() {
    println!("cargo:rerun-if-changed=wrapper.h");
    println!("cargo:rerun-if-changed=demo.c");
    cc::Build::new().file("demo.c").compile("demo");
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .allowlist_function("demo_xor")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("generate bindings");
    bindings.write_to_file(
        PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"))
            .join("bindings.rs"),
    ).expect("write bindings");
}

src/main.rs:

mod raw {
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

fn xor_bytes(bytes: &[u8]) -> u8 {
    // SAFETY: bytes は呼び出し中に読み取り可能であり、長さが正しい。
    // C はポインタを保持または変更せず、長さがゼロのときは間接参照しないため、
    // 空スライスの非ヌルポインタでもこのインターフェース契約に適合する。
    unsafe { raw::demo_xor(bytes.as_ptr(), bytes.len()) }
}

fn main() {
    assert_eq!(xor_bytes(&[]), 0);
    assert_eq!(xor_bytes(&[1, 2, 3, 4]), 4);
    println!("FFI checks passed");
}

cargo run を実行するには、C コンパイラと bindgen が使用する libclang が必要です。cc は C 実装をビルドしてリンクし、bindgen は Rust の宣言を生成するだけです。生成されたファイルを含めるだけでは、ネイティブ関数の実装が自動的に提供されるわけではありません。bindgen ビルドチュートリアル

クロスコンパイルの場合、Clang のターゲット、sysroot、インクルードパス、およびマクロが、実際にライブラリをコンパイルする設定と一致している必要があります。生成に成功したからといって、ヘッダーが解析できるだけでなく、バインディングとデプロイされたバイナリの ABI が一致するとは限りません。重要な構造体については、両端で sizeof、alignof、およびフィールドのオフセットを比較できます。

ポインタのライフサイクルと解放元

CStr::from_ptr は有効な NUL 終端文字列を要求し、スキャン範囲と借用期間の両方で読み取り可能である必要があります。任意の裸のアドレスを検証するものではなく、バイトが UTF-8 であることを証明するものでもありません。CString は Rust が割り当てた C 文字列を所有し、as_ptr の借用はオブジェクトのライフサイクルを超えてはなりません。

境界を跨ぐリソースの移譲には、対となるプロトコル(例:create/destroy、または Rust による割り当てと Rust がエクスポートする解放関数)を提示する必要があります。Box や CString を無造作に C の free で解放したり、Rust の割り当てレイアウト契約に適合しない C メモリに対して Vec::from_raw_parts を呼び出したりしてはいけません。

C がポインタを保持する場合、一時的なスタックオブジェクトや 1 回の関数呼び出し内の借用では通常不十分です。登録解除や保留中のコールバックの完了待機後に解放を保証する所有権オブジェクトが必要です。単にアドレスを usize に変換しても、ライフサイクルは延長されません。

create/destroy をコピーできないオブジェクトとしてラップする

同期スライスの例ではメモリを借用しているだけです。C が長期リソースを返す場合、不透明型を使用してレイアウトを隠蔽し、Rust が関数を通じてのみアクセスできるようにできます。以下は、前述のプロジェクトに拡張を加えた完全な手順です。wrapper.h の #endif の前に宣言を追加します。

typedef struct demo_counter demo_counter;
demo_counter *demo_counter_new(uint32_t initial);
uint32_t demo_counter_get(const demo_counter *p);
int demo_counter_add(demo_counter *p, uint32_t amount);
void demo_counter_free(demo_counter *p);

demo.c に実装を追加します。教学インターフェースでは、初期値は 100 を超えられないこと、作成に失敗した場合は NULL を返すこと、add のオーバーフローは 1 を返し元の値を変更しないこと、成功した場合は 0 を返すことが規定されています。get/add の引数は、まだ存続しているオブジェクトを指している必要があります。

#include <stdlib.h>
struct demo_counter { uint32_t value; };

demo_counter *demo_counter_new(uint32_t initial) {
    if (initial > 100) return NULL;
    demo_counter *p = malloc(sizeof *p);
    if (p) p->value = initial;
    return p;
}
uint32_t demo_counter_get(const demo_counter *p) {
    return p->value;
}
int demo_counter_add(demo_counter *p, uint32_t amount) {
    if (UINT32_MAX - p->value < amount) return 1;
    p->value += amount;
    return 0;
}
void demo_counter_free(demo_counter *p) {
    free(p);
}

build.rs で allowlist_function("demo_xor") を allowlist_function("demo_.*") に変更し、新しい関数も宣言が生成されるようにします。必要なパラメータ型は依存関係に従って含まれます。その後、src/main.rs を以下の所有型ラッパーに置き換えます。bindgen allowlist

demo_counter_newNULL: 拒否または割り当て失敗Counter(NonNull)一意の所有と非公開フィールドDropC の free 関数を呼び出すadd 失敗: エラーを返し、数値は変更されない同じ Counter によって所有され、解放される不透明ハンドル: 作成に成功して初めて所有状態に入るNonNull は NULL を除外するのみです。有効なオブジェクト、一意の解放権、およびスレッドの規則は依然としてインターフェース契約に由来します。
use std::ptr::NonNull;
#[allow(non_camel_case_types)]
mod raw {
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
struct Counter(NonNull<raw::demo_counter>);
impl Counter {
    fn new(initial: u32) -> Result<Self, &'static str> {
        // SAFETY: C インターフェースは任意の u32 を受け入れ、成功時に新しい一意のオブジェクトを返す。
        let pointer = unsafe { raw::demo_counter_new(initial) };
        NonNull::new(pointer).map(Self).ok_or("counter creation failed")
    }
    fn value(&self) -> u32 {
        // SAFETY: プライベートハンドルは self によって所有されており、解放されていない。get はオブジェクトを変更しない。
        unsafe { raw::demo_counter_get(self.0.as_ptr()) }
    }
    fn add(&mut self, amount: u32) -> Result<(), &'static str> {
        // SAFETY: 有効なハンドルへの排他的アクセス。関数はポインタを保持せず、失敗しても値を変更しない。
        match unsafe { raw::demo_counter_add(self.0.as_ptr(), amount) } {
            0 => Ok(()),
            _ => Err("counter addition failed"),
        }
    }
}
impl Drop for Counter {
    fn drop(&mut self) {
        // SAFETY: C の作成関数と対になり、通常の所有パスではちょうど 1 回解放される。
        unsafe { raw::demo_counter_free(self.0.as_ptr()) }
    }
}
fn main() {
    // SAFETY: 長さがゼロの場合、C はポインタにアクセスしない。NULL は demo_xor の規約に適合する。
    assert_eq!(unsafe { raw::demo_xor(std::ptr::null(), 0) }, 0);
    assert!(Counter::new(101).is_err());
    let mut counter = Counter::new(10).expect("create counter");
    counter.add(5).unwrap();
    assert_eq!(counter.value(), 15);
    assert!(counter.add(u32::MAX).is_err());
    assert_eq!(counter.value(), 15);
    drop(counter);
}

ラッパーは Clone を実装せず、生のポインタコンストラクタも公開しないため、2 つの所有者がそれぞれ同じリソースを解放することを防ぎます。&mut self は更新要件を Rust の呼び出し元に明示します。もし元のライブラリが別のスレッドからオブジェクトを自分で変更する場合は、追加で同期契約を構築する必要があり、このシグネチャだけでは不十分です。

NonNull は Send/Sync を自動的に実装しないため、このラッパーは勝手にスレッド間の使用を保証するものではありません。また、アドレスの読み取り可能性やオブジェクト型の正しさを証明するものでもなく、new の外部保証が有効性の源となります。Drop は通常のスコープ外への退出と展開パスを処理します。mem::forget やプロセス終了の場合、リソースが解放されない可能性があるため、メモリ安全性は「デストラクタが常に実行されること」に依存してはいけません。NonNull

この作成インターフェースは、「初期値が許可されない」と「割り当て失敗」を NULL に統合しているため、Rust は C が捨てたエラー原因を信頼できる形で再構築できません。区別が必要な場合は、NULL から具体的な原因を推測するのではなく、C レベルでステータスコードと出力ハンドルプロトコルを設計すべきです。

コールバック:スレッド、再帰呼び出し、および解除は関数ポインタよりも重要

callback + void* context を登録する際は、以下の 4 点を明確にしてください:呼び出しはどのスレッドで行われるか、並行して行われるか、再帰呼び出しされるか、そして解除が返った後に保留中のコールバックがないことが保証されるか。Rust 側では、ロック、アトミック操作、スレッド制限、または他の所有権構造を使用してラップするかを決定します。

C のシグネチャに const がないからといって、すぐに &mut T を構築しないでください。排他参照は、借用期間全体で Rust のエイリアスと有効性の規則を満たすことを要求しますが、外部コールバックの再帰呼び出しや並行アクセスはこれを違反する可能性があります。Rustonomicon FFI

登録解除後も context にアクセスするのは誰か

登録解除だけでは足りません。このプロトコルでは先に入ったコールバックがまだアクセス中です。終了前に context の解放を試すと依存が残ることが分かります。安全なラッパは、新規アクセスがないことと最後の既存アクセスが終わったことの両方を確立します。

図を準備しています
登録解除後も context にアクセスするのは誰か

新規コールバックの停止と実行中の終了待ちの両方が必要で、関数ポインタの型はこの時系列を表しません。

パニックと言語間展開

通常の extern "C" は、展開(unwinding)が越えることを許可しない ABI です。Rust のパニックがこのような境界を越えようとするとプロセスは終了します。「すべての Rust パニックは C 境界を跨いで未定義動作(UB)になる」という一般的な言い伝えをそのまま受け継ぐことはできません。一方、許可されていない展開の Rust 境界に外部例外が入ると、未定義動作のリスクがあります。

本当に境界を跨ぐ展開が必要な場合は、双方のツールチェーンがサポートする C-unwind を使用し、全体としての例外プロトコルを構築する必要があります。通常の C ライブラリインターフェースでは、Rust のコールバック内で捕捉可能なパニックをエラーに変換するのが一般的ですが、catch_unwind は展開型パニックのみを捕捉し、panic=abort は捕捉できません。また、任意の外部例外に対する汎用捕捉器でもありません。復元後にデータ構造が依然として一貫しているかどうかは、アプリケーション側で責任を負います。FFI 展開ルール

Rust 2024 では、外部宣言には unsafe extern ブロックの使用が義務付けられています。固定されたシンボル名をエクスポートするには、#[unsafe(no_mangle)] などの対応する構文を使用します。unsafe 属性は名前衝突などのグローバルな義務を強調しますが、関数に C 呼び出し規約を自動的に付与するものではありません。Rust 外部ブロック

自動生成の境界

bindgen は、長さパラメータ間のセマンティクス、ポインタの保持に関する約束、またはスレッド制約を理解しません。複雑な C++ テンプレートやインライン関数の処理も、「すべてサポートできない」と単純化できるものではありません。一部のシナリオでは、ラッパーの生成、具体化されたインスタンスの処理、または追加のコンパイル補助ファイルの処理が可能であり、これはオプションと制限に依存します。複雑な C++ インターフェースでは、まず明確な C インターフェース層を整理することが推奨されることがよくあります。bindgen FAQ

生成層は機械的かつ再構築可能であり、安全層は各 unsafe が依存する外部保証を記録し、呼び出し元が通常の安全な操作を通じてそれを破壊できないようにします。契約の推論の詳細については、unsafe の soundness を参照してください。