添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
深入理解Rust如何进行交叉编译

深入理解Rust如何进行交叉编译

Motivation

在学习 Rust的ToyDB项目 过程中,采用其Dockerfile在我的Mac M1电脑上没法运行成功。遇到的第一个问题是 "error adding symbols: File in wrong format" ,查阅相关 issue ,发现原因是交叉编译(cross-compiling)时需要指定Rust的linker,通过加入 #ENV RUSTFLAGS=-Clinker=musl-gcc 编译成功。然而Docker运行编译后的toydb,其提示 No such file or directory ,一开始我的反应是文件没有复制过去,后来通过 RUN ls 发现文件确实存在;那么就剩下一种可能,其Rust编译的target是 x86_64-unknown-linux-musl ,而我的Mac M1芯片是Arm64架构的,因此没法运行。

所采用的 Dockerfile :

# Initial build
FROM rust:1.53-slim AS build
ARG TARGET=x86_64-unknown-linux-musl
RUN apt-get -q update && apt-get -q install -y musl-dev musl-tools
RUN rustup target add $TARGET
# FIXME: cargo does not have an option to only build dependencies, so we build
# a dummy main.rs. See: https://github.com/rust-lang/cargo/issues/2644
WORKDIR /usr/src/toydb
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
    && echo "fn main() {}" >src/main.rs \
    && echo "fn main() {}" >build.rs
#ENV RUSTFLAGS=-Clinker=musl-gcc
RUN cargo fetch --target=$TARGET
RUN cargo build --release --target=$TARGET \
    && rm -rf build.rs src target/$TARGET/release/toydb*
COPY . .
RUN cargo install --bin toydb --locked --offline --path . --target=$TARGET
# Runtime image
FROM alpine:3.14
COPY --from=build /usr/local/cargo/bin/toydb /usr/local/bin/toydb
COPY --from=build /usr/src/toydb/config/toydb.yaml /etc/toydb.yaml
CMD ["toydb"]

这引发了我的思考,也就是说Docker默认pull的镜像应该是和本地电脑(Host)的平台架构是一致的,也就是说上述Dockerfile中的 rust:1.53-slim alpine:3.14 其实都隐含对应的是 arm64/rust:1.53-slim arm64/rust:1.53-slim 这两个镜像,通过运行 docker inspect toydb:latest 可以发现其包括如下字段:

....
"Architecture": "arm64",
"Variant": "v8",
"Os": "linux",
...

这证实了确实如此,在docker文档 Multi-platform images 其实也进行了说明。 那么问题是问什么这里一定要指定Rust编译的target platform呢,采用默认配置不就不存在这个问题了吗?这其实与 alpine镜像 有关,其系统要求C程序库必须采用 musl libc ,因此必须指定 rustc 的target为 aarch64-unknown-linux-musl

那么更深层次的问题是,Rust到底是如何交叉编译的?

本文从依赖 std 库的编译开始捋起,然后介绍裸机( no_std )的Rust程序如何交叉编译。

基本术语

为了便于后文描述,首先需要花点时间对一些概念进行区分。交叉编译涉及到两种不同的系统/计算机/设备,分别称为 宿主系统(Host) 目标系统(Target) 。宿主系统是程序实际执行编译的物理位置,工具链(tool-chain)则是宿主系统编译所需的一系列程序;目标系统则是编译后的程序实际将执行的物理位置,目标系统通常需要链接一些对应操作系统所需的库(supporting libraries)。

比如说,在我的Mac M1芯片电脑(aarch64-apple-darwin)上编译Rust程序,使其可以在64位的Ubuntu系统(x86_64-unknown-linux-gnu)上执行,其中区别不同系统的特征(包括架构和操作系统等)通常采用 三元组字符串 {arch}-{vendor}-{sys}-{abi} 来标识,虽然有4个字段,但很多时候会省略abi,一般都称为三元组。比如 arm-unknown-linux-gnueabihf 表示具有如下特征的系统: - 架构: arm(没有特指armv7还是armv8的话就表示都兼容) - 发行商: unknown标识没有或者不重要 - 系统: linux - ABI: gnueabihf,表示它使用 glibc 作为C标准库,并有浮点运算硬件加速

在宿主系统上可采用 rustc --version --verbose uname -a 查看该三元组。

因此,交叉编译就是在宿主系统上编译为与之三元组不同的目标系统的可执行程序。

编译要求

一般需要如下3个步骤来完成交叉编译:

1. 确定目标系统的三元组;

2. 根据该三元组利用相应工具链( rustc )编译该Rust程序;

3. 利用链接器将程序所需的系统库文件(如 libc )和步骤2中生成的文件链接到一起。

第2步:编译

第一步确定三元组上文已说明,接下来就是第二步,需要根据三元组选取相应的Rust编译工具链。这一点在Rust中比较简单, rustc 本来就是一个交叉编译器,只需要下载目标系统的所需的支持库,就可以直接在宿主系统上进行编译;这点和C语言的编译器 gcc 有很大的不同, gcc 需要为不同的目标系统下载不同的编译器。 因此只需要在 Platform support 中找到对应的 $target 类型即可(也可通过 rustc --print target-list 命令来查看), 然后下载对应的工具包:

rustup target add $target

第3步:链接

这一步是比较容易误解的地方,也很容易出错,如果省略这一步可能会出现如下错误:

$ (rusty-risc) cargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
error: linking with `cc` failed: exit status: 1
... error adding symbols: file in wrong format
          collect2: error: ld returned 1 exit status
...

由于目标系统和宿主系统架构不一样,因此这里会出错也很正常,但值得注意的是为什么Rust会选择 cc 来链接,是不是会为每一个不同的目标系统自动选择其他链接工具?实际上Rust不会这样做,Rust均采用的是如下的 默认配置 Gcc 链接器:

linker_flavor: LinkerFlavor::Gcc,
    linker: option_env!("CFG_DEFAULT_LINKER").map(|s| s.to_string()),

在命令行输入 cc -v 可以看到在mac中默认采用的是 clang version gcc ,linux平台一般则是 gcc

gcc 交叉编译器不像rustc那样,它通常只针对一种目标系统。最让人费解的是不同系统上的 gcc 交叉编译器可能有不同的名字,比如针对rustc的目标类型 arm-unknown-linux-gnueabihf ,Ubuntu系统上的工具叫 arm-linux-gnueabihf-gcc ,exherbo系统的工具则叫 armv7-unknown-linux-gnueabihf-gcc

为避免出错,最好是用该链接器编译一个简单的程序,并在目标系统上测试。同时,不同的linux发行版也提供了不同的C交叉编译工具链,比如在 packages.ubuntu.com 可以搜索 Ubuntu 提供的 gcc 工具,Linux(Host)->Mac(Target)则可以参考 osxcross项目

这些C交叉编译工具链会程序链接上 libc ,但需要注意的是宿主工具链的 libc 需与目标系统的 libc 相匹配,比如前文提及的 Alpine Linux 使用的是 musl libc ,就需要安装 apt-get install -y musl-tools ,使用 musl-gcc 来链接。

具体设置

使用rustc编译

采用rustc交叉编译只需要传递两个参数即可:

1. --target=$rustc_target 以指示 rustc 目标系统类型;

2. -C linker=$gcc_prefix-gcc 以指示 rustc 采用的C链接器程序;

如编译如下 hello.rs 程序:

$ rustc \
--target=arm-unknown-linux-gnueabihf \
    -C linker=arm-linux-gnueabihf-gcc \
    hello.rs

使用 cargo 编译

采用 cargo 编译可以采用配置文件来进行编译设置,在项目目录下创建 ./.cargo/config.toml 文件,并写入类似于如下的配置:

[build]
target = "riscv64gc-unknown-linux-gnu"
[target.riscv64gc-unknown-linux-gnu]
linker = "riscv64-unknown-linux-gnu-gcc"

这样运行 cargo build 时就只会编译目标系统的可执行文件。

动态链接与静态链接

注: 静态链接指在编译期间就将所有的依赖库(包括Rust本身库和C库等)都打包到二进制文件中;动态链接则在程序运行时,从操作系统中将所需的库加载到程序中。静态链接的优点是程序一次编译即可在多个三元组相同的目标平台上运行,发布更容易一些,但是库一旦更新就需要重新编译;动态链接则是多个程序共享想同的库,因此程序文件体积更小,且对库更新无感知,但要求目标系统具有该库。 对于目标系统类型为 *-*-linux-gnu* 的情形,Rust一般会将 glibc 和其他库动态链接到编译的二进制文件中,linux可以采用 ldd (mac采用 otool -L 代替)命令查看是否是动态链接,如:
toydb git:(688bbfc) ✗ ldd target/debug/toydb
target/debug/toydb:
    /System/Library/Frameworks/Security.framework/Versions/A/Security (compatibility version 1.0.0, current version 60420.60.24)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)

动态链接采用上节的设置进行编译即可。对于目标系统并不包含动态链接库的系统(比如Raspberry Pi或采用RISC-V指令集的系统)而言,则需要将所依赖的库直接静态链接到编译后的二进制文件中。这里主要有两种情形:

1. *-*-*-musl 目标系统类型的,直接就链接进去了不需要更多设置;

2. 对于 linux-gnu 目标平台类型的则自 Rust1.19 后则可通过传递 -C target-feature=+crt-static 参数给rustc编译器。其 ./.cargo/config.toml 文件类似于:

[build]
target = "riscv64gc-unknown-linux-gnu"
[target.riscv64gc-unknown-linux-gnu]
rustflags = ["-C", "target-feature=+crt-static"]
linker = "riscv64-unknown-linux-gnu-gcc"

Mac上编译linux版本程序(以x86_64-unknown-linux-musl为例)

brew tap messense/macos-cross-toolchains
brew install x86_64-unknown-linux-musl
rustup target add x86_64-unknown-linux-musl
#若报错SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443
#则是由于旧版curl不支持https,重装 brew reinstall curl

在项目目录下配置.cargo/config.toml

[build]
target = "x86_64-unknown-linux-musl"