从零实现一个编程语言:中文计算 三 构造虚拟机 没有字节码 VM(TVM) 我们一起出发

168 阅读12分钟
在英语中,文学是 26个字母,10 个阿拉伯数字和大约 8 个标点符号的水平线上的特殊排列组合。

在计算机中,编程应该属于文科类别。
    
	__ steve jobs 乔布斯 在某采访中回答。

接上一章: 写在前面

这是我们在三个章节完成一个中文编程语言挑战的最后一章。

我们已经花了巨量的时间去把源代码表示为表达式语句,这样的AST 让我们可以更好将其表示为机器码。 在解释为机器码之前,我们还需要做一些事情,那就是实现指令虚拟机。

其最终的指令形式如下,这让我们更清楚地了解编译器如何将用户的源代码翻译成一系列的机器可以执行的代码

 ('IPUSH', 3), ('IPUSH', 4),('IPUSH', 5),('IMUL',),('IADD',), ('PRINTI',) 

我们把字节码这些间接层都排除在外,也不去担心内存的管理,暂时将其理解为无穷大小,因为我们隐式地依赖GC垃圾回收机制,它会替我们处理好。

当专注于高级概念时,可以掩盖它们。我们在这里只是去了解,去模拟一个硬件如何执行计算,它就是堆栈虚拟机Stack VM

它的应用非常广泛,Python 虚拟机,JAVA 虚拟机,C# CIL, WebAssembly, Erlang 我们也不打算全部实现它,这里实现基本的 逻辑,整数计算和输出。

一个堆 就像一个只有一条没有出口的小路,如果太多人进入这其中,要排空这里,只有从入口依次取出

	存入 	start ->-> end

	取出 	<-<- end
	
	add, sub, mul ,div, and ,or ...

按优先级调用这个堆,并把结果返回,最后的结果即是我们需要的。 优先级以此升高。

5 执行: 逻辑运算和算术运算函数

检查我们已有的表达式语句。

 PrintStatement (*Node)(0xc0001011d0)
			{data:BinOp {Expression: nil, 
				Op: '+', 
				Left: *Node {Next: nil, 
					Data: (*Integer) 
					data:{Value:'3'}, 
				Right: *Node {Next: nil, 
					Data: (*BinOp) BinOp {Expression:  nil, 
						Op: '*', 
						Left: *Node {Next: nil, 
							Data: (*Integer),
							data:{Value:'4'}, 
						Right: *Node {Next: nil, 
							Data: (*Integer),
							data: {Value:'5' }
			}

我们将不会支持全部的已知数据类型,这里支持整数int32,先看看我们的工作

	3 + 4 * 5

在整数堆中存入的结果 右侧先存入,其取出顺序为

	5,4,3

我们期望调用一次 VM IMUL 函数计算后的工作为

	3 + 20

再一次执行 VM IADD 函数计算后 剩下的工作为

	23

最后,只需要一个整数的取出调用和刷到控制台即可

	PRINTI
    23

因此,我们希望实现一个抽象机器,一个通用CPU,它拥有一组基本的标准操作说明,我们之前计算的表达式将映射到这些操作。 'IPUSH', 'IMUL', 'IADD', 'ISUB', 'IDIV', 'PRINTI', 'LAND', 'LOR', 'HALT'

诸如以下例子

	func (vm *TVM) IPUSH(v int) {
		...
	}

我们将在下一节实现它们。

5.1 实现 TVM虚拟机: 基于堆的TVM 模拟CPU的执行者

堆 在这里是数据结构,不是内存管理中操作系统的堆。 基于堆的虚拟机是解释器内部架构的一部分。

在基于堆的 VM 中执行指令非常简单。在最后,您还会发现将源语言编译为基于堆栈的指令集简直是小菜一碟。

然而,这种架构也足够快,可以被生产语言实现并广泛使用。感觉就像在编程语言游戏中弯道超车。

虽然基于堆栈的解释器不是灵丹妙药, 但是它们往往是足够的。 我们交给它一大块代码——实际上是一个块——然后它运行它。VM 的代码和数据结构驻留在一个新模块frame中。

我们从指令填充开始。VM 将逐渐获取它需要跟踪的一整堆状态,因此我们现在定义一个结构来填充所有内容。

目前,我们存储的只是固定地执行它的块。 把代码都注册到VM上下文管理器中,并且在执行时取出。 如果需要限定寄存器的大小,请在构造函数中进行限制,如 make([]int, 128)

// 标记是否运行中,是否全局等
type TVM struct {
	Pc       int
	Running  bool 
	IsTack   []int 
	Globals  map[string]any 
	Frame    *Frame
}

以下是乐趣所在,它发生在run() 中,当有代码执行时,它将持续运行,如果被调度器把运行状态置为false,虚拟机将停止运行。

每次遍历该循环,我们读取并执行单个指令并使用调度器执行,要处理执行一条指令,我们首先要弄清楚我们正在处理的是哪种指令,这将在调度器实现。

同时需要注意Pc码,因为每一次执行,我们将前进一步,也执行一次,它可以用于跟踪堆执行位置。 这将在执行大型程序时考验虚拟机的性能,因此这是整个虚拟机中性能最关键的部分。

编程语言的知识充满了有效地进行调度的巧妙技术,可以追溯到计算机的早期。 如果想要了解,可以参考诸如"thread code"、"skip table" 和 "goto",现代的PMG架构等等。

但是我们在这里专注实现一个基本可工作的虚拟机。

	//启动和执行,直到上下文全部完成
	func (vm *TVM) Run(VMCode *TVMContext) {
            
		vm.Pc = 0
		vm.Running = true
		vms := *vm
		for i, code := range VMCode.Code {
			code := code
			for vms.Running {
				vms.Pc += 1
				opp := code[0]
				nodes, err := strconv.Atoi(code[1]) 
				if err != nil {
					vms = CallFuncs(vms, opp, []reflect.Value{})
				} else {
					refVal := reflect.ValueOf(nodes)
					vms = CallFuncs(vms, opp, []reflect.Value{refVal})
				}
				break
				}
			} 
		}

		func CallFuncs(vm *TVM, ...) *TVM {}

在将来如果我们实现了控制流,可以更好的方式去执行它。但是现在不需要。 调度者根据其名称 Call 虚拟机中的相关函数,这里隐式的依赖了其他语言的规范。这将在下一节介绍。

这里需要的是继续实现寄存器的其他执行函数。正如上一节所讲,这里需要它们,当然还要栈协议支持两种操作 PUSH POP:IPUSH', 'IMUL', 'IADD', 'ISUB', 'IDIV', 'PRINTI', 'LAND', 'LOR', 'HALT'

	// 整数操作
	func (vm *TVM) IPUSH(v int) {
		vm.IsTack = append(vm.IsTack, v) 
	}

	func (vm *TVM) IPOP() int {

		lastOne := len(vm.IsTack) - 1
		if lastOne < 0 {
			empt := fmt.Sprintf("IsTack is empty:%v", vm.IsTack)
			panic(empt)
		}
		pv := vm.IsTack[lastOne]
		vm.IsTack = vm.IsTack[:lastOne] 

		return pv
	}

	// 加法
	func (vm *TVM) IADD() {

		right := vm.IPOP()
		left := vm.IPOP()

		rst := left + right
		vm.IPUSH(rst) 
	}

	func (vm *TVM) ISUB() {

		right := vm.IPOP()
		left := vm.IPOP()
		vm.IPUSH(left - right)
	}

	func (vm *TVM) IMUL() {

		right := vm.IPOP()
		left := vm.IPOP()
		vm.IPUSH(left * right)
	}

	func (vm *TVM) IDIV() {

		right := vm.IPOP()
		left := vm.IPOP()
		vm.IPUSH(left / right)
	}

	func (vm *TVM) AND() {

		right := vm.IPOP()
		left := vm.IPOP()
		vm.IPUSH(left & right)
	}

	func (vm *TVM) OR() {

		right := vm.IPOP()
		left := vm.IPOP()
		vm.IPUSH(left | right)
	}

	func (vm *TVM) HALT() {
		vm.Running = false
	}

	func (vm *TVM) PRINTI() {
		fmt.Printf("%v\n", vm.IPOP())
	}

这些函数的“实现”,帮助我们将VM状态初始化或释放,或计算 调用,以实现静态 VM 实例。

这里做的是一个全局变量,唯一的一个虚拟机,在大型编程时,可能有人听说过的全局变量的某些不好的东西,所以请避免在大型程序使用。

不希望这变成说教。 我也不会假装自己是任何语言的专家。 这里没有执行时跟踪,也没有完全的测试。

完全的测试有巨大的工作量,但是我们仍然希望有条件的人能够在使用它们前进行测试。

	3 + 4 * 5

	i8.push 3 [3]
	i8.push 4 [3, 4]
	i8.push 5 [3, 4, 5]  
	i8.mul [3, 20]
	i8.add [23] 
	i8.ipop [23]
	i8.printi [23]

现在已经有了执行运算的虚拟机,姑且称之为 TVM,也就是OTao Vritual Machine,属于通用stack machine. 仍然需要一个上下文管理器,来帮助调度和执行解析器返回的 代码和语句。

5.2 实现 注册代码: 虚拟寄存器,把指令注册到寄存器上下文

构造一个思想,如果时间太长就把注意力放在实践,它将改进理论思想,
执行实践,如果时间太长则把注意力放在思想,它将改善理论思想。

上下文是个啥东西,它帮助我们管理代码,它整理指令周围的信息,负责管理指令的执行范围,属于哪个环境,是否main入口。

最重要的是,它管理着我们的最终指令组,一如以前 我们计划在这里解析的语句,组织为 二元组的 代码块。

[][2]string{{'IPUSH', '3'}, {'IPUSH', '4'}, {'IPUSH', '5'}, {'IMUL',''} {'IADD',''}   {'PRINTI'.''} }

因此,我们定义一个 TVMContext 上下文管理器,只要管理我们的二元代码块

	type TVMContext struct {  
		Code     [][2]string 
		Scope    string
	}

我们将代码范围默认指定为 global,并且提供把代码块注册到虚拟机VM的 机制。

	/*
	检测代码的顶级函数
	*/
	func GenerateTVMC(mode *tlink) *TVMContext {

		tcontext := MakeWVMContext("")
		for mode.size > 0 {

			_, statements := mode.MvTheOne()
			Datas := statements.GetNodeData()
			if Datas != nil { 
				Generate(Datas, tcontext) 
			}
		}

		tcontext.Code = append(tcontext.Code, [2]string{"HALT"})
		return tcontext
	}

识别代码语句,将其存储在TVMContext,Generate执行这个工作,但是我们并不知道下一个Node节点中Data的实际类型。 这里使用 any,以便 接受 其不同节点值的类型。

	/*
	@param:node 结构体实例, 比如 PrintStatement,
	*/
	func Generate(nodeData *Node, context *TVMContext) string {

		node = nodeData.Data

		switch nt := node.(type) {
		case *PrintStatement:

			val := node.(*PrintStatement)
			ty := Generate(val.Value, context)
			if ty == "int" {
				context.Code = append(context.Code, [2]string{"PRINTI"})
				return ""
			}
			return "" 
		case *Integer:

			val := node.(*Integer)
			context.Code = append(context.Code, [2]string{"IPUSH", val.Value})
			return "int"  
		case *BinOp:

			var rettype string
			var instr [2]string
			nod := node.(*BinOp)
			leftype := Generate(nod.Left, context)
			Generate(nod.Right, context)
			// fmt.Printf("right type:%#v \n", righttype)
			rettype = leftype
			if leftype == "int" { //整型计算
				if nod.Op == "+" || nod.Op == "PLUS" {
					instr = [2]string{"IADD", ""}
				} else if nod.Op == "-" || nod.Op == "MINUS" {
					instr = [2]string{"ISUB", ""}
				} else if nod.Op == "*" || nod.Op == "TIMES" {
					instr = [2]string{"IMUL", ""}
				} else if nod.Op == "/" || nod.Op == "DIVIDE" {
					instr = [2]string{"IDIV", ""}
				} else if nod.Op == "&&" || nod.Op == "LAND" {
					instr = [2]string{"AND", ""}
				} else if nod.Op == "||" || nod.Op == "LOR" {
					instr = [2]string{"OR", ""}
				}
			}
			context.Code = append(context.Code, instr)
			return rettype
		default:

			msg := fmt.Sprintf("Couldn't generate %#v with nt:%#v ", node, nt)
			panic(msg)
		}
		return ""
	}

依靠以上的实现,我们可以将代码语句转换为虚拟机可以匹配和执行的形式

[][2]string{{'IPUSH', '3'}, {'IPUSH', '4'}, {'IPUSH', '5'}, {'IMUL',''} {'IADD',''}   {'PRINTI'.''} }

没有太多神奇的地方,对吗? 确实如此,这里重在理解其工作方式。 有趣的是如果拥有大量 tvm. 这样每个最终将拥有大量函数,将指向 VM的代码置入 context中管理,并在不同ENV中选择执行有大量工作。

这有一个讨巧的方法,但是也节省了一些时间。 相反,我们创建了一个全局调度器解析 对象 TVM,并显式地使用,以检索虚拟机的全部函数并进行匹配和执行。

请看下一节。

5.3 调度和执行指令:调度虚拟机_VirtualMachine 把指令集执行后结果返回到 VM 的堆栈

现在我们已经有了一组指令,还有了一些硬件执行措施,现在最需要的是调度和执行,比如这里的执行者组织,简单易懂 就叫CallFunc。

同时由于这里的虚拟机是全局唯一的,因此,我们只需要顺序调度在上一节产生的指令,然后在虚拟机TVM执行即可。 这里没有堆栈跟踪,但是在发送错误时,可以查找到哪一行的报错。

我们认为这样的东西通常对刚开始更好。 当表达式不是按照用户直觉的顺序计算时——在不同的实现中有可能以不同的顺序! 可能会很痛苦。

现在我们已经解析完成表达式,只需要按顺序执行即可。 那就很简单了对吗

 	/*
	@param T 待调用结构体
	@return 返回它的函数 
 	*/
	func Methods[T any](tt T) []string {
		var t T
		val := reflect.ValueOf(&t)
		typ := val.Type() 
		var NamesMethod []string
		for i := 0; i < val.NumMethod(); i++ {
			NamesMethod = append(NamesMethod, typ.Method(i).Name)
		} 
		return NamesMethod
	}

	/*
	@param tys 虚拟机实例
	@param fcName 函数名称 
	@param refVals 
	@return 返回虚拟机实例, 调用任意结构体的某个函数,用于查看函数执行结果.
	*/
	func CallFuncs(tys TVM, fcName string, refVals []reflect.Value) TVM {
		ms := Methods(tys)
		for _, m := range ms {
			if m == fcName { 
				reflect.ValueOf(&tys).MethodByName(m).Call(refVals)
				break
			}
		}
		return tys
	}

把它嵌入到虚拟机上下文管理器中即可。 这是一个简单的实现,但它已经足够我们完成当前的任务。

6 最后组织计算的入口: 中文表达式计算器_Evaluating Expressions 基于堆的TVM 虚拟机

我们将把之前的分词器,解析器,虚拟机整合为一个入口,为了醒目,我们使用: 提醒使用者计算结果在哪里。

	var filePath string
	if len(os.Args) < 2 {
		filePath = "./testxen.bl"
	} else {
		filePath = os.Args[1]
	}
 
	modelLinked := comp.ParseFile(filePath)
 	code :=  GenerateTVM(modelLinked)
	tvm :=  MakeTVM()
 	fmt.Printf("\n:\n\n")
	tvm.Run(code)

这就是完整的中文编程语言。

我们完成了挑战,在三个章节里完成一个可用的中文编程语言,也许可称之为计算器更多人容易接受。

我们使用了一种基于解释器的方法,去实现了中文编程语言,必须承认,这才刚刚开始,还有更多值得尝试的内容。

7 写在最后: 前景:穷途末路了吗

在现在的人工智能时代,有些人类学家,语言学家认为,其计算机用以处理现有的字符流,字节流,
有序依次或并行地处理输入,在工程上取得了优秀的成绩,
回顾发展历史,其根本逻辑的变化很小,也许深度学习的方式算得一些变化。 
但是这并不能表示人们已经完全掌握了 人类的进化机制,学习机制,人们如何面对未知和学习未知的机制。

———— web summary 大会记

更新更快的量子计算机,在将来我们也许可以处理更多更复杂的 宇宙尺度的数据,其承载的物理介质有些变化。

究其演化本质,是否仍然在重复人们自己, 是否值得重复,是个值得思考的问题。 关于更多尝试和优化Optimizatio

	在未来我们可做的事情:
	字节码 更通用地接近硬件
	内存管理
	
 	控制流,惰性计算
 	需要面向对象吗?
 	哈希表,闭包
 	按需扫描,跳跃 goto
 	类型系统
		类型检查, 类型互感
 	超类,方法和实例化,泛型,全局,局部变量,可变参数和类型。
 	跨通用平台
 	纯函数式的
 	LSP编译器向导

结语:

夜晚是美好的。我们已经完成了一天的计划。现在你可以来享受它了,也许可以看一看星辰。 选择发布在掘金这个平台,是希望借掘金平台这个巨人变得更强。 谢谢。