はじめに
こちらの記事では、ダイナミックリンクによるバイナリのスリム化をトライしたが、逆に、100%スタティックリンクでどれだけバイナリをスリム化できるかトライしてみた時の備忘録である。
情報が古くなってきたので、見直してみたが、macOS は Catalina、Ubuntu も 18.04 LTS なのはご容赦を。(2020年12月アップデート)
環境
- macOS Catalina 10.15.7 (19H114)
- Docker Desktop for Mac 3.0.3
- Ubuntu 18.04.5 LTS
- rustc 1.50.0-nightly (bb1fbbf84 2020-12-22)
Philipp Oppermann さんのテンプレート
色々と調べてみたら、Philipp Oppermann さんのブログにやりたいことそのもののテンプレートを見つけた。詳細な解説については、わかりやすい記事なのでそちらを読んでいただくとして、テンプレートは以下の感じになっている(一部、私の趣味で修正済み)。
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
[package]
name = "minimal_binary"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
edition = "2018"
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-args=-e _start -static -nostdlib"]
rustflags
の後ろの方は "link-args=\"-e __start -static -nostartfiles\""
と書きたくなるのだが、そう書くとビルド時にエラーとなる。また、色々なターゲットで試したいので、.cargo/config.toml
は、ターゲットを限定した書き方にしておく。
ついでに、ホストが macOS でターゲットが Linux のクロス環境の設定もしておこう。GCC & GNU ld ベースの ELF バイナリを生成するためのクロスビルド環境を brew install x86_64-elf-gcc
でインストールし、Docker Desktop for Mac もインストールしておく。そして、~/.cargo/config.toml
に下記の設定をする。
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=x86_64-elf-gcc"]
runner = "docker-run"
#!/bin/sh
case "$1" in
/*)
BINARY=$(echo "$1" | sed "s,^"$CARGO_MANIFEST_DIR"/,,")
HOSTDIR="$CARGO_MANIFEST_DIR/target"
GUESTDIR="/target"
;;
\./*)
HOSTDIR="$PWD"
GUESTDIR=/$(basename "$HOSTDIR")
BINARY=$(echo "$1" | sed "s,^\.,"$GUESTDIR",")
;;
*)
BINARY="$1"
DIR=$(echo "$1" | sed 's,/.*$,,')
HOSTDIR="$PWD/$DIR"
GUESTDIR="/$DIR"
;;
esac
shift
exec docker run -it -v "$HOSTDIR":"$GUESTDIR" --rm alpine:latest "$BINARY" "$@"
では、cargo run
してみよう。
$ cargo run --release
Compiling minimal_binary v0.1.0 (/Users/xxxxxx/Work/rustytest/minimum)
Finished release [optimized] target(s) in 0.18s
Running `target/release/minimal_binary`
^C
$
無限ループなので、^C で止めるしかない。バイナリサイズは 4160 bytes(stripすると 4096 bytes)となり、なるほど最小と思えるサイズになった。
"Hello, world!" に挑戦
標準的なターゲットへは...
上記の環境では libc.a 相当の OS のライブラリがリンクされていない状態なので、Rust から OS を叩くシステムコールをダイレクトに発行しないといけない。crates.io には crate syscall があるが、macOS の 64bit 環境には対応していないので、crate sc を使う。
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
use sc::syscall;
fn write(fd: usize, buf: *const u8, len: usize) {
unsafe {
syscall!(WRITE, fd, buf as usize, len);
}
}
fn exit(status: usize) -> ! {
unsafe {
syscall!(EXIT, status);
}
loop {}
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
let msg = "Hello, world!\n";
let buf = msg.as_ptr();
let len = msg.len();
write(1, buf, len);
exit(0);
}
これを cargo run
してみる。
$ cargo run --release
Compiling sc v0.2.3
Compiling hello v0.1.0 (/Users/xxxxxx/Work/hello/rust-static)
Finished release [optimized] target(s) in 0.59s
Running `target/release/hello`
Hello, world!
無事、動いた。Ubuntu でやっても結果は同様なので、省略。
macOS ホストのクロス環境でも確認しよう。
$ cargo run --release --target x86_64-unknown-linux-gnu
Compiling sc v0.2.3
Compiling hello v0.1.0 (/Users/xxxxxx/Work/hello/rust-static)
Finished release [optimized] target(s) in 0.49s
Running `docker-run target/x86_64-unknown-linux-gnu/release/hello`
Hello, world!
無事、動いた。
非標準のターゲットへは...
Rust では、ターゲットの CPU アーキテクチャがサポートされていて、ターゲットのリンカが用意できれば、標準ではサポートされていないターゲットであっても、簡単にクロスビルド環境を構築できる。以前は、cargo-xbuild というツールが必要だったが、最近の nightly であれば、それも必要がない。
まずはターゲットを定義するが、ゼロから書き上げるよりも、近そうな標準のターゲットをベースに書き換えていくのが良い。今回は std なしのスタティックリンクの Linux 環境を作ってみる。ベースとしては、x86_64-unknown-linux-gnu を使ってみる。rustc を使うとターゲット環境の定義を JSON 形式で取り出すことができる。
$ rustc -Z unstable-options --print target-spec-json --target x86_64-unknown-linux-gnu > x86_64-unknown-linux-gnu.json
CPUに関する定義はそのままで良く(実は、C の int の大きさ、ポインタの大きさ、エンディアンが x86_64-unknown-linux-gnu では定義されていないので、追加する必要があるのだが)、リンカまわりの指定を .cargo/config.toml
から持ってくるだけで今回の場合は良い。
x86_64-unknown-linux-gnu.json
をベースに次のような定義にしてみた。
{
"arch": "x86_64",
"cpu": "x86-64",
"crt-static-default": true,
"crt-static-respected": true,
"data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128",
"dynamic-linking": false,
"env": "gnu",
"executables": true,
"has-elf-tls": true,
"has-rpath": true,
"is-builtin": true,
"llvm-target": "x86_64-unknown-linux-gnu",
"max-atomic-width": 64,
"os": "linux",
"position-independent-executables": true,
"pre-link-args": {
"gcc": [
"-Wl,--as-needed",
"-Wl,-z,noexecstack",
"-Wl,--eh-frame-hdr",
"-m64",
"-e_start",
"-static",
"-nostdlib"
]
},
"relro-level": "full",
"stack-probes": true,
"target-c-int-width": "32",
"target-endian": "little",
"target-family": "unix",
"target-pointer-width": "64",
"vendor": "unknown"
}
~/.cargo/config.toml
には次を追加する。
[target.x86_64-unknown-linux-nostd]
rustflags = ["-C", "linker=x86_64-elf-gcc"]
runner = "docker-run"
今回のビルドに必要なのは crate core
のみなので、次のようにしてビルド&ランができる。
$ cargo run -Z build-std=core --release --target x86_64-unknown-linux-nostd.json
Compiling core v0.0.0 (/Users/xxxxxx/.rustup/toolchains/nightly-x86_64-apple-darwin/lib/rustlib/src/rust/library/core)
Compiling compiler_builtins v0.1.36
Compiling rustc-std-workspace-core v1.99.0 (/Users/xxxxxx/.rustup/toolchains/nightly-x86_64-apple-darwin/lib/rustlib/src/rust/library/rustc-std-workspace-core)
Compiling sc v0.2.3
Compiling hello v0.1.0 (/Users/xxxxxx/Work/hello/rust-static)
Finished release [optimized] target(s) in 30.62s
Running `docker-run target/x86_64-unknown-linux-nostd/release/hello`
Hello, world!
結果
前回の結果に今回の結果を追記してみよう。
target | unstripped | stripped | |
---|---|---|---|
macOS | Rust (-C opt-level=3) | 271,832 bytes | 175,328 bytes |
Rust (-C opt-level=3 -C prefer-dynamic) | 9,048 bytes | 8,656 bytes | |
Rust (-C opt-level=3, no_std) | 4,216 bytes | 4,096 bytes | |
C (-Oz) | 8,432 bytes | 8,440 bytes | |
Linux | Rust (-C opt-level=3, no_std) | 904 bytes | 528 bytes |
うん、なかなか良い感じ。さすが Rust ですね。
今回のソースコードは GitHub にあります。
さらにターゲットを変えてトライもちょっと古くなったが、ご参考までに残しておくことにする。
補足: crate syscall が macOS で動作しない理由
macOS 10.12.6 などのカーネルのソースコードのシステムコールに関連する部分 xnu-3789.70.16/osfmk/mach/i386/syscall_sw.h を読んでみると、64bit のアプリからシステムコールを発行する場合、上位 32bit にシステムコールクラスが、下位 32bit にシステムコールクラス内でのシステムコール番号を入れなさいとコメントに書いてある。これは、Unix のシステムコール番号と macOS の土台の Mach カーネルのシステムコール番号が衝突しないようにするため。32bit OS の頃は、Unix システムコール番号はそのまま、Mach システムコール番号はマイナスにするという従来からの Mach の流儀で衝突を防いでいたが、64bit では方針を変えたようだ。
つまり、macOS の場合、システムコール番号そのままでシステムコールを発行してもダメで、Unix システムコールクラスを表す 0x2000000 をシステムコール番号に足す必要がある。crate syscall
はその対応がなされていないので、macOS の 64bit 環境では正常に動作しない訳だ。