Rust FFI:从 Rust 调用 C 函数
FFI 是 Foreign Function Interface(外部函数接口)的缩写,是 Rust 和其他语言编写的代码打交道的方式。这个“其他语言”几乎总是 C,或者遵守 C 那套规则的语言,因为 C 是几乎所有语言和操作系统都听得懂的“通用语”。
可以想象两个母语不同的人,但两人都会说一点英语。C 就是那门共同语言。有了 FFI,你可以在 Rust 里使用成熟的 C 库,也可以写一个高性能的 Rust 库,让 Python、Node.js 或 C 程序来调用。这一页建立在 unsafe Rust 的基础上,如果你还不熟悉 unsafe 块,建议先读那一页。
调用 C 函数
Section titled “调用 C 函数”在 Linux、macOS 和 Windows 上,Rust 程序总会链接 C 标准库,所以拿它的函数做第一个实验再合适不过。要调用一个 C 函数,先在 extern "C" 块里声明它:写出它的名字、参数和返回类型。然后在 unsafe 里调用它:
use std::ffi::c_int;
unsafe extern "C" { // 这是用 Rust 写出来的 C 函数 `int abs(int x);`。 fn abs(x: c_int) -> c_int;}
fn main() { let n = unsafe { abs(-42) }; println!("abs(-42) = {n}");}输出:
abs(-42) = 42有几点值得注意:
"C"指定的是 ABI,也就是参数怎么传递、返回值怎么返回的规则。C 的规则是大家都认可的那一套。c_int是 Rust 里对应 C 的int的类型。std::ffi模块里有c_int、c_long、c_char这样的类型,让你的声明在每个平台上都和 C 完全一致。- 这里写的是
unsafe extern "C"。在 edition 2024 中,这个unsafe是必须的,因为你在承诺这份声明是正确的。旧代码(以及 edition 2021)常常只写extern "C" { ... }。带unsafe的写法在两个版本里都能用,所以这一页统一用它。
为什么每次调用都是 unsafe,以及怎么把它藏起来
Section titled “为什么每次调用都是 unsafe,以及怎么把它藏起来”Rust 编译器读不懂 C 代码。它不知道 abs 是不是真的接收一个 int,会不会崩溃,会不会偷偷保存你传进去的指针。所以每次调用外部函数都是 unsafe 的:正确使用它的责任由你来承担。
好消息是,这件事你只需要认真想一次。常见的做法是写一个小小的安全包装(safe wrapper):一个普通的 Rust 函数,先检查规则,再执行 unsafe 调用。其他人只管调用这个包装函数就行。
来看一个真实的例子。在 C 里,abs(INT_MIN) 是未定义行为,因为结果太大,int 装不下。我们的包装函数先把这种情况挡在外面:
use std::ffi::c_int;
unsafe extern "C" { fn abs(x: c_int) -> c_int;}
// 安全包装:调用者不需要写 `unsafe`。fn safe_abs(x: i32) -> Option<i32> { // abs(i32::MIN) 在 C 里没有合法的结果,所以直接拒绝。 if x == i32::MIN { return None; } // SAFETY: x 不是 i32::MIN,所以 abs 会返回合法的结果。 Some(unsafe { abs(x) })}
fn main() { println!("{:?}", safe_abs(-42)); println!("{:?}", safe_abs(i32::MIN));}输出:
Some(42)None// SAFETY: 注释是一个值得学习的 Rust 习惯。它解释了为什么这个 unsafe 块没问题,这样下一个读代码的人就不用去猜了。
如果某个 C 函数对任何输入都确实没有需要遵守的规则,你可以在 unsafe extern 块里把它标记为 safe fn,之后调用时就不用写 unsafe 了。abs 不是这样的函数(别忘了 INT_MIN),所以我们让它保持 unsafe,自己检查输入。
字符串:CString、CStr 和 NUL 字节
Section titled “字符串:CString、CStr 和 NUL 字节”大多数 FFI 的 bug 都出在字符串上,因为 Rust 和 C 存储字符串的方式不一样:
- Rust 的
String/&str知道自己的长度,可以包含任何字节,包括0。 - C 字符串只是一个指向字节的指针,字符串在遇到第一个
0字节(叫作 NUL)时结束,并不存储长度。
所以不能把 &str 直接传给 C。用 CString(英文)构建一个有所有权、以 NUL 结尾的副本,再用 .as_ptr() 拿到 C 需要的 *const c_char:
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn c_strlen(text: &str) -> Option<usize> { // CString::new 会自动在末尾加上 NUL。 let c_text = CString::new(text).ok()?; // SAFETY: c_text 是合法的、以 NUL 结尾的字符串,而且会一直活到函数结束。 Some(unsafe { strlen(c_text.as_ptr()) })}
fn main() { let hello = CString::new("hello").unwrap(); println!("{:?}", hello.as_bytes_with_nul());
println!("{:?}", c_strlen("hello")); println!("{:?}", c_strlen("héllo"));
match CString::new("hel\0lo") { Ok(_) => println!("ok"), Err(e) => println!("error: {e}"), }}输出:
[104, 101, 108, 108, 111, 0]Some(5)Some(6)error: nul byte found in provided data at position: 3这里的字符串保留了英文,因为输出打印的正是它们的字节和长度。有两个意外,而且都很重要:
strlen("héllo")是 6,不是 5。C 数的是字节,而é在 UTF-8 里占两个字节。(一个汉字占 3 个字节,所以"你好"的结果会是 6。)- 如果文本里含有
0字节,CString::new会失败。C 会以为字符串在那里就结束了,悄无声息地把"lo"截掉。Rust 选择拒绝,而不是放任这种事发生,所以CString::new返回的是Result。
反过来,当 C 交给你一个 char * 时,用 CStr(英文)把它包起来。CStr 和 CString 的关系,就像 &str 和 String 的关系:它是一个借用的视图。C 的 strchr 会返回一个指向第一个匹配字符的指针,正好拿来演示:
use std::ffi::{c_char, c_int, CStr, CString};
unsafe extern "C" { fn strchr(s: *const c_char, c: c_int) -> *mut c_char;}
fn find_from(text: &CStr, ch: u8) -> Option<String> { let ptr = unsafe { strchr(text.as_ptr(), ch as c_int) }; if ptr.is_null() { return None; // C 用空指针表示“没找到” } // SAFETY: ptr 指向 `text` 内部,而 text 还活着,并且以 NUL 结尾。 let found = unsafe { CStr::from_ptr(ptr) }; Some(found.to_string_lossy().into_owned())}
fn main() { let text = CString::new("hello, world").unwrap(); println!("{:?}", find_from(&text, b',')); println!("{:?}", find_from(&text, b'z'));
// c"..." 字面量是一个 &CStr,末尾已经带好了 NUL。 println!("{:?}", find_from(c"rust-lang.org", b'.'));}输出:
Some(", world")NoneSome(".org")to_string_lossy() 把 C 的字节转换成 Rust 文本。遇到无效的 UTF-8,它会替换成 �,因为 C 字符串并不保证是 UTF-8。如果你更想得到一个错误,就改用 .to_str()。
用 #[repr(C)] 共享结构体
Section titled “用 #[repr(C)] 共享结构体”Rust 可以为了节省空间而重新排列结构体的字段。C 从来不会这样做:字段按你写的顺序排在内存里,中间加上填充(padding)。比如一个字段依次为 u8、u32、u8 的结构体,按 Rust 目前的布局占 8 个字节(Rust 把两个 u8 挪到了一起),按 C 的布局却占 12 个字节。如果 C 和 Rust 对布局的理解不一致,C 就会读到错误的字节,而且没有任何警告。
所以当结构体要跨越语言边界时,用 #[repr(C)] 告诉 Rust 使用 C 的布局。C 的 div 函数返回一个 div_t 结构体,它有两个 int 字段:quot(商)和 rem(余数):
use std::ffi::c_int;
// 对应 C 的:typedef struct { int quot; int rem; } div_t;#[repr(C)]#[derive(Debug)]struct DivT { quot: c_int, rem: c_int,}
unsafe extern "C" { fn div(numerator: c_int, denominator: c_int) -> DivT;}
fn main() { let result = unsafe { div(17, 5) }; println!("{result:?}"); println!("17 / 5 = {} 余 {}", result.quot, result.rem);}输出:
DivT { quot: 3, rem: 2 }17 / 5 = 3 余 2让 C 调用 Rust
Section titled “让 C 调用 Rust”FFI 是双向的。写一个 extern "C" fn(不带块),就得到一个遵守 C 调用规则的 Rust 函数,C 代码可以调用它。C 的 qsort 接收一个比较函数作为参数,我们可以把一个 Rust 函数交给它:
use std::ffi::{c_int, c_void};
unsafe extern "C" { fn qsort( base: *mut c_void, count: usize, size: usize, compare: extern "C" fn(*const c_void, *const c_void) -> c_int, );}
// 一个会被 C 调用的 Rust 函数。extern "C" fn compare_i32(a: *const c_void, b: *const c_void) -> c_int { // SAFETY: qsort 只会给我们指向这个 i32 数组内部的指针。 let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) }; a.cmp(&b) as c_int // Less = -1,Equal = 0,Greater = 1}
fn sort_with_c(numbers: &mut [i32]) { unsafe { qsort( numbers.as_mut_ptr() as *mut c_void, numbers.len(), std::mem::size_of::<i32>(), compare_i32, ); }}
fn main() { let mut numbers = [42, 7, 19, -3, 0]; sort_with_c(&mut numbers); println!("{:?}", numbers);}输出:
[-3, 0, 7, 19, 42](真实代码里直接调用 sort() 就行。这里只是为了演示 C 回调 Rust。)
当 C 程序需要按名字找到你的函数时,加上 #[no_mangle]。平时 Rust 会把名字“修饰”(mangle)成类似 _ZN4main11add_numbers17h...E 的样子,这样不同的 crate 就永远不会撞名。#[no_mangle] 会保留原本的名字 add_numbers:
#[no_mangle]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}关于 edition 2024:本站的 Playground 示例用的是 edition 2021,在那里写 #[no_mangle] 没问题。在 edition 2024(新建 cargo new 项目时的默认版本)中,它必须写成 #[unsafe(no_mangle)],因为两个未修饰名字相同的函数会让程序出错。新写法在 edition 2021 中也能用。
要构建一个能被 C 链接的库,在 Cargo.toml 里把 crate 类型设为 cdylib:
[lib]crate-type = ["cdylib"]// src/lib.rs(edition 2024)#[unsafe(no_mangle)]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}对于一个名为 mathlib 的 crate,cargo build --release 会生成 target/release/libmathlib.so(macOS 上是 .dylib,Windows 上是 .dll)。之后 C 程序声明这个函数,并链接这个库:
#include <stdio.h>#include <stdint.h>
int32_t add_numbers(int32_t a, int32_t b);
int main(void) { printf("2 + 3 = %d\n", add_numbers(2, 3)); return 0;}$ cc main.c -L target/release -lmathlib -o main$ LD_LIBRARY_PATH=target/release ./main2 + 3 = 5手写这种 C 头文件很快就会让人厌烦。cbindgen(英文)可以根据你的 Rust 代码自动生成。
用 build.rs 和 cc 编译你自己的 C 代码
Section titled “用 build.rs 和 cc 编译你自己的 C 代码”到目前为止,我们只用了已经链接好的函数。要使用你自己的 C 文件,就让 Cargo 来编译它。项目根目录下名为 build.rs 的文件是一个构建脚本(build script):Cargo 会在编译你的 crate 之前先运行它。有了 cc(英文)这个 crate,只需要几行代码。
cargo new hello-ccd hello-ccargo add --build cc#include <stdio.h>
int add_ints(int a, int b) { return a + b;}
void greet(const char *name) { printf("Hello from C, %s!\n", name); fflush(stdout);}fn main() { // 把 src/greet.c 编译成静态库,并链接进来。 cc::Build::new().file("src/greet.c").compile("greet"); // 只有 C 文件改变时才重新运行这个脚本。 println!("cargo::rerun-if-changed=src/greet.c");}use std::ffi::{c_char, c_int, CString};
unsafe extern "C" { fn add_ints(a: c_int, b: c_int) -> c_int; fn greet(name: *const c_char);}
fn main() { let sum = unsafe { add_ints(2, 3) }; println!("2 + 3 = {sum}");
let name = CString::new("Ferris").unwrap(); unsafe { greet(name.as_ptr()) };}$ cargo run -q2 + 3 = 5Hello from C, Ferris!你需要装好一个 C 编译器(gcc、clang,或者 Windows 上的 MSVC),cc 会自动找到它。
对于一个有几百个函数的大型 C 库,手写每一条 extern 声明又慢又容易出错。bindgen(英文)会读取 C 头文件,帮你生成 Rust 声明,通常也是在 build.rs 里完成。crates.io 上大多数 -sys crate(比如 openssl-sys)都是这样构建的。
不止 C:Python、Node.js 和 Web
Section titled “不止 C:Python、Node.js 和 Web”因为 C 是共同语言,同样的思路也能让 Rust 和许多其他语言对话。这些场景下你很少需要直接写 extern "C" 代码,会有 crate 替你完成 unsafe 的部分,并提供安全、好用的 API:
- Python:PyO3(英文)让你用 Rust 编写 Python 模块(也可以从 Rust 调用 Python)。配套工具
maturin可以把它们构建并发布成普通的pip包。Polars 和 pydantic-core 这样的库就是用它做的。 - Node.js:napi-rs(英文)用 Rust 构建原生的 Node.js 扩展,还会自动生成 TypeScript 类型。
- C 和 C++ 项目:
cdylib加上 cbindgen(英文),就像上面演示的那样。 - 浏览器:这不是 C 意义上的 FFI,但
wasm-bindgen扮演着类似的角色。见 WebAssembly。
在 unsafe 之外调用外部函数。
unsafe extern "C" { fn abs(x: i32) -> i32;}
fn main() { let n = abs(-7); println!("{n}");}error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe function or block --> src/main.rs:6:13 |6 | let n = abs(-7); | ^^^^^^^ call to unsafe function修复方法:把调用放进 unsafe { ... } 里,或者更好的做法是放进一个安全的包装函数里,就像上面那样。
指向已经消失的 CString 的指针。这段代码能编译通过,但它是一个真正的 bug:
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn main() { let ptr = CString::new("hello").unwrap().as_ptr(); let len = unsafe { strlen(ptr) }; println!("{len}");}warning: this creates a dangling pointer because temporary `CString` is dropped at end of statement --> src/main.rs:8:46 |8 | let ptr = CString::new("hello").unwrap().as_ptr(); | ------------------------------ ^^^^^^ pointer created here | | | this `CString` is dropped at end of statement | = help: bind the `CString` to a variable such that it outlives the pointer returned by `as_ptr`这个 CString 是一个临时值,所以在 ; 处就被释放了,ptr 现在指向的是已经释放的内存。修复方法:把 CString 存进它自己的变量(let s = CString::new("hello").unwrap();),并且在 s 还活着的时候调用 s.as_ptr()。千万不要忽略这个警告。
在 edition 2024 项目里写普通的 extern "C"。从旧教程复制过来的代码会这样报错:
error: extern blocks must be unsafe --> src/main.rs:3:1 |3 | / extern "C" {4 | | fn add_ints(a: c_int, b: c_int) -> c_int;5 | | fn greet(name: *const c_char);6 | | } | |_^修复方法:写成 unsafe extern "C" { ... }。
忘了加 #[repr(C)]。这根本不会报错,C 只会读到一堆垃圾数据。每个跨越语言边界的结构体和枚举都要加上 #[repr(C)]。
- 在
unsafe extern "C" { ... }块里声明 C 函数,使用std::ffi里的 C 类型,比如c_int和c_char。 - 每次外部调用都是
unsafe的。用一个检查规则的安全 Rust 函数把它包一次,并写上// SAFETY:注释。 - C 字符串以 NUL 字节结尾。用
CString把文本发给 C,用CStr读取来自 C 的文本。C 使用指针期间,要让CString一直活着。 - 和 C 共享的结构体要用
#[repr(C)],因为 Rust 可能会重新排列字段。 extern "C" fn加上#[no_mangle](edition 2024 中写作#[unsafe(no_mangle)])就能让 C 调用 Rust;cdylib可以为此构建一个库。build.rs加cc编译你的 C 代码,bindgen生成声明,PyO3 和 napi-rs 把 Rust 和 Python、Node.js 连接起来。
小测验
1. 为什么调用 extern "C" 块里的函数总是 unsafe 的?
2. CString::new("hel\0lo") 会发生什么?
3. 为什么和 C 共享的结构体需要 #[repr(C)]?
4. 在 edition 2024 的 crate 里,要让函数为 C 保留原本的名字,属性该怎么写?
得分: 0 / 4
- FFI — The Rustonomicon(英文):官方的深入讲解,包括回调、链接、可空指针等内容。
std::ffi— 标准库文档(英文):CString、CStr、c_int、c_char等类型,附示例。- External blocks — The Rust Reference(英文):
extern块、ABI 以及safe/unsafe项的确切规则。 - The bindgen User Guide(英文):根据 C 头文件生成 Rust 绑定。
- The PyO3 User Guide(英文):用 Rust 编写 Python 扩展。