译者序 由于我最近有一个计划,就是写一个适应性很强的脚本语言,这个语言将主要用来处理剧情,希望能够用于绝大多数需要剧情的游戏.于是最近开始找一些关于script的东西来看看,当我在flipcode看到这篇的overview时,见它提到了unreal的脚本系统和字节码和虚拟机,就开始在没有完全读完的情况下翻译这一系列文章(共9篇).在翻译中加一些注释...希望不要误导您,还有为了丰富原文我也加入了几个自己的程序.因为这是我首次翻译这种系列文章,在脚本引擎方面也缺乏经验,所以难免会有一些不当之处,还请大家批评指正. 目录 Part I: 概述 Part II: 词法分析器 Part III:语法分析器 Part IV: 符号表 & 语法树 Part V: 语义检查 & 中间代码生成 Part VI: 优化 Part VII:虚拟机(The Virtual Machine) Part VIII:可执行代码 Part IX:高级主题 版本列表 2000年2月至2000年3月 第一稿 声明 原文发布时间是1999年5月至8月,原文作者是Jan Niestadt 如果有中文版权的话版权属译者(即燕良)所有 如果您觉得有用当然可以转载,不过请保持文章的全部内容,如果您有什么修改意见请与译者联系 暂时不可用作商业用途 Part I:概述 序言 OK,在你的引擎中你想有一个脚本语言. 首先,确定你需要那种脚本语言;Henry Robinson 已经写过了一个各种脚本语言区别的介绍(如果你还没读过就去读一下吧),在这个系列教程中我将讨论一个象虚幻脚本(Unreal script)那样的编译器/虚拟机系统. 下一步,你需要知道两件事:怎样实现那样一个脚本引擎,还有脚本引擎不仅仅是酷而且在实际中十分有用的一些理由. 这里是我想到的一些:
但是也有一些不利因素:
当然我们不会因为这些就停下来,我们已经准备好实现我们的想法了.现在,从哪里开始呢? 在虚幻(Unreal)发布前很久我就开始了.我浏览他们的技术站点,并且发现了虚幻脚本参考文档(UnrealScript reference document).我当然听说过虚幻脚本,但是并不真正知道他是什么.我阅读了这些文档,觉得一个脚本语言的想法实在是很酷.我要自己写一个,然后连接到一个游戏引擎,以便我的游戏的整个世界都可以轻松的建立新的内容. 幸运的是我有一个学期的编译器构造课程(燕良注:我也刚学了一个学期的编译原理,还考了92分,嘻嘻,不过Julien当时竟然只用两个月就考了98分,佩服,佩服),并且作为一个实际的任务我曾经实现过一个非常非常简单的Pascal编译器.我开始并行工作,更好,编译器.我已经有一个接受C的子集的可工作的版本,但是我用了2周来编码,其内部结构相当的可怕,...我不得不完整的重新设计那东西.我相信你在某些地方有与我相似的经验...现在我依然在做这东西,并且学到了很多关于编译器的知识. 现在,接触一点有用的信息吧. 首先,我建议所有想要编写一个编译器的人弄一本龙之书.你们中的大多数(尤其是象我这样的计算机系学生)可能已经知道了这个(燕良注://shake).告诉那些不知道的人,我是在说Aho, Sethi 和 Ullman 所著的<<Compilers - Principles, Techniques and Tools >>(ISBN 0-201-10194-7). 因为他的封面有一条龙,所以得到了龙之书的名字.相信我,所有对编译器有所了解的人都读过这本书(燕良注:国内有卖吗?). 这本书从1986年就不曾修改过,这是因为从60年代编译器的设计基本技术就没有变过.当然,这本书不涉及面向机器的优化,但是有其他书.此外,我们想要编译出字节码(bytecode)而非机器码. 其次,如果你想要得到一个快速实现字节码脚本语言的预览GamaSutra的一篇文章.那是一个值得一读的关于实现Jedi骑士脚本语言的故事.那里的所有的东西我也将涉及,但是那仍然是值得一读的. 一个编译器基本上包括一下这些组成部分:
受到了一点点打击吗?毕竟决定使用脚本不是那么酷,DLL真的是唯一的路吗?没关系,我将很快讨论每一组件的细节,它们的绝大多数并不是那么困难.建立一个完整的脚本引擎是一个巨大的工作,但是,本质上是构造你自己的代码. 在这个教程的剩余部分我们将开发一个简单的编译器/虚拟机系统.尽管他没有地方象是一个完整的脚本语言,但是他实现了上面提到的所有组件.我在回想一个操作字符串的简单语言. 现在检查上面的连接,了解他们.顺便提一下,感谢所有的评论. Quote! 我总是说在学一个东西的时候例子总是不能足够简单.这就是为什么当我想要设计一个包含所有完整编译器应该有的特性的简单的编译器时感到很累.我拼凑了一个字符串处理语言,它使用象C那样的语法,有BASIC那样的功能.下面是用我们的语言的正确编写的一个程序.
就象你看到的,他没有构造象函数,类等等那样的功能,它甚至没有数值类型.这就是最终的东西,但是,他是很容易扩展的. 但是在接触那个之前我们还一很长的路要走--记得上次的组件列表吗?今天我们将实现第一个:词法分析器或称短语分析器.这是一个很好的热身,因为它不是编译器中真正难的部分. OK,准备好了吗? 首先我猜你想要知道词法分析器是什么和为什么我们要用它?词法分析器的任务是把源文件的字符流转换成记号流.本质上它查看连续的字符然后把它们识别为"单词". 我们当然可以写一个函数用来把源文件当前位置取得的字符串与我们的所有关键字比较,但是这将是不可忍受的慢.所以我们使用有限自动机来识别单词(燕良注:就是DFA了,设计过程是正则式==>NFA==>DFA==>最小化).如果你不知道它是什么,好吧,你不需要知道(燕良注:如果你想知道,请看本文最后的附注). 关于词法分析器的一个基本情况是我们不需要作实际的艰苦的工作,我们使用一个叫作"LEX"的程序生成词法分析器.这是一个标准的UNIX程序,他也有几个win 32的版本(燕良注:我有一个FLEX.exe).这里有完整的LEX手册的HTML版. 好的,现在你知道了词法分析器作什么和我们将如何制作它.现在你可以下载 *tut2.zip*并且看一眼那些代码.这部分的源程序是string.l(燕良注:LEX源程序)和main.cpp以及几个头文件.请注意ZIP文件中含有目录结构,flex.exe在主目录,这部分的代码在tut2/目录. LEX需要一些简单的规则来生成我们的词法分析器.在介绍规则之前,先让我们看一下LEX源程序的分段.
<说明部分>包含一些正则式(regular expression )的宏(正则式在LEX手册中有解释,想彻底了解它请看这里).这些告诉LEX我们使用的LETTER,DIGIT, IDENT(标识符,通常定义为字母开头的字母数字串)和STR(字符串常量,通常定义为双引号括起来的一串字符)是什么意思.(燕良注:呵呵,多熟悉呀.) 这部分也可以包含一些初始化代码.例如用#include来使用标准的头文件和前向说明(forward references).这些代码应该再标记"%{"和"%}"之间,你马上将看到我include了一个lexsymb.h 文件. <规则部分>可以包括任何你想用来分析的代码;我们这里包括了忽略所有注释中字符的功能,传送ID名称和字符串常量内容到主调函数和main函数的功能. lexsymb.h 文件声明了词法分析器函数将要返回的记号的符号.它还声明了一个'yylval' 共用体(union),用来传送额外的信息(例如标识符的名字)到主调函数;这里我们使用这个特殊的共用体可以使下一部分更清晰些. 现在让我们看一下实际的规则.我使用/* */作注释;LEX是一个相当老的程序,所以它着支持//引导的注释.顺便提一下,我们将使用LEX生成C程序,C++版的LEX程序也有,但是标准的UNIX LEX产生C代码.我们想要使这东西便携,不是吗? (燕良注: LEX源文件 .L--->FLEX--->C源文件,默认文件名是lex.yy.c)
我删去了一些非常简单的规则.就象你看到的那样,每一条规则开始部分是LEX将要识别的样式,接下来是一些代码告诉LEX当规则匹配后作什么(这部分代码可以包含C++代码,因为LEX只是简单把它们的拷贝到输出文件中).记住最顶端的规则被最优先评估,这通常很重要. 头3条规则十分的简单,它们只是识别一个字符串,然后返回相对应记号的符号.你可以改变这些字符串,例如你想使用":="来作赋值操作符. 第4行是第一条有趣的规则:它使用了IDENT宏,它识别不满足前面的条件的字母/数字串.如果匹配,它将调用Identifier()函数,此函数把yytext(保存当前记号的文本)的内容赋复制到一个新字符数组.词法分析器返回ID记号,主调函数可以使用'yylval->str'指针来访问标识符非名称.STR对于字符常量实现同样的功能. 下一行规则处理注释,换行和空白.注意行号将被计数,将来我们在出错信息中将使用它.最后一行告诉LEX如果输入不能满足上面所有的规则(表达式"."的意思是:除了'/n'以外的所有字符),我们应该返回一个错误记号,然后让主调函数决定作什么. LEX的源程序可以使用下面的命令行来编译成LEX.CPP: ZIP中还包含一个MSVC 6.0 (string.dsp)的Project文件,我相信它在5.0中也能工作,但是我不确定.Project为string.l设置了一个自定义命令行,所以它可以被自动编译. 不幸的是LEX使用一个非标准的头文件,unistd.h,它不能在windows中使用.在主目录中有一个空的unistd.h文件,请添加主目录到include路径中(in MSVC:Tools->Options->Directories->Include). lex.cpp包含一个满足我们规则的完整的词法分析器.它是那么简单!主程序只是使用词法分析函数读取一个记号,然后显示记号的名字和值(它是ID还是STR).你可以试着加入一些测试数据,然后观察词法分析器如何处理它们;随机的字符序列通常被识别为ID,我们不使用的字符,例如'$'引发一个ERROR_TOKEN.你也可以试试example.str (在主目录). 好吧,我们现在有了一个可以"读"的程序.遗憾的是它对它读的是什么和这些是否符合我们的标准依然没有概念.它只是接受它知道的一些记号. 看来它需要知道语法,惊人的巧合,语法正是我们下一部分将要讨论的.下一个组件是语法分析器,它的功能是找出程序的结构并且检查语法. 这样就变得真正有趣了.我们将能使程序成为一个编译器,它将接受一些东西,并不只是因为它可以接受几乎所有东西,而是因为它知道这个程序的语法是正确的.我知道你肯定和我一样激动,但是我不得不等到下一部分... Quote! 程序的功能是把下面这些实常数转换成相应的科学计数表示:
设计思路: Pi,E,Degree可以当作关键字来处理,不是本程序的主要部分,本程序的主要功能是识别一下各种形式的实常数:
识别上述形式实常数的DFA为: 参见程序:CONSTANT.zip 附送另一个程序,识别C语言源程序的LEX源程序. 前一部分的执行可以作一件很好的工作:把程序转换成记号.所有的关键词,操作符,分隔符,标识符和常量都被立即识别和报告.然而,你可以输入: { this ) = "pointless" + ; 然后程序将只是接受它,并且高高兴兴的产生一个记号的列表.因为它不清楚我们想要允许什么东西(我不知道上面的"语句"要做什么).我们必须能够识别输入程序的语法结构(或者它的缺点). 我们借助语法分析器来作这件事,语法分析器用来找出程序的结构并且检查所有的语法错误. 我们怎么告诉语法分析器我们的语言是什么样呢?我们可以使用一个叫作BNF范式(Backus-Naur Form )的东西来描述语法(syntax或grammar).这种描述方法使用组成程序的基本概念.举例说明,表达式可以是在其他任何东西之中,标识符或者字符串常量(expressions can be, among other things, identifiers or string constants).在BNF范式中,它可以写成下面的形式: expression: identifier | string; 一个打印语句或者输入语句: statement (记住PRINT,INPUT和END_STMT都是词法分析器返回的记号) 现在,一个程序可以被表示成下面这种语句列表的方式: program: | program statement; 上式说明一个程序可以是空或者由程序加上一个语句构成,这里说的语句是一个递归定义,它可以是一串语句(燕良注:这个文法是左递归的). 那么,我们已经用BNF范式定义的语言包含下面的语句: print a; 通过我们的定义,input语句(燕良注:指产生式statement:INPUT identifier END_STMT;)只能作用于一个标识符,而不能是字符串常量. 我们可以使用BNF范式正规的描述我们的语言的整个语法.注意这些现在还不包含语义,于是这条语句: a = (b == c); 纵使它没有意义它也将被语法分析器接受(我们正在试图把一个布尔值赋给一个字符串变量).语义将在下一阶段作检查. 语法分析器也可以用一个外部程序来产生.这个叫作Yacc(一个标准的UNIX工具,就像是LEX);我们将使用一个叫作Bison的改良版(get it?).Bison的手册有又可以在这里(http://www.monmouth.com/~wstreett/lex-yacc/lex-yacc.html)找到.Yacc文件(extension .y) 的分段实际上与LEX文件十分的相似.
<说明部分>包括记号的定义,类型信息,还有在前一部分我们看到的yylval共用体.那就是为什么我们使用一个共用体:Yacc使用同一共用体来在两个不同的"语言概念"之间传递信息,例如表达式,语句,程序.根据这些定义,Yacc为我们产生lexsymb.h(实际上它建立的是parse.cpp.h文件,不过parser.bat把它改名了). 就象LEX文件一样,在这部分同样可以在标记"%{"和"%}"之间包含一些初始化代码.在这部分的教程中没有用到这个功能,但还是可以增加一些你需要的附加的代码. 规则是特定的一些BNF范式,用来解释前一部分. Yacc有一个恶劣的陷阱,那就是你的语言必须使用LR(1)文法描述...这究竟是什么意思在龙之书中有详细的解释(第4.5,关于自下向上分析),LR(1)文法基本的意思是语法分析器还必须能够在查看当前语法记号或者最多预读一个符号就能说出使用什么样的语法规则.下面的语法规则会产生一个移进/归约冲突(shift/reduce conflict).(关于更多的文法理论可以参见最后我加的附注) A: 冲突产生在当你从输入文件中读了一个'B',而预读符号是'C'时,因为他们可以被组合(这两种产生式最终都将产生一个文法符号);问题是第2个产生式以'D'为结尾,而且第3个以它我起始:当语法分析器读取到了'C',而且预读的是'D'时,它不能决定是否归类为A2或A1后面跟着一个A3(燕良注:请注意我们只预读一个文法符号)!尽管这个完整的文法定义可能根本就没有二义性,但是对于语法分析器它却是有的,因为语法分析器只能预读一个文法符号.Yacc把这种不确定性称为移进/归约冲突或归约/归约冲突. 呵呵,别让这些吓着你.看一下这些规则.最重要的一条可能就是这条语句规则了:
你能看到,这里定义了我们的语言所有的语句类型,后面的代码是告诉语法分析器当发现了每个产生式时应该作什么.我认为这条规则是十分漂亮的.有一件事:"Errorstatement"告诉Yacc当分析一条语句时如果它遇到了一个语法错误后应该作什么(例如一个非法的记号或者一个不合时宜的记号).在这种情况下它会查找下一个END_STMT记号,然后继续分析后面的东西.语法错误会始终报告到在main.cpp中定义的yyerror()函数,所以我们的编译器会使用一个恰当的方法来处理它.如果在你的.y文件中没有提供任何一个错误规则,那么你的语法分析器遇到语法错误就会定下来,这可不是很好. 也许你在奇怪为什么会有这么多的表达式规则呢:expression, equal_expression, assign_expression, concat_expression 和 simple_expression.这是为了描述操作符的优先级.如果语法分析器看到了这个: if (a == b + c) 它应该知道它不应该先计算a==b然后试着把这个布尔值计算结果与一个字符串变量c相加(燕良注:这里的侧重点不是数据类型,而是算符的优先级).这些不同的表达式规则确定了唯一的语句的语法分析方法.花点时间好好看看它;它能够工作. 另外一个问题是当分析下面的语句时: if (a == b) if (c == d) e = f; else g = h; 语法分析器不知道else属于那个if语句(内层的还是外层的);它可以认为你的意思是: if (a == b) {if (c == d) e = f;} else g = h; 但是作为一个所有语言都遵循的惯例,else与内层的if匹配. 因为没有办法通过改变我们的规则来解决这个问题,Yacc将会报告一个移进/归约冲突.这个冲突可以简单的在说明部分加上这行来禁止: %expect 1 这意味这Yacc应该预期冲突1(Yacc should expect 1 conflict).Yacc将把else与最近的if配对,正象我们想要的那样.这就是它发现任何冲突的默认的解决方法. 一旦你理解了BNF范式,Yacc文件是非常的不解自明的.如果你还有什么不清楚的地方,你可以给我来mail或者在messageboard上提出问题. Yacc的源文件可以使用这条命令来编译: bison --defines --verbose -o parse.cpp 如果你得出了什么冲突,看一下输出的parse.cpp文件,那里包含冲动的细节(即使没有错误,那仍然是一个有趣的文件).如果你陷入了任何不能解决的冲突,可能把你的.y文件发给我,我会看一下的. 如果每件事都OK了(在样例代码中应该是这样的),那你在parse.cpp中就得到了一个可工作的语法分析器.我们的主程序要做的就是调用yyparse()函数,这个输入文件就会按我们的要求处理了. 再试试example.str文件,然后看一下它产生的错误.错误?是的,没错,我在第13行最后忘了一个';'.呵呵,它很棒吧? 今天我们作了很多事.我们学习了一些形式语言理论,如何使用Yacc,为什么Yacc对它支持的文法如此的挑剔,如何描述操作符的优先级.在最后,我们制作了一个可以工作的语法分析器. 好吧,我想最难的部分就在后面.如果你理解了这些,休息一下吧.然而,我在LR(1)文法上忽略了很多.给我来信或者发到messageboard让我来澄清那些问题.欢迎任何的问题和评论,让我知道有人在读这些东西. 下面是什么呢?下次我们大概要写两个新的组件:符号表和语法树.到那之后,你有一周来试验这些代码.提示:试着找到一个接受C风格的while语句的编译器.
如果我们想要用上两部分我们建立的词法分析器和语法分析器来作些有用的事的话,那么我们需要把我们从程序中收集的的信息存储到数据结构中.这就是下面我们要作的.这包括两个重要的组件: 符号表和语法树. 符号表,顾名思义,它是我们的程序中用来存储所有符号的一个表;在我们这里,包括所有的字符串变量,还有常量字符串.如果你的语言含有函数和类,他们的符号也将被存储的符号表. 语法树是程序结构的一个树形表示;请看下图.在下一部分中我们使用这个表示来生成中间代码.尽管不是必须建立一个语法树(我们已经从语法分析器中得到了所有关于程序结构的信息),但是我认为这可以使编译器更透明(燕良注:原文是tranparent,我猜是拼写错误,所以按transparent译的,不知道那是否是什么术语...),这正是在这个系列文章中我所要达到的目标. 显而易见,我们必须在我们的语法分析器中添加功能;例如,当我们发现一个符号时我们把它送人符号表--但是我们还希望它的"父"规则(事实上使用此标识符的规则)在符号描述中也要能够被访问. 我们在建立一个语法树时需要某些近似的东西:我们需要父规则有一个指针指向他的"孩子结点"(构成父规则的那些规则) 还记得yylval共用体吗?Yacc也使用他在规则之间传递信息.每一个规则能够使用yylval共用体的一个域;这是规则的类型.在string.y的顶部,你能看到类似下面的类型说明: %type <symbol> identifier string symbol和tnode是那个共用体的新成员;他们分别描述一个指向符号描述的指针和一个指向语法树的指针. 现在语句的规则象下面这样使用这些类型: | expression END_STMT {$$ = new TreeNode (EXPR_STMT, $1);} 它的意思是:如果你发现了一个expression语句,构造一个EXPR_STMT类型的新的树结点(并且返回新的结点指针),他带有一个"孩子":组成这个语句的表达式.$$代表一个规则的返回值,$1是规则定义中的第一个符号返回的值(expression).在这里$2没有意义,因为词法分析器没有为END_STMT记号设置一个yylval成员. 我希望这样的解释够清楚了,因为这很重要.本质上,规则是分层的,每一条规则能够返回一个值到"更高层"的规则. 现在让我们看一下符号表和语法树使用什么样的数据结构. 符号表在我们例子中至包含很少的信息;基本上它只是变量名和它第一次被声明的行.后面我们会使用它来存储更多的数据. 实现非常的简单:它只是当我们取回一个符号时(看一眼symtab.cpp)为符号的描述建立一个单链表(singly-linked list)并且线性的查找这个链表.对于一个真正的编译器,符号表通常被实现为一个binary search tree 或 hash table,以便能够更快的找到符号. 你要作的是当语法分析器发现这个时把我们的符号送入那个表:
我们把字符串常量处理成常量,我们为他们生成一个名字然后把他们送入那个表.注意:一个更高级些的编译器可能会让词法分析器来存储和取回标识符.这是因为复杂的语言中标识符可能有很多不同的含义:变量,函数,类型,等等.词法分析器可以取回标识符的描述,并直接把相应的记号返回给语法分析器.因为我们标识符肯定是变量,所以我们只使用语法分析器来处理他们. 我为语法树建立了一个非常简单的TreeNode类.它只存储指向孩子的指针和一些附加信息(结点类型,如果可用还有一个符号的连接).看看吧,这没什么复杂的. 象你前面看到的,我们可以从已经验证的语法规则轻松的建立语法树:
你会看到在某些时候我们只是无变化的从孩子规则到父规则传递信息;如果你的equal_expression 事实上就是一个assign_expression,就没有必要为它建立一个新的结点;你只使用在assign_expression中建立的那个.记住我们使用这么多表达式规则的唯一的原因是为了清楚的处理操作符的优先级. 编译这部分(和下面的部分)使用与前面相同的方法.程序还是接受语法结构上正确的程序,但是现在转储到它建立的符号表和语法树中. OK,它读程序并且分析它.但是它没有对程序作任何真正聪明或有用的事,不是吗? 是的,依然是.我们还有更多的组件要实现.下一部分将涉及语义检查和中间代码的生成.这将是一条通向编译程序的漫漫长路. 我希望你不要认为它进展的太慢,我只是想要集中到每一个分立的组件,而不是走马观花.如果你很快理解了这些东西,实验一下他们吧. 下次再见. 这次晚了一点...考试真是件可怕的事,它真的妨碍了一些有用的东西. 是的,上次我承诺了结果,你想要得到它们.也许多过你的希望 ;-) 首先是关于这个教程的一个备注.我是想要写一个非常紧凑的解释.所有的信息都在这里,但是常常是每个句子有两个重要的事情..这样作的缺点是是否有些事不大清楚,你可能没有跟上这个教程.当我进行的太快时请告诉我,好让我能够把事情说清楚. 回到这部分.它是关于语义和中间代码的.语义检查将确认你的程序是真正的正确,中间代码将是向虚拟执行(virtual executable)的一个巨大飞跃. 让我们开始检查吧! 语义检查不单单是检查程序语法的正确性,它还要确认语句有意义.例如,提供给函数的参数的个数应该是函数所预期的. 语义检查的主要部分是类型检查:决定表达式的类型和报告任何的不一致,如想要比较一个布尔值和一个字符串,或者传给函数错误的参数. 当然,也许你想要允许某些"不一致":例如有人使用了下面的语句 print "a and b equal: " + (a == b); 他的意思可能是表达式(a == b)应该被自动转换成一个字符串,最后成为字符串"true"或"false".这称为强制类型转换.在我们这个简单的编译器中我只允许布尔到字符串的强制转换,但是如果你认为字符串到布尔的强制转换有用,你可以轻松的加上它. 我们的语义检查器的代码并不复杂.我为TreeNode加了一个名为Check()的成员函数(在synttree.cpp文件中),它检查一个结点的语义,我们假定它的所有孩子结点都已经被检查了.Chech()在TreeNode的构造函数中自动调用,所以这个假定是安全的. 检查设置了一个名为rettype的新成员变量,表达式的"返回类型".例如,一个条件,当一个字符串连接另一个字符串时,布尔是它的返回类型.rettype用来检查父结点的语义.CoerceToString函数通过插入一个作为被强制转换的结点的父结点的新结点,COERCE_TO_STR,来强制转换任何的表达式为字符串类型(如果它还不是). 对一个简单的编译器这是很轻松,但是通常它不是这样.如果你的语言包含更多的基本类型,索引(references),数组,类和(操作符)重载,事情很快就变得非常的可怕;如果你希望你的程序能够运行,那么你最好有一个坚实的检查系统. 在一个真正的编译器中它从事更多的工作:有更多的强制转换,你必须计算出要使用哪个重载函数,类型等价不是再是这么平常,等等. 代码应该足够说明它们.它只执行一些简单的事,如if条件应该是布尔型,赋值表达式应该是字符串,等等. 中间代码在我们程序中表示为一个有序的图:每一条指令有一个指向下一条指令的指针,跳转有一个指向它的目标指令的指针. 我能想出两个这么做(使用指针)而不是立即产生代码到一个大的数组的两个好处:第一,使用指针便于把代码片段的连接,而且去掉某些指令时不用更新所有的跳转,等等.优化也因此相应的简单了.第二,如果你想要更改虚拟机的一些指令,这使你的编译器更容易改写来适应新的VM,因为你只需改变从中间代码到最终代码的翻译步骤,这相对的简单. 于是,基于上面的思想,我们设计了我们的中间代码语言.这个语言的操作码(opcode)将与我们的虚拟机要执行的即使不完全一致也是十分的相似.看一下它们: enum Opcode { 你将看到我们的VM是一个堆栈机器(a stack machine):操作码对堆栈中的值进行操作,把值放回堆栈.我想对产生代码和执行代码来说这是都最简单的机器类型了. 一个关于JUMPTARGET操作码的说明:每当我们的代码中有一个(条件)跳转时,它并不指向一条实际的指令而是指向一个有"JUMPTARGET"前缀的指令.这么做的原因是当我们优化时我们必须知道代码中的每个跳转的目的指针,或者我们也许会把一条目的指令优化掉并且混乱(mess up)我们的程序.这些JUMPTARGET将不出现再我们最终的字节码中. 一般而言,所有的操作码操作堆栈顶端的项目.OP_STR_EQUAL从堆栈中弹出顶端的两个项目(必须是字符串),检查它们是否相等,然后把结果的布尔值进栈.你的程序接着可以使用OP_JMPF指令来使用这个结果:如果栈顶的布尔值是false跳转到目标指令(由本指令提供,而不是在栈中),如果栈顶是true就继续执行. 指令被存储到一个非常简单的中间指令类中,它只是保存操作码,一个符号--操作数(例如OP_INPUT),如果需要还有一个跳转目的指令,一个下一指令指针和一个行号.行号实际上只是在使用Show()函数时使代码可读. 现在让我们看看如何产生中间代码(intcode.cpp).通常我们为语法树中的所有子树产生代码.所以main以树根来调用GenIntCode()函数;GenIntCode处理并且返回一个中间代码的起始指针. 先看个简单的例子,INPUT_STMT结点: case INPUT_STMT: 这产生一个新的OP_INPUT指令并且返回它.注意这个指令也是一个长度为1的指令块(block of instructions) ,next指针默认为NULL. PRINT_STMT更困难一点: case PRINT_STMT: 首先我们产生代码来计算表达式提供给print语句(root->child[0]).接着我们产生一个新指令OP_PRINT来打印栈顶的字符串.注意我们假设表达式把它的值放到栈顶.当然,我们得自己来保证这一点.最后我们连接两个代码块,然后返回结果. 现在是一个真正难的:IFTHEN_STMT.我产生所有需要的块,然后把它们都连到一起.它检查条件,如果它是false调换到结尾,如果它是true就执行then部分. case IFTHEN_STMT: 记住root->child[0]是条件子树,root->child[1]是then子树. 好的,如果明白了那个,对与剩余的代码你就没问题了.所有树的结点都使用这个方法翻译.Show()函数显示我们产生的代码.看一下所有这些: Program: 这看上去非常的象汇编代码,是吧?这是因为它就是.它是虚拟汇编(Virtual Assembly),本质上我们只需要写一个汇编程序来产生虚拟执行代码. 那进行的很快,不是吗?刚才我们还想我们是否将作一些有趣的事,突然我们就产生了虚拟汇编代码.我们几乎完成了. 下次我们将看一下优化(我确信如果你观察这部分的输出你能想到一些).很快我们将产生真正的虚拟机代码--但是我猜我们最好先有一个虚拟机!我们将看到从那我们去哪里.欢迎你发给我一些想法或建议. Bottom line: some interesting stuff is coming up. Stay tuned! See you next time. 我的考试结束了,我现在可能继续了. 在这部分我将涉及优化我们的中间代码的方法.记得吗,我们使用了一个非常简单的代码生成算法,所以那些代码也许相当的需要优化. 因为我们将在一个虚拟机上执行,所以优化变得格外的重要:我们的每一条指令将花费20条CPU指令去执行(很难更少),所以指令越少越好. 注意,我将只讨论与机器无关(machine-independent)的优化;面向机器的优化是一个完全不同的话题,在那里我们必须考虑象流水线效率,寄存器的使用等等这些.并且,当然的,面向机器的优化只有当你的代码在硬件上运行时才需要,这我们不需要.当然,可能有很多的方法来加速执行虚拟机本身,但是我们将在后面讨论. 对不起,这部分没有例子代码.一些优化的想法实现起来都是相当的简单,你将不会在这些问题上碰到麻烦.另外一些更复杂并且需要花大力气来实现.我没有时间来作,所以我只是给出一般的概念. 有两个重要的加速我们的代码的途径.一个是把代码翻译成更少的指令.另一个是制作更多强大的指令. 高级的指令(Higher-level instructions)可以在VM上执行的更快,因为堆栈操作和更新指令指针的总开销是(粗略)的相同的.所以我们将忽略RISC并且为外来的代码(exotic instructions)而疯狂!;) 让我们观察一些代码.这是example.vasm的一部分,example.str的编译后的版本: 1: OP_NOP 我应该注意它的一些事情.第一,在这个代码中的三个地方有一个OP_DISCARD跟随在一条OP_GETTOP的情况.我们将它它转换成一条OP_POP来提高速度,这条指令取得栈顶的值并且把它从堆栈中移走.我可以在开始时这么做,但是我想现在这样更简单. 第二,我看到了OP_PUSH; OP_GETTOP; OP_DISCARD两次.. 这是一个向"a = b"这样的简单赋值语句的代码.我们可以为它提供一个特殊的操作码OP_COPY,它把一个变量的值拷贝到另一个中. 第三,在这个程序的完整的代码中有相当多的"double pushes",两个进栈操作在一起.我们一个制作一个单独的OP_PUSH2操作码来加速它. 你或许能想出另外的高级指令.例如,一条连接一个现有字符串OP_CONCATTO操作码(s += "foo";).如果仔细的挑选他们将能够加速执行,所以花写时间来研究你的汇编代码,然后发现优化的可能.
优化输出代码的另一个途径是吧一部分代码变形成更快的执行同样任务的某些东西.下面是一个例子.
下面是一些变形代码的算法,节约指令..(saving instructions and thus time) 绝大多数优化集中在优化一些被认为是"基本模块(basic blocks)"的一小段代码.一个基本模块有下面这些性质:你能够在开始时跳转到它里面,并且你只能在它的结尾跳出.所以在这些块的中间没有跳转或者跳转目标(jump targets).这意味着在块之内我们能够确定一件关于我们的变量的值的必然的事情,我们可以利用这个信息来优化代码.举个例子,如果你可以跳转到块内的某处,我们不能确定,t仍然保留着值(a * b - c). 指针带给基本模块优化很多困难,因为你必须确定变量没有通过一个指针被修改,而不是了基本模块的某处通过它的名字被修改.往往你不能确定这点(指向指针的指针就几乎不可能知道什么变量被改变了). 一个优化代码的简单方法是使用产生相同结果的更快版本来替代原来的"天真的"计算.这些"天真的"计算的计算经常采用一个简单的代码产生方案而不是象程序员指定的那样.观察下表,十分明显.
这种优化利用某一表达式可能多次使用一小段代码的事实: a = a + (b - 1); 这里(b-1)是一个通用子表达式并且可被再次使用(第二个(b-1)表达式可以被"消除"). t = b - 1; // 把子表达式存储到一个临时变量中
一个众所周知的程序员的格言"程序90%的时间花费在执行10%的代码上",尽管这个百分比每个程序都不同,但是每个人都会同意绝大多数运行时间花费在一个内层循环上. 所以如果我们能使用某种方法优化这些循环,我们就能节省很多的时间...好吧,有很多中优化循环的方法;我将简单的讨论他们中的两个,代码移动和变量归纳(code motion and induction variables). 代码移动类似与子表达式消除,但是不是在一个基本模块中,它在循环开始前计算表达式并且在循环的整个过程中使用这个值. while ( i <= limit-2 ) 变为 t = limit - 2; 可是,循环也许没有很多的不变的表达式.它们经常使用的是一个循环技术器,并且这个技术器被频繁的使用在计算中,例如数组下标,等等.那就是变量归纳能帮我们的了. 如果j是我们的循环技术器,并且每次循环中都计算j*4,我们可以使用一个变量归纳,然后把这个乘法替代为加法: for (j = 0; j < n; j++) { 变为: t = 0; 有时你能够通过观察跳转的目的块来消去一个跳转.例如,你可能有: 1: jmp 7 你可以从目的块拷贝代码,然后节省一个跳转(如果条件为假): 1a: str_equal // | 目的块的拷贝 你要决定为了消除一个跳转你将要复制多大一部分代码,但是在内层循环中它能省很多时间.
这些信息使你的程序变得更有效率了.可是编译器优化是一个非常复杂的领域,我们只涉及到了非常少的一点.龙之书讨论了更多,所以如果你感兴趣,就去看它吧 . 下次我们将建立我们虚拟机,然后也许产生我们的虚拟机代码吧.那时我们就终于可以执行一个程序了. Somewhere on the wall a small white light flashed. "Come," said Slartibartfast, "you are to meet the mice. Your arrival on the planet has caused considerable excitement. It has already been hailed, so I gather, as the third most improbable event in the history of the Universe." "What were the first two?" "Oh, probably just coincidences," said Slartibartfast carelessly. HHG 1:30 我们已经在Part V产生了中间代码,并且我们想要把它转换成可执行代码,好让我们能够执行一个程序.但是我已经决定要先建立一个虚拟机,这样我们可以知道该如何处理产生可执行代码. 虚拟机当然是一个脚本引擎中非常重要的组件.我们的代码将在它那里执行,所以它最好快一些.但是这里我将不把焦点集中到速度上. Oh yeah:这部分结束后,你将完全免费的得到我那令人惊奇的堆栈模板(Amazing Stack Template),也不需要额外的小费.并且你将得到一个为这部分特别编写的很酷的字符串类,它完成至少5个精密的工作.那是你的物有所值的东西. 但是,首先是一个不同机器类型的说明.在Part V我只是说了我们的VM将是什么种类,没有说明其它的可能.Andy Campbell询问我关于这方面的其它可能性,并且我想其他人也许会感兴趣. 以前说过,我们的机器将是一个堆栈机器(stack machine).在真实的机器中,堆栈CPU被用于早期的计算机(并且今天依然在一些简单的设备中使用).缺点是需要很多的堆栈操作:每个操作数需要一个PUSH,每个结果需要一个POP.尽管你直接使用这个结果来进行下面的计算,所以那不总是必须的. 现在的大多数CPU有寄存器(数量非常有限的存储位置)来进行操作而不是堆栈;堆栈依然在函数传递参数时使用.可以只在寄存器上操作的机器被称为load/store机器,因为你必须load每个你用到的值,然后在你计算完后store每个结果. 某些处理器只操作内存数据;没有堆栈,也没有寄存器.使用这种处理器的机器被称为三地址机器(three-address machines),因为绝大多数指令有三个地址操作数(例如 ADD dest,src1,src2).由于内存带宽的限制,我认为他们不会在很多硬件中使用,但是他是虚拟机的一个选择. 对于虚拟机,堆栈机器非常容易实现,因为当你计算一个表达式时不需要临时变量来存储中间结果;你把所有东西放入堆栈(它与你处理一个后缀表达式的方法十分相似).虽然我将在这里使用临时变量.后面还有更多内容. 我不清楚三地址机器是否可能有一个优点;速度是最重要的一个,尽管我尝试了两者,我能肯定的说出哪个在优化中做了更少的计算...我想优化三地址代码更容易,所以也许这是这种机器的一个优点. JAVA表面上使用一个堆栈机器(我听说是这样,我对JAVA VM不熟). 我们的虚拟机对象根本就不复杂.它的最重要的成员有:一个指令数组,一个字符串表和一个堆栈.它有三个主要的接口函数:Reset,Read和Execute. 指令数组存储我们的程序包含的指令.指令类简单极了,看上去就像我们在Part 5中的中间代码使用的一样. 字符串表只是一个指针数组,它可以是NULL或者一个当前使用的字符串.这可能是一个程序的变量,或者一个堆栈中的临时变量. 我们的堆栈是由整数组成的.它们指向字符串表,使我们知道什么字符串现在在堆栈中.为什么我使用整数,而不是字符串类的指针呢?因为我想保持事物的简单(为了读者,也为了我自己):记住我们有时也想让堆栈存储布尔值,所以我们不得不建立一个存储字符串指针或布尔值的'stack item'类...现在我们只是使用一个整数:如果它是非负数,我们知道它指向一个字符串,如果它是负数它就是一个布尔值.它是脏的代码,但是他有利于工作并且每个人都可以理解它.不要在家试它,不要在一个真正的项目中使用它. 现在是接口函数.'Reset'重新初始化VM.它是一个很简单的函数. 'Read'将要在程序中读取.下次我们将改变这个函数让他从stdin中读取,但是现在它里面有一个测试程序.如果你喜欢就改写它--只是小心的让程序保持正确,不要让我们的VM崩溃. 'Execute'执行当前在内存中的程序.这也是一个简单的函数:它有一个指令指针,它察看一个指令,然后使用一个switch语句执行正确的代码.关于临时变量的一个说明:每当我们把一个变量放到堆栈,我们需要它的一个拷贝:我们不能只是把在字符串表中的变量的索引值进栈,因为他们的值可能改变并且接着堆栈中的值也会改变.这就是为什么几乎每个堆栈操作都使用NewTempCopy和DelTempCopy. 一点关于优化VM的说明:我们应该确保我们的堆栈操作尽可能的快;我们的堆栈模板不是特别的快.在字符串操作上也一样.一般而言,我们应该使通用的case快.最好把所有普通的优化技术应用到VM上. 关于VM还有很多要说:存储分配(allocation schemes),垃圾收集(garbage collection),保持他们稳定和高速,但是我想我将推延到下一部分. 下一次 "Come," called the old man, "come now or you will be late." "Late?" said Arthur. "What for?" "What is your name, human?" "Dent. Arthur Dent," said Arthur. "Late, as in the late Dentarthurdent," said the old man, sternly. "It's a sort of threat you see." Another wistful look came into his tired old eyes. "I've never been very good at them myself, but I'm told they can be very effective." HHG 1:22 我们有了执行我们的程序的所有需要的东西,除了...可执行代码.我们已经有了中间代码,并且它已经非常接近我们的虚拟机能理解的东西了.所以我们必须作的是一个中间代码和可执行代码之间的快速的翻译步骤. 为什么这需要是一个分离的步骤?就象你看到的,翻译实际上涉及到把我们的字符串放到一个数组中,并且为符号表提供他们的索引而不是指针.我们上次已经做了跳转目的,所以他们将不再改变.所以这是一个简短的部分,代码改变不大. 也许对于我们,建立中间代码不是严格的需要.但是写一个更高级的编译器时,有这样一个分离是非常有用的,在实际的机器码之前更多的'概念上的'阶段:它简化代码优化;你可以不困难的重新定义你的编译器到另一个机器. 当你阅读这部分的代码时,你将在几个地方看到到我的懒惰,它使我写了真正罪恶的代码. 举个例,我把编译器和虚拟机组合到了一个程序中,并且我传送"中间代码"给虚拟机,这不是很恰当的方法.你也许想要你的编译器来处理每件事直到可执行代码产生,然后也许存储可执行代码到一个文件,然后让你的VM读取&执行这个文件. 在我们这里,VM中的Read()函数首先从我们的符号表中取得所有的字符串,然后把他们放入字符串数组.然后它线性的通览代码,并且一行接一行的翻译它们.我们所使用的特殊的跳转目的指令只被转换成NOP指令,它应该被优化掉. Oh,我做得一个显著的可恶的事是我用来自编译器的符号表来存储虚拟机的字符串表索引(使用符号表的新成员PutNo()/GetNo())...它是非常简单的找到字符索引的方法,但是我同意模块化的程序设计是全然不同的... 嗨,你真的可以使用这个编译器/虚拟机的结合体来执行一个程序!你大概几乎放弃了它,不是吗?好吧,继续尝试例子.这部分有源码可以下载...他们应该正确执行.这很有趣吧. 好的,那就是我们曾经为之工作的东西.一个小小的语言,尽管它自身不是很有用,但是它表现了很酷的东西--你现在学习了建立你自己的脚本引擎的足够的东西. 现在发生了什么?经过了这样一个难以置信的极限(啊咳)我相信你有一点感觉空虚和不知所措.我们将从这里去到哪里? 我将可能作一个或更多的part介绍一些高级的主题,也许谈到为这个语言增加函数,类,多态,等等.让我知道你对什么感兴趣. 尽管将不再有代码--每个人都可以取得这个简单的编译器并且扩充它.或者,更好,写一个你自己的.The world's your oyster! Quote!"More importantly, a towel has immense psychological value. For some reason, if a strag (strag: non-hitch hiker) discovers that a hitch hiker has his towel with him, he will automatically assume that he is also in possession of a toothbrush, face flannel, soap, tin of biscuits, flask, compass, map, ball of string, gnat spray, wet weather gear, space suit etc., etc. Furthermore, the strag will then happily lend the hitch hiker any of these or a dozen other items that the hitch hiker might accidentally have "lost". What the strag will think is that any man who can hitch the length and breadth of the galaxy, rough it, slum it, struggle against terrible odds, win through, and still knows where his towel is is clearly a man to be reckoned with." HHG 1:3 DownloadsDownload the tutorial code (tut8.zip) (15k) 现在你已经玩了一下那个完成的脚本例子,也许你实现了一些新特性,或许当我们将要接触新东西时你在疑惑. 请允许我提醒您,这些好东西里的绝大部分都需要大量的工作(这些我将不再提供例子代码).我将讨论几个高级的脚本主题,给出如何实现(我的想法)的一般想法. 第一个: 前一段时间Joseph Hall给了我一个处理无限循环(infinite-looping)的脚本代码的很好的想法.他的思想是:每次调用虚拟机时给他最大数量的操作码去执行,并且如果下一帧它还没有完成时让它继续执行;这是虚拟的等价与CPU优先级多任务.这种方法使你的游戏引擎在脚本代码挂起时可以保持运行;它可以自动检测脚本是一个不变的循环并且重起VM. 现在,让我们看看我们可以怎么样扩展我们的语言: 在你的脚本语言中增加函数是非常困难的,它引入了参数和局部变量的概念.为了他们需要使用堆栈.在一个函数调用前程序把参数入栈.然后函数在同一堆栈中预留空间给它的局部变量.然后执行函数,使用预留的堆栈空间来读写值.在我们的简单的编译器中,我们仅仅从栈顶进栈和退栈,但是现在你也可以访问堆栈中间的内存地址. 你需要为函数使用两个特殊的操作码:CALL和RETURN.CALL是一个无条件的跳转,它吧指令指针保存到堆栈中.RETURN读取那个被存储的指令指针,然后跳回CALL后面的指令. 要做的一件最符合逻辑的事是让调用者(不是该函数)把参数从堆栈中移走参数;毕竟最初是调用者把他们放进来的.这也考虑到一个"输出参数(output parameters)"的简单机制:函数改变一个参数的然后调用者把这个值存入一个变量.一个函数的返回值也可以看作是一个输出参数. 函数的信息头可以存储到一个符号表中.使用他们,你可以存储它的参数和局部变量(可以每个是一个分离的符号表实体).在代码生成的过程中,你可以在符号表中存储函数的起始地址. 重载(Overloading) 操作符可能看作是一个用不同语法调用的函数;如果用这种方法来处理你的操作符(不要真把它们作成函数(慢),而是inline函数或者宏),你可以轻松的扩展函数重载到操作符重载. 类 如果你想要在你的语言中实现类,正确的决定你想要支持那些特性.支持完整的C++类,包括多继承,访问控制,动态束定,虚函数,等等是非常困难的,我建议不要在一开始就处理所有这些.一个带有单继承的简单的类系统是一个很好的起点,如果需要的话你以后可以扩展它. 类和结构体是符合数据类型:他们包含多个数据成员,并且连接到一定数量的方法或者成员函数.你可以在你的符号表中存储一个成员列表,它与其他分离的成员符号表实体相连接.这可以使你简单的找到结构中某个成员的偏移量. 继承 多继承,当调用一个成员函数或者访问一个数据成员时,它带来了二义性问题.思考这个:两个类B和C是统一个类--类A的子类.然后建立一个类D源于类B和类C这两个类.现在,如果类A有一个公有成员函数DoSomething,当成员在一个D类型的对象中调用DoSomething时,你不能知道调用两个DoSomething中的哪个:一个是B的A部分,另一个是C的A部分..好吧,也许看图可以更清楚. 虚函数 虚函数函数使用一个所谓的vtable来实现.当父类声明一个函数为virtual时,它在那个类中增加了vtalbe.每个子类现在取得他们自己版本的vtable,这样,不同的函数调用基于那个对象实际的类型,尽管在调用者看来这些table之间并没有区别. 动态束定 类型变量(Type variables) TypeVar<Enemy> enemytype; // A type variable 你可以传递类型变量到一个函数;这将使得他们很有可塑性,你可以使用同一个函数来建立和处理很多不同类型的对象. 为了类型变量,你需要扩充类和他们的父类的表来包含每个类型的大小;否则你将没法动态建立他们. UnrealScript(据我所知)是第一个提出了两个在游戏中非常有用的特性的语言:状态和隐藏代码. 状态 状态并不是非常难加入,尽管它的确需要一些工作;状态是一个额外的类成员(不可见),并且每当调用特定的状态函数时恰当的函数版本将被执行.这可以使用一个使用状态号码为索引的跳转表来轻松实现. 状态可以有它们自己的函数外的代码,在UnrealScript中是状态代码.这可以方便的与下一个构思相结合:隐藏的函数. 隐藏的函数(latent code) 隐藏代码带来的另一个问题是本质上它与其他代码并行执行.偶尔隐藏代码被执行,然后它又被停止.所以我们必须记住隐藏代码的指令指针.并且当对象改变它的状态时,你将也需要执行其他的隐藏代码. 我们可以看到UnrealScript唯一提供隐藏代码的原因是为了调用状态代码,而不是普通函数:假设隐藏函数可以在任何地方被调用,每个对象本质上可以有很多的并行执行的"线程"..这可能需要大量的记录并且将变慢.而且也将产生同步问题:一个对象的线程将把一个成员变量设为某个特定的值,然后一个其他的线程变为活动后再次修改它...如果你想允许它将需要实现一个完整的多线程系统. That's it for now.. 我希望这可以激发你的想象力.有许多特性你的脚本语言可以实现;如果你想完成它你将限制自己为某一个. 这可能是这个系列教程的最后以部分.我乐于写它.如果你觉得在一些地方还不够,让我知道,也许我将写一个额外的部分.当然,如果你有其他的一些问题我也乐于听你说. Good luck, and keep on scripting! ;-) |
![]() | |||
|