🦶

Rustの実行バイナリを必死に小さくする(Windows)

2023/12/26に公開

結果

標準出力にHello, World!を出力するプログラム、1248バイトになりました(^-^)v


あらすじ

Go言語、バイナリサイズが大きい(Hello Worldで1800KB)代わりに依存dllが少ない(kernel32.dll 1個)
Rust、サイズまあまあデカい(msvc版148.5KB、gnu版4800KB(リリースビルド))し依存dllが多い(msvc版8個、gnu版7個)
gcc74.6KB(依存dll2個)、cl139.5KB(依存dll1個)、VisualStudio12KB(依存dll9個)
orz

内容

ツールチェーン: nightly-x86_64-pc-windows-msvc

https://github.com/johnthagen/min-sized-rust
https://github.com/retep998/hello-rs
https://qiita.com/lalafell/items/df3250dc45fb416a5e65
以上の3つをまぜこぜに

Cargo.toml
[package]
edition = "2021"
authors = ["nesken7777"]
name = "hello"
version = "0.1.0"

[dependencies]
windows = { git = "https://github.com/microsoft/windows-rs.git", version = "0.52.0", features = [
    "Win32_System_Console",
] }

[profile.release]
panic = "abort"
strip = true
opt-level = "z"
lto = true
codegen-units = 1

.cargo/config.toml
[build]
target = "x86_64-pc-windows-msvc"
rustflags = [
    "-Zlocation-detail=none",
    "-Clink-arg=/ENTRY:entry_point",
    "-Clink-arg=/ALIGN:16",
    "-Clink-arg=/SUBSYSTEM:CONSOLE",
    "-Clink-arg=/MERGE:.edata=.rdata",
    "-Clink-arg=/MERGE:.rdata=.text",
    "-Clink-arg=/MERGE:.pdata=.text",
    "-Clink-arg=/DEBUG:NONE",
    "-Clink-arg=/EMITPOGOPHASEINFO",
]

[unstable]
build-std = ["std", "panic_abort"]
build-std-features = ["panic_immediate_abort"]

main.rs
#![no_std]
#![no_main]

use windows::{
    core::Result,
    Win32::System::Console::{GetStdHandle, WriteConsoleA, STD_OUTPUT_HANDLE},
};

fn main() -> Result<()> {
    let msg = "Hello world!\n";
    let stdout = unsafe { GetStdHandle(STD_OUTPUT_HANDLE)? };
    unsafe { WriteConsoleA(stdout, msg.as_bytes(), None, None)? };
    Ok(())
}

#[no_mangle]
pub extern "system" fn entry_point() {
    let _ = main();
}

#[link(name = "vcruntime")]
#[link(name = "ucrt")]
extern "C" {}

見るからに邪道ですね^^;じゃ一つ一つ見ていきましょうね~

プロファイル設定

まあまず普通に、リリースビルドのコンパイル設定をCargo.tomlで設定できる範囲で設定しましょうね~

opt-level = "z"

最適化レベルの設定ですね。
"s""z"がサイズ用の最適化オプションですけど、今回"z"を選んだ理由はmin-sized-rustに従っただけです。ちなみにこのプログラムだと"s"にしてもサイズは一切変わりませんでした。

strip = true

実行時に使わない補助データを消すやつ。

lto = true

LLVMのリンク時最適化をオンにするやつ。

codegen-units = 1

クレートを並列にコード生成はしないようにするやつ。

panic = "abort"

パニック時にunwindなんていう丁寧な処理はせず実行を止めるようにするやつ。
ここら辺にパニックについての思想が書かれてるけどよく分かんないや(^-^)/
https://doc.rust-jp.rs/rust-nomicon-ja/unwinding.html
まあ今回の場合絶対にパニックしないですしね。

main.rs

こっから普通じゃないことをし始めます。

#![no_std] #![no_main]

「必死」さがあふれ出す実用ほぼ無理ファイルサイズ削減キット

この2つは大体セットで存在してるよねいつも。
stdの分だけバイナリサイズが増えるのは嫌なので#![no_std]、mainの前処理をすっとばしたいので#![no_main]を付けてます。(←前処理すっとばして大丈夫なの?)

なぜかパニックハンドラは付けなくてもコンパイル通ります。
いつのまにそんなこと出来るようになったんだ…?と思って色々ごちゃごちゃしてたらwindowsクレートのErrorErrとしてResultを返す関数が存在すれば(それがdead_codeであっても)コンパイルが通るみたいです。なんで?

つまり以下のコードでもコンパイルが通ります。

#![no_std]
#![no_main]

// mainはただ存在するだけ
fn main() -> Result<(), windows::core::Error> {
    //panic!("(^v^)/"); // ←ここにpanic!があってもコンパイルは通る
    Ok(())
}

#[no_mangle]
pub extern "system" fn entry_point() {
    panic!("(^-^)/");
}

#[link(name = "vcruntime")]
#[link(name = "ucrt")]
extern "C" {}

というかwindowsクレートってno_std環境で動くところは動くんだ。
…と思いましたがwindows::core::Errorはstd使っているのでもうよく分かりません。助けて!

fn main

mainって名前ついてますけど#![no_main]なのでただの名前です。別にabcとかでもいいです。entry_pointってとこから呼ばれているのがよく分かる。

no_stdだしprintln!は使いません。"Hello, World!"を表示するだけなんですからもっと効率よく!Windows APIの関数を使って直で標準出力のハンドルをもらって直で書き込む!

fn entry_point

こいつもentry_pointって名前ついてますがlink.exeへのフラグでどうとでもなります。別に名前がeeeでもいいです。

hello-rsのコードから転用したため考えずextern "system"を付けてますが、まあ考えればWindows側の呼び出し規則に合わせるべきですね。

その上には#[no_mangle]がついてます。関数名変えられちゃあlink.exeに関数名指示しても伝わりませんからね。

#[link(name = "vcruntime")] #[link(name = "ucrt")]

(危ねえ 必要なくね?と思って消すところだった…)

たまにmemcpy memcmp memsetがリンクエラーになるので必要になります。
いつ必要になるかは分からない。たまに。

最小のバイナリが作成されるとき(panic_immediate_abortがオンのとき)はこの記述が必要なかったので気づくのに遅れました。

config.toml

リンカーオプションてんこ盛り黒魔術マシマシの世界へ…

target = "x86_64-pc-windows-msvc"

ターゲットがx86_64-pc-windows-msvcになるのは当然なのですが、これを付けてないと依存クレートのwindows_x86_64_msvcでのビルドスクリプトでlink.exeがエラーを吐くしbuild-stdはtarget付けろって言ってくるので仕方ないです。

-Zlocation-detail=none

-Zとある通りこれはnightly必須!

パニック時にどの部分でパニックが発生したかの情報(thread 'main' panicked at src\main.rs:13:5:src\main.rs:13:5の部分)を消します。
つまりどこの部分でパニックしようと<redacted>:0:0になります。


ここまでは前座。
ここからリンカーオプションの説明が入ります。

https://learn.microsoft.com/ja-jp/cpp/build/reference/linker-options?view=msvc-170

ここに全部書いてあります

/ENTRY:entry_point

https://learn.microsoft.com/ja-jp/cpp/build/reference/entry-entry-point-symbol?view=msvc-170

エントリーポイントの関数名を指定するやつ。
entry_pointの部分が関数名なのでeeeをエントリーポイントにしたいなら/ENTRY:eeeにすればOK。

この関数は、__stdcall 呼び出し規則を使うように定義する必要があります。

しっかりエントリーポイントの呼び出し規則のルール書いてありますね。

/ALIGN:16

各セクションのアラインを設定するやつ。

アラインって何…?と思って調べてみましたけど、

https://stackoverflow.com/questions/3756994/what-is-alignment-and-how-to-convert-from-one-alignment-to-another
になんか理解できそうな説明がありますがうーん…まああんまり細かいことはよく分からん!

/SUBSYSTEM:CONSOLE

Windowsサブシステムを指定するやつ。

"LNK1221: サブシステムは推論できません。定義されている必要があります。"って言ってくるので仕方なくつけてます。

/MERGE

セクションとセクションを合わせて1つのセクションにしちゃうやつ。

Rustを使ってWindowsで動くシェルコードを作る」にあるやつをコピっただけだけど、やっていいのこれ…?シェルコード用に特化されたオプションじゃない…?

確認していくか…
ここ参照:

https://learn.microsoft.com/ja-jp/windows/win32/Debug/pe-format

.edata=.rdata

.edataはエクスポートテーブルらしく、今回一個もエクスポートしてるものはないので有っても無くても変わらないっぽいですね。

.rdata=.text

.rdataは読み取り専用の初期化済みデータ、.textセクションは実行可能コードです。

読み取り専用データと実行可能コードを一緒にしてるっぽいです。
.rdataセクションにあったHello, World!文字列が.textセクションの中に入った。

.pdata=.text

.pdataは例外処理に使用される関数の配列らしいです。例外処理無くね?と思ってGhidraで確認したらエントリーポイントの開始アドレスと終了アドレスが格納されてました。これも.textセクションの中に入っただけ。

これらのオプションは付けてもデータがセクションで区切られなくなっただけでそんなに変わりないかも?怖いし付けないほうがいいと思う!

/DEBUG:NONE

実行ファイルのデバッグ情報を作るやつ。を消す。
strip = trueがあるからあっても無くても変わんないや(^-^)/

/EMITPOGOPHASEINFO

え、解説ページ無いんだけど

https://stackoverflow.com/questions/45538668/remove-image-debug-directory-from-rdata-section

文書化されていないリンカ―オプション…隠しオプションってこと!?
IMAGE_DEBUG_DIRECTORYが消えるらしい…それしか分からない…


build-std

[unstable]とある通り、ここからはnightly限定です。

min-sized-rust曰く、stdはツールチェーンに同梱されていて、ビルド時に静的にリンクされるみたいです。(%USERPROFILE%\.rustup\toolchains\<ツールチェーン名>\lib\rustlib\<ターゲット名>\lib\のやつ?)
それがサイズ最適化もされていないし使わない部分も同梱しちゃうしなので、stdも自分でビルドしよう!っていうのがbuild-stdです。stdとpanic_abortの2つだけでも多少サイズを減らせますが、後述のpanic_immediate_abortのために敷いたものなだけかも…

build-std-features

stdをビルドするときにfeaturesを指定してビルドするオプションです。
%USERPROFILE%\.rustup\toolchains\<ツールチェーン名>\lib\rustlib\src\rust\library\の各フォルダのCargo.tomlにあるフィーチャーフラグをオンにします。
つまりbuild-stdが付いてないとこのオプションは意味を成しません。

その中で使うのはさっきまでで2回顔を出していたpanic_immediate_abort。こいつは名前の通りパニック時にガチで何もせずトンズラします
panic = "abort"でもスタックの巻き戻しはしなくてもパニック時メッセージとパニック場所は表示しておいて終了します。
しかしこいつはその一切をせず無言で終了します

その分の処理を消す、ということです。


さて以上で内容の解説は終わり。

少し修正

そりゃあこんなに無理に小さくしたんですからちょっとよくない部分があります。(といっても1つしか見つけてないですが…)
これを実行したとき、想定通りの処理が行われても異常終了したとみなされます:

といっても終了コード1で終了しているだけなので、これを0で終了するように変更すればいいだけです。:

main.rs
#![no_main]
#![no_std]

use windows::{
    core::Result,
    Win32::System::Console::{GetStdHandle, WriteConsoleA, STD_OUTPUT_HANDLE},
};

fn main() -> Result<()> {
    let msg = "Hello world!\n";
    let stdout = unsafe { GetStdHandle(STD_OUTPUT_HANDLE)? };
    unsafe { WriteConsoleA(stdout, msg.as_bytes(), None, None)? };
    Ok(())
}

#[no_mangle]
- pub extern "system" fn entry_point() {
+ pub extern "system" fn entry_point() -> i32 {
    let _ = main();
+    0
}

#[link(name = "vcruntime")]
#[link(name = "ucrt")]
extern "C" {}

#![no_std]無しだったらどうなんだよ

#![no_std]はかなりきついのでさすがにな。こいつ消しましょう。

あ、ごめん同じだったわ(^-^)/

あれ…じゃあno_std意味無い…?

#![no_main]もなかなかイレギュラーなので消しましょう。
ALIGNが128以上を指定していないとlink.exeがエラーを吐くので/ALIGN:128にします。

28KBくらい増えた。めちゃデカくなった。サブルーチンめちゃくちゃ増えた。

危なそうなコンパイルフラグをオフにする

まず/MERGEはなんか怖いので全部オフります。

全然変わんね~

じゃあこのままrustflags全部オフります。

全然変わんね~

パニック情報が出ないのはやっぱし困るのでpanic_immediate_abortオフります。

60KB増えた。これじゃん!!!!

ついでにbuild-stdもオフります。

こいつも30KB影響有ったんだ…

println!を使う

やっぱし直で標準出力のハンドル取って直で出力することなんてしないんだからprintln!のHello, World!も必要でしょ!

#![no_main]
#[no_mangle]
pub extern "system" fn entry_point() -> i32 {
    println!("Hello, World!");
    0
}

#[link(name = "vcruntime")]
#[link(name = "ucrt")]
extern "C" {}

2桁ですけど11KBですから十分小さいですね!

依存dll

そういえば忘れてた!
って言ってもやる意味あんま無いと思う…どうせOSに依存してるんだから何も変わりないと思うし…

WriteConsoleAを使う方:

1個。

println!を使う方:

3個。

結論

エントリーポイント変更とpanic_immediate_abortが影響がデカい。

ただエントリーポイント変更はパニック時対応に必要な前処理がすっ飛ばされるし他に何か必要な処理すっ飛ばしてそうで怖いし、panic_immediate_abortはパニック情報出ないし…

じゃあ絶対にpanicしないプログラムを書ければいいってこと…になる?

全部Resultに?を使ってOptionもok_orとか使ってResultにすればとりあえずpanicでは無くなるからまあいけるか…?でも外部クレートでpanicしてきたら困るし…

でもエントリーポイント変更に関してはかなり厳しいというか…普通ならmainはユニット型もしくはResult型なら返してもよくて、Result返すなら?を付けまくってもmainでErrが返ったらエラー情報を出してくれて結構楽に書けるけどそれが出来なくなるからなあ…

やるとしてこう…?

#![no_main]
#[no_mangle]
pub extern "system" fn entry_point() -> i32 {
    match main() {
        Ok(_) => {}
        Err(e) => {
            eprintln!("{:?}", e);
        }
    }
    0
}

fn main() -> Result<(), Box<dyn Error>>{
    // メイン処理
}

まあよく分かんない!!!怖いのでやめいといたほうがいいかも!!

https://youtu.be/q8irLfXwaFM

こういうのとかあるけどELFの話だし…
出来る限りの裁量でサイズを減らしていきます…

2023-12-27追記(mainの前は何をしているのか)

パニック処理がRust的ランタイムみたいな感じ…?
と思ったらまんまのFAQあった

https://prev.rust-lang.org/ja-JP/faq.html#does-rust-have-a-runtime

エントリーポイントとpanic_immediate_abortがランタイム部分を全部すっ飛ばしてるってことをはっきり伝えてくれている。

ふむふむrt.rsにmainより前の初期化処理の記述があると…

https://github.com/rust-lang/rust/blob/master/library/std/src/rt.rs

一回限りの実行時初期化。
main`の前に実行される。
安全性:ランタイムの初期化中に一度だけ呼び出されなければならない。
注意: Rust のコードが外部から呼び出された場合など、実行は保証されない。

sigpipe パラメータ

2014年以降、Unix上のRustランタイムは SIGPIPE ハンドラを SIG_IGN に設定しています。そのため、 fn main()#[unix_sigpipe = "..."] 属性を使って、 fn main() が呼ばれる前に SIGPIPE をどのようにセットアップするか (変更する場合) を選択できるようになっています。詳細は https://github.com/rust-lang/rust/issues/97889 を参照すること。

この関数の sigpipe パラメータは、 fn lang_start() を呼び出すために rustc が生成するコードから値を取得します。Unix だけでなく、すべてのプラットフォームで sigpipe を使用できるようにしたのは、std では cfg ディレクティブをこのような高レベルで使用できないからです。詳しくは src/tools/tidy/src/pal.rs のモジュールドキュメントを参照してください。他のすべてのプラットフォームでは、 sigpipe は値を持ちますが、その値は無視されます。
u8であっても、4つの値しか持たない。これらは compiler/rustc_session/src/config/sigpipe.rs` に記述されている。

(DeepLで翻訳)

次に、先ほど作成したガード情報で現在のスレッドを設定する。これは新しいスレッドでは一般的に必要ではないが、メイン・スレッドに名前を付け、スタック境界に関する正しい情報を与えるために行うだけであることに注意。

(DeepLで翻訳)

この関数によって呼び出されたコードが、Rust で制御されるコードの外側に巻き戻され(つまり未定義動作)ないよう保護します。これは、#[lang="start"] 属性の実装方法とパニック メカニズム自体の実装の組み合わせによって課せられる要件です。

巻き戻しが始まる可能性のある例がいくつかあります。1つ目は、bstd によって制御される rt::initrt::cleanup、および同様の関数の内部です。このような場合、パニックは標準実装のバグです。 std がこれらの関数に誤ってパニックを引き起こすのを防ぐ方法がないため、これも非常に可能性が高いです。もう1つは、「main」のユーザー コードからのもの、またはさらに悪質なことに、例:issue #86030。
安全性: 実行時の初期化中に1回だけ呼び出されます。

(Googleで翻訳した方が品質が良かったからこっちはGoogleで翻訳)

うーーん、見た感じパニック系の話ばかりだし、ガチで絶対にpanicしなければ大丈夫っぽい…?

追記終わり

おまけ(何もしない実行バイナリ)

528B(^-^)v

2023-12-27追記(おまけ2)

ここまで小さくしちゃうともう

https://zenn.dev/dalance/articles/2f18bb4e516bea

で頼りにしているようなrust_panicとかrust_eh_personalityとか言った文字列は全部なくなっちゃいます。(rust_eh_personalityの方はWindowsでのビルドだとmsvc版もgnu版も存在しないっぽい)
元記事ではLTO付けると消えちゃうと書いてあるのでrust_panicは割と早めに消えますが、それでも消えず手ごわかったcalled `Option::unwrap()` on a `None` valuecalled `Result::unwrap()` on an `Err` valuepanic_immediate_abortが消しました。

なのでRustで書かれたプログラムだってことが分からなくなっちゃいますね。
まあそもそも実行ファイルに関してのRustのアイデンティティってpanicくらいしかないですしね。

panic_immediate_abortが付いた上で#![no_main]が付いてないなら残る識別しやすそうな文字列はthread '' has overflowed its stackくらい…?
でもdll(cdylib)だと識別できそうな文字列無くなっちゃった…

たまに助けられたリンク

https://github.com/rust-lang/wg-cargo-std-aware/issues/56

https://users.rust-lang.org/t/unresolved-external-symbol-memcpy-memset-memmove-memcmp-strlen-cxxframehandler-cxxthrowexception/58546

https://github.com/rust-lang/compiler-builtins/issues/403
↑32bit版の方も作ろうとして挫折しました…orz

Discussion