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

main()の前の旅

概要

  • 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/

PIC16シリーズみたいな古いマイコンでこれをやるのが好きなんだ。スタックポインタやタイマー、変数がどう設定されてるかを見るのが面白いよね。

ELFファイルには、カーネルにどの共有ライブラリをロードするかを指示する動的セクションと、関数へのポインタを動的に「再配置」するようカーネルに指示する別のセクションが含まれているから、すべてがうまくいく。これはGNU/Linuxでの動的リンクの仕組みとは違う。カーネルはメインプログラムのプログラムヘッダーを処理して(PT_LOADセグメントをマッピングするが、再配置はしない)、プログラムヘッダーの中にあるPT_INTERPプログラムインタープリタ(動的リンカーへのパス)に気づく。カーネルはその後、メインプログラムと同じように動的リンカーをロードして(再配置なしで)、そのエントリーポイントに制御を移す。自己再配置を行い、参照される共有オブジェクトをロードして(この時は普通のmmapとmprotectを使い、カーネルのELFローダーは使わない)、それらとメインプログラムを再配置して、最後にメインプログラムに制御を移すのは動的リンカーの役割なんだ。この仕組みは、#!シェバン行とあまり変わらなくて、動的リンカーがスクリプトインタープリタの役割を果たすんだけど、ELFはバイナリフォーマットなんだよね。

その通りだね。これを書いた2月の時点で知ってたよ。投稿する前に間違って修正しちゃったみたい。直すね。ちょっと恥ずかしいな。

Linuxではローダーがユーザースペースにあるのに、なぜもっと人気のあるローダーが選べないのか、ずっと不思議に思ってた。

そうなんだ、カーネルはセクションなんて全然気にしないんだよね。プログラムヘッダーテーブルのPT_LOADセグメントだけを気にしてる。これは要するに、mmapシステムコールの引数のテーブルみたいなもんだ。セクションは動的リンカーメタデータに過ぎなくて、PT_LOADセグメントには含まれないんだ。これ、よくある勘違いみたいだね。俺も一度これに悩まされたことがある… objcopyを使ってELFファイルに任意のファイルを埋め込もうとしたんだけど、ツールはファイルの内容で新しいセクションを簡単に作れたのに、カーネルはそれをメモリに読み込んでくれなかった。最初は本当に混乱したよ。 https://stackoverflow.com/q/77468641 プログラムヘッダーテーブルをパッチするツールがなかったから、自分で作っちゃった! moldリンカーは、このパッチを簡単にするための機能まで追加したんだ! https://www.matheusmoreira.com/articles/self-contained-lone-...

これに触れたのは久しぶりだけど、俺の記憶ではELFインタープリタ(ldso、カーネルじゃない)が、初期ELFのセグメントをマッピングした後のすべてを担当してるはず。確かexecveはプログラムヘッダーからpt_loadセグメントをマッピングして、スタック上のauxベクタを埋めて、ELFインタープリタのエントリーポイントに直接ジャンプするんだ。リンクされたオブジェクトはユーザースペースでELFインタープリタによってロードされる。カーネルはPLT/GOTについては知らないんだよね。

これめっちゃ面白い!もっと知りたい人には、数年前に書いたhttps://cpu.land/を見てほしいな。OPほどメモリレイアウトについて深くは掘り下げてないけど、マルチタスクやコードが最初にどうロードされるかについてはカバーしてるよ。

cpu.landが大好き!こんな楽しいリソースを作ってくれてありがとう。

大学でこのことを教えてる者として、毎年学生たちが教科書のメモリの描き方に混乱しているのを見てきたよ。問題は主に視覚的なもので、概念的なものじゃない。教科書やスライドのほとんどの図は古いハードウェア中心の慣習を使っていて、ページの上に高いアドレス、下に低いアドレスを描いてる。人々は「建物の階は上に行くから」とかのアナロジーでこれを正当化するけど、これは今の人間の読み方とは逆なんだよね。VS Codeや他のIDEでコードを見ると、1行目は一番上にあって、次に2行目、3行目と続く。下に行くほど数字が大きくなる。脳は「下 = 大きいインデックス」と学習する。実際のLinuxプロセスのメモリは、教科書の図が示すよりもVS Codeのモデルにずっと近いんだ。自分で確認できるよ:cat /proc/$$/maps($$の代わりに任意のPIDを選んでね)。... [0x00000000] 低いアドレス ... [0x00620000] ヒープの開始 [0x00643000] ヒープの拡張 ↓(より多くの割り当て => 高いアドレス) ... [0x7ffd8c3f7000] スタックのトップ(↑ スタックポインタはここから始まり、プッシュすると上に(低いアドレスに)移動する) [0x7ffd8c418000] スタックの開始 ... [0xffffffffff600000] 高いアドレス ... 出力は低いアドレスから高いアドレスに向かって印刷される。出力の一番上には通常、バイナリや共有ライブラリ、ヒープなどが表示される。これらはすべて低い仮想アドレスに存在する。出力のさらに下にはスタックがあって、これは高い仮想アドレスに存在する。つまり、下にスクロールするとアドレスが大きくなる。エディタで下にスクロールすると行番号が大きくなるのと同じだ。「ヒープは上に成長する」と「スタックは下に成長する」という表現は間違ってはいない。ただ、数値アドレスに起こることを説明しているだけで、ヒープは高いアドレスに向かって拡張し、スタックは低いアドレスに移動する。実際の問題は、私たちがそれをどう描くかなんだ。ページ上で「上」を「高いアドレス」とラベル付けするのは、人々がコードを読む方法や/proc//mapsが印刷される方法とは逆なんだよね。だから学生たちは、スタックとヒープが何をしているのか考える前に、図を頭の中でひっくり返さなきゃいけない。もしメモリをエディタのように描いたら(低いアドレスが上、より高いアドレスが下)、すぐに理解できると思う。下にスクロールするとアドレスが上がって、スタックは一番下にある。そうなれば「スタックは下に成長する」ではなく、単にスタックポインタがデクリメントされて低いアドレスに移動する(図では上に移動することを意味する)ってことになる。

それが、机の上のスタックが成長する方法で、現実でもすべてがそうだよね。机の上の積み重なったものを上から番号を付けることはないし、常に変わるからね。木の最初の枝(植物)を一番上のものと呼ぶこともないよね。君の例で「スタックは下に成長する」は、画像では間違ってるように思える。

図を描きながらアドレス空間を学んでいた時と同じような沼にはまってしまった気がする。君のモデルは学生にとってずっと理解しやすいと思う。関連して、以前はアドレス(例えば0xAB_CD)が実際には[0xCD, 0xAB]のビット表現を持っていることに苦労していた。これに対処する一般的な方法があるのかな?

スタックはどうあれ下に成長するんだよね、プッシュするとスタックポインタが減るから。君の図ではこれを「上」と表現できるけど、概念的には簡単な配列のプッシュ/ポップに例えると、直感的には高いアドレスに最近のスタック内容が含まれていると思うだろう。問題の核心は、スタックの成長方向が「通常の」メモリアクセスパターンとは異なることなんだ。通常は低いアドレスから高いアドレスに割り当てるから(配列アクセスや文字列がメモリにどのように配置されるかを考えてみて。リトルエンディアンシステムが多数派だし)。でも、視覚化のオプションを選ぶなら、低いアドレスを左に配置して横に視覚化するのが好きだ。これは配列にアクセスしたり、メモリに文字列を配置する方法と自然に対応しているから。

メインバイナリのリロケーションは、リンカーが自分のシンボルを解決する前と後、どちらで適用されたのを見た?デバッガでステップスルーすると、順序がいつもブラックマジックのように感じるんだよね。

このことをハッキングするのはめっちゃ楽しい!! > プログラムによっては、_startがエントリーポイントとメイン関数の間にある唯一のものかもしれない。かつて、私はこのアイデアを中心に完全に構築されたliblinuxプロジェクトを開発したことがある。libcとその初期化、複雑さ、グローバルステートを取り除きたかったんだ。Cライブラリは非常に複雑で、パッケージ管理の原始的な形が組み込まれている: https://blogs.oracle.com/solaris/post/init-and-fini-processi... だから、argc、argv、envp、auxvを実際のメイン関数に渡すだけの_start関数を作った: https://github.com/matheusmoreira/liblinux/blob/master/start... https://github.com/matheusmoreira/liblinux/blob/master/start... これだけで驚くほど遠くまで行けて、実際に何が起こっているのか理解することができる。最大の痛点は、数値/文字列変換のようなCライブラリのユーティリティ関数がないことだった。自分で書いたよ。 https://github.com/matheusmoreira/liblinux/tree/master/examp... Linuxだけがこれを可能にしてくれるオペレーティングシステムなんだ。他のシステムでは、Cライブラリはカーネルインターフェースの一部だから、こうやってバイパスすると問題が起こることがある。Go開発者たちはこれを辛い方法で発見したことがある。 https://www.matheusmoreira.com/articles/linux-system-calls カーネルには今、彼ら自身のnolibcインフラがあって、私のプロジェクトよりずっと良いに違いない。 https://github.com/torvalds/linux/tree/master/tools/include/... みんなに使ってほしい。あと、_startは任意のシンボルだから、特別な名前ではないよ。リンカーのデフォルトなだけ。ELFヘッダーにはエントリーポイントへのポインタが含まれていて、シンボルではない。好きな名前を選んでいいよ!