Chapter 4:
エルフ卿になる(ELF) Edit on GitHub

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

紙に描かれた絵。魔法使いのエルフが瞑想しており、片手にgnuの頭を、もう片手にLinuxのペンギンを持っています。エルフは言っています「実際、Linuxはカーネルだけで、オペレーティングシステムは...」。絵には赤いマーカーで「あなたはエルフ・オン・ア・シェルフを聞いたことがあります!今、準備して... GNU/Linux上のエルフをご覧ください」と書かれています。絵には「Nicky」のサインがあります。

(かわいらしい絵を提供してくれた Nicky Case に感謝します。)

余談: エルフはどこにでもいますか?

Linuxでアプリやコマンドラインプログラムを実行すると、それがELFバイナリである可能性が非常に高いです。ただし、macOSでは事実上のフォーマットは Mach-O です。Mach-OはELFと同じことをすべて行いますが、構造が異なります。Windowsでは、.exeファイルは Portable Executable フォーマットを使用しますが、これもまた同じコンセプトを持つ異なるフォーマットです。

Linuxカーネルでは、ELFバイナリは binfmt_elf ハンドラによって処理され、他の多くのハンドラよりも複雑で、数千行のコードが含まれています。これはELFファイルから特定の詳細情報を解析し、それを使用してプロセスをメモリに読み込み、実行する責任を負っています。

私はいくつかのコマンドラインテクニックを使用して、binfmtハンドラを行数で並べ替えました:

Shell session
$ 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ファイルの構造の概要を示すダイアグラム。セクション1、ELFヘッダー:バイナリに関する基本情報、およびPHTとSHTの場所。セクション2、プログラムヘッダーテーブル(PHT):ELFファイルのデータをメモリにどのようにどこに読み込むかを説明します。セクション3、セクションヘッダーテーブル(SHT):デバッグを補助するためのオプションのデータの「マップ」。セクション4、データ:バイナリのすべてのデータ。PHTとSHTはこのセクションを指します。

ELFヘッダ

すべてのELFファイルにはELFヘッダが含まれています。これはバイナリに関する基本情報を伝える非常に重要な役割を果たします:

ELFヘッダは常にファイルの先頭にあります。それはプログラムヘッダテーブルとセクションヘッダの位置を指定し、これらのテーブルはさらにファイル内の他の場所に格納されたデータを指します。

プログラムヘッダーテーブル

プログラムヘッダーテーブルは、バイナリを実行時にどのようにロードして実行するかに関する具体的な詳細情報を含むエントリのシリーズです。各エントリには、どの詳細情報を指定しているかを示すタイプフィールドがあります。たとえば、PT_LOADはメモリにロードする必要があるデータを含むことを意味しますが、PT_NOTEはセグメントがメモリに必ずしもロードされる必要がない情報テキストを含むことを意味します。

4つの一般的なプログラムヘッダータイプを示すテーブル。 タイプ1、PT_LOAD:メモリにロードするデータ。 タイプ2、PT_NOTE:著作権表示、バージョン情報などのフリーフォームテキスト。 タイプ3、PT_DYNAMIC:動的リンクに関する情報。 タイプ4、PT_INTERP:「ELFインタープリタ」の位置へのパス。

各エントリは、そのデータがファイル内のどの位置にあるか、そして場合によってはそのデータをメモリにどのようにロードするかに関する情報を指定します:

セクションヘッダーテーブル

セクションヘッダーテーブルは、セクションに関する情報を含むエントリの連続です。このセクション情報は、ELFファイル内のデータをマップのように示し、デバッガなどのプログラムがデータの異なる部分の意図された使用方法を理解しやすくします。

島、川、ヤシの木、コンパスローズを備えた古い宝の地図。一部の島には「.text」、「.data」、「.shstrtab」、「.bss」などのELFセクション名がラベル付けされています。この図には「セクションヘッダーテーブルはバイナリデータの地図のようです」とキャプションがついています。

たとえば、プログラムヘッダーテーブルは、一緒にメモリにロードされる大量のデータを指定できます。その単一のPT_LOADブロックにはコードとグローバル変数が含まれているかもしれません!プログラムを実行するためにはそれらを別々に指定する必要はありません。CPUはエントリポイントから開始し、プログラムが要求するとき、どこでデータにアクセスするかを進めます。ただし、プログラムを分析するデバッガのようなソフトウェアは、各領域がどこで始まりどこで終わるかを正確に知る必要があります。そうでないと、テキストとして「hello」と表示されるテキストをコードとしてデコードしようとしてプログラムが失敗するかもしれません。この情報はセクションヘッダーテーブルに格納されています。

通常含まれていることが多いですが、セクションヘッダーテーブルは実際にはオプションです。ELFファイルはセクションヘッダーテーブルを完全に削除しても正常に実行でき、コードの動作を隠したい開発者は、意図的にELFバイナリからセクションヘッダーテーブルを削除または変更することがあります。デコードが難しくなるように

各セクションには名前、タイプ、および使用方法とデコード方法を指定するフラグがあります。標準的な名前は通常、ドットで始まります。最も一般的なセクションは次のとおりです:

データ

プログラムおよびセクションヘッダーテーブルのエントリはすべて、ELFファイル内のデータブロックを指すもので、それらをメモリにロードするか、プログラムコードの場所を指定するか、またはセクションの名前を指定するかのいずれかです。これら異なるデータの要素は、ELFファイルのデータセクションに含まれています。

ELFファイルの異なる部分がデータブロック内の位置を参照する様子を示すダイアグラム。データの連続コレクションが、末尾でフェードアウトし、ELFインタープリタのパス、セクションタイトル ".rodata"、および文字列 "Hello, world!" など、明確に認識できる要素が含まれています。データブロック上にはいくつかの例のELFセクションが浮かび上がり、それらのデータを指す矢印があります。たとえば、PHTおよびSHTエントリの例からのデータセクションは、同じ "Hello, world!" テキストを指しています。SHTエントリのラベルもデータブロックに格納されています。

リンクの簡単な説明

binfmt_elf コードに戻りましょう:カーネルはプログラムヘッダーテーブル内の2つのタイプのエントリに注意を払います。

PT_LOAD セグメントは、.text.data セクションのようなプログラムデータがメモリにロードされる場所を指定します。カーネルはこれらのエントリをELFファイルから読み取り、データをメモリにロードしてプログラムがCPUによって実行されるようにします。

カーネルが気にするもう一つのプログラムヘッダーテーブルのエントリは PT_INTERP です。これは「動的リンクランタイム」を指定します。

動的リンキングについて話す前に、一般的な「リンキング」について話しましょう。プログラマーは再利用可能なコードライブラリの上にプログラムを構築する傾向があります。例えば、前に話したようにlibcです。ソースコードを実行可能なバイナリに変換する際、リンカと呼ばれるプログラムがこれらの参照を解決し、ライブラリコードをバイナリにコピーします。このプロセスは「静的リンキング」と呼ばれ、外部コードが直接配布されるファイルに含まれることを意味します。

しかし、一部のライブラリは非常に一般的です。libcは例えばほとんどのプログラムで使用されています。なぜなら、これはシスコールを介してOSと対話するための標準的なインターフェースだからです。コンピューター上のすべてのプログラムに別個のlibcのコピーを含めるのはスペースの非効率的な使用ですし、ライブラリのバグがライブラリを使用する各プログラムの更新を待つ必要がある代わりに、ライブラリのバグを1か所で修正できると便利でしょう。これらの問題の解決策が動的リンキングです。

静的にリンクされたプログラムがライブラリ「bar」から「foo」という関数を必要とする場合、プログラムは「foo」の完全なコピーを含めます。しかし、動的にリンクされている場合、プログラムは「bar」ライブラリから「foo」を必要とするという参照のみを含めます。「bar」がコンピューターにインストールされていることを期待して、プログラムが実行されると、「foo」関数のマシンコードが必要に応じてメモリにロードされます。コンピューターの「bar」ライブラリのインストールが更新されると、プログラム自体を変更せずに次回プログラムが実行されると新しいコードが読み込まれます。

静的リンクと動的リンクの違いを示す図。左側では静的リンキングが示されており、いくつかのコード「foo」の内容が2つのプログラムに別個にコピーされています。これにはライブラリ関数が開発者のコンピューターから各バイナリにコピーされるというテキストが付属しています。右側では動的リンキングが示されており、各プログラムに「foo」関数の名前が含まれ、矢印がプログラムの外側からユーザーのコンピューターにあるfooプログラムに向かっています。これには、バイナリがライブラリ関数の名前を参照し、それらがランタイムでユーザーのコンピューターからロードされるというテキストが付属しています。

出回っているダイナミックリンク

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 の中にいます)。カーネルはプログラムが読み取るためにargcargv、および環境変数をスタックにプッシュします。

レジスタは今クリアされています。システムコールを処理する前に、カーネルはレジスタの現在の値をスタックに保存し、ユーザースペースに戻る際に復元されるようにします。ユーザースペースに戻る前に、カーネルはスタックのこの部分をゼロにします。

最後に、システムコールが終了し、カーネルがユーザースペースに戻ります。カーネルはレジスタを復元し、これでゼロになっています。そして、保存された命令ポインタにジャンプします。この命令ポインタは、新しいプログラム(またはELFインタープリタ)の開始地点であり、現在のプロセスが置き換えられました!

Continue to Chapter 5: コンピュータ内の翻訳家