1 ポイント 投稿者 GN⁺ 4 시간 전 | 1件のコメント | WhatsAppで共有
  • Rustバイナリは fn main() の前に ランタイム初期化段階 を経ており、この段階でパニック・アンワインド処理やプログラム引数の変換などが行われる
  • OSローダーがエントリポイントに制御を渡すと、CランタイムとRustランタイムが初期化関数を実行し、#[unsafe(link_section = "...")] とコンストラクタ方式で pre-mainコード を配置できる
  • リンカーセクション は複数のクレートが提出したデータをバイナリ生成時に一か所へ集約し、link-section はそれをRustスライスのように扱えるようにする
  • ctorlink-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は startstop シンボルをサポートするが名前が異なり、Windowsは startstop シンボルをサポートしない一方で、実質的に同等のセクション整列規則を持つ
  • ctorlink-sectionlinktime プロジェクトのクレートで、プラットフォームごとの差異とリンカー作業の複雑さを抽象化する
  • inventorylinkme は同じ原理の上に作られた広く使われているクレートだが、この例には制約がある
  • 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$startsection$end シンボルを合成する類似パターンがある
  • GNUリンカーでリンカースクリプトに明示されていないセクションは 孤立セクション と呼ばれる
  • セクション名がCシンボル名と互換である場合にのみ、リンカーは _start_stop 接頭シンボルを自動定義する
  • our_strings は動作するが、our.strings.our_strings は同じようには動作しない
  • 境界シンボルはデータを持たずアドレスだけが重要なため、例では MaybeUninit<()> で表現される
  • Stable Rustには理想的な「不透明な外部型」がまだ実装されていないため、MaybeUninit が代替として使われる
  • &raw const ポインタを static 項目に対して作るのは常に有効なので、値を読まずにアドレスだけを安全に取得できる
  • link-section はこうしたリンカーセクションの詳細を抽象化し、標準のスライス演算を使えるRustスライスへ変換する
  • リンクセクションの力は、バイナリにコードを提供するどのクレートからでも同じセクションへ項目を提出でき、最終バイナリ生成の直前にリンカーがそれらをすべて集めてくれる点にある

依存性注入

  • セクションベースの登録パターンは、依存性注入 と同じ原理で動作する
  • DaggerSpring のようなフレームワークも、登録データの消費者が提供者と結合してはならないという原理の上にある
  • 提供者は定義場所でデータを登録し、消費者はレジストリを読む
  • 従来の依存性注入では、フレームワークが起動時にモジュールグラフをたどったり、ロード済みクラスをスキャンしたりして、提供者と消費者を見つける必要があることが多い
  • リンカーセクションでは、バイナリ生成時にリンカーが提供者データを収集し、消費者が簡単に読めるようにする
  • CLIサブコマンド登録の例は、link_section::section でサブコマンドを登録するこのパターンの一例である
  • Turbopack は、文字列プール定数、シリアライズ・デシリアライズ登録装置、turbotaskインクリメンタルコンパイル関数 の登録にこのパターンを使っている
  • 仮想的なWebサーバーでも、このパターンを使ってルートやミドルウェアをビルド時に自動収集できる

登録にセクションを使う

  • main 前処理の利点は、明示的に開始しない限りスレッドが実行されないことにある
  • この環境では、多くの場合ロックや同期プリミティブの複雑さを避けられる
  • データのライフサイクルを、main 前の書き込み可能な段階と、main 後の不変段階に明確に分けられる
  • 実行中プログラムでデータへアクセスする際にロックの取得と解放を避けられれば、構造が単純になり効率も高まる可能性がある
  • 例では CliSubcommand 構造体、const コンストラクタ関数、#[section] を使ってサブコマンドを収集する
  • listaddhelp のようなサブコマンドはコード中のどこにあってもよい
  • main 関数は CLI_SUBCOMMANDS セクション定義さえ見えていれば、登録済みサブコマンドの名前や場所を知らなくても動的にディスパッチできる
  • 登録されたサブコマンドがなければデフォルトのサブコマンドに戻り、この例では help がデフォルトとして動作する

不変データを超えて

  • 前の例はリンク済みデータが不変であることを前提としているが、リンカーベースのデータ整理は可変データにも使える
  • グローバル静的データの可変性はRustでよくある問題であり、ミューテックスやアトミック型のような内部可変性ツールで解決できる
  • ミューテックスやアトミック型は競合がないときでも高コストではないが、決して無料ではない
  • Rustでデータを安全に変更するには、変更がスレッドセーフに行われ、可変参照が存在する間は同じデータへの別の参照が存在しない必要がある
  • pre-main環境は、明示的にスレッドを開始しない限り単一スレッドなので、アトミック操作は不要である
  • 単一スレッド環境では、変更が後続の読み取りより前に起きる happens-before 関係が自動的に成立する
  • main 前にリンクセクションのデータを変更しておけば、その後はどのスレッドからでもロックなしで安全にアクセスできる
  • 可変参照を main 前にだけ作成して閉じれば、可変参照が存在するときに他の参照がないという条件も満たせる
  • リンクセクションのスライスはセクション内の静的項目へのエイリアスであるため、スライスにも静的項目にもエイリアス規則が適用される
  • スライス経由で安全に変更するには、静的項目を必ず UnsafeCell の中に置かなければならない
  • UnsafeCell で包まれていない静的項目については、LLVMが値をキャッシュしたり再順序化したり、そのデータについて仮定を置いたりできる
  • UnsafeCell 自体は Sync ではないため、別のラッパー型が必要になる
  • 例では SyncUnsafeCellMaybeUninit<SyncUnsafeCell<...>> を使って境界シンボルと項目を構成している
  • 整列可能な文字列インターニングプールの例では、リンク時点で文字列プールを定義し、ランタイム初期段階でスライスを整列して、その後は二分探索で文字列を探す
  • 手動実装はボイラープレートが多いが、ctorlink-section を使えば TypedMutableSection とコンストラクタで同じ構造を簡潔に作れる
  • TypedMutableSection の項目は const である必要があり、これは手動実装例と似た方式のコードが内部で使われるためである

リンクセクションパターンの利点

  • このパターンはタグ付き項目を保証された方法で集約し、すべてのデータを事前割り当て済みの連続メモリに配置する
  • 登録場所をコード中のどこにでも分散できる
  • セクション内の項目数を保証された値として取得できる
  • リンクセクションは追加の割り当てを必要としない
  • リンクセクションなしで同じ構造を作ると、HashMapVec、その他のデータ構造を割り当て、項目を集める過程で何度もサイズ変更することになる
  • 従来の収集方式では、共有型モジュール、寄与モジュール、収集モジュールの間で依存関係が深く絡み合う
  • リンクセクションを使えば、収集側はどこにあってもよく、どのモジュールがデータを寄与するかを気にする必要がない
  • scattered-collect は、リンク時サポートを備えた複数のデータ構造類似体を提供する
    • Scattered*Slice はスライスを提供するさまざまな Vec 類似構造で、オプションでソートもサポートする
    • ScatteredMapScatteredSet は、最小限のpre-main初期化でハッシュベースのキー・値検索を提供する HashMapHashSet 類似構造である

この方式を使うべきでない場合

  • リンク時点計算は強力だが、常に適切なツールとは限らない
  • リンク時点方式の代わりに、データを寄与する各クレートを見渡せるクレートで手動収集することもできる
  • 手動収集は不便になりがちで、寄与者がコアクレートの単一寄与点を見る代わりに、多数のクレート参照を持つ収集クレートが必要になる
  • デッドコード除去は難しくなる
  • link-sectionlinkme は項目に #[used] を付けるため、リンカーは未使用データを削除できない
  • インターンされた文字列アトムのような小さなデータでは問題にならないかもしれないが、生のJSON・JavaScript断片や大きなデータ構造をインターンすると、特定しにくいデッドコードが大量に蓄積する可能性がある
  • pre-mainコンストラクタ関数には制限がある
  • コンストラクタ関数はパニックを起こしてはならず、Rustは標準ライブラリのすべての関数が使えることを保証していない
  • 同じ優先度内での初期化関数の呼び出し順序は保証されず、プラットフォーム依存性も大きい
  • これらの制限は慎重な設計で回避できる場合もあるが、pre-main方式は微妙でデバッグしづらいという理由から適切でないことがある
  • Miri は、すべてのpre-mainコンストラクタとリンクセクション構成を完全にはサポートしていない
  • 現在のMiriはpre-main実行を非常に基本的にしか扱わず、リンクセクションをモデル化しない
  • 未定義動作のテストには ASanTSan などのLLVMサニタイザが推奨される
  • 制御の反転パターンは、リンクセクションへデータを寄与しているすべての場所を監査しにくくする可能性がある
  • 広く配布され、多く使われているRustプログラムの少なからぬ数が、すでに ctorlink-sectioninventorylinkme のようなpre-main機能に依存している

WASMについての短い整理

  • WASMは、過去の選択の影響により、リンカーセクションをネイティブにはサポートしていない
  • #[link_section] アノテーションは項目を本物のコードセクションには配置できず、WASMコード自体からアクセスできないWASMカスタムセクションに配置する
  • linktime クレートはWASMをサポートしており、WASMバイナリでもこのアプローチが動作するようにエミュレーションによる回避策を提供する
  • 適切なWASMサポートを追加する方法は、今後提案される可能性がある

結論

  • main の前には、特定のケースで大きな利点をもたらす多くの処理を実行できる
  • pre-main環境は順序が強く制御され、制御可能性も高いため、ロック、アトミック型、その他の同期プリミティブなしでも多くの処理をより自信を持って実行できる
  • リンクセクションは、バイナリ全体にわたって関連データを任意に集約して一緒に配置でき、不自然なクレート依存順序を避けられる
  • 多くの場合、割り当てを完全に避けられるため、繰り返しの割り当てによる断片化といったアロケータ問題から距離を置ける
  • 関連クレートとしては ctordtorlink-sectionscattered-collect がある

1件のコメント

 
GN⁺ 4 시간 전
Lobste.rs の意見
  • Go は多くのプラットフォームで C ランタイムを避ける点で例外的だが、Apple はシステムコールへのアクセスに C ランタイムを必要とする
    Apple はシステムコールの ABI 安定性の境界として libSystem.dylib を使い、NT 系 Windows はシステムコールではなく ntdll.dll を ABI 安定性の境界としている: not syscalls
    OpenBSD では、Go がローダーによって設定された読み取り専用の libc マッピング外でシステムコールを試みた場合にカーネルが終了させるというポリシーを回避するため、NX ビットの強制適用を無効にするようなメタデータフラグを設定していたように見える
    ただし libSystem.dylib contains the functionality which would normally be libc.so plus 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 headerAddressOfEntryPoint から見つける。これは PE ヘッダーの先頭基準で Offset 0x0028 にあり、この PE ヘッダーは MZ(DOS EXE)ヘッダーと DOS Stub の後に来る
    PE ヘッダーの詳細を学ぶには Making the smallest Windows applicationTiny PE がよい。Tiny PE は Windows が受け入れる形で PE 仕様に違反することもあり、たとえば OS が読まない部分を重ねたり、使われないヘッダーフィールドにコードを入れたりする。このレベルまで行くと、Windows が受け入れる最小ファイルサイズは実行する Windows のバージョンによって変わる
    Linux の極小 ELF 実行ファイルについては A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux も参考になる
    • FreeBSD と NetBSD のシステムコールは、システムライブラリと同様に ABI 安定性 を持っている
    • _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) が返す位置に対応する
  • Rust における main 以前の世界に興味があり、それが何で、なぜ有用なのかを一つの記事としてまとめるとよいと思った
    リンカー集約を活用してより高速なコレクションを作る方法のような続編のアイデアもあるが、まずはこの 入門寄りのテーマ についてフィードバックを聞きたい
    • 組み込み Rust を多くやってきたので、no_std で、ときには alloc すらない環境では、main は単なるもう一つの関数にすぎず、初期化はたいてい開発者の責任になる
      同様の用途のためにコードベース内に自作の定型コードがかなりあるので、こうしたクレートが 組み込み環境 とどう噛み合うのか気になる