用 typescript 整一个虚拟机(上)

814 阅读6分钟

前言

上篇文章《用 typescript 整一门编程语言(伪)》 中,用 ts 定义了一个抽象语法树。本来之后的工作就是整个后端,生成目标代码。

可是编译到那个环境呢?接下来就是整个虚拟机,作为编译的目标

类型与值

在上节设计抽象语法树里其实已经将哪几种值定义出来了。然而对于虚拟机而言,处理的方法、关注点也不太一样,所以还需要单独处理。

首先定义一个枚举,将所有需要处理的类型表示出来。

export enum LxType {
    nil, int, func
}

一共有三个类型 —— 空类型、整型、函数。而一个值则是一个元组,两个元素分布表示这个值的类型和参数。

对于空类型而言就一个值。

type Nil = [LxType.nil, null]

对于整型而言,参数为一个数值。

type Int = [LxType.int, number]

对于函数而言,参数为一个函数的原型。

type Func = [LxType.func, Proto]

函数的原型,则包括以下几个属性。

export class Proto {
    numParams: number = 0 // 固定参数个数
    code: number[] = [] // 指令表
    slotSize: number = 0 [] // 寄存器数量
    maxStackSize: number = 0// 栈空间
    constants: LxValue[] = [] // 常量表
    protos: Proto[] = [] // 子函数原型表
}

固定参数个数记录这个函数传入几个参数;

函数 body 内的语句都会编译成虚拟机的指令,指令表即是这些指令的列表;

指令的操作都在栈上进行,栈空间则声明了这个函数一共需要多大的栈,方便初始化栈;

类似 int(1) 这种数值都不会放在指令里面,毕竟指令的长度有限。而是专门有个常量表存着;

对于一个函数而已,内部可以还存在多个内部函数,子函数原型表则存着这些内部函数的原型。

于是我们虚拟机能处理的所有类型就都可以定义出来了。

type LxValue = Nil | Int | Func

寄存器与栈

在诸如 c / c++ 或是 rust 这类比较底层的语言中,我们常会考虑某个数据是放堆上,还是放栈上。堆的容量大,而栈的访问速度比较快。而这个栈,即是我们虚拟机中处理运算最基本的结构。

这个“虚拟栈”即需要实现这两个功能,局部/临时变量的存储,以及运算。这其实也是模拟的 CPU 的功能。

我们将这个栈逻辑上拆分为两个部分,前一部分用来实现局部/临时变量的存储,即寄存器。后一部分则实现计算部分。

于是当计算 c = a + b 时,栈的变化过程如下。

  1. 初始状态 image.png
  2. 将 a 入栈 image.png
  3. 将 b 入栈 image.png
  4. 从栈里取出两个数,计算结果,将结果入栈 image.png
  5. 将结果写入 c 中 image.png

在这,我们用数组实现这个栈。


export class Stack {
    #slots: LxValue[] = []
    // 栈顶索引, -1 表示空栈
    #top: number = -1

    constructor(size: number) {
        this.#slots = new Array(size).fill(null)
        this.#top = -1
    }
    // 将相对 index 转换为绝对 index
    absIndex(idx: number) {
        return idx >= 0 ? idx : (idx + this.#top + 1)
    }
    // 入栈
    push(val: LxValue) {
        if (this.#top + 1 >= this.#slots.length)
            throw new Error('stack: stackoverflow!')

        this.#top++
        this.#slots[this.#top] = val
    }
    // 出栈
    pop() {
        if (this.#top < 0)
            throw new Error('stack: stack is empty!')

        const val = this.#slots[this.#top]

        if (!val)
            throw new Error()

        this.#slots[this.#top] = nil()
        this.#top --

        return val
    }
    // 获取索引对应的值
    get(idx: number) {
        const absIdx = this.absIndex(idx)
        const val = absIdx >= 0 && absIdx <= this.#top
            ? this.#slots[absIdx]
            : null

        if(!val) 
            throw new Error('out of range!')

        return val
    }
    // 设置对应索引的值
    set(idx: number, val: LxValue) {
        const absIdx = this.absIndex(idx)
        if (absIdx >= 0 && absIdx <= this.#top)
            this.#slots[absIdx] = val
        else
            throw new Error('stack: invalid index')
    }
    // 获取栈顶位置
    top() {
        return this.#top
    }
    // 设置栈顶位置
    setTop(n:number){
        if (n + 1 >= this.#slots.length)
            throw new Error('stack: stackoverflow!')
        this.#top = n
    }
    // 将栈顶的值推出并写入对应索引处
    replace(idx: number) {
        const val = this.pop()
        this.set(idx, val)
    }
}

调用帧与栈

抛开堆栈的 “栈”,在另一方面我们也常用到“栈”这个概念 —— 调用栈。当执行一个函数调用的时候,会在调用栈上推入一个新的调用帧,函数执行完成后这个调用帧就会出栈,继续上一个函数。

首先我们先定义一个调用栈,其记录了栈顶的调用帧。

然后实现入栈出栈操作。如果本身就为最底栈,那么继续出栈就会终止程序。

terminate 方法目前还不好实现,那么目前就以抽象方法定义它。于是 LxCallStack 也就成了抽象类。

export abstract class LxCallStack {
    #top: LxCallFrame
    constructor(top:LxCallFrame){
        this.#top = top
    }
    push(frame:LxCallFrame){
        frame.prev = this.#top
        this.#top = frame
    }
    pop(){
        const top = this.#top
        if(top.prev){
            this.#top = top.prev
            return top
        }else{
            this.terminate()
        }
    }
    abstract terminate(): void
}

而在我们的调用帧中,记录了它上一个调用帧。

调用帧是和函数直接相关的,自然也需要记录下当前函数的原型。

函数的指令是在调用帧上执行的,于是也需要记录下调用栈方便出栈和入栈。

export class LxCallFrame {
    prev: LxCallStack | null = null
    resultSlot :number
    #proto: Proto
    #stack: LxCallStack
    constructor(proto:Proto,stack: LxCallStack){
        this.#prev = prev
        this.#stack = stack
    }
}

调用帧

上一节中,我们已经把调用帧和调用栈结合在一起,并设计出了调用帧的雏形。

但我们还需要将调用帧和栈结合在一起让调用帧有执行指令的能力。

如何结合?继承!

由于 proto 上记录了最大需要的栈空间,正好通过 super() 传递给栈。

同时通过 setTop() ,预留出给寄存器的空间 slotSize

resIdx 则记录了调用桢执行结束后的结果需要写入的位置

export class LxCallFrame extends Stack {
    prev: LxCallFrame | null = null
    #stack: LxCallStack
    #resIdx: number
    #proto: Proto

    constructor(proto: Proto, stack: LxCallStack) {
        super(proto.maxStackSize)
        this.setTop(proto.slotSize - 1)
        this.#proto = proto
        this.#stack = stack
        this.#resIdx = resultIdx
    }
}

由于调用帧记录下了对应函数的原型,自然可以为栈拓展出更多的方法。

  • 记录下当前的指令位置,和指令跳转,获取下个指令的方法。
export class LxCallFrame extends Stack {
    // ...
    // 指令索引
    #pc = 0
    // 指令跳转
    addPC(n: number) { this.#pc = this.#pc + n }
    // 获取下个指令
    fetch() {
        const ins = this.#proto.code[this.#pc]
        this.#pc = this.#pc + 1
        return ins
    }
}
  • 获取常量
export class LxCallFrame extends Stack {
    // ...
    pushConst(idx: number) {
        const s = this.#proto.constants[idx]
        this.push(s)
    }
}
  • 拓展一下对于栈的操作
export class LxCallFrame extends Stack {
    // ...
    // 将指定索引处的值推入栈顶
    pushValue(idx: number) {
        const val = this.get(idx)
        this.push(val)
    }
    // 把某个位置的值复制到另一个位置
    copy(fromIdx: number, toIdx: number) {
        this.pushValue(fromIdx)
        this.replace(toIdx)
    }
}
  • 拓展一下对于函数调用的操作

首先将函数推入堆栈

export class LxCallFrame extends Stack {
    // ...
    // 将指定索引处的子函数推入栈顶
    loadProto(idx: number) {
        const proto = this.#proto.protos[idx]
        this.push(func(proto))
    }
}

执行函数调用,执行调用前,执行函数和调用参数需要排列在栈顶。所以还需要传参的个数。

执行后,参数和函数均出栈。函数则用来生成新的调用帧。参数则会放入新调用帧的栈顶。根据函数原型中描述固定参数的数量,如果不足则补上 nil , 如果多了就舍弃掉。

export class LxCallFrame extends Stack {
    // ...
    // 将指定索引处的子函数推入栈顶
    call(arguLength: number) {
        let argus: LxValue[] = []

        while (arguLength-- > 0) {
            argus.push(this.pop())
        }

        argus.reverse()

        const fn = this.pop()

        if (fn[0] !== LxType.func) throw new Error()

        const proto = fn[1]
        const stack = this.#stack
        const nframe = new LxCallFrame(proto,stack)

        for (let i = 0; i < proto.numParams; i++) {
            nframe.set(i,argus[i] ?? [LxType.nil, null])
        }

        this.#stack.push(nframe)
    }
}

返回函数调用结果,将结果推入上一个调用帧的对应的 resIdx 寄存器中。

export class LxCallFrame extends Stack {
    // ...
    // 将指定索引处的结果推入栈顶
    return(idx: number) {
        const val = this.get(idx)
        if (!val) throw new Error()

        if (this.#prev) {  
            this.prev.push(val)
            this.#stack.pop()
        }
    }
}

后记

上面我们定义了一个虚拟机的雏形,然而这个虚拟机仅仅只有一个架子在,最重要的功能 —— 执行代码,缺没有。

下一篇文章,即是为这个虚拟机添上这个功能。