程序的构成之一

一 可执行文件要包含哪些东西

   从基本原理出发,我们看程序要包含哪些东西。就是说,不管是什么系统,程序都要包含的一些基本内容。

   为了分析方便,我们引入两个概念,就是程序的静态形式和动态形式。这里所谓一个程序的静态形式,主要指的是程序以可执行文件的形式存在。从根本上来讲,是否以某种可执行程序文件格式存在,并不是必须的。因为从机器运行角度来讲,只要是机器码,只要存在一个放到内存的通道,那就可以放到内存里,由CPU来执行。所谓的格式,也是人为设定,由指令来解释的。所以,从最本质的根子上讲,程序主要还是可执行代码的指令集合。

   为了更好的描述程序的构成,这里,我们回退一大步,从更早的历史看起,摆脱现在各种漂亮的GUI装扮,对我们深入理解程序背后原理带来的困扰。因为表面看到的直观现象,隐藏了背后太多的细节,久而久之,就形成了一个不好的思维习惯,感觉一切都是理所当然的一样。我们习惯于各种点击,习惯于各种拟物的展示,这打断了我们的思路,使我们不再思考背后的流程,最后就容易形成定式思维,认为可执行文件就要一定是这样的格式,这样的运行方式,如果细问为什么,反而会感到惊奇,这不就是理所当然的吗,哪有什么为什么,别再钻牛角尖了......这将是一件可怕的事情。所以,程序员可以发挥创造性,让计算机的使用,有更好的体验,但是自身要避免陷入这种“良好体验”中,从而忽略了背后的实现。好了,言归正传,接着看早期的系统能给我们带来那些不一样的东西。

   学习计算机的人都了解,最早期的程序和代码,是通过打孔纸带的形式存在的。通过有无孔洞,分别来代表二进制的0和1。此时,程序和代码是昂贵的,不仅仅人力成本上,还包括执行成本上。一段程序,完成之后,基本就定型了,跟生产出的产品一样。如果出错了,不但得重新造,而且也没有好手段找到问题点。所以,这一时期的程序员,对程序的这种一锤子买卖,是深有体会的。我没有经历过这一时期,也不好再多说什么,但可以想象得出,那时候的程序员如果发现代码有错误,会是什么样的心情。

   当然,随着技术的发展,程序代码都逐渐的转移到磁盘、磁带或者软盘上。这些设备通过有无磁性,来代表0和1,从而彻底的改变了纸带时期的古老存储方式。这里面最关键的一点,就是数据可以擦写了。如果程序出错,可以擦掉,然后重新写入正确的。这也许才是程序员苦逼历史的结束。当然,也可以说是另一个苦逼历史阶段的开始。

   技术发展,不仅带来程序存储方式的改变,也带来了代码编写方式的改变。这就是语言的丰富。我相信,最开始的第一个程序,很可能就是直接用机器语言书写的,直接将一条条的二进制指令组织在一起。汇编虽然改变了这种现状,但仅仅是记忆方式的改变(助记符),本身仍然没有逻辑性可言,编写者仍然沉浸在指令和寄存器的世界里。直到类似逻辑语言的高级语言出现,程序员终于可以脱离苦海。但是反过来,用高级语言编写的程序,仍然需要转换成汇编,再进一步翻译成机器指令才能执行,执行的本质没有变。因为机器并不能直接执行高级语言,但是原来汇编的字符替换,变为现在的翻译【编译】过程,并由专门的工具实现,不管怎样,这都是一个巨大的进步。因为这将人的精力极大的解放了出来。这些工具只要一次完成,就可以千百次的使用。虽然第一次开发,会花费些许精力,但跟带来的整体效率提升相比,这点花费几乎都可以忽略不计了。

   这样一来,程序的执行可以按照下述流程进行:程序员用高级语言编写代码,然后使用编译工具,将代码最终转换成机器代码,存储到磁盘类设备上。当要执行该程序时,操作系统从磁盘类设备上,读取程序的机器码,将其存储到内存中,然后将CPU的指令指针指向代码的开始处,之后的执行,就由程序自身的代码来控制了。如下图所示:

 

   上图展示了一个程序从磁盘到内存到执行的一个流程。在这个例子里,我们认为可执行文件里就是简单的代码,这对于说明道理没有问题,对于简单的系统,其实也没有问题。但是,问题是,实际情况并不是这么简单的存在。首先,操作系统不再简单。一旦操作系统具备了虚拟内存管理、进程管理这些现代操作系统功能后,就需要考虑很多问题了。其次,程序也不再简单。实际中的程序,可能用到很多额外的库,很多其他的资源,比如图标、数据、配置文件等。这些东西整合在一起,才是一个完整的可执行程序。此时,也不能再随随便便无规则的将这些外部的资源进行组合,否则操作系统将无法解析处理。所以,总的一句话,问题不再乐观。

   问题是不再乐观,但是问题的根本在于什么?通过上面的介绍,其实根本在于规则。定义一个什么样的规则,既可以满足程序本身的构造需要(可以包含自身执行需要的很多东西),又能够满足操作系统的需要(可以掌握程序加载执行所需要的东西),那么问题就解决了。这样一来,规则就显得比较重要了。这里所说的规则就是可执行程序的格式。但如常言所说,饭要一口一口吃,路要一步一步走,问题就需要一点一点解决。想一口气吃成个大胖子,显然不现实。同样,想一下子就弄出个规则,显然也是不现实的。不过,好在计算机世界里,这一类问题,并不是说需要一个人有多高深的理论知识才可以解决,而是更多的来自实践经验。因为有很多程序问题都可以通过归纳总结整理的方法得到答案,而非数学证明。下面,我们就通过枚举的罗列方式,来看看可执行程序里应该有那些东西。

   首先,肯定是代码。规则的核心部分就是代码的二进制表示。我想,这一点是不可否认的。不管程序实现什么功能,高深的还是普通的,简单的还是复杂的,都是需要通过实实在在的代码来完成,没有代码,一切都是扯淡。

   其次是数据。数据的重要性不亚于代码。程序的逻辑在运行过程中,为完成功能,免不了要操作数据。比如,某些算法需要固定的一段数据作为引子,这类数据是固定的,没有一种方式能够构造它。再比如,一些固定常量的定义,参与运算的数据,中间结果……等等等等,所有这些都是用数据的例子。其实,实际中,远不止这些,而且,有位计算机领域的大咖,总结了程序的精髓就是算法加数据结构。这里的数据结构,再怎么说,也是跟数据有关联的。程序功能的完成过程,基本就是代码逻辑对数据的处理过程,所以,数据的重要性是不言而喻的。

   除了代码和数据,还有吗?也许还有吧?也许?肯定还有的。但是,有数据,有代码,基本的架子就有了。回想之前关于CPU和内存部分的介绍,在内存管理中,使用了分段的机制,这种分段里面就有代码段和数据段,CPU里面也有专门的寄存器来指向这些段。因此,操作系统加载可执行文件时,将代码和数据都加载到内存中,代码放在代码段,数据放在数据段,并有专门的寄存器关照,代码是代码,数据是数据。完成后,再将指令指针指向代码段开始处,后面的流程,就由代码逻辑来控制了。这时的流程图如下,

 

   这里,我们需要对数据再进行细分。首先看变量。从语言本身的角度来讲,变量分为全局变量和局部变量。想来,大部分计算机语言都是这样分类的。另外,变量也分为静态的和非静态的。所以,总结起来,就是一个变量总归要划分到这四种情况中的一种,即全局非静态,局部非静态,全局静态,局部静态。所谓全局非静态变量,在整个程序的生命周期都是存在的,不论程序的任何模块,都可以使用。也不存在变量是何时定义的问题,即程序运行之前,这些变量就已经在内存中准备好了,立马可以使用。而局部非静态变量,则是动态分配的,即程序执行到这块逻辑了,就分配,否则,不进行分配。比如函数里定义的变量,在函数被执行时,临时分配,函数退出时,则释放。如果函数没有被执行到,那其中定义的局部非静态变量,将不会在内存中有存在过的机会。好了,这是全局与局部的区别。除此以外,还有全局静态和局部静态的区分。全局静态是在全局变量的定义前,加上静态属性,局部静态也是在局部变量的定义前,加上静态属性。静态变量本质上是全局的,不论是全局静态还是局部静态。就是说,不管静态变量在什么地方定义,也不管其是否会在整个程序的生命周期中用到,它在整个程序的生命周期中都是存在的。相对于非静态的变量,它们的使用范围有限制,全局的,只在其所在的文件中可以使用,在别的文件里不可以使用,这与普通全局变量不同。局部的,则只在其所在的函数接口中可以使用,离开函数,则不能使用,但是变量本身并没有释放,这又与局部非静态有很大的区别。

   这样一分类,那么数据区中包含的是这几类变量中的那一种呢?是全部还是部分?为揭开这个答案,我们就穷举,一种一种来看。先看全局非静态。这类变量在整个程序运行过程中都需要存在,随用随取,且不分模块,显然将其放入数据区,程序加载时存入数据段,并没有什么不妥,相反,因为这种变量不存在重复问题,只要在数据区固定位置存放,代码中所有操做到该数据的地方,都用这个固定地址即可,保证随用随取,且全局有效,逻辑上十分清晰。如下图所示:

 

   再看局部非静态变量,由于在程序的一次执行中,并不能确定其是否会被分配内存,所以,在可执行文件中,它们并不在数据区存在。又因为它们的作用范围是局部的,可以使用的时机也是确定的,也就是在定义之后,因而,它们都被编译为操作栈的代码而存放在代码区了,这样实现,逻辑上显得更为恰当。举个简单的说明问题的例子,有如下一个函数,从代码中,我们可以看出,定义了局部变量a和b。

 

   这段代码经过编译后,其汇编指令的一部分是这样的

 

   从汇编指令中,我们看出,函数开始执行前,会创建一个堆栈。变量a和b都存到堆栈中了,成为了代码的一部分。在编译阶段,编译器为每个变量的偏移做好标记,后面,在该接口中,所有有关这个变量的操作,都会操作到堆栈中变量所在偏移的位置。如上图所示,编译器先标记好变量a在8偏移处,那后面所有有关a的操作,不管是读取还是写入,都会找8偏移处操作,最终就形成了上图所看到的汇编代码。这也就是我们说得,局部变量实际上存在于代码的操作之中。数据的存储空间就是堆栈,它是在接口被调用时创建,此时变量占用内存。当函数或者接口返回后,这些变量的值不再有用,变量本身在别的地方也不能使用,堆栈区域也相应的就会被释放,此时,变量占用的内存区域也就被回收,从而也节省了内存使用。不然,每一个接口的代码都放到数据区,白白占用内存,显然是不合理的。所以,在可执行文件的数据区,不再包含局部变量。

   局部变量是这样,那么,静态全局和静态局部又是如何呢?之前说了,静态属性,即表明了该变量是全局存在的,而全局和局部,则只是限定了变量可以使用的范围。对于全局情况,限定在所定义文件中,局部限定在接口范围内。全局静态,跟全局非静态类似,会放到数据区,供其所在的定义文件中的代码随时使用,且每一次修改的值,都会被保存。如此,编译器如何做到只在变量定义的文件中可用的呢?其实很简单,编译器仍然通过指定该变量在数据区的地址来使用它,但是,在编译阶段,会给予该静态变量特殊照顾,即将它特殊命名,这样一来,它就不会跟其他代码文件里的同名静态变量混稀了,同时,会将当前文件里对静态变量的使用,替换为经过如此特殊标记后的变量的地址,效果就如同数据区中存在一个唯一的全局变量,但是它的名字只会被定义它的文件用到,自然不会跟其他静态变量冲突了,整个效果如下图所示。

 

   上述例子中,使用了一个全局变量和一个全局静态变量。我们看看汇编代码,作为参考:

 

   数据区中两个变量都保存了。

 

   使用上,这里的汇编没有区分全局静态和全局普通变量。只要编译检查通过即可。调用时,是基于当前地址加上一个偏移,来获取数据的。

   全局静态的问题解决了,那局部静态呢?显然不能跟局部变量同样处理,因为放在堆栈中的变量在返回时,会因为堆栈释放而释放,而且,即使在堆栈中,也不能保证变量的值能够被保存下来在下次使用。实际上,局部静态的处理跟全局静态是一样的,这一点,读者根据全局静态的逻辑简单推理一下就明白了。下面的实际例子也证实了这一点。

 

 

 

   好了,这样一来,除了局部非静态变量外,另外三种都存放在数据区。变量的问题似乎得到了解决。不过,还有一个问题没有考虑,那就是变量本身还存在一个是否定义了的问题。这种情况,对局部非静态变量不存在问题,因为这种变量不在数据区存放,而是在代码中直接处理,定义初始值与否,都无关系。定义了,那我就修改堆栈中变量的值即可,没有定义,一般就使用堆栈中残留的值(对于程序功能来讲,这是非常危险的)。但是,对于其他三种存放在数据区的变量就不同了,这几类变量是直接占用内存的,就是说,数据区里的变量,在操作系统加载程序时,会直接分配对应大小的内存,将各个变量都存放进去。这样一来,有初始值的变量,在加载到内存中时,赋予其定义的初始值,没有什么问题,但是没有定义初始值的变量呢?该怎么处理?最简单的方案就是当作初始值为零的情况来处理。通过这种方法,问题可以解决,没有什么不妥,但是,除了这种最直接的方法外,还有没有更好的方案呢?

   我们来考虑,如果不按照赋值零来处理,该怎么办?显然,不能放到数据区了,不然加载后,该如何赋予初值倒会变成一个负担。但是这些变量总归还是要占用全局空间的,所以可以将这些变量单独放到一起,只记录其大小和总的大小,而不存值,待到被加载到内存中时,再按照总的占用空间大小,将其所占用的内存区统一清零,这样一来,也可达到目的,同时,可执行文件因为少存东西,则会瘦身,而批量的内存清零,效率也会更高。这个单独存放这类变量的地方,在可执行文件里,都是一个叫BSS的段专门来负责,相当于将原来的数据区拆分成如此两个部分。

 

   我们看看汇编中的两种变量

 

   上面这是数据段。

 

   上面这是BSS段。

   对于变量,再多提几句。变量有一些是基本类型的,比如整型,字符型等,也有一些应该算作非基本型的,暂且称做复合型,比如数组,结构体等。因为它们基本是由基础类型组合而成的。这类变量,占用的内存空间一般相对较大,使用也比较灵活,它们在编译时,是如何处理的呢?其实,跟基本类型是一样的,对变量整体的操作,或者对里面包含的基本数据的操作,都是对内存地址的操作,所以编译器只要找到地址就可以,(比如对结构体里面的某一项操作,也是转化为结构体首地址加偏移即可)无需太多特殊处理。像C++里面,类的数据部分,也是类似结构体的存储。从底层的角度来看,上层的实现可以很复杂,但是转化到底层,本质上差别不大,这就叫以不变应万变。

   接着,我们来看看宏定义。代码里的宏定义所对应的数据,只是为了方便程序员开发,方便代码的阅读,实际在编译中,是直接替换的,也就是说如果代码中某个地方用到了宏定义,就直接替换为初始定义的值,因而不存在需要放到数据区的情况。实际存在于代码中。

   继续,我们看看常量,包括字符串常量。这部分讨论print里面的字符串常量。但是对于放到rodata中的数据,不同编译器的实现,差异可能比较大,也没有通用的比较规范的定义,属于比较随意的一个区块。具体信息,待自行实验后确定。基本来看,主要还是打印里面的字符串,以及程序中出现的字符串常量。比如字符串比较,字符串的处理等地方。将这类数据放到rodata区域,在加载后,将这块内存标记为只读,就可以保证程序运行过程中,这块的数据不被有意或者无意的修改。

 

 

   可以看到,字符常量在只读数据区。至此,我们介绍完了数据。现在,可执行文件里存在了四个区块。就如下图所示。

 

   如此,可执行文件是不是就OK了?答案取决于我们是否已经枚举了所有可能的情况。不同于编译原理,所偏向于理论,需要进行语法语义完备的状态机分析,生成可执行文件的过程,更多的偏向于工程。考虑实际系统跑起来后,可执行文件中缺少什么,那就补充什么,然后再根据总体情况,进行优化。所以,还是要从实际中可能存在的情况来分析。另外,有些部分,也是随着功能的强化,而后增加的。这个文件的内容格式,会越来越优化,越来越完善,当然,也会越来越复杂。而且,部分特性(或者说段)的出现,是为了适应新的语言而增加的。像init与finit,对应于C++中的构造和析构。语言设计的再强大,终归是要变成串行的二进制代码来变现。强大的语言,可以方便开发大型程序,方便人们的逻辑思维,但是,CPU取指令、分析指令,执行指令的流程并没有变化,因而,相应的编译复杂度就会不可避免的增加。好了,接着继续看程序的构成。

   第一种补充情况,程序分debug 版本和release版本。要方便的调试,可能就需要添加调试信息。基于此,增加debug段。如果编译时,设定不添加debug信息,则最终的可执行文件里,就不包含该段。

 

   debug段对于程序的调试非常重要。调试信息包含了程序中各个符号对应的字符串名称,比如函数名称,变量名称等,还有所在代码的行号等等。这些信息本身不影响、不参与程序的逻辑执行,但是当使用gdb调试程序时,能提供极大的帮助。执行strip命令,可以移除这部分内容。

   第二种补充情况,comment段。与程序中的注释有关?不是这样。实际上,主要是包含编译器的的相关信息,比如什么编译器,版本是多少等。算是辅助信息。

 

   以上介绍的内容,基本涵盖了代码文件转换为可执行程序时,应该要包括的各种段。如下图所示:

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值