Chapter 1:

この記事を書きながら何度も驚いたことの1つは、コンピューターがどれほどシンプルであるかでした。私はまだ、「現実以上の複雑さや抽象性を求めるな」と自分自身を説得するのが難しいくらいです!続ける前に、頭に焼き付けるべきことが1つあるなら、それはすべてが見かけの通りに実際に単純であるということです。この単純さは非常に美しく、時には非常に、非常に呪われていることがあります。

さて、あなたのコンピューターがその最も基本的な部分でどのように動作するかの基本から始めましょう。

コンピューターアーキテクチャの仕組み

コンピューターの中央処理装置(CPU)は、すべての計算を担当しています。それが全ての指示を実行する主要な部分です。それはまさに大役を果たします。それは魔法のようなものです。コンピューターを起動するとすぐに作動し、指示を次々に実行します。

最初の量産型CPUは、イタリアの物理学者でエンジニアであるFederico Fagginによって1960年代末に設計されたインテル4004でした。それは現代の64ビットシステムとは異なり、はるかに単純でしたが、その単純さの多くは今でも残っています。

CPUが実行する「指示」は、単なるバイナリデータです。実行されている指示を表すための1バイトまたは2バイト(オペコード)の後に、指示を実行するために必要なデータが続きます。私たちが機械語と呼ぶものは、実際にはこれらのバイナリ指示の連続です。アセンブリ言語は、生のビットよりも読み書きしやすい、人間が読み書きするのに役立つ構文です。それは常に、CPUが読み取る方法を知っているバイナリにコンパイルされます。

機械語がアセンブリに翻訳され、逆に戻る方法を示す図。双方向の矢印が3つの例を接続しています:機械語(バイナリ)とその後の3つのバイナリ数値のバイト、機械語(16進数)とそれらの3つのバイトが16進数に翻訳されたもの(0x83、0xC3、0x0A)、そしてアセンブリと "add ebx, 10"。アセンブリと機械語は色分けされているため、機械語の各バイトがアセンブリの1ワードに対応していることがわかります。

注:命令は常に上記の例のように機械語で1:1で表されるわけではありません。例えば、add eax, 51205 00 02 00 00 に翻訳されます。

最初のバイト(05)は、EAXレジスタに32ビットの数値を加算することを特定するオペコードです。残りのバイトは 512 (0x200) で、リトルエンディアン バイト順序です。

Defuse Securityは、アセンブリと機械語の変換を試すための便利なツールを作成しました。

RAMはコンピュータの主記憶バンクであり、コンピュータ上で実行されるプログラムが使用するすべてのデータを格納する大きな汎用スペースです。これにはプログラムコード自体と、オペレーティングシステムのコアにあるコードも含まれます。CPUは常に機械語をRAMから直接読み取り、RAMにロードされていない場合はコードを実行できません。

CPUは 命令ポインタ を保持し、RAM内の次の命令を取得する場所を指します。各命令を実行した後、CPUはポインタを移動させて繰り返します。これが フェッチ実行サイクル です。

フェッチ-実行サイクルを示す図。テキストのバブルが2つあります。最初のものには「フェッチ」というタイトルが付いており、「現在の命令ポインターからメモリから命令を読み取ります」というテキストが含まれています。2番目のものは「実行」というタイトルが付いており、「命令を実行し、その後命令ポインターを移動します」というテキストが含まれています。フェッチのバブルには実行のバブルを指す矢印があり、実行のバブルにはフェッチのバブルを指す矢印があり、繰り返しプロセスを示しています。

命令を実行した後、ポインターはRAM内のその命令の直後に移動します。これにより、ポインターは次の命令を指すようになります。これがコードが実行される理由です!命令ポインターは単純に前に進み続け、メモリに格納された順序で機械語を実行します。一部の命令は、代わりに命令ポインターを他の場所にジャンプさせるか、特定の条件に応じて異なる場所にジャンプさせることができます。これにより、再利用可能なコードと条件付きロジックが可能になります。

この命令ポインターはレジスタに格納されています。レジスタはCPUが読み書きするのが非常に速い小さなストレージバケットです。各CPUアーキテクチャには、計算中に一時的な値を保存するためからプロセッサを設定するためまで、固定のレジスタセットがあります。

一部のレジスタは機械語から直接アクセスできるもので、先の図では ebx がその例です。

他のレジスタはCPU内部でのみ使用されますが、特殊な命令を使用して更新または読み取ることができることがあります。その一例が命令ポインターであり、直接読み取ることはできませんが、例えばジャンプ命令を使用して更新できます。

プロセッサは単純です

最初に、実行可能プログラムをコンピュータ上で実行すると何が起こるかについて考えてみましょう。まず、それを実行する準備のために多くの魔法が行われます — これについては後で詳しく説明します — しかし、プロセスの最後にはどこかのファイルに機械語があります。オペレーティングシステムはこれをRAMに読み込み、CPUに指示して命令ポインタをRAM内のその位置にジャンプさせます。CPUは通常通りフェッチ-実行サイクルを実行し続けるため、プログラムは実行を開始します!

(これは私にとって、本当に、この記事を読んでいるために使用しているプログラムがどのように動作しているかという瞬間の1つでした!あなたのCPUは、RAMからブラウザの命令を順番にフェッチし、それらを直接実行して、この記事を表示しています。)

RAM内のバイトのシリーズを示す図。ハイライトされたバイトは "命令ポインタ" とラベル付けされた矢印で指し示されており、命令ポインタがRAM内でどのように前進するかを示す矢印があります。

実は、CPUは非常に基本的な世界観を持っています。CPUは現在の命令ポインタと少量の内部状態しか見ることができません。プロセスは完全にオペレーティングシステムの抽象概念であり、CPUが元々理解または追跡しているものではありません。

*手を振る* プロセスは、コンピュータをもっと売るためにos devs大きなバイトによって作成された抽象概念です

私にとって、これはより多くの疑問を呼び起こします:

  1. CPUはマルチプロセッシングについて知らないし、命令を順次実行するだけなので、実行中のプログラム内で詰まらないのはなぜですか?複数のプログラムはどのように同時に実行できるのでしょうか?
  2. プログラムはCPU上で直接実行され、CPUはRAMに直接アクセスできるので、なぜコードは他のプロセスまたはカーネルからメモリにアクセスできないのでしょうか?
  3. 言ってみれば、すべてのプロセスがコンピュータに対してどんな命令も実行し、何でもできないようにするメカニズムは何ですか?それにしても、シスコール(システムコール)って何ですか?

メモリに関する質問は独自のセクションが必要であり、第5章 で取り上げられていますが、要約すると、ほとんどのメモリアクセスは実際にはアドレス空間全体をリマップする誤誘導の層を通過します。今のところ、プログラムはすべてのRAMに直接アクセスでき、コンピュータは一度に1つのプロセスしか実行できないと仮定しましょう。これらの仮定の両方を後で説明します。

さあ、最初のウサギの穴に飛び込んで、シスコールとセキュリティリングが詰まった世界に入りましょう。

余談:ところで、カーネルって何ですか?

あなたのコンピュータのオペレーティングシステム、たとえばmacOS、Windows、またはLinuxは、コンピュータ上で動作し、すべての基本的な機能を実現するソフトウェアの集まりです。 “基本的な機能” とは非常に一般的な用語であり、同様に “オペレーティングシステム” もそうです。異なる人に尋ねると、それにはコンピュータにデフォルトで付属するアプリ、フォント、アイコンなども含まれるかもしれません。

しかし、カーネルはオペレーティングシステムの中核です。コンピュータを起動すると、命令ポインタはどこかのプログラムから開始します。そのプログラムがカーネルです。カーネルはコンピュータのメモリ、周辺機器、その他のリソースにほぼ完全なアクセス権を持ち、コンピュータにインストールされたソフトウェア(ユーザーランドプログラムと呼ばれるもの)を実行する責任があります。この記事の途中で、カーネルがこのアクセス権を持つ方法と、ユーザーランドプログラムが持たない方法について学びます。

Linuxはカーネルだけであり、シェルやディスプレイサーバなどのユーザーランドソフトウェアが必要です。macOSのカーネルはXNUと呼ばれ、Unixのようです。また、現代のWindowsカーネルはNTカーネルと呼ばれています。

二つのリングで彼らを支配せよ

モード(時折特権レベルまたはリングと呼ばれることもあります)は、プロセッサが許可されていることを制御します。現代のアーキテクチャには、少なくともカーネル/スーパーバイザーモードとユーザーモードの2つのオプションがあります。アーキテクチャが2つ以上のモードをサポートするかもしれませんが、これらの日常的に使用されるのはカーネルモードとユーザーモードだけです。

カーネルモードでは、何でも可能です。CPUはサポートされているすべての命令を実行し、すべてのメモリにアクセスすることが許可されています。ユーザーモードでは、一部の命令のみが許可され、I/Oおよびメモリアクセスが制限され、多くのCPU設定がロックされます。一般的に、カーネルとドライバはカーネルモードで実行され、アプリケーションはユーザーモードで実行されます。

プロセッサはカーネルモードで起動します。プログラムを実行する前に、カーネルはユーザーモードへの切り替えを開始します。

ユーザーモードとカーネルモードの保護の違いを示す2つの偽のiMessageスクリーンショット。最初のものは「カーネルモード」と書かれており、「この保護されたメモリを読んでください!」と右側に書かれており、左側には「こちらです、親愛なる :)」と書かれています。2番目のものは「ユーザーモード」と書かれており、右側に「この保護されたメモリを読んでください!」と書かれており、左側には「いいえ!セグメンテーション違反!」と書かれています。

プロセッサモードが実際のアーキテクチャでどのように表れるかの例:x86-64アーキテクチャでは、現在の特権レベル(CPL)はcs(コードセグメント)と呼ばれるレジスタから読み取ることができます。具体的には、CPLはcsレジスタの2つの最下位ビットに含まれています。これらの2つのビットはx86-64の4つの可能なリングを格納できます:リング0はカーネルモードであり、リング3はユーザーモードです。リング1とリング2はドライバを実行するために設計されていますが、ほんの一部の古いニッチなオペレーティングシステムでしか使用されていません。たとえばCPLビットが11であれば、CPUはリング3、つまりユーザーモードで実行されています。

シスコールとは一体何なのでしょうか?

プログラムは、コンピュータへの完全なアクセス権を持つことができないため、ユーザーモードで実行されます。ユーザーモードは、コンピュータのほとんどへのアクセスを制限し、しかし、プログラムは何らかの方法でI/Oへのアクセス、メモリの割り当て、そしてオペレーティングシステムとの対話ができる必要があります!これを実現するために、ユーザーモードで実行されるソフトウェアは、オペレーティングシステムカーネルに助けを求める必要があります。オペレーティングシステムは、プログラムが悪意のある操作を行わないように独自のセキュリティ保護を実装できます。

もしもオペレーティングシステムと対話するコードを書いたことがあれば、おそらく openreadforkexit などの関数を認識するでしょう。これらの関数は、抽象化のいくつか下に、オペレーティングシステムに助けを求めるために システムコール を使用しています。システムコールは、プログラムがユーザースペースからカーネルスペースへの移行を開始し、プログラムのコードからOSのコードにジャンプさせる特別な手続きです。

ユーザースペースからカーネルスペースへの制御移行は、ソフトウェア割り込み と呼ばれるプロセッサ機能を使用して実現されます:

  1. ブートプロセス中、オペレーティングシステムはRAMに 割り込みベクターテーブル (IVT; x86-64ではこれを 割り込みディスクリプタテーブル と呼びます) という表を格納し、CPUに登録します。IVTは割り込み番号をハンドラコードポインタにマッピングします。
表「割り込みベクターテーブル」の画像。最初の列には「#」でラベル付けされた列があり、01から04までの数値が続きます。対応するテーブルの第2列には、「ハンドラアドレス」とラベル付けされた列があり、各エントリごとにランダムな8バイトの16進数が含まれています。テーブルの一番下には「これなどが続きます...」と書かれています。
  1. それから、ユーザーランドのプログラムは INT などの命令を使用して、プロセッサに指定された割り込み番号をIVTで検索し、カーネルモードに切り替え、次にIVTに格納されたメモリアドレスに命令ポインタをジャンプさせるように指示します。

このカーネルコードが終了すると、IRET のような命令を使用して、CPUにユーザーモードに切り替え、割り込みがトリガーされたときの命令ポインタを元に戻します。

(もしも興味があれば、Linuxのシステムコールに使用される割り込みIDは 0x80 です。Linuxのシステムコールのリストは Michael Kerriskのオンラインマンページディレクトリ で読むことができます。)

Wrapper APIs: 割り込みを抽象化する

これまでのところ、システムコールについて知っていることは以下の通りです:

プログラムは、システムコールをトリガーする際にオペレーティングシステムにデータを渡す必要があります。OSは、実行する特定のシステムコールと、たとえば開くファイル名のようなシステムコール自体が必要とするデータを知る必要があります。このデータの渡し方は、オペレーティングシステムとアーキテクチャによって異なりますが、通常は割り込みをトリガーする前に、特定のレジスタやスタックにデータを配置することで行われます。

デバイスごとにシステムコールの呼び出し方法が異なるため、プログラマがすべてのプログラムに対して自分でシステムコールを実装することは非常に非現実的です。これはまた、古いシステムを使用するように書かれたすべてのプログラムを壊すことを恐れてオペレーティングシステムが割り込み処理を変更できなくなることを意味します。最後に、通常、プログラムを生アセンブリで書かなくても済みます。プログラマはファイルを読み取るかメモリを割り当てるたびにアセンブリに戻ることを期待されるべきではありません。

システムコールはアーキテクチャごとに異なる実装がされています。

したがって、オペレーティングシステムはこれらの割り込みの上に抽象化レイヤーを提供します。Unix系のシステムでは、libc が必要なアセンブリ命令をラップする再利用可能なハイレベルライブラリ関数を提供し、Windowsではntdll.dll と呼ばれるライブラリの一部がそれを提供します。これらのライブラリ関数を呼び出すと、カーネルモードへの切り替えは発生しません。それらは通常の関数呼び出しです。ライブラリ内部では、アセンブリコードが実際に制御をカーネルに移し、ラップされたライブラリサブルーチンよりもプラットフォーム依存性が高いです。

Unix系システム上でCから exit(1) を呼び出すと、この関数は内部でマシンコードを実行して割り込みをトリガーし、正しいレジスタ/スタック/その他の場所にシステムコールのオペコードと引数を配置します。コンピュータは本当に素晴らしいですね!

速さの必要性 / CISCの世界へ

多くのCISCアーキテクチャ、例えばx86-64は、システムコールの一般的な利用から生まれた命令を含んでいます。

インテルとAMDはx86-64に関しては非常に協力しきれておらず、実際には最適化されたシステムコール命令が2つも存在しています。SYSCALLSYSENTER は、INT 0x80のような命令に代わる最適化された選択肢です。それらの対応するリターン命令、SYSRETSYSEXIT は、素早くユーザースペースに戻り、プログラムコードを再開するために設計されています。

(AMDとインテルプロセッサは、これらの命令とわずかに異なる互換性を持っています。一般的に、64ビットプログラムにはSYSCALLが最適なオプションですが、32ビットプログラムとの互換性にはSYSENTERがより適しています。)

RISCアーキテクチャの代表的な特徴として、特別な命令を持たないことがあります。RISCアーキテクチャであるApple SiliconがベースとしているAArch64は、シスコールとソフトウェア割り込みの両方に対して1つの割り込み命令のみを使用します。おそらく、Macユーザーは問題なく使用していることでしょう :)


うーん、それはたくさんの情報でしたね!簡単に振り返りましょう:

では、以前の最初の質問に答える方法を考えましょう:

CPUは複数のプロセスを追跡せず、ただ命令を実行し続けるのなら、実行中のプログラム内で固まらないのはなぜですか?複数のプログラムが同時に実行される仕組みはどのようになっていますか?

この質問への答えは、私の親愛なる友人よ、Coldplayがなぜ人気なのかという質問と同じ答えです… clocks!(厳密にはタイマーです。ただ、そのジョークをはさんでみたかっただけです。)

Continue to Chapter 2: 時間をスライス