导读: C2Rust 工具能够将大多数 C 模块翻译成语义上等价的 Rust 代码。这些模块将单独编译,以生成兼容的目标文件。C2Rust 是 Galois 和 Immunant 共同推出的项目。由于受到 Facebook 和 Microsoft 的推崇,Rust 开始走进大众的视野,InfoQ 曾发表过《Rust 是系统编程的未来,C 是新的 Assembly》等文章,那么问题来了,在将遗留的 C 代码转换为 Rust,会遇到哪些问题,又该如何解决呢?我们翻译并分享了由两位 Immunant 联合创始人 Andrei Homescu、Stephen Crane 撰写的文章,这篇文章详细阐述了将 C 代码转换为 Rust 的经验教训。
C2Rust 项目完全是关于将 C 代码转换为等价的、应用二进制接口兼容的 Rust 实现。(请阅读我们的 C2Rust 入门博文)。在实践中,我们发现了 C 语言在实践中编写代码时遇到的一些“黑暗角落”,并发现了 Rust 不能以相同的应用二进制接口(ABI)完全复制相同代码的地方。这是关于那些 “黑暗角落” 的故事,我们认为 Rust 还需要改进,以达到在语言交互接口(FFI)上与 C 完全兼容。
译注: FFI(Foreign Function Interface),意即语言交互接口,顾名思义,FFI 是用来与其它语言交互的接口,在有些语言里面称为语言绑定 (Language Bindings),Java 里面一般称为 JNI (Java Native Interface) 或 JNA (Java Native Access)。由于现实中很多程序是由不同编程语言写的,必然会涉及到跨语言调用,比如 A 语言写的函数如果想在 B 语言里面调用,这时一般有两种解决方案:一种是将函数做成一个服务,通过进程间通信 (IPC) 或网络协议通信 (RPC, RESTful 等);另一种就是直接通过语言交互接口调用。前者需要至少两个独立的进程才能实现,而后者直接将其它语言的接口内嵌到本语言中,所以调用效率比前者高。
ABI(Application Binary Interface),应用二进制接口。是指两程序模块间的接口;通常其中一个程序模块会是库或操作系统所提供的服务,而另一边的模块则是用户所运行的程序。
背景
Rust 是作为一种系统编程语言设计的,它通过借用检查器(Borrow Checker)在编译时增强临时内存安全性,借用检查器强制执行严格的所有权规则,并限制内存分配和指针的别名(Alias)。此外,Rust 使用 RAII 模式实现了内存管理的自动化,这也是在编译时通过在每个作用域的末尾调用本地对象的析构函数(Destructors)来实现的。相反,其他编程语言使用垃圾收集或引用计数(Reference Counting)来解决这些问题。然而,垃圾收集器通常是语言运行时的重要组成部分,它的存在会影响语言的其余部分的设计,包括语言的语言交互接口。将垃圾收集的对象通过语言交互接口传递给另一种语言(例如 C 语言),而此举会带来巨大的挑战,因为垃圾收集必须跟踪跨语言的指针,否则会有过早释放指针的风险。另外,垃圾回收语言可能完全不允许通过语言交互接口来传递垃圾回收的指针,就像在 Go 一样,或者将内存分配的负担转嫁给开发人员。
译注: RAII 全称为 Resource Acquisition Is I nitialization,它是在一些面向对象语言中的一种惯用法。RAII 源于 C++,在 Java、C#、D、Ada、Vala 和 Rust 中也有应用。1984-1989 年期间, Bjarne Stroustrup 和 Andrew Koenig 在设计 C++ 异常时,为解决资源管理时的异常安全性而使用了该用法,后来 Bjarne Stroustrup 将其称为 RAII。RAII 要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。
引用计数(Reference Counting)是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。
Rust 对内存安全的静态强制(Static Enforcement)(与通过垃圾收集或引用计数进行动态管理相反)使它在许多用例中成为 C 或 C++ 的有价值的替代方案。这些情况包括:
多种语言必须在一个进程中共存,并通过语言交互接口进行通讯。
添加重量级语言运行时是不可行的。
垃圾收集的暂停时间或内存需求是不可取的。
Rust 是少数几种可以用于实现中间件和操作系统组件的语言之一,重写可以视情况专为原始文件的直接替换。实际上,当我们与他人讨论 C2Rust 时,最常见的问题之一就是:“你尝过 transpiling OpenSSL 吗?”(幸运的是,已经存在用 Rust 编写的二进制兼容重写)。另一个很好的 Rust 替换例子是 relibc,它是用 Rust 编写的一个 C 标准库。尽管 Rust 在编写应用程序方面可以与 Java、Go、Swift 和 Python 等语言竞争,但它特别适合编写库和低级系统组件,如 kernel modules、firmware。
译注: Transpiling 是一个特定的术语,用于将一种语言编写的源代码转换为另一种具有相同抽象层次的语言。它是由 transforming(转换)和 compiling(编译)组合而成的术语。因此(简单来说)当你编译 C# 时,编译器将函数体转换为中间语言(IL)。这不能称为 transpiling,因为这两种语言的抽象层次完全不同。当你编译 TypeScript 时,编译器将它转换为 JavaScript。二者的抽象层次相同,所以你可以称之为 transpiling。其他一些常见的可以称为 transpiling 的组合包括 C++ 到 C,CoffeeScript 到 JavaScript,Dart 到 JavaScript 以及 PHP 到 C++。
在上述情况下,由 Rust 编译的二进制文件并不能在独立环境中使用,而是作为一个更大的系统组件链接(静态或动态),这个系统可以用一种或几种其他语言(如 C 语言)来编写。这意味着 Rust 二进制文件必须与 C 二进制文件实现应用二进制接口兼容。在 Linux 上,这样的二进制文件是使用 gcc 或 clang 编译的,并使用 GNU 连接器、gold 或 lld 进行链接。为了使用 gcc 实现真正的应用二进制奇偶校验(ABI Parity),理想情况下,rusts 应该支持到 C 的每个 gcc 扩展。Josh Triplett 在今年早些时候关于 Rust 和系统编程的演讲中,他将与 C 的奇偶校验称为系统语言采用的一个重要因素,最后总结了这方面目前存在的问题,以及今后在这方面的改进。在本文中,我们给出了 Rust 与 C 还不完全兼容的实际例子,其中一些已经在 Josh 的演讲中提及过。
提高 Rust 与 C 语言兼容性的机会
在使用 C2Rust 将 C 代码转换为 Rust 时,我们遇到了一些边缘情况,在这些情况下,Rust 不能完全复制 C 特性(或者至少不能复制 C 语言的 gcc 变体)。如果我们想让 Rust 成为 C 的应用二进制接口兼容的替代方案,这些都是我们亟需解决的一些问题。
long double 类型
在 C 语言中,long double
类型被指定为至少与 double
一样长,但通常在 x86 上实现为 80 位浮点值,尽管实现是依赖于平台的。为了与 C 语言实现应用二进制接口完全兼容,Rust 需要支持与所支持的平台(即 f80
和 f128
)上使用的实现相匹配的长浮点类型。启动一个 Pre-RFC 线程 来讨论其他可选的浮点类型。有人建议,在 std::arch
下添加这些类型,这似乎是一条不错的道路。
在我们尝试转换 newlib C 库时,在通用环境下遇到了 long double
类型,这个库可以选择构建为支持 long double
类型,包括在它的 printf
实现中。对于 long double
类型,最佳替代方案是 f128
包装箱(Crate),它在内部将其实现为字节数组,并在 C 中实现所有操作。但是,由于大多数 x86 C 编译器中的 long double
是内部存储在 128 位中的 80 位浮点数,因此它与 f128
包装箱实现的 __float128
类型并不兼容。这不仅会给变量中的 long double
使用带来了问题(这也是我们最初遇到的问题的原因),而且在 C 和 Rust 之间传递 long double
值也会出现问题。
译注: Rust 有两个不同的术语与模块系统有关:包装箱(crate)和模块(module)。包装箱是其它语言中的库(Library)或包(Package)的同义词。
GCC 扩展
C 的许多 gcc 扩展,并没有 Rust 的等价品。例如,我们遇到了以下扩展的问题:
符号别名(链接到公开问题),如
__attribute__((alias("foo")))
,由 libxml2 使用。此属性将同一函数或全局变量导出为多个符号(甚至可能具有不同的可见性)。Rust 提供了#no_mangle
,这让我们可以重命名全局变量,但并没有等价属性以第二个名称来导出它。打包结构也有对齐要求,例如,内核中的
xregs_state
。我们在 GitHub 上就此提出了一个问题,目前,该讨论仍在进行中。对齐的全局变量,例如,ioq3 的
ssemask
。我们可以通过对齐的结构替换它们来处理这些情况,但这与原始 C 代码并不完全等同。例如,对于这段 C 代码:
变量 foo
和 foo16
的对齐都是 16,但它们的大小分别是 5 和 16。
静态库大小
大多数情况下,在构建可以直接替换的 Rust 代码时,我们都希望构建一个 C 共享库(Cdylib)。然而,当我们想要构建一个静态库时,生成的库非常庞大。例如,构建下面最小的 no_std
Rust 模块,它不依赖于任何 Rust 标准库,结果,生成一个 1.6M 的静态库!
结果生成以下包装箱的大小:
因为我们需要将这段代码嵌入到内核模块中,因此,我们不能使用 cdylib
(内核模块是 .ko
,它是目标文件,而内核不支持加载共享库)。rlib
的构建是假设它将链接到另一个 Rust 目标,所以我们决定避免使用它。staticlib
的输出正是我们真正想要的结果,但它的大小要大得多。
顺便说一句,cargo 在指定 crate-type = "staticlib"
时会生成 ELF 存档,但某些构建系统,比如内核,只接受目标文件。我们可以通过使用 ld-r
链接一个可重定位的目标文件来解决这个问题(我们将会发表另一篇关于内核模块的文章)。
未来展望
总结我们在低级 Rust 中的发现和经验,以下是一些想法,探讨了关于 2020 年 Rust 可能的发展方向:
与 GCC 的兼容性:前文已阐述一些当前的边缘情况。潜在的改进包括对更多类型的支持,比如
long double
(可能通过某种方式将所有 LLVM 类型置于 Rust 之下)。另外,Rust 可以支持许多 GCC 属性。链接:对于 Rust 链接的一些小改进,我们有一些想法:
如果 Cargo 生成的是目标文件而不是库,例如,通过添加一个新的
crate-type = "object"
包装箱类型,那么在某些构建系统中嵌入 Rust 代码会更容易。如上所述,即使对于很小的输入,
staticlib
的输出存档文件也可能会变得很大,在最终文件不是链接的二进制文件的情况下,那么减小它的大小也会有所不同。内联汇编(Inline assembly):目前,对内联汇编方面来讲,Rust 非常接近于 LLVM,这是一种不同于 gcc 的格式,因此,我们必须解决这种不匹配的问题。我们期待将来有一天,Rust 能够为内联汇编提供稳定的支持。在这一过程中需要注意的是,现有的内联汇编可以被重写为语义上等价的汇编,无论使用什么语法。
译注: 内联汇编(Inline assembly)是由部分编译器支持的一种功能。其将非常低级的汇编语言内嵌在高端语言源始码中。
结语
现在,Rust 几乎涵盖了人们在 C 语言中想要做的一切。尽管我们在本文讨论的问题微不足道,但它们阻碍了全功能奇偶校验和真实 C 软件的替换。我们已经能够将大多数 C 代码转换为与语言交互接口兼容的、等价的 Rust:lua、NGINX 和 zstd 在无需任何更改的情况下进行了 transpile,而 ioq3 只需在 Rust 输出中进行一个小的更改即可运行(上面所示的 ssemask
问题)。我们希望,随着 Rust 的成熟,我们可以解决这些阻碍 C 和 Rust 之间的完全兼容的边缘情况。
作者介绍:
Andrei Homescu 是 Immunant 的联合创始人和首席科学家。他是基于编译器和链接器的安全技术方面的专家,也是资深 C++ 和 Rust 程序员。Stephen Crane 是 Immunant 的联合创始人和首席技术官。他热衷破解基于编译器的系统的安全保护。
原文链接:
https://immunant.com/blog/2019/11/rust2020/
评论