ロードと起動:ELFエントリからmainへ

このページの目次

mainはCランタイムが呼び出すアプリケーションのエントリポイントだが、カーネルが認識するのはELFのエントリアドレスである。通常、プログラムにリンクされる起動オブジェクトが_startを定義するが、名前自体に特別な意味はない。リンカは他のエントリを指定できるし、libcを使用しないプログラムは独自の起動コードを実装することもできる。

execveが成功すると、現在のプロセスのプログラムイメージが置き換えられ、新しいPIDは作成されず、旧プログラムの次の命令には戻らない。起動パスを理解するには、まず「カーネルによるイメージの構築」と「ユーザー空間での初期化コードの実行」という2つの段階を明確に区別する必要がある。execve(2)

execveからmainへ:制御権は誰を経由するか カーネルが新しいプロセスイメージを構築 ELFプログラムヘッダを読み取り、メインプログラムをマップし初期スタックを構築 1 PT_INTERPがある場合、まずインタープリタへ 依存関係をマップし、リロケーションを実行して関連する初期化を完了 2 メインプログラムのELFエントリへジャンプ 通常はmainではなく、ツールチェーンが提供する_start 3 Cランタイムが起動を完了 mainへの引数と初期化規約を整備 4 mainが戻り、通常終了フローへ 登録された終了処理を実行し、stdioをフラッシュ 5 インタープリタがないプログラムは、カーネルから直接メインエントリへ移行する。ランタイムでも初期化や自己リロケーションが必要になる場合がある。

カーネルは制御権を誰に渡すか

カーネルはPT_LOADに基づいてメインイメージのマップを構築し、初期スタックと引数、環境、auxvを提供する。PT_INTERPが存在する場合、指定されたELFインタープリタをロードし、まずインタープリタのエントリへ入る。そうでなければ、メインイメージのエントリから開始する。エントリのランタイムアドレスはロードオフセットと組み合わせて計算する必要があり、e_entryをファイルオフセットとして扱ってはならない。

動的インタープリタは通常、glibcのld-linuxである。それはそれ自体が限られたブートストラップと対応するリロケーションを完了する必要があり、その後、メインプログラムの依存関係、TLS、シンボル解決、初期化などを処理する。それは特殊な起動オブジェクトであり、自身はET_DYNだからといって、「実行するために別のld.soに依存しなければならない」と記述してはならない。

マップすることは、必ずしもすべてのページを即座に物理メモリに読み込むことを意味しない。最初のアクセスではページフォルトが発生する可能性がある。main前後で観察されるページフォルトやI/Oは、ソースコードが存在する段階のみに起因するものではない。具体的なロード実装については、Linux v6.18 binfmt_elf.cを参照のこと。

execはプロセスのアイデンティティは保持するが、旧Cプログラムの状態は保持しない

execを「別のmainを呼び出すもの」と理解すると、リソースと制御フローを誤解する。旧アドレス空間内のヒープオブジェクト、関数ポインタ、atexit登録は、新プログラムの実行状態ではない。新プログラムは独自の起動パスから始まり、旧mainには戻らない。Linuxは各属性の処理が異なる。execveの属性変化

状態exec成功後デザインへの影響
PID保持同じプロセスアイデンティティで異なるプログラムイメージを経験できる
旧コード、ヒープ、スタック、マップ置き換えられる旧アドレスを新プログラムの利用可能なポインタとして渡してはならない
ファイルディスクリプタ通常保持される(FD_CLOEXECは閉じる)どのハンドルが引き継ぎインターフェースに属するかを明確にする必要がある
シグナル処理関数キャッシュされたシグナルのdispositionはデフォルトにリセット旧関数アドレスを処理エントリとして引き続き使用できない
シグナルマスク保持新プログラムはマスクされたシグナルを継承する可能性がある
他のスレッド破棄されるexecは旧スレッドと新mainを並列実行するものではない
旧atexit登録保持されない。execによって実行もされないexec前のビジネスロジックのクリーンアップに依存してはならない

以下では、execvを使用して同じ実験ディレクトリのターゲットプログラムをロードし、環境を継承しつつargvを再指定する。exec-launch.cとして保存する。成功時は「exec failed」が表示されず、失敗時は旧プログラムが通常エラーを報告できる。Linuxでは、イメージ置き換えの重要なポイントを跨ぐ失敗は極めて稀であり、旧プログラムを回復できないため、「失敗は必ず戻る」という絶対的な保証として記述すべきではない。

#include <stdio.h>
#include <unistd.h>
int main(void) {
    char *args[] = {"chosen-name", "hello", NULL};
    printf("launcher pid=%ld\n", (long)getpid());
    if (fflush(stdout) == EOF) return 1;
    execv("./exec-target", args);
    perror("exec failed");
    return 1;
}

exec-target.c:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv) {
    printf("target pid=%ld argc=%d argv0=%s arg1=%s\n",
           (long)getpid(), argc, argc > 0 ? argv[0] : "<none>",
           argc > 1 ? argv[1] : "<none>");
    return 0;
}
cc -Wall -Wextra exec-launch.c -o exec-launch
cc -Wall -Wextra exec-target.c -o exec-target
./exec-launch

2行のPIDは同じであり、ターゲットはargc=2、argv0=chosen-name、arg1=helloを受け取る。argv[0]は呼び出し側によって設定され、実行ファイルの実際のパスを信頼できる形で証明するものではない。起動側でfflushを行うのは、旧stdioバッファ内の出力をファイルディスクリプタに書き出すためであり、exec自体は旧libcの出力をフラッシュしない。

初期スタックは通常の関数呼び出しスタックではない

初期ユーザースタック:値、ポインタ配列、補助ベクトル argc 引数の数 argv[0…argc-1]、NULL 引数文字列を指すポインタ envp[0…]、NULL 環境文字列を指すポインタ auxv:type/valueペア、AT_NULL カーネルから渡される補助情報 図は論理的な順序を示している。文字列、ランダムバイト、アライメントパディングの位置は、ボックスの大きさで表現されない。

_startは通常のCのcallからmainの呼び出し元を経て入るものではなく、自由にretして戻る上位関数は存在しない。起動コードはターゲットのABIに従って初期スタックを読み取り、C関数を呼び出すために必要なスタックアライメントを満たす必要がある。

ネイティブx86-64 glibcを例にとると、_startは__libc_start_mainに必要な引数を準備する:

引数System V AMD64での位置出所と役割
mainrdiアプリケーションmainのアドレス
argcrsi初期スタック内の引数の数
argvrdx初期スタック内の引数ポインタ配列
initrcx新版起動コードはNULLを渡し、libcが対応する初期化情報を探す
finir8新版起動コードはNULLを渡し、「8番目の内部フラグ引数」ではない
rtld_finir9ローダーから渡されたクリーンアップ関数を保持
stack_endスタック経由で渡される初期スタックに関連する境界情報

したがって、pop argcとrspのアライメントのみをコピーし、mainのアドレスとrcxの設定を漏らしたアセンブリは、正しい起動の例としてはならない。glibcのx86-64 start.SにはCFI、PIC、および異なるデータモデルの処理も含まれており、直接ターゲットバージョンを確認する方が、何世代ものソースコードを混ぜるよりも信頼できる。glibc start.S

初期化作業はローダーとlibcに分散している

動的プログラムのTLSなどの基本条件是は、対応するユーザーコードがそれらに依存する前に準備されている必要があり、すべての初期化を__libc_start_main内の疑似C関数に配置することはできない。メインプログラムと依存ライブラリの初期化も、無条件の「すべてのライブラリをトラバースし、initポインタを呼び出す」フローではない。

現在のglibcパスでは、ローダーが対応する依存関係とpreinit処理を行い、libcの起動コードがメインプログラムのDT_INIT/DT_INIT_ARRAYなどを処理する。旧起動オブジェクトは初期化関数を渡す可能性があるが、新起動オブジェクトのinit引数はNULLである。依存関係の循環や異なるオブジェクト間のコンストラクタ順序は、ファイル名やリンクコマンドの表面的な順序で仮定してはならない。glibc libc-start.c

.init/.finiは通常、命令を含むが、.init_array/.fini_arrayは関数ポインタの配列である。C++の非局所オブジェクト初期化やコンパイラのconstructor属性は、これらのメカニズムを通じてmain前の実行フェーズに入る可能性があるが、constructor属性とその優先度はツールチェーンの拡張であり、ISO Cの統一ルールではない。

auxv:カーネルからランタイムへ渡される補助情報

キー値の意味
AT_PHDR、AT_PHENT、AT_PHNUMメインプログラムのプログラムヘッダのランタイム位置、エントリサイズ、および数
AT_ENTRYメインプログラムのエントリアドレス。インタープリタが先に実行されても、依然としてメインプログラムを記述する
AT_BASEELFインタープリタのロードベースアドレス。メインプログラムのベースアドレスとして扱ってはならない
AT_PAGESZシステムページサイズ
AT_SYSINFO_EHDR利用可能な場合のvDSO ELFヘッダアドレス
AT_RANDOM16バイトのランダムデータを指し、ランタイム初期化の保護値に使用できる
AT_SECUREローダーが環境などを安全実行条件として処理すべきかどうか
AT_HWCAP、AT_HWCAP2アーキテクチャ固有のユーザー空間ハードウェア能力ビット

AT_RANDOMはASLRのスイッチを制御するフィールドではない。AT_SECUREはsetuidによるトリガーのみで発生するものでもない。補助ベクトルの読み取りにはgetauxvalを優先して使用するべきであり、見つからない場合と合法なゼロ値は、errnoをインターフェースの要件に従ってチェックすることで区別できる。getauxval(3)

初期環境ポインタ配列の後に確かにauxvがあるが、実行中のenvironはsetenvなどの操作によって移動される可能性があるため、現在のenvironの末尾からメモリをスキャンし続けるべきではない。自身の/proc/self/auxvを読み取るか、getauxvalを呼び出す方が、ランタイムクエリに適している。

最初の命令とAT_ENTRYは同じアドレスか?

「CPUは今どこへジャンプするか」と「auxvがどのオブジェクトを記述するか」を分けて考える。インタープリタのベースアドレスを変更すると最初の命令の位置は変わるが、AT_ENTRYは変わらない。これこそが、動的プログラムでstartiとmainのブレークポイントが異なるフェーズに落ちる理由である。

図を準備しています
最初の命令とAT_ENTRYは同じアドレスか?

PT_INTERPがある場合、まずインタープリタへ入り、AT_ENTRYは依然としてメインプログラムのエントリであり、AT_BASEはインタープリタのロードベースアドレスを記述する。mainはユーザー空間の起動コードによって後で呼び出される。

main前後を観察し、矢印を描くだけでなく

以下では、GCC/Clangのconstructorとdestructor拡張を使用して、メインプログラム内での実行順序を観察する:

#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>

__attribute__((constructor))
static void before_main(void) { puts("constructor"); }

__attribute__((destructor))
static void after_main(void) { puts("destructor"); }

static void at_exit(void) { puts("atexit"); }

int main(void)
{
    if (atexit(at_exit) != 0)
        return 1;
    printf("main: page size=%lu\n", getauxval(AT_PAGESZ));
    return 0;
}
cc -g -Wall -Wextra startup.c -o startup
./startup
readelf -hW -lW startup
readelf -dW startup
objdump -d --disassemble=_start startup
LD_SHOW_AUXV=1 ./startup

このglibcの例では、constructorはmainより先に実行される。mainが正常に戻ると、atexitコールバックと対応する終了処理が実行される。この実験は、異なる共有ライブラリ間の任意に依存可能なグローバル初期化順序を証明するものではない。実際のページサイズは出力から読み取り、4096と固定してはならない。

mainから正常に戻るのはlibcのexitフローに対応し、終了処理を呼び出してstdioをフラッシュする。_Exit/_exitはこれらのユーザー空間クリーンアップをスキップし、プロセスがクラッシュしたり強制終了されたりしても、それらが完了することが保証されない。元のLinux exitシステムコールとlibcのプロセス終了ラップは、スレッド範囲の違いも関与している。システムコールABIを参照のこと。exit(3)

同じファイルディスクリプタでも、stdioバッファが自動的にコミットされるわけではない fputs("pending", stdout) バイトは一時的にユーザー空間バッファに格納 return / exit バッファをフラッシュし、終了処理を実行 _Exit / 成功したexec 旧stdioバッファをフラッシュしない ファイルにpendingが見える この例ではatexitフラグもある バッファバイトが消失 カーネルはコミットされていないバイトを把握できない 図はユーザー空間バッファのみを議論している。ファイルディスクリプタのクローズとストレージデバイスへの永続化は別のメカニズムである。

終了方法と出力バッファを別々に検証する

exit-mode.cとして保存する。明示的なフルバッファリングにより、実験がstdoutが端末に接続されているかどうかによらないようにする。終了コールバックは標準エラー出力にフラグを書き出し、通常出力とは別にチェックできるようにする。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char output_buffer[1024];
static void finish(void) { fputs("atexit ran\n", stderr); }
int main(int argc, char **argv) {
    if (setvbuf(stdout, output_buffer, _IOFBF, sizeof output_buffer) != 0)
        return 1;
    if (atexit(finish) != 0) return 1;
    fputs("pending", stdout);
    if (argc == 2 && strcmp(argv[1], "immediate") == 0)
        _Exit(7);
    return 7;
}
cc -Wall -Wextra exit-mode.c -o exit-mode
./exit-mode > normal.out 2> normal.err
./exit-mode immediate > immediate.out 2> immediate.err

2回の終了ステータスはどちらも7であるべき。正常な戻りの場合、normal.outはpending、normal.errにはatexit ranが含まれる。即時終了の場合、2つのファイルはどちらも空である。スクリプトがset -eを有効にしている場合、予期される非ゼロステータスを明示的に受け取るようにし、そうでなければ最初の実行後に停止する可能性がある。

これは、終了コードとユーザー空間クリーンアップが2つの独立した情報であることを示している。同じステータスコードが、同じ片付け動作が実行されたことを証明するものではない。正常な終了でstdioをフラッシュすることは、データがストレージデバイスにすでに永続化されたことを意味しない。永続化セマンティクスが必要なプログラムは、ファイルシステムとインターフェースの規約に従って同期を手配する必要がある。exit(3)、_exit(2)

静的リンクでも起動作業は存在する

ビルド形式一般的なエントリ配置依然として注目すべき点
動的PIEPT_INTERP、インタープリタが先に実行依存関係のマップ、リロケーション、初期化
静的非PIE通常PT_INTERPなし、直接メインエントリlibc起動、TLS、コンストラクタ、IFUNCの可能性
静的PIE通常PT_INTERPなし、直接メインエントリ自己リロケーションとランタイム初期化
カスタムlibcなしプログラムカスタムエントリ独自でABIと終了規約に従う

静的リンクは通常、必要なメンバーをアーカイブから抽出するものであり、libc全体を無条件にファイルに詰め込むものではない。静的プログラムでもGOT/PLT関連構造を含んだり、IFUNCリロケーションが必要だったりすることがあり、静的PIEは位置変化の処理を明確に必要とする。「静的ならリロケーションなし、起動は10倍速い」という主張は成立しない。同じ機能、同じ環境で比較すべきである。

main前でクラッシュした場合の特定方法

まずreadelfでメインエントリ、インタープリタ、依存関係を確認し、gdb ./startupのstartiを使用して最初のユーザー空間命令で停止する。動的プログラムの場合、この時点でインタープリタにある可能性がある。その後、main、コンストラクタ、または__libc_start_mainにブレークポイントを置くことができる。内部シンボルが見えるかどうかは、デバッグシンボルとビルド方法に依存する。

strace -e trace=execve,openat,mmap,mprotect ./startupは、ロードフェーズのシステムコールを表示できるが、すべてのユーザー空間初期化を表示するものではない。依存関係が正常にマップされ、mainに到達していない場合は、mainの最初の行だけを見つめるのではなく、リロケーション、IFUNC、コンストラクタ、およびTLSの使用を継続して確認する。