Rustには`main`より前に実行されるコードがある
(grack.com)- Rustバイナリは
fn main()の前に ランタイム初期化段階 を経ており、この段階でパニック・アンワインド処理やプログラム引数の変換などが行われる - OSローダーがエントリポイントに制御を渡すと、CランタイムとRustランタイムが初期化関数を実行し、
#[unsafe(link_section = "...")]とコンストラクタ方式で pre-mainコード を配置できる - リンカーセクション は複数のクレートが提出したデータをバイナリ生成時に一か所へ集約し、
link-sectionはそれをRustスライスのように扱えるようにする ctorとlink-sectionを組み合わせると、CLIサブコマンド登録や文字列インターニングプールの整列といったパターンをmainの前に構成し、その後はロックなしで読める- この方式は割り当て不要の集約と制御の反転を提供する一方、デッドコード除去の難しさ、コンストラクタの制約、プラットフォーム差異、Miri互換性の限界があるため、適用範囲は慎重に選ぶ必要がある
Rustバイナリの main 前段階
- すべてのRustバイナリは
fn main()を持つが、実際の実行フローはOSローダーとランタイム初期化を経てからmainに到達する - Cには
libcとして認識されるCランタイムがあり、Rustは標準ライブラリを通じて独自のランタイムを持ち、Cランタイムの上により高水準な抽象化を構築している - ランタイムの目的は、開発者のコードとプラットフォームOSを統合することにある
- Cランタイムは
main前の段階で、割り当て、ファイルアクセス、スレッドローカルストレージなどのランタイムサービスを構成する - Rustはこの時点でパニックとアンワインド処理を準備し、Cスタイルのプログラム引数を
std::env::argsインターフェースに変換する - pre-main段階はユーザーコードより先に実行され、単一スレッドで、順序が予測可能な環境であるため、決定的な初期化に適している
エントリポイント
- バイナリは、OSローダーがバイナリをメモリに載せて環境を設定し、その後に制御を渡すことで開始される
- LinuxではELFヘッダーの
e_entryフィールドにエントリポイントが保存され、通常はリンカーが_startというシンボルのアドレスを配置する - Windowsにも同様のフックがあり、実行ファイルは
_WinMainCRTStartup関数から始まる - 初期のランタイムブートストラップは、ファイルI/O初期化やアロケータ初期化のような静的関数呼び出しツリーだった
- ランタイムが複雑になるにつれて静的初期化呼び出しツリーも肥大化し、バイナリには必要かもしれないし不要かもしれないCランタイム機能がより多く含まれるようになった
- リンカーが未使用コードをバイナリ生成前に削除できるようになると、静的初期化呼び出しツリーを置き換える方式が必要になった
- GCCの
__attribute__((constructor))方式は、初期化関数ポインタの一覧をバイナリ内の連続領域に配置し、Cランタイムが起動時にそれを走査して呼び出す構造だった - コンストラクタには優先度を与えられるようになり、たとえばバッファ付きファイルI/Oより前に
malloc初期化が必要になることがある - Linuxの最新
glibcランタイムは.init_arrayに関数ポインタを保持し、数値サフィックスで実行順序を決められる - 優先度100以下の値はランタイム自体に予約されているため、Cランタイムを使うコードは101以上を使う必要がある
- Rustでは
#[used]や#[unsafe(link_section = ".init_array.101")]のような属性で初期化関数ポインタを配置できる
linktime: ctor, link-section など
- 例はLinuxと複数のBSDで動作するが、クロスプラットフォームの例として設計されたものではない
- macOSは
startとstopシンボルをサポートするが名前が異なり、Windowsはstartとstopシンボルをサポートしない一方で、実質的に同等のセクション整列規則を持つ ctorとlink-sectionはlinktimeプロジェクトのクレートで、プラットフォームごとの差異とリンカー作業の複雑さを抽象化するinventoryとlinkmeは同じ原理の上に作られた広く使われているクレートだが、この例には制約があるctorクレートは、コンストラクタをクロスプラットフォーム方式で登録するためのボイラープレートを処理する#[ctor(unsafe, priority = 101)]のような属性を付けた関数は、コード中で直接呼び出さなくても、リンカーが整理した後にCランタイムから呼び出される
セクションとリンカースクリプト
- コンパイラはデータやコードをバイナリ内の特定位置、ほとんどのプラットフォームでは セクション と呼ばれる領域に配置できる
- Rustでも
link_section属性を通じて同じ整理機能を使える - 多くのリンカーでは、開発者が リンカースクリプト を提供でき、このテキストファイルがオブジェクトファイルをどう組み立てるかをリンカーに指示する
- リンカースクリプトを使えば、1つのCファイルをLinux実行ファイルにも、ハードディスクのブートセクタに置かれる生のアセンブリブロックにもできる
- リンカースクリプトはソースファイルには存在しないが、Cコードからロード済みバイナリの基本データポインタへアクセスするのに使える仮想シンボルを定義できる
- 例示のリンカースクリプトにある
_TEXT_START_と_TEXT_END_は.textセクションの開始と終了を指すよう定義される _TEXT_START_ = .;のピリオドは、バイナリの現在の出力アドレスに近い値として解釈される位置カウンタを意味する
リンカーシンボル
- リンカーは開始・終了シンボルの値をポインタとして設定するのではなく、同名の
staticが置かれるアドレスを設定する - 開始・終了シンボルは
*const Typeポインタではなく、独自のデータを持たずアドレスだけに意味がある - セクションは、開始シンボルを含み終了シンボルを含まない範囲にあるデータで構成される
- 多くのリンカーは、実行ファイル中のすべてのセクション境界を自動定義する機能を持つようになった
- GNUツールチェーンでは、
MY_SECTIONというセクションに対して__start_MY_SECTIONと__stop_MY_SECTIONシンボルが自動定義される - macOSには各セクションごとに
section$startとsection$endシンボルを合成する類似パターンがある - GNUリンカーでリンカースクリプトに明示されていないセクションは 孤立セクション と呼ばれる
- セクション名がCシンボル名と互換である場合にのみ、リンカーは
_start・_stop接頭シンボルを自動定義する our_stringsは動作するが、our.stringsや.our_stringsは同じようには動作しない- 境界シンボルはデータを持たずアドレスだけが重要なため、例では
MaybeUninit<()>で表現される - Stable Rustには理想的な「不透明な外部型」がまだ実装されていないため、
MaybeUninitが代替として使われる &raw constポインタをstatic項目に対して作るのは常に有効なので、値を読まずにアドレスだけを安全に取得できるlink-sectionはこうしたリンカーセクションの詳細を抽象化し、標準のスライス演算を使えるRustスライスへ変換する- リンクセクションの力は、バイナリにコードを提供するどのクレートからでも同じセクションへ項目を提出でき、最終バイナリ生成の直前にリンカーがそれらをすべて集めてくれる点にある
依存性注入
- セクションベースの登録パターンは、依存性注入 と同じ原理で動作する
- Dagger や Spring のようなフレームワークも、登録データの消費者が提供者と結合してはならないという原理の上にある
- 提供者は定義場所でデータを登録し、消費者はレジストリを読む
- 従来の依存性注入では、フレームワークが起動時にモジュールグラフをたどったり、ロード済みクラスをスキャンしたりして、提供者と消費者を見つける必要があることが多い
- リンカーセクションでは、バイナリ生成時にリンカーが提供者データを収集し、消費者が簡単に読めるようにする
- CLIサブコマンド登録の例は、
link_section::sectionでサブコマンドを登録するこのパターンの一例である - Turbopack は、文字列プール定数、シリアライズ・デシリアライズ登録装置、turbotaskインクリメンタルコンパイル関数 の登録にこのパターンを使っている
- 仮想的なWebサーバーでも、このパターンを使ってルートやミドルウェアをビルド時に自動収集できる
登録にセクションを使う
main前処理の利点は、明示的に開始しない限りスレッドが実行されないことにある- この環境では、多くの場合ロックや同期プリミティブの複雑さを避けられる
- データのライフサイクルを、
main前の書き込み可能な段階と、main後の不変段階に明確に分けられる - 実行中プログラムでデータへアクセスする際にロックの取得と解放を避けられれば、構造が単純になり効率も高まる可能性がある
- 例では
CliSubcommand構造体、constコンストラクタ関数、#[section]を使ってサブコマンドを収集する list、add、helpのようなサブコマンドはコード中のどこにあってもよいmain関数はCLI_SUBCOMMANDSセクション定義さえ見えていれば、登録済みサブコマンドの名前や場所を知らなくても動的にディスパッチできる- 登録されたサブコマンドがなければデフォルトのサブコマンドに戻り、この例では
helpがデフォルトとして動作する
不変データを超えて
- 前の例はリンク済みデータが不変であることを前提としているが、リンカーベースのデータ整理は可変データにも使える
- グローバル静的データの可変性はRustでよくある問題であり、ミューテックスやアトミック型のような内部可変性ツールで解決できる
- ミューテックスやアトミック型は競合がないときでも高コストではないが、決して無料ではない
- Rustでデータを安全に変更するには、変更がスレッドセーフに行われ、可変参照が存在する間は同じデータへの別の参照が存在しない必要がある
- pre-main環境は、明示的にスレッドを開始しない限り単一スレッドなので、アトミック操作は不要である
- 単一スレッド環境では、変更が後続の読み取りより前に起きる happens-before 関係が自動的に成立する
main前にリンクセクションのデータを変更しておけば、その後はどのスレッドからでもロックなしで安全にアクセスできる- 可変参照を
main前にだけ作成して閉じれば、可変参照が存在するときに他の参照がないという条件も満たせる - リンクセクションのスライスはセクション内の静的項目へのエイリアスであるため、スライスにも静的項目にもエイリアス規則が適用される
- スライス経由で安全に変更するには、静的項目を必ず
UnsafeCellの中に置かなければならない UnsafeCellで包まれていない静的項目については、LLVMが値をキャッシュしたり再順序化したり、そのデータについて仮定を置いたりできるUnsafeCell自体はSyncではないため、別のラッパー型が必要になる- 例では
SyncUnsafeCellとMaybeUninit<SyncUnsafeCell<...>>を使って境界シンボルと項目を構成している - 整列可能な文字列インターニングプールの例では、リンク時点で文字列プールを定義し、ランタイム初期段階でスライスを整列して、その後は二分探索で文字列を探す
- 手動実装はボイラープレートが多いが、
ctorとlink-sectionを使えばTypedMutableSectionとコンストラクタで同じ構造を簡潔に作れる TypedMutableSectionの項目はconstである必要があり、これは手動実装例と似た方式のコードが内部で使われるためである
リンクセクションパターンの利点
- このパターンはタグ付き項目を保証された方法で集約し、すべてのデータを事前割り当て済みの連続メモリに配置する
- 登録場所をコード中のどこにでも分散できる
- セクション内の項目数を保証された値として取得できる
- リンクセクションは追加の割り当てを必要としない
- リンクセクションなしで同じ構造を作ると、
HashMap、Vec、その他のデータ構造を割り当て、項目を集める過程で何度もサイズ変更することになる - 従来の収集方式では、共有型モジュール、寄与モジュール、収集モジュールの間で依存関係が深く絡み合う
- リンクセクションを使えば、収集側はどこにあってもよく、どのモジュールがデータを寄与するかを気にする必要がない
scattered-collectは、リンク時サポートを備えた複数のデータ構造類似体を提供するScattered*Sliceはスライスを提供するさまざまなVec類似構造で、オプションでソートもサポートするScatteredMapとScatteredSetは、最小限のpre-main初期化でハッシュベースのキー・値検索を提供するHashMap・HashSet類似構造である
この方式を使うべきでない場合
- リンク時点計算は強力だが、常に適切なツールとは限らない
- リンク時点方式の代わりに、データを寄与する各クレートを見渡せるクレートで手動収集することもできる
- 手動収集は不便になりがちで、寄与者がコアクレートの単一寄与点を見る代わりに、多数のクレート参照を持つ収集クレートが必要になる
- デッドコード除去は難しくなる
link-sectionとlinkmeは項目に#[used]を付けるため、リンカーは未使用データを削除できない- インターンされた文字列アトムのような小さなデータでは問題にならないかもしれないが、生のJSON・JavaScript断片や大きなデータ構造をインターンすると、特定しにくいデッドコードが大量に蓄積する可能性がある
- pre-mainコンストラクタ関数には制限がある
- コンストラクタ関数はパニックを起こしてはならず、Rustは標準ライブラリのすべての関数が使えることを保証していない
- 同じ優先度内での初期化関数の呼び出し順序は保証されず、プラットフォーム依存性も大きい
- これらの制限は慎重な設計で回避できる場合もあるが、pre-main方式は微妙でデバッグしづらいという理由から適切でないことがある
- Miri は、すべてのpre-mainコンストラクタとリンクセクション構成を完全にはサポートしていない
- 現在のMiriはpre-main実行を非常に基本的にしか扱わず、リンクセクションをモデル化しない
- 未定義動作のテストには ASan、TSan などのLLVMサニタイザが推奨される
- 制御の反転パターンは、リンクセクションへデータを寄与しているすべての場所を監査しにくくする可能性がある
- 広く配布され、多く使われているRustプログラムの少なからぬ数が、すでに
ctor、link-section、inventory、linkmeのようなpre-main機能に依存している
WASMについての短い整理
- WASMは、過去の選択の影響により、リンカーセクションをネイティブにはサポートしていない
#[link_section]アノテーションは項目を本物のコードセクションには配置できず、WASMコード自体からアクセスできないWASMカスタムセクションに配置するlinktimeクレートはWASMをサポートしており、WASMバイナリでもこのアプローチが動作するようにエミュレーションによる回避策を提供する- 適切なWASMサポートを追加する方法は、今後提案される可能性がある
結論
mainの前には、特定のケースで大きな利点をもたらす多くの処理を実行できる- pre-main環境は順序が強く制御され、制御可能性も高いため、ロック、アトミック型、その他の同期プリミティブなしでも多くの処理をより自信を持って実行できる
- リンクセクションは、バイナリ全体にわたって関連データを任意に集約して一緒に配置でき、不自然なクレート依存順序を避けられる
- 多くの場合、割り当てを完全に避けられるため、繰り返しの割り当てによる断片化といったアロケータ問題から距離を置ける
- 関連クレートとしては
ctor、dtor、link-section、scattered-collectがある
1件のコメント
Lobste.rs の意見
Apple はシステムコールの ABI 安定性の境界として libSystem.dylib を使い、NT 系 Windows はシステムコールではなく
ntdll.dllを ABI 安定性の境界としている: not syscallsOpenBSD では、Go がローダーによって設定された読み取り専用の
libcマッピング外でシステムコールを試みた場合にカーネルが終了させるというポリシーを回避するため、NX ビットの強制適用を無効にするようなメタデータフラグを設定していたように見えるただし libSystem.dylib contains the functionality which would normally be
libc.soplus other things なので、その意味では BSD 系の「libc が安定性の境界」という方式と同じであるまた As of Go 1.16 以降、Go は OpenBSD のシステムコール方針に従うため libc を使っている
Linux は安定したシステムコール番号を持つため比較的まれなケースで、他の OS のように「プロセスのアドレス空間に動的ライブラリとしてロードされるカーネルの断片が、カーネルモードのコードと不安定なシステムコール
enum定義を共有する」という構造ではなく、また Linux と glibc が他の環境のように同じリポジトリで一緒に開発されているわけでもないためであるWindows では C ランタイムが、MS-DOS が受け継ぎ Windows の子プロセス生成 API にも引き継がれた CP/M 由来のコマンド文字列を、POSIX 風の
argv配列にパースする役割も担っているそのため Python の
subprocessドキュメントには Converting an argument sequence to a string on Windows という節があり、MS C ランタイムに組み込まれた引用符ルールに従ってargv配列を文字列に変換する方法を説明している。呼び出された子プロセス側の独自パーサーは、必要であればこのルールとは異なる動作もできるLinux の
_startも、正確にはリンカーがその名前のシンボルを自動的にバイナリへ入れるという意味ではない。ELF 形式のバイナリがライブラリではなく実行ファイルであれば、ヘッダーのe_entryフィールド、つまりオフセット0x18に、ローダーがメモリ設定後にジャンプするアドレスが入る_startは、libc が提供するエントリポイントを使わない場合にe_entryが指す対象を指定する GCC の慣例であり、NASM のようなツールもこれに従っていると記憶しているWindows の
_WinMainCRTStartupもローダーが PE header のAddressOfEntryPointから見つける。これは PE ヘッダーの先頭基準で Offset0x0028にあり、この PE ヘッダーは MZ(DOS EXE)ヘッダーと DOS Stub の後に来るPE ヘッダーの詳細を学ぶには Making the smallest Windows application と Tiny PE がよい。Tiny PE は Windows が受け入れる形で PE 仕様に違反することもあり、たとえば OS が読まない部分を重ねたり、使われないヘッダーフィールドにコードを入れたりする。このレベルまで行くと、Windows が受け入れる最小ファイルサイズは実行する Windows のバージョンによって変わる
Linux の極小 ELF 実行ファイルについては A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux も参考になる
_startに関連していうと、a.out システムではカーネルが実行ファイルへ入るエントリポイントは伝統的に csu/crt0 で宣言されたstartだった。例として 7th edition, VAX BSD がある当時の C コンパイラはグローバルシンボルの前に
_を付けていたため、V7 では_mainを宣言しており、BSD では C のstart()に対するアセンブリ名を装飾なしのstartとして宣言しているのが分かる当時のプログラムはファイルの先頭地点から始まり、
ccのリンカー呼び出しがcrt0を最前に来るよう配置していた。csuは C startup code、crt0は 0 番目の C ランタイム支援オブジェクトを意味するELF が登場した System V で正確にどう動作していたかは調べにくいが、
startまたは_startは引き続き csu/crt0 で宣言されたプログラムのエントリポイントとして使われていたELF が
_接頭辞の扱いをどう変えたのかは十分に理解したことがないが、おそらく面白半分でもう一段追加したせいでstartが何らかの理由で_startになったのだと思う明確な対応例としては、ELF が
_endを追加したように見え、これは BSS の上端に相当し、malloc()がヒープを作る前にsbrk(0)が返す位置に対応するmain以前の世界に興味があり、それが何で、なぜ有用なのかを一つの記事としてまとめるとよいと思ったリンカー集約を活用してより高速なコレクションを作る方法のような続編のアイデアもあるが、まずはこの 入門寄りのテーマ についてフィードバックを聞きたい
no_stdで、ときにはallocすらない環境では、mainは単なるもう一つの関数にすぎず、初期化はたいてい開発者の責任になる同様の用途のためにコードベース内に自作の定型コードがかなりあるので、こうしたクレートが 組み込み環境 とどう噛み合うのか気になる