摘要

1) 一句话总结

由于缺乏稳定的应用二进制接口(ABI),Rust 目前无法像 C 语言那样使用预编译的共享库,导致编译时间长且二进制文件庞大,尽管社区正在探索如 CRABI 等实验性解决方案以改善互操作性。

2) 关键要点

  • 安全优势与代价:Rust 的借用检查器等特性可有效避免 C 语言中常见的内存安全问题,但代价是编译时间极长且生成的二进制文件体积巨大。
  • C 语言的共享库机制:C 语言依赖稳定的 ABI 和 ELF 格式,允许程序在运行时调用预先编译好的共享库(如 libc),并保证跨程序的函数接口和数据布局一致。
  • Cargo Crate 的本质:Cargo 包仅为源代码,Rust 每次构建项目时都会将所有依赖包重新编译,并静态链接到一个巨大的 ELF 文件中,而非使用预编译的共享库。
  • 缺乏稳定 ABI 的技术原因
    • 内存布局不固定:Rust 编译器可以随意重排结构体字段的顺序,跨程序边界时缺乏一致性保证。
    • 泛型复杂性:泛型在编译期会进行单态化展开,导致 ABI 定义极其复杂。
    • 静态分析局限:Rust 的核心安全机制(如借用检查)依赖编译期静态分析,无法直接作用于已编译的二进制文件边界。
  • repr(C) 的局限性:虽然可以通过 repr(C) 将 Rust 类型和函数按 C ABI 暴露,但这只是一种妥协,无法保留 Rust 的高级类型系统。
  • 社区探索(CRABI):开发者 Josh Triplett 提交了一个包含实验性“CRABI”接口的 PR,旨在提供比 C ABI 更高级、更安全的跨语言互操作标准。

3) 风险与缺口

  • C 语言安全风险:尽管不断增加安全特性,C 语言的内存问题仍导致了约 70% 的安全漏洞。
  • 性能与资源消耗缺口:由于没有真正的共享库,Rust 开发者必须忍受每次从头编译数千个 crate 带来的时间损耗和存储空间浪费。
  • 跨边界安全验证缺口:一旦代码被编译为二进制文件,Rust 的借用检查器便无法验证传入其中的可变引用是否被安全使用。
  • 跨语言调用的安全与类型降级风险:目前跨语言调用只能以 C ABI 作为“最低公分母”,这迫使开发者放弃 Rust 的高级安全类型(如安全的 UTF-8 字符串、Option<T>Result<T, E>),并引入 unsafe 代码和借用检查失效的风险。

正文

每次你用 Rust 从头构建一个新项目时,往往都得先让 Cargo 下载上千个 crate,然后花掉“下半辈子”来编译。这个话题的坑很深,我们从头捋一遍。

我是 Low Level Learning,在 YouTube 上做编程与软件安全方面的视频。Rust 是我最喜欢的编程语言之一。作为一名安全研究员,我真心认为 Rust 的借用检查器、运行时访问检查等特性,会把软件安全带进新时代。

世界上所有关键系统几乎都用 C 语言写成,Linux 内核以及众多基础软件都是如此。C 的确极快,但从安全角度看,它给程序员提供了太多“爆头”的机会。尽管天天有人拿技术不行当借口,C 语言和标准库也天天加安全特性,可 70% 的安全漏洞依旧源于 C 的内存问题。

同时我也承认,Rust 并不是一门好上手的语言:语法有点乱,编译器还动不动就“生气”,编译时间更是长到离谱。为什么会这样?Rust 编译为啥这么慢?生成的二进制文件又为何如此巨大?

本来,真正的“库”能一次性解决这些毛病。但遗憾的是,Rust 真正的库根本不存在,而且可能永远不会存在。

Cargo Crate 不是真正的库

在你开口提 Cargo,说 Cargo 的 crate 就是库之前,先别急。Cargo 是包管理器,它确实管库,但这些库跟 C 语言那种库完全不是一回事。

任何 C 程序都依赖 GNU C 库(也就是 libc)。像 openreadwriteclose 这些你天天用的函数,早就有人写好,更重要的是,它们已经提前编译好了。libc 以共享对象(Shared Object)的形式躺在文件系统里,加载器在运行时能直接进去找需要的函数。

C 能做到这一点,是因为 Linux 上的 ELF 可执行文件格式配合应用二进制接口(ABI)。ABI 把函数调用约定按 C 的方式标准化了。这个 ABI 给你的 ELF 程序提供了一个接口,让它能跑到另一个 ELF 里找到所需函数。ELF 里有符号表,程序可以按表所记找到对方导出的函数,libc 暴露函数调用靠的就是这套机制。

此外,ABI 还保证了一个程序里的数据布局与另一个完全一致。于是,结构体(Struct)的成员 A、B、C 永远按固定的顺序排布。这样一来,库之间不仅能共享函数,也能共享数据结构。

为什么 Rust 做不到?

接下来就有点离谱了:Rust 目前还没有稳定的 ABI,无法在多个二进制文件之间共享接口。

所以 Cargo 包是有的,但编译好的 Rust 共享库并不存在。Cargo 包只是一坨源码,你在本地把它编译完,所有代码被揉进一个巨大的 ELF 文件里。实际上,每次你编译 Rust 程序时,都会把项目所需的所有 Cargo 包一起重新编译,然后全部塞进一个二进制文件里。于是,编译时间变长,二进制体积也变大。

那怎么解决?答案并不简单。

给 C 这种语言定 ABI 还算轻松,因为 C 只是汇编的高级包装,需要隐藏的信息不多,无非就是基本类型和函数调用。但 Rust 却是另一头“怪兽”:

  • 内存布局不固定:结构体字段在跨程序边界时,顺序完全没有保证。只要字段都在结构体里,编译器怎么排布顺序无所谓。
  • 泛型的复杂性:泛型在编译期会展开并静态分发(也就是常说的“单态化”),带来了一堆麻烦。再加上语言里各种类型问题,复杂度直接爆表。
  • 静态分析的局限:Rust 的大量威力来自于编译期静态分析(比如借用检查)。一旦变成已编译的二进制文件,这些检查就无从谈起。如果我传一个可变引用进已编译的二进制文件,借用检查器怎么保证它被安全使用?

repr(C) 只是妥协,不是解药

我知道很多人此刻在想:等等,Rust 不是有 C ABI 吗?

没错,Rust 可以用 repr(C) 把类型和函数按 C ABI 暴露出去。repr(C) 装饰器让编译器按 C 的方式排布结构体、生成符号,并建立外部函数接口。

那问题不就解决了?并没有。

repr(C) 不能让任何 Rust 的高级特性跨越进程边界。我们无法用 Rust 独有的类型系统暴露函数,任何跨越 C ABI 边界的调用都是 unsafe 的,借用检查也会失效。repr(C) 适合把 Rust 变成 C 库,却做不出“Rust 对 Rust”的二进制对象。

社区的探索与未来

好在不止我在唠叨这事,许多比我聪明的人正试图定义 Rust 自己的 ABI。这个问题得让整个社区统一站到某一最完整、最合理的 ABI 上,才能有实质进展。

你可以去看看 Josh Triplett 提交的一个 Pull Request(链接会在简介放出)。他加了一个实验性的 feature gate,引入了一个叫 CRABI(也有人写作 CBAPI;字幕不清)的新接口。

这个提案的动机在于:今天在做多语言项目或跨语言调库时,只能拿 C ABI 当“最低公分母”。结果就是,跨语言调用被迫使用不安全的 C 表示法。即便两种语言都有对应的安全类型,比如把 Rust 的字符串传给另一门高级语言时,通常也只能用不安全的 C 字符串,哪怕两边都有安全的 UTF-8 字符串类型。

正如我之前所说,更高级的数据类型(如 Option<T>Result<T, E>)在 Rust 中必须转换成与 C ABI 兼容的类型,这让人非常不愿在跨语言接口里使用它们。我们目前还没有办法在接口里直接传递这些泛型。

去看看这个 PR 吧,我觉得挺有意思的。整个 ABI 的世界,以及如何让系统更好地互操作,对我来说真的很有趣。

现在在评论区告诉我你的想法:Rust 的共享库真的会出现吗?共享库会统治世界吗?还是说 Rust 会一直只能编译出这些庞大臃肿的“单体”二进制文件?

如果你喜欢这个视频,帮我个忙:点个赞,点个订阅,然后去看看我的另一个关于联网的视频(我不剧透,你自己去看)。

关联主题