背景

书接上回,在 ushuttle - 支持代理的 UDP in TCP 实现里,我提到了希望制作静态链接的二进制来分发。在实际操作前,考虑到 Go 的交叉编译轻松愉快,本以为 Rust 的流程不会很复杂,但实际操作起来发现… Rust 的交叉编译和静态链接都谈不上轻松。其实主要的原因是 Rust 不自带链接器,编译过程只要安装对应目标工具链即可,但链接过程需要借助其他链接器完成,这便埋下了坑。

我尝试了在 x86_64 的 Linux 上为 {x86_64, i686, aarch64} 上的 {Linux, Windows, macOS, FreeBSD} 构建二进制,最终目标是希望得到完全静态链接的可执行文件,这个贴子的接下来部分也将按照操作系统来展开,因为同一个操作系统的不同架构版本构建方法基本一致。

太长不看版的结论(截止 2023 年 3 月):

这里我要尝试构建的程序是纯 Rust 的,因而不涉及系统库的问题。但之前的个人经历中,对于依赖系统库的程序还需要处理 .so 和 .a 文件,处理 pkg-config 的配置等。纯 Rust 程序已经够掉头发了,如有需要处理系统库的话请参考上面的链接。本文如无特别说明默认要构建的是纯 Rust 项目。

cross

这个项目拯救了希望交叉编译的人们。正如上面所说,Rust 不自带链接器。当使用 rustup target add 时,添加的文件中一般只包括 Rust 编译器,因而编译过程能跟顺畅进行。但到了最后,如果找不到合适的链接器的话,构建仍然会失败。

如果是在 Linux 上为 Linux 构建倒是很简单,gcc 提供了很完善的交叉编译器,安装对应版本一般即可解决问题。然而假如既要跨平台又要跨架构,配置相应工具链的流程就很让人头秃了(下面会提到,比如 Windows 的 MSVC 工具链)。cross 项目本质上提供了供不同架构不同平台用的 Dockerfile,其中打包了对应架构平台的工具链,因此只需要指示 Cargo 使用这个 Docker 容器中的工具链即可。对于除 Windows 和 macOS 平台以外的平台基本上可以做到开箱即用,比较便利。(当然了,与 Go 的交叉编译体验比较的话仍然不够便利,但似乎已经是当前能做到的最好的了。)

Linux

  • 交叉编译难度:低
  • 静态链接难度:低

在 Linux 上,多亏了 musl,静态链接体验相对较好。Linux 上静态链接最主要的坑其实就在于 glibc,如果链接进去可能会存在功能上的问题(参考 Why is statically linking glibc discouraged?),不链接进去的话如果碰上低版本 glibc 的机器程序就无法执行。musl 牺牲了一些性能和兼容性,换来了完全静态链接的体验,一般情况下都能保证处处皆可运行,而且二进制体积没有很大。

不管是 Cargo 还是 cross 都对 musl 有完善支持,一般不使用系统库的话都能很顺畅地完成编译链接。即使是交叉编译,musl 也提供了很多架构的支持,使用 cross 可以方便完成。这个部分值得讨论的内容不多。

Windows

  • 交叉编译难度:中
  • 静态链接难度:?

毕竟是这次讨论的系统中唯一一个非 POSIX 的,Windows 的构建流程还是比较独特的。在链接器方面,Rust 支持使用 GNU ABI 和 MSVC ABI,前者其实就是大名鼎鼎的 mingw 中常用的,后者是 Windows 原生的。GNU ABI 也很好使用,毕竟是 GNU 家的东西,安装一些工具链即可轻松搞定,当然直接用 cross 也行。但似乎 GNU ABI 不提供 aarch64 版本,想构建 aarch64 版本的话必须要 MSVC ABI。这就很尴尬了,众所周知,MSVC 是随着微软的 VS/VC 分发的,只有 Windows 版,是闭源的,该怎么办呢?于是 cross 搞出了一套 Docker 中跑 Wine 中跑 MSVC 的扭曲方案。而且受版权限制,cross 不提供构建好的镜像。不过构建过程还算简单:

1
2
3
4
5
git clone https://github.com/cross-rs/cross
cd cross
git submodule update --init --remote

cargo build-docker-image aarch64-pc-windows-msvc-cross --tag local

最后在 Cross.toml 里指定下镜像:

1
2
[target.aarch64-pc-windows-msvc]
image = "aarch64-pc-windows-msvc-cross:local"

即可使用 cross 完成编译。

这里我想吐槽的地方是镜像大小,只能说不愧是 Docker 中跑 Wine 中跑 MSVC 的扭曲方案,镜像大小从不令人失望:

1
2
3
4
REPOSITORY                                       TAG       IMAGE ID       CREATED        SIZE
i686-pc-windows-msvc-cross local 0db9973bb3c5 40 hours ago 11.7GB
x86_64-pc-windows-msvc-cross local 6841384ff7af 40 hours ago 11.7GB
aarch64-pc-windows-msvc-cross local 155929c5dadd 40 hours ago 11.7GB

幸好三个不同目标架构的镜像共享绝大部分内容,所以三个镜像实际上只占用大概一个的空间。但即便如此一个链接器 11.7 GB 也足够离谱了。这里只能希望未来有一天微软能发布 Linux 下原生的 MSVC 吧,虽然感觉有生之年不太可能。

关于静态链接,可能是我对 Windows 程序的了解还不够,但似乎无法实现真正意义上的完全静态链接?即使是 Go 构建出来的二进制也依赖 C:\Windows\system32\kernel32.dll。不过看起来也很合理,内核总是要有的。而我使用 RUSTFLAGS='-C link-arg=-s -C target-feature=+crt-static' 环境变量构建出的二进制则依赖如图所示的动态链接库:

windows-dependency

Windows 作为广泛使用的桌面系统应该兼容性做的不错,所以倒也不是什么问题,大概。

macOS

  • 交叉编译难度:高
  • 静态链接难度:高

因为手头没有 macOS 设备,因此这个部分只是尝试,不一定准确。

首先是 cross 镜像构建,镜像构建做不到无脑,因为似乎受版权限制不能分发苹果的 SDK。虽然有正确的获取方法,但我偷懒直接找了 GitHub 上的一份 SDK:MacOSX-SDKs。我从 Release 中选取了最高版本来构建 Docker 镜像:

1
cargo build-docker-image aarch64-apple-darwin-cross --build-arg 'MACOS_SDK_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.3.sdk.tar.xz'

好像也很简单?但不知为何,在我构建镜像的时候总是无法下载 GitHub 上的文件,即使换境外网络也不行。这里比较迷,可能是那天 GitHub 服务器抽风了吧。整个构建时间也很长,因为需要现场编译很多东西。

其实个人感觉我这样偷懒直接用网上现有的 SDK 是有风险的,万一分发 SDK 的人不怀好意向里面放入奇怪的东西的话,有可能会成为供应链攻击。

最后构建出来的镜像:

1
2
3
4
REPOSITORY                                       TAG       IMAGE ID       CREATED        SIZE
i686-apple-darwin-cross local 2612d6151647 40 hours ago 1.71GB
aarch64-apple-darwin-cross local 81e32274a9fd 40 hours ago 1.71GB
x86_64-apple-darwin-cross local c626cabecdb3 40 hours ago 1.71GB

还行,出乎意料地不大。

静态链接的话,使用 RUSTFLAGS='-C link-arg=-s 构建出来的二进制的依赖:

1
2
3
4
$ llvm-otool -L ushuttle-macos-x86_64
ushuttle-macos-x86_64:
/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1292.100.5)

看起来也还合理,虽然没有实现完全静态链接,但似乎也是系统中常备的库。手头没有 macOS 环境就无从测试了。

此外,i686 的版本无法构建,提示:

1
error: component 'rust-std' for target 'i686-apple-darwin' is unavailable for download for channel 'stable'

大概已经没什么人用 32 位的 macOS 也就没什么人想维护工具链了吧。也罢。

FreeBSD

  • 交叉编译难度:低
  • 静态链接难度:高

最后是 FreeBSD。本以为跟 Linux 一个祖宗,追求开源和自由软件的 BSD 系列老大(macOS 就不算了吧,毕竟实际上不太能称为 BSD 了),整个流程会很轻松。但实际上不是。

交叉编译方面,直接用 cross 即可,轻松愉快完成。这里想讨论的问题主要在链接上。

首先明确一点,FreeBSD 的应用自然可以静态链接。用 Go 就可以很轻松做到。FreeBSD 没有 musl 这样的东西,但其本身的 libc 也提供了 .a 的静态库,按理来说不难。但 Rust 这边我折腾了半天最终选择放弃。无论如何,不管用不用交叉编译,我只能得到这样的二进制:

1
2
3
4
5
6
7
8
$ file ushuttle
ushuttle: ELF 64-bit LSB pie executable, x86-64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, FreeBSD-style, stripped

$ ldd ushuttle
ushuttle:
libthr.so.3 => /lib/libthr.so.3 (0x8011f4000)
libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x801222000)
libc.so.7 => /lib/libc.so.7 (0x80123c000)

看到这一串动态链接库的版本号,已经能猜到这个二进制的兼容性会怎么样了。旧版本 FreeBSD 大概也是直接炸吧。不过手头只有 FreeBSD 13.1-RELEASE 就也无从测试了。

在网上冲浪时看到 Cross-compiling Rust from aarch64-darwin to x86_64-freebsd借助 Zig 把 Rust 编译到 musl 的踩坑记录 还有 Zig Makes Rust Cross-compilation Just Work,里面提到了一种叫做 Zig 的东西,就也试试吧,说不定大力出奇迹了呢。

Zig 是什么?看起来是一门新的编程语言,但也可以作为 C/C++ 编译器使用。重要的是它提供原生的交叉编译支持,因此也许可以让 Cargo 使用 Zig 的链接器完成链接过程。嗯,听起来好像挺合理的。

根据 Cross-compiling Rust from aarch64-darwin to x86_64-freebsd,作者使用 Zig 的编译器/链接器的尝试失败了。这篇文章的作者遇到的问题是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cp ~/.local/bin/zcc-x86_64-linux-gnu ~/.local/bin/zcc-x86_64-freebsd-gnu
$ cat <<'EOF' >> ~/.cargo/config.toml
[target.x86_64-unknown-freebsd]
linker = "/Users/n8henrie/.local/bin/zcc-x86_64-freebsd-gnu"
EOF
$ cargo build --target x86_64-unknown-freebsd
...
Compiling hello-world-pfsense v0.1.0 (/Users/n8henrie/git/hello-world-pfsense)
error: linking with `/Users/n8henrie/.local/bin/zcc-x86_64-freebsd-gnu` failed: exit status: 1
...
/opt/homebrew/Cellar/zig/0.10.0/lib/zig/include/inttypes.h:21:15/opt/homebrew/Cellar/zig/0.10.0/lib/zig/libunwind/src/config.h: fatal error: 21:
/opt/homebrew/Cellar/zig/0.10.0/lib/zig/include/inttypes.h:21:15'inttypes.h' file not found:: fatal error: 'inttypes.h' file not found
...
/opt/homebrew/Cellar/zig/0.10.0/lib/zig/include/inttypes.h:21:15: fatal error: 'inttypes.h' file not found
...
error: could not compile `hello-world-pfsense` due to previous error

嗯,看起来缺少头文件。也难怪,毕竟是交叉编译。在 GitHub 上搜了下,Zig 对 FreeBSD 交叉编译的支持好像挺烂的,一堆开着的 issue。不过无论如何,缺头文件好说,我们补上即可。我直接从手头的 FreeBSD 13.1-RELEASE 机器上拷贝了整个 /usr/include 到 Linux 的编译机上,准备大力出奇迹:

1
$ CC="/home/dyn/zig/zig-freebsd" C_INCLUDE_PATH="/home/dyn/zig/freebsd/include" CPLUS_INCLUDE_PATH="/home/dyn/zig/freebsd/include" RUSTFLAGS='-C link-arg=-s' cargo build --target x86_64-unknown-freebsd --release

Boom!

当时的输出已经找不到了,但其实就是 regression: can’t target x86_64-freebsd-* (“error: libc not available”/“unable to provide libc” - even if not linking to libc) #14729 里这个错误。没有 libc,那没有我补上就是了嘛,又一次大力出奇迹,把 /usr/lib 拷来了,但最后发现 Zig 的 --libc 不能用于 C/C++ 编译器,而构建 Zig 程序用的命令行与 Cargo 产生的不兼容。又是死胡同。

最后还是不信邪,干脆直接在 FreeBSD 机器上试试算了。编译链接都很顺利,然后我又一次得到了依赖三个 .so 文件的二进制。

行吧。告辞。

总结

Rust 的交叉编译和静态链接的过程绝对谈不上轻松愉快。和 Go 相比,这方面还差的远。如果只是想构建个(大概率)能用的二进制的话,cross 足够了(只是可能需要自己制作下镜像,不算难)。如果想在 Linux 以外实现静态链接… 祝君好运。