跳转到内容

用 C 语言创建虚拟机/寄存器虚拟机

来自 Wikibooks,开放世界的开放书籍

第一个示例将是最简单的架构之一,它将包含以下组件

  1. 一组寄存器(我将任意选择有 4 个,编号为 0 到 3)。寄存器用作一组读/写内存单元。
  2. 一个程序,它是一个只读的 VM 指令序列
  3. 一个执行单元来运行程序。执行单元将按顺序读取每个指令并执行它。

寻址模式

[编辑 | 编辑源代码]

在任何汇编语言中,数字都用于多种用途。除了表示标量值外,它们还表示寄存器编号和内存位置。在本例中,我将遵循使用前缀字符来表示数字所扮演的角色的约定。

  • 寄存器编号以字母r开头,如r0r1r2
  • 立即数(标量)值以井号#开头,如#100#200
  • 内存地址以符号@开头,如@1000@1004

指令集

[编辑 | 编辑源代码]

设计完 VM 后,需要设计 VM 的指令集。指令集仅仅反映了我们想要用这个 VM 做的事情(或者允许其他人用这个 VM 做的事情)。以下是一些用这个 VM 可以做的事情

  • 将一个立即数(常数)加载到一个寄存器中。
  • 对两个寄存器执行算术求和(即,将两个数字相加)。
  • 停止机器。

让我们现在就保持这种简单性。在获得工作实现之后,我们可以返回并添加更多指令。

首先,让我们使用这三个指令编写一个简短的程序,看看它们可能是什么样子

loadi r0 #100
loadi r1 #200
add r2 r0 r1
halt

如果 VM 运行该程序,以下是对每行发生的事情的描述

  1. 将立即值 100 加载到寄存器r0中。请注意,将值放入寄存器通常称为加载操作。
  2. 将立即值 200 加载到寄存器r1中。
  3. 将寄存器r0r1的内容相加,并将结果放入寄存器r2中。
  4. 结束程序并停止 VM。

请注意,为了保持与通用约定的统一,我选择在操作数列表中首先放置目标寄存器。

指令码

[编辑 | 编辑源代码]

创建汇编语言后,需要确定如何将每个指令表示为一个数字。这在汇编语言中的每个指令和指令码集中的每个指令码之间建立了一一对应关系。将程序从汇编语言转换为指令码称为汇编,而从指令码转换回汇编语言称为反汇编

此时我们必须做出几个选择

  • 用什么数字表示每个汇编语言指令?
  • 如何对指令操作数进行编码?
  • 操作数是指令字的一部分(请记住,数字),还是单独的字(数字)?

首先,为了回答最后一个问题,由于这个 VM 中的指令和寄存器数量很少,即使(为了简单起见)我使用一个 16 位的指令字,也不难将所有操作数编码到一个指令字中。因此,用十六进制表示的 16 位数字有 4 位,为我们提供了 4 个信息字段的方便访问,每个字段包含 16 种变体(0-9 和 A-F)。

  1. 机器字的第一位将是指令编号。这使我们的 VM 潜在地拥有多达 16 条不同的指令。就当代标准而言,这是一个很小的数量,但对于我们的示例虚拟机来说已经足够了。
  2. 接下来的三位将用于操作数。它们可以用作三个 1 位操作数,两个 1 位和 2 位操作数,或一个 3 位操作数。

做出这些决定后,让我们现在建立编码。回想一下,我们有 16 个可用的指令编号。

halt指令将是指令 0,选择 0 用于该指令有一个重要的原因。由于计算机内存中的空闲空间很可能被 0 填充,因此任何失控程序最终都会遇到 0 并尝试执行此指令,立即停止程序。

剩下的两条指令可以任意分配:loadi指令可以是指令 1,add指令可以是指令 2。这是我们目前的指令编码列表

0 = halt
1 = loadi
2 = add

检查第一个程序指令,我们看到我们现在必须对寄存器和立即值进行编码

loadi r0 #100

有三个十六进制数字可用于操作数,因此我们将使用这三个数字中的第一个作为寄存器编号,并将第二个和第三个一起作为立即值。现在我们已经确定了loadi指令的完整编码

bits 15-12 = 1
bits 11- 8 = register number
bits  7- 0 = immediate value

此指令的寄存器编号为 0,立即值为 100,十六进制为 64(6 x 16 + 4)。因此,第一个指令的完整 16 位十六进制指令码为 1064。

第二个指令以类似的方式汇编。立即值为 200,十六进制为 C8。第二个指令汇编为 11C8。

第三个指令是 2201。

最后一个指令是 0。

将指令组合在一起,我们得到这 4 个 16 位十六进制数字作为完整的程序

1064
11C8
2201
0000

现在是时候实现这个设计了。我选择用 C 语言编写这个程序,原因有几个

  • 它是一种非常简单的语言,可以编写简洁的程序。
  • 它是一种具有某些高级特性的低级语言。这使得它成为创建 VM 的一个不错的选择。

VM 的主要功能将是一个run函数,它按顺序在循环内执行以下步骤。循环重复执行,直到执行halt指令。

  1. 从程序中获取下一条指令。
  2. 将指令解码为其组成部分。
  3. 执行解码后的指令。

让我们开始编写程序。

寄存器

[编辑 | 编辑源代码]

要实现的第一个也是最简单的部分是 4 个寄存器集。

# define NUM_REGS 4
int regs[ NUM_REGS ];

这个 VM 中的寄存器是有符号整数,根据您的计算机和操作系统,宽度为 16 位、32 位或 64 位。对于本示例,确切的尺寸无关紧要。

程序指令

[编辑 | 编辑源代码]

完全汇编的程序可以轻松地存储在一个整数数组中。

int prog[] = { 0x1064, 0x11C8, 0x2201, 0x0000 };

取值函数从程序中检索下一个字。为了做到这一点,我们必须引入一个称为程序计数器(有时也称为指令指针)的变量。在从函数返回指令字之前,必须递增程序计数器,以便它引用程序数组中的下一条指令。

int pc = 0; /* program counter */

int fetch()
{
  return prog[ pc++ ];
}

译码函数将完全解码每条指令。它将确定每个指令的三个操作数寄存器和立即值,即使某个指令没有使用该部分。实际上,这使得函数更简单。指令编号和操作数存储在变量中。

int instrNum = 0;
/* operands: */
int reg1     = 0;
int reg2     = 0;
int reg3     = 0;
int imm      = 0;

/* decode a word */
void decode( int instr )
{
  instrNum = (instr & 0xF000) >> 12;
  reg1     = (instr & 0xF00 ) >>  8;
  reg2     = (instr & 0xF0  ) >>  4;
  reg3     = (instr & 0xF   );
  imm      = (instr & 0xFF  );
}

执行函数实际执行指令。首先需要一个运行标志,halt指令可以将其设置为 0。

/* the VM runs until this flag becomes 0 */
int running = 1;

现在是eval函数。它只是一个switch阶梯。此函数使用译码函数存储值的指令码和操作数变量。请注意,在每个指令 case 中,我都会显示指令。这使得在程序运行时易于跟踪。

/* evaluate the last decoded instruction */
void eval()
{
  switch( instrNum )
  {
    case 0:
      /* halt */
      printf( "halt\n" );
      running = 0;
      break;
    case 1:
      /* loadi */
      printf( "loadi r%d #%d\n", reg1, imm );
      regs[ reg1 ] = imm;
      break;
    case 2:
      /* add */
      printf( "add r%d r%d r%d\n", reg1, reg2, reg3 );
      regs[ reg1 ] = regs[ reg2 ] + regs[ reg3 ];
      break;
  }
}

run 函数非常简单:获取,然后解码,最后执行。

void run()
{
  while( running )
  {
    int instr = fetch();
    decode( instr );
    eval();
  }
}

主函数

[编辑 | 编辑源代码]

main 函数简单地调用 run 函数。

int main( int argc, const char * argv[] )
{
  run();
  return 0;
}

完整程序源代码

[编辑 | 编辑源代码]

为了方便起见,我已将完整的程序源代码包含在一个单独的代码块中。请注意,我添加了一个 showRegs 函数,以便在指令之间查看所有寄存器的值。这让我能够验证 VM 和汇编程序是否正常工作。

# include <stdio.h>

# define NUM_REGS 4
unsigned regs[ NUM_REGS ];

unsigned program[] = { 0x1064, 0x11C8, 0x2201, 0x0000 };

/* program counter */
int pc = 0;

/* fetch the next word from the program */
int fetch()
{
  return program[ pc++ ];
}

/* instruction fields */
int instrNum = 0;
int reg1     = 0;
int reg2     = 0;
int reg3     = 0;
int imm      = 0;

/* decode a word */
void decode( int instr )
{
  instrNum = (instr & 0xF000) >> 12;
  reg1     = (instr & 0xF00 ) >>  8;
  reg2     = (instr & 0xF0  ) >>  4;
  reg3     = (instr & 0xF   );
  imm      = (instr & 0xFF  );
}

/* the VM runs until this flag becomes 0 */
int running = 1;

/* evaluate the last decoded instruction */
void eval()
{
  switch( instrNum )
  {
    case 0:
      /* halt */
      printf( "halt\n" );
      running = 0;
      break;
    case 1:
      /* loadi */
      printf( "loadi r%d #%d\n", reg1, imm );
      regs[ reg1 ] = imm;
      break;
    case 2:
      /* add */
      printf( "add r%d r%d r%d\n", reg1, reg2, reg3 );
      regs[ reg1 ] = regs[ reg2 ] + regs[ reg3 ];
      break;
  }
}

/* display all registers as 4-digit hexadecimal words */
void showRegs()
{
  int i;
  printf( "regs = " );
  for( i=0; i<NUM_REGS; i++ )
    printf( "%04X ", regs[ i ] );
  printf( "\n" );
}

void run()
{
  while( running )
  {
    showRegs();
    int instr = fetch();
    decode( instr );
    eval();
  }
  showRegs();
}

int main( int argc, const char * argv[] )
{
  run();
  return 0;
}

示例运行

[编辑 | 编辑源代码]

以下是程序的输出结果。

regs = 0000 0000 0000 0000
loadi r0 #100
regs = 0064 0000 0000 0000
loadi r1 #200
regs = 0064 00C8 0000 0000
add r2 r0 r1
regs = 0064 00C8 012C 0000
halt
regs = 0064 00C8 012C 0000
华夏公益教科书