Part of Putting the “You” in CPU: a rabbit hole into how your computer runs programs.
Chapter 4:
エルフ卿になる(ELF)
Edit on GitHub
現在、私たちは execve をかなり理解しています。ほとんどの経路の終わりにおいて、カーネルは実行するためのマシンコードを含む最終プログラムに到達します。通常、コードにジャンプする前にセットアッププロセスが必要です。例えば、プログラムの異なる部分をメモリ内の適切な場所に読み込む必要があります。各プログラムは異なる目的のために異なる量のメモリが必要ですので、実行のためのプログラムのセットアップ方法を指定する標準ファイルフォーマットがあります。Linuxは多くのそのようなフォーマットをサポートしていますが、圧倒的に最も一般的なフォーマットは ELF (実行可能およびリンカブルフォーマット) です。

(かわいらしい絵を提供してくれた Nicky Case に感謝します。)
余談: エルフはどこにでもいますか?
Linuxでアプリやコマンドラインプログラムを実行すると、それがELFバイナリである可能性が非常に高いです。ただし、macOSでは事実上のフォーマットは Mach-O です。Mach-OはELFと同じことをすべて行いますが、構造が異なります。Windowsでは、.exeファイルは Portable Executable フォーマットを使用しますが、これもまた同じコンセプトを持つ異なるフォーマットです。
Linuxカーネルでは、ELFバイナリは binfmt_elf ハンドラによって処理され、他の多くのハンドラよりも複雑で、数千行のコードが含まれています。これはELFファイルから特定の詳細情報を解析し、それを使用してプロセスをメモリに読み込み、実行する責任を負っています。
私はいくつかのコマンドラインテクニックを使用して、binfmtハンドラを行数で並べ替えました:
$ wc -l binfmt_* | sort -nr | sed 1d
2181 binfmt_elf.c
1658 binfmt_elf_fdpic.c
944 binfmt_flat.c
836 binfmt_misc.c
158 binfmt_script.c
64 binfmt_elf_test.c
ファイル構造
binfmt_elfがELFファイルを実行する方法を詳しく見る前に、ファイル形式自体を見てみましょう。ELFファイルは通常、次の四つの部分から構成されています:

ELFヘッダ
すべてのELFファイルにはELFヘッダが含まれています。これはバイナリに関する基本情報を伝える非常に重要な役割を果たします:
- どのプロセッサーで実行するために設計されているか。ELFファイルにはARMやx86など、異なるプロセッサータイプのマシンコードが含まれることがあります。
- バイナリが単独で実行されることを意図しているのか、それとも他のプログラムによって「動的リンクされたライブラリ」として読み込まれることを意図しているのか。動的リンクについての詳細は後で説明します。
- 実行可能ファイルのエントリーポイント。後のセクションでは、ELFファイル内のデータをメモリにどこに読み込むかを指定します。エントリーポイントは、プロセス全体が読み込まれた後のメモリ内の最初のマシンコード命令があるメモリアドレスを指します。
ELFヘッダは常にファイルの先頭にあります。それはプログラムヘッダテーブルとセクションヘッダの位置を指定し、これらのテーブルはさらにファイル内の他の場所に格納されたデータを指します。
プログラムヘッダーテーブル
プログラムヘッダーテーブルは、バイナリを実行時にどのようにロードして実行するかに関する具体的な詳細情報を含むエントリのシリーズです。各エントリには、どの詳細情報を指定しているかを示すタイプフィールドがあります。たとえば、PT_LOADはメモリにロードする必要があるデータを含むことを意味しますが、PT_NOTEはセグメントがメモリに必ずしもロードされる必要がない情報テキストを含むことを意味します。

各エントリは、そのデータがファイル内のどの位置にあるか、そして場合によってはそのデータをメモリにどのようにロードするかに関する情報を指定します:
- ELFファイル内のデータの位置を指します。
- データをメモリにロードする仮想メモリアドレスを指定できます。通常、セグメントがメモリにロードされる必要がない場合、これは空白のままになります。
- 2つのフィールドがデータの長さを指定します。1つはファイル内のデータの長さ、もう1つは作成されるメモリ領域の長さです。メモリ領域の長さがファイル内の長さよりも長い場合、余分なメモリはゼロで埋められます。これは、実行時に使用する静的メモリセグメントが必要なプログラムにとって有益です。これらの空のメモリセグメントは通常、BSSセグメントと呼ばれます。
- 最後に、フラグフィールドがメモリにロードされた場合に許可される操作を指定します。
PF_Rは読み取り可能、PF_Wは書き込み可能、PF_XはCPUで実行を許可するコードであることを意味します。
セクションヘッダーテーブル
セクションヘッダーテーブルは、セクションに関する情報を含むエントリの連続です。このセクション情報は、ELFファイル内のデータをマップのように示し、デバッガなどのプログラムがデータの異なる部分の意図された使用方法を理解しやすくします。

たとえば、プログラムヘッダーテーブルは、一緒にメモリにロードされる大量のデータを指定できます。その単一のPT_LOADブロックにはコードとグローバル変数が含まれているかもしれません!プログラムを実行するためにはそれらを別々に指定する必要はありません。CPUはエントリポイントから開始し、プログラムが要求するとき、どこでデータにアクセスするかを進めます。ただし、プログラムを分析するデバッガのようなソフトウェアは、各領域がどこで始まりどこで終わるかを正確に知る必要があります。そうでないと、テキストとして「hello」と表示されるテキストをコードとしてデコードしようとしてプログラムが失敗するかもしれません。この情報はセクションヘッダーテーブルに格納されています。
通常含まれていることが多いですが、セクションヘッダーテーブルは実際にはオプションです。ELFファイルはセクションヘッダーテーブルを完全に削除しても正常に実行でき、コードの動作を隠したい開発者は、意図的にELFバイナリからセクションヘッダーテーブルを削除または変更することがあります。デコードが難しくなるように。
各セクションには名前、タイプ、および使用方法とデコード方法を指定するフラグがあります。標準的な名前は通常、ドットで始まります。最も一般的なセクションは次のとおりです:
.text:メモリにロードされ、CPUで実行されるマシンコードです。SHT_PROGBITSタイプで、実行可能であることを示すSHF_EXECINSTRフラグと、メモリにロードされて実行されることを意味するSHF_ALLOCフラグがあります(名前に惑わされないでください、それはまだ単なるバイナリマシンコードです!「.text」と呼ばれているのに、読み取り可能な「テキスト」ではないことはいつも少し奇妙だと思っていました。).data:実行可能ファイルにハードコードされた初期化されたデータで、メモリにロードされます。たとえば、一部のテキストを含むグローバル変数がこのセクションにあるかもしれません。低レベルのコードを書く場合、これは静的変数の場所です。これもタイプSHT_PROGBITSを持ち、「プログラムの情報を含む」ことを意味するだけの「情報」セクションです。そのフラグはSHF_ALLOCとSHF_WRITEです。.bss:前述のように、初期値がゼロの割り当てられたメモリが一般的です。ELFファイルに空のバイトを大量に含めるのは無駄ですので、特別なセグメントタイプとしてBSSが使用されます。デバッグ時にBSSセグメントについて知っておくと便利です。したがって、メモリの長さを指定するセクションヘッダーテーブルエントリも存在します。これはSHT_NOBITSのタイプで、SHF_ALLOCとSHF_WRITEのフラグが設定されています。.rodata:これは.dataのようですが、読み取り専用です。非常に基本的なCプログラムが「printf(“Hello, world!”)」を実行する場合、文字列「Hello world!」は.rodataセクションに含まれ、実際の印刷コードは.textセクションに含まれます。.shstrtab:これは面白い実装の詳細です!セクション自体の名前(「.text」や「.shstrtab」など)はセクションヘッダーテーブルに直接含まれていません。代わりに、各エントリには名前を含むELFファイル内の位置へのオフセットが含まれています。これにより、セクションヘッダーテーブルの各エントリは同じサイズであるため、解析が容易になります。名前をテーブルに含める代わりに、名前データは「.shstrtab」と呼ばれる独自のセクションに保存されます。タイプはSHT_STRTABです。
データ
プログラムおよびセクションヘッダーテーブルのエントリはすべて、ELFファイル内のデータブロックを指すもので、それらをメモリにロードするか、プログラムコードの場所を指定するか、またはセクションの名前を指定するかのいずれかです。これら異なるデータの要素は、ELFファイルのデータセクションに含まれています。

リンクの簡単な説明
binfmt_elf コードに戻りましょう:カーネルはプログラムヘッダーテーブル内の2つのタイプのエントリに注意を払います。
PT_LOAD セグメントは、.text や .data セクションのようなプログラムデータがメモリにロードされる場所を指定します。カーネルはこれらのエントリをELFファイルから読み取り、データをメモリにロードしてプログラムがCPUによって実行されるようにします。
カーネルが気にするもう一つのプログラムヘッダーテーブルのエントリは PT_INTERP です。これは「動的リンクランタイム」を指定します。
動的リンキングについて話す前に、一般的な「リンキング」について話しましょう。プログラマーは再利用可能なコードライブラリの上にプログラムを構築する傾向があります。例えば、前に話したようにlibcです。ソースコードを実行可能なバイナリに変換する際、リンカと呼ばれるプログラムがこれらの参照を解決し、ライブラリコードをバイナリにコピーします。このプロセスは「静的リンキング」と呼ばれ、外部コードが直接配布されるファイルに含まれることを意味します。
しかし、一部のライブラリは非常に一般的です。libcは例えばほとんどのプログラムで使用されています。なぜなら、これはシスコールを介してOSと対話するための標準的なインターフェースだからです。コンピューター上のすべてのプログラムに別個のlibcのコピーを含めるのはスペースの非効率的な使用ですし、ライブラリのバグがライブラリを使用する各プログラムの更新を待つ必要がある代わりに、ライブラリのバグを1か所で修正できると便利でしょう。これらの問題の解決策が動的リンキングです。
静的にリンクされたプログラムがライブラリ「bar」から「foo」という関数を必要とする場合、プログラムは「foo」の完全なコピーを含めます。しかし、動的にリンクされている場合、プログラムは「bar」ライブラリから「foo」を必要とするという参照のみを含めます。「bar」がコンピューターにインストールされていることを期待して、プログラムが実行されると、「foo」関数のマシンコードが必要に応じてメモリにロードされます。コンピューターの「bar」ライブラリのインストールが更新されると、プログラム自体を変更せずに次回プログラムが実行されると新しいコードが読み込まれます。

出回っているダイナミックリンク
Linuxでは、barのようなダイナミックリンク可能なライブラリは、通常、.so(共有オブジェクト)拡張子のファイルにパッケージ化されます。これらの.soファイルは、プログラムと同様にELFファイルです — ELFヘッダーにはファイルが実行可能なものかライブラリかを指定するフィールドが含まれていることを思い出すかもしれません。さらに、共有オブジェクトにはセクションヘッダーテーブル内に .dynsym セクションがあり、ファイルからエクスポートされたシンボルと動的にリンクできる情報が含まれています。
Windowsでは、barのようなライブラリは.dll(ダイナミックリンクライブラリ)ファイルにパッケージ化されます。macOSでは、.dylib(ダイナミックリンクライブラリ)拡張子が使用されます。macOSのアプリケーションやWindowsの.exeファイルと同様に、これらはELFファイルとはわずかに異なる形式でフォーマットされていますが、同じコンセプトと技術です。
この2つのリンクのタイプの興味深い違いの1つは、静的リンクでは、使用されているライブラリの部分のみが実行可能ファイルに含まれ、したがってメモリにロードされることです。ダイナミックリンクでは、ライブラリ全体 がメモリにロードされます。これは初めては効率が悪いように思えるかもしれませんが、実際には、現代のオペレーティングシステムがメモリにライブラリを1回ロードし、そのコードをプロセス間で共有することを可能にします。ライブラリは異なるプログラムに対して異なる状態が必要なため、コードのみ共有できますが、その節約は依然としてRAMの数十から数百メガバイトの範囲になることがあります。
実行
ELFファイルを実行しているカーネルに戻りましょう。バイナリが動的にリンクされている場合、OSはすぐにバイナリのコードにジャンプできません。なぜなら、動的にリンクされたプログラムは、必要なライブラリ関数への参照しか持っていないからです。
バイナリを実行するには、OSは必要なライブラリを特定し、それらをロードし、すべての名前付きポインタを実際のジャンプ命令で置き換え、それから実際のプログラムコードを開始する必要があります。これはELFフォーマットと深く関わる非常に複雑なコードであり、通常はカーネルの一部ではなく、スタンドアロンのプログラムです。ELFファイルは、プログラムが使用するパス(通常は /lib64/ld-linux-x86-64.so.2 のようなもの)をプログラムヘッダテーブルの PT_INTERP エントリに指定します。
ELFヘッダを読み取り、プログラムヘッダテーブルをスキャンした後、カーネルは新しいプログラムのメモリ構造を設定できます。まず、すべての PT_LOAD セグメントをメモリにロードし、プログラムの静的データ、BSS領域、およびマシンコードを埋めます。プログラムが動的にリンクされている場合、カーネルはELFインタープリタ (PT_INTERP) を実行する必要があるため、インタープリタのデータ、BSS、およびコードもメモリにロードします。
さて、カーネルはCPUの命令ポインタを設定する必要があります。実行可能ファイルが動的にリンクされている場合、カーネルはメモリ内のELFインタープリタのコードの開始地点に命令ポインタを設定します。それ以外の場合、カーネルは実行可能ファイルの開始地点に設定します。
カーネルはほとんどの準備が整い、システムコールから戻る準備ができました(まだ execve の中にいます)。カーネルはプログラムが読み取るためにargc、argv、および環境変数をスタックにプッシュします。
レジスタは今クリアされています。システムコールを処理する前に、カーネルはレジスタの現在の値をスタックに保存し、ユーザースペースに戻る際に復元されるようにします。ユーザースペースに戻る前に、カーネルはスタックのこの部分をゼロにします。
最後に、システムコールが終了し、カーネルがユーザースペースに戻ります。カーネルはレジスタを復元し、これでゼロになっています。そして、保存された命令ポインタにジャンプします。この命令ポインタは、新しいプログラム(またはELFインタープリタ)の開始地点であり、現在のプロセスが置き換えられました!
Continue to Chapter 5: コンピュータ内の翻訳家