跳转到主内容build.rs、link 和 pkg-config | 物联网民工

build.rs、link 和 pkg-config

写 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(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、可选能力和不同交付配置”。