编译的代价---数据类型消失了

来源:互联网 发布:linux lsb release 编辑:程序博客网 时间:2024/04/30 23:40

作者 d.x.c

引言:曾看过好几本编译原理方面的教材,窃以为,这些教材大都脱离实际。须知,大多数人学编译原理并不是为了自己去写一个编译器,而是为了更好的理解自己写的代码。这一点对底层开发和嵌入式开发者来说尤其重要。      

本文仅讨论编译型静态语言。

 

 

一:编译器工作的基本过程。

       语言,是一个很神奇的东西。至今世界上的专家们还不能开发出相当于10岁小孩智商的自然语言处理器(注1)。为了使编译器识别我们的程序,我们必须使用一种编程语言。由于编程语言限制了可使用的符号(词法)和符号之间的组合方式(语法),所以编程语言可以用不同于自然语言的简单的方法进行处理。

       在编译器工作的时候,首先要识别程序代码中的符号,这个过程叫做语法识别,它的作用相当于我们背英语单词;之后把单词整理成句子,这个是语法识别,相当于我们把主谓宾语组合成句子。然后根据识别出的符号进行语义分析,从而理解代码所包含的意义,就好像我说我是个帅哥,然后你的头脑中就出现了一个骗子的形象,而不只是5个汉字的形状。

编译器中语法分析和语义分析通常是同时进行,但是这个同时只是时间上的同时,它们的理论基础,实现方法都是不一样的。       对于程序员来说,词法,语法和语义最重要的区别在于编译器可以纠正绝大多数语法错误(拼写错误),如果我们把Size拼写成了Sise,编译器会给我们一个不可识别的符号错误;大部分语法错误也可以被纠正,{}不成对就是这一类错误;但是只有很少的语义错误能被修改,如果我们把int 写成了short造成溢出,那么大多数情况下,编译器是不会纠正你的错误的。       随着编程语言和编译器技术的进步,越来越多的语义错误(潜在的BUG)可以在编译时被检查出,但是永远不要期望,在未来的某一天,编译器能检测出一切语义错误。如果真有这么一天的话,世界上就不需要程序员了,程序会自动生产程序,大家就等着回家喝西北风吧。

 

      

二:什么是数据类型

       国内大多数的编程爱好者都是从Basic,C,Java,Pascl开始入门的,最开始接触的编程知识就是数据类型;但是在掌握了汇编语言甚至机器语言之后,又有多少人想过,数据类型到底是个什么东西??   

       我们知道,在汇编语言级,数据名可以和几种类型挂钩,一种是equ常量(宏定义),另外就是db,dw,dd等存储类型。              在一个dw类型的变量里面,我们既可以保存指针也可以保存数值,实际上编译器并不关心我们在变量里面放了什么,只有程序员自己知道,哪个变量是作指针用而哪个变量作数值用,甚至兼做指针和数值。           在一些编译器如nasm汇编的语法中,编译器甚至连db,dw,dd都不分了,编译器只管把数据的名字映射到地址上面,不处理和数据类型有关的任何信息,关于数据长度的信息必须在使用时显示输入。

       而在机器语言级,所有的数据类型都不存在了,或者也可以说变量已经是不存在的了,所剩下的只有内存地址而已。 虽然在操作码中会指定操作数据的长度,但这实际上已经是每个操作的一部分,而不是和每个数据绑定的了。

       那么数据类型到底是用来作什么的呢??

       首先,根据数据类型,编译器可以自动生成操作码,而在nasm这样的汇编里面,我们必须在每一个有需要的语句上显示的标明被操作数的长度。

       其次,数据类型可以增强源代码的可读性,最显著的一点就是,在高级语言里面指针类型和数值类型是不同的,不易混淆;而汇编语言编程时往往需要给用作指针的变量使用特殊的前缀名以增加可读性

       最后,数据类型的引入使得编译器可以进行类型检查,并且在编译阶段可以发现很多潜在的BUG

 

 

三:数据类型的丢失

       我们的程序最终都是要变成机器语言的,所以说数据类型的丢失是既定的事实;现在的问题在于,数据类型是在哪里丢失的?我们能否通过某些技术将它保存下来??

让我们回到第一节。语法分析只是告诉编译器,源程序中有哪些关键字,那些符号,这一步编译器还没有得到数据类型的信息,所以是不会丢失的(注2)。   语法分析,编译器知道了哪些符号组成了语句,哪些符号又是关键字,但还是不知道数据类型的信息。       当我们来到语义分析阶段,数据类型的信息已经出现了,这时编译器会建立一个符号表,将所有和数据类型有关的信息保存在符号表里。     在理解了源代码的含义以后,编译器就可以做代码生成的工作了。由于种种原因,绝大多数编译器会首先生成中间代码,经过优化以后再把中间代码转换成目标代码(机器语言或者obj文件,注3)。       而在中间代码中,某些中间代码保留了数据类型,而另外一些中间代码去掉了数据类型。在目标语言中,数据类型彻底的消失了。

       对于我们给出的每一个变量,编译器要做两件事情,1是分配内存,这将直接体现在最终的机器代码中;2是生成符号表,它是生成代码和编译时类型检查所需要的数据。     这也引入了声明和定义的区别。    定义乃是为了让编译器为变量分配内存,而声明只是向符号表中写入数据(虽然说定义往往兼据说明的功能)。

       在语义分析阶段以后,符号表里面包含了变量和其它标识符的信息,编译器可以从符号表得到数据的类型信息;但是一旦源代码编译完成,目标代码里面就没有和数据类型有关的信息了,也就是说,我们无法动态的判断(注4)某个地址中包含的是指针还是数据,甚至无法判断出某个内存地址中包含的是数据还是代码。

 

 

四:数据类型的回归

       也许有的朋友觉得奇怪,你怎么能说数据类型和代码分开了呢??MFC里面不是可以使用动态类判断(运行时类型判断)吗??JAVA的扫描式自动内存管理不是也能知道,哪些内存地址所包含的是指针吗(注5)??

       答案就是,编译器产生的目标代码并不是真正意义上的程序。产生程序的最后一个步骤是:链接。(注6

       无论是windows还是Linux都支持灵活的文件头,于是我们可以把符号表作为独立的段链接到文件头中,然后通过读符号表就可以在运行时获得数据类型的信息。

       但是这样做也有一个缺点,技术门槛比较高。首先,你要找到一个支持符号表输出的编译器和支持符号表链接的链接器,如果找不到链接器的话就要自己写。       然后还要想办法去访问它,因为不同与在源代码中定义的数据,所以只能根据链接器放置符号表的位置,直接把内存地址赋值给指针。       最后还有编译器输出的符号表格式………….经过这么一大串的过程,移植性和可维护性会受到巨大的影响。

       JAVA的设计中,由于JAVA程序是在虚拟机中运行的,所以可以方便的把符号表留到运行时使用。

       由于对动态类型判断的需求比较少,MFC使用了另一种效率比较低但是可移植性比较好并且更容易实现的方法。   MFC中,每一个类都会用额外的变量保存和类有关的信息。假设(注7)在类对象开始地址偏移0字节处,保存有一个2字节的编号,这个编号唯一的表明了一个类,那么当我们在运行时得到指针后,只要读取这2个字节的内容,就可以知道指针指向的对象所属的类类型了。

      

 

 

 

1:自然语言指人们日常使用的语言,这种语言符号众多,语法语义极其复杂,还涉及到模糊处理的问题,例如一日就是一天

2:既然编程语言里面允许了数据类型存在,既然我们写程序的时候要区分数据类型,那么可以得到一个结论,编译器需要数据类型,只有在编译器得到了数据类型以后才能将他丢弃。

3obj文件最初是由Intel公司发布的一个文件格式,作为编译器的输出供链接器使用,很多公司都公布了自己的obj文件标准,现在应用最广泛的obj是微软的obj标准。非常遗憾的是,不同公司的obj文件格式往往互不兼容。Obj文件的格式非常接近于机器语言,此外还包括一些对链接器有用的信息例如入口地址。

4:静态和动态。     静态指的是在编译时决定的特性,而动态指的是在实际运行时决定的特性。例如用通过函数名的调用是在编译时就已经决定的,属于静态特性;而通过函数指针的调用是在运行时才决定跳转地址的,属于动态特性。

5:扫描式垃圾收集需要判断出在整个内存中有哪些指针,分别指向哪些内存,而那些没有被任何指针指向的内存就是不可使用的垃圾。如果通过扫描整个内存来进行指针分析是很慢的,而且会碰到伪指针的问题(本来是普通数据,但是正好符合指针的格式);只有在已知哪些地址保存的是指针,才能快速正确的进行自动内存管理。

6:若目标代码是bin类型,则它是最终的程序。但是由于未包含链接器所需的信息,bin文件不能被用来链接,所以绝大多数情况下我们使用的是obj文件。

7:这里只是假设,关于具体的实现,可以参考 深入浅出MFC 一书