実行ファイル形式の内部(ELF・PE・Mach-O)
バイナリが起動する仕組みが見えると、起動失敗やリンクエラーの切り分けが速くなる。ELF・PE・Mach-Oのセクション・セグメント・エントリポイント・再配置・ロードを原理から押さえる。
- 1.実行ファイルはリンク時の視点であるセクション(.text/.data/.bssなど)と、ロード時の視点であるセグメント(メモリへのマップ単位)の二層構造を持ち、ローダはセグメント単位でファイル内容を仮想アドレスへmmapする。
- 2.ELF(Linux)・PE(Windows)・Mach-O(macOS)はヘッダ→ロード命令→セクション本体という骨格が共通で、エントリポイントは実際にはランタイム初期化コードを指し、ユーザーのmainはその後に呼ばれる。
- 3.再配置はロード先アドレスが固定でない前提で外部参照やポインタを補正する仕組みで、PIE/ASLRやプリリンクの可否、起動コストとセキュリティのトレードオフを決める。
なぜ実行ファイルに「形式」が要るのか
実行ファイルは、ただの機械語の塊ではありません。OSのローダがファイルを開いてプロセスのアドレス空間を組み立てるための 設計図 が、機械語と一緒に梱包されています。どのバイト列をどの仮想アドレスに置き、どこにどんな権限(読み/書き/実行)を与え、どこから実行を始め、どの外部ライブラリを必要とするか——これらをローダが解釈できる構造で記述したものが実行ファイル形式です。主要3形式は、Linuxの ELF(Executable and Linkable Format)、Windowsの PE(Portable Executable)、macOS/iOSの Mach-O です。
3形式は表面的な名前が違うだけで、骨格はよく似ています。コンパイラがソースから機械語を生成し、リンカがそれらと外部参照をまとめて1つの実行ファイルへ束ねる流れはコンパイルとインタプリタ、リンカが何を確定させるかはABI・呼び出し規約とリンカの仕組みで補えます。本稿はその成果物である ファイルの内部構造 と、それをローダがメモリに展開する過程に絞ります。
セクションとセグメント——二つの視点
実行ファイル形式の最重要概念が、セクション(section)と セグメント(segment)の使い分けです。両者は同じバイト列を別の視点で切り分けたものです。
- セクション はリンカ向けの細かい分類です。コードは
.text、初期値ありデータは.data、初期値なし(ゼロ初期化)データは.bss、読み取り専用定数は.rodata、シンボル表は.symtab、再配置情報は.rela.textなどに分かれます。リンカはセクション名で同種を集めて配置します。 - セグメント(ELFではプログラムヘッダ、PEではセクション+ヘッダのメモリ属性、Mach-Oでは
LC_SEGMENT)はローダ向けの粗い単位です。ローダは「このファイル範囲を、この仮想アドレスへ、この権限でマップせよ」という指示の単位として読みます。
重要なのは、複数のセクションが1つのセグメントにまとめられる 点です。例えば .text と .rodata は同じ「読み取り+実行(または読み取り専用)」セグメントに同居し、.data と .bss は「読み書き」セグメントに同居します。ローダはセクション名を気にせず、セグメントのページ権限だけを見てマップします。
.bss(ゼロ初期化データ)は、ファイル上には「サイズ情報」しか持たず実体のゼロ列を持ちません。ローダはロード時に、ファイルが裏付けないゼロ埋めページ(anonymousページ)を割り当てるだけで済みます。だから100MBのゼロ配列を静的確保しても実行ファイルは太りません。セグメントが持つ「ファイル上のサイズ」と「メモリ上のサイズ」が食い違い、後者が大きい差分が .bss 領域です。
共通の骨格——ヘッダ・ロード命令・本体
3形式は「先頭の識別ヘッダ → ロード指示の配列 → 各セクションの本体」という三段構成を共有します。先頭の数バイト(マジックナンバー)でローダは形式を判別します。
| 要素 | ELF (Linux) | PE (Windows) | Mach-O (macOS) |
|---|---|---|---|
| 先頭マジック | 0x7F 'E' 'L' 'F' | 'MZ' → PEシグネチャ | 0xFEEDFACE / 0xFEEDFACF |
| ファイルヘッダ | ELFヘッダ | DOSスタブ+COFFヘッダ | mach_header |
| ロード指示 | プログラムヘッダ表 (PHT) | セクションテーブル | ロードコマンド列 (LC_*) |
| リンク用情報 | セクションヘッダ表 (SHT) | セクションテーブル兼用 | シンボルテーブル等のLC |
| 共有ライブラリ | .so | .dll | .dylib |
PEが先頭に「DOSスタブ」(MZ で始まる古いMS-DOS用の小プログラム)を残しているのは後方互換の名残で、本体のPEヘッダへのオフセットがそのスタブ内に書かれています。Mach-Oのマジックは32ビット(FEEDFACE)と64ビット(FEEDFACF)で1ビット違い、さらに複数アーキテクチャを1ファイルに束ねる ユニバーサルバイナリ(fat binary)を許す点が独特です。これはAppleのアーキテクチャ移行(PowerPC→Intel→Apple Silicon)を支えてきた仕組みです。
エントリポイント——mainより前に何が起きるか
ヘッダにはプロセス開始時にCPUがジャンプする エントリポイント のアドレスが書かれます。ここで初学者が誤解しがちなのは、エントリポイントが main だという思い込みです。実際にはエントリポイントはランタイムの起動コード(C/C++なら _start、Windowsでは CRT の mainCRTStartup 相当)を指します。
起動コードの仕事は、main を呼べる状態を整えることです。具体的には、引数 argc/argv と環境変数の整列、グローバルコンストラクタや .init_array(C++の静的初期化)の実行、スタックやTLS(スレッドローカル記憶域)の準備などです。これらを終えて初めて main を呼び、main の戻り値を exit へ渡します。動的リンクが絡む場合は、main 到達前に動的リンカ(ld.so 等)が共有ライブラリのロードとシンボル解決を済ませています。
エントリポイントはヘッダの値を書き換えれば差し替えられます(リンカの -e 指定など)。またライブラリには「ロード/アンロード時に自動実行される関数」を登録する仕組みがあり、ELFは .init_array/.fini_array、PEは DllMain、Mach-Oは __mod_init_func セクションで実現します。main だけがコードの入口だと考えると、こうした初期化フックの存在を見落とします。
再配置——アドレスが固定でない前提
リンカやローダは、コードやデータの中に現れる「他の場所を指すアドレス」を、最終的な配置に合わせて埋め直す必要があります。この補正が 再配置(relocation)です。再配置エントリは「ファイルのこの位置を、このシンボルの実アドレスを使って、この方式で書き換えよ」という指示の集合です。
再配置が要る根本理由は、コードが置かれる仮想アドレスがビルド時に確定しないこと にあります。固定アドレスを前提に書ける時代もありましたが、現在は ASLR(Address Space Layout Randomization)でロード先を毎回ランダム化するため、実行ファイル自体を任意アドレスに置ける 位置独立 な作りが求められます。ELFでは PIE(Position-Independent Executable)、PEではベース再配置テーブル+ASLR、Mach-Oでは常時PIEがこれを担います。位置独立コードが外部参照をGOT/PLT経由の間接参照に置き換える詳細は動的リンクとシンボル解決(PLT/GOT・遅延束縛)を参照してください。
ELFの再配置エントリ(概念):
offset : 0x4020 ← 書き換える場所(GOTスロット等)
type : R_X86_64_GLOB_DAT ← 書き換え方式
symbol : printf ← この実アドレスを使う
→ ロード時、ld.so が printf の番地を解決し offset 位置へ書き込む
PEとMach-Oは方式が少し異なります。PEは「希望ロードアドレス(ImageBase)」を持ち、そこに置ければ再配置不要、ずれた場合のみ ベース再配置テーブル の差分(デルタ)を全絶対アドレスに加算します。Mach-Oは再配置情報を LC_DYLD_INFO(あるいは新しい LC_DYLD_CHAINED_FIXUPS)にまとめ、動的リンカ dyld がポインタを補正します。
ローダがメモリにマップする過程
ここまでの部品が、実行時に1本の流れとしてつながります。execve(Linux)/CreateProcess(Windows)/posix_spawn(macOS)からプロセスが起動すると、おおむね次の順で進みます。
- 形式判定とヘッダ読み込み——マジックを見て形式を確定し、ロード指示の配列を読む。
- セグメントのマップ——各ロード可能セグメントを、指定の仮想アドレスへ指定権限で
mmapする。多くはファイルを裏付けとする デマンドページング で、実際にアクセスされたページだけが後から物理メモリへ読み込まれる(仮想メモリとページングのプログラミング的含意)。 - .bss の確保——ゼロ埋め領域をanonymousページとして割り当てる。
- 動的リンカの起動——動的リンク実行ファイルなら、まずインタプリタ(
PT_INTERPが指すld.so等)をロードし、制御を渡す。 - 依存ライブラリのロードとシンボル解決——共有ライブラリを再帰的にロードし、再配置を適用。関数は遅延束縛されることが多い。
- 初期化とエントリへジャンプ——
.init_array等を実行し、エントリポイント(_start)へ制御を移す。
「.text はなぜ読み取り専用+実行で、書き込み不可なのか」は頻出です。コードページを書き込み不可にすると、複数プロセスでの共有が安全になり(書き換えが起きない)、かつコード注入攻撃を防げます(W^X:書き込み可能と実行可能を同時に与えない原則)。逆に .data/.bss は書き込み可・実行不可です。この権限分けはセクションではなく セグメントのページ権限 で実現される点を押さえておくと、ELF/PE/Mach-Oの差異を超えて理解できます。
まとめ
実行ファイル形式は、機械語に「どこへ・どの権限で・どこから・何を要求して動くか」というロード指示を添えた設計図です。ELF・PE・Mach-Oは名前こそ違え、ヘッダ→ロード命令→本体という骨格を共有し、セクション(リンカ視点の細かい分類)と セグメント(ローダ視点のマップ単位)の二層で同じバイト列を整理します。エントリポイントは main ではなくランタイム初期化を指し、再配置はロード先が固定でない前提でアドレスを補正してPIE/ASLRを成立させます。ローダはセグメントを mmap し、動的リンカが依存と再配置を解決してから初期化を経てエントリへ飛ぶ——この一連の約束ごとを知れば、readelf/dumpbin/otool が吐く情報が、起動失敗やリンク不整合を切り分ける手がかりに変わります。
プログラミング Article
実行ファイル形式の内部(ELF・PE・Mach-O)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ELF
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
ELF(Linux)・PE(Windows)・Mach-O(macOS)はヘッダ→ロード命令→セクション本体という骨格が共通で、エントリポイントは実際にはランタイム初期化コードを指し、ユーザーのmainはその後に呼ばれる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ELF / PE」に近いか確認する。
- 強みである「実行ファイルはリンク時の視点であるセクション(.text/.data/.bssなど)と、ロード時の視点であるセグメント(メモリへのマップ単位)の二層構造を持ち、ローダはセグメント単位でファイル内容を仮想アドレスへmmapする。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。