Rustの実行バイナリを必死に小さくする(Windows)
結果
標準出力に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
以上の3つをまぜこぜに
[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
[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"]
#![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なんていう丁寧な処理はせず実行を止めるようにするやつ。
ここら辺にパニックについての思想が書かれてるけどよく分かんないや(^-^)/
まあ今回の場合絶対にパニックしないですしね。
main.rs
こっから普通じゃないことをし始めます。
#![no_std]
#![no_main]
「必死」さがあふれ出す実用ほぼ無理ファイルサイズ削減キット
この2つは大体セットで存在してるよねいつも。
stdの分だけバイナリサイズが増えるのは嫌なので#![no_std]
、mainの前処理をすっとばしたいので#![no_main]
を付けてます。(←前処理すっとばして大丈夫なの?)
なぜかパニックハンドラは付けなくてもコンパイル通ります。
いつのまにそんなこと出来るようになったんだ…?と思って色々ごちゃごちゃしてたらwindowsクレートのError
をErr
として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
になります。
ここまでは前座。
ここからリンカーオプションの説明が入ります。
ここに全部書いてあります
/ENTRY:entry_point
エントリーポイントの関数名を指定するやつ。
entry_point
の部分が関数名なのでeee
をエントリーポイントにしたいなら/ENTRY:eee
にすればOK。
この関数は、__stdcall 呼び出し規則を使うように定義する必要があります。
しっかりエントリーポイントの呼び出し規則のルール書いてありますね。
/ALIGN:16
各セクションのアラインを設定するやつ。
アラインって何…?と思って調べてみましたけど、
になんか理解できそうな説明がありますがうーん…まああんまり細かいことはよく分からん!
/SUBSYSTEM:CONSOLE
Windowsサブシステムを指定するやつ。
"LNK1221: サブシステムは推論できません。定義されている必要があります。
"って言ってくるので仕方なくつけてます。
/MERGE
セクションとセクションを合わせて1つのセクションにしちゃうやつ。
「Rustを使ってWindowsで動くシェルコードを作る」にあるやつをコピっただけだけど、やっていいのこれ…?シェルコード用に特化されたオプションじゃない…?
確認していくか…
ここ参照:
.edata=.rdata
.edataはエクスポートテーブルらしく、今回一個もエクスポートしてるものはないので有っても無くても変わらないっぽいですね。
.rdata=.text
.rdataは読み取り専用の初期化済みデータ、.textセクションは実行可能コードです。
読み取り専用データと実行可能コードを一緒にしてるっぽいです。
.rdataセクションにあったHello, World!
文字列が.textセクションの中に入った。
.pdata=.text
.pdataは例外処理に使用される関数の配列らしいです。例外処理無くね?と思ってGhidraで確認したらエントリーポイントの開始アドレスと終了アドレスが格納されてました。これも.textセクションの中に入っただけ。
これらのオプションは付けてもデータがセクションで区切られなくなっただけでそんなに変わりないかも?怖いし付けないほうがいいと思う!
/DEBUG:NONE
実行ファイルのデバッグ情報を作るやつ。を消す。
strip = true
があるからあっても無くても変わんないや(^-^)/
/EMITPOGOPHASEINFO
え、解説ページ無いんだけど
文書化されていないリンカ―オプション…隠しオプションってこと!?
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で終了するように変更すればいいだけです。:
#![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>>{
// メイン処理
}
まあよく分かんない!!!怖いのでやめいといたほうがいいかも!!
こういうのとかあるけどELFの話だし…
出来る限りの裁量でサイズを減らしていきます…
2023-12-27追記(mainの前は何をしているのか)
パニック処理がRust的ランタイムみたいな感じ…?
と思ったらまんまのFAQあった
エントリーポイントとpanic_immediate_abortがランタイム部分を全部すっ飛ばしてるってことをはっきり伝えてくれている。
ふむふむrt.rs
にmainより前の初期化処理の記述があると…
一回限りの実行時初期化。
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::init
、rt::cleanup
、および同様の関数の内部です。このような場合、パニックは標準実装のバグです。 std がこれらの関数に誤ってパニックを引き起こすのを防ぐ方法がないため、これも非常に可能性が高いです。もう1つは、「main」のユーザー コードからのもの、またはさらに悪質なことに、例:issue #86030。
安全性: 実行時の初期化中に1回だけ呼び出されます。
(Googleで翻訳した方が品質が良かったからこっちはGoogleで翻訳)
うーーん、見た感じパニック系の話ばかりだし、ガチで絶対にpanicしなければ大丈夫っぽい…?
追記終わり
おまけ(何もしない実行バイナリ)
528B(^-^)v
2023-12-27追記(おまけ2)
ここまで小さくしちゃうともう
で頼りにしているようなrust_panic
とかrust_eh_personality
とか言った文字列は全部なくなっちゃいます。(rust_eh_personality
の方はWindowsでのビルドだとmsvc版もgnu版も存在しないっぽい)
元記事ではLTO付けると消えちゃうと書いてあるのでrust_panic
は割と早めに消えますが、それでも消えず手ごわかったcalled `Option::unwrap()` on a `None` value
とcalled `Result::unwrap()` on an `Err` value
もpanic_immediate_abort
が消しました。
なのでRustで書かれたプログラムだってことが分からなくなっちゃいますね。
まあそもそも実行ファイルに関してのRustのアイデンティティってpanicくらいしかないですしね。
panic_immediate_abort
が付いた上で#![no_main]
が付いてないなら残る識別しやすそうな文字列はthread '' has overflowed its stack
くらい…?
でもdll(cdylib
)だと識別できそうな文字列無くなっちゃった…
たまに助けられたリンク
↑32bit版の方も作ろうとして挫折しました…orz
Discussion