Conmajia ???? ? 2012, Alan Bryan ???? ? 2012
部分设计参考了 Alan Bryon 的 B32 虚拟机,已获授权.
Updated on Feb. 19, 2018
1 虚拟机基础
这篇文章是我自制虚拟机系列文章的第一部分. 这个系列将从零开始,设计并实现一个完整可运行的虚拟机.
虚拟机(virtual machine)和模拟器(simulator)在概念上有部分重叠. 我所指的虚拟机就是软件模拟的仿真计算机.
虚拟机是一种模拟硬件环境的中间件(Middleware), 是高度隔离的软件容器,可以运行自己的操作系统和应用程序,行为完全类似于一台实际的计算机. 它包含自己的 CPU,有些甚至扩展了 RAM、硬盘和网络接口卡(NIC)等虚拟硬件.
操作系统无法分辨虚拟机与物理硬件之间的差异,应用程序和网络中的其他计算机也无法分辨. 即使是虚拟机本身也认为自己是一台真正的计算机. 不过,虚拟机完全由虚拟机软件组成,不含任何硬件组件. 因此,虚拟机具备物理硬件所没有的很多独特优势.
- 兼容性(compatibility)
- 隔离(isolation)
- 封装(packing)
- 独立于硬件(hardware independent)
2 从一款简单的 CPU 开始
虚拟机来自于对实际硬件的虚拟. 我设计了一款简化版的 16bit 处理器:SunnyApril. 因为 CPU 位宽只有 16bit,所以针对它设计的虚拟机寻址空间为 0x0000
~0xffff
. 接下来为 SunnyApril 添加寄存器(register). 寄存器是具有有限存贮容量(通常是 1、2 字节)的高速存储部件,用来暂存指令、数据或者地址. 简单来说,寄存器就是 CPU 内部的内存.
为了简单,我只设计了 5 个寄存器,分别是 A
、B
、D
、X
和 Y
. A
、B
寄存器是 8 位寄存器,可以保存 0x00
~0xff
的无符号数或是 0x80
~0x7f
的有符号数. X
、Y
和 D
寄存器都是 16 位的,可以保存 0x0000
~0xffff
的无符号数或是 0x8000
~0x7fff
的有符号数. 同样是为了设计简便,只考虑无符号数的情况,有符号数将在后面研究浮点数的时候一起进行.
D
寄存器是一个特殊的 16 位寄存器. 它的值是由 A
、B
寄存器的值合并而成,A
保存了 D
的高 8 位值,B
保存了低 8 位值. 例如 A
寄存器值为 0x3c
,B
寄存器值为 0x10
,则 D
寄存器值为 0x3c10
. 反之,如果修改 D
寄存器值为 0x07c0
,则 A
寄存器值变为 0x07
,B
寄存器值变为 0xc0
.
图 1 说明了 SunnyApril 的寄存器关系.
图 1 寄存器布局
为了让虚拟机能在第一时间反馈运行结果,我从 64KB 的内存空间中留出 4000 字节的空间(0xa000
~0xafa0
)作为临时显示器的缓存. 模仿 DOS 命令行的显示风格,我用其中 2000 字节用于保存显示字符(这样可以得到 80×25 的字符屏幕),2000 字节用于保存每个字符的样式. 样式字节低 3 位分别表示前景色的红、绿、蓝颜色值,第 4 位表示明暗度,5~7 位用于表示背景颜色. 样式字节的最高位本来是表示是否闪烁字符,但在我的设计中不需要这个功能,所以直接忽略.
接下来的工作就是设计能让虚拟机运行起来的指令集(instruction set)和字节码(binary code). 指令集和 SunnyApril 的汇编语言一起设计,简便起见,先设计 4 个指令,如表 1 所示.
表 1 SunnyApril CPU 指令集(部分)
指令 | 字节码 | 功能 | 示例 | 运行结果 |
---|---|---|---|---|
LDA |
0x01 |
将数据存入 A 寄存器 |
LDA #41H |
A=0x41 |
LDX |
0x02 |
将数据存入 X 寄存器 |
LDX #1000H |
X=0x1000 |
STA |
0x03 |
将 A 寄存器的值存入操作数指定的内存地址 |
STA X |
[0x1000]=0x41 |
END |
0x04 |
结束程序,并标记起始标签 | END START |
无 |
以 LDA
指令为例,这个指令用于将操作数(operand)存入 A
寄存器. 由于操作数寻址方式太多,这里简单地用 #
符号起头,表示立即数(类似 51 单片机的汇编语言). 以 H
结尾的数字表示为十六进制,类似的有 O
(八进制)、B
(二进制)和 D
(十进制,可以省略).
END
指令表示程序结束. 它的操作数称为标签,表示程序的起始标签,用于标注程序运行的开始位置. 标签是以字母开头,半角冒号结尾的单行字符串,例如:
程序 1 标签
START:
接下来设计编译后的二进制文件格式. 大部分编译器的二进制文件格式都是以一串魔术字字符串开头的. 例如,DOS/Windows 中的 PE 文件用 MZ
开头,Java 二进制文件用 4 字节的数字 3405691582 开始,用 16 进制表示就是 0xCAFEBABE
(咖啡宝贝). SunnyApril 使用 CONMAJIA
作为魔术字,之后是文件体偏移量,表示文件体(即程序字节码)在文件中的起始位置,接着是文件体长度. 执行地址表示字节码执行起始地址,固定为 0. 偏移段用于保存额外的数据或者中断向量表等,其长度为 \(\Delta-13\) 字节. 文件头结构参见表 2.
表 2 SunnyApril 文件头(单位:字节)
魔术字 | \(\Delta\) | 程序长度 | 执行地址 | 偏移段 |
---|---|---|---|---|
7 | 2 | 2 | 2 | [\(\Delta-13\)] |
文件体紧跟于头部,保存了程序编译后的全部二进制代码.
3 汇编器(Assembler)
现在可以设计汇编器了. 汇编器将编写的汇编源程序编译后输出到可以供虚拟机运行的二进制字节码文件中(即可执行文件).
汇编文件格式如下:
[标签:]
<指令> \(\sqcup\) <操作数>
方括号中的内容是可选的,\(\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 实现汇编器
汇编器(assembler)界面如图 2 所示,它实际上是编译器(compiler)和连接器(linker)的集合体.
图 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 采用小端模式(低位在前),所以在文件中是以 00 A0
的形式存储的.
第四行 STA
指令,存入字节码 0x03
,然后存入 Registers.X
枚举值(16,即 0x10`).
第五行 END
指令,存入字节码 0x04
,然后存入 START
标签地址 0x0200
(2 字节,仍以小端模式存储).
至此,可以判断,这个 SunnyApril 汇编器符合设计预期.
4 接下来的工作
在下一章中,我将开始设计 SunnyApril CPU 的其他部分.
The End. \(\Box\)