深入理解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"