自己写的CC++编译器Doctors[软件+文档]

    技术2022-05-11  40

    自己写的C/C++编译器Doctors[软件+文档]

     

    http://blog.csdn.net/huyansoft/archive/2009/08/20/4464772.aspx

     

    更新版本1.0.1:该版本解决了之前1.0.0版本中找不到链接库libc.lib的问题,以及IDE下点击Project菜单出现的BUG。下载地址:http://download.csdn.net/source/1597132

     

    Doctors编译器内部实现

    For version 1.0.1

    出处:http://blog.csdn.net/huyansoft

    作者:胡彦

    Copyright(c) 2009,All Rights Reserved

     

    简介:

    Doctors是标准C++语言子集的编译器,它可以将C++源程序编译链接成Win32平台上可执行的EXE文件。代码采用OOP语言完全手写而成,提供了IDE界面和命令行二种使用方式,其设计初衷是为程序提供更多的诊断功能。

     

    本文通过其部分源码,介绍了其设计方法、实现思路、和作者的一些体会。

    本文档可作为编译课程的课外读物,读者可从中了解一个实际编译器的内部构造,补充教材上缺乏的实践知识,加深对抽象理论的理解。对实现类似编译器、解释器的读者也具有直接的参考价值。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    目录

    第一节 初衷和目标

           一 排错工具

           二 多线程

           三 Unicode

     

    第二节 开发方法

           一 为什么不用Yacc和Lex

           (一)Yacc的缺点

                  1库

                  2编程方法和多线程

                  3错误诊断

                  4二义性和错误恢复

           (二)Lex的缺点

                  1状态冗余

                  2紧耦合

                  3行号

                  4效率

           (三)小结

           二 OOP的优缺点

           (一)算法由数据结构表达

           (二)核心多态

           (三)编码反馈出设计的优劣

     

    第三节 整体结构和流程

           一 整体结构

           二 整体流程

           三 编译流程

     

    第四节 词法分析器

           一 记号

           (一)记号的类别

           (二)支持的C++记号

           二 词法分析过程

     

    第五节 数据类型

           一 基本类型

           二 数组类型

     

    第六节 符号表

           一 前端符号表

           (一)构造与析构

           (二)前端符号表的作用

                  1查找名字的定义

                  2存储预分配

           二 后端符号表

           (一)常量表

           (二)变量表

                  1全局变量

                  2静态变量

                  3外部变量

           (三)字符串表

     

    第七节 语法分析器

           一 支持的语法

           二 调整文法

           (一)从产生式到BNF

           (二)消除二义性

           三 抽象语法树的设计

           (一)函数的AST结点

           (二)语句的AST结点

           (三)表达式的AST结点

           四 语法分析过程

           五 语义分析

           六 错误恢复

           (一)错误的应对

                  1词法错误

                  2语义错误

                  3语法错误

           (二)错误恢复机制

                  1继续分析下去

                  (1)假设、虚构

                  (2)忽略、推迟

                  (3)跳跃

                  (4)调整文法

                  2防止误报

     

    第八节 中间代码的设计

           一 中间代码的结构

           二 中间代码具体格式

           (一)赋值

                  1一元赋值

                  2二元赋值

                  3函数调用

                  4数组元素访问

           (二)标号

           (三)跳转

                  1无条件跳转

                  2条件跳转

                  (1)布尔跳转

                  (2)关系跳转

     

    附录:参考资料

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第一节 初衷和目标

    一 排错工具

    在写程序时,你是否犯过下面的错误?

    1

    for(int i=0; i<10; ++i);

    {

      cout<<i<<endl;

    }

    打算输出10个数,可怎么调试循环体都只能执行一次,输出0!是编译器坏了?调试器坏了?累到半死才发现,是增删代码过程中,第一行尾不小心残留了分号,导致循环体为空。

     

    2

    #include "header.h"

    const int MAX_LEN=256;

    准备定义常量MAX_LEN,可是编译器总是报错“missing ';' before 'constant'”,改成“int MAX_LEN;”还是一样,被折腾得要崩溃才发现,原来header.h中有这么一句:#define MAX_LEN 256

     

    3一个复杂表达式中括号不匹配时,编译器会提示“括号不配对”,可这么多的括号,究竟是哪个不匹配呢?要费神去找,类似{}也一样。

     

    这些BUG占用了不少调试时间,可这些低级错误总是一犯再犯,即使熟练者也不例外。因此,一个好用的编译器不仅要报告语法错误,还要能发现程序中潜在的逻辑错误,并且错误信息尽可能准确,对出错位置不但要精确到行,还要精确到列,如果出错的记号是由宏扩展而来,还要提示宏定义的位置。

     

    上面都是设计Doctors的初衷,我期望它成为一个有用的程序排错工具(尽管现在离这个目标还差很远)。

    为了做到这一点,首先要能支持ISO C++大部分语法(很宏伟很遥远的理想)。再次,仅仅完成语义检查是不够的,还要生成中间代码,在中间代码优化过程中执行数据流分析和控制流分析,可以找出更深层的错误,比如VC报告函数“not all control paths return a value”就要控制流分析,并提前发现一些运行时错误,如通过常量折叠和复制传播能发现引用空指针、除数为0的错误。

    要是仅实现诊断功能,完成了优化也就可以了,之所以还有目标代码生成,是想做一次尝试,实现了汇编生成、调用完汇编器、链接器生成可执行文件后,才能知道编译器到底是怎么运作的。此外,前端符号表的设计要考虑后端如何使用,只能掌握汇编语言才能明白运行时存储分配,才能真正理解高级语言机制。

     

    除此之外,我还想实现其它一些想法:

    二 多线程

    现今大部分机器已经是多核CPU了,如果程序能多线程并行地编译多个源文件,就可以放手去做那些耗时的优化和错误分析(越朝将来好处越明显)。还有,编译器中一些函数可能实时被其它模块调用(如IDE也需要识别注释和字符串),总不成一被调用就并发错误吧!

    不知你是否注意到,双核CPU已经有些年头了,可主流编译器仍然是串行编译各个源文件的,为什么呢?因为要实现多线程,首先要消除代码中的全局变量(总不能一个全局变量加一个互斥锁吧),而主流编译器经过这么多年的发展,规模已经相当宠大了,即使是代码原作者要重构掉这些全局变量,也是一件麻烦的事情。

    因此,从一开始就要杜绝全局变量。经过努力,这个版本已经做到了,所有程序没有使用一个全局变量和静态变量(具体做法可参见后续章节)。

    此外,编程语言的运行库要支持多线程。不幸的是,我用的VC2005下C++ I/O流库虽然支持多线程(运行不出错),可速度却慢到无法接受的地步,要解决只有采取其它变通方法,因此,这一想法暂时被搁置。

     

    三 Unicode

    扫描器中难免会用到如isdigit之类的函数,如果不支持Unicode,一旦源程序出现汉字等全角字符,这些函数就会断言失败,反之在Unicode下就没事。还有一次,我的机器由于软件冲突,所有窗口的文字都变成了乱码,只能金山词霸巍然不动,这给我留下了深刻印象。因此,程序全部用Unicode书写了,包括main函数都是宽字符版的。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第二节 开发方法

    词法分析、语法分析、代码生成等所有模块都是手写的,未使用生成器工具,分析器采用递归下降方法。

    源程序采用标准C++、STL编写,采用OOP风格,仅驱动程序中有少量平台相关的代码,它们被放在单独的源文件中。

     

    一 为什么不用Yacc和Lex

    在讨论它们的缺点之前,让我们先来回顾一下它们的优点:

    1当用户学过编译原理后,便已经掌握正规式和上下文无关文法了,对Yacc和Lex语言也有了解,只要上机调试,学会搭建Yacc和Lex的工作环境,便可以用它们来写扫描器和分析器了。

     

    2在Yacc程序中,产生式与处理过程(语义动作)一一对应,可以将标准C/C++的文法照搬过来稍微调整。若以后语法变了,修改对应产生式的工作量也小。同样,在Lex中,书写和修改正规式比手工打造识别代码要轻松得多。

     

    3采用LR方法时,Yacc事先将语法分析表算好,分析时查表匹配输入串,运行效率高(Lex的DFA状态转换也类似)。

     

    4Yacc支持的文法类型更多(不仅LALR),适应实现不同特征的目标语言。

    总之,Yacc/Lex工具封装了实现扫描器、分析器的常用框架,用它们构造编译器难度低、效率高、易维护。

     

    但为什么不用它们呢?

     

    (一)Yacc的缺点

    1库

    如果我们的程序是用Yacc写的,编译时必须与Yacc库相链接才能运行。虽然大部分Yacc实现都提供了源代码,但在向其它平台移植时总会遇到麻烦。由于程序必须依赖于第三方库(就像走到哪里都得拄拐棍一样),带来沉重的包袱。

     

    2编程方法和多线程

    Yacc/Lex诞生于上世纪70年代,由于历史的局限性,Yacc/Lex程序中必须大量使用全局变量,比如在Lex中,获取匹配的文本要用yytext,传递记号给Yacc用yylval,读写串用yyin/yyout,状态切换用yystart…虽然有些Yacc实现能够生成面向对象和多线程的代码,但那样我们就得舍弃移植性去接受它的“方言”。

     

    3错误诊断

    递归下降分析可以提供准确丰富的诊断信息。比如当一个声明语句错误时,我们可以明确指出是局部变量声明错误,还是函数形参声明错误,只要对应的解析函数多加一个参数就行了。

    可是在Yacc归约到声明语句时,当前语义动作并不知道自己是在一个局部块中,还是在函数声明中,要想知道,只有事先在上层语义动作把属性存进全局变量,在下层语义动作中读取它。

    另一个例子,假若我们用Yacc实现C/C++中的条件编译指令#if…#endif,由于#if…#endif是可以嵌套的,当进入内层#if…#endif时,必须知道外层是否处在编译状态(外层被忽略了内层自然也要忽略),因此必须将属性层层向下传递…

    也就是说在Yacc中,即使采用了LR分析方法,也要借助许多自顶向下技术来弥补它的缺陷(也不能全怪Yacc,幕后黑手是LR)。

     

    4二义性和错误恢复

    Yacc中产生式一旦冲突往往让人不知所措,如果屡次尝试失败,只能在纸上演算LALR的项目集,才能找到冲突的原因,这使得初学者望而生畏。

    又例如,对C++语句int (x); 它既可能是对x的声明语句,也可能是对x强制转换的语句,标准C++规定在这种情况下视为声明,如果用Yacc去解析这一句就会遇到麻烦。

    虽然Yacc的错误恢复是一个妙招(出错时跳至预定记号,并自动匹配非终结符error),可对error的使用不是随心所欲的,写不好就会冲突或报错。

     

    Yacc的这些特征,Lex一样也不少,此外Lex还有自己独到的缺点。

     

    (二)Lex的缺点

    C/C++的词法分析和预处理过程,逻辑上至少分成下面几遍(虽然大部分实现不会真的扫描这么多遍):

    第1遍,字符集转换(例如三联符)

    第2遍,断行续接

    第3遍,识别并处理注释

    第4遍,执行预处理指令(文件包含、条件编译、宏替换)

    第5遍,转义字符替换

    第6遍,相邻字符串拼接

    第7遍,将每个预处理记号转换为词法记号

    在顺序执行了上面逻辑遍后,才能正确识别出一个词法上的记号。

     

    如果我们用Lex去实现,想一两遍完成是不可能了(算法极为繁杂),只有多遍处理,每遍添加一个Lex状态。开始扫描时用BEGIN切换到第一遍状态,处理结果存在文件或字符串中,一遍完了在yywrap()函数中修改yyin为刚才处理结果,再用BEGIN切换到下一遍状态…问题也随之而来:

     

    1状态冗余

    在每一遍过程中又要增加许多状态(比如进入单行还是多行注释)。除前2遍外,每遍都要识别和处理源程序中的字符串(比如一段注释若出现在字符串中就不是注释),但由于每一遍着眼点不同,这些状态的正规式和语义动作是难以复用的(比如第4遍以前识别字符串时,要排除#include”…”的头文件名,而后续状态则不用),结果每遍都要增加类似的状态和正规式,造成了大量冗余(Lex输出的文件中状态数目会相当多)。

     

    2紧耦合

    每组紧密协作的正规式间都是牵一发而动全身的,如果我们无意间修改了一个正规式(哪怕只是一个空白符),就会牵连其它正规式、产生意想不到的匹配结果。

     

    3行号

    当删除一段多行注释、或在断行续接时,其中的换行符同时也要删去,后续遍再统计行号就会错误,只有采用插入特殊字符、修改底层yygetchar函数这种不优雅的方式去解决。

     

    4效率

    前一遍识别了某个记号,只能存放在字符串或文件中,下一遍又得从输入串中逐字符读取、重新装配这个记号,可谓事倍工半。我们也可以将识别出的记号放在一个list中,下一遍直接遍历list,需要时yyunput一下让Lex去匹配,但这样实际上已经半抛弃Lex了。

     

    总之,Lex强迫用户面向字符流工作(而不是记号流),这对于像C/C++逻辑上要多遍扫描的词法分析器而言,并不合适。

     

    (三)小结

    Yacc/Lex为用户编写分析器和扫描器提供了便利的框架,如果用它们写计算器或其它小规模的程序,还是非常便利的。但Yacc/Lex的框架同时也束缚了用户的手脚,如果选择了它们,我们的程序就像小盒子里的爬虫只能做有限的翻滚动作。对实现编译器而言,从长远看还是手写更合适。

     

    二 OOP的优缺点

    OOP的若干好处就不重复了,这里只列举它对编译器的优点:

    (一)算法由数据结构表达

    例如,生成中间代码实际上要遍历整个抽象语法树,在面向过程中我们可能会这样写:

    void Gen(AstNode* pNode)//为AST结点pNode生成中间代码

    {

           if(pNode有子树1)

                  Gen(pNode->子树1);//为子树1生成中间代码

           if(pNode有子树2)

                  Gen(pNode->子树2);//为子树2生成中间代码

           …

    }

    即我们要显式递归调用Gen(可能还要根据pNode的类型强制转换成对应的AST结点,再调用相应的Gen函数)。在OOP中就不用这样了:

    void AstNode::Gen()//为AST结点pNode生成中间代码

    {

           子树1.Gen();//为子树1生成中间代码

           子树2.Gen();//为子树2生成中间代码

           …

    }

    我们没有任何递归调用,只是顺序为各个子树生成代码,但实际上它却实现了遍历操作,因为算法已经由类的数据结构静态确定下来了。

     

    (二)核心多态

    上段程序中的Gen()一定是多态的,比如同样是语句,if与for代码生成方式不同,就会对应不同的Gen函数。

    比较上面2段程序可以看出,多态函数的表述更自然,不需用晦涩的函数指针和强制转换。如果一个模块的主要函数不是多态的,那么这段程序就不能称之为OOP。

    在Doctors中,AST和IR被设计为类的树型继承结构,中间代码、目标代码的生成全部是多态的。也许你会怀疑这是否导致性能的下降?不用担心,C++的设计者早就意识到这一点,在C++中查找一次虚函数是通过类似变址寻址实现的,其耗时与访问一次数据元素相差无几(想想程序中本来就有多少次间接访问?),从实际应用来看,虽然大量调用虚函数,却并未感觉到性能的降低。

     

    (三)编码反馈出设计的优劣

    在C中,使用强制转换是必须的(没什么替代方法),而C++中,当你必须把一个类的指针强制转换为另一个类的、或者必须在某类中增加一个“type”成员再用if去辨别它们时,就应该考虑到是设计上出问题了,要调整类层次结构、引进基类和虚函数使其合理。一段“不优雅”的代码很可能暗示出一个不良的设计。

     

    当然,OOP也有缺点,它将用户的注意力引向了程序的结构,分散了用户在算法上的精力(对于一些纯粹算法似乎更应面向过程),而一些好的算法又可能被埋没在名字空间中了。另一个缺点是,一旦修改了基类的结构,许多子类都会受到牵连。不过总体来说,优点远远大于缺点,在以后的内容中我们会不断地体会到。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第三节 整体结构和流程

     

    一 整体结构

    所有程序除main函数外,全部划分在下面几个名字空间中,每个名字空间包含若干个类,实现对应模块的功能:

    aegis:内存泄漏检查

    assem:汇编代码生成

    ast:抽象语法树,包括表达式、语句、函数等类族

    driver:驱动程序,包括命令行类,Compile、Assemble 、Link等驱动函数

    err:错误报告

    ir:中间代码,包括赋值、跳转、标号这几个类族

    lex:词法分析器,包括扫描器类、各种记号类

    parser:语法分析器,包括一系列递归下降的解析函数

    pub:一些公共数据

    symtab:符号表与类型,包括各类符号表、基本类型与数组类型

     

    不同名字空间下可以有同名的类,从而有效解决重名问题,否则你会发现很快用完了喜欢的名字。

     

    二 整体流程

    从main函数的源码,可以看出整体流程:

    int wmain(int argc, const wchar_t* argv[])

    {

           using namespace driver;

           CmdLine cmdLine;//命令行对象

           if(!cmdLine.Parse(argc, argv))//解析命令行

           {

                  return -1;//命令行有错误

           }

           if(!Compile(cmdLine))//按命令行的options编译其中的source files

           {

                  return -1;//编译失败

           }

           if(!Assemble(cmdLine)) //将各source files的编译结果汇编成obj文件

           {

                  return -1;

           }

           if(!Link(cmdLine)) //将各obj文件链接成一个可执行文件

           {

                  return -1;

           }

           Run(cmdLine.m_sOutFile);//运行链接出的可执行文件

           return 0;

    }

    Compile函数将源程序编译成Win32下的汇编代码,之后Assemble和Link函数分别调用Microsoft的汇编程序ml和链接程序link,产生最终的可执行文件。为了调试方便,链接后就直接运行这个程序了。

     

    待改进

    增加若干选项,指示分别执行到哪一步停止。

    将整个程序写成动态库,可以被更灵活、高效地调用。如果需要,可另写一带命令行的控制台程序封装这个动态库。

     

    三 编译流程

    从二个重载Compile函数的源码,可以看出编译流程:

    bool Compile(const CmdLine& cmdLine)//依次编译cmdLine中每个源文件

    {

           wcout<<L"/nCompiling .../n";

           bool bRet=true;

           const CmdLine::FileNames& fileNames=cmdLine.m_fileNames;

           for(CmdLine::FileNames::const_iterator iter=fileNames.begin(); iter != fileNames.end(); ++iter)

           {

                  const FileName* pFileName=*iter;

                  if(!Compile(pFileName))//编译这个source file

                  {

                         bRet=false;

                  }

           }

           return bRet;

    }

     

    bool Compile(const FileName* pFileName)//编译pFileName指示的源文件

    {

           const wstring& sSrcFile=pFileName->m_sName;//源文件名

           wifstream srcFile(sSrcFile.c_str());//打开

           if(!srcFile)

           {

                  wcerr<<L"cannot open source file "<<sSrcFile<<endl;

                  return false;

           }

           wcout<<sSrcFile<<endl;

     

           using namespace lex;

           Lexer lex;//词法分析器

     

           using namespace err;

           Err err(lex, wcerr);//错误对象,它默认输出到wcerr(或其它流),生成错误信息时根据lex获得行号

     

           using namespace parser;

           Parser parser(lex, err);//语法分析器,它使用词法分析器lex,错误信息输出到err

     

           Ast ast;//抽象语法树

           Btab symTab;//符号表

           if(!parser.Parse(ast, symTab, srcFile))//解析srcFile,输出ast和symTab,以及错误信息err

           {

                  return false;

           }

     

           Ir ir;//中间代码

           ast.Gen(ir, symTab);//对ast生成中间代码,期间访问symTab

     

           const wstring& sFile=pFileName->Get();

           const wstring sIrFile=sFile+L".ir";

           wofstream irFile(sIrFile.c_str());

           if(irFile)

           {

                  ir.Out(irFile);//输出中间代码到.ir文件

           }

           else

           {

                  wcerr<<L"cannot create Ir file "<<sIrFile<<endl;

           }

     

           const wstring sAsmFile=sFile+L".asm";

           wofstream asmFile(sAsmFile.c_str());//汇编码的输出文件

           if(!asmFile)

           {

                  wcerr<<L"cannot create asm file "<<sAsmFile<<endl;

                  return false;

           }

           using namespace assem;

           Asm winAsm(asmFile);//汇编码对象,内含输出文件asmFile

           ir.Gen(winAsm, symTab);//对ir生成汇编代码到.asm文件,期间访问symTab

     

           return true;

    }

    从上面可以看出,第1遍对输入流语法分析,生成抽象语法树,第2遍对抽象语法树生成中间代码,第3遍对中间代码生成目标代码。为便于检查和对照,生成的中间代码还保存到对应的.ir文件中,汇编代码保存到对应的.asm文件中。

    每个源文件都对应自己的扫描器、分析器、和错误对象,各源文件的运行状态互不干扰,这是实现多线程的基础。每个对象的初始化由构造函数完成,清除由析构函数完成,省去了手动的初始化和清除步骤。

     

    待改进

    加入单独的优化过程,初步打算加入局部优化、活跃变量分析、死代码删除

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第四节 词法分析器

    一 记号

    (一)记号的类别

    用下列枚举常量分别代表不同的记号类别,它们对应语法上的终结符:

    class Token//记号的基类

    {

    public:

    enum

    {

           AND=256,//记号&&

           ARRAY,//表明一个记号是数组类型,即这个标识符是数组名

           BREAK,//关键字break

           DEC,//运算符--

           DO,//关键字do

           ELSE,//关键字else

           END,//表示输入流的结束

           EQ,//记号==

           EXTERN,//关键字extern

           FALSE,//关键字false

           FLOAT,//浮点常量

           FOR,//关键字for

           GE,//记号>=

           ID,//除关键字以外的标识符

           IF,//关键字if

           IN,//关键字cin

           INCH,//关键字getch

           INT,//整型常量

           LE,//记号<=

           LS,//记号<<

           MINUS,//表示负号,供语法分析器使用。词法分析器无法区分负号和减号,它一律返回减号的ASCII值

           NE,//记号!=

           OR,//记号||

           OUT,//关键字cout

           OUTCH,//关键字putch

           RETURN,//关键字return

           RS,//记号>>

           STATIC,//关键字static

           TEMP,//供中间代码生成使用,表示分配的临时变量,以与ID相区别

           TRUE,//关键字true

           TYPE,//int, double, char, bool等基本数据类型

           WHILE,//关键字while

    };

    public:

           const int m_nTag;//记号的类别

    ……

    };

    对于+、=之类的单字符记号,用它们的ASCII值表示其类别。有几个特殊的记号类别:

    ARRAY:表明一个记号是数组类型,即这个标识符是数组名。

    END:当扫描到输入流结束时,向语法分析器返回这个记号。

    MINUS:表示负号,供语法分析器使用。词法分析器不区分负号和减号,它一律返回其ASCII值。

    TEMP:供中间代码生成使用,表示分配的临时变量,以与ID相区别。

     

    (二)支持的C++记号

    class Int: public Token//整型常量

    {

    public:

           const int m_nValue;//对应的整数值

    };

    class Float: public Token//浮点常量

    {

    public:

           const double m_fValue;//对应的实数值

    };

    class IdPunc: public Token//关键字、标识符、标点符号

    {

    public:

           const wstring m_sValue;//对应的字符串

    };

     

    二 词法分析过程

    class Lexer//词法分析器

    {

    private:

           wistream* m_pInStream;//输入流

           wchar_t m_cNext;//下一字符

           int m_nLine;//当前行号

           typedef set<const Token*> Tokens;

           Tokens m_tokens;//记号表

    private:

           const Int* Add(int nValue);//若m_tokens中不存在值为nValue的整数记号,则new一个Int对象加入之,并返回该对象的地址

           const Floating* Add(double fValue);//与上类似,添加一个浮点数记号

           const IdPunc* Add(const wstring& sValue);//与上类似,添加一个标识符等记号

    public:

           const Token* GetNextToken();//返回下一个Token的地址

    ……

    };

    输入流采用了抽象基类istream,这使得源程序不但可以来自文件,还可来自字符串、甚至控制台,而程序不需做任何改动。

    Lexer类里有一张记号表m_tokens,每识别出一个记号看是否在已在表中,不在时才加入,后续模块若要比较二个记号是否相等,只需比较它们的地址,提高了效率。释放工作在Lexer的析构函数里统一完成,防止了泄漏。

    GetNextToken函数提供给语法分析器调用,每调用一次,它从输入流中识别一个整数、浮点数、标识符或其它符号,调用重载了的Add函数,返回其地址(Token*),语法分析器根据其m_nTag可知其类别,必要时转换回原来的Int、IdPunc等类型。

    每个Lexer对象有一个行号成员m_nLine,它记录了当前源文件被扫描到的行号。

    读字符时识别简单的//注释,当遇到连续的//时,便忽略后面的字符,直到读入了换行符。

    当输入流结束时,向语法分析器返回记号END。

     

    待改进

    识别简单的/**/注释

    识别字符常量和字符串文字量记号

    (长远的)加入预处理过程,它向词法分析器返回预处理记号(pp-tokens),每个pp-tokens都含有源文件名、起始行列位置、终止行列位置,若这个pp-tokens由宏扩展而来,还要提供原宏所在位置,使得上层程序能够生成精确的错误信息。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第五节 数据类型

    一 基本类型

    class Type //基类

    {

    public:

           const int m_nWidth;//该类型的宽度,用于存储分配

           static const Type INT;//int类型

           static const Type DOUBLE; //double类型

           static const Type CHAR; //char类型

           static const Type BOOL; //bool类型

           static const Type VOID; //void类型

    public:

           virtual const Type* GetBottom() const

           {

                  return this;

           }

    ……

    };

    未对每种基本类型单独派生子类,而是定义为一静态常量,这样,当判断一个Type对象是不是整型时,直接将其地址与&Type::INT相比较。

     

    待改进

    增加float, long, short, 等基本类型,可以从Type派生不同的子类,也可用枚举成员来区分。

    增加unsigned无符号类型。

    支持更多的隐式转换。

     

    二 数组类型

    class Array: public Type//一维或多维数组类型

    {

    private:

           const int m_nNum;//元素个数

           const Type* const m_pType;//元素的类型

    public:

           virtual const Type* GetBottom() const//例如double[3][5]的底层类型是double

           {

                  return m_pType->GetBottom();

           }

    ……

    };

    数组类型Array由Type派生,m_pType可以是基本类型,如果它又是数组类型,那这个Array对象便是多维数组。虚函数GetBottom用于获取最底层数组元素的类型,对于基本类型它直接返回自己,对于数组类型,它调用其元素类型的GetBottom,直到某一次调用到了Type:: GetBottom()。

     

    待改进

    加入指针类型,只能支持指针才能处理字符串。

    加入结构类型,它是class的基础。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第六节 符号表

    符号表不是一张表,而是若干张表的总称,例如对前面Lexer类的记号表,你也可以把它看成一张符号表。

    在Doctors中,符号表有前后端之分,前端不用后端的符号表,后端也不用前端的符号表,它们的特性完全不同。

     

    一 前端符号表

    (一)构造与析构

    前端符号表是语法分析器内部使用的,其中存放函数名、变量名和数组名,分析结束时亦全部销毁。

    class Ftab//前端符号表

    {

    private:

           typedef set<const Name*> Names;

           Names m_names;//标识符列表

           Ftab* m_pPre;//指向外层语句块的Ftab

           int m_nSize;//符号表于当前函数中已分配的大小,用于计算局部变量的偏移地址

    public:

           Ftab(Ftab* pPre=NULL, int nSize=0);

           const Name* Find () const;

           bool InCurLevel () const;

    ……

    };

    当parser开始分析时,它会新建顶层的Ftab(置其m_pPre为NULL),每当遇到函数或全局变量声明时,将其名字加入当前(即顶层)的Ftab。在函数体内,每进入一个语句块(新的作用域),便分配一新Ftab,置其m_pPre为当前Ftab。当离开这个语句块时,释放该Ftab,并置当前Ftab为其m_pPre。分析结束后释放顶层的Ftab。所有Ftab形成一个类似树状的结构。

    也许你会怀疑,分析结束后,所有符号表都释放了,后续阶段要使用些符号怎么办?原因在于,我们释放的是符号表本身,而不是表中的符号,每个符号都是new出来的对象,具有全局生命期。当然,一旦释放后,变量的作用域便无从知晓了。

     

    (二)前端符号表的作用

    1查找名字的定义

    例如,分析器每识别出一个变量定义时,会调用Ftab::InCurLevel ()在当前Ftab中查找该名字,找到时报告“重复定义”的错误,否则仅将该变量名加入当前Ftab(不到外层查找)。这样便支持了“不同作用域的变量重名”。

    当分析器识别出一个变量的引用时,会调用Ftab::Find ()依次由内层向外层Ftab查找该变量,这样便支持了“内层名字遮盖外层的”。

     

    2存储预分配

    刚进入一函数块时,新建Ftab的m_nSize置为0,每向Ftab中加入一变量时,变量的偏移地址设为新当前的m_nSize,再使m_nSize累加上该变量的大小。函数体内每新建一Ftab时,其m_nSize等于外层Ftab的m_nSize。对于如下函数,它的存储分配过程如下所示

    void fun()

    {                          //new tab1; tab1.m_nSize=0

           int i1;             //i1.offset= tab1.m_nSize=0, tab1.m_nSize=4

           {                   // new tab2; tab2.m_nSize=tab1.m_nSize=4

                  int i2;     //i2.offset=tab2.m_nSize=4, tab2.m_nSize=8

           }                   //delete tab2

           {                   // new tab3; tab3.m_nSize=tab1.m_nSize=4

                  int i3;     //i3.offset=tab3.m_nSize=4, tab3.m_nSize=8

           }                   //delete tab3

    }                          //delete tab1

    采用这个方法,i1算出的偏移地址为0,i2为4,i3也为4,即i3分配在原i2的空间,这是合理的,因为进入i3所在块时,i2的生命期已经结束了。如果i2或i3是数组,则节省的空间更可观。

    生成汇编码时,便用Name::offset来寻址局部变量,例如语句i3=0会生成汇编指令“mov dword ptr[ebp+8], 0”,其中8就由offset计算而来。

     

    从上面可以看出,前端符号表具有强烈的作用域特性,而后端符号表恰好相反。

     

    二 后端符号表

    后端符号表是根据目标语言(如汇编)的需要而设置的,它由语法分析器建立,供目标代码生成模块使用,它分成下面几张表:

    class Btab//后端符号表

    {

    public:

           typedef set<const Const*> Consts;

           typedef set<const Name*> Vars;

           typedef set<const wstring> Strs;

           Consts m_consts;//常量表

           Vars m_vars;//变量表

           Strs m_strs;//字符串表

    ……

    };

     

    (一)常量表

    由于Win32汇编不支持浮点型立即数,浮点数必须定义成符号常量才能使用,因此语法分析中每遇到一个浮点常数,便加入常量表(同一浮点数只加入一次),生成汇编码时,在.data段生成定义语句,如

    .data

           double0   real8              2.71828

           double1   real8              3.14159

    由于整数可以作为立即数出现在汇编指令中,故不需定义成符号常量和加入符号表。

     

    (二)变量表

    与前端符号表不同,该表中仅存放那些全局生命期的变量/数组名,其中包括:

    1全局变量

    在生成汇编时用comm指示符定义在数据段中,使其具有外部链接属性,如:

    .data

           comm _g_i: 4

     

    2静态变量

    包括局部静态变量和全局的静态变量,在生成汇编码时以普通方式定义在数据段中,不具备外部链接属性,如:

    .data

           @s_c1 dword ?

     

    3外部变量

    在生成汇编码时用extern指示其是外部链接的,如:

    .data

           extern _g_d: near32

     

    这些名字都由语法分析器在解析过程中加入符号表。

     

    (三)字符串表

    用于存放汇编指令中要用到的字符串常量,生成汇编时定义到数据段,如:

    .data

           str1  byte '%d %g %c ', 0ah, 0

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第七节 语法分析器

     

    一 支持的语法

    其中大写单词是由词法分析器返回的终结符:

     

    program:

           program trans_unit | trans_unit//程序是一组翻译单元(由连接器保证,语法分析器只处理下面的)

    trans_unit:

           decls END | END//翻译单元(通常是一个源文件)或者为终结符END,或者为若干声明加END,END是词法分析器遇输入流结束时的返回值

    decls:

           decls decl | decl

    decl:

           obj_decl | fun_decl_def//声明分为对象声明与函数声明

    obj_decl:

           var_decl | array_decl//对象声明分为变量声明与数组声明

    var_decl:

           storage basic_type ID ;//变量声明为存储类别+变量类型+变量名,语义检查保证basic_type不能是VOID

    storage:

           EXTERN | STATIC |ε//支持extern与static二种存储类别,为空时采用默认的

    basic_type:

           INT | DOUBLE | CHAR | BOOL | VOID//基本类型

    array_decl:

           storage basic_type ID dims ;//数组声明为存储类别+元素类型+数组名+维数,语义检查保证basic_type不能是VOID

    dims:

           dims dim | dim//维数不能为空(至少是一维)

    dim:

           [ INT ]//元素个数仅支持整数

    fun_decl_def:

           fun_decl | fun_def

    fun_decl:

           storage basic_type ID ( params ) ;//函数声明为存储类别+返回类型+函数名+(形参列表); ,返回类型可以是VOID

    params:

           paras |ε//形参列表可以为空

    paras:

           paras , para | para//各形参用逗号分隔

    para:

           basic_type ID//形参为类型+形参名,不支持数组形参,语义检查保证basic_type不能是VOID

    fun_def:

           storage basic_type ID ( params ) fun_body//函数定义为存储类别+返回类型+函数名+(形参列表)+函数体 ,返回类型可以是VOID

    fun_body:

           block//函数体是语句块

    block:

           { obj_decls stmts }//语句块用花括号括起,前面是若干条对象声明(只能集中声明),后面是0或多条语句

    obj_decls:

           obj_decls obj_decl | obj_decl

    stmts:

           stmts stmt | ε

    stmt:

           | select_stmt | loop_stmt | jmp_stmt | expr_stmt | io_stmt | block | ;//语句分为选择语句、循环语句、跳转语句、表达式语句、输入输出语句、块语句、空语句(单个分号)

    select_stmt:

           if_stmt | if_else_stmt//二种选择语句(以后可以加入switch)

    if_stmt:

           IF ( expr ) stmt//语义检查保证expr是布尔表达式

    if_else_stmt:

           IF ( expr ) stmt ELSE stmt//语义检查保证expr是布尔表达式

    loop_stmt:

           for_stmt | while_stmt | do_stmt//三种循环语句

    for_stmt:

           FOR ( expr ; expr ; expr ) stmt//for语句,语义检查保证第2个expr是布尔表达式

    while_stmt:

           WHILE ( expr ) stmt//while语句,语义检查保证expr是布尔表达式

    do_stmt:

           DO stmt WHILE ( expr ) ;//do语句,语义检查保证expr是布尔表达式

    jmt_stmt:

           break_stmt | ret_stmt//跳转语句包括break和return(以后可加入continue和goto)

    break_stmt:

           BREAK ;//break语句为关键字后跟分号

    ret_stmt:

           RETURN ;//return语句为关键字后跟分号

    expr_stmt:

           expr ;//表达式加分号构成表达式语句

    io_stmt:

           putch_stmt | in_stmt | out_stmt//三种输入输出语句

    putch_stmt:

           OUTCH ( expr ) ;//输出字符语句,语义检查保证expr是整型表达式,以与标准库一致

    in_stmt:

           IN ins ; | IN ;//输入语句为终结符IN后跟若干输入项,可没有输入项

    ins:

           ins in | in

    in:

           >> ID//输入项为为“>>变量名”

    out_stmt:

           OUT outs ; | OUT ;//输出语句为终结符OUT后跟若干输出项,可没有输出项

    outs:

           outs out | out

    out:

           << add_sub_expr//输出项为“<<表达式”,之所以用add_sub_expr而不是expr,是为了与C++运算符<<的优先级保持一致,否则会误接受类似“cout<<1>2;”的语句

    expr:

           assign_expr//表达式目前为赋值表达式,以后可改为优级最低的逗号表达式

    assign_expr:

           or_expr assign_opr assign_expr//语义检查保证or_expr是lvalue

    assign_opr:

           =//赋值运算符,以后可加入+=, *=等

    or_expr:

           or_expr || and_expr | and_expr//或表达式,'||'是逻辑或运算符

    and_expr:

           and_expr && eq_expr | eq_expr//与表达式

    eq_expr:

           eq_expr == rel_expr | eq_expr != rel_expr | rel_expr//等于、不等于表达式

    rel_expr:

           add_sub_expr > add_sub_expr | add_sub_expr >= add_sub_expr | add_sub_expr < add_sub_expr | add_sub_expr <= add_sub_expr//四种关系表达式,比==和!=优先级高

    add_sub_expr:

           add_sub_expr + mul_div_expr | add_sub_expr - mul_div_expr | mul_div_expr//加减表达式

    mul_div_expr:

           mul_div_expr * unary_expr | mul_div_expr / unary_expr | unary_expr//乘除表达式

    unary_expr:

           ! factor | - factor | factor//一元表达式,包括逻辑非、取负和因子

    factor:

           INTEGER | FLOAT | TRUE | FALSE | getch | call | lvalue | ( expr )//因子可以是整数、浮点数、布尔常量true/false、输入字符表达式、函数调用表达式、左值、括号表达式

    getch:

           INCH ( )//输入字符表达式为终结符INCH加一对空括号

    call:

           ID ( args ) | ID ( )//函数调用表达式为函数名+(实参列表),实参列表可以为空

    args:

           args , arg | arg//用逗号分隔各个实参

    arg:

           expr//实参是任何表达式

    lvalue:

           ID | array_elem//左值为变量名或数组元素

    array_elem:

           ID subscripts//数组元素为数组名后跟若干个下标

    subscripts:

           subscripts subscript | subscript

    subscript:

           [ expr ]//语义检查保证下标expr是整型表达式

     

    从语法上可以看出,Doctors支持:

    函数先声明后使用,这使得可以调用外部函数(包括部分库函数,只要支持该库函数的声明语句)。

    static函数。

    全局变量/数组。

    static变量/数组,可以是全局或局部的。

    extern变量/数组,可以在全局或局部作用域声明。

    bool类型、布尔常量true和false,以及bool数组。

    数组维数不限,下标是任意整型表达式,使得数组能够用于循环。

    常用的if-else, for, while, do-while, break, return语句。

    赋值运算和函数调用都是表达式,它们可以出现在允许表达式的任何地方。

    模拟C++ I/O对象的输入输入语句cin, cout,“<<”和“>>”的优先级与标准一致。

    模拟函数的getch是表达式,从控制台输入的字符可参与其它运算。以及输出字符语句putch。

    逻辑运算&&, ||, !,和关系运算==, !=, >, >=, <, <=。

    算术运算+, -, *, /,和取负运算。

    用括号更改运算的优先级次序。

     

    语法基本上是标准C++的子集,这使得在Doctors下编译通过的程序,在标准C++编译器下也能通过(当然要带上头文件)。

     

    待改进

    一条语句定义多个变量。

    变量定义时初始化。

    变量和数组边定义边使用。

    支持指针相关的运算符和表达式。

     

    二 调整文法

    按语法写LL分析程序似乎是件让人犯愁的事,因为许多产生式中都存在左递归,而且还要计算许多FIRST集合,下面让我们一一克服。

     

    (一)从产生式到BNF

    要消除左递归,首先要理解这个产生式,否则即使套上公式算出后还是糊里糊涂的。

    让我们先来看“or表达式”的产生式:

    or_expr:

      or_expr || and_expr | and_expr

    它是左递归的,经过思考我们认识到,一个or表达式至少是一个and_expr,或者后面再跟若干个“|| and_expr”。也就是说,一个or表达式是若干个and_expr相或,这样我们可以直接得出递归下降的算法:

    void or()

    {

           and();//识别第1个and_expr

           while(next_token=='||')//如果下一个记号是终结符||,就一直循环

           {

                  get_next_token();//跳过||,看下一个记号

                  and();//识别后面的and_expr

           }

    }

    void and()//与or()类似

    {

           ……

    }

     

    这个算法其实就是or_expr对应的BNF了,现在让我们带入公式验证一下:

    原式:    A->Aa | b              (1)

    结果:    A->bA'           (2)

                  A'->aA' |ε       (3)

    这里,A=or_expr,a= || and_expr,b=and_expr,代入(2)和(3)得

                  or_expr->and_expr or_expr'                (4)

                  or_expr'-> (|| and_expr or_expr') |ε    (5)//变左递归为右递归

    由此,一个or_expr前面是and_expr,后跟(5),而(5)的含义是,要么它为空,要么是若干个"||and_expr",我们假设把它写为

                  or_expr'-> 空,或若干个"||and_expr"               (5)'

    将(5)'代入(4)得

                  or_expr->and_expr (空,或若干个"||and_expr") (6)

    经过一番折腾后,我们终于消除了左递归,如果按(6)写程序,那么恰好对应上面的or()函数。

    其它左递归的产生式,如decls, paras等,都可得出类似的程序,有的更简单一些。当熟练后,就能直接根据产生式写出解析程序了。

     

    (二)消除二义性

    根据FIRST集,每遇到一个终结符(岔路口),决定调用哪个识别函数(往哪走)。事实上,我们并不需要计算所有的FIRST集,只要对产生式加以调整,合并一些公共部分,就可以解决这个问题。

    观察var_decl、array_decl、fun_decl和fun_def,它们都是以存储类别+类型+标识符开头,我们提出这些左公因子:

    decl_head: storage basic_type ID

    fun_head: decl_head ( params )

    有了decl_head和fun_head,上面几个产生式就可以改写为:

    var_decl: decl_head ;

    array_decl: decl_head dims ;

    fun_decl: fun_head ;

    fun_def: fun_head fun_body

     

    这样,识别decl时我们一口气读完decl_head,看下一个记号,如果是分号,就是变量声明,否则,如果是'[' (dims的first集成员),就当作数组声明。否则再一口气读完fun_head的右括号,如果下一记号是分号,就是函数声明,否则认为是函数的定义。

     

    类似地,二义性文法if-else可以改成:

    if_stmt: IF ( expr ) stmt

    if_else_stmt: if_stmt ELSE stmt

    读完第一个stmt后,若下一个是终结符ELSE,则是if-else语句,否则是if语句。

     

    由此可见,写文法也要像写代码一样,尽可能“重用”已有的产生式,合并冗余的产生式,才有助于编写分析程序。

     

    三 抽象语法树的设计

    抽象语法树(以下简称AST)与语法树、语法分析树似乎容易混淆。其区别在于,AST是编译程序内实在要用的,为了简化问题、节省内存,ast省去了一切不必要的成分,例如对下面程序段:

    while(true)

    {

           break;

    }

    对应的语法树可能为:

         while

        

    ( true ) { break ; }

    它含有7个叶结点,而在AST里,一对圆括号、花括号、分号都会被去掉,只留下true和break二个结点。

     

    对语法分析器而言,程序是一组变量和函数的定义,其中变量已由符号表管理,而AST则是若干函数的集合:

    class Ast

    {

    private:

           typedef vector<Fun*> Funs;

           Funs m_funs;//若干个函数体

    ……

    };

    AST的每个结点可分为3类,函数、语句、表达式,它们都从抽象基类AstNode中派生,下面分别介绍。

     

    class AstNode//抽象语法树结点,为其子类提供共同的祖先

    {

    public:

           virtual ~Node();

    ……

    };

     

    (一)函数的AST结点

    class Fun: public AstNode//函数的AST结点

    {

    private:

           const FunDecl* m_pDecl;//函数的声明

           typedef vector<const Stmt*> Stmts;

           Stmts m_stmts;//函数体内的语句序列

          int m_nMaxOffset;//函数内形参和局部变量总字节数的最大值,生成汇编指令时用于分配堆栈空间

    public:

           void Gen(FunIr* pFunIr, Btab& symTab);//为函数体内所有语句生成中间代码

           void UpdateOffset(int nOffset);//更新m_nMaxOffset

    ……

    };

     

    class FunDecl//函数声明

    {

    private:

           Name* m_pName;//函数名,其m_pType成员指示了函数的返回类型

           typedef vector<Name*> Paras;

           Paras m_paras;//形参列表,

    ……

    }

     

    语法分析器每解析一个函数的声明,便分配一FunDecl对象加入顶层符号表,遇到函数调用时到符号表中查找该函数名,这样,即使函数尚未定义,仍然可以调用(比如对于外部函数)。

    每扫描到一个函数体,便分配一Fun对象加入Ast::m_funs,它将是中间代码生成的对象。

     

    (二)语句的AST结点

    class Stmt: public AstNode//语句结点的基类

    {

    private:

           int m_nNextLabel;//该语句下一条语句的标号,生成中间代码时以获得该语句的出口

           static const Stmt NUL;//表示一个空语句或有错的语句,它们在生成中间代码时被忽略

    public:

           virtual void Gen(FunIr* pFunIr, Btab& symTab, int nFirstLabel, int nNextLabel) const;//为该条语句生成中间代码

    ……

    }

     

    各类语句都从stmt继承,并加入自己需要的成份,例如if-else语句结点如下定义:

    class IfElse: public Stmt//if-else语句的AST结点

    {

    private:

           const Expr* m_pExpr;//条件表达式

           const Stmt* m_pStmt1;//条件表达式为真时执行的语句

           const Stmt* m_pStmt2;//条件表达式为假时执行的语句

    public:

           virtual void Gen(FunIr* pFunIr, Btab& symTab, int nFirstLabel, int nNextLabel) const;//为该条语句生成中间代码

    ……

    };

     

    而break语句结点定义为:

    class Break: public Stmt//break语句的AST结点

    {

    private:

           const Stmt* m_pOut;//指向自己外层语句结点(如for, while),生成中间代码时跳往m_pOut->m_nNextLabel

    public:

           virtual void Gen(FunIr* pFunIr, Btab& symTab, int nFirstLabel, int nNextLabel) const;//为该条语句生成中间代码

    ……

    };

     

    其它从stmt继承的AST结点有

    class DoWhile: public Stmt

    class ExprStmt: public Stmt//表达式语句

    class For: public Stmt

    class In: public Stmt//cin语句

    class If: public Stmt

    class Out: public Stmt//cout语句

    class PutCh: public Stmt//putch语句

    class Return: public Stmt

    class While: public Stmt

     

    在分析过程中,函数列表Ast::m_funs的尾结点(假设为curFun)对应当前正在解析的函数,语法分析器每解析出一条语句,便将其AST结点加入列表curFun.m_stmts,列表内的语句将用来生成中间代码。

     

    (三)表达式的AST结点

    class Expr: public AstNode//表达式结点的基类

    {

    private:

           const Type* m_pType;//表达式的类型

           static const Expr ERR;//代表有错的表达式,它可作为一些解析函数的返回值

    public:

           virtual IrAssign* Gen(FunIr* pFunIr) const;//为该表达式生成中间代码

           virtual const wstring GetRep() const;//返回该常量或变量在汇编指令中的表示

    ……

    };

     

    class Const: public Expr//整型常量、浮点常量、布尔常量对应的AST结点

    {

    private:

           static const Const TRUE;//代表布尔常量true

           static const Const FALSE;//代表布尔常量false

           const int m_nNo;//指示该常量在汇编数据段中的序号(仅用于浮点常量)

    public:

           virtual const wstring GetRep() const;//返回该常量在汇编指令中的表示,对布尔常量返回0和1,对浮点常量返回其符号名,其它返回立即数

    ……

    };

     

    class Name: public Expr//变量名、数组名、函数名

    {

    public:

           enum Storage//变量或函数的存储类别

           {

                  STATIC,//静态的

                  EXTERN,//外部的

                  DEFAULT//缺省的,对全局变量和函数默认为extern,对局部变量默认为auto

           };

    private:

           const Storage m_eStorage;//存储类别

           const FunDecl* m_pDecl;//如果是函数名,则该函数的原型

          int m_nOffset;//变量在堆栈中相对位置(偏移量),生成汇编指令时用

           int m_nNo;//若是静态变量,则其编号(不同函数和语句块内可能会定义同名的static变量)

           const bool m_bGlobal;//是否为全局变量

           const int m_bPara;//是否为函数的形参

    public:

           virtual const wstring GetAddr() const;//根据m_nOffset得到局部变量或形参在栈中的地址,如[ebp-4]

           virtual const wstring GetRep() const;//根据局部变量或形参的类型,返回前面带有PTR操作符的寻址串,如dword ptr [ebp-4]

           const wstring GetAsmName() const;//返回该变量在汇编指令中的名字,其格式为:局部变量为[ebp +/- 偏移量]。全局变量和extern变量名字前加下划线,以防止与符号常量重名。静态变量为"@+名字+编号",@用于防止与全局变量、extern变量、符号常量重名,编号在源文件内唯一,以防止不同作用域的静态变量重名

    ……

    };

     

    此外还从Expr派生出表示与、或、非、关系运算的Logic结点,表示算术、赋值、函数调用、数组下标计算的Oper结点,这里就不一一列举了。

     

    待改进

    从Name派生出Var(变量名)和Func(函数名)二个子类。

     

    四 语法分析过程

    class Parser//语法分析器

    {

    private:

           Lexer& m_lex;//使用的词法分析器

           Err& m_err;//错误对象,用于管理错误信息

           Ast* m_pAst;//要输出的抽象语法树

           Btab* m_pBtab;//要输出的后端符号表

           Ftab* m_pNames;//当前作用域的符号表

           const Token* m_pNext;//向前看记号

    public:

      Parser(Lexer& lex, Err& err);//构造分析器

           bool Parse(Ast& ast, Btab& symTab, wistream& inStream);//启动一次分析过程

     

           void NextToken();//调用m_lex.GetNextToken()移入下一个记号

           void Skip();//跳往下一个能使分析继续进行下去的记号,如分号,右花括号

           bool Matched(int nTag, bool bMove=true, wstring sErr=L"");//比较记号m_pNext的类别是不是nTag,否则根据sErr产生错误信息,并根据bMove决定是否移进下一个记号

     

           void ParseDecl();//解析下一个全局的定义或声明(函数、全局变量等)

           void ParseFun(const Token* pToken, const Type* pType, Name::Storage eStorage)//处理函数pToken的声明或定义,其返回类型为pType,存储类别为eStorage

           const Stmt* ParseStmt();//解析下一条语句,返回相应语句子类的AST结点

           const Expr* ParseExpr();//解析下一个表达式,返回相应表达式子类的AST结点

    ……

    };

     

    bool Parser::Parse(Ast& ast, Btab& btab, wistream& inStream)//启动一次分析过程

    {

           m_pAst=*

           m_pBtab=&btab;

           m_lex.Reset(inStream);//预置要扫描的输入流、行号初始化为0

           m_pNames=new Ftab(NULL);//分配当前作用域(顶层)的符号表

     

           m_pNext=m_lex.GetNextToken();//移入第1个记号

           while(m_pNext->m_nTag!=Token::END)//当输入流未结束时

           {

                  ParseDecl();//解析下一个声明

           }

     

           delete m_pNames;//释放当前作用域(顶层)的符号表

           return m_err.m_nErr==0;//根据错误数目m_nErr得知源程序是否有错

    }

     

    从“编译流程”一节的Compile函数可知,构造分析器时传入扫描器,和错误对象:

    Parser parser(lex, err);

    再定义ast和后端符号表:

    Ast ast;

    Btab symTab;

    之后调用Parser::Parse()启动分析过程:

    if(!parser.Parse(ast, symTab, srcFile))

    {

      ……

    }

    从函数ParseDecl()开始,递归向下进行解析,对前述文法中的每个产生式,通常都对应有Parse函数。

    每解析一个出一个表达式,分配对应表达式的AST结点,该结点用于构造其所在语句的AST结点。

    每解析出一条语句,分配对应语句的AST结点(如for),加入当前函数的AST结点。

    每解析一个函数,分配一Fun结点,加入m_pAst->m_funs。

    当分析完成后,便建立了一棵以m_pAst为根的抽象语法树,后端要使用的符号也包含在symTab中。

     

    五 语义分析

    在Doctors中,语义检查是在语法分析同时完成的,这样做主要原因是:

    1在设计AST时,我们期望解析出抽象语法树能够提供给其它软件使用(比如一个代码格式化工具),不能输出有错误的AST。

     

    2否则在语法分析后,要遍历这颗AST,进行单独的语义检查。由于AST结构的复杂性,当查出一个错误的AST结点后,要将它从千丝万缕的枝叶中剥离出来再delete掉是不容易的。

     

    事实上,边解析边检查是自然的,比如识别了一个赋值表达式后,立刻检查左、右值类型是否匹配,不匹配时忽略这个表达式。与其分配有病AST结点再想办法释放,不如不分配这个结点。

    只要采取了适当的错误恢复机制(见下节),语义错误并不会打乱预定的分析过程。

     

    六 错误恢复

    (一)错误的应对

    从分析器角度而言,源程序中的错误可分为:

    1词法错误

    的词法分析器常常将不识别标点记号返回给语法分析器,负责任的词法分析器应尽量将非法记号修补正确,如字符串末尾缺少引号时自动补上。

     

    2语义错误

    通常,语义错误对分析过程并不构成威协,例如对赋值句i=j; 如果i与j类型不匹配,当前函数只需返回错误标志&Stmt::NUL,通知上层函数忽略这条语句,后者便可以从容识别后续的记号流。

     

    3语法错误

    对于简单的分析程序,是按照其“自我欲望”来工作的,它每读取一个记号,便与“内心期望的”相比较,一旦比较不符,便造成了“心理错位”,即使后面的记号流完全合法,也可能会报告一大堆错误,例如分析器遇到关键字if时,它会按预定的步骤匹配:

    (1) match(“if”)

    (2) match(左括号)

    (3) match(表达式)

    (4) match(右括号)

    (5) match(语句)

    假若输入串是if 3>2) stmt,它可能会拿3去匹配第2项,发现不匹配,再拿>去匹配第3项,仍然不匹配,接着拿2和右括号分别匹配第4、5项。由于缺少了一个左括号,导致分析器接而连三地报告虚假的错误,因此,语法错误对分析过程是致命的。

     

    (二)错误恢复机制

    一旦源程序中出现上述错误,错误恢复机制期望做到:

     

    1继续分析下去

    如果发现一个错误就停止分析,似乎被认为太懒惰了,为能够继续下去,Doctors中采取了以下几种方法:

    (1)假设、虚构

    例如对:

    intt i;//类型标识符写错了,假设其为整型

    例如对:

    int fun;

    void fun(){}//名字已被占用,为该函数生成一个不存在的名字(如fun2)

     

    (2)忽略、推迟

    例如对:

    cout  3  5;//二次缺少了分隔符<<,忽略之

    对:

    while(b)//假设b未定义

    {

           cout<<true;

    }

    当发现b未定义时,仍然继续向下识别后面的语句块,完成后向上层函数返回&Stmt::NUL,以表示该while语句有错误。

     

    (3)跳跃

    发生错误时跳到下一个特殊记号,在下面叙述。

     

    (4)调整文法

    从上面可知,语法错误会扰乱后续输入串的识别,而语义错误通常不会,因此在设计文法时可以放宽某些产生式,使一些语法错误转化为语义错误。例如Doctors中要求if的条件应是布尔表达式,如果我们将if的产生式写为:

    if_stmt: IF ( or_expr ) stmt

    这当然正确,但它在面对输入串if(a=3)时会误报一堆错误。反之,如果我们将产生式改为:

    if_stmt: IF ( expr ) stmt

    则该串便能正常解析,之后再用一段语义例程去检查表达式“a=3”是否为布尔类型、报错、再从容识别后面的语句。

     

    待改进

    当一个标识符未定义时,到记号表中查找一个与它最匹配的,并假设就是它,例如

    iff(3>5)

    {

        bol flag;

        flagg=tru;

    }

    采用此法,记号if, bool, flag, true都能正确被识别,从而有效解决用户录入程序时的疏误。至于查找时匹配的方法,可以统计每个字符出现概率。

     

    2防止误报

    为防产生无价值的错误信息,当遇到非法记号时,立刻从错误状态中恢复过来是很有必要的,可考虑以下几种方法:

    (1)不移入下一记号,将下一个语法元素与这个记号相比较,例如

    putch 97);//少打了一个左括号

    当匹配了putch后,它发现下一记号97与期望中的左括号不符,便忽略这次比较,将记号97与内心期望的下一语法元素(表达式)相比较,结果顺序匹配,便从错误中恢复过来了。

     

    但此法对另一个输入串却行不通:

    putch{97);//误写为左花括号

    i=(1+2)*3;

    当左花括号不匹配’(‘后,它会不断将该左花括号与表达式、右括号、分号相匹配(都不符),结果误匹配了下一句中的左括号,这显然是不期望的。最坏的情况是,如果源程序下面没有左括号了,那么分析程序可能会死锁,所以该法只能有限制地使用。

     

    (2)移入下一记号,将下一记号与当前语法元素相比较,例如

    putch(();//多打了一个左括号,缺少了97

    采用此法可以忽略多出左括号的错误(且不会造成死锁),但对缺少的表达式又无能为力了。

     

    (3)放弃当前语句,不断移入,直到看见了能使分析继续进行下去的记号,通常有

    分号’;’(语句结束符)

    右括号’)’(函数调用和表达式结束符)

    右花括号’}’(语句块结束符)

    这虽然漏报了一些错误,但误报的数量明显减少了,此法用在表达式解析中十分有效。

    遗憾的是,如果函数声明中有错,那么该函数体中每条语句都成为“不期望的”,没什么有效办法能够“跳到下个函数”。

     

    可见,上面的方法都有其局限性,源程序中的错误千变万化,很难找到包治百病的良药,有时编译器只能采取一些无奈的方法:

    (1)将某些错误宣布为“致命”的,如#include指定的头文件打不开时,中止当前文件的解析。

    (2)达到一定错误次数后停止分析,例如,一个正常程序通常不会有100处错误,否则分析器很可能已陷入错误泥潭了。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    第八节 中间代码的设计

    一 中间代码的结构

    与AST中每个函数结点相对应,中间代码(以下简称IR)首先是函数的列表:

    class Ir//中间表示

    {

    private:

           typedef vector<const FunIr*> FunIrs;

           FunIrs m_funIrs;//若干函数的list

    public:

           void Out(wostream& outStream) const;//输出所有中间代码到outStream

           void Gen(Asm& asms, const Btab& symTab) const;//对所有中间代码生成汇编指令

    ……

    };

     

    其中每个FunIr才是中间代码的list:

    class FunIr//中间代码中的函数

    {

    private:

           typedef list<const IrCode*> Codes;

           Codes m_codes;//若干条中间代码的list

           int m_nLabelNo;//函数内当前的标号数量

           int m_nTempNo;//函数内当前临时变量的数量

           AstFun* m_pAstFun;//在IR生成过程中,如果分配了临时变量,则需要更新m_pAstFun->m_nMaxOffset

    public:

           void Add(const IrCode* pIrCode);//添加一条Ir

           void AddLabel(int nLabel);//添加一条标号Ir

           void AddJmp(int nLabel);//添加一条无条件跳转Ir

           void Out(wostream& outStream) const;//输出函数内所有IR到outStream

           void Gen(Asm& asms, const Btab& symTab) const;//对函数内所有IR生成汇编指令

    ……

    };

     

    class IrCode//所有中间代码的抽象基类

    {

    public:

           virtual ~IrCode();

           virtual void Out(wostream& outStream) const=0;//以易读的方式输出该条中间代码

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const=0;//为该条中间代码生成汇编指令

    ……

    };

     

    二 中间代码具体格式

    中间代码具体有二十多种格式,但它们都隶属于赋值、标号、跳转这3种。

    (一)赋值

    class IrAssign: public IrCode//赋值中间码(抽象基类),它仅指出被赋值的变量

    {

    private:

           const Name* m_pDst;//被赋值的变量

           bool m_bReserved;//当该条IR的结果不会使用时,该IR是否必须保留(例如函数调用必须保留,即使其返回值未被使用),该成员用于IR优化

    ……

    };

     

    赋值操作又分为以下4种

    1一元赋值

    class UnaryAssign: public IrAssign//一元赋值(抽象基类),即只有一个源操作数的赋值

    {

    private:

           const Expr* const m_pSrc;//源操作数

    ……

    };

    一元赋值的具体类有:

    Mov:执行m_pDst=m_pSrc

    Neg:执行m_pDst= - m_pSrc

    以后还可加入其它一元运算对应的IR,如取地址、类型转换等。

     

    2二元赋值

    class BinaryAssign: public IrAssign//二元赋值(抽象基类),即有二个源操作数的赋值

    {

    private:

           const Expr* const m_pSrc1;//源操作数1

           const Expr* const m_pSrc2;//源操作数2

    ……

    };

    二元赋值的具体类有:

    Add:执行m_pDst=m_pSrc1+m_pSrc2

    Sub:执行m_pDst=m_pSrc1-m_pSrc2

    Mul:执行m_pDst=m_pSrc1*m_pSrc2

    Div:执行m_pDst=m_pSrc1/m_pSrc2

    以后还可加入其它运算对应的IR,如求余、位运算等。

     

    3函数调用

    class Call: public IrAssign//函数调用,它的“源操作数”是函数名和实参列表,返回值保存在IrAssign::m_pDst中

    {

    private:

           const FunDecl* const m_pDecl;//指向要调用函数的声明

           typedef vector<const Expr*> Args;

           Args m_args;//实参列表,无实参时列表为空

    public:

           virtual void Out(wostream& outStream) const;//提供了实现

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//提供了实现

           virtual int PushArg(Asm& asms, const Expr* pArg) const;//生成实参pArg的值入栈的汇编指令,子类InIr重载了该函数,将pArg的地址入栈

           virtual int PushAfterArgs(Asm& asms, const Btab& symTab) const;//在各个实参入栈后、call指令前执行的操作,子类InIr和OutIr重载了该函数,以将格式串的地址入栈

    ……

    };

    从Call中又派生出InIr(输入cin)、OutIr(输出cout)、GetCh(输入字符)、PutCh(输入字符),从Call继承的Gen函数会自动为它们生成调用对应库函数的汇编指令序列。

     

    4数组元素访问

    class ArrayAccess: public IrAssign//数组访问操作m_pArray[m_pIndex](抽象基类)

    {

    public:

           const Expr* const m_pIndex;//偏移量

           const wstring GenElemAddr(Asm& asms, const Name* pArray) const;//根据数组首地址nBase、元素偏移地址m_pIndex,及元素类型pElemType,生成将元素有效地址放入ebx的指令,并返回元素寻址字符串,如dword ptr[ebx]

    ……

    };

    从ArrayAccess又派生出ReadArray(读数组元素)和WriteArray(写数组元素),它们在生成IR时共享基类的方法。

     

    (二)标号

    class LabelIr: public IrCode//标号中间码

    {

    private:

           const int m_nLabel;//标号由一个整数指定

    public:

           virtual void Out(wostream& outStream) const;//输出”L”+m_nLabel

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//生成当前汇编文件中唯一的标号,格式为"L"+m_nLabel+函数名

    ……

    };

    当一条中间代码带有标号时,在其前面放置一条LabelIr中间码,作为各种跳转IR的目标。

    也许你意识到,每条中间码可能都会对应标号,为什么不在每个中间代码类中添加一个Label成员呢?这主要是考虑到以后的优化,在优化过程中,一些IR会被删去,如果要删除一条带有标号(假定为N)的IR,我们必须到其下一条IR,获得其标号(假定为M),再将所有跳往N的IR的跳转目标修改为M,这样即麻烦又易错。

    现在将标号从IR中抽出来后,优化时只需删除要删的IR,所有标号IR原地不动。而且带标号的IR毕竟是少数,不需要每条IR都加一个Label成员。

     

    (三)跳转

    class IrJmp: public IrCode//跳转IR(抽象基类)

    {

    private:

           int m_nLabel;///目标标号由一个整数指定

    ……

    };

    跳转IR又分为无条件跳转和条件跳转二大类:

     

    1无条件跳转

    class Jmp: public IrJmp//无条件跳转

    {

    public:

           virtual void Out(wostream& outStream) const;//输出”goto L”+m_nLabel

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//生成汇编指令jmp,它转向标号m_nLabel

    };

     

    从无条件跳转又派生出二种return中间码,它们在代码生成时共享基类的方法:

    class RetIr: public Jmp//对应无返回值的return语句,return也要做无条件跳转

    {

    public:

           virtual void Out(wostream& outStream) const;//输出“return”

           //继承基类的Gen()实现

    ……

    };

     

    class Ret: public RetIr//对应有返回值的return语句

    {

    private:

           const Expr* const m_pExpr;//返回值,可以是常量、变量或表达式

    public:

           virtual void Out(wostream& outStream) const;//输出“return”+m_pExpr

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//先生成将m_pExpr的值放入EAX或浮点栈顶的指令,再调用基类的Gen()生成无条件跳转指令

    ……

    };

     

    2条件跳转

    class CondJmp: public IrJmp//条件跳转(抽象基类)

    {

    public:

           virtual CondJmp* Reverse()=0;//条件翻转

    };

     

    从条件跳转又派生出:

    (1)布尔跳转

    class BoolJmp: public CondJmp//布尔跳转(抽象基类)

    {

    private:

           const Expr* const m_pSrc;//根据何值做布尔判断

           const wstring m_sName;//源操作数与0相比较后,执行的跳转指令

    public:

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//为该布尔跳转生成汇编指令

    ……

    };

    布尔跳转有2种具体类:

    Jz:m_pSrc为0时跳转

    Jnz:m_pSrc非0时跳转

     

    (2)关系跳转

    class RelJmp: public CondJmp//关系跳转(抽象基类)

    {

    private:

           const Expr* const m_pSrc1;//源操作数1

           const Expr* const m_pSrc2;//源操作数1

           const wstring m_sIntName;//若源操作数是整型的(整型比较),则对应的跳转指令,如JG,JGE

           const wstring m_sFloatName;//若源操作数是浮点型的(浮点型比较),则对应的跳转指令,如JP,JNP

           const int m_nTestVal;//若源操作数是浮点型的(浮点型比较),则在执行跳转指令m_sFloatName前,test指令要测试的值

    public:

           virtual void Gen(Asm& asms, const Btab& symTab, const FunDecl* pDecl) const;//为该整型或浮点型关系跳转生成汇编指令

    ……

    };

    关系跳转对应6种具体类:

    Jg:m_pSrc1>m_pSrc2时跳转

    Jge:m_pSrc1>=m_pSrc2时跳转

    Jl:m_pSrc1<m_pSrc2时跳转

    Jle:m_pSrc1<=m_pSrc2时跳转

    Je:m_pSrc1==m_pSrc2时跳转

    Jne:m_pSrc1!=m_pSrc2时跳转

     

    它们的界面都类似,具体代码就不一一列举了。

     

    从上面可以看出,AST关注的对象是表达式、语句、函数这些高层语法结构,到了中间代码层后,关注对象已经被分解为赋值、跳转、标号这些低级结构了(这当然是出于优化的需要),到了目标代码层会变为指令串这类更低级的结构。

     

    由于篇幅有限,所有内容只能介绍到这里,如果要进一步了解IR的生成,可以看编译器输出的.ir文件。

    从上面各IR类的成员和函数,也许你已联想到目标代码的生成方式,以及运行时全局、静态、局部变量和数组的存储分配,这可进一步看编译器输出的.asm文件。

     

    ----------------------------------------------------------------------------------------------------------------------

     

    附录:参考资料

    1 龙书

    2 ISO-IEC 14882

    3 ISO-IEC 9899

     

    [全文完]

     


    最新回复(0)