如何调用外部 C 函数?

解读

在国内 Rust 岗位面试中,“调用外部 C 函数”高频且必答的底层能力题。面试官想确认三件事:

  1. 你是否真正写过 FFI(Foreign Function Interface) 代码,而不是背概念;
  2. 能否在 Cargo 工程 里把 C 源码/动态库无缝集成,解决 交叉编译持续集成 问题;
  3. 是否理解 Rust 内存模型C 内存模型 的差异,能写出 UB-free 的安全封装。
    回答时务必给出 最小可编译示例,并主动提及 bindgen、cc、cmake、pkg-config 等国内常用工具链,否则会被判定为“纸上谈兵”。

知识点

  1. extern "C"ABI 约定:防止 Rust 符号被 mangle,确保与 C 侧符号一一对应。
  2. #[link(name = …)]build.rs:告诉 rustc 如何找库,国内常用 静态库 .a(减少 dll 地狱)并配合 cargo:rustc-link-search=native= 输出。
  3. unsafe 块:调用 C 函数必须置于 unsafe,但 unsafe 不等于放弃安全,要在上层提供 Rust 安全包装
  4. 原始指针与内存布局*const c_char*mut c_void#[repr(C)] 结构体,必须保证内存对齐与大小端一致
  5. bindgen 自动生成:国内大厂代码库往往有海量 C 头文件,bindgen 在 build.rs 里一键生成 Rust 绑定,避免手写出错。
  6. panic=abort 与 unwind 策略:若 C 侧调用 Rust 回调,必须设置 panic=abort,防止跨 FFI 边界 unwind 引发 UB。
  7. 交叉编译与 CI:国内常用 aarch64-unknown-linux-gnumips-unknown-linux-musl 等目标,docker 镜像 + cargo-zigbuild 可一次性搞定。

答案

下面给出一个 可在国产银河麒麟 x86_64 与 aarch64 上直接编译运行 的完整示例,目录结构符合国内工程规范:

ffi_demo/
├── Cargo.toml
├── build.rs
├── src/
│   └── main.rs
├── c_src/
│   ├── mathlib.c
│   └── mathlib.h

c_src/mathlib.h

#ifndef MATHLIB_H
#define MATHLIB_H
int add(int a, int b);
#endif

c_src/mathlib.c

#include "mathlib.h"
int add(int a, int b) { return a + b; }

build.rs(国内镜像加速 + 静态库)

fn main() {
    // 使用 cc  crate 编译 C 代码
    cc::Build::new()
        .file("c_src/mathlib.c")
        .static_flag(true)          // 强制静态链接,避免 .so 路径问题
        .compile("mathlib");        // 输出 libmathlib.a

    // 告诉 cargo 重新编译条件
    println!("cargo:rerun-if-changed=c_src/mathlib.h");
    println!("cargo:rerun-if-changed=c_src/mathlib.c");
}

src/main.rs

// 1. 声明外部函数
#[link(name = "mathlib", kind = "static")]
extern "C" {
    fn add(a: libc::c_int, b: libc::c_int) -> libc::c_int;
}

// 2. 提供安全 Rust 包装
pub fn safe_add(a: i32, b: i32) -> i32 {
    unsafe { add(a, b) }
}

fn main() {
    let sum = safe_add(2, 3);
    println!("2 + 3 = {}", sum);   // 输出 2 + 3 = 5
}

Cargo.toml

[package]
name = "ffi_demo"
version = "0.1.0"
edition = "2021"

[dependencies]
libc = "0.2"

[build-dependencies]
cc = "1.0"

编译验证(国产麒麟 aarch64 示例)

$ cargo build --target aarch64-unknown-linux-gnu --release
$ ./target/aarch64-unknown-linux-gnu/release/ffi_demo
2 + 3 = 5

拓展思考

  1. 双向回调:如果 C 库需要注册 Rust 函数作为回调,务必使用 extern "C" fn 并在 no_mangle 后导出;同时设置 panic=abort,防止跨语言 unwind。
  2. 结构体字段对齐:国内不少硬件 SDK 使用 #pragma pack(1),Rust 侧必须用 #[repr(C, packed)]禁止直接借用字段,需先拷贝再使用,否则触发 UB
  3. 异步场景:在 Tokio 运行时中调用阻塞式 C 库,必须用 spawn_blockingblock_in_place,避免污染整个调度器;若 C 库本身支持 epoll,可封装为 mio 事件源,实现 零成本异步
  4. 安全审计:国内等保 2.0 要求对 FFI 边界做 内存安全审计,建议开启 -Z sanitizer=address,leak,memory 在 CI 中跑单元测试,提前发现越界与泄漏