一句话总结: 详细介绍 Rust 如何通过外部函数接口(FFI)与 C/C++ 代码进行高效、安全的互操作,实现现有代码库的集成。
引言:FFI 的重要性(利用现有 C/C++ 库、性能关键部分)

Rust 语言以其卓越的内存安全、并发安全和高性能而备受赞誉。然而,在现实世界的软件开发中,很少有项目能够完全从零开始。许多大型、成熟的系统和库都是用 C 或 C++ 编写的,它们积累了数十年的经验和优化。在这种背景下,**外部函数接口(Foreign Function Interface, FFI)**成为了 Rust 融入现有生态系统的关键桥梁。

FFI 允许 Rust 代码调用其他语言编写的函数,反之亦然。对于 Rust 而言,这意味着:

  1. 利用现有 C/C++ 库: 开发者可以无需重写,直接在 Rust 项目中复用大量成熟、经过验证的 C/C++ 库,例如图形库、网络库、加密库或操作系统 API。这极大地加速了开发进程,并降低了风险。
  2. 性能关键部分: 尽管 Rust 自身性能优异,但在某些极端性能敏感的场景下,可能需要利用 C/C++ 中高度优化的汇编代码或特定硬件接口。FFI 提供了这种直接访问的能力。
  3. 逐步迁移: 对于大型 C/C++ 项目,FFI 使得团队能够逐步将部分模块用 Rust 重写,享受 Rust 带来的安全性和开发效率提升,而无需一次性重构整个系统。

虽然 FFI 带来了巨大的便利,但它也引入了复杂性。由于 C/C++ 代码不遵循 Rust 的所有权和借用规则,通过 FFI 交互时,Rust 的编译时安全保证会被部分绕过,需要开发者手动确保内存安全。因此,理解 FFI 的工作原理和最佳实践至关重要。

extern "C" 块:声明外部函数

在 Rust 中,与 C/C++ 函数进行互操作的第一步是使用 extern "C" 块来声明外部函数。这个块告诉 Rust 编译器,这些函数是外部定义的,并且它们遵循 C 语言的调用约定(ABI,Application Binary Interface)。C ABI 是一个通用的标准,确保不同语言编译的代码能够正确地相互调用。

  • 声明外部函数: 在 extern "C" 块内部,你声明外部函数的签名,包括函数名、参数类型和返回值类型。这些类型需要与 C 语言中的对应类型兼容。
  • 调用 C 函数: 声明后的外部函数可以在 Rust 代码中像普通函数一样调用。然而,由于 Rust 编译器无法验证外部 C 代码的安全性,调用这些函数通常被认为是 unsafe 的操作,需要放在 unsafe 块中。这是因为 C 函数可能返回裸指针、修改不属于 Rust 管理的内存,或者有其他 Rust 编译器无法检查的前置条件。

以下是一个调用 C 语言标准库中 abs 函数的简单示例:

// 声明一个外部 C 函数,它接受一个 i32 并返回一个 i32
// extern "C" 块告诉 Rust 编译器,这个函数遵循 C 语言的调用约定
extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    let num = -5;
    let result;

    // 调用外部 C 函数是 unsafe 的,因为 Rust 无法保证 C 代码的安全性
    unsafe {
        result = abs(num);
    }

    println!("C 语言的 abs({}) 是: {}", num, result); // 输出: C 语言的 abs(-5) 是: 5
}

在这个例子中,我们声明了 abs 函数,并在 unsafe 块中调用了它。Rust 编译器信任开发者会确保 abs 函数的调用是安全的。

裸指针(Raw Pointers):*const T 和 *mut T

在 FFI 中,裸指针是与 C/C++ 代码交互的核心。C/C++ 广泛使用指针来直接操作内存,而 Rust 的引用(& 和 &mut)则受到借用检查器的严格管理。为了与 C/C++ 的指针模型兼容,Rust 提供了裸指针类型:

  • *const T 表示一个指向 T 类型数据的不可变裸指针。它类似于 C 语言中的 const T*
  • *mut T 表示一个指向 T 类型数据的可变裸指针。它类似于 C 语言中的 T*

与 Rust 引用不同,裸指针:

  • 没有生命周期,编译器不会检查它们的有效性。
  • 可以为空(std::ptr::null() 或 std::ptr::null_mut())。
  • 可以指向任意内存地址,甚至是非法的地址。
  • 可以有多个可变裸指针指向同一数据,或者同时存在可变和不可变裸指针。

因此,解引用裸指针(即通过 *ptr 访问其指向的值)是一个 unsafe 操作。开发者必须手动确保裸指针是有效的、非空的,并且指向的内存是已分配且未被释放的。

fn main() {
    let mut value = 42;

    // 从 Rust 引用创建裸指针
    let raw_ptr_const: *const i32 = &value;
    let raw_ptr_mut: *mut i32 = &mut value;

    unsafe {
        // 解引用不可变裸指针
        println!("通过 *const i32 解引用的值: {}", *raw_ptr_const);

        // 通过 *mut i32 修改值
        *raw_ptr_mut = 100;
        println!("通过 *mut i32 修改后的值: {}", *raw_ptr_mut);
    }

    println!("原始变量 value 的值: {}", value); // 输出: 原始变量 value 的值: 100
}

在这个例子中,我们展示了如何从 Rust 引用创建裸指针,并在 unsafe 块中解引用和修改它们。

传递复杂数据结构:#[repr(C)]VecString

当需要在 Rust 和 C/C++ 之间传递结构体、数组或字符串等复杂数据时,需要特别注意内存布局和数据表示。

  1. #[repr(C)] 属性:

    • Rust 编译器为了优化性能,可能会重新排列结构体字段的内存布局。然而,C 语言有其自己的内存布局规则。
    • #[repr(C)] 属性强制 Rust 结构体使用与 C 语言兼容的内存布局。这意味着字段的顺序和填充将与 C 编译器生成的一致,从而确保 Rust 和 C 之间的数据结构能够正确地相互解释。
      #[repr(C)] // 确保内存布局与 C 兼容
      struct Point {
          x: i32,
          y: i32,
      }
      
      extern "C" {
          fn process_point(p: Point); // 假设 C 端有一个函数接收 Point 结构体
      }
      
      fn main() {
          let p = Point { x: 10, y: 20 };
          unsafe {
              process_point(p); // 将 Rust 结构体传递给 C 函数
          }
      }
      

      2.Vec 与 C 数组的转换:

Rust 的 Vec<T> 是一个动态数组,其内部数据是连续存储的。这使得它非常适合与 C 语言的数组进行互操作。

你可以使用 vec.as_mut_ptr() 获取 Vec 内部数据的裸指针,并使用 vec.len() 获取其长度。将这两个信息传递给 C 函数,C 函数就可以像操作普通 C 数组一样操作 Vec 的数据。

extern "C" {
    // 假设 C 端有一个函数,接收一个 i32 数组的指针和长度
    fn sum_array(arr: *const i32, len: usize) -> i32;
}

fn main() {
    let my_vec = vec![1, 2, 3, 4, 5];
    let total_sum;

    unsafe {
        // 获取 Vec 内部数据的裸指针和长度
        total_sum = sum_array(my_vec.as_ptr(), my_vec.len());
    }

    println!("C 函数计算的数组和: {}", total_sum); // 输出: C 函数计算的数组和: 15
}

String 与 C 字符串的转换:

  • Rust 的 String 和 &str 是 UTF-8 编码的,并且不以 null 终止。而 C 字符串(char* 或 const char*)通常是 ASCII 或其他编码,并且以 null 字节 (\0) 终止。
  • 为了将 Rust 字符串传递给 C,你需要将其转换为一个以 null 终止的 C 字符串。std::ffi::CString 类型就是为此目的而设计的。它会在 Rust 字符串的末尾添加一个 null 字节,并提供一个裸指针。

从 C 接收字符串时,可以使用 std::ffi::CStr 来安全地处理 C 字符串,并将其转换为 Rust 的 &str

use std::ffi::{CStr, CString};
use std::os::raw::c_char; // C 语言的 char 类型

extern "C" {
    // 假设 C 端有一个函数,接收一个 C 字符串并打印
    fn print_c_string(s: *const c_char);
    // 假设 C 端有一个函数,返回一个 C 字符串
    fn get_c_string() -> *const c_char;
}

fn main() {
    // Rust String -> CString -> *const c_char
    let rust_string = String::from("Hello from Rust!");
    let c_string = CString::new(rust_string).expect("CString::new failed");
    let c_ptr = c_string.as_ptr();

    unsafe {
        print_c_string(c_ptr); // 将 C 字符串指针传递给 C 函数

        // 从 C 函数获取 C 字符串指针 -> CStr -> Rust &str
        let received_c_ptr = get_c_string();
        let received_c_str = CStr::from_ptr(received_c_ptr);
        let received_rust_str = received_c_str.to_str().expect("无效的 UTF-8 序列");
        println!("从 C 函数接收到的字符串: {}", received_rust_str);
    }
}
从 Rust 调用 C:build.rs 和 bindgen

当 C 代码比较复杂,或者需要编译 C 库时,手动管理 FFI 可能会变得繁琐。Rust 提供了强大的工具来简化这个过程。

build.rs:构建 C 库

build.rs 是一个特殊的 Rust 脚本,它在编译你的 Rust crate 之前运行。它通常用于编译 C/C++ 库,并将其链接到你的 Rust 项目中。

你可以使用 cc crate(一个 Rust 库)来方便地在 build.rs 中编译 C/C++ 源代码。

build.rs 示例:

// build.rs
fn main() {
    // 告诉 cargo 链接到名为 "my_c_lib" 的静态库
    println!("cargo:rustc-link-lib=static=my_c_lib");
    // 告诉 cargo 在当前目录查找库
    println!("cargo:rustc-link-search=native=.");

    // 使用 cc crate 编译 C 源代码
    cc::Build::new()
        .file("src/my_c_lib.c") // C 源文件路径
        .compile("my_c_lib"); // 编译成名为 my_c_lib 的静态库
}

src/my_c_lib.c 示例:

// src/my_c_lib.c
#include <stdio.h>

void greet_from_c(const char* name) {
    printf("Hello, %s from C!\n", name);
}

int add_numbers(int a, int b) {
    return a + b;
}

src/main.rs 示例:

// src/main.rs
use std::ffi::CString;
use std::os::raw::c_char;

extern "C" {
    fn greet_from_c(name: *const c_char);
    fn add_numbers(a: i32, b: i32) -> i32;
}

fn main() {
    let name = CString::new("Rust").unwrap();
    unsafe {
        greet_from_c(name.as_ptr());
        let sum = add_numbers(10, 20);
        println!("10 + 20 = {}", sum);
    }
}

bindgen 工具:自动生成 Rust 绑定

  • 手动为复杂的 C 头文件编写 extern "C" 声明是乏味且容易出错的。bindgen 是一个强大的工具,它可以自动从 C/C++ 头文件生成 Rust FFI 绑定。
  • bindgen 可以解析 C 头文件,并生成对应的 Rust extern "C" 块、结构体定义(带有 #[repr(C)])、枚举等。这大大减少了手动编写 FFI 代码的工作量,并降低了出错的可能性。
  • 通常,bindgen 也在 build.rs 脚本中运行。

build.rs 中使用 bindgen 的概念示例:

// build.rs
extern crate bindgen;

use std::env;
use std::path::PathBuf;

fn main() {
    // 告诉 cargo 重新运行此脚本,如果 my_c_lib.h 发生变化
    println!("cargo:rerun-if-changed=src/my_c_lib.h");

    // 生成 Rust 绑定
    let bindings = bindgen::Builder::default()
        .header("src/my_c_lib.h") // C 头文件路径
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("无法生成绑定");

    // 将绑定写入到输出目录
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("无法写入绑定文件");

    // 编译 C 库 (同上)
    cc::Build::new()
        .file("src/my_c_lib.c")
        .compile("my_c_lib");
}

然后,在 src/main.rs 中,你可以通过 include!(concat!(env!("OUT_DIR"), "/bindings.rs")); 导入生成的绑定。

从 C 调用 Rust:#[no_mangle] 和 extern "C" 函数

FFI 不仅仅是 Rust 调用 C,C/C++ 代码也可以调用 Rust 函数。这对于将 Rust 模块集成到现有 C/C++ 项目中非常有用。

  1. #[no_mangle] 属性:
    • Rust 编译器默认会对函数名进行“名称重整”(name mangling),以支持函数重载、模块路径等特性。这意味着 Rust 编译后的函数名与源代码中的函数名可能不同。
    • #[no_mangle] 属性告诉 Rust 编译器不要对该函数进行名称重整,使其在编译后的二进制文件中保持原始名称。这样,C/C++ 代码就可以通过其声明的名称找到并调用它。
  2. extern "C" 函数:
    • 与从 Rust 调用 C 函数类似,当 C 调用 Rust 函数时,Rust 函数也需要遵循 C ABI。因此,Rust 函数需要用 extern "C" 关键字标记。
    • 这些函数通常还需要是 pub 的,以便在库外部可见。

src/lib.rs 示例(编译为静态库或动态库):

// src/lib.rs
use std::os::raw::c_char;
use std::ffi::{CStr, CString};

// 告诉 Rust 编译器不要重整此函数名,并使用 C ABI
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    println!("Rust 函数 rust_add 被调用!");
    a + b
}

#[no_mangle]
pub extern "C" fn greet_rust(name_ptr: *const c_char) {
    // 从 C 字符串指针安全地转换为 Rust &str
    let name = unsafe {
        CStr::from_ptr(name_ptr).to_str().expect("无效的 UTF-8 序列")
    };
    println!("Hello, {} from Rust!", name);
}

// 为了让 C 能够释放 Rust 分配的字符串,Rust 需要提供一个释放函数
#[no_mangle]
pub extern "C" fn rust_free_string(ptr: *mut c_char) {
    if ptr.is_null() { return; }
    unsafe {
        // 将裸指针重新构造成 CString,然后让 CString 在 drop 时释放内存
        let _ = CString::from_raw(ptr);
    }
}

要从 C 调用这些 Rust 函数,你需要将 Rust 项目编译成一个静态库(.a 或 .lib)或动态库(.so 或 .dll)。例如,在 Cargo.toml 中添加:

[lib]
crate-type = ["cdylib"] # 编译为动态库
# 或者 crate-type = ["staticlib"] # 编译为静态库

然后,C 代码可以链接到这个库并调用 rust_add 和 greet_rust 函数。

结论:FFI 使得 Rust 能够无缝集成到现有生态中,同时保持其安全性和性能优势

Rust 的 FFI 机制是其强大功能集的重要组成部分。它使得 Rust 不仅仅是一个独立的现代语言,更是一个能够与庞大且成熟的 C/C++ 生态系统无缝协作的工具。通过 FFI,开发者可以:

  • 高效利用现有资源: 避免重复造轮子,直接集成和利用数十年积累的 C/C++ 库。
  • 实现极致性能: 在必要时,通过 FFI 调用 C/C++ 中高度优化的代码,以满足最严苛的性能要求。
  • 平滑过渡: 为大型遗留项目提供了一条逐步引入 Rust 的可行路径,降低了迁移风险。

然而,FFI 并非没有代价。它要求开发者深入理解 C/C++ 的内存模型和调用约定,并在 unsafe 块中手动维护 Rust 的安全不变量。这意味着在使用 FFI 时,必须格外小心,遵循最佳实践,如使用 #[repr(C)]CStringVec 的裸指针,并考虑使用 bindgen 等工具来自动化和简化绑定过程。

最终,FFI 使得 Rust 能够在一个充满挑战的领域——系统编程和底层互操作——中,既保持其核心的安全性和性能优势,又能展现出卓越的实用性和适应性。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐