Rust 欧洲之声|Rust 和 Cpp 互操作

本文为 EuroRust 2022 大会上 slint 团队的分享,主题为 Rust and Cpp ,介绍 Slint 中 Rust 和 Cpp 的交互方案和工具 。

原视频链接:https://www.youtube.com/watch?v=WQAMJDS1tv4,

视频也被搬运到了 B 站:https://www.bilibili.com/video/BV1kG4y1t7xs/。

本次演讲文稿:https://slint-ui.com/blog/rust-and-cpp.html 。

Slint 介绍

Slint[1] 曾经的名字叫 SixtyFPS ,是 QtQml 引擎核心开发者和维护者出来创业的项目。Slint 可以有效地为任何显示器开发流畅的图形用户界面:嵌入式设备和桌面应用程序。我们支持多种编程语言,例如 Rust、C++ 和 JavaScript。Slint 也许是 Qt 的替代品。

演讲正文

slint 致力于用 Rust 开发一个 UI 工具包。这个 UI 工具包除了对 Rust 和其生态系统有用之外,还对其他语言和他们的生态系统有用。所以 Slint 带有 C++ 甚至 Javascript 的API。当然,这些API必须让使用这些语言的开发者感到完全是原生的。由于这个原因,我们对如何为C++世界的用户提供原生感觉的Rust代码的API有着强烈的兴趣。

Slint可以(选择性地)利用现有的C++代码来整合到不同的操作系统环境中。这包括像 Widget 的样式,可访问性等主题。这就是为什么我们也关心将现有的C++代码暴露在Rust世界中。

在这篇博文中,我想探讨Rust和C++之间的两个整合方向,并介绍我们在Slint中使用的一些工具。

如果你的Rust项目中需要一个开源的C或C++库。请看一下 crates.io[2]lib.rs[3]。也许别人已经为你做了这些工作?

有 C++ 背景的读者

作为一名 Rustacean (Rust 开发者统称),我使用 Rust 意义上的 “安全”。如果Rust编译器已经确保了执行内存安全所需的所有属性得到满足,那么代码就是安全的。由于Rust编译器无法解析C++代码并检查其中的属性,所有的C++代码根据定义都是不安全的。这并不意味着 “不安全 “的C++代码会触发未定义的行为或做无效的内存访问,只是说它可能会。

在这篇文章中,你不需要了解Rust,但你会遇到的一个概念是Rust的宏。它们与C语言的宏不同。Rust宏是一个用Rust编写的函数,它接受一个词条流(TokenStream)作为输入,并产生一个词条流作为输出。编译器在编译时只要遇到代码中的宏,就会运行这个函数,传入当前的词条流,然后用生成的流来代替它。这种机制使得强大的宏仍然是 “卫生的”。它们不会改变其周围代码的含义。

语言层面的集成

我们先来看看语言层面的整合:如何使 Rust 调用 C++编写的代码,反之亦然。

Rust编译器无法理解 C++ 代码。这使得我们有必要告诉 Rust 编译器你想在C++端使用的代码。需要一点胶水代码:语言绑定(binding)。绑定以Rust编译器可以理解的方式定义了C++方面的函数和数据类型。一旦有了绑定,Rust代码就可以使用这些绑定来调用C++端的代码。当然,在另一个方向也是如此。C++编译器也需要语言绑定来告诉它如何用Rust这边的代码。

这意味着你不能混合和匹配C++和Rust的代码,而是需要定义的接口来从一种语言跨越到另一种语言。

挑战

我们所需要做的就是生成一些绑定,然后一切都会一帆风顺。这能有多难呢?

有下面一些挑战在等着你:

  • 我们想要相互映射的两种语言确实有非常不同的概念。Rust的宏系统与C++不同,C++有继承性,而Rust使用特质系统(这两个概念不能直接映射到对方),Rust有生命期,这对C++是陌生的。C++的模板和Rust的泛型解决了类似的问题,但方法不同。所有这些不匹配使得这两种语言之间很难进行映射。

  • Rust没有稳定的应用二进制接口(ABI)。这意味着Rust编译器可以自由地改变它在生成的二进制输出中如何表示数据类型或函数调用。当然,这使得以二进制形式交换数据成为一种挑战。在C++方面的情况并没有太大不同:ABI是由编译器定义的。这就是为什么你不能混合使用MSVC和GCC生成的库。最小的共同点是C的外部函数接口(FFI),它提供了一个稳定的二进制接口,但它也将接口限制在可以用C编程语言表达的范围内。尽管有这个限制,C-FFI是大多数语言间交流(不仅是Rust和C++之间)的主要方式。

  • 两种语言都有数据类型来表达文本字符串这样的概念,但这些数据类型的内部表示方式不同。例如,两种语言都提供了一种方法来表示相同类型的元素的动态序列,这些元素彼此相邻存储。即 C++中的std::vector或Rust中的std::Vec,两者都将向量定义为某个内存的指针,一个容量和一个长度。但是这个指针有什么类型?指向的数据在内存中需要如何对齐?什么类型代表容量和长度?指针、容量和长度是以何种顺序存储的?这些或其他细节上的任何不匹配都会使一种语言的类型无法映射到另一种语言概念上的等价类型。

  • 即使数据结构刚好匹配。不同的语言对存储在这些数据类型中的数据可能有不同的要求。例如,一个字符串在Rust中需要是有效的UTF-8,而对C++来说,它只是一个字节序列,程序员肯定知道要使用什么编码。这意味着将一个字符串从Rust传到C++总是安全的(假设标准库中关于字符串类型的所有小细节刚好匹配),但将一个字符串从C++传到Rust可能会引发恐慌(Panic)。

  • 另一个问题以内联(Inline)代码的形式出现。这类代码不能直接用二进制的方式调用。相反,它被插入到使用内联代码的地方。这就要求编译器能够编译相关的代码。Rust编译器显然不能内联C++代码,C++编译器也不能内联Rust代码。这是一种广泛使用的技术。在C++中,所有的模板实际上都是内联代码。

所有这些问题都使我们很难生成绑定来斡旋 Rust 和 C++ 之间的映射。

自动生成绑定

在一个理想的世界里,不需要绑定。但这对于Rust和C++的组合来说是不可能的,所以让我们看看下一个最好的办法:从现有的rust文件或C++头文件自动生成二进制文件。这就是自动绑定生成的意义所在。

尽管很难自动创建合格的语言绑定,但拥有代码生成器仍然很有价值。它们帮你建立起点。有两个可选方向:让Rust代码可以用于C++,也可以反过来。

最常使用的绑定生成器是Bindgen[4]Cbindgen[5]

Bindgen

Bindgen 解析头文件并生成Rust绑定。这对C语言代码很有效,但对C++代码并不完美。默认情况下,bindgen 会跳过任何它不能生成绑定的结构。这样它就能产生尽可能多的绑定。

在实践中,bindgen需要配置才能在任何现实的C++项目中工作。你可以根据需要包括和排除类型,或者将类型标记为不透明的。这意味着它们可以从C++传到Rust,再从Rust传回C++,但Rust一方不能以任何方式与这些类型进行交互。你可能需要添加C(++)辅助函数,使其能够访问bindgen默认不可见的功能。

通常情况下,bindgen被用来生成一个名字以-sys结尾的底层crate。-sys crate往往充斥着对它们所包含的C或C++库的 unsafe 调用。

由于Rust是关于在 unsafe 的代码周围建立安全的包装,你通常会在-sys crate周围编写另一个带有安全包装的crate,然后从它的名字中去掉-sys后缀。

请注意,这个过程与C++开发者为C库提供安全包装的方式并无不同。当然,这里不需要-sys,因为C++可以直接使用C头文件。

Cbindgen

Cbindgen涵盖了另一个方向。它解析Rust代码并从中生成C或C++头文件。

Cbindgen关注的是由开发者使用#[repr(C)]属性特别标注为与C FFI接口兼容的代码。

通常情况下,开发者在他们的Rust项目中创建一个模块(通常称为ffi),并在这个模块中收集所有他们想要公开的#[repr(C)]。这个过程与C++开发者为他们的C++代码编写C级接口的方式并无不同。

何时使用绑定生成器

当你在一种语言中有稳定的接口的代码,并且想让这种代码在另一种语言中可用时,绑定生成器的效果最好。一般来说,这些代码是以库的形式存在的。

这就是我们在Slint中使用绑定生成器的方式:我们从我们稳定的Rust API中生成绑定。然后我们在C++端扩展生成的代码,使代码在C++中更好地交互,(部分)将生成的代码隐藏在手工制作的门面(facade)后面。

如何使用绑定生成器

绑定生成器可以运行一次,并将生成的绑定放在版本控制之下。但这只对具有非常稳定的接口的代码可靠地工作。

绑定生成器应该在构建时生成绑定。当然,这需要集成到所选择的构建系统中。

半自动绑定生成

半自动绑定生成的工作原理是通过一段自定义的代码或配置来定义两种语言之间的接口。然后将其转化为一套Rust和C++的绑定,在这套绑定之间隐藏着一个自动生成的C FFI接口。

这样做的好处是,在C FFI接口的基础上可以有更多的抽象,使生成的绑定使用起来更加舒适。

cxx crate

一个流行的选择是  cxx[6]  crate。也有其他的crate,它们要么建立在cxx之上,要么提供类似的功能。

cxx 承诺安全和快速的绑定。

安全性只限于绑定本身。通过这些绑定调用的代码当然还是不安全的。这是一个很好的特性,因为你可以确定生成的代码没有引入自己的问题。你可以专注于调试绑定的 “另一面”,而不是研究生成的代码。

为了确保绑定的安全性,cxx生成静态断言并检查函数和类型签名。

为了保持绑定的快速性,cxx 确保在绑定中不存在数据的拷贝,也不存在任何转换。这就导致了一种语言的类型渗入另一种语言。例如,C++中的std::string变成了Rust中的CxxString。这使得生成的绑定对开发者来说感到陌生。

这看起来像什么呢?你需要在你的Rust代码里有一个模块来定义接口的两边。下面是一个取自cxx文档的例子。

#[cxx::bridge]
mod ffi {
    struct Metadata {
        size: usize,
        tags: Vec<String>,
    }

    extern "Rust" {
        type MultiBuf;

        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }

    unsafe extern "C++" {
        include!("demo/include/blob_store.h");

        type Client;

        fn new_client() -> UniquePtr<Client>;
        fn put(&self, parts: &mut MultiBuf) -> u64;
    }
}
    
  1. 你需要用#[cxx::bridge]来标记这个模块。这将触发一个Rust宏来处理这段代码。在模块内部(本例中称为ffi)定义了 C++和Rust都可以使用的数据类型。
  2. 接下来是extern "Rust " 块。cxx 注意到 next_chunk 的第一个参数是对 MultiBuf 数据类型的可变引用。它将MultiBuf建模为C++端的一个类,并使next_chunk成为该类的成员。
  3. 一个Unsafe的extern "C++"块定义了在C++端可用的数据类型和函数,它们应该可以在Rust中使用。你需要表达生命期的信息,以及一个函数是否可以安全调用。在这种情况下,new_clientput都是安全的。这些信息与Rust方面有关,但对被包装的C++代码没有影响。

什么时候使用cxx?

当你可以控制API的两边时,它的效果最好。例如,当你想把现有的C++实现中的一些代码分解到用Rust编写的新库中时,cxx是理想的选择,因为它一次性地定义了一组匹配的绑定和它们之间的C FFI接口。

不生成绑定

第三种选择是使用Rust中的cpp[7]  crate来内联编写C++代码。让我们看看一个(简短的)Rust 方法 notice,取自Slint源代码。

fn notify(&self) {
  let obj = self.obj;
  cpp!(unsafe [obj as "Object*"] {
    auto data = queryInterface(obj)->data();
    rust!(rearm [data: Pin<&A18yItemData> as "void*"] {
      data.arm_state_tracker();
    });
    updateA18y(Event(obj));
  });
}

当我第一次在Rust代码中看到这个时,它让我大吃一惊。这段代码是做什么的?

  1. 一个局部变量obj被创建,持有对实例字段obj(类型为&c_void)的引用。
  2. cpp!宏处理所有的代码,直到notice函数结尾的括号。这个宏隐含地声明了一个返回void的 unsafe 的C++函数,它需要一个叫做objObject*类型的参数。该宏希望obj能在周围的Rust代码中被定义。这个C++函数的主体是大括号之间的代码。
  3. 在C++世界中,我们与obj交互,提取一些信息,然后将其存储到一个局部变量data中。当然,这个数据只在我们刚刚隐式定义的C++函数中可见。周围的Rust代码不能看到它。
  4. 在下一行,我们使用rust! (pseudo-)宏。这又切换到了Rust语言。
  5. 这个rust! 宏创建了另一个(rust)函数,叫做rearm,它将接受一个Pin<A18yItemData>类型的参数数据。这个参数必须存在于周围的C++代码中,我们希望它在那里有一个void*的类型。我们需要在这里给出C++和Rust的类型定义,因为不幸的是cpp crate不能在C++那边找到类型。Rust函数的主体将包含data.arm_state_tracker(); 并将返回void。它还将创建必要的绑定,以便从C++中调用新的rearm函数。一旦rust! 宏生成了这段代码,它将通过生成的C++绑定代码代替自己。
  6. 回到由cpp创建的C++函数中,我们再调用一些C++代码updateA11y(Event(obj));并达到隐式创建的C++函数主体的终点。一旦cpp宏生成了所有的代码,它就会通过为其创建的Rust绑定,用对其生成的C++函数的调用来替换自己。

在所有的宏被展开后,我们有两个新的函数被生成,包括必要的绑定来调用它们。Rust编译器看到的是最终notice函数只是定义了obj变量,然后调用了一些以这个obj为参数的绑定。

这种方法并没有避免绑定的产生,所以这一节的标题有误导性。它隐含地处理了很大一部分的绑定生成问题。当然,你仍然需要为你想要访问的Rust和C++中的数据类型生成绑定。cpp crate有更多的宏来帮助解决这个问题。

这是如何做到的?

由cpp crate提供的宏确实生成了所有的代码。你确实需要构建系统集成来构建和链接生成的C++代码。

什么时候使用cpp crate?

在Slint中,我们使用cpp crate来与有稳定API的C++ GUI工具包进行交互。它在这种情况下非常有效。

总结

你有广泛的选择来整合C++和Rust代码,但你总是需要生成语言绑定。这种间接性避免了语言之间的紧密耦合,为Rust开辟了更多的设计空间,但它也使得Rust和C++的无缝集成成为可能。

构建系统集成

一旦你有了一个结合了Rust和C++代码的项目,你需要同时构建Rust和C++部分,并将两者合并为一个一致的二进制文件。让我们简单看看构建一个跨语言项目需要什么。

`cargo`[8],官方的Rust构建系统,是唯一支持的构建Rust代码的方式。你还有另一个用于C++代码的构建系统。一般来说,这个构建系统并不简单,不要试图在cargo中重新实现它。把这两个构建系统相互整合起来吧。

我们先来看看Cargo 。

Cargo

如果你在 大的 Rust项目下有少量的C++代码,将Cargo作为驱动你项目构建的主要构建工具非常适合。典型的用例是围绕C和C++代码生成绑定。

Cargo可以在构建时执行任意的代码。它在Cargo.toml文件的旁边寻找一个叫做 `build.rs`[9] 的文件。如果存在build.rs文件,cargo会在构建过程中构建并执行这个文件。build.rs文件可以通过在stdout上向cargo打印指令来通知其他的构建过程。详情请查阅cargo文档。

build.rs是普通的Rust代码,可以使用Cargo.toml文件中指定任何crate作为构建依赖项。

在处理C和C++代码时, cc[10] crate很有意思。它允许在build.rs中驱动C或C++编译器。这对于构建一些简单的文件来说是很理想的。对于较大的C或C++项目,你可能想直接运行项目的构建系统。cmake crate在这里就派上用场了。它驱动典型的CMake配置、构建、安装工作流程,并在之后将CMake构建目标暴露给Cargo。

其他构建系统也有类似的支持crate,或者可以通过较低级别的crate来运行任意的命令,如xshell[11]

CMake

我把CMake作为广泛用于C和C++项目的构建系统的一个例子。其他构建工具也有类似的支持,有些甚至声称可以原生支持Rust,通常是直接运行rust编译器。

当你在一个更大的C++项目中拥有少量的Rust代码时,使用现有的C++构建系统来驱动整个构建是非常理想的。一个典型的用例是用Rust编写的代码替换项目的某些小部分,或者使用Rust库。

`corrosion`[12] 项目提供了CMake 与 Cargo 的集成案例。一个简单的CMakeLists.txt文件构建了一个Rust示例库并链接到它,看起来像这样。

cmake_minimum_required(VERSION 3.15)
project(MyCoolProject LANGUAGES CXX)

find_package(Corrosion REQUIRED)

corrosion_import_crate(MANIFEST_PATH rust-lib/Cargo.toml)

add_executable(cpp-exe main.cpp)
target_link_libraries(cpp-exe PUBLIC rust-lib)
  1. 你以任何CMake项目中常见的两行开始,定义构建项目所需的最小CMake版本,然后是项目名称和CMake需要构建的编程语言。注意,在那里没有提到Rust。
  2. find_package(Corrosion REQUIRED)行要求CMake包含Corrosion支持,如果没有找到则失败。你也可以使用FetchContent来下载Corrosion作为你构建的一部分。
  3. 现在Corrosion是可用的,你可以要求它使用corrosion_import_crate来构建Rust代码,把它指向一个现有的Cargo.toml文件。Corrosion会构建这个Rust项目,并将所有构建目标暴露给CMake。
  4. 例子中的最后两行构建了一个C++二进制文件,并将其链接到Rust代码中。Slint使用Corrosion项目,使C++开发者能够在C++代码中使用Slint库,而不需要过多地去管Rust。

我希望这能为你整合C++和Rust代码的项目提供一个好的起点。有问题可以来 GitHub 讨论[13]

参考资料

[1]

Slint: https://github.com/slint-ui/slint

[2]

crates.io: https://crates.io/

[3]

lib.rs: https://lib.rs/

[4]

Bindgen: https://lib.rs/crates/bindgen

[5]

Cbindgen: https://lib.rs/crates/cbindgen

[6]

cxx: https://lib.rs/crates/cxx

[7]

cpp: https://lib.rs/crates/cpp

[8]

cargo: https://doc.rust-lang.org/cargo/index.html

[9]

build.rs: https://doc.rust-lang.org/cargo/reference/build-scripts.html

[10]

cc: https://lib.rs/crates/cc

[11]

xshell: https://lib.rs/crates/xshell

[12]

corrosion: https://github.com/corrosion-rs/corrosion

[13]

GitHub 讨论: https://github.com/slint-ui/slint/discussions/1847

Click to rate this post!
[Total: 0 Average: 0]

人已赞赏
名家说每日优选

SeeMusic | 栏目上线!和我们一起看见Web3音乐世界!

2022-11-10 17:14:45

名家说每日优选

对话Lingfeng合伙人:从Binance Labs到基金合伙人

2022-11-15 18:09:27

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
有新消息 消息中心
搜索