程序员人生 网站导航

自制虚拟机系列第一部分:构思及汇编器

栏目:框架设计时间:2016-07-11 08:16:09

© Conmajia & icemanind 2012

本文根据How to Create Your Own Virtual Machine系列文章编译,并进行了大量改造(已征得作者同意)。


浏览:上篇、下篇
下载:源代码、英文教程(PDF)


叙言

By Conmajia

各位,你们正在浏览的这个系列的文章将从零开始,带你1步1步设计并实现1个完全可运行的虚拟机(Virtual Machine)。我们将要使用C#语言,基于Microsoft .NET Framework 2.0运行库来完成全部虚拟机的制作(出于兼容性斟酌,也是为了将主要精力集中在设计上)。因此,你需要具有最基本的.NET程序开发知识。也就是说,最少你应当会使用Visual Studio 2005(或更高版本),并且能成功运行自己的「Hello World」程序。
在开始设计前,让我们先来了解1下虚拟机的相干知识。
虚拟机是1种摹拟硬件环境的中间件(Middleware),是1种高度隔离的软件容器,它可以运行自己的操作系统和利用程序,就好像它是1台物理计算机1样。虚拟机的行动完全类似于1台物理计算机,它包括自己的虚拟(即基于软件实现的)CPU,有些乃至扩大了RAM、硬盘和网络接口卡(NIC)等虚拟硬件。
操作系统没法分辨虚拟机与物理机之间的差异,利用程序和网络中的其他计算机也没法分辨。即便是虚拟机本身也认为自己是1台「真实的」计算机。不过,虚拟机完全由虚拟机软件组成,不含任何硬件组件。因此,虚拟机具有物理硬件所没有的很多独特优势。

虚拟机的优势

1般而言,虚拟机具有以下4个关键特点:
1. 兼容性:虚拟机与所有标准的 x86 计算机都兼容
2. 隔离:虚拟机相互隔离,就像在物理上是分开的1样
3. 封装:虚拟机将全部计算环境封装起来
4. 独立于硬件:虚拟机独立于底层硬件运行

好了,下面就开始设计我们自己的虚拟机。

设计虚拟机

我们要为这个虚拟机绘制1个蓝图。我们给虚拟机起名为:SunnyApril(简称SA)。为了简化设计,SA被设计成1个16位的机器(这意味着她的CPU位宽是16-bit的)。这样1来,SA能够支持的地址空间就是0000H-FFFFH。现在我们为SA加入5个寄存器(Register)。寄存器是计算机硬件的1个重要概念和组件。寄存器是具有有限存贮容量(通常是1、2字节)的高速存储部件,用来暂存指令、数据或地址。几近所有的CPU和虚拟机中都包括有内建的寄存器。简单来讲,寄存器就是「CPU内部的内存」。
为了简单,我们只设计了5个寄存器,分别是ABDXYAB寄存器是8位寄存器,可以保存0-FFH的无符号数或是80H-7FH的有符号数。XYD寄存器都是16位的,可以保存0-FFFFH的无符号数或是8000H-7FFFH的有符号数。一样是为了设计简便,目前我们只斟酌无符号数的情况,有符号数将在后面研究浮点数的时候1起进行。
D寄存器是1个特殊的16位寄存器。它的值是由AB寄存器的值合并而成,A保存了D的高8位值,B保存了低8位值。例如A寄存器值为3CHB寄存器值为10H,则D寄存器值为3C10H。反之,如果修改D寄存器值为07C0H,则A寄存器值变成07HB寄存器值变成C0H
下面的图形象地说明了各寄存器的规格和之间的关系。

为了让我们的虚拟性能在第1时间「反馈」运行结果,我们从64KB的内存空间中留出4000字节的空间(A000H-AFA0H)作「显示器」缓存。我们模仿DOS下的汇编语言,用其中2000字节用于保存显示字符(这样可以得到80x25的字符屏幕),2000字节用于保存每一个字符的样式。每一个样式字节低3位分别表示前风景的红、绿、蓝色彩值,第4位表示明暗度,5⑺位一样,用于表示背景色彩。样式字节的最高位本来是表示是不是闪烁字符,但在我们的设计中不需要这个功能,所以直接疏忽。
接下来的工作就是设计能让虚拟机运行起来的指令集(即字节码)了。指令集和我们自制的「汇编语言」1起设计,简便起见,先设计4个指令,如图所示。

LDA指令(字节码01H)为例,该指令将操作数(#41H)存入A寄存器,即「Load A」。由于操作数寻址方式太多,这里简单地用「#」符号开端,表示「立即数」(模仿51单片机的汇编语言)。以「H」结尾的数字表示为16进制,类似的有「O」(8进制)、「B」(2进制)和「D」(10进制,可以省略)。
END指令(字节码04H)表示程序结束。同时它后面的「标签」表示程序的起始标签,用于标注程序运行的开始位置。标签是使用「:」半角冒号结尾的单独成行的字母开头的字符串,如START标签就这样书写:

START:

接下来是设计编译后的字节码文件格式。大部份的2进制文件格式都是以1串「魔法数字」字符串开头的。例如,DOS/Windows文件用「MZ」开头,Java2进制文件用4字节的数字3405691582开始,用16进制表示就是「CAFEBABE」(咖啡宝贝)。我们的SunnyApril就使用「CONMAJIA」作为魔法数字。魔法数字以后是文件体偏移量,表示文件体(即程序字节码)在文件中的起始位置。接着是程序长度,即文件体长度。履行地址表示字节码履行起始地址,固定为0。(后续可能会改变)偏移段用于保存额外的数据或中断向量表等,其长度为「偏移量⑴3」字节。文件头后就是文件体,保存了程序编译后的全部字节码。文件结构参见下图。

汇编器

现在我们可以开始动手设计汇编器了。这个汇编器将能够把我们写好的汇编源程序编译后写入到可以供虚拟机运行的2进制字节码文件中。汇编文件格式以下:

[标签:] <指令><空白><操作数>[空白]<换行>

其中,方括号[]中的内容是可选的。

注:以下内容和源代码经过较大幅度的改造和优化,和原文差异较大,注意区分。

这就是我们的汇编源程序:

START: LDA #65 LDX #A000H STA X END START

这个程序的功能就是简单地把字符A输出到屏幕的左上角。第1行代码定义了START标签。第2即将立即数65(即ASCII代码’A’)存入A寄存器。第3即将立即数A000H(即显示缓存的起始地址,参见设计1节)存入X寄存器。第4行代码将A寄存器中的值(65)存入X寄存器中的数值(A000H)代表的内存地址。最后用END结束程序。
下面我们运行Visual Studio,新建1个「Windows窗口利用程序」项目,选择.NET Framework版本为2.0,仿照下面的截图设计窗体。

其中,textBox1.Readonly属性设置为truenumericUpDown1.Hexadecimal属性设置为true
首先在窗体类中建立以下的变量。

Dictionary<string, UInt16> labelDict; UInt16 binaryLength; UInt16 executionAddress;

定义1个寄存器枚举。

enum Registers { Unknown = 0, A = 4, B = 2, D = 1, X = 16, Y = 8 }

在窗体的构造函数中初始化变量和控件。

public Form1() { InitializeComponent(); labelDict = new Dictionary<string, ushort>(); binaryLength = 0; executionAddress = 0; numericUpDown1.Value = 0x200; }

button1的功能是打开「文件阅读」对话框选择需要汇编的源文件。双击button1,在生成的Click事件中输入以下代码:

OpenFileDialog ofd = new OpenFileDialog(); ofd.Filter = "SunnyApril Assembly Files(*.asm)|*.asm"; ofd.DefaultExt = "asm"; ofd.FileName = string.Empty; if (ofd.ShowDialog() == System.Windows.Forms.DialogResult.OK) textBox1.Text = ofd.FileName; else textBox1.Clear();

button2功能是履行汇编,并生成2进制字节码文件,主要代码以下:

if (textBox1.Text == string.Empty) return; labelDict.Clear(); binaryLength = (UInt16)numericUpDown1.Value; FileInfo fi = new FileInfo(textBox1.Text); BinaryWriter output; FileStream fs = new FileStream( Path.Combine( fi.DirectoryName, fi.Name + ".sab"), FileMode.Create ); output = new BinaryWriter(fs); // magic word output.Write('C'); output.Write('O'); output.Write('N'); output.Write('M'); output.Write('A'); output.Write('J'); output.Write('I'); output.Write('A'); // org output.Write((UInt16)numericUpDown1.Value); // scan to ORG and start writing byte-code output.Seek((int)numericUpDown1.Value, SeekOrigin.Begin); // parse source code line-by-line TextReader input = File.OpenText(textBox1.Text); string line; while ((line = input.ReadLine()) != null) { parse(line.ToUpper(), output); dealedSize += line.Length; Invoker.Set(progressBar1, "Value", (int)((float)dealedSize / (float)totalSize * 100)); } input.Close(); // binary length & execution address (7 magic-word, 2 org before) output.Seek(10, SeekOrigin.Begin); output.Write(binaryLength); output.Write(executionAddress); output.Close(); fs.Close(); MessageBox.Show("Done!");

在这个方法中,通过1个while逐行解析源代码(原作者是全文解析),解析方法以下:

private void parse(string line, BinaryWriter output) { // eat white spaces and comments line = cleanLine(line); if (line.EndsWith(":")) // label labelDict.Add(line.TrimEnd(new char[] { ':' }), binaryLength); else { // code Match m = Regex.Match(line, @"(\w+)\s(.+)"); string opcode = m.Groups[1].Value; string operand = m.Groups[2].Value; switch (opcode) { case "LDA": output.Write((byte)0x01); output.Write(getByteValue(operand)); binaryLength += 2; break; case "LDX": output.Write((byte)0x02); output.Write(getWordValue(operand)); binaryLength += 3; break; case "STA": output.Write((byte)0x03); // NOTE: No error handling. Registers r = (Registers)Enum.Parse(typeof(Registers), operand); output.Write((byte)r); binaryLength += 2; break; case "END": output.Write((byte)0x04); if (labelDict.ContainsKey(operand)) { output.Write(labelDict[operand]); binaryLength += 2; } binaryLength += 1; break; default: break; } } }

其中用到了读取字节(byte)操作数的内部方法,以下所示。稍作改进可以很方便地支持多种数制。读取字(Word)操作数的方法与此类似,不再另作说明。

private byte getByteValue(string operand) { byte ret = 0; if (operand.StartsWith("#")) { operand = operand.Remove(0, 1); char last = operand[operand.Length - 1]; if (char.IsLetter(last)) switch (last) { case 'H': // hex ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 16); break; case 'O': // oct ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 8); break; case 'B': // bin ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 2); break; case 'D': // dec ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 10); break; } else ret = byte.Parse(operand); } return ret; }

运行汇编器,对前面保存的demo1.asm文件进行汇编,得到demo1.sab2进制字节码文件(SpringApril Binaries),该文件内容以下:

可以见到,汇编器忠实地完成了我们交代的任务,正确计算了文件大小,在0200H位置处开始,汇编出的字节码为「01 00 02 00 00 03 10 04 00 02」,下面我们对比源程序进行检验。为了便于视察,再写1遍源程序。

START: LDA #65 LDX #A000H STA X END START

第1行动START标签,将地址0200H存入缓存(在文件中没有体现)。
第2行LDA指令,存入字节码01H,然后存入单字节操作数(A寄存器是8位寄存器)65,即41H
第3行LDX指令,存入字节码02H,然后存入双字节操作数(X寄存器是16位寄存器)A000H,由于计算机采取小端模式(低位在前),所以在文件中是以「00 A0」的情势存储的。
第4行STA指令,存入字节码03H,然后存入Registers.X枚举值(16,即01H)。
第5行END指令,存入字节码04H,然后存入START标签地址0200H(2字节,仍以小端模式存储)。
根据以上分析,我们制作的汇编器完全符合设计。
下1步,我们将开始设计虚拟机,敬请期待。
欢迎各种建议意见。

(第1部份 完)

© Conmajia 2012, icemanind 2012

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐