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

更新版本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) 2009All Rights Reserved

 

简介:

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

 

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

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

 

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

 

目录

第一节 初衷和目标

       排错工具

       多线程

       Unicode

 

第二节 开发方法

       为什么不用YaccLex

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

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

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

 

Unicode

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

 

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

 

第二节 开发方法

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

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

 

为什么不用YaccLex

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

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

 

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

 

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

 

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

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

 

但为什么不用它们呢?

 

(一)Yacc的缺点

1

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

 

2编程方法和多线程

Yacc/Lex诞生于上世纪70年代,由于历史的局限性,Yacc/Lex程序中必须大量使用全局变量,比如在Lex中,获取匹配的文本要用yytext,传递记号给Yaccyylval,读写串用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()一定是多态的,比如同样是语句,iffor代码生成方式不同,就会对应不同的Gen函数。

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

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

 

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

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

 

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

 

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

 

第三节 整体结构和流程

 

整体结构

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

aegis:内存泄漏检查

assem:汇编代码生成

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

driver:驱动程序,包括命令行类,CompileAssemble 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下的汇编代码,之后AssembleLink函数分别调用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,输出astsymTab,以及错误信息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可知其类别,必要时转换回原来的IntIdPunc等类型。

每个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();

       }

……

};

数组类型ArrayType派生,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_pPreNULL),每当遇到函数或全局变量声明时,将其名字加入当前(即顶层)的Ftab。在函数体内,每进入一个语句块(新的作用域),便分配一新Ftab,置其m_pPre为当前Ftab。当离开这个语句块时,释放该Ftab,并置当前Ftab为其m_pPre。分析结束后释放顶层的Ftab。所有Ftab形成一个类似树状的结构。

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

 

(二)前端符号表的作用

1查找名字的定义

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

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

 

2存储预分配

刚进入一函数块时,新建Ftabm_nSize置为0,每向Ftab中加入一变量时,变量的偏移地址设为新当前的m_nSize,再使m_nSize累加上该变量的大小。函数体内每新建一Ftab时,其m_nSize等于外层Ftabm_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算出的偏移地址为0i24i3也为4,即i3分配在原i2的空间,这是合理的,因为进入i3所在块时,i2的生命期已经结束了。如果i2i3是数组,则节省的空间更可观。

生成汇编码时,便用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,或者为若干声明加ENDEND是词法分析器遇输入流结束时的返回值

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 |ε//支持externstatic二种存储类别,为空时采用默认的

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语句,语义检查保证第2expr是布尔表达式

while_stmt:

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

do_stmt:

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

jmt_stmt:

       break_stmt | ret_stmt//跳转语句包括breakreturn(以后可加入continuegoto)

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_exprlvalue

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类型、布尔常量truefalse,以及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();//识别第1and_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_expra= || and_exprb=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_declarray_declfun_declfun_def,它们都是以存储类别+类型+标识符开头,我们提出这些左公因子:

decl_head: storage basic_type ID

fun_head: decl_head ( params )

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

var_decl: decl_head ;

array_decl: decl_head dims ;

fun_decl: fun_head ;

fun_def: fun_head fun_body

 

这样,识别decl时我们一口气读完decl_head,看下一个记号,如果是分号,就是变量声明,否则,如果是'[' (dimsfirst集成员),就当作数组声明。否则再一口气读完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里,一对圆括号、花括号、分号都会被去掉,只留下truebreak二个结点。

 

对语法分析器而言,程序是一组变量和函数的定义,其中变量已由符号表管理,而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;//返回该常量在汇编指令中的表示,对布尔常量返回01,对浮点常量返回其符号名,其它返回立即数

……

};

 

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=&ast;

       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; 如果ij类型不匹配,当前函数只需返回错误标志&Stmt::NUL,通知上层函数忽略这条语句,后者便可以从容识别后续的记号流。

 

3语法错误

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

1 match(“if”)

2 match(左括号)

3 match(表达式)

4 match(右括号)

5 match(语句)

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

 

(二)错误恢复机制

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

 

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;//输出函数内所有IRoutStream

       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指令前执行的操作,子类InIrOutIr重载了该函数,以将格式串的地址入栈

……

};

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),再将所有跳往NIR的跳转目标修改为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种具体类:

Jzm_pSrc0时跳转

Jnzm_pSrc0时跳转

 

2)关系跳转

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

{

private:

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

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

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

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

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

public:

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

……

};

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

Jgm_pSrc1>m_pSrc2时跳转

Jgem_pSrc1>=m_pSrc2时跳转

Jlm_pSrc1<m_pSrc2时跳转

Jlem_pSrc1<=m_pSrc2时跳转

Jem_pSrc1==m_pSrc2时跳转

Jnem_pSrc1!=m_pSrc2时跳转

 

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

 

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

 

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

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

 

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

 

附录:参考资料

1 龙书

2 ISO-IEC 14882

3 ISO-IEC 9899

 

[全文完]

  • 0
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 33
    评论
以下是一个简单的医院信息管理系统的C++代码示例,包括了添加、删除和查询功能: ```c++ #include <iostream> #include <string> #include <vector> using namespace std; // 定义医生结构体 struct Doctor { int id; string name; string department; }; // 定义病人结构体 struct Patient { int id; string name; string gender; int age; string disease; vector<int> doctorIds; // 该病人的医生id列表 }; // 医院信息管理系统类 class HospitalManager { public: // 添加医生 void addDoctor(string name, string department) { Doctor doctor = { ++doctorId, name, department }; doctors.push_back(doctor); cout << "添加医生成功,id为:" << doctor.id << endl; } // 删除医生 void deleteDoctor(int id) { for (vector<Doctor>::iterator it = doctors.begin(); it != doctors.end(); it++) { if (it->id == id) { doctors.erase(it); cout << "删除医生成功,id为:" << id << endl; return; } } cout << "没有找到该医生,id为:" << id << endl; } // 添加病人 void addPatient(string name, string gender, int age, string disease, vector<int> doctorIds) { Patient patient = { ++patientId, name, gender, age, disease, doctorIds }; patients.push_back(patient); cout << "添加病人成功,id为:" << patient.id << endl; } // 删除病人 void deletePatient(int id) { for (vector<Patient>::iterator it = patients.begin(); it != patients.end(); it++) { if (it->id == id) { patients.erase(it); cout << "删除病人成功,id为:" << id << endl; return; } } cout << "没有找到该病人,id为:" << id << endl; } // 查询医生信息 void queryDoctor(int id) { for (vector<Doctor>::iterator it = doctors.begin(); it != doctors.end(); it++) { if (it->id == id) { cout << "医生id:" << it->id << ",姓名:" << it->name << ",科室:" << it->department << endl; return; } } cout << "没有找到该医生,id为:" << id << endl; } // 查询病人信息 void queryPatient(int id) { for (vector<Patient>::iterator it = patients.begin(); it != patients.end(); it++) { if (it->id == id) { cout << "病人id:" << it->id << ",姓名:" << it->name << ",性别:" << it->gender << ",年龄:" << it->age << ",疾病:" << it->disease << ",医生id列表:"; for (vector<int>::iterator it2 = it->doctorIds.begin(); it2 != it->doctorIds.end(); it2++) { cout << *it2 << " "; } cout << endl; return; } } cout << "没有找到该病人,id为:" << id << endl; } private: int doctorId = 0; // 医生id自增长 int patientId = 0; // 病人id自增长 vector<Doctor> doctors; // 医生列表 vector<Patient> patients; // 病人列表 }; // 测试 int main() { HospitalManager manager; // 添加医生 manager.addDoctor("张三", "内科"); manager.addDoctor("李四", "外科"); manager.addDoctor("王五", "儿科"); // 查询医生信息 manager.queryDoctor(1); manager.queryDoctor(4); // 删除医生 manager.deleteDoctor(2); manager.deleteDoctor(5); // 添加病人 vector<int> doctorIds = { 1, 3 }; manager.addPatient("小明", "男", 10, "感冒", doctorIds); manager.addPatient("小红", "女", 20, "发烧", doctorIds); // 查询病人信息 manager.queryPatient(1); manager.queryPatient(4); // 删除病人 manager.deletePatient(2); manager.deletePatient(5); return 0; } ``` 以上代码只是一个简单的示例,实际的医院信息管理系统需要更完善的功能和更复杂的数据结构来支持。
评论 33
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值