i春秋做家:immenmaphp
原文来自:从虚拟机架构到编译器实现导引【一本书的长度】html
在说些什么实现的东西以前,笔者仍然想话唠唠叨下虚拟机这个话题,一是给一些在这方面不甚熟悉的读者简单介绍下虚拟机的做用和包治百病的功效,二来是题头好好吹一吹牛,有助于提高笔者在读者心中的逼格,这样在文中犯错的时候,不至于一下就让本身高大上的形象瞬间崩塌。java
是的,当前虚拟化技术已经在计算机及互联网中烈火燎原了,从服务器到我的PC上直至手机甚至手表上,几乎都离不开它的身影,尽管当前提及虚拟机,更多的人想到的是Java是运行在JVM上的,而JVM是个虚拟机,或者你们很是熟悉的虚拟机软件VMWare或者是Virtual
Box,KVM等大牌虚拟机软件。但实际上虚拟化技术早而有之甚至不那么容易界定明显,例如,咱们在PC上想玩红白机游戏,那么FC模拟器就是个虚拟机,咱们想在PC上作作电路实验,试试咱们在89C51上编写的跑马灯好很差使,proteus也是虚拟机,甚至于作作虚拟仪器的业内标杆软件Labview,这个无需解释估计是最直观的虚拟机了,大多数虚拟机本质上是执行在宿主的一款程序,对宿主机CPU没办法直接执行的指令字节流或别的一些什么东西进行解释执行,最终达到某种功能或目的的一种程序,那么按照这种推论,浏览器里的JavaScript,Lua脚本语言运行环境,或者是Python算不算虚拟机呢,实际上大多数人更愿意使用解释器来描述这些语言的执行环境,可是无论怎么说,乍看之下其彷佛也彻底符合虚拟机的特性,无论是文字解释仍是将代码编译为字节流再进行解释本质上并无多少区别,所以笔者认为将它们归为虚拟机也一点毛病也没有,纠结这些毫无心义.算法
那么简单来说虚拟机究竟是什么呢,简单来讲,假如你只会中文而不会英文,某天一个老外要和你交谈,语言不通怎么办,请翻译啊,那么,这个翻译的做用就像虚拟机.编程
<ignore_js_op>windows
相信在文章的开篇就有人跳出来提出这个问题了,为何要本身作个虚拟机,如今那么多现成的执行体系很差么,为何要重复造轮子,况且这个轮子还未必有别人的圆,固然笔者写这篇文章的目的也并非吹嘘本身写的虚拟机有多好,但笔者相信只要你在计算机开发的游戏引擎或二进制安全或图形图像或是人机交互PLC工控…等等等领域之一混的足够久,你就会知道本身拥有本身的一套虚拟机系统所带来的好处和必要性了,笔者相信时间和经验会告诉你作一件事有没有这种必要,而别人说再多话也是别人说的,固然,就像别人设计的虚拟机再好也是别人的,本身设计的虚拟机再挫也是本身的.当你在一套虚拟系统中有所疑惑或者是须要额外的功能实现时,你就知道”就算别人的花儿再漂亮,本身种的终归是本身种的”这句话的深入含义了.数组
那么,一套虚拟机系统有什么好处呢?笔者总结了部分功能.浏览器
<ignore_js_op>安全
执行环境可控,毕竟每一条指令都由虚拟机负责解释执行,这意味着能够对程序执行的每个流程进行控制,虚拟机做为一个沙盒环境,对权限控制,调试都有诸多好处。尤为是在权限的控制中,能够避免一些恶意程序对原生系统的破坏,固然,这有没有让你有种主宰世界的感受。服务器
<ignore_js_op>
固然若是说读者读完这篇文章就能立刻撸出一个虚拟机+完整的编译系统,那真是难为笔者了,这也就是为何题目中笔者加了导引两个字,这是一个庞大的工程,笔者没法在寥寥万余字中将每个技术细节都说明白,当中涉及的东西太多不只在书里更要在实现中去体会而不是纸上谈兵(相信我,那些把编译理论吹得稀烂的大多本身也没有写过编译器最多就使用LLVM描了个边,当他们真正从底层去处理词法和语法上的问题基本也是一脸懵,那一刻就会体会到真正要他们解决的玩意书和那套听不懂的装逼理论并不会告诉他们),虽然书不能帮你解决全部问题,但笔者仍然向有兴趣写编译器虚拟机的推荐\<\<编译原理\>\>\<\<游戏脚本高级编程\>\>这两本书,前者有一套完整的编译理论的构架对学习编译优化很是有好处,固然其实很大一部分理论用不到,后者那本书则是结结实实的写了一套完整的虚拟机和编译器,不过也厚的多,足足2本书近千页的内容(固然,仍是没法彻底说起每个技术细节:),但很是有参考价值值得一读.最后是笔者的这篇文章了,是的,虽然没法让读者直接写出个编译器和虚拟机,可是至少能够说个大概,2w字的篇幅说长不长说短不短,半个小时就能读完,买不了吃亏买不了上当,可让你初略地了解下虚拟机和编译器究竟是怎么回事.
在码农界设计一个虚拟机一直被当作高端大气上档次的事.然而事实上虚拟机的设计并无什么复杂的内容,乃至于说解释器里的语法和词法分析都比虚拟机复杂的多,当前,假如你想设计一款和VMWare或者Virtual
Box同样的的虚拟机软件,那当我以前的话没说,在本文中,笔者经过设计一款较为简单的虚拟机程序而且在虚拟机完成咱们须要的功能.
所以,在开始这个项目以前,咱们首先要确立如下目标
咱们要虚拟机主要功能是什么,功能的不一样,将很大程度改变虚拟机的架构,例如是咱们的虚拟机实做为游戏引擎的脚本控制,那么咱们的指令设计就应该尽量的精简稳定并便于优化,这样使用虚拟机设计的游戏引擎才不至少不至于在虚拟机方面卡成PPT让每个玩家骂娘,而若是是作算法的反逆向保护,那么咱们就要好好的藏好咱们指令集“真正的”功能,甚至不作优化让破解者绕圈圈,这个时候冗余设计反而有助于保护咱们的算法。
咱们的虚拟机开发环境是什么,用在什么地方,固然,这包括使用什么语言,什么环境来开发咱们的虚拟机都在考虑的范围以内,固然,当前至关一部分有名的虚拟机环境都选择使用C语言进行开发,这有不少好处,首先目前绝大部分的运行环境都提供C语言的编译器,虚拟机写好后,能够很是方便地在多平台进行移植,再者只要编译器给力,C语言编译出来的指令流相对执行效率优秀,这对容易带来明显性能损失的虚拟机尤其有利,最后,C语言学习较为容易,学习成本不高,能让咱们把更多的注意力放在“如何实现”而不是“如何让别人以为我代码语法糖写的有多牛逼”上。
咱们的虚拟机的指令集如何实现,固然这是一个笼统的说法,这还包括如何咱们虚拟机如何对内存进行管理,须要哪些寄存器,这些寄存器分别由什么用,指令的数据结构是怎么样的之类多种的问题,不过不用担忧,咱们有不少现成的指令集能够供咱们参考,例如MIPS这种指令集至今仍做为众多CPU粉乐于用虚拟机模拟实现的指令集,这原于这个指令集的精简并容易实现,所以每一年的毕设上,总能看到相关的设计论文,相对的x86
ARM指令集就复杂得多,但不用担忧,咱们能够学习他们部分的实现,管中窥豹可见一斑,即便咱们只完成了一部分实现,对于咱们的虚拟机而言,大部分的功能也足够实现了.
如何设计调试器方便咱们的虚拟机调试,这个是很是重要的一点,假如你设计的虚拟机没有对应的调试方案,那么即便是做为虚拟机做者的你编写的虚拟机程序进行亲自调试,也将会是一场噩梦,所以在你真正动手开发虚拟机以前,你最好想好你的调试器如何架构在你的虚拟机之上.
虚拟机的运行方式及IO,简单来讲就是你得想好你的虚拟机如何去解析指令执行,另外虚拟机执行完后也不能光运行啊,算法把结果算出来了总得把结果输出来,这就涉及到了虚拟机和本地代码的数据交互,这些都须要提早考虑好.
<ignore_js_op>
项目开始的第一步,天然没多少难度,不过但凡干大事者,都要先立个名号,虽说这并不算什么大事,但笔者自认为是一个比较中二病的人,所以,冥思苦想仍是得给这个虚拟机项目取个名字.好比终结者,双子星,地球毁灭者,宇宙收割机之类的,不过好像又过了那个年纪,仍是务实一点,固然,本篇文章的事例虚拟机取自笔者早前已经写好的一个虚拟机项目,它被用在游戏引擎,嵌入式系统控制及UI界面当中,名字是早已订好了叫StoryVM,属于StoryEngine(游戏/图像引擎)的一部分
<ignore_js_op>
至于为何叫StoryVM,笔者本身也不是很清楚.就以为叫着舒服,固然,本篇文章的目的也是告诉你们这个虚拟到底如何实现的,读者们若是有兴趣,不妨也花点时间为本身的虚拟机项目起个霸气的名字和LOGO,万一火了,那么就能够为这个虚拟机名字怎么来的想个故事了.
在开始部署咱们的虚拟机程序以前,咱们先来复习一下计算机专业的经典知识点,冯诺依曼的计算机体系结构和哈佛结构,相信计算机系的看官们应该并不陌生毕竟多多少少都有几位是栽在他们手上的
<ignore_js_op>
冯诺依曼和哈佛结构主要的不一样点是程序和运行数据是不是存储在同一个空间中,实际上两种体系虚拟机都可以实现,毕竟时间有限,所以,笔者没法将两种体系的虚拟机实现都说一遍,出于演示和尽量偷懒原则,笔者在本文中采用的是哈佛结构体系的虚拟机架构,为何使用哈佛结构(程序和数据分开存储)呢,其中有如下几点好处
从执行安全性考虑,方便进行越界检查,当指令地址不在指令的存储范围内时,确定是无效指令越界访问了.
二进制漏洞十有八九都是越界访问或者缺乏边界检查的数据修改形成的,程序修改程序形成远程代码执行的事儿我们也不是第一天见过了,所以,分开存储有利于提升脚本的安全性与可控性.
那么,这个虚拟机的数据空间看起来是怎么样的呢:
<ignore_js_op>
是的,笔者将虚拟机的各个数据都进行分类存储,一来不只便于访问,二则方便管理及权限控制,只要设计得当,常量区的只读数据就能获得很好的保护,程序代码区也不会被修改,须要注意的是,笔者仍然将栈空间和堆空间设计在同一空间里,固然这是有必定缘由的,这点我会在后面的章节中说出缘由.
那么从如今开始,咱们就要开始接触一些编码方面的东西了,固然,为了保证这篇文章尽量的受众面广,笔者并不打算过于地强调用何种语言来编写这个虚拟机,但笔者编写这个虚拟机使用的是C语言进行开发的,所以在不少的地方,仍然不可避免须要使用C语言中的一些代码对功能的实现进行说明,明显的,本文并不打算写有关C语言怎么写之类的问题,所以若是读者不熟悉C语言的话,笔者仍然建议读者自行查阅相关资料.
在虚拟机开发的第一步,咱们先来了解一下元数据
什么是元数据呢,简单来讲就是虚拟机可以定义的最小单位,咱们以C语言为例,C语言排除结构体和修饰符外,那么可以定义char
short int float double
long….等几种类型,其中,char类型不论在哪一种编译器下一定占据一字节,排除位域或编译器额外的实现,char是C语言可以定义的最小数据大小,所以咱们称char为元数据类型
而在Basic语言中,定义则简单的多,能够直接使用dim a=3141,或者dim b=”hello
world”来定义类型,在这个时候,类型所需的内存空间再也不是一个定数.在这里定义的元数据类型能够是一个整数小数或者是字符串类型.
在不少的状况下,咱们把相似于C语言的类型称为强类型,表示类型间没法直接相互转换,而Basic则称为弱类型,不一样类型间能够相互转换.
固然,对于那些依靠CPU直接执行的指令,基本不会采用弱类型的数据访问方式,这将致使电路实现过于复杂,可是虚拟机彻底能够采用弱类型的方式来编码数据的访问,这主要有如下几个优势.
编写程序时简便的多,这意味着开发虚拟机程序时不用花过多的心思在数据转换上
写起来方便,看起来直观,好比”Hello”+”World”这样的字符串相加运算,顺手
固然,弱类型带来了优势,缺点也很多
显然的,虚拟机的实现要复杂的多,必须考虑资源分配、深浅拷贝、垃圾回收等问题。
一定带来性能损失,尤为是面临深拷贝和垃圾回收。
不论内存管理如何优秀,这种分配回收机制都不可避免引起更多的内存碎片
对一些特殊类型的操做,好比字符串,可能须要额外增长一些关键字来增强对类型功能的使用,好比字符串不可避免须要引入strlen函数统计其长度,或者用[]运算符修改其某个字符.
“数字:”+4294967295的结果应该是字符串’’数字:4294967295”么?,要知道,4294967295在32位类型中和数字-1是同样的,若是”数字:”+4294967295是字符串”数字:4294967295”那”数字”+-1又该是什么,你能够说给类型定义有符号仍是无符号的标识啊,那么好,定义一个类型为100,那么这个100是有符号仍是无符号的,你能够说加修饰符来修饰啊,那问题又来了,既然要加修饰符,那我还用弱类型作什么,绕个弯子再自找麻烦么.
总结了弱类型的几个好处,但同时咱们也发现其糟糕的地方也很多,那咱们虚拟机须要设计成支持弱类型的访问么,固然要,弱类型有如此多的好处,不能由于他存在某些缺点就全盘否认,但这也是为何本章标题笔者起名为元数据而非”都听好了,咱们要设计一个弱类型数据访问型的虚拟机”,为了规避弱类型访问的一些缺点,咱们须要对虚拟机的数据结构进一步改造,咱们能够这样规定,一个元数据(多是常规寄存器,堆栈里的某一数据)能够是一个整数,小数,或者是字符串或数据流类型,可是,不一样的数据类型不可以直接进行运算,须要使用特殊的指令进行操做,这样咱们就解决了弱类型带来的歧义的问题.
说完了理论,咱们来看看实践,咱们先来看看C语言如何定义一个元数据
typedef struct
{
int type;
union
{
char _byte;
char _char;
word _word;
dword _dword;
short _short;
int _int;
uint _uint;
float _float;
string _string;
memory _memory;
};
} VARIABLE;
观察结构体VARIABLE定义,其中type表示该变量的类型,在该虚拟机中,有以下枚举定义
typedef enum
{
VARIABLE_TYPE_INT,
VARIABLE_TYPE_FLOAT,
VARIABLE_TYPE_STRING,
VARIABLE_TYPE_MEMORY,
} VARIABLE_TYPE;
VARIABLE_TYPE_INT,表示这个数据是一个整数类型定义,
VARIABLE_TYPE_FLOAT表示这个数据是一个浮点类型,
VARIABLE_TYPE_STRING表示这是一个字符串类型定义
VARIABLE_TYPE_MEMORY 表示这是一个数据流类型
接下来是一个联合体,数据类型公用一块内存尽量节省一个元类型占用的内存空间
若是在高中数学的角度上来讲,6和6.00这两个数字并无什么区别,6.00后面的两个0能够省略,可是在不少的编程语言当中,6和6.00有着本质上的区别,6是一个整数,6.00是一个浮点数,它们在内存中的布局经常天差地别,同时,6/4和6.00/4的结果也大相径庭
笔者在本章开头写这个,目的并非给读者讲解整数和浮点数编码的区别,而是但愿说起一点,数据的不一样写法所表达出的数据也大相径庭,那么StoryVM支持几种数据呢,在上一章节咱们已经讲过元数据的组成方式,从结构体定义咱们能够看到,支持char
short int uint float string memroy几种类型(word ,dword..本质上是unsigned
short和unsigned
int),可是笔者并不打算让StoryVM关注于如此多的类型,所以,在笔者设计的StoryVM中,仅仅支持int
float string memroy四种类型
读者可能会表示疑问.若是我须要表示一个无符号数,或者只须要表示一字节那怎么办,int类型不是只能表示有符号整数呢
其实按照读者的设计,在方便的时候,数据长度能够宁多不宁少,int类型彻底能够用来表示字节类型,无非是使用时本身注意点将它当作字节类型来用时不要超过255就好了,而有符号无符号类型在内存中表示其实并无什么出入,例如,-1和4294967295在内存中并无什么区别,而有符号数适用面更为普遍,至于到底显示出来时是有符号或者是无符号,彻底能够靠本身把握.
在StoryVM中,如何使用汇编表示一个数据类型呢
显然的 int类型能够直接使用数字来表示,例如
12345,这个是一个合法的int类型,固然,为了方便,还引入了十六进制表达,例如0xffffffff也是一个合法的int类型,固然,须要注意的是StoryVM最大支持32位的整数类型,这也意味着十六进制范围是0\~0xffffffff,最后是字符类型,例如‘A’表示字符A的asc码值,也是一个合法的整数类型,’B’表示字符B的ascii码值…..以此类推
Float类型应该无需笔者多说了,1.0,3.14,6.66都是合法的float类型
String也就是字符串类型和C语言的字符串表示保持一致,”Hello
World”这就是一个合法的字符串类型,用双引号包含,固然,和C语言有些不一样的是,字符串类型中仅支持\r\n\t三种类型转义
最后是数据流类型,这个是StoryVM中自定的一种数据类型,理解起来并不复杂,例如
\@0102030405060708090A0B0C0D0F\@这就是一个数据流类型,一个数据流类型使用两个\@包含,当中的文本是一个十六进制表示的数据流,两两为一对为一字节,这也就意味着当中的字符数必须在范围0-F中,而且一定是偶数个.
在开始设计具体的指令前,咱们先来考虑下虚拟机指令集如何设计,固然,当前的指令集大多以以下的模式设计:
<ignore_js_op>
其中,操做码表示这条指令的具体做用,例如x86汇编中的MOV eax,1中的mov就是操做码
紧接在操做码以后的是操做数类型(或者也能够叫参数类型),例如上上面这条汇编指令中一共有2个操做数(参数),分别是eax和数字1,它们分别表示一个寄存器类型和一个当即数类型,最后是操做数了,也就是咱们常说的参数.
固然,上述的规则适用于大多数的指令编码格式,对于一些很是经常使用的指令,甚至会将一些操做数给”集成”到操做码中,例如上述的MOV
eax,1指令中,mov
eax,被直接用E8代替,而操做数1则直接使用一个dword来设置,在这条指令中,只有一个操做码和一个操做数.
如此的设计能够保证编译的程序尽快能的小,可是做为代价,执行对于的指令集的CPU或虚拟机也须要设计更多的实现而变得愈来愈复杂
设计出x86相似的复杂指令集须要耗费大量的心血,但在咱们的虚拟机系统中,咱们无需使用如此复杂的指令集设计
笔者斟酌了定长指令和不定长指令的一些特色,设计出以下的一套指令集规范
操做码以1字节进行标识
接着是3字节的操做数类型
<ignore_js_op>
这意味着咱们的指令设计每一个指令至少占4字节宽度,而且最多只能接受3个操做数.
在CPU设计,寄存器用于数据的暂存,在电路设计中,这不一样的寄存器被赋予不一样的意义,在笔者的虚拟机架构中,并不须要关注电路设计如此复杂的内容,但笔者仍然将寄存器设计分为两种寄存器,一种是临时数据寄存器,一种是特殊寄存器.
其中,临时数据寄存器本质上就是以前提到的元数据,它与堆栈中的元数据并无别的区别,访问临时寄存器用R+寄存器标号的方式访问,在笔者设计的虚拟机中,每一个虚拟机实例一共有16个这样的临时寄存器,用R0\~R15对他们进行访问.
以后是三个特殊寄存器,SP,IP,BP,若是有阅读过汇编代码的读者应该对这三个寄存器再熟悉不过了,SP永远指向栈顶,IP寄存器指向当前正在执行的指令,BP更可能是为了支持函数调用中寻找参数的偏移地址用的,提早将它加进来为后期设计高级语言的编译器作下准备
这三个特殊寄存器都是dword类型,这意味这咱们的虚拟机最大的寻址范围是4GB.
在虚拟机的堆栈是由元数据构建起来的,固然,栈的增加方向为高地址向低地址,而堆的方向则是低地址到高地址
<ignore_js_op>
在StoryVM中,通常使用GLOBAL[索引号]访问堆栈的元数据,通常使用LOCAL[索引号]来访问栈数据
固然
GLOBAL[BP+i]和LOCAL[i]是等价的,LOCAL表示在偏移量加上一个BP寄存器的值,主要用来访问参数和临时变量.
实际上不只仅是虚拟机,目前咱们见到的大部分的计算机架构均可以把程序当作一张很长的写满指令的纸条,而计算姬要作的就是从头读到尾,并从头执行到尾,咱们的虚拟机一样遵循着这样的”执行守则”
当一个脚本被编译为指令流后,虚拟机依次读取一条指令而后执行,固然,指令也并非彻底按照顺序读取,由于指令当中也包含一些”跳转”指令,这将会让虚拟机”跳转”到纸条的其它地方执行指令.
固然,虚拟机设计是一个庞大的须要深思熟虑的系统,若是考虑IO(输入输出)及中断,多线程的线程调度的话,咱们没法简简单单用一个字条来描述一个虚拟机的执行过程,可是在文章的开始,初学者依照这个比喻,对虚拟机是如何运行的有个初步的概念.
<ignore_js_op>
笔者在最初设计StoryVM的时候,指令只有短短的几条,一个指令集的完善,不能仅仅是靠初期想固然的脑补,在StoryVM部署到实际的项目以后,笔者再不断去添加那些须要的指令,固然,在本文当中笔者并不打算演示全部的指令实现,笔者决定挑选几个很是具备表明性的指令进行讲解,固然,首当其冲的就是mov这条指令,这也就是为何本章笔者并不把标题起为虚拟机指令设计,笔者认为,有几个特殊指令是值得专门花费一章节去讲解的.
那么,MOV指令是怎么回事,有什么用呢
其实很是简单的说法,这是一个数据传送(赋值)指令,好比下面的算式
i = 314
就是把变量i赋值为314
固然,若是把这条语句写为StoryVM的汇编代码形式,那么就是
MOV\ i,314
固然,i在汇编中并不存在,假设它是一个全局变量,那么它应该在堆中,假设它在GLOBAL[0]的位置,那么,应该写成
MOV\ GLOBAL\lbrack 0\rbrack,314
这样,GLOBAL[0]就被正式赋值为一个整数,为314,固然看到这里,你应该会以为MOV指令很是简单,例如,下面的汇编语句即便笔者不说读者也很容易理解要表达的意思
MOV R1,123 //R1寄存器赋值为123
MOV R2,3.14 //R2寄存器赋值为3.14
MOV R3,”Hello World” //R3寄存器赋值为字符串”Hello World”
MOV R4,\@0102\@//R4寄存器赋值为两字节长度的0x01 0x02
上面的语句没什么问题,其中,R1和寄存器R2顺利地被赋值到了元数据寄存器中,可是R3和R4不得不说起一下,当一个元数据被赋值为一个字符串和数据流类型时,不可避免地涉及到了内存分配的问题,但还好,这实现起来并不复杂,当一个寄存器被赋值为了字符串或者数据流类型时,从内存池中划出一块内存将字符串或数据类型存储进去,而后这个元数据中指定一个字符串指针指向它就好了.
<ignore_js_op>
那么咱们来看看下面的两个语句
MOV R1,123
MOV R1,”Hello”
先将寄存器R1赋值为123,而后再将字符串赋值到寄存器R1中(在内存池申请内存,而后将元),这没什么问题,可是咱们将两个语句换一下,那么问题就来了
MOV R1,”Hello”
MOV R1,123
首先,R1寄存器被赋值为”Hello”,在这以后,它又被赋值为123,这将会带来一个问题,若是将R1直接赋值为123,为字符串hello在内存池分配的空间将得不到释放,所以,当一个字符串或是数据流时,在内存池分配的空间都应该被释放
从上一章节MOV的讨论中咱们能够看出当一个元数据由一种类型变换为另外一种类型时,一是可能伴随着内存的分配,既然有了分配那就应该有回收机制,例如当一个元数据由int类型变为了string类型,那么,在内存池中必须申请一块内存区域用于存储字符串类型,而由string类型变为了int类型,将伴随着string类型所占用的内存释放,也就是内存的回收,那么在何时内存须要分配而何时须要回收呢,笔者总结了如下几种状况
当一个数据由其余类型变为了string或者是memory类型时,须要在内存池中为其分配内存空间.
即以下代码
MOV R1,”ABC”
MOV R2,R1 //须要为R2分配内存以进行字符串类型的拷贝
当一个元数据由string或memory类型变为其它类型时,固然,这也包括string类型变为memory或者是memroy类型变为了string类型,须要对原内存进行回收
MOV R1,”Hi”
MOV R1,”Hello”
那么,R1所在的字符串所在内存可能由于字符串长度的改变须要进行从新分配,须要分配与回收
由于采用了这种”元数据”的存储方式,对于字符串及数据流类型,不可避免地就须要好好思考下如何管理内存了,毕竟内存泄漏在虚拟机的执行过程当中是决不容许的,谁也不但愿本身的程序跑着跑着内存就被一点点吃光最后致使崩溃.这种内存管理机制咱们经常称之为GC(garbage
collection 垃圾回收机制)
不过在StoryVM中,咱们只要遵循并注意这个”元数据”的类型切换时内存的管理就能够避免内存泄漏了,除此以外咱们也注意到了,内存的回收分配机制,是一个很是耗费性能的调度机制,而且优化的难度大且难以免,这也难怪在网上常常看获得对java这种重度依赖GC的语言被各类的吐槽,不过幸亏在StoryVM中咱们使用的是自行架构的内存池方案,避免了直接使用malloc/free等须要syscall的API额外调用开销.
固然,不只仅是mov指令,全部对元数据形成修改(无论它是寄存器仍是堆栈中的元数据)的指令咱们都须要遵循上述的gc规则进行管理,否者结果一定是灾难性的,笔者使用mov指令作”抛砖引玉”之用,是由于Mov指令太具备表明性了,这个看上去最简单的指令,其实现倒是storyVM中最复杂的指令,不过读者们也无需担忧,在mov指令设计完成后,剩下的指令要设计起来就简单多了.
若是说让个小学生作个加减乘除运算,想必并也并非什么复杂的事情,但在StoryVM上,咱们考虑的就有点多了.
首先咱们先来看看下面两个表达式
1+1=2
1+1.0=2.0
在数学的意义上,1和1.0是等价的,上面两个表达式一样是个等价的表达式运算,可是,在计算机当中,数字的不一样表示方式可能致使大相径庭的运算规则,首先,整数和浮点数的编码在计算机中是不一样的这也就意味着
1+1是一个整数运算而1+1.0是一个浮点类型的运算
出于精度的考虑,在计算的结果中咱们会以精度更高的表达方式进行表达,所以.当一个整数和一个浮点数进行运算后,它的结果也是一个浮点数.这点在StoryVM中须要被认真的考虑,与此同时的,在编程开发时,咱们也经常使用的到浮点截断(去掉小数点后面的数值),所以,必须也设计相应的指令,将浮点数转换成整数,或者是将一个整数转换成浮点数的表示方式
在StoryVM的指令运算设计中,双目运算符(须要两个数字进行运算的操做符)遵循如下的运算规律
整数与整数运算,获得的也是一个整数
整数与浮点数运算,获得一个浮点数
同时,浮点数的编码方式也须要被严格的考虑,若是由于编译环境的不一样而致使浮点的编码方式不一样,那么脚本在跨平台运行方面就会出现错误,但幸运的是,StoryVM使用C语言进行编写开发,而C语言的编译器基本都使用IEEE
754的标准对浮点数进行编码.
在StoryVM中,参考了x86指令中加减乘除的助记符,加减乘除的汇编指令分别为
ADD SUB MUL DIV
写起来也基本相似,例如要实现1+1=2的这个表达式方式,指令编写以下
MOV R1,1 //寄存器R1赋值为1
ADD R1,1 //R1+1=2
在ADD R1,1指令中,ADD称之为操做码,R1,1称之为操做数,其中R1位操做数1,数字1为操做数2
实际上严格来讲,ADD应该称之为加法操做码对应的助记符(mnemonic),R1是寄存器1对应的助记符,1是一个常量,固然,ADD函数遵循着运算隐式转换的规则,若是指令改成
ADD R1,1.0
那么寄存器R1对应的元数据类型也会相应的转换为一个浮点数据类型.
若是汇编器将ADD,R1,1这个指令编译成指令流,那么,它应该是下面这个样子的
<ignore_js_op>
参照以前提到的编码格式,其对应的指令流为0x02 0x02 0x01 0x00 0x00000001
0x00000001一共12字节.
下面,咱们用opcode表示操做码.op1表示操做数1,op2表示操做数2,GLOBAL表示接受堆数据数据类型,LOCAL表示接受栈数据数据类型,REG表示接受寄存器数据类型,int,float,string,memory分别表示接受整形,浮点型,字符串型和数据流型常量.num表示接受一个数字,便可是是浮点类型也但是整数类型.
例如减法指令sub的描述以下
减法指令,op1=op1-op2,将操做数1的值减去操做数2的值,而后将结果赋值给操做数1
固然,操做数1必须是一个寄存器或者是堆栈中的元数据,由于常量不能被赋值,操做数2能够是一个寄存器或者堆栈中的元数据或者一个数字常量均可.
sub [reg,local,global],[num,reg,local,global]
依次类推,那么,在StoryVM中,几个运算指令的描述以下(为了说明方便,后面的表达式用C语言的运算符进一步描述)
add
加法指令,op1=op1+op2
add [reg,local,global],[num,reg,local,global]
sub
减法指令,op1=op1-op2
sub [reg,local,global],[num,reg,local,global]
neg
符号求反指令,op1=-op1
neg [reg,local,global]
div
除法指令,op1=op1/op2
div [reg,local,global],[num,reg,local,global]
mul
乘法指令,op1=op1*op2
mul [reg,local,global],[num,reg,local,global]
mod
余数指令,两个操做数必须为整数 op1=op1%op2
mod [reg,local,global],[int,reg,local,global]
shl
左移位指令,两个操做数必须为整数 op1=op1\<\<op2
shl [reg,local,global],[int,reg,local,global]
shr
右移位指令,两个操做数必须为整数 op1=op1\>\>op2
shr [reg,local,global],[int,reg,local,global]
and
与运算指令,op1=op1&op2
and [reg,local,global],[num,reg,local,global]
or
或运算指令,op1=op1|op2
or [reg,local,global],[num,reg,local,global]
xor
异或运算指令op1=op1\^op2
xor [reg,local,global],[num,reg,local,global]
inv
位取反指令,op1=\~op1
inv [reg,local,global]
not
逻辑非指令 op1=!op1
not [reg,local,global]
andl
逻辑与指令 op1=op1&&op2
andl [reg,local,global],[num,reg,local,global]
orl
逻辑或指令 op1=op1||op2
andl [reg,local,global],[num,reg,local,global]
pow
阶乘指令(op1为底数,op2为指数,结果在op1中) op1=op1_op2
pow [reg,local,global],[num,reg,local,global]
sin
正弦函数op1=sin(op2)
sin [reg,local,global],[num,reg,local,global]
cos
余弦函数 op1=cos(op2)
cos [reg,local,global],[num,reg,local,global]
int
强制类型转换为int型(原类型float)
int [reg,local,global]
flt
强制类型转换为float型
flt [reg,local,global]
相比于可能修改元数据须要当心翼翼管理内存的指令,条件跳转指令的实现可就简单的多了,惟一须要注意的是,如何肯定跳转的位置,在x86指令集中,跳转指令的设计就复杂的多了,有近跳转,远跳转,相对跳转和绝对跳转,可是在StoryVM中,跳转指令并不须要设计的那么复杂,全部的跳转指令都为绝对跳转.
在StoryVM中,使用JMP指令表示一个无条件跳转指令,例如
JMP 10表示程序跳转到地址为10的位置执行
这么设计固然没有一点问题,可是咱们不可能在编写程序时,手工去计算咱们要跳转的位置,那么问题就是如何肯定跳转的地址了,幸运的是,每条指令的长度均可以很方便的进行计算,咱们只须要设计一个标志,就能够很容易计算出标志所在的地址了
在StoryVM中,标志的表示方式是一个助记符加上一个冒号,例如
FLAG:
MNEMONIC:
ADDR:
TRUE:
都是合法的标志类型,不少时候为了表现这是一个函数,在标号前能够加入描述符FUNC
例如
FUNC FLAG:
和
FLAG是等价的,FUNC这个助记符对源代码没有任何的影响,只是为了代码方便查看及分类添加的一个没有意义的关键字
如今观察下面的指令
MOV R1,1 //长度为12字节
ADD R1,2 //长度为12字节
FLAG: //偏移地址为24
ADD R1,2//长度为12字节
JMP FLAG//跳转到FLAG处开始执行
须要注意的一点是,若是一个汇编程序从开始编译到结束,那么,标号必须在JMP指令以前,也就是说JMP必须是向前跳转的,这显然不符合一个跳转指令应该具备的功能,要解决这一个问题实际也并不复杂,在汇编指令的编译期间,对源代码进行两次扫描,第一次扫描肯定全部的标号对应的位置,第二次扫描才将JMP指令”链接”到对应的标号中实现跳转,
<ignore_js_op>
除了无条件跳转指令,固然还有一系列的跳转指令,具体描述以下
je
条件跳转,当op1等于op2,跳转到op3
je [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jne
条件跳转,当op1不等于op2,跳转到op3
jne [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jl
条件跳转,当op1小于op2,跳转到op3
jl [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jle
条件跳转,当op1小于等于op2,跳转到op3
jle [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jg
条件跳转,当op1大于op2,跳转到op3
jg [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jge
条件跳转,当op1大于等于op2,跳转到op3
堆栈操做自己属于数据结构的一种,固然,堆栈的操做和特殊的寄存器SP,BP相关,就和咱们以前说的那样LOCAL[i]和GLOBAL[BP+i]是等价的,而SP指令和
PUSH
POP
两个指令相关
PUSH x指令实际上和下面的指令等价
SUB SP,1
MOV GLOBAL[SP],x
也就是说,每当执行一条PUSH指令,SP寄存器的值会被减去1,而后将PUSH的值赋值到对应的堆空间GLOBAL[SP]中
而POP x指令和PUSH指令恰好相反,其等价于
ADD SP,1
MOV x,GLOBAL[SP]
在指令的编写及设计中,PUSH指令和POP指令经常是成对出现的,也就是咱们经常说的要保证堆栈平衡,若是PUSH
POP不成对出现,每每容易致使内存泄漏,越界访问等异常现象.
实际上虚拟机有上述指令就已经能够完成大部分的工做了,但除了通常的跳转指令,在StoryVM中还设计了一个特殊的指令CALL指令,CALL指令自己并无什么特别的地方,它的做用是将下一条指令的地址压栈,而后跳转到目的标号中,CALL指令的设计一样是一种数据结构上的调度处理,当咱们为这个汇编语言设计高级语言编译器的时候,CALL指令就会被常常的用到.
介绍完几种关键的指令后,最后贴上StoryVM所支持的全部指令集,读者能够参照这些指令自行实现
mov
赋值指令,将op2的值赋值给op1
mov [reg,local,global],[num,string,reg,local,global]
add
加法指令,op1=op1+op2
add [reg,local,global],[num,reg,local,global]
sub
减法指令,op1=op1-op2
sub [reg,local,global],[num,reg,local,global]
neg
符号求反指令,op1=-op1
neg [reg,local,global]
div
除法指令,op1=op1/op2
div [reg,local,global],[num,reg,local,global]
mul
乘法指令,op1=op1*op2
mul [reg,local,global],[num,reg,local,global]
mod
余数指令,两个操做数必须为整数 op1=op1%op2
mod [reg,local,global],[int,reg,local,global]
shl
左移位指令,两个操做数必须为整数 op1=op1\<\<op2
shl [reg,local,global],[int,reg,local,global]
shr
右移位指令,两个操做数必须为整数 op1=op1\>\>op2
shr [reg,local,global],[int,reg,local,global]
and
与运算指令,op1=op1&op2
and [reg,local,global],[num,reg,local,global]
or
或运算指令,op1=op1|op2
or [reg,local,global],[num,reg,local,global]
xor
异或运算指令op1=op1\^op2
xor [reg,local,global],[num,reg,local,global]
inv
取反指令,op1=\~op1
inv [reg,local,global]
not
逻辑非指令 op1=!op1
not [reg,local,global]
andl
逻辑与指令 op1=op1&&op2
andl [reg,local,global],[num,reg,local,global]
orl
逻辑或指令 op1=op1||op2
andl [reg,local,global],[num,reg,local,global]
pow
阶乘指令(op1为底数,op2为指数,结果在op1中) op1=op1_op2
pow [reg,local,global],[num,reg,local,global]
sin
正弦函数op1=sin(op2)
sin [reg,local,global],[num,reg,local,global]
cos
余弦函数 op1=cos(op2)
cos [reg,local,global],[num,reg,local,global]
int
强制类型转换为int型(原类型float)
int [reg,local,global]
flt
强制类型转换为float型
flt [reg,local,global]
strlen
字符型长度指令
op1=strlen(op2)
strlen [reg,local,global],[reg,local,global,string]
strcat
字符型拼接指令
strcat(op1,op2)
strcat [reg,local,global],[int,reg,local,global,string]
strrep
字符串替换函数
将op1存在的op2字符串替换为op3中的字符串, 注意:op2 op3必须为字符串类型
strrep [reg,local,global],[reg,local,global,string],[reg,local,global,string]
strchr
将op2在索引op3中的字存储在op1中, 注意:op2必须为字符串类型
strchr [reg,local,global],[reg,local,global,string],[reg,local,global,int]
strtoi
将op2转换为整数保存在op1中,注意:op2必须为字符串类型
strtoi [reg,local,global],[reg,local,global,string]
strtof
将op2转换为浮点数保存在op1中,注意:op2必须为字符串类型
strtof [reg,local,global],[reg,local,global,string]
strfri
将op2整数类型转换为字符串类型保存在op1中
strfri [reg,local,global],[reg,local,global,int]
strfrf
将op2浮点类型转换为字符串类型保存在op1中
strfrf [reg,local,global],[reg,local,global,float]
strset
将op1所在字符串索引为op2 int的字符置换为op3
若是op3为一个int,则取asc码(第八位1字节),若是op3为一个字符串,则取第一个字母
strset [reg,local,global],[reg,local,global,int],[reg,local,global,string,int]
strtmem
将op1字符串类型转换为内存类型
strfrf [reg,local,global]
asc
将op2的第一个字母以asc码的形式
asc [reg,local,global],[reg,local,global,string]
membyte
将op3 内存类型对应op2索引复制到op1中,这个类型是一个int类型(小于256)
membyte [reg,local,global],[reg,local,global,int],[reg,local,global,memory]
memset
设置op1对应op2索引的内存为op3
memset [reg,local,global],[reg,local,global,int],[reg,local,global ,int]
memtrm
将op1内存进行裁剪,其中,op2为开始位置,op2为大小
memcpy [reg,local,global],[reg,local,global,int],[reg,local,global,memory]
memfind
查找op2对应于op3内存所在的索引位置,返回结果存储在op1中,若是没有找到,op1将会置为-1
memfind [reg,local,global],[reg,local,global,memory],[reg,local,global,memory]
memlen
将op2的内存长度存储在op1中
memlen [reg,local,global],[reg,local,global,memory]
memcat
将op2的内存拼接到op1的尾部
memcat [reg,local,global],[int,reg,local,global,memory]
memtstr
将op1内存类型转换为字符串类型,若是op1的内存结尾不为0,将会被强制置为0
memtstr [reg,local,global]
datacpy
复制虚拟机data数据,从地址op2到地址op1,长度为op3
datacpy [reg,local,global,int], [reg,local,global,int], [reg,local,global,int]
jmp
跳转指令 跳转到op1地址
jmp [reg,num,local,global,label]
je
条件跳转,当op1等于op2,跳转到op3
je
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jne
条件跳转,当op1不等于op2,跳转到op3
jne
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jl
条件跳转,当op1小于op2,跳转到op3
jl
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jle
条件跳转,当op1小于等于op2,跳转到op3
jle
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jg
条件跳转,当op1大于op2,跳转到op3
jg
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jge
条件跳转,当op1大于等于op2,跳转到op3
jge
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
lge
逻辑比较指令,当op2等于op3时将op1置1,不然为0
lge [reg,local,global], [num,string,reg,local,global] ,
[num,string,reg,local,global]
lgne
逻辑比较指令,当op2等于op3时将op1置0,不然为1
lge [reg,local,global], [num,string,reg,local,global] ,
[num,string,reg,local,global]
lgz
逻辑比较指令,当op1等于0时将op1置1,不然为0
lgz [reg,local,global]
lggz
逻辑比较指令,当op1大于0时将op1置1,不然为0
lggz [reg,local,global]
lggez
逻辑比较指令,当op1大于等于0时将op1置1,不然为0
lggez [reg,local,global]
lglz
逻辑比较指令,当op1小于0时将op1置1,不然为0
lglz [reg,local,global]
lglez
逻辑比较指令,当op1小于等于0时将op1置1,不然为0
lglez [reg,local,global]
call
调用指令,若是op1是本地地址则将当期下一条指令地址压栈,而后跳转到op1,若是op1是一个host地址,则该call为一个hostcall,hostcall不会将返回地址压栈
call [reg,int,local,global,label,host]
*Host Call的返回值在r[0]中
*由被调用者清理堆栈
push
将op1压栈 sp-1,stack[0]=op1
push [num,reg,local,global,string,label]
pop
出栈,并将该值
pop [reg,local,global]
adr
取堆栈的绝对地址,返回该堆栈的绝对地址
ADR [reg,local,global], [local,global]
popn
将op1个元素出栈
popn [reg,local,global]
ret
返回,pop一个返回地址,跳转到该地址.
wait
等待一个信号量置为0,否者这个虚拟机实例将被暂时挂起(但并不不影响suspend标准位),在每一个虚拟机实例中都有16个信号量,经过signal指令对这些信号量进行设置
signal
等待op1对应索引的信号量置为op2,
在每一个虚拟机实例中都有16个信号量,这意味着op1的范围是0-15,当一个信号量被设置为非0值时,执行wait指令后改虚拟机实例会被阻塞,直到这个信号量被置为0时才能继续执行后续指令
bpx
若是启动了调试器,将会在该指令上断点,否者做为一个空指令
nop
空指令
也许制做一个高级语言的编译器会复杂得多,可是编写一个汇编编译器并不复杂,在大部分的时候,汇编编译器所作的工做无非是将助记符”编译”为指令数据流,这有如下几点好处
指令流每每所占的空间更小
指令流解析速度远远快于直接解析助记符
一个指令是否被翻译为指令流也经常被当作是这个语言是解释型语言仍是编译型语言的分水岭,可是就和以前提到的,其实本质上都是对指令的解释只是方法不一样,并无特别的区别
在将指令编译为指令集以前,咱们须要先处理几种状况觉得开始编译作准备.
首先要处理的是以前提到的标志,标志用于指明跳转指令的跳转位置,所以在编译以前,扫描整个源文件全部的标号,并将标号记录在表中.
<ignore_js_op>
在第二次正式开始编译时,更新对应标号的跳转指令,让他们指向正确的位置
<ignore_js_op>
这样,对标号的处理就算完成了,固然除了标号,还有其余的数据须要处理,观察下面的几句汇编代码:
<ignore_js_op>
首先咱们编译MOV
R1,255,当操做数做为寄存器和数字常量这没有一点问题,咱们很容易就能写出其编译后对应的指令流,可是,MOV
R1,”Hello
World”就没有那么简单了,依照StoryVM的指令编码标准,每一个操做数对应一个1字节的操做数类型和一个4字节的值,显然,Hello
World这个字符串已经超过了四字节所能容纳的范围了,所以和FLAG同样,咱们也要将全部的字符串和数据流类型在第一次扫描时提取出来,将他们放入一个表中
<ignore_js_op>
当第二次正式编译时,咱们一样和FLAG同样的方式,将字符串对应的索引号连接到对应的操做数当中.
<ignore_js_op>
<ignore_js_op>
固然除了字符串,对数据流也采用一样的办法创建一张表并创建映射关系,
最后是关键字ASSUME的处理,这是一条伪指令,其做用相似于C语言的define
例以下面的代码
ASSUME NUM,123
MOV R1,NUM
它等价于代码
MOV R1,123
由于内存空间是有限的,所以对堆栈的大小必须有一个限制,在C语言或者其余的高级语言中,堆的大小能够从全局或者静态变量计算出来,而若是栈没有被明确的设定,那么编译器会选择一个默认值做为栈的大小,以visual
studio的MSVC为例,默认的栈大小是2M,正常状况下这个大小是足够了.
在StoryVM中,由于采用了元数据的存储方式,这也就意味着咱们开辟的栈大小实际占用的内存会是这个栈的大小十倍乃至二十几倍,所以控制栈的大小就变得很是有必要,正常状况下,笔者使用65535个元数据做为栈(实际占用了将近2M的内存空间),可是在不少状况下除非使用深度的迭代并在局部变量中创建了大数组,实际并不须要那么大的栈空间,咱们没法预测用户实际上会用到多少的栈空间,所以在StoryVM的汇编中,咱们引入了.GLOBAL和.STACK两个关键字
例如
.GLOBAL 100
.STACK 1024
表示创建一个大小为100个元数据的堆,大小为1024个元数据的栈,它在内存中其实是这样排布的
<ignore_js_op>
能够看到,堆栈其实是访问了同一块内存空间,也就是说,若是访问GLOBAL[101]实际上已经访问了栈的区域了,须要注意的是,当栈溢出后,它将会覆盖堆中的数据,固然,StoryVM中作边界检查并不复杂.
实际上在以前已经有过很是多的指令编译的讨论了,如下图为例
上面的指令流其实是MOV
R1,255的编译,其中MOV被编译为了01表示MOV指令的实际操做码就是01,R1的操做数类型是寄存器,其中,02就表示这个操做数是一个寄存器,由于他是寄存器1,因此其对应的操做码参数也是1,最后是255,他是一个常量,01表示这是一个常量类型的操做数,它的值是255,也就是十六进制的000000ff
实际上,笔者总结了操做数的类型主要为如下几种enum PX_SCRIPT_ASM_OPTYPE
{
PX_SCRIPT_ASM_OPTYPE_INT, //整数常量,操做数参数为这个常量的值
PX_SCRIPT_ASM_OPTYPE_FLOAT, //浮点常量,操做数参数为这个常量的值
PX_SCRIPT_ASM_OPTYPE_REG, //寄存器,操做数参数为这个寄存器的索引号
PX_SCRIPT_ASM_OPTYPE_LOCAL, //局部变量类型
PX_SCRIPT_ASM_OPTYPE_LOCAL_CONST, //局部变量引用,例如LOCAL[5],操做数参数就是5
PX_SCRIPT_ASM_OPTYPE_LOCAL_REGREF,
//局部变量的寄存器引用,例如LOCAL[R1],就是一个寄存器引用,操做数参数是对应寄存器的索引
PX_SCRIPT_ASM_OPTYPE_LOCAL_GLOBALREF,
//局部变量的全局变量引用,例如LOCAL[GLOBAL[1]],就是一个全局变量引用,操做数参数是对应全局变量的偏移量,例如这里就是1
PX_SCRIPT_ASM_OPTYPE_LOCAL_LOCALREF,
//局部变量的局部变量引用,例如LOCAL[LOCAL[2]],就是一个局部变量引用,操做数参数是对应局部变量的偏移量,例如这里就是2
PX_SCRIPT_ASM_OPTYPE_GLOBAL,//全局变量类型
PX_SCRIPT_ASM_OPTYPE_GLOBAL_CONST, //全局变量引用,例如GLOBAL[5],操做数参数就是5
PX_SCRIPT_ASM_OPTYPE_GLOBAL_REGREF,
//全局变量的寄存器引用,例如GLOBAL[R1],就是一个寄存器引用,操做数参数是对应寄存器的索引
PX_SCRIPT_ASM_OPTYPE_GLOBAL_GLOBALREF,
//全局变量的全局变量引用,例如GLOBAL[GLOBAL[1]],就是一个全局变量引用,操做数参数是对应全局变量的偏移量,例如这里就是1
PX_SCRIPT_ASM_OPTYPE_GLOBAL_LOCALREF,
//全局变量的局部变量引用,例如GLOBAL[LOCAL[2]],就是一个局部变量引用,操做数参数是对应局部变量的偏移量,例如这里就是2
PX_SCRIPT_ASM_OPTYPE_GLOBAL_SPREF,
//全局变量的SP寄存器引用,例如GLOBAL[SP],就是一个SP寄存器引用,操做数参数是对应局部变量的偏移量,例如这里就是2
PX_SCRIPT_ASM_OPTYPE_STRING,//字符串常量,操做数参数就是以前所述的对应字符串索引
PX_SCRIPT_ASM_OPTYPE_LABEL,//标签,实际上标签并不会被编译成实体的指令流
PX_SCRIPT_ASM_OPTYPE_HOST,//Host函数标签,以后再说
PX_SCRIPT_ASM_OPTYPE_MEMORY,//数据流类型常量,操做数参数就是以前所述的对应字符串索引
PX_SCRIPT_ASM_OPTYPE_BP,//BP寄存器
PX_SCRIPT_ASM_OPTYPE_SP,//SP寄存器
PX_SCRIPT_ASM_OPTYPE_IP,//IP寄存器
};
操做数类型由以上定义完成,而操做码读者能够自行设计任意一个值,例如笔者设定的StoryVM中
MOV指令的操做码是01
ADD 是02
SUB是03
MUL 是04
DIV是 05
……..读者能够根据本身的须要自行设计
和原生二进制编译的代码有所不一样的是,咱们编译出的程序没法直接供外部调用执行,而后脚本的主要做用就是供虚拟机调用须要的函数来执行咱们所须要的功能,所以,咱们须要将咱们的FLAG暴露给外部供原生的程序调用,同时在不少的时候,咱们的脚本也须要调用原生代码里的一些函数来完成交互,可是目前咱们的虚拟机设计仍然没有办法知足这一功能
例如以前咱们所说的下面的程序
MOV R1,1
ADD R1,2
FUNC:
MOV R1,3
JMP FUNC
在这里,FUNC这个标志仅能供给程序中的跳转,但却没法在虚拟机中直接调用.所以,笔者引入了一个关键字EXPORT意为导出函数,当一个标号被加上了EXPORT关键字后,程序编译期间将会把这个标号对应的地址记录在文件当中.以方便虚拟机中进行调用
<ignore_js_op>
一样的,不少时候也须要在脚本中调用原生的代码函数,这种函数咱们通常称之为host函数
为此,在虚拟机中咱们须要设计一个对应的隐射关系表,将host函数名与其地址对应起来以方便脚本进行调用,例如,假如脚本须要调用一个叫print的函数,那么,对应的脚本代码应该相似于这样编写
MOV R1,”Hello World”
Push R1
Call \$print
注意,在CALL这条指令中,print这个标号在这个脚本文件中自己并不存在,但取而代之的是在其前缀中添加了一个\$号表示这是一个host函数,当虚拟机执行到这条指令的时候,将会在host函数表中查找是否有对应的函数,若是有,那么就会跳转到该函数进行执行,若是没有那么虚拟机会抛出一个异常
<ignore_js_op>
最后就是关于返回值的问题了,在通常状况下,默认规定函数的返回值都存储在寄存器R1中,若是须要获取返回值,读取R1寄存器就能够了
最终,咱们须要将全部的信息进行进一步的整合,以便于虚拟机更好地执行咱们编译出来的指令流,首先咱们能够参照PE格式的可执行文件,为咱们的可执行文件设置一个文件头在笔者设计的StoryVM的编译文件中,其设计知足如下的描述
<ignore_js_op>
其中,文件头包含如下定义,其中px_dword表示4字节
typedef struct __PX_SCRIPT_ASM_HEADER
{
//////////////////////////////////////////////////////////////////////////
px_dword magic;//Magic Numeric必定是PASM
px_dword CRC;//CRC校验
//////////////////////////////////////////////////////////////////////////
px_dword stacksize;//栈大小
px_dword globalsize;//堆大小
px_dword threadcount;//最大执行线程数量
px_dword oftbin;//到代码区的偏移量
px_dword oftfunc;//到导出表的偏移量
px_dword funcCount;//导出函数数量
px_dword ofthost;//到导入表的偏移量,也就是host函数
px_dword oftmem;//到数据流常量区偏移量
px_dword memsize;//数据流常量区大小
px_dword hostCount;//导入函数数量
px_dword oftString;//到字符串常量区偏移量
px_dword stringSize;//字符串常量区大小
px_dword binsize;//代码区大小
px_dword reserved[6];//保留
}PX_SCRIPT_ASM_HEADER;
将代码进行整理打包后,一个能够供虚拟机执行的编译脚本也就算制做完成了.
当虚拟机载入一个编译后的可执行脚本后,通常要进行如下几个步骤
验证文件头中的Magic和CRC,验证这个脚本是不是一个完整的编译型脚本,固然,最好引入编译器的版本号,若是之后对虚拟机的环境有修改,并规定该虚拟机能够运行哪些版本的脚本,这个字段将尤其重要.
完成了验证以后,以后就是对常量区进行一系列初始化了,从文件中读取字符串及数据流常量区将它们存储在运行内存中以供调用.
初始化host函数表,固然,只是初始化一个空表,在这里咱们并不急着将导入函数映射到这个表当中.
如今要准备运行环境了,第一步固然是为堆和栈分配空间,在这里这个大小是能够计算的,它等于(堆大小+栈大小*最大支持线程数)*元数据的大小,能够看到这里咱们引入了一个最大支持的线程数,这点咱们将在以后讨论.
在开始运行虚拟机代码以前,笔者先来聊一聊多线程的问题,若是你是一名开发人员那么这个问题应该是再熟悉不过了,不过若是你并不了解多线程是什么玩意,你能够理解为一我的同时作多件事情.
在你的PC上你能够看见计算机同时运行着多个软件,仿佛全部的程序都在同时运行着,在CPU仍是单核的年代,这是如何办到的呢,其实要理解这点也很是的简单,你能够理解为计算机在一秒钟,先执行某一程序的一些指令,而后马上切换到另外一个程序中执行另外一个程序的指令而不是等待第一个程序的代码执行完成后再执行另外一个程序,若是重复这个过程而且切换的速度很是的快的话,这些程序看上去就像是同时运行的.
那么问题就是如何进行这种切换了,在多线程当中,存在着数据共享区,也有那些独立的区域,共享数据区好说,就是堆区了,无论哪个线程都访问同一个堆,独立的区域那就是栈了,这关系到函数调用问题,除了栈以外,寄存器也很是重要,所以每一次切换咱们都要保存当前线程的栈和寄存器状态,当再次执行到这个线程的时候,再把这个栈和寄存器进行恢复,这就是咱们说的上下文切换,能够说,上下文切换时多线程实现的关键技术.
了解了以上这几点,那么就能够解释以前在分配运行时内存空间为何是参者这个(堆大小+栈大小*最大支持线程数)*元数据的公式了,是的,咱们须要为每个线程分配一个独立的栈空间,而且咱们为每个线程设定一个寄存器实例,那么每个线程使用的寄存器其实是分开的.
<ignore_js_op>
所以在虚拟机的设计中,执行的步骤是,先执行某一线程的一些指令,而后切换到下一个线程执行另外一些指令,这样就产生了一种多个程序同时执行的状况,但咱们也注意到多线程运行的同时也附带着上下文切换带来的性能开销,不过幸运的是,因为在StoryVM中全部的线程都有本身独立的寄存器实例与独立的栈空间,所以上下文切换带来的性能开销基本能够忽略不计,咱们要作的就是根据实际状况看给线程分配多少的时间片了(每一个线程每次执行多少条指令后切换).
咱们注意到多线程的引入产生了一些新的问题,那就是如何解决多线程的资源竞争问题(多个线程同时访问修改一个数据),在windows中,提供了互斥体才避免这种状况的发送,相信读者也发现了在多线程章节给出的说明图中,多出了一个信号量最高东西,实际上这和互斥体相似,一样是为了解决资源竞争所带来的问题的
在以前的StoryVM指令表中有如下两个特殊的指令
wait
等待一个信号量置为0,否者这个虚拟机实例将被暂时挂起(但并不不影响suspend标志位),在每一个虚拟机实例中都有16个信号量,经过signal指令对这些信号量进行设置
signal
等待op1对应索引的信号量置为op2,
在每一个虚拟机实例中都有16个信号量,这意味着op1的范围是0-15,当一个信号量被设置为非0值时,执行wait指令后改虚拟机实例会被阻塞,直到这个信号量被置为0时才能继续执行后续指令
这也就意味着,当一个线程须要访问一片资源时,他须要先进行wait某一信号量.若是不须要等待,再使用signal对其置为非0值防止其余线程对其进行访问,最后完成后再对signal归0操做
终于到实际执行指令的时候了,那么程序从哪里开始运行呢,固然,StoryVM中并无像PE文件里那样有一个OEP(程序最开始执行的地址),但咱们的程序的的确确须要执行一些必须先执行的指令,在StoryVM中笔者定义其为_BOOT,通常状况下它是代码区中的第一条指令,还记得咱们以前提到的导出标签么,是的,_BOOT实际上就是一个导出标签,在_BOOT标签后紧跟着是一些全局变量的初始化操做,固然这是后话了,在目前咱们提到的汇编中,尚未全局变量初始化这一律念.这点咱们将在后期StoryScript的高级语言中讨论,但如今咱们须要执行代码怎么办,固然,这些都由用户本身决定,若是你但愿从某处开始执行,那么你就应该在这里添加一个导出标签,例如以下代码
EXPORT _MAIN:
MOV R1,”Hello,从这里开始运行”
Ret
如今从哪里运行解决了,那么程序怎么结束呢,注意上面的代码有一个ret指令,这个指令的做用是从栈中弹出一个值,并将这个值做为这个函数的返回地址,能够看到,在这个函数前咱们并无对栈进行操做,是的,虚拟机在调用这个导出标签的时候,将会在栈中压入一个值为-1的返回地址,当程序执行到ret时,发现返回地址是-1的话,就意味着这个程序已经执行完成了,那么就会对程序进行资源回收,若是这是一个多线程调用的话,就会注销这个线程,若是虚拟机中已经没有须要执行的线程的话,就会挂起这个虚拟机.
若是说汇编语言是为了简化直接编写机器语言而诞生的,那么大多数的高级语言的出现就是为了进一步简化汇编语言而带来的繁琐.
在前几章节中,笔者阐述了汇编语言的编译与虚拟机的工做流程,但要将虚拟机实际应用这些还远远不够,咱们须要更具备效率的编程语言来提升开发的效率,在接下来的章节中主要讨论高级语言编译器的实现技术,固然鉴于篇幅的关系,咱们仍然没法讨论全部的技术细节,若是你有相关的须要,你可能须要在网上或书中查找更多相关的资料.
毫无疑问的是,编写一个高级语言的编译器须要花费大量的心血,其工做量甚至比以前的虚拟机和汇编编译器加起来还多,不过幸运的是,汇编器已经为咱们完成了大量的重要工做,好比字符串数据流资源的整理,符号的扫描和集成的汇编指令的实现.
在开始以前,咱们仍然须要探讨咱们到底须要编写一个什么样的高级语言编译器,关于这点笔者参考了多种方案,最终使用了一个类C语言的方案来编写StoryScript编译器.这主要有如下几个优势.
有现成的语言作参考,C语言有至关多的资料
函数式语言,过程化,实现起来相对简单
笔者编写C语言程序估摸算算也有十多年之久了,是的,笔者也使用过Java Pascal
C++但最终仍是回到了C语言的怀抱.
C语言用的人也很多
IDE就更多了,甚至不少状况下能够直接使用C语言的IDE来编写StoryScript
那么说了那么多,到底StoryScript是怎么样的呢,笔者先写一段StoryScript看看
#name “Main”
#runtime thread 4
#runtime stack 4096
#define Num 9
Host void Print(string t);
String a,b;
Void print9x9(int c,int d)
{
Int I,j;
For(i=1;i<= Num;i++)
{
For(j=1;j<=I;j++)
Print(string(i)+””+string(j)+”=”+string(ij)+”\t”);
Print(“\n”);
}
}
Export int start()
{
Print9x9(1,2);
}
这是一个输出99乘法表的程序,你能够注意到i这个变量,不过这不要紧.,StoryScript是一个大小写无关的语言.下面咱们对这段程序进行分析,并最终阐述编译器是如何工做的.
和汇编脚本语言同样,StoryScript一样有预处理指令,在这章节中将这段程序的预处理命令一块儿讨论,首先咱们先明确一点,StoryScript的编译器其最终目的并非编译出指令流,而是将StoryScript高级语言转换为符合其语法规则的汇编语言,以后再由汇编编译器将其编译成指令流.
首先咱们要处理的是
#name这个预处理,这个是StoryScript特有的一个标示语句,每个StoryScript必须在源代码的开头有这个语句,他的做用有点像这个源代码起个名字,固然,每一个源文件有且必须有一个名字,这样当其余的源文件须要包含这个源文件时就能够用#include
”Name”来包含这个源文件了,其功能和C语言中的#include
“xxxx.h”是同样的,你可能会问为何要画蛇添足加上这个name专门去指定这个源文件的名字呢.直接用文件名很差么,但笔者设计StoryScript的目的是,这个语言能够执行在任何可移植StoryVM的平台上,这也意味着其编译器能够一并移植到任意只要能提供C语言编译环境的平台上(这也任意平台不只能够执行编译后的文件,还能够直接提供源代码执行,也就是便可以当编译型脚本用,也能够当解释型脚本用),在不少的嵌入式平台中,并不提供文件系统这一律念(实际上,StoryVM的虚拟机和编译器的C语言代码不包含任何的C语言标准库,从内存池实现到数学运算所有都从新实现了一遍),所以笔者最终采用了这个方案来标识每一个源代码的名称.
接下来是
#runtime thread 4
#runtime stack 4096
两个语句,这两个语句,在汇编中会直接被编译为
.Thread 4
.Stack 4096
如你所见,他设置了这个脚本所支持的最大线程数量和默认的堆栈大小,固然,这两个是可选的参数,若是你不写这两条语句,那么,线程数会被默认设置为1,栈大小会被默认设置为65535
#define Num 9
这条语句和C语言的#define等价,也就是说,源文件中全部的Num会被替换为数字9
最后是Host void Print(string t);
这是一个函数定义,前面的Host关键字表面,这个函数在源文件中并不存在,它是一个host函数,host函数的做用在以前已经讨论过了,它是虚拟机使用原生代码实现的函数,是脚本调用原生代码的一种方式,在以后的代码中,若是调用这个Print函数,其汇编代码会被翻译为
Call \$Print
这样的指令.
接下来就是start函数的定义和实现了
Void print9x9(int c,int d)
函数的调用一贯是函数式语言的工做方式,从在StoryScript中并无与C语言相似的main函数,从哪里开始执行是由用户定义的,但从上面的这个语句来看这显然是一个标准的函数定义,
在函数的定义期间,并不会生成实际工做的汇编代码,但它会产生一个标号,并肯定函数的栈分配和调用方式.
谈及函数的调用关系,难以不说起当今主流的两种比较有表明性的函数调用方式stdcall和cdecl,这两种调用方式的参数传递方式都是从右向左压栈,但惟一不一样的是,stdcall在函数结束时由函数来维持堆栈平衡,而cdecl则是由调用方来维持堆栈平衡,为了演示这两种调用方式的不一样,咱们查看两种调用方案的汇编代码
首先是stdcall的
push d
push c
call print9x9
在print9x9中须要弹出2个栈元素
而后是cdecl的
push d
push c
call print9x9
popn 2
在StoryScript中,咱们默认采用的是cdecl的模式由调用者来平衡堆栈,这也就意味着调用者必须清理栈来保证堆栈平衡.
最后是关于访问参数的问题了,正如咱们以前提到的,LOCAL[x]实际访问的是GLOBAL[x+BP],咱们知道,每次CALL指令调用后,会将函数的返回地址压人栈中,所以按照咱们的思惟来讲,LOCAl[1]访问的是参数c,LOCAL[2]访问的是参数d,这也就意味着,咱们必须在函数的开头执行
MOV BP,SP将BP寄存器与栈进行挂钩
<ignore_js_op>
这能够很好的工做没有什么问题,可是这样的设计是存在缺陷的,当咱们引入局部变量后,这种方式多多少少会引来不便.
咱们在代码中注意到,在代码中有两个变量的定义
其中一个是全局变量string a,b
另外一个是局部变量int I,j;
那么变量是如何隐射到堆栈中的呢,其中全局变量很好理解,咱们只须要在堆中进行线性排布就能够了,例如,a其实是GLOBAL[0],b其实是GLOBAL[1]
那么I,j如何处理呢,以前谈论的方案很好的解决了参数的问题,但却没有解决好局部变量的问题,为了继续容纳局部变量I,j,咱们须要对栈结构进行从新部署,在函数的开始时就为局部变量预留好空间,那么在print9x9这个函数中,在MOV
BP,SP以前,咱们须要为局部变量开辟栈空间
SUB SP,2
MOV BP,SP
那么,实际的栈结构变成了下面这个样子
<ignore_js_op>
所以咱们最终发现,参数和局部变量实际上存储在同一个区域并无什么差异,但要注意的是,局部变量的释放在函数的结束必定要作,例如这里在函数的结尾必定要加上popn
2否者程序就会崩溃,而在执行ret指令后,须要在作一个popn 2把压入的参数释放掉.
abstract syntax tree
(AST)抽象语法树,那么什么是语法树呢,简单来讲就是把代码转换为一个数结构便于分析的数据结构方案
<ignore_js_op>
那么什么是递归降低分析法呢,简单来讲就是模仿语法树的分析方案创建起的一套算法规则,准确来讲,递归降低分析法主要是用来分析代码中的表达式的.那么表达式是什么呢,通俗点说就是算式
例以下面的表达式
1+2*3
这个式子创建起的AST树相似于这个样子
<ignore_js_op>
由于2,3节点深度比1节点大,所以先计算2*3,而后结果再和1进行相加获得最终的结果,与AST树稍有不一样的是,递归降低分析法是基于堆栈式的语言,咱们先来看看递归降低分析法是如何解决上述式子的解析的
为了方便描述,咱们创建了两个栈,一个叫作操做码栈,一个叫操做数栈,操做码栈简单而言就是存放数字的,而操做数栈就是存放运算符例如加减乘除的,在进一步讨论以前,咱们须要先认识到运算符优先级这一个概念,小学的知识告诉咱们,乘法的优先级比加法的高,所以,咱们须要先计算乘法再计算加法,例以下面的表达式
1+2*3+4/5
由于乘法和除法的运算优先级比加法高,所以,2*3和4/5会被优先计算,那么递归降低分析法会如何处理呢,实际上,递归降低分析法会先计算2*3,而后结果与1进行相加,以后再计算4/5,再与以前的结果相加
递归降低分析法的核显是,从左到右依次读取运算符,若是读取的运算符优先级比上一个运算符优先级小或处于同一优先级,则进行计算.为了让读者更好地理解递归降低表达式的做用,咱们一步一步对1+2*3+4/5用递归降低分析法进行运算
1.读取第一个操做数1
操做数栈: 1
操做码栈:
2.读取操做码+
操做数栈:1
操做码栈:+
3.读取操做数2
操做数栈:1,2
操做码栈:+
4.读取操做码*
操做数栈:1,2
操做码栈:+,*
5.读取操做数3
操做数栈:1,2,3
操做码栈:+,*
6.读取操做码+
操做数栈:1,2,3
操做码栈:+,*
注意,在这个时候,由于加法的运算符优先级比乘法的小,所以咱们须要进行计算,由于乘法是一个双目运算符,所以从操做数栈中弹出两个操做数2,3计算2*3获得结果6,在这个时候,再把6压入操做数栈中获得
操做数栈:1,6
操做码栈:+
注意,由于加法的运算级和以前那个加号是同等运算优先级,所以,会在进行计算,由于加法是双目运算符,所以,从操做数栈弹出2个操做数继续计算1+6得7,那么最终结果变为了
操做数栈:7
操做码栈:
最后,别忘了把读取到的加号再次压入栈中,因而就有
操做数栈:7
操做码栈:+
7.读取操做数4
操做数栈:7,4
操做码栈:+
8.读取操做码 /
操做数栈:7,4
操做码栈:+ /
9.读取操做数5
操做数栈:7,4,5
操做码栈:+ /
10.表达式结束,这个时候,看成读取了一个优先级为最低的运算符,所以须要处理全部尚未处理的操做码,,先进行除法运算,4/5的0.8
操做数栈:7,0.8
操做码栈:+
执行加法运算
操做数栈:7.8
操做码栈:
至此表达式结束,取得最终的结果7.8
从上面的递归降低分析中,咱们很好地处理了这个表达式的解析关系,由于这个表达式的计算都是常量,计算起来也没有那么多的障碍,在StoryScript中,虽然一样使用递归降低分析法来分析表达式问题,但由于存在变量的引用,实际产生的分析步骤却复杂的多
例以下代码
int a=2,b;
b=1+2*a;
那么,下面的表达式是如何变成汇编代码的呢
咱们如今观察递归降低中的堆栈变换,并最终将上述的表达式变为可执行的汇编代码
1.读取第一个操做数b
操做数栈: b
操做码栈:
2.读取操做码=
操做数栈: b
操做码栈:=
3.读取操做数1
操做数栈: b,1
操做码栈:=
4.读取操做码+
操做数栈: b,1
操做码栈:=,+
5.读取操做数2
操做数栈: b,1,2
操做码栈:=,+
6.读取操做码*
操做数栈: b,1,2
操做码栈:=,+,*
7.读取操做数a
操做数栈: b,1,2,a
操做码栈:=,+,*
8.表达式结束,按照递归降低的规则,咱们先对乘法进行运算,获得2*a这个算式,为了完成这个算式,咱们须要使用寄存器进行操做
MOV R1,2
MUL R1,a
这样,R1的值就存储着咱们所须要的值了
那么,栈是否变为了
操做数栈: b,1,R1
操做码栈:=,+
呢,在这个表达式中,这样固然没有问题,然而实际的状况是若是咱们直接将R1做为操做数压入栈中,那么碰上
b=2*a+4*a这样的表达式就会出现问题了,由于2*a将结果放在了R1中,那么4*a就不能再使用R1使用了否者会将原来的值覆盖掉,那么就只能选择R2寄存器了,可是寄存器是有限的,若是这个表达式足够的长,那么咱们的算法体系就会濒临崩溃,所以咱们不能直接将R1做为操做数压入栈中,咱们须要将R1做为一个值变成汇编代码压入运行的栈中变成这个样子
MOV R1,2
MUL R1,a
PUSH R1,那么,操做数栈实际变为了
操做数栈: b,1,POP
操做码栈:=,+
那么咱们进行了下一步.下一步是一个加法运算,为了取得前一次计算的值咱们须要进行一次pop操做,并将这个值放入R2寄存器当中,一样的在计算结束后,结果仍然在R1中,咱们还须要再次将R1进行压栈
MOV R1,1
POP R2
ADD R1,R2
PUSH R1
这个时候,递归降低的两个栈变成了
操做数栈: b,POP
操做码栈:=
最后一次运算了
POP R2
MOV b,R2
MOV R1,b
PUSH R1
读者可能会疑惑,为何还要MOV R1,b再PUSH R1呢,到MOV
b,R2不是已经完成了这个表达式么,固然,咱们设计一个程序不能只看到这样的一个表达式,在不少的时候咱们必需要保证其通用性例如当碰上
b=(a=1)这样的表达式时,你就知道为何咱们须要”画蛇添足”了,为了保证咱们编译的汇编程序的准确性,咱们须要考虑各类不一样的状况,尽管这可能会引入一系列的冗余代码,但这是值的的,冗余的代码咱们能够在优化的部分再作处理,最后相信读者也知道了,这个表达式最终的结果是什么
MOV R1,2
MUL R1,a
PUSH R1
MOV R1,1
POP R2
ADD R1,R2
PUSH R1
POP R2
MOV b,R2
MOV R1,b
PUSH R1
POP R1
最后的POP
R1,表示取得表达式的最终计算结果.并将它放入寄存器R1当中,固然咱们也发现了这个表达式中仍然有很是多的冗余代码能够优化,但这是后话了.固然,最后别忘了汇编代码中的a,b其实被映射到了LOCAL[]的栈元素中,笔者直接写a,b只是为了方便读者观看,最终a,b会被替换成LOCAL[x]这种格式.
虽然递归降低分析法为咱们解决了很多的问题,可是仍然有不少额外的问题须要咱们解决,其中一个就是类型在表达式中的做用,其中要说明的一点是StoryScript属于强类型语言,一共支持四种类型
int---整数型
float----浮点型
string----字符串类型
memory-----数据流类型
除了int和float类型能够互相运算操做,string,其它类型间不容许直接进行计算
例如
string a;
a=”hello”+”world”
这个表达式是一个合法的表达式
但a=”hello”+123这个不是一个合法的表达式,在进行递归降低分析时,一样须要对表达式的类型进行进一步的检查,上面的字符串类型和一个整数类型进行加法运算显然不是一个合法的表达式类型的运算,所以在检查到这类没法类型匹配的表达式时,编译器应该要抛出一个错误.
除了类型匹配以外,还须要注意的是运算符匹配,例如位运算中的与或非异或等操做面向的是整数型,若是表达式结果是其它类型一样须要抛出一个错误
例如
int a=1;
a=a\&1;这是一个合法的表达式
可是
int a=1;
a=(a+1.5)&1倒是一个不合法的表达式,由于a+1.5的运算结果是一个浮点数
不一样类型间若是须要进行计算,须要专门的函数对其进行转换,在StoryVM中,这些特殊的函数被直接编程到汇编指令中专门进行这种转换操做,不少时候也管这种函数称之为关键字,其做用相似于C语言中的sizeof,但有所不一样的是,sizeof是编译期间就已经完成的,而StoryScript中的关键字却会变编译成实体的汇编指令.
继续观察代码,咱们来到了For(j=0;j\<=I;j++)这条语句,之因此在这个代码中使用for语句做为示范是由于这个for语句最具备表明性,它包括了while和if语句的实现细节,能够说,若是你能够编译For语句,你也能够很顺利地编译while和if语句
观察for语句的结构基本由以下这种方式来完成
for(初始化表达式;条件判断表达式;末尾循环体)
{
for循环体
}
咱们以前已经说了递归降低分析表达式的方法,那么剩下的就是如何构造for的语句结构了,为了方便说明笔者用一张图来表示for语句结构的剖析
<ignore_js_op>
当执行到for语句的时候首先执行的是初始化代码,执行完成后将会跳转到条件判断代码中判断代码是否成立,若是不成立则直接结束,若是成立则会执行for循环体,当for循环体执行完成后再跳转到末位循环体中,读者可能会有所疑问,为何末位循环体被前置到那个位置,安装逻辑不该该在for循环体以后么,虽然道理你们都懂可是从编译的角度进行分析,在咱们编译for语句的时候,最早被解析的表达式就是初始化代码区,条件判断和末位循环体的表达式,而for循环体中的代码可能很复杂还可能包含有各类的嵌套结构,所以若是将末位循环体放在for循环体以后,其实现起来会复杂的多,一个东西越复杂,那么其就越有可能出错,所以咱们采用了这种折中的方式来实现for循环体,效果同样却节省了大量的代码,这也是笔者为何一直强调,不少问题只有在你真正动手去作了你才知道怎么回事,有些东西书本上没法告诉你,那套听上去吊炸天的架构和公式也没法最终帮你解决不少问题
最后,咱们手写下For(j=0;j\<=I;j++)的编译结果
//初始化代码区
mov R1,0
mov j,R1
JMP _FOR_condition
//末位循环体
_FOR_LOOPEXPR:
ADD I,1
//条件判断
MOV R1,j
MOV R2,i
LGLE R1,R2 //若是R1小于R2,则R1为1否者为0
JE R1,0,_FOR_END;//若是R1位0,for语句结束
for循环体的汇编代码
JMP _FOR_LOOP_EXPR
_FOR_END;
相对于for语句的实现,if语句的实现就简单的多了,IF语句的格式以下
if(条件表达式)
{
IF语句块
}
else
{
ELSE处理
}
以下图所示
<ignore_js_op>
一开始到达的是条件判断代码区,若是条件判断为假,则跳转到else语句块中若是为真就继续执行,固然在if语句块执行结束后也要跳转到结束,否则就继续执行else语句块里面的代码了
相信要编写相应的汇编代码并不复杂.笔者就不继续复述了.
while语句估计是三种语句中最简单的一种了,其语法格式以下
while(条件判断)
{
while语句块
}
就直接看图吧
<ignore_js_op>
首先执行条件判断,若是为假,跳转到结束,否者执行while语句块也就是循环体,循环体执行结束后再跳转到条件判断中继续执行.
固然有了上述几种结构后,对于其它结构怎么来的读者应该能够自行发挥想象了,像do
while,switch等应该均可以按照上述的思路去完成,在码农界经常称这些结构为语法糖,但无论语法糖怎么造,实际上while和if语句几乎就能够解决全部的逻辑问题了,所以无论一门语言怎么变,了解其最根本的东西才是关键的,无论一个语言的语法糖有多好,真正适合本身的,能作出本身须要功能的语言才是一门好语言
固然在StoryScript中笔者自行实现了Compare语句做为switch语句的替代品
其语法格式为
Compare(比较表达式1)
{
with(比较表达式2;比较表达式3…..)
{
with语句块
}
………
}
其做用为当比较表达式1和with中其中一个表达式的结果相等,那么就执行with语句块中的代码,实际上一个compare语句中能够包含多个with语句,其和switch语句稍有不一样的是,with语句中的表达式不一样于case须要是一个常量,所以它比switch语句用起来会方便的多,但相对的,其比switch语句在比较较多的条件下性能确定不如switch,不过既然是一门脚本语言,咱们天然不能在性能上奢求太多,毕竟咱们没法作到面面俱到.一门语言着眼于作好一类问题,其它方面尽量好就好了.
在StoryScript编译器的最后,笔者想最后聊聊关于语句嵌套的问题,那么什么是语句嵌套呢,观察下面的代码
if(a\>10)
{
if(a\>20)
{
}
}
这是一个标准的嵌套语句,由2个IF语句构成,虽然它们都是IF语句,可是处理起来却不肯意,由于if(a\>20)是包含在if(a\>10)的语句块里的,实际上在处理语句嵌套的问题上,编译器采用栈的方式对这些嵌套语句进行处理,当一个语句块结束后,再将它从栈中弹出来而且添加其结束代码(语句块结束有一个很明显的特征,那就是有一个右花括号)
例如在上面的代码中,咱们把第一个IF叫作IF1,第二个IF叫作IF2,从以前的代码生成咱们知道,语句块的控制基本是有标签+跳转来实现的,那么由于名字的不一样,这两个if语句块将会生成不一样的标签
所以实际上述代码的实际处理流程其实是这样的
碰到了第一个if语句,将它叫作if1,而后将这个if语句压栈
碰到了第二个if语句,将它叫作if2,而后将这个if语句压栈
碰到了花括号},从栈中弹出一个语句块,发现这个花括号属于if2的那么添加if2的跳转
碰到了花括号},从栈中弹出一个语句块,发现这个花括号属于if1的那么添加if1的跳转
这个过程适用于任何的语句嵌套,例如
for()
{
if()
{
}
}
这种格式的或者是
while()
{
for()
{
}
}
这种格式的嵌套,每次处理到花括号}时,都从栈中弹出一个结构,而后再添加相应的处理代码,这种栈式的处理方式可以正确的处理嵌套语句的代码生成,同时这也为咱们处理continue和break两个特殊指令的关键字提供了思路
众所周知的continue和break语句仅对while for switch(这里应该是compare) do
while语句生效,所以当找到这类特殊指令时,应该从栈顶开始搜索并找到第一个符合条件的结构,而后添加对应的处理代码.
无论怎么说,设计一套虚拟机和编译器系统是一个庞大的工程,从词法到语法分析到整个虚拟机系统的设计和编译器的优化笔者将近使用了5w余行的代码来完成其具体实现,当你真正动手写一个东西时,你会发现有不少以前你都没能考虑到或者是考虑周全的问题,所以尽管码农界一直在说不要重复造轮子,但有一些轮子你不本身造一造恐怕你永远不知道他具体是怎么回事.
最后,笔者将之上一章节的99乘法表代码使用这套编译系统运行,你能够在本文的附件中找到这段代码的DEMO,同时你也能够修改这个代码本身尝试一下这个编译器编译的过程和结果.
点击文件夹,找到StoryScript Console.exe
<ignore_js_op>
运行
<ignore_js_op>
点击Load,而后在文件对话框中选中99乘法表示范程序.txt,观察输出结果
<ignore_js_op>
你能够任意修改示范程序中的代码,在示范程序中,程序由Main函数开始执行,有一个导入的host函数为Print,意为在控制台输出消息
<ignore_js_op>
同时你会发如今脚本的同一目录下生成了两个新的文件,一个为.asm后缀的文件,一个是st后缀文件
<ignore_js_op>
你能够打开asm文件查看编译器是如何将StoryScript编译为汇编代码的,也能够使用hex打开st文件查看编译后的文件结果
<ignore_js_op>
<ignore_js_op>
固然在最后,你能够直接Load编译后的程序,那么它将直接运行出99乘法表一样的结果.