1 ポイント 投稿者 GN⁺ 2025-10-26 | 1件のコメント | WhatsAppで共有
  • プログラムが実行される前に、カーネルが execve システムコールを通じてプロセスを生成・初期化する過程を探る技術分析
  • この呼び出しは実行ファイルのパス、引数、環境変数を渡し、カーネルはそれに基づいて ELF 形式の実行ファイルをロードする
  • ELF ファイルにはコード、データ、シンボル、動的リンク情報などが含まれ、カーネルはこれを解釈して メモリマッピングとスタック初期化を行う
  • その後カーネルは _start エントリポイントに制御を渡し、言語ごとのランタイムが初期化された後にようやく ユーザー定義の main 関数が呼び出される
  • この過程は OS、コンパイラ、ランタイムの協調構造を示しており、システムレベルでプログラム実行がどのように行われるかを理解するうえで重要である

プログラム実行の開始点: execve 呼び出し

  • Linux でのプログラム実行は execve システムコールによって始まる
    • execve(const char *filename, char *const argv[], char *const envp[]) の形で、実行ファイル名、引数リスト、環境変数リストを渡す
    • カーネルはこれによって、どのプログラムをどの環境で実行するかを決定する
  • 高水準言語では、この呼び出しは 標準ライブラリのプロセス実行 API でラップされている
    • 例: Rust の std::process::Command は内部的に execve を呼び出す
    • シェルの PATH 探索と似た形で、コマンド名を完全パスに変換する処理を行う
  • Shebang(#!) があるスクリプトの場合、カーネルは指定されたインタープリタを使ってプログラムを実行する
    • 例: #!/usr/bin/python3 → Python インタープリタで実行

ELF: 実行ファイルの構造

  • Linux の実行ファイルは ELF(Executable and Linkable Format) 形式に従う
    • ELF はコード、データ、シンボル、再配置情報などを含む 標準的な実行ファイル形式
    • 他の OS では Mach-O(macOS)、PE(Windows) など別の形式が使われる
  • ELF ヘッダにはファイルの構造やメモリ配置情報が含まれる
    • 例: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address はプログラムが最初に実行する命令のアドレスである
  • 例示された ELF ヘッダでは、RISC-V アーキテクチャ向けの ELF32 実行ファイルで、0x10358 アドレスがエントリポイントに指定されている

ELF の内部構成要素

  • ELF ファイルはいくつもの セクション(section) で構成されている
    • .text: 実行コード
    • .data: 初期化済みグローバル変数
    • .bss: 未初期化グローバル変数
    • .plt: 共有ライブラリ呼び出し用テーブル
    • .symtab, .strtab: シンボルおよび文字列テーブル
  • PLT(Procedure Linkage Table) は共有ライブラリ関数の呼び出しを支える
    • 例: libcprintf, malloc など
    • ELF の PT_INTERP セクションは動的リンカ(interpreter)を指定する
  • カーネルは ELF を読み取り、ロード可能なセクションをメモリに配置し、必要に応じて ASLR, NX bit などのセキュリティ機能を適用する

シンボルテーブルとランタイムリンク

  • ELF の シンボルテーブル(symtab) には関数や変数のアドレス情報が含まれる
    • 例: _start, main, __libc_start_main などのエントリが存在する
    • 単純な「Hello, World!」プログラムであっても 2300 個以上のシンボルを含むことがある
  • これは大半が 標準ライブラリとランタイム初期化コードに由来する
    • muslglibc といった libc 実装がリンクされているためである
  • カーネルは ELF の各セクションをロードした後、インタープリタ(動的リンカ) に制御を渡す
    • インタープリタは再配置(relocation)、アドレス空間配置のランダム化(ASLR)、実行権限の設定(NX bit)などを処理する

スタック初期化の過程

  • カーネルはプログラム実行前に スタック(stack) を直接構成しなければならない
    • スタックはローカル変数、関数呼び出しフレーム、引数の受け渡しなどに使われる
  • execve 呼び出し時に渡された argv, envp はスタックに格納される
    • プログラムはこれを通じてコマンドライン引数や環境変数にアクセスする
  • カーネルはさらに ELF 補助ベクトル(auxv) もスタックに含める
    • ページサイズ、ELF メタデータ、システム情報など約 30 項目を含む
    • 例: AT_PAGESZ はメモリページサイズ(例: 4KiB)を指定する
  • RISC-V エミュレータの例では、スタックポインタ(sp)を高位アドレスから開始し、引数、環境変数、補助ベクトルを逆順に積んでいく

エントリポイントと _start 関数

  • ELF の エントリポイント_start 関数のアドレスに指定される
    • _start はカーネルが制御を渡す最初のユーザー空間コードである
  • ほとんどの言語は _startランタイム初期化を行った後に main を呼び出す
    • 例: Rust の std::rt::lang_start, C の __libc_start_main
  • Rust の例では、#![no_std], #![no_main] 属性を使うことで、ランタイムなしに _start を直接定義できる
    • _start 内でスタックから argc, argv, envp を読み取り、main ポインタを呼び出す
  • 言語ごとのランタイムは、グローバルコンストラクタ、スレッドローカルストレージ、例外処理など 言語特有の初期化処理を担う

main() 呼び出し前までの全体の流れ

  • 全体の流れは次のように要約できる
    1. execve 呼び出し → カーネルが ELF ファイルをロード
    2. ELF の解釈 → コード/データセクションをマッピングし、インタープリタを指定
    3. スタックの構成 → 引数、環境変数、補助ベクトルを格納
    4. エントリポイント _start を実行
    5. ランタイム初期化後に main() を呼び出す
  • この一連の流れは OS カーネル、ELF フォーマット、言語ランタイムの協力構造を示している
  • 実際の Linux カーネルにはアドレス空間、プロセステーブル、グループ管理など追加の内部ロジックも含まれるが、本稿はその前段階にある中核的な流れを説明している

結論と訂正

  • main() 以前の実行過程は カーネルレベルの初期化とランタイム設定の結合体である
  • 単純な「Hello, World!」プログラムでさえ、複雑な ELF 構造とランタイム初期化を経て実行される
  • 記事の初期版では一部のセクション読み込みロジックをカーネルの役割としていたが、実際には ELF インタープリタの役割であることが訂正された
  • この分析は システムプログラミング、コンパイラ、OS アーキテクチャの理解に役立つ基礎資料となる

1件のコメント

 
GN⁺ 2025-10-26
Hacker Newsの意見
  • ELFファイルの動的リンク過程について説明している
    カーネルはELFのPT_LOADセグメントをマッピングし、PT_INTERPで指定された動的リンカ(ld.so) をロードしてから制御を渡す
    その後、動的リンカが自分自身を再配置(relocation)し、必要な共有オブジェクトをmmap/mprotectでロードする
    この構造はスクリプトのshebang(#!) メカニズムに似ているとたとえている

    • カーネルはセクション情報にはまったく関心がなく、PT_LOADセグメントだけを処理する
      以前、objcopyでELFに任意のファイルを挿入しようとしたが、カーネルがロードしなくて混乱した経験を共有している
      結局、自分でプログラムヘッダテーブルのパッチツールを作り、moldリンカにもこの機能が追加されたという
      関連記事: Self-contained Lone Lisp Applications
    • 投稿者は以前に内容を誤って修正して投稿してしまったことを認め、修正すると述べている
    • Linuxではローダがユーザー空間で動作するのに、なぜもっと多様なローダが存在しないのか、以前から不思議に思っていたという
  • コード全体をmain()より前、あるいはmain()なしでパッキングする実験をしたという
    関連記事: Packing a codebase into a single function

    • 読んでみると意外に単純で脆弱でもなく、興味深かったという
      すべての関数をmain(100+n, ...)の形に変えればよいと冗談を言っている
  • このテーマに興味があるなら、自分が作った cpu.land を参照してほしいとしている
    メモリレイアウトよりもマルチタスクとコードのロード過程を扱っている

    • cpu.landが本当に大好きだと感謝を伝えている
  • Cプロジェクトの中で、標準ライブラリを避けてLinux syscallだけを直接呼ぶケースがどれほどあるのか気になるという
    こういう書き方のほうがずっと面白いと感じている

    • syscallを直接使うのはむしろ非効率だと主張している
      ALSAやDRMのような機能は、カーネルsyscallではなくシステムライブラリ経由でアクセスするほうが利点が多い
      この方式は移植性と保守性の面でWindowsスタイルのアプローチより優れていると説明している
    • WindowsではWin32 APIだけを使えば、Cランタイムをリンクしなくてもよいと付け加えている
    • 自分も以前、liblinuxプロジェクトを作ってsyscallだけでプログラムを書いていたという
      今はLinuxのnolibcヘッダがよく整備されているので中止したが、
      現在はsyscallベースのLispインタプリタ言語を開発中だという
      システムコールで直接Linuxユーザースペースを構築する実験で、とても興味深い旅路だったと述べている
    • 移植性は保ちたいが、ファイルディスクリプタは便利すぎて手放しにくいという
    • 多くのドライバコードは実際にsyscallだけを使っていると付け加えている
  • ELFインタプリタ(ld.so)が初期ELFセグメントをマッピングした後、すべてのロードを担当すると説明している
    execveはPT_LOADセグメントをマッピングし、aux vectorをスタックに詰めたあと、
    ELFインタプリタのエントリポイントへジャンプする
    カーネルはPLT/GOTについて何も知らない

  • 大学でこのテーマを教えている立場として、学生がメモリダイアグラムのせいで混乱すると述べている
    教材ではアドレスが高いほど上に描かれるが、実際のLinuxプロセスは
    低いアドレスが上、高いアドレスが下の形で表示される
    /proc/<pid>/mapsを見ると、下へスクロールするほどアドレスが大きくなる
    つまり、「heapは上に伸び、stackは下に伸びる」という表現は数値上の方向にすぎず、
    視覚的にはむしろ逆である
    IDEのように下へ行くほどアドレスが大きくなる形で描けば、はるかに直感的だと提案している

    • スタックはいずれにせよスタックポインタが減少しながら伸びるので、「下に伸びる」という表現はやはり正しいという
      ただし可視化は横方向で行うほうが自然だと提案している
    • 自分も以前同じ混乱を経験し、リトルエンディアンのアドレス表記が紛らわしかったと振り返っている
    • 実際の物が積み重なる方向を考えると、「スタックが下に伸びる」という表現は直感的ではないと反論している
  • 古いPIC16マイクロコントローラでこうした実験をするのが好きだという
    スタックポインタ、タイマー、変数設定などを直接扱うのが楽しいと感じている

  • shebang(#!) に関する経験を共有している
    Javaアプリケーションが実行スクリプトを見つけられないというエラーを出したが、
    実際の問題はスクリプトのshebangパスが間違っていたことだった
    ローカルでは問題なく動いたが、リモートサーバではインタプリタのパスが異なっていたために起きた問題だった

    • これはJava固有の問題ではなく、ENOENTエラーが発生するあらゆるプログラムで起こりうるという
      straceで実行すれば、どのsyscallでエラーが起きたのかすぐ確認できると助言している
    • shebangの構造を分析した記事を共有している: What the #! means
    • カーネルでshebangをサポートするにはCONFIG_BINFMT_SCRIPT=y設定が必要だと付け加えている
  • デバッグ中、メインバイナリの再配置順序がいつ適用されるのか、いつも混乱するという
    リンカが自分自身のシンボルを解決する前なのか後なのかが、まるでブラックマジックのようだと表現している

  • Markdown内の「lang_start function (defined here)」のリンクが壊れていることを指摘している