23. Foreign Functions — 外部函数接口(FFI)
Rust 的外部函数接口(FFI)让 Rust 代码能够调用 C 库中的函数,也让 C 代码能够调用 Rust 库。由于大多数操作系统提供 C 接口,FFI 是 Rust 与外部世界交互的桥梁。本章介绍 extern 块声明、C 类型映射、内存管理跨 FFI 边界、以及构建 safe wrapper 的最佳实践。
extern 块:声明外部函数
use std::os::raw::c_char;
extern "C" {
fn strlen(s: *const c_char) -> usize;
}extern "C" 块中的函数使用 C 的调用约定(ABI)。这些函数自动被视为 unsafe——编译器无法验证 C 代码是否遵守 Rust 的安全规则。
在 extern 块上方使用 #[link] 属性指定要链接的库:
#[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) | 说明 |
|---|---|---|
short | c_short | 通常 i16 |
int | c_int | 通常 i32 |
long | c_long | 平台相关 |
long long | c_longlong | 通常 i64 |
unsigned short | c_ushort | |
unsigned int | c_uint | |
unsigned long | c_ulong | |
char | c_char | i8 或 u8(平台相关) |
signed char | c_schar | |
unsigned char | c_uchar | |
float | c_float | |
double | c_double | |
void* | *mut c_void | 不透明指针 |
size_t | usize | (直接使用) |
ptrdiff_t | isize | (直接使用) |
C bool | Rust bool |
注意:Rust 的 char 是 4 字节 Unicode 码点,不等于 C 的 char(1 字节)。Rust 也没有与 C wchar_t 精确对应的类型。
#[repr(C)] — 兼容 C 的内存布局
Rust 默认不保证结构体字段的顺序和布局。#[repr(C)] 强制使用 C 的布局规则:
#[repr(C)]
pub struct git_error {
pub message: *const c_char,
pub klass: c_int,
}对应的 C 定义:
typedef struct {
char *message;
int klass;
} git_error;#[repr(C)] 也适用于 enum 和 union:
#[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。两者不能直接互换。
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 函数:完整示例
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 中使用零大小私有字段的结构体声明:
#[repr(C)]
pub struct git_commit { _private: [u8; 0] }用 MaybeUninit 处理输出参数
C 函数通常通过输出指针返回数据。Rust 的 MaybeUninit<T> 优雅地处理这种模式:
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":
// 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 动态库:
[lib]
crate-type = ["cdylib"]然后在 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 并回收:
// 传递所有权给 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 块:
[dependencies]
libc = "0.2"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 绑定:
// 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:
cbindgen --config cbindgen.toml --crate my_rust_lib --output my_lib.h自动提取 #[no_mangle] pub extern "C" 函数和 #[repr(C)] 类型,生成对应的 C 声明。
构建脚本(build.rs)
构建脚本在编译前运行,用于配置 FFI 链接:
// 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)关键原则:
- 使用 Rust 类型系统封装生命周期:用
PhantomData和生命周期参数表示借用关系 - 在 safe API 中检查所有前置条件:空指针、错误码、有效 UTF-8 等
- 将错误码转换为
Result<T, E>:让调用者使用?而非手动检查整数返回值 - 将资源释放绑定到
Drop:确保 RAII,防止内存泄漏 - 初始化/清理用
std::sync::Once:保证库只初始化一次
示例——将 C 库的 create/free 模式转换为 RAII:
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 的最佳实践:底层
-syscrate 负责 unsafe 绑定,上层 crate 提供符合 Rust 惯例的 safe API。