写 FFI 时,声明 C 函数只是第一步:
unsafe extern "C" {
fn sensor_read(out: *mut u8, len: usize) -> i32;
}
这只告诉 Rust:有一个 C ABI 函数叫 sensor_read。
但链接器还要知道:
这个函数在哪个库里?
库文件在哪个目录?
还需要链接哪些依赖库?
交叉编译时用的是目标系统的库,还是宿主机的库?
C 项目里这些通常由 Makefile、CMake、pkg-config、sysroot 和交叉工具链处理。Rust 项目里,对应工具是 #[link]、build.rs 和 build dependency。
简单库可以用 #[link]
如果库名字固定,并且链接器能在默认路径里找到,可以直接写:
#[link(name = "sensor")]
unsafe extern "C" {
fn sensor_read(out: *mut u8, len: usize) -> i32;
}
这相当于告诉链接器:
链接 libsensor.so 或 libsensor.a
在 C 命令行里大致对应:
cc main.o -lsensor
如果还要指定库类型,可以写:
#[link(name = "sensor", kind = "static")]
unsafe extern "C" {
fn sensor_read(out: *mut u8, len: usize) -> i32;
}
#[link] 适合很简单的场景:库名稳定,路径由系统或工具链处理,不需要复杂探测。
链接搜索路径不要写死在源码里
有时库不在默认目录,需要告诉 rustc 搜索路径。不要把本机路径写进 Rust 源码:
// 不要这样设计
// #[link_search = "/home/me/vendor/lib"]
Rust 常用 build.rs 输出链接指令:
fn main() {
println!("cargo:rustc-link-search=native=vendor/lib");
println!("cargo:rustc-link-lib=sensor");
}
Cargo 会在构建时运行 build.rs,读取这些 cargo: 指令,再传给 rustc。
常见指令包括:
cargo:rustc-link-search=native=PATH
cargo:rustc-link-lib=NAME
cargo:rustc-link-lib=static=NAME
cargo:rerun-if-changed=PATH
cargo:rerun-if-env-changed=VAR
build.rs 是构建脚本,不是运行时代码。它在编译阶段运行,用来发现依赖、生成绑定、输出链接参数。
build.rs 适合处理构建环境差异
C 项目里 Makefile 经常会根据平台切换库路径:
ifeq ($(TARGET),linux)
LDLIBS += -ludev
endif
Rust 里可以在 build.rs 判断目标平台:
fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os == "linux" {
println!("cargo:rustc-link-lib=udev");
}
}
常见环境变量有:
TARGET
HOST
CARGO_CFG_TARGET_OS
CARGO_CFG_TARGET_ARCH
OUT_DIR
这里要分清:
HOST -> build.rs 运行在哪个平台
TARGET -> 最终产物要跑在哪个平台
交叉编译时,这两个可能不同。build.rs 在宿主机运行,但它输出的是目标产物的链接参数。
pkg-config 用来发现系统库
很多 C 库提供 .pc 文件。C 项目里常见:
pkg-config --cflags --libs libudev
它会输出头文件路径、库路径和依赖库。Rust 里可以用 pkg-config crate:
[build-dependencies]
pkg-config = "0.3"
build.rs:
fn main() {
pkg_config::Config::new()
.probe("libudev")
.expect("libudev not found");
}
probe 成功后,会自动向 Cargo 输出对应链接参数。
这比手写:
println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-lib=udev");
更稳,因为真实系统里库路径、依赖库和多架构目录可能不一样。
交叉编译时 pkg-config 要指向目标 sysroot
交叉编译最容易错的是:build.rs 在宿主机上运行,却意外找到宿主机的 .pc 文件和库。
例如你要编译 ARM Linux 目标,却链接了 x86_64 主机上的 libudev。编译阶段可能就会失败;更糟的是路径混乱到后面才暴露。
交叉编译时要让 pkg-config 使用目标 sysroot。常见环境变量包括:
PKG_CONFIG_ALLOW_CROSS=1
PKG_CONFIG_SYSROOT_DIR=/path/to/sysroot
PKG_CONFIG_PATH=/path/to/sysroot/usr/lib/pkgconfig
PKG_CONFIG_LIBDIR=/path/to/sysroot/usr/lib/pkgconfig
具体变量要按项目工具链约定设置。关键原则是:
build.rs 运行在 host
链接库属于 target
pkg-config 必须查 target 的 .pc 和库
不要在 build.rs 里写死 /usr/lib。那通常是宿主机路径,不是目标系统路径。
bindgen 和 cbindgen 是另一个层次
构建脚本也常用来生成绑定。
Rust 调 C 时,可以用 bindgen 从 C 头文件生成 Rust FFI 声明:
fn main() {
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.generate()
.expect("generate bindings");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
}
C 调 Rust 时,可以用 cbindgen 从 Rust 导出类型生成 C 头文件。
但要分清层次:
bindgen/cbindgen 解决声明生成
pkg-config/build.rs 解决库发现和链接参数
ABI 设计仍然要靠你自己保证
自动生成工具不能替你决定谁释放内存、错误码怎么定义、回调在哪个线程执行。
rerun-if 避免无谓重跑
build.rs 默认在很多情况下会重跑。生成绑定或探测依赖时,最好告诉 Cargo 什么时候需要重新运行:
fn main() {
println!("cargo:rerun-if-changed=wrapper.h");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
pkg_config::Config::new()
.probe("libudev")
.expect("libudev not found");
}
这样头文件或关键环境变量变化时才重新执行。大型项目里这能减少很多不必要的重建。
build.rs 不应该变成第二套构建系统
build.rs 很强,但不要把它写成一个复杂 Makefile。
它适合:
- 输出链接参数
- 探测系统库
- 生成 FFI 绑定
- 编译少量 C helper
- 根据目标平台打开少量条件
它不适合:
- 下载随机源码
- 在构建时修改仓库文件
- 依赖宿主机上不可控的全局状态
- 把复杂交叉编译逻辑硬编码进脚本
复杂系统应该把工具链、sysroot、环境变量和依赖版本放进明确的构建配置,而不是让 build.rs 猜。
这一篇先记住什么
Rust/C 边界的构建发现可以先这样看:
固定库名 -> #[link(name = "...")]
需要输出链接路径/库名 -> build.rs 打印 cargo:rustc-link-*
系统库带 .pc 文件 -> pkg-config crate
交叉编译 -> pkg-config 指向 target sysroot
头文件生成 Rust 声明 -> bindgen
Rust 导出 C 头文件 -> cbindgen
构建脚本重新运行条件 -> cargo:rerun-if-changed / rerun-if-env-changed
不要把“函数声明写对了”和“链接一定正确”混为一谈。FFI 要同时处理 ABI 声明、库文件发现、链接参数、目标平台和运行时加载路径。
下一篇讲 workspace、feature 和构建配置。链接解决“找到哪个库”,feature 和 workspace 解决“同一个 Rust 工程怎样组织多个 crate、可选能力和不同交付配置”。