ソフトウェアの視点:CPUからプロセスへ
私たちは「機械が何をしているのか」についてのメンタルモデルを構築したいと考えています。 機械的なレベルで、コンピューターがメモリ内のプログラムをどのように実行するのかについてです。 システムプログラマーは、このモデルの制約の中で効率的なプログラムを書きます。 エクスプロイト開発者は、脆弱なプログラムの制御を奪うためにこれを悪用します。
私たちのメンタルモデルは、現実のあらゆる複雑な詳細を反映する必要はないことを思い出してください。 技術的にはより正確であっても、より複雑なモデルが必ずしもより有用なモデルであるとは限りません。 私たちが主に関心を持っているのは、あらゆる「低レベル」1言語の中核にある単一の概念、すなわちランタイムメモリ管理です。
それには、前章で言及した2つのメモリ領域であるスタックとヒープがどのように機能するかを理解することが含まれます。 かなり詳細にです。 スタックから始める理由は2つあります。
-
普遍性 - スタックメモリは、最小のマイクロコントローラーから最も強力なサーバーまで、あらゆるシステムに存在します。ヒープメモリの使用は一般的ですが、任意でもあります。非組み込みプログラムは、性能や移植性のためにヒープ割り当てを避けることを選択できますが、組み込みプラットフォームではヒープをまったくサポートしていない場合があります。スタックメモリは常に関与しています。
-
単純性 - スタックメモリはハードウェアサポートによって実装され、その操作は比較的単純です。スタックにフレームをプッシュ(追加)し、先頭からフレームをポップ(削除)できます。対照的に、ヒープメモリのロジックは複雑で、ソフトウェアによって制御されます。スタックメモリは一般論として議論できますが、ヒープの基礎を理解するには特定のアロケータの実装を調べる必要があります(これは後で行います!)。
スタックメモリそのものへ進む前に(push に掛けた洒落です)、CPUとプログラムのロードという2つの前提事項を簡単に取り上げる必要があります。 どちらのトピックもそれだけで技術書が丸ごと一冊書けるものなので、ランタイムスタックのメンタルモデルの足場となる程度の詳細だけを図示します。
どのような種類の「組み込みシステム」がヒープメモリを使わないのでしょうか?
「組み込みシステム」は非常に広い用語で、無意味と言ってよいほどです。 少なくとも、より具体的な文脈がなければそうです。 一般的な議論を助けるために、Muenchら2は、実用的な区別に役立つ分類法を提案しています。
Type-I: 汎用OSベースのデバイス - 一般的なデスクトップ/サーバーソフトウェアを「スリム化」したバージョン。
- 例: Linuxカーネルを組み込んだ自動車のインフォテインメントシステム。
- スタックメモリとヒープメモリの両方をサポートします。
Type-II: 組み込みOSベースのデバイス - 低リソースおよび/または単一目的プラットフォーム向けのカスタムソフトウェア。
- 例: VxWorks RTOS3で動作するNASAの火星探査ローバー。
- スタックメモリをサポートし、ヒープメモリの使用は任意/モジュール式です。
Type-III: OS抽象化を持たないデバイス - ソフトウェアスタック全体が「モノリシックファームウェア」であり、外部イベント(例:周辺機器によってトリガーされる割り込み)に時折対応する単一の制御ループです。
- 例: 位置の三辺測量にカスタムファームウェアを使用するGPS受信機。
- 多くの場合、スタックメモリのみに制限されます4。
スタックメモリは、3種類すべての組み込みシステムの動作に不可欠です。 これはプログラムランタイムの普遍的な抽象化だと考えてください。
このセクションの残りの部分では、Type-IおよびType-IIシステム5を扱います。 私たちの可視化では一般に、仮想メモリやプロセスのような基本的なOS抽象化が存在することを前提としています。
CPUとRAM
メインメモリ、つまり物理マシンのRandom Access Memory(RAM)は、あらゆる非自明なランタイム計算を支えています。 それが保存し、操作するビットパターンは、2つの異なるものを表現します。
-
データ - あらゆる情報を表す可変長のバイト列:ハードコードされた文字列、カラーコード、数値、画像ファイルや動画ファイル全体など。各バイトは個別にアドレス指定できますが、性能のためにはワードアラインされたアクセスが望ましいことがよくあります。
- データはディスクやネットワーク(例:永続ストレージ)に書き込んだり、そこから読み出したりできますが、プログラムによる更新では必ず、データをRAM(例:揮発性ストレージ)に読み込み、変更を行い、それを書き戻すことになります。
-
コード - 短いバイト列としてエンコードされたネイティブCPU命令。上の図では、すべての有効な命令が同じ長さであると仮定しています6。ワードとは、CPUの「自然な」データ単位(そのハードウェアが効率的に操作するよう設計されているもの)です。
- 命令は低レベルの操作に焦点を当てます:算術、ブール論理、条件テスト、メモリ内のデータ移動などです。任意に複雑なプログラムは、これらの基本操作の長い列へ分解できます7。アセンブリ言語は、生の命令エンコーディング(別名「機械語」)を人間が読める形で表現したものです。
Central Processing Unit(CPU)は、レジスタと呼ばれる中間結果を保存するための小さな「作業領域」を備えた、非常に高速な命令処理ステートマシンです。 最終結果はRAMに書き戻されます。 レジスタには2種類あります。
-
汎用(
GP*)レジスタは、任意の時点で任意の種類の結果を保存できます。 -
特殊目的レジスタ(例:
IP、SP、CCR)は、結果処理中の内部状態を追跡するために使用されます。
では、実際には処理はどのように機能するのでしょうか? 命令とデータの両方が、データバス8を介してCPUとRAMの間を行き来します。 アドレスバスにより、CPUは読み取りまたは書き込み対象となる特定のメモリ位置とデータサイズを指定できます。 すべてのCPUは、3ステップの命令サイクルを継続的に繰り返します(下の各ステップを上の図で追ってみてください)。
-
フェッチ - Instruction Pointer (IP) レジスタ9が現在指している RAM から命令を読み取る。次の命令を指すように
IPを更新する。- 上図: CPU は現在の
IP値をアドレスバス経由で送信し、その値が指す命令をデータバス経由で受け取る。
- 上図: CPU は現在の
-
デコード - フェッチした命令の意味を解釈する。命令には必ず opcode(その操作を一意に表すエンコード)が含まれていなければならないが、operands(操作の引数)やプレフィックス(振る舞いを修飾するもの)が含まれる場合もある。
- 上図: CPU は受け取った命令のセマンティクスを解読する。
-
実行 - 命令を実行して副作用を生成する。内部的には、これは Control Unit (CU) が命令固有の信号を機能ユニットへ渡すことを意味する。たとえば、Arithmetic Logic Unit (ALU) は、レジスタ値に対して数学的演算を実行する機能ユニットである。
-
上図: 命令に応じて、CPU は
SP、CCR、およびGP*レジスタを更新する。さらに:-
命令が書き込みを行う場合、CPU は書き込み先のアドレスをアドレスバス経由で送信し、書き込むデータをデータバス経由で送信する。
-
命令が読み取りを行う場合、CPU は読み取り先のアドレスをアドレスバス経由で送信し、対応するデータをデータバス経由で受信する。
-
-
現代の CPU は、命令サイクルを高速化するために、命令パイプライン10や投機的実行11のような複雑な最適化に依存している。 幸いなことに、日々のプログラミングでそのような細かな事項を考慮したり理解したりする必要はない。
一方で、さまざまなレジスタの役割は、実用的なメンタルモデルにとって重要である。 IP に加えて、注目に値する 2 つの特殊目的レジスタは次のとおりである。
-
Stack Pointer (
SP) レジスタ - 現在のスタックフレームの底を示すアドレス。スタックフレームは、計算を行い、関数ローカル な結果を保存するための、関数の RAM 上の「メモ帳」に似ている。let x = 3 + 6;という文では、xはレジスタを使って計算され、その後、値9がスタックに格納される12。これにより、プログラム内で複数の関数が呼び出されるときに、CPU は小さく固定されたGP*レジスタ群を新しい計算に再利用できる。
-
Condition Code Register (
CCR) レジスタは、プロセッサの現在のステータスフラグビットを集める。このレジスタは、さまざまな用途の中でも、条件付きロジックの実装に役立つ。たとえば、ある操作の結果がゼロでない場合に命令ポインタを特定の位置へ「ジャンプ」させる、といったものだ。- この単一のレジスタによって、プログラマーとして頼りにしている
if文やforループのような、すべての制御フロー構造13が実現される。if z == true { do_1(); } else { do_2(); }は、「zとtrueの等価性をテストし、等しければCCR内の等価フラグを設定する。CCR内のこの等価フラグが設定されていれば、do_1()にジャンプして実行し、そうでなければdo_2()にジャンプして実行する」へと展開される。
- この単一のレジスタによって、プログラマーとして頼りにしている
まとめよう。CPU は RAM から命令を継続的に読み取り実行し、内部レジスタを更新してから、RAM からデータを読み取る、または RAM へデータを書き込む。
ハードウェアがどのように計算を支えているかという基本を理解したので、スタックメモリについて議論する準備はほぼ整った。 しかしその前に、もう 1 つの概念を理解する必要がある。それはプロセス、つまり OS レベルの重要な抽象化である。
実行可能ファイル vs プロセス
コンパイラがプログラムを出力するとき、それはディスク上に保存された実行可能ファイルとして表現される。 オペレーティングシステムは、実行可能ファイルの構造を標準化するために、異なるファイル形式を使用する。Windows では PE、Linux では ELF、MacOS では Mach-O である。 どの形式にも、次のものを格納するセクションが含まれる。
-
ヘッダー(どの形式でもファイルの先頭) - さまざまなメタデータを含む。ファイルタイプの識別子、内容の説明、追加セクションおよび特殊なテーブル(例: Linux のセクションヘッダーテーブル)のオフセットなど。
-
実行可能コード(Linux では
.textセクション) - コンパイルされたプログラムのロジックをエンコードした命令。プログラマーとしてのあなたの苦労の大部分はここに存在する! -
読み取り専用データ(Linux では
.rodataセクション) - 静的文字列やハードコードされたルックアップテーブルのような定数値。 -
書き込み可能データ(Linux では
.data) - 初期化済みで書き込み可能なグローバル変数およびバッファ。余談: 初期化されていないデータには専用のセクションがある(Linux では.bss。実際にはゼロ初期化されることが多い)。
プロセス は、現在実行中の実行可能ファイルのインスタンスである。 つまり、OS がそのプロセスに CPU 時間を割り当てるようスケジュールしており、実行可能ファイルのバイト列が、先に述べたフェッチ/デコード/実行サイクルを回っていくことになる。
OS のローダーは、ディスク上の実行可能ファイルの内容を取り出し、それらをメモリに配置し、そのメモリをプログラム実行に備えて準備する。 コンパイル時またはロード時に、複数の実行可能ファイル由来である可能性もあるコードやデータのさまざまな断片を結合するために、リンカーが関与する場合もある。 第 2 章での静的リンクと動的リンクに関する議論を思い出してほしい。 いずれにせよ、ロードの最終結果はおおよそ次のようになる。
仮想メモリ と呼ばれる OS 提供の抽象化により、プロセスは、ほぼ無限で完全に線形なアドレス空間において、自分が唯一の存在であると仮定できる。 実用上のあらゆる意図と目的において、そのメモリレイアウトは上図の右側である。
この仮想レイアウトから、他のプロセスと共有される物理ストレージへのマッピングを維持することは、OS の仕事である。 そしてこれは、日々のシステムプログラミングタスクのほとんどで安全に無視できる複雑さの層である。 覚えておこう。完全に詳細なモデルが最も実用的であることはめったにない。それこそが抽象化設計の美点である!
このセクションで最も重要な要点は、図の右側である。 具体的には、実行可能コード、静的メモリ、スタックメモリ、ヒープメモリが、同じプロセスアドレス空間内でどのように共存しているかである。 通常の実行もプログラムの悪用も、このメモリモデルの範囲内で発生する。 これは、システムコードを書くときにあなたが推論する対象である。 記憶しておくことが重要だ。メモリだけに。
Linuxのプロセスとシステムコール
プロセスはシステムプログラミングにおける重要な概念です。 少し寄り道して、より広い文脈について説明しましょう。
プロセスはユーザーによって、またユーザーのために実行されます。 そのため、プロセスは「ユーザー空間アプリケーション」と呼ばれることがあります。 これらには特別な権限はなく、つまり「リング3」で実行されます。これはコードが動作できる最も権限の低いモードです14。 多数のプロセスが同時に実行されますが、それぞれは自分自身の仮想アドレス空間の外にあるものを読み書きすることはできません。
では、OSカーネル自体はどうでしょうか? そのコード、データ、スタック、ヒープは、隔離されたメモリ領域である「カーネル空間」に格納されます。 カーネルは、利用可能な中で最も権限の高いモードである「リング0」で実行されます。 カーネルは任意のプロセスのメモリを読み書きでき、ハードウェアに直接アクセスし、特別なCPU機能を制御できます。
リング2と3は、実際にはほとんど使われません14。 これらのモードは、カーネルがベンダー固有のハードウェアと通信できるようにする特別なプログラムであるデバイスドライバー向けに意図されていました。 実際には、ほとんどのデバイスドライバーはカーネルに直接ロードされ、リング0でカーネルと並んで実行されます(これにより、OSの大きな攻撃対象領域が生じます15)。
ユーザー空間プログラムが特権操作(例: 新しいプロセスを生成する)を実行したり、ハードウェアとやり取りしたり(例: ディスクからファイルを読み取る、またはネットワーク経由でリクエストを送信する)する必要がある場合、カーネルにリクエストを行わなければなりません。 そのようなリクエストのための低レベルAPIは「システムコール」と呼ばれ、略して「syscall」と呼ばれます。 Linuxカーネルは400を超えるシステムコールをサポートしており、その一部はアーキテクチャ固有です16。 プロセスのライフサイクルを管理するための一般的な2つのsyscallを考えてみましょう。
fork(プロセスを複製するためのsyscall) - プロセスは自分自身のコピーを作成できます。スクリプトは補助タスクを並列に実行したい場合があり、Webサーバーはより多くのリクエストに対応できるようスケールしたい場合があります。forkされたプロセスのメモリはコピーオンライトです。元の(「親」)プロセスと新しいコピー(「子」)は、それぞれ異なる仮想アドレス空間を見ますが、共通の要素(例: 同一のコードセクション、読み取り専用またはまだ変更されていないデータ)は物理RAM上に一度だけ現れます。
execve(実行可能ファイルをロードするためのsyscall) - あるプログラムは別のプログラムを実行できます。それを行うためのシステムコールは新しいプロセスを作成するのではなく、現在のプロセス内に新しいプログラムをロードして実行します。つまり、以前の内容をすべて上書きします。新しいセグメントは、新しいプログラムの実行可能ファイルによって裏付けられます。例を使って、これら2つのsyscallを組み合わせてみましょう。コマンドラインシェルはどのように動作するのでしょうか? コマンドを入力すると、シェルは子を
forkします。 子には実行可能ファイルのパスと引数が入力として与えられ、要求されたアプリケーションで自分自身を置き換えるためにexecveを呼び出します。 親プロセスとして実行され続けているシェルは、パイプを通じて出力をキャプチャします。通常、通常の出力にはstdout、エラー出力にはstderrが使われます。
要点
中央処理装置(CPU)は、メインメモリまたはRandom Access Memory(RAM)に対して継続的に動作する小さな状態機械です。 RAMはコードとデータの両方を格納します。 CPUはRAMから命令(コード)をフェッチし、デコードし、実行し、該当する場合は結果(データ)を書き戻します。
プログラムが実行される前には、メモリにロードされ、プロセスを作成する必要があります。 これには、その実行可能コードをRAMにマッピングし、3つの特別なメモリ領域を設定することが含まれます。
- 静的メモリ - グローバル変数と定数を格納します。
- スタックメモリ - ローカル変数を含む関数フレームを格納します。
- [任意]ヒープメモリ - 関数とスレッド間で共有されるデータを格納します。
次にスタックメモリと静的メモリを扱います。 言語に依存しない信頼性パターンの文脈で。 ヒープメモリは少し後で登場します!
-
「低レベル」は、プログラミング言語に適用するには曖昧で、多義的になり得る用語です。この用語をどのように使っているかについては、第1章の“Languages by Level”セクションで簡単に説明しました。 ↩
-
What You Corrupt Is Not What You Crash: Challenges in Fuzzing Embedded Devices。Marius Muench、Jan Stijohann、Frank Kargl、Aurelien Francillon、Davide Balzarotti(2018)。 ↩
-
An Overview of the Mars Exploration Rovers Flight Software。Glenn Reeves(2014)。 ↩
-
技術的には、Type-IIIシステムはファームウェアビルドの一部としてカスタムアロケーターを明示的にリンクでき、実質的にヒープサポートを「自前で持ち込む」ことができます。しかし、多くの、もしそれが大多数でないとしても、単一目的のデバイスはそうしません。これはリソースおよび/または信頼性の制約によるものです。 ↩
-
興味のある方へ: Type-IIIシステムは、Read-Only Memory(ROM)に直接焼き込まれたプログラムを1つだけ持つことができ、そのエントリーポイントはデバイスのリセット時にジャンプされます。汎用システムとは異なり、任意の実行可能ファイルを実行できる柔軟性を必要としません。したがって、上で図示したプロセス抽象化は必要ありません。 ↩
-
これは、固定長エンコーディングを使用する32ビットおよび64ビットARMのような特定のInstruction Set Architecture(ISA)に当てはまります。x86のような他のISAは可変長エンコーディングを使用します。64ビットx86では、命令の長さは1バイトから15バイトまで変化します。想像できるように、その可変性はソフトウェア逆アセンブラーに課題を生みます。 ↩
-
xoreaxeaxeax/movfuscator。Chris Domas(2023年アクセス)。実際、任意のプログラムはたった1種類の命令、つまりmovのシーケンスに分解できます!効率的ではありませんが、効果的な難読化技術です。 ↩ -
データバスのハードウェア実装は、各車線がCPUとRAMの間の物理的な電気接続である複数車線の高速道路に似ています。 ↩
-
Instruction Pointer(IP)はProgram Counter(PC)と呼ばれることがよくありますが、本書ではIPで統一します。 ↩
-
Instruction pipelining。Wikipedia(2022年アクセス)。 ↩
-
Speculative execution。Wikipedia(2022年アクセス)。 ↩
-
実際には、最適化コンパイラーは関数呼び出しをまたいでレジスターを管理できるほど賢く、
xをスタックに書き込む必要がなく、レジスター内に安全に保持できる場合があります(その方が読み書きが高速です)。この例は一般的なケースを示しており、最適化された特殊ケースを示しているわけではありません。 ↩ -
Duff’s Device。Wikipedia(2023年アクセス)。Cの制御フローに関する興味深く奇妙な例です。Rustとは異なり、Cは非構造化制御フロー(
goto文を含む)を許可します。 ↩ -
Protection ring。Wikipedia(2022年アクセス)。 ↩ ↩2
-
Microsoft recommended driver block rules。Microsoft(2023年アクセス)。 ↩
-
すべてのアーキテクチャにおけるLinuxカーネルのシステムコール Marcin Juszkiewicz (2022年アクセス). ↩