为什么要学习编译


        来北航半年了,这半年一直想做一件事,就是跟着本科生完成编译技术的大作业——一个小型编译器的实现。虽然还没做完,但相信会有一个完满的结果。假期中完成了基本的功能,开学后补全了自己想要实现的其他功能。很棒!代码在GitHub上:PL/0编译器

        为什么要学习编译原理呢?这是一门偏重实际应用的课程,好像如果将来不去做这个领域的话,没有必要非要学懂它。

        自己最初的想法是,本科时忙着考研,加上老师讲得不用心,所以上编译课时基本没怎么听。来北航之后,听说北航这门课教的很棒,大作业是完成一个PL/0语言的编译器,也是让他们本科生都感觉很有挑战的一个作业。所以就想跟着本科生把大作业做下来,算是给自己补上以前的一个空缺。

        这学期去听了2/3的课,在给定了文法之后,做完了词法分析、语法分析、符号表生成和出错处理。接下来该做中间代码生成、优化和目标代码生成了。现在在四元式这里还想不清楚,有些头疼。但即使只做了这么多,也通过编译原理的学习,长了很多知识。所以,现在来看,为什么要学编译原理呢?我想大概有以下几点原因吧:

        现在已经写完了编译器,有了完整的编译的流程。只是对中间代码的优化部分没有做,因为中间代码的优化有很多个不同角度的优化的算法,而且也是相对独立的,加上优化最好,但不加优化也可以正常执行。毕竟硕士期间的研究方向不是编译,所以就没有单独增加优化的处理。只是在中间代码生成的过程中,做了一些直观的优化。主要是立即数的运算优化、节约使用临时变量,以及条件语句的优化。而另一方面,增加了一些给定文法中没有的,但又很常用的文法规则,譬如文法只指定了for i := M to/downto N的循环语句,但这样太简单了,就自己增加了while(condition)的循环类型,还增加了循环语句中的break与continue;文法中的条件只是简单条件,譬如if(a > b),后来增加复合的条件语句,使不同的简单条件可以做与或运算(忘记加非运算了=,= 囧!非运算好像还有些难度,但仔细想一想应该也可以做出来)。这样子,用扩充了的文法再去写程序,就容易多了。

        结合之前的感悟,现在总结一下,为什么要学习编译原理?或者说,通过学习编译原理与实现一个简单的面向过程语言的编译器,到底学到了什么?


一、 对代码的另一种理解。

        刚开始学计算机的时候,看代码,就像看一本天书,上面都是字,但却没有什么意义。

        后来知道了怎样编程,再看代码,就变成了一条条有生命的语句。每条不同的语句都有不同的功能,组合起来,可以干一些很奇妙的事情。编译->链接,从源代码,到汇编码,到机器码,我们知道虽然形态不一样,但做的事情是一样的,仿佛是一个生命体的不同表现形式。

        再后来,学了编译之后再看代码,知道了其实那些语句并没有多么神奇。无论是谁写的程序,都一视同仁地,被当做字符串去处理。分词、构建语法推导、进行语义分析,再转化成中间代码、目标代码。每一步都要精心设计,才能保证生成的目标代码就是源代码想要执行的指令。

        这种很神奇的感觉,说不上有什么用,但好像有点破除迷信的意味。


二、 对字符串/非结构化数据的系统的处理方式

        以前写过一个小的计算器,当时是模拟了Matlab的操作界面,输入一个表达式,可以给出表达式的值。或者输入一个赋值语句,也可以临时把这些变量存储起来,用字母进行计算。

        最开始这个计算器只能支持带括号的四则运算,通过一个数据栈顺序存储表达式中的值,一个符号栈来存储运算符号,并判断运算符号的优先级,进行计算。

        后来,想算一些乘方,就又把乘方的运算符号加了进去。加的时候比较麻烦,因为运算符的优先级不好定义。之前是加减的优先级最低,乘除最高,但现在多了一个乘方,优先级比乘除还要高。在修改优先级判断的语句那里,琢磨了好久,才把乘方的符号加了进去。

        再后来,想算对数。这就比较麻烦了。因为之前的加减乘除乃至乘方,都是一个运算符,左右两边各一个操作数。现在有了对数之后,log(x,y)这种形式破坏了之前规则的表达式的形式。操作符log变到了两个操作数的前面,后面还有一个右括号。而且,操作数也可以是一个表达式!譬如 log(x+y, x^2-y),都应该是合法的。这下要改动的地方就多了,本来在算符后面跟着的一定是一个操作数,现在还要判断后面是不是log!判断出log之后,还要计算两个操作数的值。真是麻烦得很。不过也还好,毕竟程序没有很大,只有几百行而已。所以就又加进了log这个特殊符号(其实只加了ln的符号,即计算以自然对数e为底的对数)。再后来,又加入了平方根、三角函数、反三角函数。勉强还可以支撑。

        最后,说啥也不想改这个程序了。


        学了编译后,才知道语法推导这个神器的存在。只要知道了字符串的生成规则,就可以根据这组规则去解析字符串。在解析的过程中,就可以生成各种想要的结果啦,包括语法树、符号表之类的。还可以进行语义分析,检查这些字符串是否表义正确。

        之前用栈去做表达式的分析,只可以说是一种工具,而且是局限性比较强的工具。当真正用语法推导去分析字符串的时候,才找到了本质。这才明白,只要可以用一定的规则把字符串的生成过程描述出来,就可以用这种最本质的方法去分析。这是一项非常强的技能。

        而且,葫芦娃之前在看的一个防SQL注入的恶意代码检测工具,就是将编译和机器学习结合起来的想法,用语法推导去分析SQL语句,然后观察推导过程中的一些特征,再根据学习好的参数去判断这条SQL语句是不是SQL注入。感觉这个想法非常棒!


三、 对另一种开发模式的探索

        以前写程序,基本上是想好思路之后,就可以写一个大致的框架出来,基本的逻辑结构都是有的。再多就是一些边界检查、特殊情况处理等边边角角的东西。

        后来写的程序比较大了,只在脑海里hold不住设计思路了,开始写需求、画数据流图、软件层次划分,然后一步一步地根据设计好的步骤去实现。算是一种瀑布模型的开发模式。

        也写过一些大型的B/S程序,先进行参与者识别、用例划分,画出每个用例的活动图;再根据用例识别出类,画出类图,根据活动图设计时序图,完善类的方法。进行数据库的E-R图制作和表结构设计。算是基于UML的面向对象的开发模式。

        但编译器的制作过程,和以上几种都不一样,可能属于一种增量开发的模式吧。首先进行词法分析,同时对词法分析得到的token进行语法分析(也可以词法分析完成后再进行语法分析,把词法分析的分词结果存储起来)。语法分析是最核心的框架。在刚建立这个框架的时候,如果被编译的源码符合语法结构,是没什么输出的,程序只会默默地跑一遍就退出了。接下来就是增量开发的时候——在这个框架上开始加东西:语法分析的错误处理,要求识别错误类型、定位错误位置、对错误进行局部化处理(即跳过这段错误,继续编译之后的语句);符号表生成:对每个符号的声明与引用,都要对符号表进行插入或者更新。然后是语义的正确性检查:在符号表的基础上,就可以获得很多上下文信息,此时就可以检查被编译的源代码是否符合语义规范。譬如,变量是否在声明前被引用?调用函数时实参与形参的类型是否匹配?赋值语句会不会有类型转换?等等等等....

        这些东西,都是在语法分析的框架上加起来的。所以,当刚刚实现一个语法分析器的时候,代码是很规整的,一步一步进行语法推导。但加入错误处理后,就多了1/3的代码,进行错误提示和局部化处理。很多地方会加循环结构,显得程序比较乱。当加入符号表处理的时候,对符号表的查找、插入、修改等等,都很繁琐,在变量声明、引用、过程/函数调用的时候,都有不同的处理方式。最后再加上语义规范检查与语义错误提示,就更复杂了,已经几乎看不到语法分析的框架了。这时如果没有设计文档,直接去看源代码,是会疯掉的。每个语法分析的函数处理的是一个语法结构,但处理的过程可能是这样的:获得下一个词(token)、语法单位识别、类型推导、符号表查找、符号表插入/更新、语义规范检查。然后继续获取下一个词、语法单位识别……其中每一步都还有出错处理。所以,直接看编译器的代码,是极不现实的。如果没有了文档,基本上代码就废了,完全不可以被二次修改。

        这种令人胆战心惊的增量开发,也是编程过程中的一次经历吧。每次加代码都加得小心翼翼,文档一定要按时跟上。但有时看着自己一点点累积上去的代码,心中也会多一些成就感。

        但在最后,实现了所有功能以后,还是费了几天的功夫,把词法分析、语法分析、符号表生成与语义分析、中间代码生成、目标代码生成分开了,每次分析单独处理,完成后再进行下一次分析。不然这代码就真的没法再扩展了。

四、 对程序优化的理解

        程序的优化,有一条原则就是使机器指令尽可能地少(另一条是使cache命中尽可能地多)。而机器指令到底长什么样子?同样的语义,什么样的语句编译出来的机器指令更为精简?这就是编译器的事情了。当写过一个编译器后,就会明白,为什么我们更喜欢用++i而不用i++,以及诸如此类的一系列编程高手喜欢向菜鸟传达的宝贵经验。

        虽然我没有写专门的中间代码优化,但最近刚好看到了C++的优化措施:命名返回值优化NRVO。以前也看过这样类似的优化,但也只能看看,记住,并不是很理解为什么。譬如NRVO,为什么需要优化?现在从编译的角度来看,就明白多了。是因为每个函数在运行时都是有自己的局部数据区的,在函数作用域中定义的数据都在这个局部数据区中,只有这个函数自己可以访问。如果要返回某个变量或对象,要把返回值复制到一个和调用者商量好的,特定的区域中,或者是约定好的寄存器里,这样才能使调用者访问得到。而这样的一个复制操作,如果不了解程序运行栈,是不会真正理解的。又譬如,为什么C语言函数中的参数要从后向前进栈?是因为有不定参数的函数,需要从第一个参数中推导出参数的数量。这样,以第一个参数在栈中的偏移量为基地址,偏移量减小的方向是参数,偏移量增大的方向是函数的局部数据区。这样就可以在编译时确定局部数据相对基地址的偏移量。

        所以,把编译学明白了,程序中很多可以优化的地方,也许一眼就可以看出来。没学过编译去谈程序的优化,真是一件很痛苦的事情。虽然多了解运行栈、cache机制会很有帮助,但中间编译的这个过程是缺的,总会觉得根基不扎实。

        PS:现在写完了编译器。觉得优化的时候最费劲的是复合条件表达式的优化。最开始文法只规定需要处理简单条件,这时的跳转语句是很好写的。但如果要支持复合条件,首先就要处理最通用的复合条件,即(a>b && c>d || e>f && g>h )的形式,想明白这样的语义动作之后,便可以生成正确的中间代码。可如果利用这套通用的规则去分析一个简单的条件,譬如(a >b),那么就会产生很多不必要的跳转语句。所以这里要做很多优化,才能去掉所有不必要的语句,减少汇编指令的数量。

        虽然做起来很费劲,但总算有了一个基本的方法:即,先写出通用的语义动作,再根据具体情况,分析可能的优化措施,最终达到理想的效果。

        但还是要感慨一下,自己做了的这些优化(立即数的运算优化、节约使用临时变量,以及条件语句的优化)里面,条件语句真特么难写!难点在于,复合条件是有层次结构的,即:复合条件->布尔表达式->布尔项->布尔因子。但在没有优化的时候,一个很简单的条件语句,就会凭白生出那么多跳转语句,而且在中间代码中根本看不出这样的层次结构来。如果不仔细对照规则,一条一条看的话,根本不知道哪个跳转语句是哪一步产生的。非常非常非常的头大!而优化完后,偶尔一个小BUG就会导致语义错误,在不该跳转的地方跳了,查起BUG来又很费劲很费劲。这个阶段一定要一气呵成,如果断断续续地写的话,每次开始写,都要重整一遍逻辑,这样下去怕是永远都写不完了。


五、对运行栈的理解

        直到做完中间代码生成,一直做的都是编译的前端,都是独立于处理器的体系结构的。直到开始做后端(目标代码生成)时,才要真正考虑运行栈的结构。

        第一感觉就是,特别痛苦。

        要明确运行过程中所有数据的存储位置。包括:局部变量(最外层的局部变量即为全局变量)、临时变量(中间代码生成过程中产生)、过程和函数的参数、函数的返回值、外层变量的作用域的基地址。不同的存储位置就意味着不同的取址方式。

        同时,还要时刻谨记局部作用域的基指针和栈顶指针的值,什么时候该调整,什么时候要保存/恢复。

        其中,最不好理解的就是外层变量的作用域的基地址,书上叫做display区。之前还以为在符号表中就可以查到具体引用了哪个外层变量,不需要在运行栈中设定。但真正开始做时才深刻地意识到,符号表只是编译过程中用到的辅助表,在运行时是不能引用的(多么蠢的一个常识,但直到最后才意识到)!

        即使到最终生成了目标代码后,也遇到过一个BUG,就是有的时候函数执行完返不回去了。后来逐条汇编指令检查,看一条指令,在草稿纸上手动更新一下运行栈的内容。查了好几十条指令,终于找到了取址方式的一个BUG,原来是一个取址公式的小括号标错了位置!

        但当最后成功生成了目标代码(这里用的是intel体系结构的32位汇编指令),用汇编器汇编成机器指令,执行成功的一瞬间,整个心都快跳出来了。那种可以用自己定义的语言来写程序的感觉,实在太兴奋了!


后记:

        接下来该做中间代码生成了。要设计四元式,优化(可选),分配寄存器,生成汇编码。

        四元式的设计还没接触过。

        寄存器分配那里还不懂,如果做出来了,会很有成就感。

        再就是转成汇编码时,对运行栈应该会有一个更深的理解。


        网上有好几个专栏都在教怎样写编译器,知乎大神vczh的跟vczh看实例学编译原理,两年前开始写,写了两个月,说自己要学车去了,停更几个星期,就再也没更过。另一个知乎专栏从零开始写个编译器吧,写到LL(1)文法,也写不下去了。可见一个编译器的实现,真的不是朝夕之事。

        写编译器最大的特点就是,无论你进行到了哪一步,都不知道下一步到底要怎么做,都不确定自己到底能不能完成。

        不知道我什么时候可以写完自己的编译器。也不知道到时候有没有时间自己开一个专栏去教别人写。

        但那么多人都写过了,凭什么我自己写不出来?

        加油!

        就像直到现在,虽然完成了完整的编译流程,但也不敢说,再做哪一步的优化就一定能做出来。但既然已经做了这么多,慢慢地,也就减少了对未知的恐惧。只要下定决心,一点点去思考,总会有一个满意的结果!

        

        感谢北航给了我这么一个宝贵的机会,完成了多年的夙愿。

        自己开专栏写编译器,实在是太花时间了,更何况导师似乎有点恼,因为整个假期都没有看论文=,=

        该回归正道啦 ^_^  加油加油!

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值