前言
本文档包含了Jack Crenshaw的编译器开发教程的所有部分,包括新增的第15章。预定的读者群是那些非计算机专业,而又热爱计算机并且想知道编译器到底是如何工作的人们。在本教程中许多的编译器理论被省略,但是编译器实践方面的内容都覆盖到了。当你学习完本教程后,你应该能够设计和构造你自己的可以工作编译器。它也许不是世界上最好的编译器,也不能生成紧凑的目标代码。你的成果也许将永远不能把Borland或Microsoft赶出市场,但是它能够正常工作,而且它是属于你自己的。
Jack W. Crenshaw
LET'S BUILD A COMPILER!
By
Jack W. Crenshaw, Ph.D.
第一部分:入门篇
介绍
本系列文章是开发程序设计语言的分析器和编译器的一部教程,包括理论和实践两方面内容。在本教程中,我们将会涉及到编译器结构,设计一门新的程序设计语言以及建造一个可实际运行的编译器的每一方面内容。
虽然我不是一个经科班教育出身的计算机科学家(我的博士学位是在另一个不同的领域:物理),我对编译器感兴趣已经有好多年了。我购买了这个领域几乎所有的书籍,并且试图消化理解书中内容的精髓。我并不介意告诉你我学习的进展其实是缓慢的。编译器教科书是为主修计算机科学的学生们所写的,对我们这些非计算机专业的人来说显得深涩难懂。但是几年后我开始逐渐领会那些理论的内涵。真正使情况发生变化是在我开始在自己的计算机上做实验的时候。现在我想和你分享我已经领悟到的东西。但是在你学完本教程之后,你仍旧不是一个计算机专家,也不能仅靠读本教程就搞懂所有的深奥的编译器理论。我打算完全略去那些偏理论化的内容。你将学习的全都是在建造一个编译器时所要了解的实用的内容。
本教程采取边学边做的方式。在学习探索的过程中我将在一台计算机上做演示。希望你能跟着我,重复我做的实验并完成一些你自己的东西。我用的是Turbo Pascal 4.0。我将周期性的插入一些用TP所写的例子。这些例子都是可以拿来直接编译运行的代码,希望你能copy到你的计算机上并运行它们。假如你没有Turbo Pascal,你的学习就会受到很大限制,因此强烈建议你取得一份TP的拷贝。毕竟,TP是一款出色的产品,还有很多其他好用的功能!
有一些编译器理论方面的文章会向你展示一些例子,或者向你展示一个完整的作品(比如Small-C)你可以拷贝并且使用它们,但你对其内部工作原理却知之不多。我希望比上面提到的这种情况更近一步,以使你在脱离书本后能有一些自己的感受,不只是把我的作品再重写一遍,而是能够在其之上更上一层楼。
诚然,这是件野心勃勃的工作,不是一两页纸就可以讲清楚的。我希望写一系列的文章来完成它。每篇文章将单独讲述编译器理论某一方面的内容,并保持相对独立。假如你时间有限,且只对某一方面的内容感兴趣,你只需看那一篇文章即可。每篇文章完成后才会上传,所以你不得不等到最后一篇文章出炉后才能最终完成你的学习。请保持耐性。
一般的编译原理教科书涵盖了许多我们不准备涉及的基础理论。典型的顺序是:
o 对编译器的入门介绍。
o 一章或两章的篇幅介绍BNF范式(Backus-Naur Form)表示的语法产生式。
o 一章或两章介绍词法扫描,重点介绍确定性和非确定性有穷自动机。
o 几章的篇幅介绍语法分析理论,从自顶向下(top-down)的递归下降方法开始,以LALR分析器结束。
o 用一章介绍中间语言,重点在P-code和逆波兰表达式。
o 用许多的章节介绍处理子程序,参数传递,类型声明等的不同方法。
o 一章的内容介绍目标代码生成,通常是基于一些指令集很简单的虚拟机。大多数读者(事实上是绝大多数的选修此课的大学生)从来没有读到过这里
o 最后一章或两章介绍代码优化。这部分也经常是没人读的。
在本系列教程中我将走一条完全不同的途径。首先,我不会过多地在不同的实现方法间徘徊。我将会给出一种实际可行的方法。假如你想研究那些不同的方法,那很好……我支持你这么做……但是我将坚持讲解我的方法。我也会省略大多数令人昏昏欲睡的理论。不要误解我:我并不是小看理论,在处理某些棘手问题时,编译理论是极其重要的。但是我相信应该把最重要的东西放在最前面。这里我们将讲解95%的不需要很多理论指导的编译技术。
我也将只讨论一种语法分析方法:自顶向下的递归下降分析,这是唯一能够手工打造一个编译器的技术。只有当你拥有像YACC这样的自动构造工具并且不在乎最终的作品会耗费多少内存空间的时候,其他的方法才是有用的。
我也借鉴了Ron Cain的成果,Ron Cain是Small C编译器的作者。尽管绝大多数其他的编译器作者习惯于在生成目标代码前使用一种中间语言,比如P-code,并把编译器划分成两部分(前段与后段,前端生成P-code,后端处理P-code以产生可执行目标代码),Ron告诉我们生成汇编代码形式的目标代码是一种更直接的方式。(当然这种方式的也有很多缺点,如平台移植性不好,不利于代码优化等等――译者注)这些目标代码不是最紧凑的代码……代码的优化是一件复杂得多的工作。但是它仍能运行,并且运行效果良好。我不想让你觉得我们在编译器后端的工作是没有价值的,所以我打算后面向你介绍一些编译器优化方面的知识。
最后,我将使用一些自己总结出来的技巧,在理解编译器的工作流程时我发现它们非常有帮助,而不需要我费太多周折。其中主要的一个窍门是在早期的设计中使用单字符的token。我认为如果能让分析器识别并处理字符序列I-T-L,我就能够让它对IF-THEN-ELSE也完成同样的操作。这是能够办到的。在第二课中,我将向你展示将单字符分析器扩展成能处理任意长度的token是多么的容易。另一个技巧是完全忽略文件I/O,我认为如果我能从键盘读取输入并将输出送到屏幕上,也就能够对磁盘文件进行同样的读取或输出。经验证明假如一个编译器能以上述方式正确工作,将I/O重定向到磁盘文件就是件很简单的事情。最后一个技巧是不做错误更正或修复。我们的程序只识别错误,并且不会因为发现错误就让程序崩溃,只是简单地在发现第一个错误地地方停下来......就像老版本的Turbo做的那样。在后面你还会看到其他一些技巧。它们中的大部分在任何一本编译器教科书中都不曾提到过,但确实很有效。
关于编程风格和效率。就像你看到的,我写程序倾向于将其划分成若干非常小而易于理解的模块。我们要讨论学习的procedure没有一个其长度是超过15-20行的。我是软件开发中的KISS(Keep It Simple,Sidney)原则的忠实支持者。当一件事情能够用简单的方法完成时,我决不用技巧性过强或复杂的方法完成它。缺乏效率?也许吧,但是你会喜欢这样做的结果。Brian Kernighan曾经说过,首先让程序运行起来,然后才是让它运行得更快。如果下步你想让你的作品代码更紧凑,没问题很容易,因为这些代码的可读性很好。尽管如此,我还是强烈建议你,等到程序能完全按照你的要求运行时才这样做。
我还有个习惯是直到我发现确实需要某模块时才开始编写它。试图预见每个可能遇到的问题会让你发疯的,并且通常你会发现自己原先的猜测是错误的。如今我们已经有了全屏的编辑器和很快的编译器,当我发现需要一个功能更强的模块时,我会毫不犹豫地修改原来的版本。那时我也只会写一个仅满足我目前需求的版本。
最后要注意的一点:我们要坚持的原则之一是不在P-code或虚拟机上面兜圈子,而是使用汇编形式的目标代码。也许你不喜欢我所用的汇编代码……它是68000CPU的汇编,那正是在我的系统上运行的汇编语言。我想你会发现将它们转换成任何其他CPU上的汇编指令,比如x86汇编,是件轻而易举的事,至少我没发现有什么问题。实际上我希望有对x86汇编比我更在行的人能提供与文中代码等价的x86汇编代码。
发源地
每个程序都需要一些共通的或例行的部分……比如I/O例程,错误信息例程等等。这里我们开发的程序也不例外。我尽量将这些原材料的比例降到最少,以使我们将注意力集中在那些真正重要的问题上而不致于在森林中迷失。下面给出的代码展示了完成编译中的某件工作所需的最少的东西。它由一些I/O例程,一个错误处理例程和一个主程序骨架组成。我把它称作我们的发源地。当我们开发其他子程序时,我们将把它们添加到“发源地”中,并且增加调用语句。请将这个程序拷贝下来,因为我们后面将不止一次用到它。
有许多不同的方法进行词法扫描。在Unix系统中,编译器的作者倾向于使用getc()函数和ungetc()函数(ungetc()函数的作用是将从输入流中读取的字符再压回输入流中――译者注)。这里我也是使用的此方法,我使用了一个单独的全局变量向前预读一个字符。初始化过程中的一部分代码(这是目前为止唯一的一部分)通过从输入流中读取第一个字符来启动整个编译过程。在Turbo Pascal 4.0下不需要任何特殊的技巧……后面每一次对GetChar的调用都会从输入流中读取下一字符。
{--------------------------------------------------------------}
program Cradle;
{--------------------------------------------------------------}
{常量声明}
const TAB = ^I;
{--------------------------------------------------------------}
{变量声明}
var Look: char; {预读字符}
{--------------------------------------------------------------}
{从输入流中读取字符}
procedure GetChar;
begin
Read(Look);
end;
{--------------------------------------------------------------}
{报告错误}
procedure Error(s: string);
begin
WriteLn;
WriteLn(^G, 'Error: ', s, '.');
end;
{--------------------------------------------------------------}
{报告错误并中止运行}
procedure Abort(s: string);
begin
Error(s);
Halt;
end;
{--------------------------------------------------------------}
{报告下一步期待出现的字符}
procedure Expected(s: string);
begin
Abort(s + ' Expected');
end;
{--------------------------------------------------------------}
{匹配特定输入字符}
procedure Match(x: char);
begin
if Look = x then GetChar
else Expected('''' + x + '''');
end;
{--------------------------------------------------------------}
{识别字母字符}
function IsAlpha(c: char): boolean;
begin
IsAlpha := upcase(c) in ['A'..'Z'];
end;
{--------------------------------------------------------------}
{识别数字字符}
function IsDigit(c: char): boolean;
begin
IsDigit := c in ['0'..'9'];
end;
{--------------------------------------------------------------}
{取得标识符}
function GetName: char;
begin
if not IsAlpha(Look) then Expected('Name');
GetName := UpCase(Look);
GetChar;
end;
{--------------------------------------------------------------}
{取得数字}
function GetNum: char;
begin
if not IsDigit(Look) then Expected('Integer');
GetNum := Look;
GetChar;
end;
{--------------------------------------------------------------}
{制表符后输出字符串}
procedure Emit(s: string);
begin
Write(TAB, s);
end;
{--------------------------------------------------------------}
{ 制表符后输出字符串并换行}
procedure EmitLn(s: string);
begin
Emit(s);
WriteLn;
end;
{--------------------------------------------------------------}
{初始化}
procedure Init;
begin
GetChar;
end;
{--------------------------------------------------------------}
{主程序}
begin
Init;
end.
{--------------------------------------------------------------}
以上就是为入门而写地代码。将它们拷贝TP到中并编译。确保能够编译成功并正确运行。然后继续学习第一课,我们将在其中讲解如何进行表达式分析。
(未完待续)