世界を動かす技術を、日本語で。

main()の前の旅

2025年10月26日原文(amit.prasad.me)

概要

  • Linuxでプログラムが実行される流れの詳細解説
  • execveシステムコールからELFファイルの構造説明
  • スタックやELF補助ベクタ(auxv)の初期化手順
  • エントリポイントと_start関数の役割
  • 各言語ランタイムの初期化の違い

Linuxカーネルによるプログラム実行の流れ

  • プログラム実行時、Linuxでは execveシステムコール が呼ばれる
    • int execve(const char *filename, char *const argv[], char *const envp[])形式
    • 引数は実行ファイル名・引数リスト・環境変数リスト
  • 多くのプログラミング言語は標準ライブラリでexecveをラップ
    • 例: Rustのstd::process::Command
    • シェルと同様にPATH解決も行う
  • カーネルは 絶対パスの実行ファイル を期待
  • シェバン(#!)が先頭にある場合は、指定インタプリタで実行
    • 例: #!/usr/bin/python3, #!/bin/bash

ELFファイルの基本構造

  • Linuxの実行ファイル形式は ELF (Executable and Linkable Format)
    • 他OSはMach-O(MacOS), PE(Windows)など
  • ELFファイルのヘッダには マジックバイト(7f 45 4c 46)エントリポイントアドレス などが記載
  • readelfコマンドでヘッダ情報を確認可能
  • ELFはa.out形式から発展し、ほぼ全てのプログラムに対応

ELFの各セクションと役割

  • ELFファイルには複数のセクションが含まれる
    • .text: プログラム本体のコード
    • .data: 初期化済みデータ
    • .bss: 未初期化グローバル変数用領域
    • .plt: 共有ライブラリ関数呼び出し用
    • .rodata: 読み取り専用データ
    • .symtab, .strtab: シンボル・文字列テーブル
  • 動的リンク用の情報も格納
    • PT_INTERPセクションでELFインタプリタを指定
    • libc(C標準ライブラリ)などの共有ライブラリのロード指示

シンボルテーブルと実態

  • シンボルテーブルには多数のエントリが存在
    • 例: Hello Worldプログラムでも2000以上のシンボル
    • main関数や_start、__libc_start_mainなどが含まれる
  • 多くのシンボルはリンクやデバッグ、動的リンク用
  • muslやglibcなどlibcの種類で内容が異なる

カーネルによるロード処理

  • カーネルはELF内の ロード可能セクション をメモリに配置
  • PT_INTERP指定があればインタプリタ経由で処理
  • ASLR(アドレス空間配置ランダム化)やNXビット(非実行属性)などのセキュリティ機能も適用
  • スタックを初期化し、エントリポイントへジャンプ

スタックの初期化

  • スタックは 高アドレス側から低アドレス側へ成長
  • ヒープや共有ライブラリ、mmap領域との間の空間管理
  • execveで渡されたargv, envpはスタック上に配置
  • ELF補助ベクタ(auxv) もスタックに格納
    • ページサイズやエントリポイントなどのシステム情報
  • スタック初期化の擬似コード例(RISC-Vエミュレータより)

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

  • ELFヘッダのエントリポイントアドレスから実行開始
  • 通常は _start関数 が最初に呼ばれる
    • glibcやmuslが提供、独自実装も可能
  • _startから各言語のランタイム初期化処理へ
    • 例: Rustならstd::rt::lang_start、C/C++も独自ランタイムあり

言語ごとの初期化の違い

  • _startからmainまでの間にグローバルコンストラクタやスレッドローカルストレージなどの処理

  • Rustの例

    • main関数はユーザー定義
    • _start関数でargc, argv, envp取得後、lang_startに渡す
  • C/C++も同様に最小限の初期化後mainを呼び出す

    • 主要言語は独自のランタイムを持ち、main関数実行前に各種セットアップを行う仕組み

この一連の流れにより、カーネルからmain関数実行までの複雑な処理が抽象化・自動化されている仕組み。

Hackerたちの意見

Cプロジェクトの中で、標準ライブラリを避けてLinuxのシステムコールを直接呼び出すのを好むものがどれくらいあるんだろう。こういう書き方の方が、個人的にはずっと楽しいと思う。

「C標準ライブラリを避ける」ってところまでは良かったけど、「Linuxのシステムコールを直接呼び出す」って言った瞬間に興味がなくなった。Windowsのサポートは必須だし、WSL2はカウントしないからね。C標準ライブラリは結構ひどいから、使わないのがもう少し簡単で一般的になればいいのに。

こういうのはドライバコードでよくあるよね。

基本的にはポータブルでいるようにしてるけど、ファイルディスクリプタは使わないのがもったいないくらい便利なんだよね。

完全に同じではないけど、WindowsでWin32の呼び出しだけを使うと、Cランタイムライブラリをリンクしなくても済むんだ。Win32はWindowsのC標準ライブラリの下にあって、Cランタイムはオプションなんだよね。

俺もこれのためにliblinuxプロジェクトを書いたことがあるんだ!! 本当にめっちゃ楽しかったよ。詳しくは他のコメントに書いてるから、見てみてね。 https://news.ycombinator.com/item?id=45709141 でも、Linux自体が今は豊富なnolibcヘッダーを持ってるから、結局やめちゃった。今はこのコンセプトを基にしたプログラミング言語を作ってるんだ。Linuxに直接ターゲットを絞ったフリースタンディングのLispインタプリタで、ビルトインのシステムコールサポートがあるんだ。アイデアは、インタプリタを完成させてから、システムコールを使ってLispで標準ライブラリとLinuxユーザースペースを書くことなんだ。すごい旅になってるよ。これをどこまで進められるか、信じられないくらいだね。

コードベースを「main()の前」に詰め込むことも、main()なしでやることも可能だよ。最近、これを試してみたんだけど、main()だけを使って自分自身を何度も呼び出すコードベースも作ってみた。めっちゃ楽しかった!: https://joshua.hu/packing-codebase-into-single-function-disr...

これは本当に楽しい読み物で、正直言って複雑でも壊れやすくもないみたい。すべての関数の名前をmain(100+n, ...)に変えるだけでいいんだ。

インタプリタについての注意: 実行可能ファイルがシェバン(#!)で始まる場合、カーネルはシェバンで指定されたインタプリタを使ってプログラムを実行します。例えば、#!/usr/bin/python3はPythonインタプリタを使ってプログラムを実行し、#!/bin/bashはBashシェルを使ってプログラムを実行します。これが原因で、サードパーティのJavaアプリケーションをデバッグしようとして、実行可能スクリプトを起動しようとしたときに「java.io.IOException: error=2, No such file or directory.」というIOエラーが出て、かなり悩まされた。スクリプトがそこにあるのは分かってたし(フルパスを使って)、実行可能ビットも設定されてたから、何が間違ってるのか不思議だった。結局、スクリプトのシェバンが間違ってたせいでOSが文句を言ってたんだ(シェルからの実際のエラーは「指定されたインタプリタ '/foo/bar' は実行可能なコマンドではありません。」って感じ)。でもJavaのエラーは全然誤解を招くものでした :| 注意: なんで自分でスクリプトを実行したときにこのエラーが見えなかったのかって?実行したけど、ローカルでは問題なく動いたんだ。ただ、アプリケーションが異なるパスのインタプリタを持つリモートホストで動いてたんだよね。

シェバンのカーネルサポートは、CONFIG_BINFMT_SCRIPT=yがカーネル設定に含まれているかどうかに依存していることも覚えておいてね。

これはJava特有の問題じゃなくて、他のプログラムでも起こり得るよ。「そのようなファイルやディレクトリはありません」というのは、ENOENTのわかりやすい説明で、いろんなシステムコールで発生することがあるんだ。俺は普段、プログラムをstraceで実行して、何をしてるかすぐに確認するよ。

興味がある人のために、ハッシュバンについての解説をしたよ: https://blog.foletta.net/post/2021-04-19-what-the/

Hacker Newsで議論の続きを見る