跳转到内容

Rust FFI:从 Rust 调用 C 函数

FFI 是 Foreign Function Interface(外部函数接口)的缩写,是 Rust 和其他语言编写的代码打交道的方式。这个“其他语言”几乎总是 C,或者遵守 C 那套规则的语言,因为 C 是几乎所有语言和操作系统都听得懂的“通用语”。

可以想象两个母语不同的人,但两人都会说一点英语。C 就是那门共同语言。有了 FFI,你可以在 Rust 里使用成熟的 C 库,也可以写一个高性能的 Rust 库,让 Python、Node.js 或 C 程序来调用。这一页建立在 unsafe Rust 的基础上,如果你还不熟悉 unsafe 块,建议先读那一页。

在 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,自己检查输入。

大多数 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
▶ 动手试一试

这里的字符串保留了英文,因为输出打印的正是它们的字节和长度。有两个意外,而且都很重要:

  1. strlen("héllo") 是 6,不是 5。C 数的是字节,而 é 在 UTF-8 里占两个字节。(一个汉字占 3 个字节,所以 "你好" 的结果会是 6。)
  2. 如果文本里含有 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")
None
Some(".org")
▶ 动手试一试

to_string_lossy() 把 C 的字节转换成 Rust 文本。遇到无效的 UTF-8,它会替换成 �,因为 C 字符串并不保证是 UTF-8。如果你更想得到一个错误,就改用 .to_str()。

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
▶ 动手试一试

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;
}
Terminal window
$ cc main.c -L target/release -lmathlib -o main
$ LD_LIBRARY_PATH=target/release ./main
2 + 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,只需要几行代码。

Terminal window
cargo new hello-c
cd hello-c
cargo add --build cc
src/greet.c
#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);
}
build.rs
fn main() {
// 把 src/greet.c 编译成静态库,并链接进来。
cc::Build::new().file("src/greet.c").compile("greet");
// 只有 C 文件改变时才重新运行这个脚本。
println!("cargo::rerun-if-changed=src/greet.c");
}
src/main.rs
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()) };
}
Terminal window
$ cargo run -q
2 + 3 = 5
Hello from C, Ferris!

你需要装好一个 C 编译器(gcc、clang,或者 Windows 上的 MSVC),cc 会自动找到它。

对于一个有几百个函数的大型 C 库,手写每一条 extern 声明又慢又容易出错。bindgen(英文)会读取 C 头文件,帮你生成 Rust 声明,通常也是在 build.rs 里完成。crates.io 上大多数 -sys crate(比如 openssl-sys)都是这样构建的。

因为 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