我的BLOG之旅--LET'S BUILD A COMPILER!(2表达式的语法分析)


                   LET'S BUILD A COMPILER!
                           一起做编译器
                                    By

                     Jack W. Crenshaw, Ph.D.

                           24 July 1988


                   Part II: EXPRESSION PARSING
                            表达式的语法分析

*****************************************************************
*                                                               *
*                        COPYRIGHT NOTICE                       *
*                                                               *
*   Copyright (C) 1988 Jack W. Crenshaw. All rights reserved.   *
*                                                               *
*****************************************************************
翻译者:问风. 4/3/2006

起步
如果你已经读过对这系列的介绍文档,那么你已经知道我们接下来会做什
么了.你也已经把cradle程序复制到你的Turbo Pascal系列并编译通过.所以你
应该准备往下做.


 这篇文章是为了我们学习如何对数学表达式进行语法分析和转换的.我们
将想看到输出是一系列可以去执行我们想要的操作的汇编语言语句为了清晰
起见,一个表达式是一个右侧表达式,如下所示:
 x = 2*y + 3/(4*z)
 在开始,我会放缓步伐.以至作为初学者的你不会因些失去信心.在早期这
里有一些很好的练习,让我们以后更轻松,对于有经验的读者来说,请容忍我这样
做.我们会很快加快步伐.

单个数字

 遵循本系列文章主题(KISS,还记得吗?)让我们开始我们可以猜想到的一
些绝对简单项目.那样,对于我来说,一个表达式就是只包含单个数字.在开始编码时,确保你已经有一个我曾给你的"cradle"(开始翻译成摇篮或发源地总觉得不合适,因于只是一个程序名,索性只不直译了)版本.我们会再次使用它完成一些实验.
在"cradle"原代码中加入以下代码
{---------------------------------------------------------------}
{ Parse and Translate a Math Expression }

procedure Expression;
begin
   EmitLn('MOVE #' + GetNum + ',D0')
end;
{---------------------------------------------------------------}

并在main主程序加入一行 "Expression;"
                             

{---------------------------------------------------------------}
begin
   Init;
   Expression;
end.
{---------------------------------------------------------------}

 现在运行程序,尝试输入一个数字.你应该可以获取一行汇编语言输出.尝试其他任意字符作为输入,你会看到语法分析程序会报告出错.

 恭喜你!你已经写出一个可运行的翻译程序了!


 好了,我准许你把其美化.但不要其全部轻松删除.以上当然不是一个编译器所做的,它只提供了简单的扫瞄真正意义上的编译器应该要以正确识别我们已定义的输入语言,并生成正确的可执行汇编译代码,以适合汇编成目标代码仅此重要一点是,它正确地识别非法语句,并能给出出错信息.谁还要求更多呢?当我们扩展我们语法分析程序时,我们最好保证这两个特征(可以识别合法与非法表达式,并对非法表达式给出出错信息)总是有效.


 在这个小程序中还有其他一些特征值得提及.首先,你可以看到我们没有把代码生成与语法分析区分...只要语法分析器知道我们想做的,它就可以直接生成代码.当然在一个真实的编译器中可能会从一个磁盘文件读入字符,并把生成的代码写入到另一个磁盘文件当中,但以上的方法在我们试验中更容易处理.

当然注意一个表达式必须有一个结果存放的地方.我已经选择68000CPU寄存器D0去存放结果.我可以有其它选择,但这更有意义些.

二元表达式

 既然我们已经入门了,那么我们继续往下看.诚然,一个表达式包含只有一个字符是不会满足我们的需要太久的,因此让我们看如何去扩展它.假设我们想处理如下形式的表达式

                         1+2
     or                  4-3
     或更一般表示, <term> +/- <term>
(这里采用巴克斯-诺尔范式,BNF)
为了做到这些,我们需要一个过程去识别一个项和把其结果保存在某个地方,并要另一个过程去识别区分一个"+"和"-"并生成适当的代码.但是.如果表达式将产生的结果保存在了D0寄存器,那么Term(项)的结果应放在哪里呢?回答:同一个地方.在我们计算下一个项的结果前我们将不得不保存第一个项的结果在某个地方

好了,基本上我们想做的是有一个过程Term与之前过程Expression所作的事情一样.所以只需要把Expression过程更名为Term并填入以下版本的过程Expression

{---------------------------------------------------------------}
{ Parse and Translate an Expression }

procedure Expression;
begin
   Term;
   EmitLn('MOVE D0,D1');
   case Look of
    '+': Add;
    '-': Subtract;
   else Expected('Addop');
   end;
end;
{--------------------------------------------------------------}

接着,只要在过程Expression上加入以下两个过程:

{--------------------------------------------------------------}
{ Recognize and Translate an Add }

procedure Add;
begin
   Match('+');
   Term;
   EmitLn('ADD D1,D0');
end;


{-------------------------------------------------------------}
{ Recognize and Translate a Subtract }

procedure Subtract;
begin
   Match('-');
   Term;
   EmitLn('SUB D1,D0');
end;
{-------------------------------------------------------------}
                             
当你已经完成以上的步骤,则过程排列的先后顺序应该为:
 o Term (The OLD Expression)
 o Add
 o Subtract
 o Expression
现在运行程序,尝试你可以想到用'+'或'-'分隔的两个数字的任何组合,每次运行你应该可以得到一个组四条汇编指令输出.现在尝试一些故意出错的表达式,语法分析器能够捕获这个错误吗?


 看一看目标代码生成.这里我们有两个观测结论.首先,首先代码生不是我们自己打算写的以下的序列效率是很低的.
                      MOVE #n,D0
                      MOVE D0,D1
如果我们手写以上的汇编代码,我们可以把数据直接传给D1寄存器


 这里有一个信息:由我们词法分析器生成的代码与我们手写的代码相比不太有效.习惯它吧!因为它将贯穿整本书.所有编译器只要有些扩充就会导致代码更为低效.计算机科学家把一生的时间都投入到代码优化问题.并且真的有方法可以提高代码输出.一些编译器在这方面做得很好,但这是以复杂性为代价的,而且注定损失会在某些方面.这永远不可能有一个编译器输出代码生成与一个一个汇编语言编程能手所编的代码相提并论在本节结束之前.我将简要地讲述我们可以做的优化,仅是为了向你表明我们确实能够不太麻烦地提高代码生成的某些方面但记住,我们这里是为了学习,而不是看我们可以让代码有多紧凑.从现在起,到整本书的文章,我们将故意忽略优化,而把注意力放在获得输出代码.

 谈到我们以上程序没能完成的:生成代码是错误代码!正如事情进展的那样,减法运算过程从D0中(保存着第二个参数)减去D1(保存着第一个参数)这是错误的,所以为了得到正确的结果我们必须把错误的符号纠正过来所以让我们用一个取反操作修正过程Subtract以至于如下所示:

{-------------------------------------------------------------}
{ Recognize and Translate a Subtract }

procedure Subtract;
begin
   Match('-');
   Term;
   EmitLn('SUB D1,D0');
   EmitLn('NEG D0');
end;
{-------------------------------------------------------------}

现在我们的代码更加没有效率了,但起码它给我们一个正确的答案.但不幸的是,由表达式给出的规则定义要求项(term)必须在表达式中出现,这种规则对于我们来说是不方便的.这是一个你不得不再次承认的事实.当我们设计除法时这又将神出鬼没地出现在我们面前.

 好,在这里我们已经有一个可以用于识别两个数字求和或作差的语法分析器初期我们只能识别单个数字.但真实的表达式可以有其它形式(如无穷大)为了娱乐,找出并运行程序输入一个'1'

 它不能工作吗?为什么会这样?我们只完成了只有识别两个项组成表达式才为合法的语法分析器我们必须重写过程Expression以适应更多情况,这样一个真正的语法分析器就已成形了.

 


一般表达式
 在真实的世界中,一个表达式可以包含一个或多个项,项与项之间由运算符分隔('+'或'-').用BNF表述如下:
          <expression> ::= <term> [<addop> <term>]*

我们可以通过在过程Expression中加入一个简单的循环以适应以上的表达式定义:
{---------------------------------------------------------------}
{ Parse and Translate an Expression }

procedure Expression;
begin
   Term;
   while Look in ['+', '-'] do begin
      EmitLn('MOVE D0,D1');
      case Look of
       '+': Add;
       '-': Subtract;
      else Expected('Addop');
      end;
   end;
end;
{--------------------------------------------------------------}

 现在我们已经在某些地方取得了进展!这个版本可处理任意个项,且它只花费我们额外的几行代码.当我们继续下去,你将发现这些都是递归下降的语法分析器的特征...它只需要少数几行代码就能扩展语言.这将使我们逐步逼近(一步步扩展成完善的编译器)成为可能.注意过程Expression的代码与BNF的定义多么匹配呀.这也是这种方法的特点.只要你能熟练掌握这种方法,你会发现你可以像你打字速度般的快把BNF变成语法分析器代码!

 好,编译新版本的语法分析器,并对它测试.和平常一样,验证这个"编译器"可以处理合法的表达式,而且验证它对非法表达式也能产生一个有意义的出错信息.简洁吧?你可以注意到,在我们的测试版本中,只要已经有代码生成,任何错误信息都会被覆盖.但记住,这仅是因为在一系列的实验中我们采用终端作为我们的"输出文件"...在最终版,两种输出将被分离,一个到输出文件,一个到屏幕.


使用栈

 在这点上,我将违反了我们不介绍任何复杂的事情直到我们真正需要它的设计原则,去指出我们将生成代码的一个问题.到现在为止,语法分析器使用D0作为其主寄存器,而D1作为一个寄存中间结果的地方.它工作地很好,因为我们只要它处理'+'和'-'运算,且任何项可以被加入只要发现.但一般不会正确,考虑如下表达式例子

               1+(2-(3+(4-5)))
如果我们把'l'放入D1,那么我们把'2'放到什么地方呢?因为一个一般的表达式有任何复杂度(长有无限长,可由无限项组成),我们将很快用光我们所有的寄存器                            
幸运地,这里有一个解决方法.像一般现在处理器,68000(处理器类型)有一个栈,
它是一个保存多个项的理想地方

所以取代把项放入D0,D1寄存器,而把其推进栈中.为了照顾不熟悉68000汇编的读者,一个压栈操作写成

               -(SP)
而一个出栈操作
         (SP)+ .

现在让我们把 Expression过程中的EmitLn改为


               EmitLn('MOVE D0,-(SP)');
并把在Add和Subtract两行

               EmitLn('ADD (SP)+,D0')

和            EmitLn('SUB (SP)+,D0'),
各自用以上代替.  现在再一次尝试语法分析器并确保我们没有破坏它(指仍能正确接受合法表达式和对非法表达式给出错误信息)

再一次,我们使得代码生成与前面相比不再有效,但正如你所见,这是必须的一步.

乘法和除法

现在让我们开始认真考虑一些真正严肃的问题.就如你所知,这里有除了"addops"其它要匹配的操作符...表达式也能有乘法和除法运算.你也知道这里隐含了一个优先级处理的过程,或与表达式关联的级别,以至一个表达式像如下所示:

                    2 + 3 * 4,
 我们知道我们应该先进行乘法运算再进行加法运算.(见我们为什么需要一个栈?)在早期的编译技术,人们用其它复杂的技术去确保遵循运算符的优先级规则它可以实现,但许多工作是不必要的...优先级规则可以很好地适应我们的自顶向下的语法分析技术.到现在为止,唯一的形式是我们认为项是由一个十进制数字组成的.更一般地,我们可以定义一个项为FACTORS(因式)的闭包.
例如

          <term> ::= <factor>  [ <mulop> <factor ]*
什么是一个因式呢?.暂时,它和一个项一样...单个数字.

注意对称:一个项有着和表达式一样的BNF形式.

 事实上,我们可以只要增加一些代码通过复制和重命名.但为了避免混淆,这里例出语法分析器的完成函数集(注意我们处理除法时操作数顺序颠倒)


{---------------------------------------------------------------}
{ Parse and Translate a Math Factor }

procedure Factor;
begin
   EmitLn('MOVE #' + GetNum + ',D0')
end;


{--------------------------------------------------------------}
{ Recognize and Translate a Multiply }

procedure Multiply;
begin
   Match('*');
   Factor;
   EmitLn('MULS (SP)+,D0');
end;


{-------------------------------------------------------------}
{ Recognize and Translate a Divide }

procedure Divide;
begin
   Match('/');
   Factor;
   EmitLn('MOVE (SP)+,D1');
   EmitLn('DIVS D1,D0');
end;


{---------------------------------------------------------------}
{ Parse and Translate a Math Term }

procedure Term;
begin
   Factor;
   while Look in ['*', '/'] do begin
      EmitLn('MOVE D0,-(SP)');
      case Look of
       '*': Multiply;
       '/': Divide;
      else Expected('Mulop');
      end;
   end;
end;

 


{--------------------------------------------------------------}
{ Recognize and Translate an Add }

procedure Add;
begin
   Match('+');
   Term;
   EmitLn('ADD (SP)+,D0');
end;


{-------------------------------------------------------------}
{ Recognize and Translate a Subtract }

procedure Subtract;
begin
   Match('-');
   Term;
   EmitLn('SUB (SP)+,D0');
   EmitLn('NEG D0');
end;


{---------------------------------------------------------------}
{ Parse and Translate an Expression }

procedure Expression;
begin
   Term;
   while Look in ['+', '-'] do begin
      EmitLn('MOVE D0,-(SP)');
      case Look of
       '+': Add;
       '-': Subtract;
      else Expected('Addop');
      end;
   end;
end;
{--------------------------------------------------------------}

 真了不起!(Hot dog!)!一个接近函数功能的语法分析器/编译器,仅需要55行的Pascal语句!输出开始看起来好像真的十分有效,但如果你没有注意到我希望你能看到不足,记住我们这里不尝试生成紧凑型的代码.


圆括弧


 我们集中于语法分析器中加入数学表达式中的圆括弧.就如你所知的,圆括弧是一种强制转换优先级的机制.所以,例如下面的例子

               2*(3+4) ,
圆括弧强制在进行乘法运算之前进行加法运算.更重要的是,圆括弧为我们提供了一供定义任何复杂表达式的机制,如下表达式:

               (1+2)/((3+4)+(5-6))
把圆括弧加入到我们的语法分析器中的重要一点是明白不管加上圆括弧的表达式有多复杂,总可以认为它是一个简单的因式(factor).这样,一种以上形式的因子可以用BNF描述如下:

          <factor> ::= (<expression>)
这样会引入递归.一个表达式可以包含一个因式,而这个因式又包含着另一个含有因式的表达式,如此至无穷.


不管有多复杂,我们照样可以通过加入几行Pascal语句来实现过程Factor:                          

{---------------------------------------------------------------}
{ Parse and Translate a Math Factor }

procedure Expression; Forward;

procedure Factor;
begin
   if Look = '(' then begin
      Match('(');
      Expression;
      Match(')');
      end
   else
      EmitLn('MOVE #' + GetNum + ',D0');
end;
{--------------------------------------------------------------}

再次可以看到我们可以很简单的扩展我们的语法分析器,并且可以看到Pascal代码与BNF语法是多么的匹配.


和往常一样,编译新的版本并确保它正确的分析合法语句,并提示非法表达式的错误信息.


一元减号


到现在,我们有一个语法分析器可以处理所有的表达式了吗?好,尝试输入以下表达式:

                         -1
Oh!它不能工作,是吗?过程Expression总是认为所有表达式都开始于一个整数,所以它不接受开始于一个减号的表达式.你也将发现+3也不能工作,甚至如下形式

                    -(3-2) .
这里有几个方法去解决这个问题.最简单(但不是最好地)方法是复制一个前置0在这种类型的表达式之前使得-3变成0-3.我们可以很容易地把它实现到我们现存版本的Expression中


{---------------------------------------------------------------}
{ Parse and Translate an Expression }

procedure Expression;
begin
   if IsAddop(Look) then
      EmitLn('CLR D0')
   else
      Term;
   while IsAddop(Look) do begin
      EmitLn('MOVE D0,-(SP)');
      case Look of
       '+': Add;
       '-': Subtract;
      else Expected('Addop');
      end;
   end;
end;
{--------------------------------------------------------------}
 
我说过改变是很简单的!这次它只花费了我们三行Pascal语句.注意新引用了一个函数IsAddop.因为测试是否为一个加减法运算符出现了两次,我选择把其封装在一个新的函数中.函数IsAddop的形式与函数IsAlpha很接近.如下所示:

{--------------------------------------------------------------}
{ Recognize an Addop }

function IsAddop(c: char): boolean;
begin
   IsAddop := c in ['+', '-'];
end;
{--------------------------------------------------------------}

好,修改代码并对程序进行重编译.你也应该在cradle中加入一个IsAddop函数.我们以后还需要它,现在尝试再次输入-1.哇!生成的代码太差了...,六行代码仅是为了装载一个常数...但起码它是正确的.记住在这里我们不尝试替换Turbo Pascal


 现在我们已经完成了我们的表达式语法分析器的结构.这个版本的程序可以正确分析和编译你想扔给它分析的任何表达式但是依然是很有限的,因为其每个项只能处理一个十进制数.但我希望到现在你已得到这一条信息:我们可以通过局部修改很好的扩展它.那么你可能就不会再吃惊,当你听到一个变量或甚至一个函数调用也只是另一种形式的因式

 
 在下一章节,我将展示给你看,你是可以多么简单地扩展我们语法分析器,而且你会发现扩展成一个适应于分析多位数和变量名是多么容易的一件事.所以你看,我们已经离一个真实有用的语法分析器不远了.

有关优化

 早在这一章节,我已经向你提示好何提高代码生成的质量.如我所说,产生一个紧凑型的代码不是我们这本书的主要目的.但你起码需要知道(在这里讨论优化)我们并没浪费时间...我们真的能够修改语法分析器让它产生更好的生码而不需要抛弃我们所做的从头开始.通常,它实现部分优化并不难...只需要简单的增加一个代码到我们的语法分析程序当中去.

这里有两个基本的方法我们可以采纳:

   (1) 在语法分析程序生成之后尝试修改代码
    这是"窥孔"优化的概念.它的主要思想是我们可以知道哪些编译器将会生成指令组合,同时我们也知道哪些组合是生成的差代码.因此我们需要做的是扫瞄产生的代码,寻找这些组合,并把它们替换为更好的代码.这是一种与宏扩展相反的模式匹配的直接运用.真实中可能会存在复杂性,就是可能会存在许多这样的查找组合可以说"窥孔"优化还是算比较简单,因为它每次只查找一小部分指令."窥孔"优化可以动态生成有效率的代码,而不用改变原来编译器的结构.但这是以编译器的速度,大小和复杂性为代价的查找这些组合的需要大量的IF语句测试,其每一个都有可能成为导致编译器出错的原因.当然它也需要耗费时间.
在实际"窥孔"优化程序的标准上看,它好像是另一个编译器.输出代码被写到磁盘,然后由优化程序读入并再次处理磁盘文件由于优化程序只通过一个小的指令集"窗口"去查找代码.(因此得名).一个好的实现方法是用一个简单的缓冲器去保存多行输出,并扫瞄每行输出在每次换行输出时.

(2)尝试在最开始生成最好的代码
 这个方法要求我们去寻找特殊的实例在我们输出之前.打比方说,我们应该能够识别一个常数0并输出一个CLR而不是一个Load,又知当加一个0,则我们不做运算(因a+0=a).例如,在开始,如果我们选择识别在项中的一个一元减号而不是在表达式中我们可以把其当然普通的常数(如-1)而不是借助于由正数生成.这些都不是太难实现...它们只需要在代码中增加额外的测试,这也是我为什么
不在我们的程序中加入它们的原因.从我的角度看来,一旦我们有一个可以工作的编译器去生成有效的代码执行,那么我们也要以回去把代码生成得更
紧密.这也是为什么在世界上存在有2.0版本.

 这里有几种优化值得提及,不用太多争辩就可以认同,它们好像可以保证生成紧凑型的代码.这好像是我"发明"的,因为我没有在任何刊物或杂志上看过,虽然可能我并不是原创.

 避免过度使用栈,而应多使用寄存器.还记得我们在实现加法和减法时,我们只用到了D0和D1这两个寄存器和栈?它可以工作,由于只汲及这两个操作,因此栈不用超过两个单元.

 好,68000CPU有8个数据寄存器.为什么不用它们去取代栈呢?关键的一点是如何在其处理时在每一点上识别,就是让语法分析器知道有多少个项在栈中.我们可以定义一个私有的"栈指针"用于记住所在栈的深度,及相应的寄存器地址.例如,过程Factor可以把数据放入当前任何一个遇到的栈顶寄存器
而不是全都装入到D0寄存器中.


 我们所要做是用局部栈管理的寄存器组去代替CPU内存中的栈.对于大多数表达式,其栈的深度不会超过8位,所以我们可以获得较好的生成代码.当然我们也不得不处理那些使得栈的深度超过8情况,但这不是问题.我们只要把寄存器组栈的溢出部分放入CPU的栈中.那么对于超过8的深度,生成的代码并不比我们现在生成的差,而对于低于8的深度,那么生成的代码显然要好很多.

 对于记录,我已经实现这个概念,仅确保在我向你提及之前它可以工作.它真的可以工作.在实践中,你不能真正全用到8个寄存器...你最起码需要一个寄存器用于满足除法运算的需要.(真的很想68000的CPU中也有像8080那样的交换指令XTHL)为了让表达式可以是函数调用,我们将也需要保留一个寄存器以满足其需要.仍然,这在代码大小方面对于许多表达式来说已有了很好的提高所以,你可以看到要得到一个好的生成代码并不是一件难事,但它确真的会增加我们翻译程序的复杂性...复杂性并不是我们现在可以完成的.出于这个原因,我强烈建议在余下的章节我们继续忽略准备效率问题,可以放心的一点是我们真的可以提高我们的代码生成质量但不用抛弃我们之前所做的一切.


 下一节,我将向你展示如何去处理变因式和函数调用.我也将向你展示处理嵌入空白符的多字符记号是多么简单的一件事.

*****************************************************************
*                                                               *
*                        COPYRIGHT NOTICE                       *
*                                                               *
*   Copyright (C) 1988 Jack W. Crenshaw. All rights reserved.   *
*                                                               *
*****************************************************************
谢谢你的阅读,有不足之处请指正!
Email: wenfengmtd@163.com
工作室:爱克斯菲索芙特(XFREESOFE)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值