🖥️ 自制虚拟机 - 概念和汇编器

Conmajia 🇨🇳 © 2012
Alan Bryan 🇺🇸 © 2012
共同完成
Updated on Feb. 19, 2018

1 虚拟机基础

这篇文章是我自制虚拟机virtual machine系列文章的第一部分. 这个系列将从零开始,设计并实现一个完整可运行的虚拟机.

虚拟机和模拟器simulator在概念上有重叠,我指的是软件模拟的仿真计算机.

虚拟机是一种模拟硬件环境的中间件middleware,是高度隔离的软件容器,可以运行自己的操作系统和应用程序,行为完全类似于一台实际的计算机. 它包含自己的 CPU,有些甚至扩展了 RAM、硬盘和网卡等虚拟硬件. 操作系统无法分辨虚拟机与物理硬件之间的差异,应用程序和网络中的其他计算机也无法分辨. 即使是虚拟机本身也认为自己是一台真正的计算机. 不过,虚拟机完全由虚拟机软件组成,不含任何硬件组件. 因此,虚拟机具备物理硬件所没有的很多独特优势.

  1. 兼容性compatibility
  2. 隔离isolation
  3. 封装packing
  4. 独立于硬件hardware independent

2 从一款简单的 CPU 开始

虚拟机来自于对实际硬件的虚拟. 我使用了 SunnyApril 处理器. 这是一款虚构的 16bit 处理器,它的寻址空间为 0x0000~0xffff.

接下来为 SunnyApril 添加寄存器register. 寄存器是具有有限存贮容量(通常是 1、2 字节)的高速存储部件,用来暂存指令、数据或者地址. 简单来说,寄存器可以理解为处理器内部的内存.

商业微处理器内部往往包含数十至数百个寄存器,但在 SunnyApril 中,我只设计了 5 个:ABDXY. AB 寄存器是 8 位寄存器,可以保存 0x00~0xff 的无符号数或是 0x80~0x7f 的有符号数. XYD 寄存器都是 16 位的,可以保存 0x0000~0xffff 的无符号数或是 0x8000~0x7fff 的有符号数. 同样是为了设计简便,只考虑无符号数的情况,有符号数将在后面研究浮点数的时候一起进行.

D 寄存器是一个特殊的 16 位寄存器. 它的值是由 AB 寄存器的值合并而成,A 保存了 D 的高 8 位值,B 保存了低 8 位值. 例如 A 寄存器值为 0x3cB 寄存器值为 0x10,则 D 寄存器值为 0x3c10. 反之,如果修改 D 寄存器值为 0x07c0,则 A 寄存器值变为 0x07B 寄存器值变为 0xc0. 图 1 说明了寄存器之间的关系.

图 1 SunnyApril 寄存器布局

为了让虚拟机能在第一时间反馈运行结果,我从 64KB 的内存空间中留出 4000 字节的空间(0xa000~0xafa0)作为临时显示器的缓存. 模仿 DOS 命令行的显示风格,我用其中 2000 字节用于保存显示字符(这样可以得到 80$\times$25 的字符屏幕),2000 字节用于保存每个字符的样式. 样式字节低 3 位分别表示前景色的红、绿、蓝颜色值,第 4 位表示明暗度,5~7 位用于表示背景颜色. 样式字节的最高位本来是表示是否闪烁字符,但在我的设计中不需要这个功能,所以直接忽略.

接下来的工作就是设计能让虚拟机运行起来的指令集instruction set字节码binary code(也叫机器码machine code). 指令集和 SunnyApril 的汇编语言一起设计,简便起见,先设计 4 个指令,如表 1 所示. 表中 Rn 表示寄存器 n(Register n).

表 1 SunnyApril 指令集(部分)
指令 字节码 操作数 功能 操作 示例 运行结果
LDA 0x01 Ra, K 将数据存入 A 寄存器 Ra\(\leftarrow\)K LDA #41H A=0x41
LDX 0x02 Rx, K 将数据存入 X 寄存器 Rx\(\leftarrow\)K LDX #1000H X=0x1000
STA 0x03 Ra, Rx A 寄存器的值存入操作数指定的内存地址 (Rx)\(\leftarrow\)Ra STA X [0x1000]=0x41
END 0x04 结束程序,并标记起始标签 END LABEL

LDA 指令为例,这个指令用于将操作数operand存入 A 寄存器. 由于操作数寻址方式太多,这里简单地用 # 符号起头,表示立即数寻址. 以 H 结尾的数字表示十六进制,类似的有 O(八进制)、B(二进制)和 D(十进制,可以省略).

END 指令标记程序结束. 它的操作数称为标签,表示程序的起始标签,用于标注程序运行的跳转位置. 标签是以字母开头,半角冒号结尾的单行字符串,例如:

程序 1 标签

LABEL:

接下来设计编译后的二进制文件格式. 大部分编译器的二进制文件格式都是以一串魔术字magic word字符串开头的. 例如,DOS/Windows 中的 PE 文件用 MZ 开头. Java 二进制文件用 4 字节的数字 3405691582 开头,写成 16 进制就是 0xCAFEBABE(cafe babe). SunnyApril 使用 CONMAJIA 作为魔术字,文件头结构参见表 2.

表 2 SunnyApril 文件头(单位:字节)
魔术字 \(\Delta\) 程序长度 执行地址 偏移段
7 2 2 2 [\(\Delta-13\)]

文件体紧跟于头部,保存了程序编译后的全部二进制代码.

3 汇编器

汇编器assembler将编写的汇编源程序编译后输出到可以供虚拟机运行的二进制字节码文件中(即可执行文件). 在这篇文章里,它实际上是编译器compiler连接器linker的集合. 典型的汇编语句格式如下:

[标签:]
<指令> <操作数>

方括号中的内容是可选的,\(\sqcup\) 表示一个空格字符.

下面是一个例子:

程序 2 示例源代码

1 START:
2 LDA #65
3 LDX #A000H
4 STA X
5 END START

这个程序的功能是把字符“A”输出到屏幕的左上角.

第一行代码定义了 START 标签. 第二行将立即数 65(即 ASCII 字母 A)存入 A 寄存器. 第三行将立即数 0xa000(即显存的起始地址)存入 X 寄存器. 第四行代码将 A 寄存器中的值存入 X 寄存器中的数值指向的显存地址. 最后用 END 指令结束程序.

3.1 实现汇编器

汇编器界面如图 2 所示:

图 2 SunnyApril 汇编器

寄存器枚举

程序 3 Registers 枚举

1 enum Registers
2 {
3     Unknown = 0,
4     A = 4,
5     B = 2,
6     D = 1,
7     X = 16,
8     Y = 8
9 }

汇编器的核心代码

程序 4 SunnyApril 汇编器图形界面代码

 1 if (textBox1.Text == string.Empty)
 2     return;
 3 
 4 labelDict.Clear();
 5 binaryLength = (UInt16)numericUpDown1.Value;
 6 
 7 FileInfo fi = new FileInfo(textBox1.Text);
 8 
 9 BinaryWriter output;
10 FileStream fs = new FileStream(
11     Path.Combine(
12     fi.DirectoryName,
13     fi.Name + ".sab"),
14     FileMode.Create
15     );
16 output = new BinaryWriter(fs);
17 
18 // magic word
19 output.Write('C');
20 output.Write('O');
21 output.Write('N');
22 output.Write('M');
23 output.Write('A');
24 output.Write('J');
25 output.Write('I');
26 output.Write('A');
27 
28 // org
29 output.Write((UInt16)numericUpDown1.Value);
30 
31 // scan to ORG and start writing byte-code
32 output.Seek((int)numericUpDown1.Value, SeekOrigin.Begin);
33 
34 // parse source code line-by-line
35 TextReader input = File.OpenText(textBox1.Text);
36 string line;
37 while ((line = input.ReadLine()) != null)
38 {
39     parse(line.ToUpper(), output);
40     dealedSize += line.Length;
41     Invoker.Set(progressBar1, "Value", (int)((float)dealedSize / (float)totalSize * 100));
42 }
43 input.Close();
44 
45 // binary length & execution address (7 magic-word, 2 org before)
46 output.Seek(10, SeekOrigin.Begin);
47 output.Write(binaryLength);
48 output.Write(executionAddress);
49 output.Close();
50 fs.Close();

源代码解析器

parse() 函数用于对源代码逐行解析,主要代码如下:

程序 5 parse() 函数代码

 1 private void parse(string line, BinaryWriter output)
 2 {
 3     // eat white spaces and comments
 4     line = cleanLine(line);
 5     if (line.EndsWith(":"))
 6         // label
 7         labelDict.Add(line.TrimEnd(new char[] { ':' }), binaryLength);
 8     else
 9     {
10         // code
11         Match m = Regex.Match(line, @"(\w+)\s(.+)");
12         string opcode = m.Groups[1].Value;
13         string operand = m.Groups[2].Value;
14 
15         switch (opcode)
16         {
17             case "LDA":
18                 output.Write((byte)0x01);
19                 output.Write(getByteValue(operand));
20                 binaryLength += 2;
21                 break;
22             case "LDX":
23                 output.Write((byte)0x02);
24                 output.Write(getWordValue(operand));
25                 binaryLength += 3;
26                 break;
27             case "STA":
28                 output.Write((byte)0x03);
29                 // NOTE: No error handling.
30                 Registers r = (Registers)Enum.Parse(typeof(Registers), operand);
31                 output.Write((byte)r);
32                 binaryLength += 2;
33                 break;
34             case "END":
35                 output.Write((byte)0x04);
36                 if (labelDict.ContainsKey(operand))
37                 {
38                     output.Write(labelDict[operand]);
39                     binaryLength += 2;
40                 }
41                 binaryLength += 1;
42                 break;
43             default:
44                 break;
45         }
46     }
47 }

其中用到了读取字节byte操作数的内部方法,如下所示. 稍作改进可以很方便地支持多种数制. 读取word操作数的方法与此类似.

程序 6 读取字节函数代码

 1 private byte getByteValue(string operand)
 2 {
 3     byte ret = 0;
 4     if (operand.StartsWith("#"))
 5     {
 6         operand = operand.Remove(0, 1);
 7         char last = operand[operand.Length - 1];
 8         if (char.IsLetter(last))
 9             switch (last)
10             {
11                 case 'H':
12                     // hex
13                     ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 16);
14                     break;
15                 case 'O':
16                     // oct
17                     ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 8);
18                     break;
19                 case 'B':
20                     // bin
21                     ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 2);
22                     break;
23                 case 'D':
24                     // dec
25                     ret = Convert.ToByte(operand.Remove(operand.Length - 1, 1), 10);
26                     break;
27             }
28         else
29             ret = byte.Parse(operand);
30     }
31 
32     return ret;
33 }

3.2 运行结果

运行汇编器,对前面保存的 demo1.asm 文件进行汇编,得到 demo1.sab 二进制字节码文件,内容如下:

图 3 demo1.sab 可执行文件内容

汇编器正确计算了文件大小,从 0x0200 位置处开始,汇编出的字节码为 01 00 02 00 00 03 10 04 00 02.

3.3 验证

下面根据程序 2 的源代码,逐行验证上述汇编器工作情况.

第一行为 START 标签,将地址 0x0200 存入缓存(在文件中没有体现).

第二行 LDA 指令,存入字节码 0x01,然后存入单字节操作数(A 寄存器是 8 位寄存器)65,即 0x41.

第三行 LDX 指令,存入字节码 0x02,然后存入双字节操作数(X 寄存器是 16 位寄存器)0xa000. 由于 SunnyApril 采用低位在前小端模式little-endian,所以在文件中是以 00 A0 的形式存储的.

第四行 STA 指令,存入字节码 0x03,然后存入 Registers.X 枚举值(16,即 0x10`).

第五行 END 指令,存入字节码 0x04,然后存入 START 标签地址 0x0200(2 字节,仍以小端模式存储).

至此,可以判断,这个 SunnyApril 汇编器符合设计预期.

4 接下来的工作

在下一章中,我将开始设计 SunnyApril CPU 的其他部分.

The End. \(\Box\)

posted @ 2018-02-21 17:36  Conmajia  阅读(...)  评论(...编辑  收藏