用JS搭建自己的汇编模拟器

原文链接:part1part2

写在前面:

由于自己的计组课上学期实在是划水了,很多内容没有好好的理解和掌握,但是能让我划起水,还是因为课水太多了啊!刚好自己学过前端看到这个课程就想写写看,复习一下计组。这个项目是拿Angular写的,我没学过Angular,所以就只看了大概,没有具体的构建。具体实现看我后面的更新吧!😴

有关模拟器(Simulator)

我看的这篇文章,里头的模拟器是用Angular.js进行构建的,可以运行在很多设备上的浏览器中。这个模拟器有很多限制和简化,但是他有着许多模拟器(emulator)最基础的结构。

这个虚拟机包含有一下几个组件:

  1. 256 byte的内存。
  2. 8-bit的CPU。
  3. 输出控制台(console output)

总体来说,他不是一个强大的机器但是已经足够我们使用。

有关CPU

通用寄存器

CPU中有着四个通用寄存器。

首先,CPU如何知道下一条指令是什么呢?这就需要使用到IP(instruction pointer)这个寄存器了,在IP这个寄存器中,存放着下一条指令在内存中的位置。

标志位

其次,我们还需要用到标志位,标志位的作用就体现在,比如我们需要使用到IF-ELSE等判断语句的时候,我们就可以通过标志位来判定我们的结果是否满足我们的要求。这里我们设置三个标志位:Zero(Z)、Carry(C)、Fault(F)

这三个标志位分别起到这些作用:

  1. Zero:当我们的指令的结果是零,那么这个标志位将会被置为1,否则就会置为0。
  2. Carry:当我们的指令产生了进位,这个标志位就会被置为1。
  3. Fault:当程序出现错误,例如在除法运算中,零被置于分母,那么就会将这个标志位置为1。

堆栈寄存器

堆栈指针SP为内存中当前堆栈的首地址提供了栈顶地址,存储和使用数据时将会对其进行改变。


上面我们定义了这些寄存器、标志位等,那么接下来我们要设计一个用于初始化CPU的函数,可以进行重置整个系统,并将上面我们定义的这些数据初始化。

var gpr, ip, sp, zero, carry, fault;

function reset() {
    gpr = [0, 0, 0, 0];
    self.maxSP;
    ip = 0;
    zero = false;
    carry = false;
    fault = false;
}

有关存储器(memory)

在这里我们将用一个简单的数组来进行模拟真实的内存,在这里需要注意的是,我们将每个数组单元模拟为一个字节的大小,但是实际上,JS的每个数组单元可以容纳大得多的数据。因此在CPU那里,我们设置了限制,以保证每个值都在0—255之间。

存储器将有三个功能:Load、Store、Reset。

  1. Load:可以从给定的地址检索一个字节,并将其读出。
  2. Store :可以将指定的值写入指定的地址。
  3. Reset:将所有的内存值重置为零,用于初始化和重置。
var memory = Array(256);

function load(address) {
  if (address < 0 || address >= memory.length) {
      throw "Memory access violation. Address: " + address;
  }

  return memory[address];
};
      
function store(address, value) {
  if (address < 0 || address >= memory.length) {
      throw "Memory access violation. Address: " + address;
  }

  memory[address] = value;
};

function reset() {
  for(var i=0; i < memory.length; i++) {
      mmeory[i] = 0;
  }
};

根据这些我们就可以大致模拟出CPU的代码结构了:

function step() {
    if (fault) {
        throw "FAULT. Reset to CPU continue.";
    }
    
    var instr = memory.load(ip);
    switch(instr) {
        case opcodes.ADD_REG_TO_REG:
            // Read operand 1: Target register
            var regTo = memory.load(ip+1);
            
            // Read operand 2: Source register
            var regFrom = memory.load(ip+2);
           
            // Execute instruction. Add values of both registers
            var value = processResult(readRegister(regTo) + readRegister(regFrom));
            
            // Write the new value back to the target register
            writeRegister(regTo, value);
                        
            // Increase instruction pointer            
            ip += ip+3;
            break;
        case opcodes.ADD_REGADDRESS_TO_REG:
            ...
        case opcodes.ADD_ADDRESS_TO_REG:
            ...
        case opcodes.ADD_NUMBER_TO_REG:
            ...
        case ...
            ...
        default:
            throw "Invalid opcode: " + instr;
    }        
}

function processResult(value) {
    zero = false;
    carry = false;

    if (value >= 256) {
        carry = true;
        value % 256;
    } else if (value === 0) {
        zero = true;
    } else if (value < 0) {
        carry = true;
        value = 255 - (-value) % 256;
    }

    return value;
};

有关控制台输出

这里我们约定控制台显示内存中最后的24个字节,所以最后让程序在控制台上输出数据,只需要将数据写入内存的最后24个字节

有关汇编程序

在完成上面的虚拟机的基础组件之后,我们还需要最后的一个部分,也是最重要的一个部分:怎么将代码汇编形成CPU指令。

我们的汇编程序将解析操作数,并生成CPU指令。

我们将使用正则表达式来进行解析代码,但是这只是一个很简单的一个解决方案,因为我们不可能用正则表达式来完全解析代码,但是为了简单起见我们还是使用这种方法去避免生成复杂的AST(抽象语法树)

我们会将汇编代码的每一行分配成不同代码组:

// Matches: "label: INSTRUCTION (["')OPERAND1(]"'), (["')OPERAND2(]"')
// GROUPS:      1       2               3                    7
var regex = /^[\t ]*(?:([.A-Za-z]\w*)[:])?(?:[\t ]*([A-Za-z]{2,4})(?:[\t ]+(\[(\w+((\+|-)\d+)?)\]|\".+?\"|\'.+?\'|[.A-Za-z0-9]\w*)(?:[\t ]*[,][\t ]*(\[(\w+((\+|-)\d+)?)\]|\".+?\"|\'.+?\'|[.A-Za-z0-9]\w*))?)?)?/;

// Regex group indexes
var GROUP_LABEL = 1;
var GROUP_OPCODE = 2;
var GROUP_OPERAND1 = 3;
var GROUP_OPERAND2 = 7;

在我们的程序中,我们的每一条指令都在单独的行上面。这使得我们很容易去解析他们,我们可以这样去解析每一行代码:

  1. 一行一行地分开,然后逐个处理。
  2. 审查这一行是否包含有有效地指令。
  3. 如果生成了有效地指令,我们将根据指令读取操作数,并且每个操作数将包含有0~2个操作数。并根据上面构建的许多函数去查找读取并操作操作数。
  4. 根据操作数类型(是间接寻址还是直接寻址或者是其他的方式)和操作码确定相应的操作码。
  5. 向代码串中添加最后的指令,包括操作数。然后跳转到步骤一直到所有的行都被解析。
function run(code) {
    var opCode;
    var lines = code.split('\n');
    var code = [];
    
    for (var i = 0, l = lines.length; i < l; i++) {
        var match = regex.exec(lines[i]);
    
        if (match[GROUP_OPCODE]) {
            var instr = match[GROUP_OPCODE].toUpperCase();
            switch (instr) {
                case 'ADD':
                    var op1 = readOperand(match[GROUP_OPERAND1]);
                    var op2 = readOperand(match[GROUP_OPERAND2]);
                    
                    if (op1.type === "register" && op2.type === "register")
                        opCode = OpCodes.ADD_REG_TO_REG;
                    else if (op1.type === "register" && op2.type === "regaddress")
                        opCode = OpCodes.ADD_REGADDRESS_TO_REG;
                    else if (op1.type === "register" && op2.type === "address")
                        opCode = OpCodes.ADD_ADDRESS_TO_REG;
                    else if (op1.type === "register" && op2.type === "number")
                        opCode = OpCodes.ADD_NUMBER_TO_REG;
                    else
                        throw "ADD does not support this operands";
                    
                    code.push(opCode, op1.value, op2.value);
                    
                    break;
                case ...
                case ...
                default:
                    throw "Not a valid instruction: " + instr;
            }
        }
    }
    
    return code;
};

有关UI

其实到这里,所有的主体大致都已经讲完了,UI这块就是去如何展示这些代码了。所以也就不再赘述了。

这里就贴一下Schweigi的实例地址