Rust でしっかりとスタティックリンク

# はじめに

[こちらの記事](https://qiita.com/moriai/items/f30daae118ea7280a216)では、ダイナミックリンクによるバイナリのスリム化をトライしたが、逆に、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 さんのブログ](https://os.phil-opp.com)にやりたいことそのもののテンプレートを見つけた。詳細な解説については、わかりやすい記事なので[そちら](https://os.phil-opp.com/freestanding-rust-binary/)を読んでいただくとして、テンプレートは以下の感じになっている（一部、私の趣味で修正済み）。

```rust:src/main.rs
#![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 {}
}
```
```toml:Cargo.toml
[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
```
```toml:.cargo/config.toml
[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` に下記の設定をする。

```toml:~/.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=x86_64-elf-gcc"]
runner = "docker-run"
```
```sh:~/.cargo/bin/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` してみよう。

```console
$ 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](https://crates.io) には crate syscall があるが、macOS の 64bit 環境には対応していないので、crate sc を使う。

```rust:src/main.rs
#![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` してみる。

```console
$ 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 ホストのクロス環境でも確認しよう。

```console
$ 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 形式で取り出すことができる。

```console
$ 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`をベースに次のような定義にしてみた。

```json:x86_64-unknown-linux-nostd.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` には次を追加する。

```toml:~/.cargo/config.toml
[target.x86_64-unknown-linux-nostd]
rustflags = ["-C", "linker=x86_64-elf-gcc"]
runner = "docker-run"
```

今回のビルドに必要なのは `crate core` のみなので、次のようにしてビルド＆ランができる。

```console
$ 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!
```


# 結果

[前回の結果](https://qiita.com/moriai/items/f30daae118ea7280a216#結果)に今回の結果を追記してみよう。

| 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 |
|       | <b>Rust (-C opt-level=3, no_std)</b> | <b>4,216 bytes</b> | <b>4,096 bytes</b> |
|       | C (-Oz)  | 8,432 bytes | 8,440 bytes |
| Linux | <b>Rust (-C opt-level=3, no_std)</b> | <b> 904 bytes</b> | <b> 528 bytes</b> |

うん、なかなか良い感じ。さすが Rust ですね。

今回のソースコードは [GitHub](https://github.com/moriai/hello/tree/master/rust-static) にあります。

[さらにターゲットを変えてトライ](/moriai/items/bae7fe3d1c2eaefe5cd0)もちょっと古くなったが、ご参考までに残しておくことにする。


# 補足: crate syscall が macOS で動作しない理由

macOS 10.12.6 などのカーネルのソースコードのシステムコールに関連する部分 [xnu-3789.70.16/osfmk/mach/i386/syscall_sw.h](https://opensource.apple.com/source/xnu/xnu-3789.70.16/osfmk/mach/i386/syscall_sw.h.auto.html) を読んでみると、64bit のアプリからシステムコールを発行する場合、上位 32bit にシステムコールクラスが、下位 32bit にシステムコールクラス内でのシステムコール番号を入れなさいとコメントに書いてある。これは、Unix のシステムコール番号と macOS の土台の Mach カーネルのシステムコール番号が衝突しないようにするため。32bit OS の頃は、Unix システムコール番号はそのまま、Mach システムコール番号はマイナスにするという従来からの Mach の流儀で衝突を防いでいたが、64bit では方針を変えたようだ。

つまり、macOS の場合、システムコール番号そのままでシステムコールを発行してもダメで、Unix システムコールクラスを表す 0x2000000 をシステムコール番号に足す必要がある。`crate syscall` はその対応がなされていないので、macOS の 64bit 環境では正常に動作しない訳だ。