有关 Rust 交叉编译的一些思路 (仅供参考)

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

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

很多嵌入式设备使用 ARM 架构, 不少云服务器商也提供 ARM64 架构的版本, 这些设备的性能资源往往孱弱, 无法直接在其上进行构建, 而开发者/用户却不一定有相同平台且性能足够的设备进行构建工作, 要为这些设备可以直接执行的二进制文件, 就需要进行交叉编译/构建.

编译/构建概念简述

要理解交叉编译/构建, 其实可以先从可执行程序/二进制文件的构建说起.

可执行程序里主要包含的是机器可以理解的指令, 由于它们一般由操作系统进行执行, 因此还要存储在操作系统所能读取的格式中.

而要 编译 出二进制文件, 编译器需要读取源代码, 并生成对应平台的指令, 最后封装为操作系统所要求的可执行文件格式.

当然, 实际中的项目的代码往往繁多, 最终生成的二进制文件可能也不止一个, 意味着这些代码存在复用, 因此, 为了减少重复的编译工作, 以及为了在更改了某些部分的代码后不用重新编译其他的部分, 源代码往往被会分为不同的文件, 进而不同的单元进行编译, 直到最后综合起来形成整体; 同时, 程序中所用到的代码也不一定全部都是当场编译, 很多时候会复用在其它位置处编译好的代码, 也就是 “(代码) 库”.

尽管使用的是如同 C 或 Rust 这样会编译为机器代码的语言, 但在编程时, 也几乎一定会使用到语言本身所提供的一些功能, 也就是 C 和 Rust 中的 “标准库”. 除去可能的代码许可原因, 为了在开发该语言的程序时, 省去编译这些代码的时间, 以及减少分发工具链时的复杂性, 标准库中的一些代码, 会被预先编译成库文件. 因此可以说, 后一种代码复用几乎是一定会存在的情况.

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

类似地, 若要进行交叉编译, 则需要我们有能够根据源代码生成目标平台代码, 以及生成目标平台二进制文件的工具, 也就是需要支持目标平台的编译器和链接器. 在这个过程中, 如果还需要使用外部库, 则需要获取到对应的预构建二进制文件, 抑或获取其源代码, 并在构建过程中对依赖的这些外部库进行构建.

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 中需要转义.


而如果项目或项目的一些依赖中涉及非 Rust 代码, 通常为 C/C++. 因为要涉及到除了 Rust 语言之外的编译, 情况就要复杂些. 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.

你可能感兴趣的:(rust,cargo,musl,ziglang)