C ABI 与 bindgen

本页目录

让 Rust 与 C 互相调用,需要同时对齐调用约定、类型布局和资源语义。函数签名能告诉你参数是一个指针,却通常不能告诉你它指向多少元素、能保留多久、由谁释放,以及回调是否可能从其他线程执行。

bindgen 解决声明与布局的自动生成,安全封装负责把外部契约转成 Rust 能维护的不变量。把生成函数标成 unsafe 并没有完成安全封装。

C 头文件与构建配置目标 ABI、宏定义、字段布局与导出符号1原始 Rust 绑定extern 声明、裸指针与 C 兼容类型2人工建立安全抽象验证长度、所有权、生命周期、线程与错误约定3安全 Rust 调用者只能通过已维护不变量的接口操作4bindgen 能生成声明,不能从指针签名推导全部语义。安全 FFI 封装:签名只是契约的一部分

三种“兼容”分别看

extern "C" 指定目标平台的 C 调用约定;repr(C) 指定结构体等类型的 C 布局规则;C 类型别名匹配目标平台的基础类型。三者互不替代。

C 接口类型Rust 表达需要核对
int、longcore::ffi::c_int、c_long使用目标别名,不把宿主宽度写死
size_tbindgen 生成类型,常为 usize目标 ABI 与生成选项
const char**const c_char是 NUL 结尾字符串,还是带长度字节区
void**mut c_void所有权、实际对象类型与可空性
函数指针,可为 NULLOption<extern "C" fn(...)> 等参数、返回类型、unsafe 与线程约定
C enum常用整数类型加常量或生成的新类型所有可能输入值,而不只命名枚举项

常见 64 位 Unix 使用 LP64,64 位 Windows 使用 LLP64,所以 long 的宽度不同。解决方法是匹配实际 C long;在 Windows 强行换成 i64 会直接破坏声明,而不是修复可移植性。

repr(C) 不递归改变字段内部类型的默认布局;把 String 或 Vec 放进 repr(C) 结构体也不会变成 C 可理解的容器。字段兼容、对齐、packing 与编译选项需要一起核对。C enum 的底层类型还可能受编译器选项影响,且 C 可存放 Rust 枚举未定义的整数值;直接构造无效 Rust discriminant 会产生未定义行为。Rust 类型布局

一个完整 bindgen 工程

以下用一个同步读取字节切片的 C 函数说明契约。它不保留指针,n=0 时不访问 p;n>0 时调用方必须提供至少 n 个可读字节。结果是所有字节按位异或。

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 声明;仅 include 生成文件不会神奇地提供原生函数实现。bindgen 构建教程

交叉编译时,还必须让 Clang 的 target、sysroot、include 路径和宏与真正编译库的设置一致。生成成功只说明头文件能解析,不证明绑定与部署的二进制 ABI 一致。对关键结构可比较两端 sizeof、alignof 和字段偏移。

指针生命周期与释放方

CStr::from_ptr 要求有效的 NUL 终止字符串,其扫描范围与借用期间都要可读;它不验证任意裸地址,也不证明字节是 UTF-8。CString 则拥有 Rust 分配的 C 字符串,as_ptr 的借用不能超过对象生命周期。

跨边界移交资源应给出成对协议,例如 create/destroy 或 Rust 分配、Rust 导出的释放函数。不能随意用 C free 释放 Box/CString,也不能对不符合 Rust 分配布局契约的 C 内存调用 Vec::from_raw_parts。

若 C 会保存指针,临时栈对象或一次函数调用内的借用通常不够。需要一个所有权对象保证取消注册、等待在途回调完成后再释放;单纯把地址转成 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)唯一拥有,私有字段Drop调用 C 的 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 创建函数配对,普通拥有路径恰好释放一次。
        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,也不公开原始指针构造器,防止两个拥有者各自释放同一资源。&mut self 把更新要求表达给 Rust 调用者;如果原始库会自行从其他线程修改对象,还必须额外建立同步契约,单靠这个签名不够。

NonNull 不自动实现 Send/Sync,因此这个封装没有凭空承诺跨线程使用。它也不证明地址可读或对象类型正确,new 的外部保证才是有效性的来源。Drop 处理正常离开作用域与展开路径;mem::forget 或进程终止仍可能不释放资源,所以内存安全不能依赖“析构永远会执行”。NonNull

该创建接口将“初值不允许”和“分配失败”合并成 NULL,Rust 无法可靠重建被 C 丢掉的错误原因。需要区分时,应从 C 层设计状态码与输出句柄协议,而不是根据 NULL 猜测具体原因。

回调:线程、重入与注销比函数指针更重要

注册 callback + void* context 时,明确四件事:调用发生在哪些线程,是否并发,是否重入,以及注销返回后是否保证没有在途回调。Rust 侧再决定用锁、原子、线程限制或其他所有权结构封装。

不要因为 C 签名没有 const 就立刻构造 &mut T。独占引用要求在整个借用期间满足 Rust 的别名与有效性规则,外部回调的重入或并发访问可能违反它。Rustonomicon FFI

注销以后,谁还在访问 context?

把注销想成“关闭入口”还不够:在此协议中,入口关闭前已经开始的回调仍拥有一次访问机会。尝试在它结束前释放 context,模型会指出剩余依赖。真实安全封装必须从外部库获得“不会新增访问”和“最后一次访问已结束”这两个事实。

正在呈现知识画面
注销以后,谁还在访问 context?

禁止新回调与等待已有回调结束缺一不可;函数指针签名不表达这些时序。

panic 与跨语言展开

普通 extern "C" 是不允许展开越过的 ABI。Rust panic 试图越过这种边界时会终止进程;不能继续沿用“所有 Rust panic 跨 C 边界必然 UB”的笼统说法。外部异常进入不允许展开的 Rust 边界则有未定义行为风险。

如果确实需要跨边界展开,应按双方工具链支持使用 C-unwind 并建立整体异常协议。普通 C 库接口更常见的做法是在 Rust 回调内部把可捕获的 panic 转成错误,但 catch_unwind 只捕获展开型 panic,不能捕获 panic=abort,也不是任意外部异常的通用捕获器。恢复后数据结构是否仍一致仍由应用负责。FFI 展开规则

Rust 2024 要求外部声明使用 unsafe extern 块;导出固定符号名使用 #[unsafe(no_mangle)] 等相应语法。unsafe 属性强调名字冲突等全局义务,它不会自动为函数加上 C 调用约定。Rust 外部块

自动生成的边界

bindgen 不会理解长度参数间的语义、不保留指针的承诺或线程约束。复杂 C++ 模板和内联函数的处理也不是简单的“全部不能支持”:有些场景可以生成包装、处理具体实例或额外编译辅助文件,取决于选项和限制;复杂 C++ 接口常值得先整理一个清晰的 C 接口层。bindgen FAQ

生成层保持机械、可重建;安全层记录每个 unsafe 所依赖的外部保证,并让调用者无法通过普通安全操作破坏它。更多契约推理见 unsafe 的 soundness。