有关 Rust 交叉编译的一些思路 (仅供参考)
大多数时候, 在 Rust 工具链 (toolchain) 支持的设备上进行原生构建不会遇到什么问题...

近来, 使用 Rust 语言开发的应用程序, 渐渐融入了开发者以及普通用户的日常生活. 它们不仅出现在我们常用的工作平台上, 不少嵌入式设备或者云服务器上也多见它们的身影.

Rust 是一种需要编译的语言, 且一些 crate 仍需要 C/C++ 的构建环境. 大多数时候, 在 Rust 工具链 (toolchain) 支持的设备上进行原生构建不会遇到什么问题. 而当目标设备的架构, 与构建时使用的设备架构相同时, 直接将构建好的产物发送至目标设备上即可使用.

不止是很多嵌入式设备使用 ARM 架构. 近来, 不少云服务器商也开始提供 ARM64 架构的通用计算平台服务. 如果不是出于构建目的而特意选择较高配置的服务计划, 只是出于部署服务的目的, 则往往不会为这些设备分配太多的性能资源, 进而不足以在其上进行项目的构建. 而作为个人开发者/用户, 往往不一定有相同平台且性能足够的设备进行构建工作, 要为这些设备可以直接执行的二进制文件 (binaries), 就需要进行交叉编译/构建.

编译/构建概念简述

要理解交叉编译/构建, 可以先从可执行程序 (executable) / 二进制文件 (binaries) 的构建开始.

可执行程序里主要包含的是机器可以理解的指令, 由于它们一般先由操作系统加载并执行, 因此还要存储在操作系统所能读取的格式中. 因此, 要 构建 出可以使用的二进制文件, 需要根据源代码, 生成 对应平台 的指令, 并在最后将这些指令以 操作系统所要求 的可执行文件格式进行封装.

当然, 实际中的项目的代码往往繁多, 最终生成的二进制文件可能也不止一个, 意味着代码之间存在复用的情况. 因此, 为了减少重复的编译工作, 以及为了在更改了某些部分的代码后不一定需要重新编译其他的部分, 源代码往往会被分开在不同的文件 (亦即不同的单元) 中进行编译, 最后综合起来形成整体; 项目通常也会使用到来自外部的依赖, 也就是所说的 “软件包” 或者 “(代码) 库”, 这些部分的代码, 并不全都以源代码的形式提供, 或以源代码的形式为项目所使用, 出于性能的考虑, 可能会提前编译完成, 以库 (library) 文件的形式为他人复用.

比如, 通常在编程时, 不管使用何种语言, 通常都会使用到语言本身所提供的一些功能, 也就是所说的 “标准库”. 解释性语言或者脚本语言, 运行程序一般不需要事先对源代码进行编译, 代码即是程序本身, 但是执行时需要解释器和运行时, 以及可能存在的外部依赖. 而如 C 或 Rust 这样默认需要编译构建形成可执行文件才能运行的语言, 程序运行期需要的标准库中的代码, 往往会在构建时一同合并 (或间接合并) 进入二进制文件. 为了减少分发工具链时的复杂性, 以及在开发该语言的程序时, 省去重新编译这些代码的时间, 标准库中的一些代码, 往往也会被预先编译成库文件, 供开发时使用.

要满足以上所说的两种代码复用的使用场景, 就需要 “链接” 的操作, 以将这些编译后的代码综合起来. 根据这些代码最后是否会进入二进制文件中, 分为动态链接和静态链接: 静态链接是直接将代码存入该二进制文件, 而动态链接的代码, 在该二进制文件执行时才会被寻找并加载.

类似地, 若要进行交叉编译, 则需要能够根据源代码生成目标平台代码以及二进制文件的工具 (以及这些工具所需要的环境), 也就是需要支持目标平台的编译器和链接器. 而像 C 或 Rust 这种较为底层的语言, 往往还需要系统层面的库, 如 C 标准库. 如果还需要使用外部库, 则还需要这些外部库针对对应平台的预构建二进制文件, 或在构建过程中直接从源代码构建依赖的这些外部库.

项目一般还会带有各种针对产物的测试, 这些测试一般需要运行构建出的程序. 而交叉编译的情况下, 要运行其他平台的代码, 则还需要某种虚拟化解决方案.

Rust 中的交叉编译/构建

Rust 的编译器 rustc 基于 LLVM 项目, 因此进行跨平台的代码生成并无问题;至于标准库, Rust 也有很广泛的预构建支持 (参见 Platform Support - The rustc book), 只需要 rustup target add 即可. 因此, 如果项目以及项目的依赖中只有 Rust 代码时, 交叉编译是比较简单的, 不过由于 Cargo 还是会使用系统默认的链接器, 因此可以指定链接器为 rust-lld, 该工具在添加 target 的时候便会一并下载, 所以可以直接使用. 以 x86_64 Linux 上为 ARM64 Linux 进行构建为例 (假设使用 GNU LIBC 而非 MUSL LIBC, 即 aarch64-unknown-linux-gnu 目标):

可以在 Cargo.toml 中配置要使用的 Linker (参见 Configuration - The Cargo Book):

[target.aarch64-unknown-linux-gnu]
linker = "rust-lld"

不过笔者倾向于直接在执行 cargo 命令时指定:

TRIPLET=aarch64-unknown-linux-gnu
rustup target add $TRIPLET
cargo build \
  --target $TRIPLET \
  --config target.$TRIPLET.linker=\"rust-lld\" 

在通过命令行指定 config 项的值时, 值需要符合 TOML 的语法, 比如这里, 引号就是需要保留的, 因此在 sh/bash 中使用了 \ 进行转义. 如果读者使用 PowerShell 或 pwsh, 转义方式略有不同, 可以参考 https://www.rlmueller.net/PowerShellEscape.htm.


而如果项目或项目的一些依赖中涉及非 Rust 代码的构建 (通常为 C/C++), 情况就要复杂些. Cross compiling for arm or aarch64 on Debian or Ubuntu 这篇文章就简单介绍了为 ARM 平台的交叉编译, 可供参考.

以 C/C++ 为例, 要为目标系统交叉构建, 纵使 Clang/LLVM 天生为了跨平台, 能够生成各种平台的代码, 但总归还是需要目标系统的标头文件以及若干标准库等. 于是倒不如使用常见 Linux 发行版中, 通过包管理器就能直接安装的 GCC 交叉编译工具链, 虽然需要为不同的交叉编译目标安装不同的工具链, 但这些工具链一般都已经成熟, 一般不需要什么额外的配置. 还是以上文的 aarch64-unknown-linux-gnu 平台为例:

首先安装工具链:

sudo apt install gcc make gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu

GCC 交叉编译工具链中的工具名, 即是在之前的 gcc, ld 前边, 加上目标的标识, 即 aarch64-linux-gnu-gcc, aarch64-linux-gnu-ld.

使用 Rust 的好处是, 尽管是需要 C/C++ 代码的 crate 依赖, 里面一般也都写好了构建脚本, 因此只需装好交叉构建需要的工具链, 再为 Cargo 指定好构建的目标 target, Cargo 便会 (调用一层 wrapper, 进而) 完成 CC, CXX 等编译器的选择, 这些编译器又内置了链接器的使用与配置, 因此便无需再多费心了. 只是同样可能需要手动指定链接时使用的链接器 linker. (参见 GuillaumeGomez/sysinfo - GitHub)

TRIPLET=aarch64-unknown-linux-gnu
rustup target add $TRIPLET
cargo build \
  --target $TRIPLET \
  --config target.$TRIPLET.linker=\"aarch64-linux-gnu-gcc\" 

需要注意, 这里 linker 位置实际上给的是 aarch64-linux-gnu-gcc. 笔者最初测试时, 想当然地填上了 aarch64-linux-gnu-ld, 结果虽然构建成功, 但构建产物完全无法使用.


除此之外, 有基于 musl 的交叉构建目标. 相较于 GNU LIBC, musl 的代码是一次 LIBC 的重新实现, 它的存在一定程度上使 Linux 系统间的交叉编译更简单. 目前只有少数 Linux 发行版使用 musl 作为系统的 LIBC, 因此在大多数平台上, 即使是为相同平台构建, 但是是使用 musl 的目标 (比如在 x86_64-unknown-linux-gnu 平台上构建 x86_64-unknown-linux-musl 目标), 也是一种交叉编译, 不过由于 musl 是一种对 libc dropped-in 般的替换, 因此除了指定 target 外, 并不需要额外做什么配置. 如果要为其他平台交叉编译也很简单, 只需要下载当前平台上, 针对目标平台的 musl 工具链, 其中包含类似上文中所述 GCC 交叉编译工具链中的工具, 将该工具链 (临时) 添加到 PATH 中, 就能和上一节所述一样, 为 Cargo 所使用.


上述所述, 仅为大致的思路, 在进行具体的实践时, 仍有可能会遇到特定的问题.

此外, 也有 cross-rs/cross 这样, 使整个交叉编译过程更简单的项目. 不过因为这些项目多使用 Docker 作为底层驱动, 笔者更倾向于不使用 Docker 的方式. 亦有 zigbuild 这种项目, 使用 ziglang 这门语言的编译工具链, 进行 C/C++ 的编译. 本篇文章中不再过多展开, 有兴趣的读者可以自行了解.

一个提到 zigbuild 的讨论: https://users.rust-lang.org/t/cross-compile-for-aarch64-unknown-linux-gnu-on-windows/79654.


Last modified on 2023-10-14