Skip to content
Published at:

23. Foreign Functions — 外部函数接口(FFI)

Rust 的外部函数接口(FFI)让 Rust 代码能够调用 C 库中的函数,也让 C 代码能够调用 Rust 库。由于大多数操作系统提供 C 接口,FFI 是 Rust 与外部世界交互的桥梁。本章介绍 extern 块声明、C 类型映射、内存管理跨 FFI 边界、以及构建 safe wrapper 的最佳实践。

extern 块:声明外部函数

rust
use std::os::raw::c_char;

extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

extern "C" 块中的函数使用 C 的调用约定(ABI)。这些函数自动被视为 unsafe——编译器无法验证 C 代码是否遵守 Rust 的安全规则。

extern 块上方使用 #[link] 属性指定要链接的库:

rust
#[link(name = "git2")]
extern "C" {
    fn git_libgit2_init() -> c_int;
    fn git_libgit2_shutdown() -> c_int;
}

Rust 将向链接器传递 -lgit2(Unix)或 git2.LIB(Windows)。

C 类型与 Rust 类型的映射

std::os::raw 模块定义了与 C 类型精确对应的 Rust 类型:

C 类型Rust 类型 (std::os::raw)说明
shortc_short通常 i16
intc_int通常 i32
longc_long平台相关
long longc_longlong通常 i64
unsigned shortc_ushort
unsigned intc_uint
unsigned longc_ulong
charc_chari8u8(平台相关)
signed charc_schar
unsigned charc_uchar
floatc_float
doublec_double
void**mut c_void不透明指针
size_tusize(直接使用)
ptrdiff_tisize(直接使用)
C boolRust bool

注意:Rust 的 char 是 4 字节 Unicode 码点,不等于 C 的 char(1 字节)。Rust 也没有与 C wchar_t 精确对应的类型。

#[repr(C)] — 兼容 C 的内存布局

Rust 默认不保证结构体字段的顺序和布局。#[repr(C)] 强制使用 C 的布局规则:

rust
#[repr(C)]
pub struct git_error {
    pub message: *const c_char,
    pub klass: c_int,
}

对应的 C 定义:

c
typedef struct {
    char *message;
    int klass;
} git_error;

#[repr(C)] 也适用于 enum 和 union:

rust
#[repr(C)]
enum git_error_code {
    GIT_OK = 0,
    GIT_ERROR = -1,
    GIT_ENOTFOUND = -3,
}

#[repr(C)]
union FloatOrInt {
    f: f32,
    i: i32,
}

对于 enum,#[repr(C)] 使用 C int 大小的整数表示。也可以指定 #[repr(i32)] 等来选择具体的底层类型。

字符串:CString 与 CStr

C 使用空字符(\0)结尾的字节序列表示字符串。Rust 使用带长度的 &str / String。两者不能直接互换。

rust
use std::ffi::{CString, CStr};
use std::os::raw::c_char;

// Rust -> C:创建空字符结尾的字符串
let rust_str = "hello";
let c_string = CString::new(rust_str).unwrap();  // 检查是否包含内部空字符
let ptr: *const c_char = c_string.as_ptr();       // 获取 C 兼容的指针

// C -> Rust:包装 C 字符串
unsafe {
    let c_str = CStr::from_ptr(ptr);
    let rust_str = c_str.to_str().unwrap();       // 要求有效 UTF-8
    // 或使用 to_string_lossy() 处理非 UTF-8
}

CString 拥有底层缓冲区的所有权;CStr 是借用形式。CString::new 的开销取决于输入类型——传递 String 是零拷贝(如果不需要扩展缓冲区),传递 &str 需要一次堆分配。

调用 C 函数:完整示例

rust
use std::ffi::CString;
use std::os::raw::{c_char, c_int};

#[link(name = "git2")]
extern "C" {
    fn git_libgit2_init() -> c_int;
    fn git_libgit2_shutdown() -> c_int;
    fn git_repository_open(
        out: *mut *mut git_repository,
        path: *const c_char,
    ) -> c_int;
    fn git_repository_free(repo: *mut git_repository);
}

#[repr(C)]
pub struct git_repository {
    _private: [u8; 0],  // 不透明类型(opaque type)
}

fn main() {
    unsafe {
        git_libgit2_init();

        let mut repo = std::ptr::null_mut();
        let path = CString::new("/path/to/repo").unwrap();

        let status = git_repository_open(&mut repo, path.as_ptr());
        if status == 0 {
            git_repository_free(repo);
        }

        git_libgit2_shutdown();
    }
}

对于 C 中的不透明类型(库的内部实现不对外暴露),在 Rust 中使用零大小私有字段的结构体声明:

rust
#[repr(C)]
pub struct git_commit { _private: [u8; 0] }

用 MaybeUninit 处理输出参数

C 函数通常通过输出指针返回数据。Rust 的 MaybeUninit<T> 优雅地处理这种模式:

rust
use std::mem::MaybeUninit;

unsafe {
    let mut oid = MaybeUninit::<git_oid>::uninit();
    let status = git_reference_name_to_id(
        oid.as_mut_ptr(),
        repo,
        ref_name.as_ptr(),
    );
    if status >= 0 {
        let oid = oid.assume_init();  // 安全:函数成功,内存已初始化
        // 使用 oid...
    }
}

MaybeUninit 为你分配一块未初始化的内存,避免先零初始化再覆盖的双重开销。assume_init() 是 unsafe 的——调用者必须确保此时内存已被正确初始化。

调用 Rust 代码:从 C 调用 Rust

要将 Rust 函数暴露给 C,使用 #[no_mangle]extern "C"

rust
// lib.rs
#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
    a + b
}

#[no_mangle]
pub extern "C" fn process_data(data: *const u8, len: usize) -> i32 {
    let slice = unsafe { std::slice::from_raw_parts(data, len) };
    // 处理 slice...
    0
}
  • #[no_mangle] 阻止 Rust 修改函数名,使 C 代码可以通过名字找到它
  • extern "C" 使用 C 的 ABI 调用约定
  • Cargo.toml 中设置 crate 类型为 cdylib 以生成 C 动态库:
toml
[lib]
crate-type = ["cdylib"]

然后在 C 代码中使用:

c
extern int32_t add_numbers(int32_t a, int32_t b);
extern int32_t process_data(const uint8_t *data, size_t len);

int main() {
    int result = add_numbers(3, 4);  // 7
    // ...
}

跨 FFI 边界的内存管理

规则说明
谁分配谁释放C 分配的内存由 C 释放,Rust 分配的内存由 Rust 释放
不要跨边界释放不要用 Rust 的 free 释放 malloc 分配的内存,反之亦然
Box::into_raw / Box::from_raw将 Rust 的堆内存转换为原始指针传递给 C,用完后再转回 Box 释放
所有权传递如果 C 函数需要接管内存所有权,必须在文档中明确说明

将 Rust 对象传递给 C 并回收:

rust
// 传递所有权给 C
let data = Box::new(MyData { /* ... */ });
let ptr = Box::into_raw(data);
c_store_data(ptr as *mut c_void);

// C 代码稍后交回所有权
unsafe extern "C" fn cleanup(ptr: *mut c_void) {
    let _data = Box::from_raw(ptr as *mut MyData);
    // _data 在此 drop
}

libc crate

libc crate 提供所有标准 C 库类型和函数的 Rust 声明,避免手动编写大量 extern 块:

toml
[dependencies]
libc = "0.2"
rust
use libc::{c_int, c_char, size_t, malloc, free};

unsafe {
    let ptr = malloc(1024) as *mut u8;
    // ... 使用 ptr ...
    free(ptr as *mut c_void);
}

bindgen:自动生成 C 绑定

对于大型 C 库,手写所有 extern 声明非常繁琐。bindgen 自动解析 C 头文件并生成 Rust 绑定:

rust
// build.rs
fn main() {
    println!("cargo:rustc-link-lib=git2");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .generate()
        .expect("Unable to generate bindings");

    bindings
        .write_to_file("src/bindings.rs")
        .expect("Couldn't write bindings");
}

生成的 bindings.rs 可以直接 include! 到项目中。这是 Rust 社区处理 FFI 的标准模式——-sys crate 通常使用 bindgen 生成原始绑定。

cbindgen:从 Rust 生成 C 头文件

反向场景——从 Rust 库生成 C 头文件,使用 cbindgen

bash
cbindgen --config cbindgen.toml --crate my_rust_lib --output my_lib.h

自动提取 #[no_mangle] pub extern "C" 函数和 #[repr(C)] 类型,生成对应的 C 声明。

构建脚本(build.rs)

构建脚本在编译前运行,用于配置 FFI 链接:

rust
// build.rs
fn main() {
    // 指定库搜索路径
    println!("cargo:rustc-link-search=native=/path/to/lib");

    // 指定要链接的库
    println!("cargo:rustc-link-lib=static=foo");  // 静态链接
    println!("cargo:rustc-link-lib=dylib=bar");   // 动态链接

    // 设置环境变量
    println!("cargo:rustc-cfg=feature=\"some_cfg\"");
}

构建脚本的输出会被 Cargo 解析并影响后续的编译和链接过程。

构建 Safe Wrapper 的最佳实践

直接暴露 C API 的 Rust 代码充斥着 unsafe 和原始指针。标准做法是分层:

C 库 (.so/.dll)
    ↓ 原始绑定
-sys crate (unsafe, extern 块, bindgen 生成)
    ↓ safe wrapper
高层 Rust crate (safe API)

关键原则:

  1. 使用 Rust 类型系统封装生命周期:用 PhantomData 和生命周期参数表示借用关系
  2. 在 safe API 中检查所有前置条件:空指针、错误码、有效 UTF-8 等
  3. 将错误码转换为 Result<T, E>:让调用者使用 ? 而非手动检查整数返回值
  4. 将资源释放绑定到 Drop:确保 RAII,防止内存泄漏
  5. 初始化/清理用 std::sync::Once:保证库只初始化一次

示例——将 C 库的 create/free 模式转换为 RAII:

rust
pub struct Repository {
    raw: *mut raw::git_repository,
}

impl Repository {
    pub fn open(path: &Path) -> Result<Repository> {
        ensure_initialized();  // 保证库已初始化
        let mut raw = ptr::null_mut();
        let status = unsafe {
            raw::git_repository_open(&mut raw, path_to_cstring(path)?.as_ptr())
        };
        if status < 0 {
            return Err(Error::from_last_error());
        }
        Ok(Repository { raw })
    }
}

impl Drop for Repository {
    fn drop(&mut self) {
        unsafe { raw::git_repository_free(self.raw); }
    }
}

为什么 FFI 是 unsafe 的

FFI 本质上是跨越了 Rust 的所有安全边界:

  • C 代码可以任意违反 Rust 的内存模型
  • C 指针没有生命周期概念——悬垂指针是调用者的责任
  • C 的整数类型大小和符号可能随平台变化
  • C 可能修改 Rust 的不可变数据
  • 跨 FFI 边界的 panic(栈展开)是未定义行为——在 extern "C" 函数中必须用 catch_unwind 防护

因此,安全的 FFI 代码必须由人来审查和保证,而不能依赖编译器验证。

小结

  • extern "C"声明外部 C 函数,使用 #[link] 属性指定库名。
  • std::os::raw 提供了精确对应 C 类型的 Rust 类型别名;#[repr(C)] 确保内存布局兼容。
  • CString/CStr 处理空字符结尾的 C 字符串,与 Rust 的 String/&str 明确分离。
  • MaybeUninit 优雅地处理 C 惯用的"输出指针"模式,避免不必要的初始化开销。
  • #[no_mangle] pub extern "C" 将 Rust 函数暴露给 C;设置 crate-type = ["cdylib"] 生成动态库。
  • 内存管理:Rust 分配的内存由 Rust 释放,C 分配的内存由 C 释放——不可混用。
  • build.rs 构建脚本配置链接路径和编译选项。
  • bindgen/cbindgen 自动化 C 头文件与 Rust 声明的双向转换。
  • Safe wrapper 分层模式是 Rust FFI 的最佳实践:底层 -sys crate 负责 unsafe 绑定,上层 crate 提供符合 Rust 惯例的 safe API。