使用C语言实现一个虚拟机

2015-6-22 21:32| 发布者: joejoe0332| 查看: 2891| 评论: 0|原作者: leoxu, Serval, 社会主义好, lostTemple, 思维特无敌, Mistoe, king_dust|来自: oschina

原文: https://www.oschina.net/translate/virtual-machine-in-c?lang=chs&page=1#
导读: 这是开源中国一篇翻译的文章,作者16岁,膜拜大神。 代码托管在github. https://github.com/felixangell/mac
摘要: GitHub 展示了我们将会构建的东西, 你也可以在发生错误的时候拿你的代码同这个资源库进行对比.我考虑过会写一篇有关使用C语言构建专属虚拟机的文章. 我喜欢研究“底层”的应用程序,比方说编译器、解释器以及虚拟机 ...

介绍

  GitHub 展示了我们将会构建的东西, 你也可以在发生错误的时候拿你的代码同这个资源库进行对比. GitHub 资源库

  我考虑过会写一篇有关使用C语言构建专属虚拟机的文章. 我喜欢研究“底层”的应用程序,比方说编译器、解释器以及虚拟机。我也爱谈论到它们。我也有另外一个系列的有关使用Go来编写一个解释器的文章(目前正在准备中)。我也在开发自己的编程语言 Alloy.

必要的准备工作及注意事项:

  在开始之前需要做以下工作:

  • 一个C编译器——我使用了 clang 3.4,也可以用其它支持 c99/c11 的编译器;

  • 文本编辑器——我建议使用基于IDE的文本编辑器,我使用 Emacs;

  • 基础编程知识——最基本的变量,流程控制,函数,数据结构等;

  • Make 脚本——能使程序更快一点。

 

为什么要写个虚拟机?

  有以下原因:

  • 想深入了解计算机工作原理。本文将帮助你了解计算机底层如何工作,虚拟机提供简洁的抽象层,这不就是一个最好的学习它们原理的方法吗?

  • 更深入了解一些编程语言是如何工作。例如,当下多种经常使用那些语言的虚拟机。包括JVM,Lua VM,FaceBook 的 Hip—Hop VM(PHP/Hack) 等。

  • 只是因为有兴趣学习虚拟机。

  我们的虚拟机具有一组寄存器,A,B,C,D,E, 和F。这些是通用寄存器,也就是说,它们可以用于存储任何东西。一个程序将会是一个只读指令序列。这个虚拟机是一个基于堆栈的虚拟机,也就是说它有一个可以让我们压入和弹出值的堆栈,同时还有少量可用的寄存器。这要比实现一个基于寄存器的虚拟机简单的多。

  言归正传,下面是我们将要实现的指令集:

?
1
2
3
4
5
6
PSH 5       ; pushes 5 to the stack
PSH 10      ; pushes 10 to the stack
ADD         ; pops two values on top of the stack, adds them pushes to stack
POP         ; pops the value on the stack, will also print it for debugging
SET A 0     ; sets register A to 0
HLT         ; stop the program

  这就是我们的指令集,注意,POP 指令将会打印我们弹出的指令,这样我们就能够看到 ADD 指令工作了。我还加入了一个 SET 指令,主要是让你理解寄存器是可以访问和写入的。你也可以自己实现像MOV A B(将A的值移动到B)这样的指令。HTL 指令是为了告诉我们程序已经运行结束。

虚拟机是如何工作的呢?

  现在我们已经到了本文最关键的部分,虚拟机比你想象的简单,它们遵循一个简单的模式:读取;解码;执行。首先,我们从指令集合或代码中读取下一条指令,然后将指令解码并执行解码后的指令。为简单起见,我们忽略了虚拟机的编码部分,典型的虚拟机将会把一个指令(操作码和它的操作数)打包成一个数字,然后再解码这个指令。

项目结构

  开始编程之前,我们需要设置好我们的项目。第一,你需要一个C编译器(我使用 clang 3.4)。还需要一个文件夹来放置我们的项目,我喜欢将我的项目放置于~/Dev:

?
1
2
3
4
$cd ~/Dev/
mkdir mac
cd mac
mkdir src

  如上,我们先 cd 进入~/Dev 目录,或者任何你想放置的位置,然后新建一个目录(我称这个虚拟机为"mac")。然后再 cd 进这个目录并新建我们 src 目录,这个目录用于放置代码。

Makefile

  makefile 相对直接,我们不需要将什么东西分成多个文件,也不用包含任何东西,所以我们只需要用一些标志来编译文件:

?
1
2
3
4
5
6
SRC_FILES = main.c
CC_FLAGS = -Wall -Wextra -g -std=c11
CC = clang
all:
    ${CC} ${SRC_FILES} ${CC_FLAGS} -o mac

  这对目前来说已经足够了,你以后还可以改进它,但是只要它能完成这个工作,我们应该满足了。

指令编程(代码)

  现在开始写虚拟机的代码了。第一,我们需要定义程序的指令。为此,我们可以使用一个枚举类型enum,因为我们的指令基本上是从0到X的数字。事实上,可以说你是在组装一个汇编文件,它会使用像 mov 这样的词,然后翻译成声明的指令。 
我们可以只写一个指令文件,例如 PSH, 5 是0, 5,但是这样并不易读,所以我们使用枚举器!

?
1
2
3
4
5
6
7
typedef enum {
   PSH,
   ADD,
   POP,
   SET,
   HLT
} InstructionSet;

  现在我们可以将一个测试程序存储为一个数组。我们写一个简单的程序用于测试:将5和6相加,然后将他们打印出来(用POP指令)。如果你愿意,你可以定义一个指令将栈顶的值打印出来。

  指令应该存储成一个数组,我将在文档的顶部定义它;但你或许会将它放在一个头文件中,下面是我们的测试程序:

?
1
2
3
4
5
6
7
const int program[] = {
    PSH, 5,
    PSH, 6,
    ADD,
    POP,
    HLT
};

  上面的程序将会把5和6压入栈,调用 ADD 指令,这将会把栈顶的两个值弹出,相加后将结果压回栈中,接下来我们弹出结果,因为 POP 指令将会打印这个值,但是你不必自己再做了,我已经做好并测试过了。最后,HLT 指令结束程序。

  很好,这样我们有了自己的程序。现在我们实现了虚拟机的读取,解码,求值的模式。但是要记住,我们没有解码任何东西,因为我们给出的是原始指令。也就是说我们只需要关注读取和求值!我们可以将它们简化成两个函数 fetch 和 evaluate。

取得当前指令

  因为我们已经将我们的程序存成了一个数组,所以很简单的就可以取得当前指令。一个虚拟机有一个计数器,一般来说叫做程序计数器,指令指针等等,这些名字是一个意思取决于你的个人喜好。在虚拟机的代码库里,IP 或 PC 这样的简写形式也随处可见。

  如果你之前有记得,我说过我们要把程序计数器以寄存器的形式存储...我们将那么做——在以后。现在,我们只是在我们代码的最顶端创建一个叫 ip 的变量,并且设置为 0。

?
1
int ip = 0;

  ip 变量代表指令指针。因为我们已经将程序存成了一个数组,所以使用 ip 变量去指明程序数组中当前索引。例如,如果创建了一个被赋值了程序 ip 索引的变量 x,它将存储我们程序的第一条指令。

[假设ip为0]

?
1
2
3
4
5
int ip = 0;
int main() {
    int instr = program[ip];
    return 0;

  如果我们打印变量 instr,本来应是 PSH 的它将显示为0,因为在他是我们枚举里的第一个值。我们也可以写一个取回函数像这样:

?
1
2
3
int fetch() {
    return program[ip];
}

  这个函数将会返回当前被调用指令。太棒了,那么如果我们想要下一条指令呢?很容易,我们只要增加指令指针就好了:

?
1
2
3
4
5
int main() {
    int x = fetch(); // PSH
    ip++; // increment instruction pointer
    int y = fetch(); // 5
}

  那么怎样让它自己动起来呢?我们知道一个程序直到它执行 HLT 指令才会停止。因此我们使用一个无限的循环持续直到当前指令为HLT。

?
1
2
3
4
5
6
7
8
9
10
// INCLUDE <stdbool.h>!
bool running = true;
int main() {
   while (running) {
       int x = fetch();
       if (x == HLT) running = false;
       ip++;
   }
}

  这工作的很好,但是有点凌乱。我们正在循环每一条指令,检查是否 HLT,如果是就停止循环,否则“吃掉”指令接着循环。

判断一条指令

因此这就是我们虚拟机的主体,然而我们想要确实的评判每一条指令,并且使它更简洁一些。好的,这个简单的虚拟机,你可以写一个“巨大”的 switch 声明。让 switch 中的每一个 case 对应一条我们定义在枚举中的指令。这个 eval 函数将使用一个简单的指令的参数来判断。我们在函数中不会使用任何指令指针递增除非我们想操作数浪费操作数。

?
1
2
3
4
5
6
7
void eval(int instr) {
    switch (instr) {
        case HLT:
            running = false;
            break;
    }
}

因此如果我们在回到主函数,就可以像这样使用我们的 eval 函数工作:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool running = true;
int ip = 0;
// instruction enum here
// eval function here
// fetch function here
int main() {
    while (running) {
        eval(fetch());
        ip++; // increment the ip every iteration
    }
}

栈!

很好,那会很完美的完成这个工作。现在,在我们加入其他指令之前,我们需要一个栈。幸运的是,栈是很容易实现的,我们仅仅需要使用一个数组而已。数组会被设置为合适的大小,这样它就能包含256个值了。我们也需要一个栈指针(常被缩写为sp)。这个指针会指向栈数组。

为了让我们对它有一个更加形象化的印象,让我们来看看这个用数组实现的栈吧:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[] // empty
PSH 5 // put 5 on **top** of the stack
[5]
PSH 6
[5, 6]
POP
[5]
POP
[] // empty
PSH 6
[6]
PSH 5
[6, 5]

那么,在我们的程序里发生了什么呢?

?
1
2
3
4
5
PSH, 5,
PSH, 6,
ADD,
POP,
HLT

我们首先把5压入了栈

?
1
[5]

然后压入6:

?
1
[5, 6]

接着添加指令,取出这些值,把它们加在一起并把结果压入栈中:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[5, 6]
// pop the top value, store it in a variable called a
a = pop; // a contains 6
[5] // stack contents
// pop the top value, store it in a variable called b
b = pop; // b contains 5
[] // stack contents
// now we add b and a. Note we do it backwards, in addition
// this doesn't matter, but in other potential instructions
// for instance divide 5 / 6 is not the same as 6 / 5
result = b + a;
push result // push the result to the stack
[11] // stack contents

那么我们的栈指针在哪起作用呢?栈指针(或者说sp)一般是被设置为-1,这意味着这个指针是空的。请记住一个数组是从0开始的,如果没有初始化sp的值,那么他会被设置为C编译器放在那的一个随机值。

如果我们将3个值压栈,那么sp将变成2。所以这个数组保存了三个值:

?
1
2
3
4
5
sp指向这里(sp = 2)
       |
       V
[1, 5, 9]
 0  1  2 <- 数组下标

现在我们从栈上出栈一次,我们仅需要减小栈顶指针。比如我们接下来把9出栈,那么栈顶将变为5:

?
1
2
3
4
5
sp指向这里(sp = 1)
    |
    V
[1, 5]
 0  1 <- 数组下标

所以,当我们想知道栈顶内容的时候,只需要查看sp的当前值。OK,你可能想知道栈是如何工作的,现在我们用C语言实现它。很简单,和ip一样,我们也应该定义一个sp变量,记得把它赋为-1!再定义一个名为stack的数组,代码如下:

?
1
2
3
4
5
int ip = 0;
int sp = -1;
int stack[256]; // 用数组或适合此处的其它结构
// 其它C代码

现在如果我们想入栈一个值,我们先增加栈顶指针,接着设置当前sp处的值(我们刚刚增加的)。注意:这两步的顺序很重要!

?
1
2
3
4
5
// 压栈5
// sp = -1
sp++; // sp = 0
stack[sp] = 5; // 栈顶现在变为5

所以,在我们的执行函数eval()里,可以像这样实现push出栈指令:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
void eval(int instr) {
    switch (instr) {
        case HLT: {
            running = false;
            break;
        }
        case PSH: {
            sp++;
            stack[sp] = program[++ip];
            break;
        }
    }
}

现在你看到,它和我们之前实现的eval()函数有一些不同。首先,我们把每个case语句块放到大括号里。你可能不太了解这种用法,它可以让你在每条case的作用域里定义变量。虽然现在不需要定义变量,但将来会用到。并且它可以很容易得让所有的case语句块保持一致的风格。

其次是神奇的表达式program[++ip]。它做了什么?呃,我们的程序存储在一个数组里,PSH指令需要获得一个操作数。操作数本质是一个参数,就像当你调用一个函数时,你可以给它传递一个参数。这种情况我们称作压栈数值5。我们可以通过增加指令指针(译者注:一般也叫做程序计数器)ip来获取操作数。当ip为0时,这意味着执行到了PSH指令,接下来我们希望取得下一条指令——即压栈的数值。这可以通过ip自增的方法实现(注意:增加ip的位置十分重要,我们希望在取得指令前自增,否则我们只是拿到了PSH指令),接下来需要跳到下一条指令否则会引发奇怪的错误。当然我们也可以把sp++简化到stack[++sp]里。

对于POP指令,实现非常简单。只需要减小栈顶指针,但是我一般希望能够在出栈的时候打印出栈值。

我省略了实现其它指令的代码和swtich语句,仅列出POP指令的实现:

?
1
2
3
4
5
6
7
// 记得#include <stdio.h>!
case POP: {
    int val_popped = stack[sp--];
    printf("popped %d\n", val_popped);
    break;
}

现在,POP指令能够工作了!我们刚刚做的只是把栈顶放到变量val_popped里,接着栈顶指针减一。如果我们首先栈顶减一,那么将得到一些无效值,因为sp可能取值为0,那么我们可能把stack[-1]赋给val_popped,通常这不是一个好主意。

最后是ADD指令。这条指令可能要花费你一些脑细胞,同时这也是我们需要用大括号{}实现case语句内作用域的原因。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
case ADD: {
    // 首先我们出栈,把数值存入变量a
    int a = stack[sp--];
    // 接着我们出栈,把数值存入变量b
    // 接着两个变量相加,再把结果入栈
    int result = a + b;
    sp++; // 栈顶加1 **放在赋值之前**
    stack[sp] = result; // 设置栈顶值
    // 完成!
    break;
}

寄存器

寄存器是虚拟机中的选配件,很容易实现。之前提到过我们可能需要六个寄存器:A,B,C,D,E和F。和实现指令集一样,我们也用一个枚举来实现它们。

?
1
2
3
4
typedef enum {
   A, B, C, D, E, F,
   NUM_OF_REGISTERS
} Registers;

小技巧:枚举的最后放置了一个数 NUM_OF_REGISTERS。通过这个数可以获取寄存器的个数,即便你又添加了其它的寄存器。现在我们需要一个数组为寄存器存放数值:

?
1
int registers[NUM_OF_REGISTERS];

接下来你可以读取寄存器内的值:

?
1
printf("%d\n", registers[A]); // 打印寄存器A的值

修订

我没有在寄存器花太多心思,但你应该能够写出一些操作寄存器的指令。比如,如果你想实现任何分支跳转,可以通过把指令指针(译者注:或叫程序计数器)和/或栈顶指针存到寄存器里,或者通过实现分支指令。

前者实现起来相对快捷、简单。我们可以这样做,增加代表IP和SP的寄存器:

?
1
2
3
4
typedef enum {
    A, B, C, D, E, F, PC, SP,
    NUM_OF_REGISTERS
} Registers;

现在我们需要实现代码来使用指令指针和栈顶指针。一个简单的办法——删掉上面定义的sp和ip变量,用宏定义实现它们:

?
1
2
#define sp (registers[SP])
#define ip (registers[IP])   译者注:此处应同Registers枚举中保持一致,IP应改为PC

这个修改恰到好处,你不需要重写很多代码,同时它工作的很好。

一些习题

如何实现分支指令?

我把问题留给你!记住指令指针(程序计数器)指向当前指令,并且其数值存储在一个寄存器里。所以你需要写一条指令设置寄存器的值,例如:SET REG value。接下来可以通过设置IP寄存器为某条指令的位置,进而跳转到这条指令。如果你想看一个更复杂的例子,请访问我的github代码库,那里有一个递减某个值直到其为0的例子。

这里有几道题目,实现MOV指令:MOV REG_A, REG_B。换句话说,MOV指令把数值从REG_A移到REG_B。同样SET REG_A VALUE,会设置REG_A内容为VALUE。

你可以从github此处访问源码。如果你想更“高级”虚拟机是如何实现MOV和SET指令的,请浏览bettervm.c文件。你可以拿自己的实现和它作比较。如果你只想大体了解一下代码结构,请先浏览main.c。

好了!现在你拿到代码了。在根目录下运行make,它会自动编译,接下来运行./mac。

多谢阅读本文!

使用C语言实现一个虚拟机相关推荐

  1. 怎样学好C语言,一个成功人士的心得!

    今天,我能够自称是一个混IT的人,并能以此谋生,将来大家能一次谋生,都要感谢两个人:克劳德.香农和约翰.冯.诺依曼,是他们发现了全部的数字化信息,不论是一段程序,一封email,一部电影都是用一连串的 ...

  2. 云服务器就是虚拟机,云服务器就是一个虚拟机吗

    云服务器就是一个虚拟机吗 内容精选 换一换 VR云渲游平台与周边服务的依赖关系如表1所示. 华为云帮助中心,为用户提供产品简介.价格说明.购买指南.用户指南.API参考.最佳实践.常见问题.视频帮助等 ...

  3. 云服务器是一个个虚拟机,云服务器就是一个虚拟机吗

    云服务器就是一个虚拟机吗 内容精选 换一换 简要介绍Rsyslog是一个集中日志管理工具,基于流行的服务端/客户端模式,通过TCP或者UDP传输协议来发送日志信息,或者从网络设备.服务器.路由器.交换 ...

  4. 如何学好C语言,一个成功人士的心得!

    来源URL:http://blog.csdn.net/yxnk/article/details/5976699/ 今天,我能够自称是一个混IT的人,并能以此谋生,将来大家能一次谋生,都要感谢两个人:克 ...

  5. aptos中文版白皮书-前Facebook团队打造明星公链,三个优势:Move语言、Move虚拟机、合约可升级

    摘要 区块链作为一种新的互联网基础设施的崛起,导致开发者以快速增长的速度部署了数万个去中心化的应用程序.不幸的是,由于频繁的中断.高成本.低吞吐量限制和许多安全问题,区块链的使用还不普遍.为了在web ...

  6. Java Virtual Machine:Java语言的一个非常重要的特点就是与平台的无关性

    Java语言的一个非常重要的特点就是与平台的无关性.而使用Java虚拟机是实现这一特点的关键.一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码.而引入Java语言虚拟机后,Java ...

  7. c语言制作java虚拟机_【C/C+】虚拟机实现:用C语言来写Java虚拟机

    前言:本篇文章除了能够让你了解到虚拟机的工作原理外,还可以让你了解到较低级别的编程过程. 由于我喜欢在较低级别(Low-level)的应用中(编译器,解释器,解析器,虚拟机等等)工作,所以我觉得写一篇 ...

  8. C语言的一个关键字——static

    C语言的一个关键字--static Static在C语言里面有两个作用,第一个是修饰变量,第二个是修饰函数. 1.Static修饰变量 按照作用范围的不同,变量分为局部变量和全局变量.如果用stati ...

  9. C语言:一个涉及指针函数返回值与printf乱码、内存堆栈的经典案例

    C语言:一个涉及指针函数返回值与printf乱码.内存堆栈的经典案例 一个奇怪的C语言问题,涉及到指针.数组.堆栈.以及printf.以下实现: 整数向字符串的转换,返回字符串指针,并在main函数中 ...

最新文章

  1. oracle创建函数语句,Oracle 创建函数与存储过程语句积累
  2. 从开源自治,到微服务云化,阿里云的这款产品给了一剂提升微服务幸福感的良药
  3. SAP License:SAP 中的不平
  4. Android:四大架构的优缺点,你真的了解吗? 1
  5. 计算机系统时间设置打不开,电脑时间或日期不对的修改方式
  6. oracle排除非数字,oracle字段中找出字段中含有非数字的方法!!!
  7. Mac上使用微信读书(微信读书网页版)
  8. VMware Workstation未能挂起虚拟机
  9. 白话空间统计二十五:空间权重矩阵外篇:功能地理学
  10. 菜鸟教程css事件,【推荐】DIV+CSS入门菜鸟教程
  11. h5/web 原生定位、高德、腾讯地图定位
  12. Light Emitting Hindenburg(bitset运用)
  13. 医美“四大发明”?一次令人上头的认知碰撞
  14. 跟我学Python图像处理丨图像特效处理:毛玻璃、浮雕和油漆特效
  15. 训练赛1_E_Lawnmower
  16. php 子都接受邮件,php iamp 接收邮件,收取邮件,获取邮件列表
  17. 电机标幺化、PI标幺化、锁相环PLL标幺化 详解电机模型相关标幺化处理
  18. Shell编程—日志模块
  19. shell条件测试操作
  20. idea ctrl+alt+向左箭头不能用

热门文章

  1. 2008年 斯坦福大学的计算机科学家,张某毕业于斯坦福大学计算机系,出于爱好喜欢研究计算机领域相关.._简答题试题答案...
  2. Windows2000/xp/2003系统服务快速设置(转)
  3. 如何设计APP软件图标
  4. (Genymotion3.0.4安装系列)5-(补充版)Android Studio 3.5.2安装Genymotion插件
  5. android每日更新壁纸,安卓壁纸APP|安卓壁纸 V5.14.15 免费最新版 已下架_当下软件园...
  6. Android---Tablayout自定义tab
  7. 手把手教你学会如何使用WireShark进行抓包
  8. EasyExcel 样式注解大全
  9. wifi 荣耀手机usb_华为/荣耀路由器接了USB3.0存储设备后为什么2.4G的WiFi网速变慢了?...
  10. 职称认定和职称评审的区别是什么