当前位置: 首页 > 科技观察

从HelloWorld的程序运行机制开始_0

时间:2023-03-16 12:49:54 科技观察

学习任何一门编程语言,都会从HelloWorld开始。对于一个没接触过的语言,我们可以在很短的时间内用这种语言写出它的helloworld。不过相信很多人对于helloworld这个简单程序的内部运行机制还不是很清楚。helloworld这些信息是如何显示在显示器上的?CPU执行的代码肯定和我们在程序中写的代码不一样。它是什么样子的?我们是如何从自己写的代码变成CPU可以执行的代码的?程序运行时代码在哪里?他们是如何组织的?程序中的变量存储在哪里?函数调用是如何实现的?本文将简要讨论程序的运行机制和开发平台的隐藏进程。每一种语言都有自己的开发平台,我们的大部分程序也是在这里诞生的。从程序源代码到可执行文件的转换过程其实分为很多步骤,非常复杂,但是现在的开发平台把这些东西都自己包揽了,给我们带来方便的同时也隐藏了很多实现细节。因此,大部分程序员只负责编写代码,其他复杂的转换工作则由开发平台默默完成。按照我的理解,简单来说,从源代码到可执行文件的过程可以分为以下几个阶段:1.从源代码到机器语言,按照一定的规则组织生成的机器语言。我们暂时称它为文件A。2.将文件A和运行A所需要的文件B(比如库函数)链接起来,形成文件A+3,将文件A+加载到内存中,运行文件(其实如果你看参考书或者其他资料,可能会有不止这些步骤,这里为了简单起见我总结成3个步骤)这些是形成可执行文件的关键步骤,缺一不可。现在你可以看到你被开发平台“蒙蔽”了。以下部分将拨开迷雾,揭开开发平台的真面目。目标文件在计算机领域有一句经典的说法:“计算机科学中的任何问题都可以被另一层间接层所爱”“计算机科学领域中的任何问题都可以通过增加一个中间层来解决”例如,要实现从A到B的转换,可以先把A转换成文件A+,再把文件A+转换成我们需要的文件B。(其实这个方法在Polya的《how to slove it》中也有描述,求解的时候可以通过增加一个中间层来简化问题。)那么从源代码到可执行文件的过程就可以这样理解了。从源代码到可执行文件也是如此,通过(不断地)在它们之间添加中间层来解决问题。上面说了,先把源程序转换成中间文件A,再把中间文件转换成我们需要的目标文件。这是处理文件时的思维方式。其实上面说的文件A是一个更专业的名词:目标文件。它不是可执行程序,需要与其他目标文件链接加载后才能执行。对于一个源程序,开发平台首先要做的就是将源程序翻译成机器语言。最重要的部分之一是编译。相信很多人都知道,就是把源代码翻译成机器语言(其实就是一堆二进制代码)。编译知识很重要,但不是本文的重点。感兴趣的可以自行google。目标文件格式:下面我们来看看上面提到的目标文件是如何组织的(即存储结构)。来源:想象一下,如果你设计二进制代码,你会如何组织它?就像办公桌上的物品一定要分类摆放整齐,为了便于管理,翻译后的二进制代码也要分类存放,代码放在一起,数据放在一起。这样,二进制码就被分成不同的块进行存储。这样的区域就是所谓的段。标准:像计算机科学中的很多东西一样,为了方便人们的交流,程序兼容性等问题。也为这种二进制存储方式建立了标准,于是COFF(commonobjectfileformat)诞生了。目前Windows、Linux等主流操作系统的目标文件格式与COFF类似,可以认为是它的一个变种。a.out:a.out是目标文件的默认名称。也就是说,在编译文件时,如果不对编译后的目标文件进行重命名,编译后会生成一个名为a.out的文件。使用这个名字的具体原因这里就不深究了。有兴趣的可以自行google。下图可以让你更直观的了解目标文件:上图是目标文件的典型结构,实际情况可能不同,但都是在此基础上推导出来的。ELF文件头:上图中的第一段。header是目标文件的头部,包含了目标文件的一些基本信息。比如文件的版本,目标机器的型号,程序入口地址等等。Text部分:里面的数据主要是程序的代码部分。数据段:程序的数据部分,如变量。重定位段:重定位段包括文本重定位和数据重定位,其中包含重定位信息。一般来说,代码中会出现引用外部函数或变量的情况。由于是引用,目标文件中不存在这些函数和变量。使用它们时,必须给出它们的实际地址(此过程发生在链接时)。正是这些重定位表提供了查找这些实际地址的信息。理解了以上内容,文本重定位和数据重定位就不难理解了。符号表:符号表包含了源代码中所有的符号信息。包括每一个变量名、函数名等。它记录了每一个符号的信息。例如,如果代码中有一个符号“student”,那么这个符号对应的信息就包含在符号表中。包括符号所在的段,它的属性(读写权限)等相关信息。事实上,符号表的原始来源可以说是在编译的词法分析阶段。在进行词法分析时,代码中的每个符号及其属性都记录在符号表中。字符串表:类似于符号表,存放一些字符串信息。还有一点要说:目标文件是用二进制存储的,它本身就是一个二进制文件。实际的目标文件会比这个模型复杂一些,但是它的思路是一样的,就是按照类型存储,加上描述目标文件信息的段和链接中需要的一些信息。a.out没有证据可以剖析HelloWorld。我们来研究一下helloworld编译后形成的object文件。这里用C来形容。简单的hellowworld源码:/*hello.c*/#includeintmain(){  inta=5;  printf("hellowworld\n");}为了得到数据可以放置,这里加上“inta=5”。如果是在VC上,点击运行就可以看到结果了。为了看清楚内部是如何处理的,我们使用GCC编译。运行gcchello.c并查看我们的目录,多了一个目标文件a.out。我们现在要做的是看看a.out里面有什么。有的童鞋可能还记得用vim文本查看,我当时就这么天真地想。但是a.out是什么东西,怎么可能暴露的这么简单。是的,vim不起作用。“我们遇到的问题,大部分都是前人遇到过并解决过的。”是的,有一个非常强大的工具,叫做objdump。有了它,我们就可以彻底了解目标文件的各种细节。当然还有一个叫readelf的也很好用,后面会介绍。这两个工具一般都是linux自带的,大家可以自行google。注意:这里的代码主要是在Linux下用GCC编译的,使用Objdump和readelf查看目标文件。不过我会把所有的运行结果都放上图,所以之前没接触过Linux的童鞋看看下面的内容是没有问题的。我用的是ubuntu,感觉不错~下面是a.out的组织结构:(各段起始地址,大小等)查看目标文件的命令是objdump-ha.out,即和上面描述的目标文件的格式一样,可以看出是分门别类存放的。目标文件分为6个部分。从左到右,第一列(IdxName)是段的名称,第二列(Size)是大小,VMA是虚拟地址,LMA是物理地址,Fileoff是文件内的偏移量.也就是说,该段相对于段中参考点(通常是段的起点)的距离。***algn是段属性的描述,暂时忽略“文本”段:代码段。“数据”段:也就是上面说的数据段,里面存放的是源码中的数据,通常是初始化数据。“bss”段:也是数据段,存放那些未初始化的数据,因为这些数据还没有分配空间,所以单独存放。“rodata”段:只读数据段,里面存放的数据是只读的。“cmment”存储编译器版本信息。其余两段对我们的讨论没有实际意义,不再介绍。将它们视为包含一些链接、编译和安装信息。注意:这里的目标文件格式只是列出了实际情况中的主要部分。实际情况还有一些表没有列出来。如果您也在使用Linux,则可以使用objdump-X列出更详细的段内容。深入到a.out,上面部分通过例子描述了目标文件中的典型段,主要是段信息,比如size等相关属性。那么这些段中是什么,“文本”段中存储了什么,或者使用我们的objdump。objdump-sa.out可以通过-s选项查看目标文件的十六进制格式。查看结果如下:如上图所示,列出了各个段的十六进制表示。可以看出,该图分为两列,左列为十六进制表示,右列显示相应信息。比较明显的例子是“rodata”只读数据段中的“helloworld”。.汗,好像程序里的“hello”写错了,后面多了一个“w”,截图麻烦了。对不起。也可以查看“helloworld”的ASCII值,对应的十六进制值就是里面的内容。上面提到的“注释”部分包含一些编译器版本信息。这一段之后的内容是:GCC编译器,后面是版本号。a.out反汇编和编译的过程总是先将源文本转换成汇编形式,然后再翻译成机器语言。(加个中间层)看了这么多a.out,有必要再研究一下他的装配形式。objdump-da.out可以列出文件的汇编形式。不过这里只列出主要部分,即主要功能部分。其实在main函数开始执行的时候和main函数执行完之后还有很多工作要做。即初始化函数执行环境,释放函数占用的空间。上图中左边是代码的十六进制形式,左边是汇编形式。熟悉编译的童鞋应该能看懂大部分,这里就不细说了。在介绍目标文件格式的时候,a.out头文件提到了头文件的概念,它包含了目标文件的一些基本信息。比如文件的版本,目标机器的型号,程序入口地址等等。下图是文件头的形式:可以用readelf-h查看。(下图是hello.o,是源文件hello.c编译后未链接的文件,这跟看a.out差不多)图中分为两栏,左边一栏是Attribute,right是属性值。第一行通常称为幻数。后面是一串数字,具体含义就不多说了,大家可以自行google。以下是与目标文件相关的一些信息。由于与我们要讨论的问题关系不大,这里就不讨论了。以上是目标文件内部组织的内容和具体的例子。目标文件只是生成可执行文件过程中的一个中间过程。程序如何运行还没有讨论,目标文件是如何转换成可执行文件的。而可执行文件是如何执行的,将在下一节中讨论。链接的简单理解Link通俗地说就是放几个可执行文件。如果在程序A中引用了文件B中定义的函数,为了使A中的函数正常执行,需要将B中的函数部分放在A的源代码中,那么将A和B合并成的过程一个文件这就是链接。有一个用于链接程序的特殊过程称为链接器。它处理一些输入目标文件并合成一个输出文件。这些目标文件通常具有相互的数据和函数引用。在上面我们看到了helloworld的反汇编形式,它是一个没有被链接的文件,也就是说在引用外部函数时,其地址是未知的,如下图所示:上图中,cal命令调用了printf()函数,因为此时printf()函数不在这个文件中,所以无法确定其地址,其地址用十六进制的“ffffff”表示。链接后,这个地址将成为函数的实际地址,因为链接后函数已经加载到这个文件中了。链接的分类:链接按与A相关的数据或功能合并到一个文件中的先后顺序,可分为静态链接和动态链接。静态链接:链接工作在程序执行之前完成。也就是说,在链接完成之前无法执行该文件。但是这样有一个明显的缺点,比如库函数。如果文件A和文件B都需要使用某个库函数,则链接完成后,该库函数将包含在它们链接的文件中。当A和B同时执行时,内存中存在库函数的两份副本,这无疑浪费了存储空间。这种浪费在放大时尤为明显。静态链接也有不易升级等缺点。为了解决这些问题,现在很多程序都使用动态链接。动态链接:与静态链接不同,动态链接是在程序执行时链接的。也就是说,当程序被加载和执行时。还是上面的例子,如果A和B都使用了库函数Fun(),那么A和B执行的时候内存中只需要有一份Fun()就可以了。关于链接的知识还是很多的,以后会有专门的文章讲到。我不会在这里谈论它。加载的简单解释我们知道程序必须加载到内存中才能运行。以前是将整个程序加载到机器中的物理内存中,而现在一般采用虚拟存储机制,即每个进程都有一个完整的地址空间,给人的感觉是每个进程都可以使用完整的内存。然后内存管理器将虚拟地址映射到实际的物理内存地址。根据上面的描述,程序的地址可以分为虚拟地址和真实地址。虚拟地址是她在她的虚拟内存空间中的地址,物理地址是她实际加载的地址。看上面的segments你可能已经注意到了,由于文件是unlinked和unloaded,所以每个segment的虚拟地址和物理地址都是0。加载过程可以这样理解:首先对程序中的每个segment的虚拟地址部分分配,然后建立从虚拟地址到物理地址的映射。其实最关键的部分就是虚拟地址到物理地址的映射过程。程序安装完成后,cpu的程序计数器pc指向文件中代码的起始位置,然后依次执行程序。总结一下,写这篇文章的目的是梳理一下程序运行的机制,一个可执行文件执行背后隐藏着什么。从源代码到可执行文件通常有很多中间步骤,每一步都会生成一个中间文件。只是现在的集成开发环境隐藏了这些步骤,习惯了集成开发环境的我们逐渐忽略了这些重要的技术内幕信息。本文只介绍流程的主线。每个细节都可以扩展到足以在一篇文章中进行讨论。上面写的大部分是我个人的理解和看法。如有不足之处,还望大家多多指教。