C 言語で仮想マシンを実装するにはどうすればいいですか?

C 言語で仮想マシンを実装するにはどうすればいいですか?

私は低レベルのアプリケーション (コンパイラ、インタープリタ、パーサー、仮想マシンなど) での作業が好きなので、C プログラミング言語で仮想マシンを構築する方法についての記事を書くと役立つと思いました。この記事を読めば、仮想マシンの仕組みを理解するだけでなく、低レベルのプログラミング プロセスについても理解できると思います。

コンテンツの準備

  • 使用するコンパイラの種類: 軽量コンパイラである clang を使用していますが、任意の最新コンパイラを使用できます。
  • テキスト エディター: C を書くときは、IDE を介してテキスト エディターを編集することをお勧めします。私は Emacs を使用します。
  • 基本的なプログラミング知識: 変数、フロー制御、関数、構造体とは何かなど。
  • GNU Make: GNU Make は主に実行可能プログラム (ライブラリ ファイル) の構築を自動化するために使用されます。これにより、コードをコンパイルするためにターミナルで同じコマンドを何度も記述する必要がなくなります。 Make の機能には、自動ビルドとインストールが含まれます。増分コンパイルと自動更新。 C/C++、Java、PHP などの複数の言語に適用可能。カスタム関数拡張のサポート (意味がある限り、Makefile に組み込むことができます)。

[[233734]]

なぜ仮想マシンを作成する必要があるのでしょうか?

仮想マシンを作成する必要がある理由はいくつかあります。

1. コンピューターの仕組みをより深く理解する必要があります。この記事は、コンピューターが低レベルでどのように動作するかを理解するのに役立ちます。仮想マシンは非常に単純な抽象化レイヤーを提供します。

2. ところで、仮想マシンについての知識を少し学びましょう。

3. プログラミング言語の仕組みを深く理解する。最近では、JVM、Lua VM、FaceBookのHip-Hop VM(PHP/Hack)など、さまざまな言語が仮想マシンを対象としています。

命令セット

命令セットは比較的単純なので、レジスタから値を移動したり、他の命令にジャンプしたりする方法など、簡単に概要を説明します。

仮想マシンにレジスタのセット A、B、C、D、E、F があり、これらは汎用レジスタであるため、何でも保存できるとします。これは、特殊目的レジスターとは異なります。例: x86 では、命令セットによって読み取り専用となる ip、flag、ds など。 VM がスタックベースの VM である場合、値をプッシュおよびポップできるスタックがあり、使用できるレジスタもあることを意味します。スタックベースの仮想マシンは、レジスタベースの仮想マシンよりも実装がはるかに簡単です。

以下は私が実装する命令セットの例です。

  1. 5 ;スタック5をプッシュする
  2. 10ポンドスタック10をプッシュする
  3. 追加; 2つのをポップする の上 トップ スタックスタックプッシュする追加
  4. ポップ;スタック上の値をポップし、デバッグ用に出力もします。
  5. 0 を設定しますレジスタA0設定する
  6. HLT ;プログラムを停止する

これが私の命令セットです。POP 命令はポップした命令を印刷しますが、その多くはデバッグ用であることに注意してください。 ADD は結果をスタックにプッシュするので、スタックから値をポップして、存在するかどうかを確認できます。レジスタにアクセスして書き込む方法を確認できるように、SET 命令も含めました。 MOV A, B (値 A を B に移動) などの命令を実行することもできます。HLT はプログラムの実行が終了したことを示す命令です。

仮想マシンはどのように機能しますか?

実際、仮想マシンはあなたが思っているよりもシンプルです。その動作モードは、「命令サイクル」という単純なルールに従います。全体のプロセスには、読み取り、デコード、実行という 3 つの主要な部分が含まれます。まず、命令セットを読み取り、次に命令をデコードして、デコードされた命令を実行する必要があります。

プロジェクト構造

プログラミングを始める前に、いくつかの準備が必要です。プロジェクトを入れるためのフォルダーが必要なので、~/Dev に入れたいと思っています。さらに、C コンパイラが必要です (clang 3.4 を使用しています)。ここでは、ターミナルでプロジェクトを設定する方法を説明します。すでに ~/dev/ ディレクトリがあることを前提としていますが、任意の場所に配置できます。

  1. $ cd ~/dev/
  2. $ mkdir mac
  3. $ cd mac
  4. $ mkdir src

上記は、~/dev ディレクトリに cd する方法です。まず、ディレクトリを作成します (VM を「mac」と呼びます)。次に、そのディレクトリに移動して src ディレクトリを作成します。ここにコードを保存します。

メイクファイル

私の makefile は比較的シンプルで、複数のファイルに分割する必要がないため何も含まれておらず、いくつかのフラグを使用してファイルをコンパイルするだけです。

  1. SRC_FILES = main.c
  2. CC_FLAGS = -Wall -Wextra -g -std=c11
  3. CC = カラン
  4.  
  5. 全て
  6. ${CC} ${SRC_FILES} ${CC_FLAGS} -o mac

今のところこれで十分でしょう。後からいつでも改善できますが、これで目的が達成されれば問題ないでしょう。

命令プログラミング

これで、仮想マシンのコードの作成を開始できます。まず、命令プログラミングを説明するには、命令が基本的に 0 から X までの数字であるため、列挙を使用する必要があります。基本的に、アセンブラはアセンブリ ファイルを受け取り、すべての操作を数値に変換します。たとえば、Mac 用のアセンブラ プログラムを作成すると、すべての MOV 操作が数値 0 に変換されます。

  1. typedef列挙型{
  2. PSH、
  3. 追加
  4. ポップ、
  5. セット
  6. HLT
  7. } 命令セット;

これで、テスト プログラムを配列として保存し、5 と 6 を加算して POP 命令を使用して出力するなどの簡単なテスト プログラムを作成できます。必要に応じて、スタックの一番上の値を出力するコマンドを定義できます。

指示は配列に保存する必要があり、これはドキュメントの先頭で定義します。ただし、ヘッダー ファイルに配置することもできます。以下は私のテスト プログラムです。

  1. const intプログラム[] = {
  2. PSH、5、
  3. PSH、6、
  4. 追加
  5. ポップ、
  6. HLT
  7. };

上記のプログラムは、5 と 6 をスタックにプッシュし、スタックから 2 つの値をポップする add 命令を実行し、それらを加算して結果をスタックにプッシュし戻します。次に結果がポップされ、デバッグの目的で、ポップ命令によって両方の値が出力されます。

***、HLT命令はプログラムを終了することを意味します。フローを制御したい場合は、いつでもプログラムを終了できます。ただし、何も指示しないと、仮想マシン *** は自然に終了します。

これで、仮想マシンの読み取り、デコード、実行のプロセスを実装しました。ただし、覚えておいてください。私は何も解読していません。生の指示を与えているだけです。

現在のコマンドを取得する

プログラムを配列として保存しているので、現在の命令を取得するのは簡単です。仮想マシンにはカウンターがあり、通常はプログラム カウンターと呼ばれますが、命令ポインターなどと呼ばれることもあり、通常はそれぞれ PC または IP と略されます。

今のところは、コードの先頭に ip という変数を作成し、それを 0 に設定するだけです。

  1. 整数ip = 0;

この ip は命令ポインターを表します。プログラム自体を整数配列として保存しているため、ip 変数は配列内のインデックスとして機能し、現在実行中の命令を示します。

  1. 整数ip = 0;
  2.  
  3. int main() {
  4. int instr = プログラム[ip];
  5. 0を返します
  6. }

変数 instr を印刷すると、変数 instr が列挙の最初の値であるため、PSH は 0 として表示されます。ただし、次のように検索関数を記述することもできます。

  1. 整数 フェッチ(){
  2. プログラム[ip]を返します
  3. }

この関数は呼び出されると現在の命令を返します。では、次の指示が必要な場合はどうすればよいでしょうか?命令ポインタを増分するだけです。

  1. int main() {
  2. int x =フェッチ(); // ポシュ
  3. ip++; // 命令ポインタを増分する
  4. int y =フェッチ(); // 5
  5. }

では、これをどのように自動化するのでしょうか?プログラムは HLT 命令を通過するまで停止しないことがわかっているので、プログラム自体は *** ループです。

  1. #include <stdbool.h>
  2.  
  3. bool 実行 = true ;
  4.  
  5. int main() {
  6. (実行中){
  7. int x =フェッチ();
  8. (x == HLT) 実行中 = false の場合;
  9. ip++;
  10. }
  11. }

私が現在やろうとしているのは、各命令をループして、命令の値が HLT であるかどうかを確認し、そうであればループを停止し、そうでない場合は繰り返し続けることです。

指示の評価

これは仮想マシンの動作の鍵となります。実際、仮想マシンは非常にシンプルです。巨大な switch ステートメントを記述できます。これを行う目的は、走行速度を速めることです。対照的に、すべての命令と、execute メソッドを使用する抽象クラスまたはインターフェースには HashMap を使用する必要があります。

switch ステートメント内の各ケースは列挙で定義したディレクティブであり、この eval 関数は単純なディレクティブ引数を使用してディレクティブを評価します。オペランドを使用していない限り、この関数では命令ポインタの増分を実行しないでください。

  1. void eval( int instr) {
  2. スイッチ(命令){
  3. ケースHLT:
  4. 実行中 = false ;
  5. 壊す;
  6. }
  7. }

この関数を VM のメイン ループに追加します。

  1. bool 実行 = true ;
  2. 整数ip = 0;
  3.  
  4. // 命令列挙型
  5. // 評価関数 
  6. //取得 関数 
  7.  
  8. int main() {
  9. (実行中){
  10. eval(フェッチ() );
  11. ip++; // 繰り返しごとに IP を増分する
  12. }
  13. }

スタック

しかし、他の命令を追加する前に、スタックが必要です。スタックは非常に単純なデータ構造です。リンクリストの代わりにこの配列を使用します。スタックのサイズは固定されているので、サイズ変更を心配する必要はありません。リンク リストの代わりに配列を使用すると、キャッシュ効率の点で利点が得られます。

プログラム配列のインデックスに ip を使用したのと同様に、スタック配列内の位置を示すスタック ポインター (sp) が必要になります。

以下は、私のスタックの 1 つのデータ構造の詳細なリストです。

  1. [] // 空の
  2.  
  3. PSH 5 //スタック**一番上**5を置く
  4. [5]
  5.  
  6. PSH 6 // 6オン トップ スタック
  7. [5, 6]
  8.  
  9. POP //から6ポップします 
  10. [5]
  11.  
  12. POP // 5をポップする
  13. [] // 空の
  14.  
  15. PSH 6 // 6 をプッシュします...
  16. [6]
  17.  
  18. PSH 5 // など
  19. [6, 5]

スタックに従ってプログラムを分解してみましょう。

  1. PSH、5、
  2. PSH、6、
  3. 追加
  4. ポップ、
  5. HLT

まずスタックに 5 をプッシュします。

  1. [5]

次に、6 をスタックにプッシュします。

  1. [5, 6]

次に、add 命令はそれらの値をポップして加算し、最終的に結果をスタックにプッシュします。

  1. [5, 6]
  2.  
  3. //一番上の値を取り出し、それをaという変数格納します
  4. a = ポップ; // a には6 が含まれています
  5. [5] // スタックの内容
  6.  
  7. //一番上の値をポップし、それをb という変数格納します
  8. b = ポップ; // bには5が含まれています
  9. [] // スタックの内容
  10.  
  11. // ここでbaを追加します。逆に行うこと注意してください。
  12. // これは問題ではないが、他の潜在的な命令
  13. //例えば5を6で割る  6/5同じではありません
  14. 結果 = b + a;
  15. push result // 結果をスタックプッシュする
  16. [11] // スタックの内容

スタック ポインターがどこで機能するかわかりますか?スタック ポインターは -1 に設定されており、空であることを意味します。 C では配列はゼロインデックスなので、sp が 0 の場合、配列のメモリはゼロに設定されていないため、C コンパイラによってスローされるランダムな数値に設定されます。

ここで 3 つの値をスタックにプッシュすると、sp は 2 になります。つまり、3 つの値を持つ配列は次のようになります。

  1. -> スペース -1
  2. プシュ -> スポ 0
  3. プシュ -> スペル 1
  4. プシュ -> スペ3
  5.  
  6. spはここにポイントします(sp = 2)
  7. |
  8. [1、5、9]
  9. 0 1 2 <- 配列インデックスまたは  「アドレス」  

ここで、スタックから一度ポップし、スタック ポインターの先頭をデクリメントする必要があります。たとえば、次にスタックから 9 をポップしたい場合、スタックの一番上は 5 になります。

  1. spはここにポイントします(sp = 1)
  2. |
  3. [1, 5]
  4. 0 1 <- これらは配列のインデックスです

したがって、スタックの一番上に何があるかを知りたいときは、sp の現在の値を確認するだけで済みます。これでスタックの仕組みが理解できたと思います。

今度はそれを C 言語で実装します。 C 言語でスタックを実装するのは非常に簡単です。 ip と同様に、sp 変数と配列も定義する必要があります。この配列がスタックです。

  1. 整数ip = 0;
  2. 整数sp = -1;
  3. 整数スタック[256];
  4.  
  5. ...

ここで、スタックに値をプッシュする場合は、スタック ポインターをインクリメントしてから、現在の sp に値を設定します。

このコマンドの順序は非常に重要です。最初に値を設定し、次に sp を増分すると、インデックス -1 のメモリに書き込むため、動作が悪くなります。

  1. // s = -1 です
  2. sp++; // 0 の場合
  3. スタック[sp] = 5; //スタック[0]を設定-> 5
  4. //トップ スタック現在[5]

eval 関数では、次のようにスタックをプッシュできます。

  1. void eval( int instr) {
  2. スイッチ(命令){
  3. ケースHLT: {
  4. 実行中 = false ;
  5. 壊す;
  6. }
  7. PSHの場合: {
  8. sp++;
  9. スタック[sp] = プログラム[++ip];
  10. 壊す;
  11. }
  12. }
  13. }

これは前の eval 関数とは多少異なることがはっきりとわかります。まず、各ケース ブロックを中括弧で囲みます。これを実行する目的が理解できないかもしれませんが、これにより、各ケースのスコープ内で変数を定義できるようになります。変数を今すぐ定義する必要はありませんが、将来必要になる可能性があり、定義しておくと、すべてのケース ブロックを一貫したスタイルに保つことが容易になります。

次に、PSH命令に必要なオペランドを担当するprogram[++ip]式があります。プログラムは配列に格納されているため、PSH 命令はオペランドを取得する必要があります。オペランドは本質的にはパラメータであり、関数を呼び出すときにパラメータを渡すことができるのと同じです。この状況は、値 5 をスタックにプッシュする (PSH, 5) と呼ばれます。命令ポインタ ip を増やすことでオペランドを取得できます。 ip が 0 の場合、PSH 命令が実行されたことを意味し、次にスタックにプッシュされた値を取得します。これは、IP アドレスを自動的に増やすことによって実現できます。これが完了したら、次の命令にジャンプする必要があります。そうしないと、奇妙なエラーが発生します。もちろん、sp++をstack[++sp]に簡略化することもできます。

  1. プログラム = [ PSH, 5, PSH, 6, ]
  2. 0 1 2 3
  3.  
  4. 押すとき:
  5. ip は 0から始まります(PSH)
  6. ip++なのでipは1になります(5)
  7. sp++、割り当てる 空間 スタック
  8. stack[sp] = program[ip]、スタック値5を置く

POP 命令は非常に単純で、スタック ポインターを減算するだけです。ただし、ポップ命令でポップされたばかりの値、つまりスタックからポップされた値を出力したい場合は、依然として多くの作業を行う必要があります。

  1. ケースPOP: {
  2. //スタック値をval_popped格納しスタックポインタをデクリメントします
  3. int val_popped = スタック[sp --];  
  4.  
  5. // それを印刷します!
  6. printf( "ポップされた%d\n" , val_popped);
  7. 壊す;
  8. }

*** は ADD 命令です。 ADD 命令は、2 つの数値 (繰り上がりなし) を加算するコンピュータ命令です。これは少し難しいように思えるかもしれません。ここで、いくつかの変数を導入するため、case ブロックを中括弧内に配置するというトリックが役立ちます。

  1. 場合 追加: {
  2. //まずスタックをポップしてのように保存します  'あ'  
  3. int a = スタック[sp --];  
  4.  
  5. //次に上部をポップします スタックから取り出して保存する  'ブ'  
  6. int b = スタック[sp --];  
  7.  
  8. //その後 結果を追加しスタックプッシュする
  9. int結果 = b + a;
  10. sp++; // スタックポインタを**前に**インクリメント
  11. スタック[sp] = 結果; //一番設定する スタック
  12.  
  13. // 完了しました!
  14. 壊す;
  15. }

始める前に、いくつかの操作の順序が重要であることに注意してください。

  1. 5 / 4 != 4 / 5

スタックは LIFO (先入れ先出し) です。つまり、最初に 5 がプッシュされ、次に 4 がプッシュされた場合、最初に 4 がポップされ、次に 5 がポップされます。pop() / pop() を実行した場合、間違った式が生成されるため、順序を正しくすることが重要です。

登録する

レジスタは仮想マシンではオプションであり、実装が簡単です。先ほど、A、B、C、D、E、F の 6 つのレジスタが必要になる可能性があると述べました。命令セットを実装するのと同じように、列挙体を使用してこれらを実装します。

  1. typedef列挙型{
  2. A、B、C、D、E、F、
  3. レジスタ数
  4. } レジスタ;

しかし、ここにはちょっとしたトリックがあり、列挙された *** には NUM_OF_REGISTERS が表示されます。この関数はレジスタのサイズを取得するために使用できますが、他のレジスタを追加した場合でも、そのサイズを取得できます。

レジスタを配列に格納します。これは、A = 0、B = 1、C = 2 などの列挙を使用するためです。したがって、レジスタ A を設定する場合は、register[A] = some_value と記述するだけです。

  1. intレジスタ[レジスタ数];

レジスタ A の値を出力します。

  1. printf( "%d\n" , レジスタ[A]); //レジスタA値を出力します

命令ポインタ

現在の命令を指す分岐命令ポインターがあることに留意してください。これは仮想マシンのソースコードなので、命令ポインタをレジスタとして使用し、仮想マシンプログラムからさまざまな操作を読み取って実行できるようにするのが最適です。

  1. typedef列挙型{
  2. A、B、C、D、E、F、PC、SP、
  3. レジスタ数
  4. } レジスタ;

ここで、これらの命令とスタック ポインターを実際に使用するためにコードを移植する必要があります。これを行う最も簡単な方法は、スタックの最上部にある sp 変数と ip 変数を削除し、次の定義に置き換えることです。

  1. #define sp (レジスタ[SP])
  2. #define ip (レジスタ[IP])

こうすることで、多くのコードを書き直す必要がなくなり、完璧に実行されます。欠点は、スケーラビリティがあまり高くなく、一部のコードが難読化される可能性があることです。そのため、このアプローチの使用はお勧めしませんが、単純な VM の場合は使用しても問題ないかもしれません。

コードの分岐に関してヒントを紹介します。新しい IP レジスタを使用すると、この IP に異なる値を書き込むことで分岐することができます。次の例を試して、何ができるかを確認してください。

  1. 10ポンド
  2. IP 0 を設定

これは、多くの人がよく知っている基本的な手順に似ています。

  1. 10 「Hello, World」を印刷する 
  2. 20ゴートー10

ただし、スタックには常にプッシュされているため、スタックにプッシュされた量がスペースの量を超えると、スタック オーバーフローが発生します。

各「単語」は命令なので、プログラムは次のようになります。

  1. ;これらは指示です
  2. 10ポンド0 1
  3. 20ポンド2 3
  4. IP 0を設定します4 5 6

2 番目の命令セットにジャンプしたい場合は、IP レジスタを 0 ではなく 2 に設定します。

要約する

この記事を読んだ後、プロジェクトのルートディレクトリで make を実行すると、仮想マシン ./mac を実行できます。

ソースコードはgithubでこちらからご覧いただけます。 MOV および SET 命令を含む VM の更新バージョンを確認する場合は、mac-improved ディレクトリを確認してください。この記事で実装したVMのソースコードはmac.cにあります。

<<:  マルチクラウドとハイブリッドクラウド:長所と短所を評価する

>>:  「オープンソース」によって作成され、「Haiyun Jiexun」によって運営されています

推薦する

eHiレンタカーの人事異動:3か月以内に複数の上級幹部が辞任

北京の記者、李卓中国第2位のレンタカー会社であるeHi Car Rentalは、経営陣の異例の人事異...

Shanpo.comの創設者が起業家としてのストーリーを語る: 投資を獲得した経緯

仕事を辞めてShanpo.comを立ち上げてからちょうど半年が経ちました。何度も挫折しましたが、つい...

事例分析:テンセントSosoKステーションからの回復方法

テンセントの製品であるテンセントSosoは、QQの力を借りて、侮れないウェブサイト訪問者の源です! ...

分類サイトは苦境に立たされている。アクセス数が急激に減少し、偽情報が蔓延している。

「58.com、魔法のウェブサイト!」ヤン・ミーをスポークスマンに迎えた58.comの広告は、バスや...

ユーザーにブランドロゴを覚えてもらうにはどうすればよいでしょうか? LOGO Design Network はユニークなロゴのデザインをお手伝いします。

2018年最もホットなプロジェクト:テレマーケティングロボットがあなたの参加を待っていますLOGO ...

勝利の力をマスターするための SEO「3つの法則」の簡単な分析

SEO 同士の競争は、インターネット上の煙のない戦いのようなものです。戦いはウェブサイトのランキング...

colossuscloud - $5/Windows/1g メモリ/30SSD/2T トラフィック/シンガポールのデータセンター 5 か所

colossuscloud は比較的新しいブランドで、設立されてからまだ 1 年しか経っていないため...

「スマートコーン」が北京でデビュー、Amapのブラックテクノロジーが公共交通の安全性を高める

道路工事は最も労働集約的で危険な職業の一つです。毎年、交通工事や道路清掃、事故処理などにより交通事故...

bacloud: 米国/リトアニア VPS、80% オフ、無制限トラフィック、3 ユーロ/四半期、1G RAM/25g SSD

リトアニアの老舗商人 bacloud は、VPS ユーザーを拡大したいため、OpenVZ タイプに限...

Yaohe TechnologyがSmart Retailに登場し、新小売時代のマーケティングテクノロジーについて説明

月収10万元の起業の夢を実現するミニプログラム起業支援プラン2018年9月20日、上海ドラゴンドリー...

「京鋼離婚」事件のマーケティング誇大宣伝を分析

郭静静と霍其剛の離婚がついに終結し、ネットマーケティングの強大な力を改めて示した。数日前、新浪のブロ...

推奨: vexxhost-openstack/$5/2 コア/512m メモリ/20g SSD/2T トラフィック/カナダ

Vexxhost は、7 年間運営している企業で、VPS 構成をアップグレードおよび増加し、価格を下...

ウェブマスターネットワークからの毎日のレポート:O2Oは弱く、打破する必要がある。百度は360度検索により大きな損失を被った。

1. TudouとYoukuの合併後、「1234」ビデオウェブサイトのパターンが徐々に形成されました...

一度海を見たら、水に感動することはもうないだろう。Welo City と Kupeng.com のクーポン失敗

花は咲いて散り、雲は流れて散る。絶えず変化するインターネットの世界では、更新や置き換えは当たり前のこ...

独立系ブログの現状に関する考察

はじめに:今日の質問は、次の4つの側面に分かれています:独立ブログの生命線はどれくらい長いか、独立ブ...