前言
上篇文章《用 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 时,栈的变化过程如下。
- 初始状态
- 将 a 入栈
- 将 b 入栈
- 从栈里取出两个数,计算结果,将结果入栈
- 将结果写入 c 中
在这,我们用数组实现这个栈。
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()
}
}
}
后记
上面我们定义了一个虚拟机的雏形,然而这个虚拟机仅仅只有一个架子在,最重要的功能 —— 执行代码,缺没有。
下一篇文章,即是为这个虚拟机添上这个功能。