web-dev-qa-db-ja.com

言語仮想マシンの仕事とは何か

最近、言語開発に非常に興味を持っています。過去数週間、電卓言語/式パーサーなど、多くの言語フロントエンド(レクサー、パーサー)を作成しました。非常に多くのことを作成した後、私が現在持っているものよりも構文解析とコード実行の間に抽象化がなければならないことに気づきました。これが私の主な質問につながります。

私の最初の質問は、これです。たとえば、JVMなどの言語仮想マシンの役割は何ですか(もちろん、私の想定しているシステムは、はるかに小規模です)。それは何をするためのものか?実際にハードウェアをエミュレートし、エミュレートされたハードウェアで低レベルの命令を実行しますか?それとも単に抽象化を提供し、システムで下位レベルの管理された命令を実行しますか? (私は この質問 を読みましたが、それは本当に理解するのに役立ちませんでした)

2番目の質問。バイトコードとは正確には何ですか?それは仮想マシンのマシンコードのようなものですか?それとも、人間が読める、おそらくアセンブリのような命令であり、これらの命令は直接実行されるのですか、それともネイティブコードにコンパイルされるのですか?

3番目の質問。これを実装するにはどうすればよいですか?私は自分自身を有能なプログラマーと見なしており、決して手順を追った説明は必要ありません。私が正確に何をする必要があるか、そしてこれがどれだけ低レベルであるかの概要だけです。

ヘルプ/提案/説明をありがとうございました。私が述べたことが混乱を招くものであるか、それについてさらに詳細が必要な場合は、質問してください。再度、感謝します!

6
APott

JVMなどの言語仮想マシンの仕事とは正確には何ですか?それは何をするためのものか?

仮想マシンは、コンピューターが使用するプラットフォーム用ではないマシンコードを実行できるプログラムです。したがって、たとえば、x86 Windowsコンピューターはx86 Windowsマシンコードを直接実行できますが、Javaバイトコードは実行できません。それがJVMの目的です。ギャップを埋め、x86 WindowsコンピューターがJavaバイトコードを実行できるようにします。

これは、複数のフロントエンド(Java、Scala、Clojure、Jythonなど)があり、それらを実行するために同じコードを使用したい場合に役立ちます(たとえば、VMおよびサポートされている言語のいずれかからそれらを使用します)。

もう1つの正当な理由は、複数のバックエンド(Windows、Linux、Android、x86、ARMなど)があり、それらすべてで同じ(またはほぼ同じ)コードを実行する場合です。

実際にハードウェアをエミュレートし、エミュレートされたハードウェアで低レベルの命令を実行しますか?

やや。特定のハードウェアをエミュレートしませんが、バイトコードを実行できるいくつかのハードウェアをエミュレートします。

バイトコードとは正確には何ですか?仮想マシンのマシンコードのようなものですか?

それがまさにそれです。

それとも、人間が読める、おそらくアセンブリのような指示ですか?

これは、人間が読める形式のバイトコードです。この関係は、たとえばx86アセンブリやx86マシンコードの場合とまったく同じです。

人々がバイトコードで読み書きする必要があるとき、彼らはほとんど常に人間が読める形式で作業します。ただし、仮想マシンはバイトコードを直接処理します。重要な点は、この2つの間の変換は1対1であるということです。人間が読める形式の各ステートメントは、バイトコードの1つの命令に正確に対応しています。

これらの命令は直接実行されますか、それともネイティブコードにコンパイルされますか?

それは仮想マシンの実装に依存します。最も単純な(そして最も遅い)仮想マシンは、バイトコードを解釈するだけです。より高度なVM(デスクトップJVMやCLRなど)は、バイトコードを現在のプラットフォームのネイティブマシンコードにコンパイルして実行します。ただし、2つのオプション(またはその他の実装)の唯一の違いはパフォーマンスです。

これを実装するにはどうすればよいですか?

先ほど述べたように、最も単純なVMは単なるインタプリタです。バイトコード命令を一度に1つずつ読み取り、すぐに実行します。 Javaバイトコード(または少なくともそのまともなサブセット)のようなものに対してこれを行うのは難しいことではないと思いますが、退屈だと思います。

JVMはスタックベースであるため、Stack<Object>のようなものでそれを表すことができます。そして、incost_2のような命令は、そのスタックを直接操作するだけです。 (明らかに、このVMのパフォーマンスはひどいものですが、それは重要ではありません。)

3
svick

非常に多くのことを作成した後、私が現在持っているものよりも構文解析とコード実行の間に抽象化がなければならないことに気づきました。これが私の主な質問につながります。

あんまり。典型的な産業用強度のプロダクション品質の高性能言語実装では、通常areより多くのステージがありますが、それは必ずしも必要ではありません。たとえば、MRI Rubyなどの単純なツリーウォーキングインタープリターは、実際にはLexだけを解析して、抽象構文ツリーをウォークし、アクセスしたすべてのノードに対してコードのスニペットを実行します。それでおしまい。さらにシンプルなラインベースのインタープリターは、ASTを構築することすらありません。

私の最初の質問は、これです。 JVMなどの言語仮想マシンの仕事とは正確には何ですか[…]。

VMは特別なものではありません。仮想マシンはanythingであり、あるAPIを別のAPIから完全に抽象化します。それだけです。たとえば、 、オブジェクト指向プログラミング(少なくともAlan Kayが想定している形式)では、すべてのオブジェクトが仮想マシンです。

より具体的には、言語のコンテキストでは、すべての言語はVMであり、すべてのVMは言語を定義します。

実際、プログラミング言語とVM命令セットはintentの違いのみです。プログラミング言語の構文は、人間が簡単に読み取れるように設計されています。セマンティクスは複雑な問題をエレガントに表現するために設計されています。VM命令セットの構文(形式)は、マシンで簡単に解析できるように設計されており、そのセマンティクスは簡単に解釈(またはコンパイル)され、さらにコンパイルしやすいto

しかし、もう一度:a VM命令セットは他のプログラミング言語と同じであり、他のプログラミング言語とまったく同じように実行されます:解釈するか、またはすでに解釈できる別の言語。

それは何をするためのものか?実際にハードウェアをエミュレートし、エミュレートされたハードウェアで低レベルの命令を実行しますか?それとも単に抽象化を提供し、システムで下位レベルの管理された命令を実行しますか?

VMに何をさせたいかによって異なります。たとえば、LLVM命令セットは、必要に応じて、マシンに依存しないマシンコードとして設計されています。その目的は、簡単にコンパイルできるようにすることです。効率的なマシンコードに変換します。したがって、特定のCPUに縛られることなく、可能な限り低レベルで現在の主流のCPU命令セット(x86、AMD64、IA-64、SPARC、MIPS、ARMなど)に近づけようとします。指図書。

JVML命令セットOTOHは簡単に解釈済みとなるように設計されているため、LLVM命令セットよりもはるかに高レベルです。 CIL命令セットはJVMLと同じように高レベルですが、コンパイル済みとなるように設計されているため、いくつかの異なる選択が行われます。

2番目の質問。バイトコードとは正確には何ですか?それは仮想マシンのマシンコードのようなものですか?それとも、人間が読める、おそらくアセンブリのような指示ですか、

バイトコードは、命令がテキストではなくバイトとしてエンコードされる言語の名前です。それでおしまい。

誤って「バイトコード」と呼ばれる多くの言語は実際にはそうではないことに注意してください。たとえば、CLIでは、命令はバイトではなくintとしてエンコードされるため、CILはバイトコードではなくintコードです。

これらの命令は直接実行されますか、それともネイティブコードにコンパイルされますか?

どちらか。両方とも。あなたが決める。前者は「解釈」と呼ばれ、後者は「コンパイル」と呼ばれます。ちなみに、ネイティブコードにコンパイルするためにneedを使用する必要はなく、インタープリターまたはコンパイラーがあるものであれば何にでもコンパイルできます。たとえば、Fantom VMは、JavaプラットフォームまたはCLIのどちらで実行されるかに応じて、その命令セットをJVMLまたはCILにコンパイルします。

Every言語はコンパイラーによって実装でき、every言語はインタープリターによって実装できます。 VM命令セットに違いはありません。たとえば、JVMLバイトコードを解釈するJVMがあり、プログラムの実行中にJVMLバイトコードをネイティブコードにコンパイルするJVMがあります。コンパイルするJVMがあります。プログラムが実行される前に、JVMLバイトコードをネイティブコードに変換します。JVMLバイトコードをJavaScriptソースコードにコンパイルするJVMが他にも多数あります。JVMLバイトコードを直接実行するCPUもあります(これは実際には、最初のオプションでは、インタープリターはソフトウェアではなくシリコンで実装されます)。

3番目の質問。これを実装するにはどうすればよいですか?私は自分自身を有能なプログラマーと見なしており、決して手順を追った説明は必要ありません。私が正確に何をする必要があるか、そしてこれがどれだけ低レベルであるかの概要だけです。

VM命令セットは、他の言語と同じように言語です。他の言語と同じように実装します。

  • 解析
  • セマンティック分析
  • 型推論
  • 型チェック
  • 最適化
  • コード生成(コンパイラーの場合)またはコード実行(インタープリターの場合)

VM命令セットは解析しやすいように設計されているため、通常、解析段階は簡単です。型推論と型チェックは、VM命令セットはタイプ付きです。最適化はオプションで、高性能VMを構築したくない場合はOTOH、doが高性能VMを構築したい場合、最適化は開発作業の99.999%を費やします。

2
Jörg W Mittag

3番目の質問について:

仮想マシンは、プログラムを実行するプログラムです。これには、インタープリターからジャストインタイムコンパイルVMまでさまざまです。

単純なVMを作成する方法を学ぶために、それほど多くは必要ありません。 SECDマシンは、適応可能な単純なモデルです。これは実際には、ラムダ計算で書かれたプログラムについて推論するために使用できる数学モデルです。独自のバージョンを記述するのは非常に簡単で、クロージャーを非常に簡単に実装できます。ただし、独自の健全性のためにガベージコレクションされた(そしてできれば動的に型付けされた)言語(Python、Perl、Ruby、GoなどのMLファミリの何か)から始める必要があります。 SECDには次のスタックがあります。

  • Sタック–中間値を維持する場所
  • Environment –変数のID値マッピングを保持します
  • Command –指示を保持する場所
  • Dump、ときどきスペル K制御フローの2番目のスタックとして必要な継続。

命令の実装方法はあなた次第です。私はVtablesとCommandパターンの両方を使用して、おもちゃのVMで大きな成功を収めています。

もう1つの重要なデータ型があります。それは、環境のペアであるクロージャーと命令リストです。

ここで、いくつかの指示を定義できます。 理論的な命令セット または独自の設計の1つを実装できます。私が通常実装する重要な手順は次のとおりです。

  • Const –定数引数をスタックに置きます。
  • DupDropSwapなどのスタック操作。
  • スタックから引数を取る基本的な演算。
  • 変数のルックアップ(実用的な場合は、set操作も同様)。各マップは1つのスコープレベルに対応します。文字列ベースのルックアップの場合:Envスタックの最初のマップでIDが見つからない場合は、次のマップを調べます。しかし、多くの場合、各変数を識別するために2つの整数が使用されます。最初は変数が定義されたスコープを示し、2番目はそのスコープで定義された変数の数を示します。例えば。 { var x; HERE }xは座標0, 0 –最も外側のスコープの最初の変数。しかし、{ var x; { var y; var z; HERE } }yは座標1, 0 – 2番目のスコープの最初の変数。
  • クロージャーの作成–通常、定数引数があり、それを現在のEnvへのポインターと組み合わせます。結果のクロージャはスタックに配置されます。
  • Call –スタックからクロージャと(単一の)引数を取ります。スタックマークを使用すると、複数の引数を実装できます。しかし、ラムダ計算を知っていれば、その必要はありません。

    関数を呼び出すには、現在の環境、コマンド、現在のスタックをダンプに保存します。次に、クロージャーからenvを新しいEnvとしてインストールし、その上に新しい空の環境をプッシュします(現在のスコープ)。引数を環境にインストールします。次に、クロージャーからのコマンドのコピーを新しいコマンドとしてインストールします。最後に、新しいスタックを作成します。

  • Return –スタックの戻り値をポップします。次に、コマンド、環境、スタックをダンプから復元します。戻り値をスタックにプッシュします。

  • スタックのブール値と2つのクロージャをポップし、条件付きで1つのクロージャを再び配置することで、条件文を実装できます。その後、そのクロージャーをCallできます。

これらの多くは、小さな演算のシーケンスとして実装できますが、上記の操作でチューリング完全プログラムを実行するには十分です。のような単純なプログラム

fun myadd(x, y) { x + y }
var a = 40
var result = myadd(a, 2)

opsに翻訳できます

Closure(2, [Var(1, 0), Var(1, 1), Add, Return]), Set(0, 0),
Const(40), Set(0, 1),
Var(0, 1), Const(2), Var(0, 0), Call, Set(0, 2)

ここで、Closureは、スタックからポップする関数の引数の数を指定する引数を取ります。

AST表現からこれらのOpにコンパイルするのは非常に簡単です。

実際のVMは、コマンドからOpsをポップし、正しい実装を呼び出し、コマンドが空になると停止するループになりました。

# Perl, using an array for Op implementation lookup
method run {
    while (@$command) {
        $self->display_state if $debug;
        $OPCODES[pop @$command]->($self);
    }
}

// Go, using the Command Pattern, and immutability:
func (secd *Secd) Step() *Secd {
        // spew internal info if verbose
        if secd.Verbose() {
                secd.Report()
        }
        // termination and returns are handled by opcodes
        // pop and evaluate
        return secd.c.Pop().(Command).evaluate(secd)
}

for !secd.Finished() {
    secd = secd.Step()

おもちゃから離れて、実際のパフォーマンスが必要な場合は、おそらくLLVMを調べる必要があります。 SECDは関数型プログラミングに近いのに対し、LLVMは金属に非常に近く、Cのようなプログラムを簡単に表現できます。少しの理論(単一の割り当てフォーム、phiノード)を理解した後、 Niceチュートリアル に従って、最も基本的な機能を示すカレイドスコープのおもちゃ言語を実装できます。パフォーマンスを確保するためにLLVMに依存する興味深い若い言語は Julia – GitHubの implementation でインスピレーションを得られます。

2
amon