65.9K
CodeProject 正在变化。 阅读更多。
Home

如何创建自己的虚拟机

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (75投票s)

2009年10月20日

CPOL

10分钟阅读

viewsIcon

234372

downloadIcon

7712

本文将带您逐步创建自己的虚拟机。

 

注意事项

我正在为此开发一个新版本,一个 B33 虚拟机。完成后我会在上面发布。这个版本将没有 PDF,完整教程将包含在代码项目文章中。完成后我会通知大家! 

介绍 

您是否曾经想过 Microsoft .NET Framework 或 Java 虚拟机是如何工作的?您是否曾经想过如何创建自己的模拟器来针对您拥有的特定计算机?如果您对这些问题的任何一个回答是“是”,那么您会发现我的教程很有帮助。B32 是一个用 C# 创建的完整虚拟机。这是 2 部分系列的第一部分。此处 CodeProject 上提供的教程是完整 PDF 教程的精简版。在本教程部分,我将解释基础知识,足够我们开始,我们将编写虚拟机汇编器的部分。第 2 部分将构建实际的虚拟机。本教程从一个新项目和解决方案开始。它将教您如何编写汇编器来处理我们的 B32 中间语言代码,以及如何编写虚拟机本身。

目标受众

本教程假定您非常熟悉 C# 和 Visual Studio 2005 或 2008。本教程是用 Visual Studio 2008 编写的,目标是 .NET Framework 2.0。它也应该适用于 Visual Studio 2005。本教程中介绍的一些概念对于初学者 C# 程序员来说可能有点难以理解。我已经尽量使其易于理解。请记住,阅读本教程后,没有人能立即准备好构建下一个 Java 或类似的产品。虚拟机是非常复杂的任务,即使是简单的虚拟机也可能需要很多年才能完成。如果您需要帮助或有疑问,欢迎在此处发帖或直接发送电子邮件至 icemanind@yahoo.com。如果我让您感到困惑,我建议您阅读 PDF 并将其用作补充本文的参考。PDF 包含更多详细信息。

虚拟机基础知识

要构建虚拟机,您首先需要了解什么是虚拟机。虚拟机是对物理机或虚拟机的软件实现。要创建虚拟机,我们首先需要定义一种中间语言(有时称为汇编语言)。这种中间语言将是最低级的、人类可读的代码,最终将被汇编成字节码。字节码包含一个或多个字节的数据,这些数据只能被我们的虚拟机读取(在现实世界的机器中,这被称为“机器语言”)。“汇编器”,它将中间语言转换为字节码,将是我们首先开发的工具。这将在本教程的这一部分中进行开发。

规划我们的虚拟机

在动手编写任何代码之前,我们需要规划我们的虚拟机及其工作方式。我相信尽可能坚持 KISS(保持简单愚蠢)原则。B32,本教程中创建的虚拟机,将是一个 16 位机器。它将拥有 64K 内存的访问权限,B32 字节码程序将能够访问此内存中的任何地址,范围从 $0000 - $FFFF。与当今的现代计算机相比,64K 可能看起来不多;但是,正如您将看到的,64K 足以生成一些有趣的 B32 程序。

我说程序将可以访问 64K 内存;但是,并非所有这些都将用于存储。从 $A000 - $AFA0,这 4K 内存区域将用于我们的 B32 屏幕输出。我们虚拟机中的屏幕输出将与旧的 DOS 程序非常相似。在 80x25 屏幕上,每个字符将有一个字节用于字符,一个字节用于属性。属性字节跟在字符字节之后,它定义了字符的前景和背景颜色。

我们的虚拟机将有两个 8 位寄存器,称为“A”和“B”。寄存器在编程中类似于变量。我们可以将 0 到 255 之间的任何数字存储在寄存器“A”、“B”或两者中。此外,我们还将有两个 16 位寄存器,称为“X”和“Y”。这些寄存器可以保存 0 到 65,535 之间的任何值。好像还不够,我们还将有一个 16 位寄存器,称为“D”。这个寄存器很独特,“D”代表寄存器“A”和“B”的高位和低位。换句话说,如果我们向 D 寄存器存储值 $12F9,那么它将自动将寄存器 A 的值更改为 $12,将寄存器 B 的值更改为 $F9。反之亦然。如果我们向寄存器 A 存储 $C1,向寄存器 B 存储 $78,那么寄存器 D 的值将是 $C178。

在深入创建我们的汇编器之前,我们需要定义至少三个助记符才能创建任何有用的程序。顺便说一句,助记符是我们中间语言的构建块。它们是人类可读的字符,通常为 3-5 个字符长,告诉汇编器我们想要做什么;例如,“动词”。大多数(但不是全部)助记符都有一个操作数。操作数是一些数据或信息供助记符处理。目前,我们的汇编器将理解以下助记符

助记符 字节码 描述
LDA $01 将 8 位值加载到我们的“A”寄存器
LDX $02 将 16 位值加载到我们的“X”寄存器
STA $03 将加载到“A”寄存器中的字节值存储到“X”寄存器指向的地址
END $04 终止程序的执行。

如果您不清楚“STA”实际上做了什么,可以这样理解。如果“X”寄存器包含 $1234,“A”寄存器包含 $9F,那么执行“STA”将在内存地址 $1234 存储值 $9F。其余的助记符应该很容易理解。

这是我们的第一个 B32 汇编测试文件,一旦完成,它将与我们的汇编器一起编译

START:
 LDA #65
 LDX #$A000
 STA ,X
 END START

您可能会想井号“#”是什么意思?在我们的 B32 中间语言中,“#”将始终表示“使用此数据”,或者更确切地说,使用紧跟在井号后面的值。我们程序的第一行称为标签。标签是我们程序中人类可读的参考点。标签必须以字母开头,以冒号“:”结尾,后跟一个新行。为了简单起见,我们的汇编器将没有任何错误检查,所以我们必须遵守一些规则。所有助记符**必须**前面有一个或多个空格。这是我们的汇编器如何知道文本是标签还是助记符。标签显然不应该有前导空格。第二行将值 65 分配给我们的“A”寄存器。第三行将十六进制数 $A000 分配给我们的“X”寄存器。第四行表示将“A”寄存器的值存储到我们的“X”寄存器指向的内存地址。换句话说,在 $A000 存储 65。最后一行指示程序结束。“END”之前的“START”告诉汇编器此程序的执行应从何处开始。在本例中,它从“START”标签开始,这是我们程序的开头。

您可能已经猜到这会做什么。请记住,我们之前说过我们的屏幕将从 $A000 开始?这个程序应该会在 B32 屏幕的左上角放置一个大写字母“A”(65 是字母“A”的 ASCII 值)。我知道这很无聊。但它将展示我们的汇编器和虚拟机的工作原理。

在开始构建汇编器之前,我们需要定义的最后一件事是 B32 二进制文件格式。我们的文件格式应该以一个包含一些魔术数字的头部开始,这样我们的虚拟机就能知道我们的程序是否是有效的 B32 二进制文件。我们的 B32 二进制文件将以字母“B32”开头。这将是我们的魔术数字。之后将是 16 位的起始地址。这定义了我们的程序将在 64K 内存中的哪个位置加载。我们的汇编器将默认我们的原点为 $1000,但我们可以将其更改为任何值。接下来是 16 位的执行地址。这是我们的二进制程序将开始执行的地方。最后,将是我们的字节码。

构建我们的汇编器

您可以从上面的链接下载汇编器和虚拟机的源代码。请记住,源代码是根据 PDF 教程构建的。因此,我们的汇编器将比本节中解释的功能和助记符多得多。我将只解释重要的部分,而不是分步解释构建汇编器的过程。如果您想要分步指南,请阅读上面附加的 PDF 文件。

汇编器的工作原理是首先将源文件读取到字符串缓冲区中,然后打开一个 BinaryWriter 流,指向我们的输出 B32 二进制程序

private void btnAssemble_Click(object sender, EventArgs e)
{
    AsLength = Convert.ToUInt16(this.txtOrigin.Text, 16);
    System.IO.BinaryWriter output;
    System.IO.TextReader input;
    System.IO.FileStream fs = new System.IO.FileStream(
        this.txtOutputFileName.Text, System.IO.FileMode.Create);

    output = new System.IO.BinaryWriter(fs);

    input = System.IO.File.OpenText(this.txtSourceFileName.Text);
    SourceProgram = input.ReadToEnd();
    input.Close();

    output.Write('B');
    output.Write('3');
    output.Write('2');
    output.Write(Convert.ToUInt16(this.txtOrigin.Text, 16));
    output.Write((ushort)0);
    Parse(output);

    output.Seek(5, System.IO.SeekOrigin.Begin);
    output.Write(ExecutionAddress);
    output.Close();
    fs.Close();
    MessageBox.Show("Done!");
}

注意,我们写入了 B32 魔术字节,然后是原点(或起始地址),然后是一个 16 位零。这个零稍后将被执行地址替换。目前,我们无法知道它将是什么。调用 Parse(),这是我们大部分工作所在的地方。完成后,我们后退到之前放置的零并用执行地址替换它。最后,我们关闭流并显示“完成!”框。

我们的汇编器分两个阶段工作。第一阶段只是扫描汇编文件中的所有标签,并将这些标签存储在一个哈希表中。第一阶段对于引用未来标签至关重要。第二阶段完成将助记符转换为字节码的繁重工作。这是我们的 Parse() 函数

private void Parse(System.IO.BinaryWriter OutputFile)
{
    CurrentNdx = 0;
    while (IsEnd == false)
        LabelScan(OutputFile, true);

    IsEnd = false;
    CurrentNdx = 0;
    AsLength = Convert.ToUInt16(this.txtOrigin.Text, 16);

    while (IsEnd == false)
        LabelScan(OutputFile, false);
}

此函数只是进入一个循环,调用另一个名为 LabelScan 的函数。第一个循环是第一阶段。重置索引后,它进入第二个循环。这是我们的第二阶段。所有 LabelScan() 所做的工作是检测第一个字符是否为空格。如果是,它假定它不是标签,并读取助记符。这个助记符被传递给另一个函数,该函数使用一系列 'if' 语句来处理助记符,调用相应的函数来处理助记符

private void ReadMneumonic(System.IO.BinaryWriter OutputFile, bool IsLabelScan)
{
    string Mneumonic = "";

    while (!(char.IsWhiteSpace(SourceProgram[CurrentNdx])))
    {
        Mneumonic = Mneumonic + SourceProgram[CurrentNdx];
        CurrentNdx++;
    }
    if (Mneumonic.ToUpper() == "LDX") InterpretLDX(OutputFile, IsLabelScan);
    if (Mneumonic.ToUpper() == "LDA") InterpretLDA(OutputFile, IsLabelScan);
    if (Mneumonic.ToUpper() == "STA") InterpretSTA(OutputFile, IsLabelScan);
    if (Mneumonic.ToUpper() == "END")
    { IsEnd = true; DoEnd(OutputFile, IsLabelScan); EatWhiteSpaces(); 
      ExecutionAddress = (ushort)LabelTable[(GetLabelName())]; return; }

}

每个 Interpret() 函数的做的事情几乎都一样。它们读取操作数(如果存在),然后将相应的字节码写入输出文件。

如果您从上面的链接下载了汇编器,请尝试编译我们的测试程序。记住在每个助记符前面加上至少一个空格(仅助记符,标签除外)。另外,请记住在最后一行以新行结束。牢记这两条规则,测试程序应该可以编译并生成一个 .B32 二进制文件。

如果您想了解更多关于编译器工作原理的细节,请参考 PDF 教程。它包含更深入的内容。

历史

这是我的第一份 PDF 教程。我想稍微试探一下,看看人们对我的写作和教程技能的反应。这只是虚拟机教程的第一部分。第 2 部分将介绍如何构建实际的虚拟机。请留下反馈,无论是好的还是坏的,并告诉我这是否对您有所帮助。

© . All rights reserved.