🖥️ 自制虚拟机 - 概念和汇编器
Conmajia 🇨🇳 © 2012
Alan Bryan 🇺🇸 © 2012
共同完成
Updated on Feb. 19, 2018
1 虚拟机基础
这篇文章是我自制虚拟机系列文章的第一部分. 这个系列将从零开始,设计并实现一个完整可运行的虚拟机.
虚拟机和模拟器在概念上有重叠,我指的是软件模拟的仿真计算机.
虚拟机是一种模拟硬件环境的中间件,是高度隔离的软件容器,可以运行自己的操作系统和应用程序,行为完全类似于一台实际的计算机. 它包含自己的 CPU,有些甚至扩展了 RAM、硬盘和网卡等虚拟硬件. 操作系统无法分辨虚拟机与物理硬件之间的差异,应用程序和网络中的其他计算机也无法分辨. 即使是虚拟机本身也认为自己是一台真正的计算机. 不过,虚拟机完全由虚拟机软件组成,不含任何硬件组件. 因此,虚拟机具备物理硬件所没有的很多独特优势.
- 兼容性
- 隔离
- 封装
- 独立于硬件
2 从一款简单的 CPU 开始
虚拟机来自于对实际硬件的虚拟. 我使用了 SunnyApril 处理器. 这是一款虚构的 16bit 处理器,它的寻址空间为 0x0000
~0xffff
.
接下来为 SunnyApril 添加寄存器. 寄存器是具有有限存贮容量(通常是 1、2 字节)的高速存储部件,用来暂存指令、数据或者地址. 简单来说,寄存器可以理解为处理器内部的内存.
商业微处理器内部往往包含数十至数百个寄存器,但在 SunnyApril 中,我只设计了 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 说明了寄存器之间的关系.
为了让虚拟机能在第一时间反馈运行结果,我从 64KB 的内存空间中留出 4000 字节的空间(0xa000
~0xafa0
)作为临时显示器的缓存. 模仿 DOS 命令行的显示风格,我用其中 2000 字节用于保存显示字符(这样可以得到 80$\times$25 的字符屏幕),2000 字节用于保存每个字符的样式. 样式字节低 3 位分别表示前景色的红、绿、蓝颜色值,第 4 位表示明暗度,5~7 位用于表示背景颜色. 样式字节的最高位本来是表示是否闪烁字符,但在我的设计中不需要这个功能,所以直接忽略.
接下来的工作就是设计能让虚拟机运行起来的指令集和字节码(也叫机器码). 指令集和 SunnyApril 的汇编语言一起设计,简便起见,先设计 4 个指令,如表 1 所示. 表中 Rn 表示寄存器 n
(Register n).
指令 | 字节码 | 操作数 | 功能 | 操作 | 示例 | 运行结果 |
---|---|---|---|---|---|---|
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
指令为例,这个指令用于将操作数存入 A
寄存器. 由于操作数寻址方式太多,这里简单地用 #
符号起头,表示立即数寻址. 以 H
结尾的数字表示十六进制,类似的有 O
(八进制)、B
(二进制)和 D
(十进制,可以省略).
END
指令标记程序结束. 它的操作数称为标签,表示程序的起始标签,用于标注程序运行的跳转位置. 标签是以字母开头,半角冒号结尾的单行字符串,例如:
程序 1 标签
LABEL:
接下来设计编译后的二进制文件格式. 大部分编译器的二进制文件格式都是以一串魔术字字符串开头的. 例如,DOS/Windows 中的 PE 文件用 MZ
开头. Java 二进制文件用 4 字节的数字 3405691582 开头,写成 16 进制就是 0xCAFEBABE
(cafe babe). SunnyApril 使用 CONMAJIA
作为魔术字,文件头结构参见表 2.
魔术字 | \(\Delta\) | 程序长度 | 执行地址 | 偏移段 |
---|---|---|---|---|
7 | 2 | 2 | 2 | [\(\Delta-13\)] |
文件体紧跟于头部,保存了程序编译后的全部二进制代码.
3 汇编器
汇编器将编写的汇编源程序编译后输出到可以供虚拟机运行的二进制字节码文件中(即可执行文件). 在这篇文章里,它实际上是编译器和连接器的集合. 典型的汇编语句格式如下:
[标签:]
<指令> <操作数>
方括号中的内容是可选的,\(\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 所示:
寄存器枚举
程序 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 }
其中用到了读取字节操作数的内部方法,如下所示. 稍作改进可以很方便地支持多种数制. 读取字操作数的方法与此类似.
程序 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
二进制字节码文件,内容如下:
汇编器正确计算了文件大小,从 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\)
if(jQuery('#no-reward').text() == 'true') jQuery('.bottom-reward').addClass('hidden');