CXX 深入指南:用 `[cxx::bridge]` 实现 Rust 与 C++ 的安全双向互操作

原创2026-10-07 22:56:001,901 阅读
文章标签:开发工具

CXX 深入指南:用 #[cxx::bridge] 实现 Rust 与 C++ 的安全双向互操作

CXX 是一个面向 Rust 与 C++ 的 safe FFI 框架,其核心思想是在一个 Rust 模块内同时描述语言边界两侧的函数签名与类型,再由静态分析与成对代码生成器为双方产出 ABI 兼容、零拷贝零分配的互操作代码。本文基于当前仓库 book/README.md 展开,结合仓库源码与可运行示例(demo),带你从零理解 CXX 的边界语言、构建接入方式、安全模型与内置类型体系,并掌握在 Cargo 与非 Cargo 构建系统中落地它的完整方案。

CXX 的核心机制与安全承诺

CXX 库提供了一条从 Rust 调用 C++、以及从 C++ 调用 Rust 的安全通道。与 bindgen 或 cbindgen 生成不安全 C 风格绑定不同,CXX 的模型把 FFI 边界两侧的签名“嵌入”在同一个 Rust 模块中:开发者用 #[cxx::bridge] 属性宏定义一个 bridge 模块,CXX 便获得了边界的完整图景,可以针对其中的类型与函数签名执行静态分析,同时维护 Rust 与 C++ 两侧的不变量要求。

若静态检查全部通过,CXX 会使用一对代码生成器为两侧产出相应的 extern "C" 签名以及必要的静态断言,供构建过程的后续环节验证正确性。在 Rust 侧,代码生成器就是 cxx::bridge 这个属性过程宏;在 C++ 侧,Cargo 构建下它是一个小型构建脚本(cxx-build crate),而在 Bazel、Buck 等其他构建系统中,则提供了一个易于集成的命令行工具。

由此产出的 FFI bridge 运行在零或可忽略的开销上:无需拷贝、无需序列化、无需内存分配、无需运行时检查。这是 CXX 设计目标的一部分,也是它区别于手工 extern "C" 写法的关键优势。

边界上的三类成员:共享结构体、不透明类型与函数

CXX 的边界语言由三类成员构成(见 book/src/concepts.md):

  • 共享结构体(Shared structs):字段对两种语言都可见,通常以写在 cxx::bridge 内的 Rust 定义为唯一事实来源。它们可以按值跨边界传递,并会翻译成一个与 Rust 布局完全一致的、可用聚合初始化(aggregate-initialization)的 C++ struct。
  • 不透明类型(Opaque types):字段对另一方语言保密,不能按值跨边界传递,只能通过间接方式(如引用 &、Rust 的 Box 或 C++ 的 unique_ptr)操作。它可以是某个任意复杂的泛型语言特定类型的别名。
  • 函数(Functions):在任一侧语言中实现,可从另一侧调用。
#[cxx::bridge]
mod ffi {
    // 共享结构体:字段对双方可见。
    struct BlobMetadata {
        size: usize,
        tags: Vec<String>,
    }

    // Rust 侧事实来源:Rust 类型与函数暴露给 C++。
    extern "Rust" {
        type MultiBuf;
        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }

    // C++ 侧事实来源:C++ 类型与函数暴露给 Rust。
    unsafe extern "C++" {
        include!("demo/include/blobstore.h");

        type BlobstoreClient;
        fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
        fn put(&self, parts: &mut MultiBuf) -> u64;
        fn tag(&self, blobid: u64, tag: &str);
        fn metadata(&self, blobid: u64) -> BlobMetadata;
    }
}

在 extern "Rust" 部分列出由 Rust 提供事实来源的类型与函数,它们隐式引用 bridge 模块的父模块(super),可视为 use super::MultiBuf、use super::next_chunk 并被再导出到 C++。在 extern "C++" 部分则列出由 C++ 提供事实来源的类型、函数以及声明这些 API 的头文件;目前需要手工写签名,未来的版本有可能以 bindgen 风格从头文件生成,而静态断言会验证这些签名与 C++ 端声明完全一致。

你的函数实现本身,无论用 C++ 还是 Rust 编写,都不需要定义为 extern "C" ABI 或 no_mangle——CXX 会在必要处插入合适的 shim 让一切正常工作。实现这一目标的机制可参考 src/function.rs 与 src/macros/assert.rs 等源码,Rust 侧生成的绑定由 macro/src/expand.rs 的过程宏展开负责,C++ 侧生成则由 bridge/src/write.rs 等模块实现。

实战示例:blobstore 双向调用

仓库 README.md 给出的示例场景是一个 Rust 应用调用已有的 C++ blobstore 大文件服务客户端,其中 put 操作支持非连续缓冲区上传。该示例有可运行版本,位于仓库 demo 目录,直接 cd demo && cargo run 即可运行。涉及的全部源码文件为:

在 demo/src/main.rs 中,extern "Rust" 声明的 MultiBuf 与 next_chunk 在父模块中直接实现:MultiBuf 是一个分块迭代器(玩具实现用 Vec<Vec<u8>> 模拟 rope 等更复杂的数据结构),next_chunk 每次返回下一个连续字节块,用完即返回空切片。extern "C++" 声明的 BlobstoreClient 则由 demo/src/blobstore.cc 实现:put 在 C++ 侧反复回调 Rust 的 next_chunk 收集上传内容,tag 接受 rust::Str 参数,metadata 返回共享结构体 BlobMetadata。运行输出形如:

blobid = 9851996977040795552
tags = ["rust"]

这里的关键在于:main.rs 中的 Rust 代码是 100% 普通的安全 Rust,blobstore.cc 是 100% 普通的 C++,两侧都使用自己惯用的类型,而没有任何“C 风格 FFI 胶水”的感觉。

窥探代码生成:Rust 与 C++ 两侧都产出了什么

如果你好奇 CXX 究竟生成了什么,可以按如下方式查看(这也是 README 中给出的官方做法):

# 运行 Rust 代码生成器(属性过程宏展开)并打印到 stdout
# 需要安装 cargo-expand 工具
cargo expand --manifest-path demo/Cargo.toml

# 运行 C++ 代码生成器并打印到 stdout
cargo run --manifest-path bridge/cmd/Cargo.toml -- demo/src/main.rs

第一条命令展开后你会看到大量带 #<a href="https://link.gitcode.com/i/ef9de73aedbae5dd7024a2f419ed114b" target="_blank">repr(C)]、#[link_name]、#[export_name] 的底层代码;第二条命令通过仓库中的 [bridge/cmd 独立命令行工具,将 C++ 侧生成的头文件与源文件内容输出到 stdout。此外在 Cargo 构建模式下,cxx_build 会把生成的 C++ 代码链接进 target 目录的 target/cxxbridge/ 下,其中声明了边界中出现的 CXX Rust 类型(如 rust::Slice<T>)以及对应的 extern "C" 签名。

与 bindgen / cbindgen 的定位差异

注意 CXX 的显著特点:所有函数签名会重复出现两次——一次在实现定义处(C++ 或 Rust),一次在 cxx::bridge 模块内,但编译期断言保证二者同步。这与 bindgen / cbindgen 相反:后者由人书写一次签名,工具在一门语言中消费它并产出另一门语言的签名。

这是因为 CXX 扮演的角色略有不同:它在某种意义上比 bindgen/cbindgen 更底层,可以看作是对 extern "C" 签名这一概念的替代,而非对 bindgen 的替代;因此完全可以在 CXX 之上构建更高层的 bindgen 式工具。但在另一些方面 CXX 又更高层:它对常用标准库类型有丰富的内置绑定。用 bindgen 处理惯用的 C++ API 时,通常要手工把 API 包装成 C 风格裸指针函数,bindgen 得到不安全的裸指针 Rust 函数,再复制一遍 API 以惯用方式暴露给 Rust——这种重复更糟糕,因为全程不安全。而把 CXX bridge 作为两种语言之间的共享理解,常见的 FFI 用例就能用 100% 安全代码表达。也可以混用:用 CXX bridge 处理 95% 直截了当的 FFI,余下少数怪异签名用传统 bindgen/cbindgen 方式处理。

Cargo 构建接入:规范 build.rs 与头文件包含路径

对于由 Cargo 编排的构建,需要在 Cargo.toml 中加入 cxx(运行时)与 cxx-build(构建期 C++ 代码生成器)两个依赖:

[dependencies]
cxx = "1.0"

[build-dependencies]
cxx-build = "1.0"

规范的 build.rs 如下。cxx_build::bridge("src/main.rs") 这一行返回一个 cc::Build 实例(来自常用的 cc crate),可以在其上像平常一样追加源文件与编译器标志:

// build.rs

fn main() {
    cxx_build::bridge("src/main.rs")  // 返回一个 cc::Build
        .file("src/demo.cc")
        .std("c++11")
        .compile("cxxbridge-demo");

    println!("cargo:rerun-if-changed=src/demo.cc");
    println!("cargo:rerun-if-changed=include/demo.h");
}

rerun-if-changed 行是可选的,但能避免仅改动非 C++ 代码时重复编译 C++ 代码;若不写,Cargo 会在项目内任何文件变化后重新执行构建脚本。demo 目录维护着一套完整可用的 Cargo 构建配置,可随时对照参考。

在 cxx-build 默认行为下,所有 include 路径都以 crate 名为前缀,这一点同时适用于 C++ 代码里的 #include 和 bridge 中 extern "C++" 的 include!。例如 crate 名为 yourcratename、头文件位于 path/to/header.h 时,应写作 #include "yourcratename/path/to/header.h"。若想使用与 crate 名不同的前缀,可在 build.rs 中设置 CFG.include_prefix(见 bridge/src/cfg.rs 中的配置项定义):

// build.rs

use cxx_build::CFG;

fn main() {
    CFG.include_prefix = "my/project";
    cxx_build::bridge(...)...;
}

此后该头文件应以 #include "my/project/path/to/header.h" 引入。空字符串 "" 也是合法前缀,可让 #include "path/to/header.h" 直接生效;但若你的 crate 是库,需注意下游 crate 中可能的头文件名冲突,建议保证本地路径足够命名空间化。

如果 bridge 模块包含 extern "Rust" 块或共享数据结构,CXX 生成的 C++ 头文件可通过 Rust 源文件名加 .rs.h 扩展名引用,例如从 path/to/lib.rs 生成的头文件写作 #include "yourcratename/path/to/lib.rs.h";也可用 .rs 扩展名像直接 include Rust 文件一样引入,二者皆可。依赖 crate 的手写头文件与生成头文件同样可通过“crate 名(或对方设置的 include_prefix)+ 相对路径”引入,但注意:跨 crate 引入仅对直接依赖生效,传递依赖不满足条件;且只有当依赖的 Cargo.toml 含 links 键时,其头文件才能被外部 crate 引入。

对于需要支持下游 crate include 其 C++ 公共头文件的库,CXX 还提供进阶配置(详见 book/src/build/cargo.md):

  • CFG.exported_header_dirs(绝对路径向量):定义额外的头文件目录集合,当前 crate、直接依赖以及被导出链上的 crate 都可从中 #include。仅用 cc::Build::include 只对当前构建生效,下游仍需手工添加目录;使用它时必须为 crate 设置 links 键,否则 Cargo 不保证构建脚本的执行顺序。
  • CFG.exported_header_prefixes(字符串向量):每个元素指向某个直接依赖的 include_prefix 或其前缀,声明哪些依赖参与 crate 的 C++ 公共 API。若你的头文件 #include 了某依赖的内容,就应把该依赖的前缀加入其中;反之若只有 .cc 实现文件用到,则无需导出。
  • CFG.exported_header_links(字符串向量):通过依赖的 links 属性实现与上者等价的效果,在多个 crate 共享同一 include_prefix 时可提供更细粒度的控制。

非 Cargo 构建接入:cxxbridge-cmd 命令行工具

对于 Bazel、Buck 等非 Cargo 构建,CXX 提供了独立命令行工具来调用 C++ 代码生成器。该工具以 cxxbridge-cmd crate 发布在 crates.io 上,也可从本仓库的 bridge/cmd 目录构建(见 bridge/cmd/Cargo.toml):

$ cargo install cxxbridge-cmd

$ cxxbridge src/main.rs --header > path/to/mybridge.h
$ cxxbridge src/main.rs > path/to/mybridge.cc

生成的头文件与源文件可直接交给 Bazel/Buck 等构建系统编译链接。仓库中同时提供了这些构建系统的接入示例:BUILD.bazel、MODULE.bazel(Bazel)与 BUCK(Buck),以及相应的构建辅助宏 tools/bazel/rust_cxx_bridge.bzl 与 tools/buck/rust_cxx_bridge.bzl,第三方依赖的 Bazel 构建规则位于 third-party。

安全模型:CXX 是如何做到“仅审计 C++ 侧即可”

CXX 的设计是有意限制和带立场的,其目标不是处理任一侧的任意签名,而是在今天能做出有用安全保证的、相当有表达力的功能子集上做文章。以下是保证安全的几项关键考虑(源码层面可参见 bridge/src/check.rs 的静态检查逻辑与 bridge/src/ifndef.rs、bridge/src/pragma.rs 的生成防护逻辑):

  1. 成对代码生成器同时控制边界两侧。通常手写 extern "C" 块是不安全的,因为 Rust 编译器无从得知你写的签名是否与另一语言实现一致;CXX 通过拥有两侧而获得了这种可见性。
  2. 静态分析检测并阻止不该按值传递的类型(如含内部指针、会被 Rust 移动语义破坏的类型)从 C++ 按值传给 Rust。
  3. 解决“布局相同但 ABI 不同”的经典 bindgen 坑。两个语言中布局/字段/对齐完全相同的结构体按值传递时仍可能 ABI 不同,这是导致看似正确代码 segfault 的历史 bindgen bug;CXX 因为同时拥有边界两侧,能在需要处透明地插入零成本 workaround,让你放心按值传结构体。
  4. 模板实例化的连接机制:例如要在 Rust 中暴露由真实 C++ unique_ptr 支持的 UniquePtr<T>,CXX 用 Rust trait 把行为接回另一语言执行的模板实例化上。

注意核心安全主张:审计时你仍要对全部 C++ 代码负责(100% 的 C++ 代码都是不安全的),但这个新模型下“仅审计 C++ 侧即可发现所有问题”——Rust 侧可以做到 100% 安全。

内置类型体系:Rust 类型到 C++ 类型的对照表

除全部原生类型(如 i32 ↔ int32_t)外,以下常用类型可用于共享结构体字段与函数参数/返回值(完整定义可查 include/cxx.h,即本仓库 rust 命名空间的 C++ API,使用这些类型时需在你的 C++ 代码中 #include "rust/cxx.h"):

Rust 侧名称 C++ 侧名称 限制
String rust::String
&str rust::Str
&[T] rust::Slice<const T> 不能持有不透明 C++ 类型
&mut [T] rust::Slice<T> 不能持有不透明 C++ 类型
CxxString std::string 不能按值传递
Box<T> rust::Box<T> 不能持有不透明 C++ 类型
UniquePtr<T> std::unique_ptr<T> 不能持有不透明 Rust 类型
SharedPtr<T> std::shared_ptr<T> 不能持有不透明 Rust 类型
[T; N] std::array<T, N> 不能持有不透明 C++ 类型
Vec<T> rust::Vec<T> 不能持有不透明 C++ 类型
CxxVector<T> std::vector<T> 不能按值传递;不能持有不透明 Rust 类型
*mut T, *const T T*, const T* 含裸指针参数的函数必须声明为 unsafe 才能调用
fn(T, U) -> V rust::Fn<V(T, U)> 目前仅实现 Rust → C++ 方向
Result<T> throw/catch 仅允许作为返回类型

这些内置绑定让边界可以使用任意一侧的原生类型:例如从 Rust 操作 C++ 字符串时,len() 方法会调用 C++ 定义的 size() 成员函数;从 C++ 操作 Rust 字符串时,其 size() 成员函数调用 Rust 的 len()。相关 Rust 侧实现分布在 src/cxx_string.rs、src/cxx_vector.rs、src/rust_str.rs、src/rust_slice.rs、src/shared_ptr.rs 与 src/unique_ptr.rs 等文件中,每个类型在 C++ 侧都有对应的头文件实现(如 bridge/src/builtin/shared_ptr.h、bridge/src/builtin/vector.h)。

以下类型被列为“即将支持”但尚未实现:BTreeMap<K, V>、HashMap<K, V>、Arc<T>、Option<T>(Rust 侧),以及 std::map<K, V>、std::unordered_map<K, V>(C++ 侧)。

继续深入:构建文档与运行环境说明

本文所依据的 book/README.md 还说明:该书籍目录的内容从 master 分支自动发布至 https://cxx.rs,可在本地构建查看——先安装 mdBook](cargo install mdbook),再在该目录运行 mdbook build,打开生成的 build/index.html 即可。书籍的完整章节结构见 [book/src/SUMMARY.md,涵盖核心概念(book/src/concepts.md)、完整教程(book/src/tutorial.md)、bridge 模块参考(book/src/reference.md)、内置绑定参考(book/src/bindings.md)以及多构建系统支持(book/src/building.md)等。

环境前提:当前仓库要求 rustc 1.88+ 与 C++11 或更新版本;项目的 rust-toolchain 与编译参数分别见 rust-toolchain.toml 和 compile_flags.txt。若在构建或链接任何 CXX 代码时遇到问题,可在仓库的 issue 中反馈。目前仍属早期阶段,C++ API 的惯用性改进也欢迎协作贡献。许可证为 Apache-2.0 或 MIT(见 LICENSE-APACHE 与 LICENSE-MIT)双许可。

登录后查看全文
cxx