程序运行流程——链接、装载及执行

在阅读完《深入理解计算机系统》第一章(计算机系统漫游)、第七章(链接)以及第十章(虚拟存储器)和《程序员的自我修养——链接、装载与库》后,历时悠久的梦想终于要实现了。开篇之初,首先提出一个迷惑了很久的一个问题:什么是虚拟存储器?它跟进程的虚拟地址空间有什么关系?

虚拟存储器是建立在主存--辅存物理结构基础上,有附加的硬件装置及操作系统存储管理软件组成的一种存储体系。 

顾名思义,虚拟存储器是虚拟的存储器,它其实是不存在的,而仅仅是由一些硬件和软件管理的一种“系统”。他提供了三个重要的能力:1,它将主存看成一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据(这里存在“交换空间”以及“页面调度”等概念),通过这种方式,高效地利用主存;2,它为每个进程提供了统一的地址空间(以虚拟地址编址),从而简化了存储器管理;3,操作系统会为每个进程提供独立的地址空间,从而保护了每个进程的地址空间不被其他进程破坏。 

 

虚拟存储器与虚拟地址空间是两个不同的概念:虚拟存储器是假想的存储器,而虚拟存储空间是假想的内存。它们之间的关系应该与主存储器与内存空间之间的关系类似。

链接部分:

链接就是将不同部分的代码和数据收集和组合成一个单一文件的过程,也就是把不同目标文件合并成最终可执行文件的过程。当然,务必知道:这个过程不涉及内存。链接可以分为三种情形:1,编译时链接,也就是我们常说的静态链接;2,装载时链接;3,运行时链接。装载时链接和运行时链接合称为动态链接。在此,我们的链接部分将主要讲述静态链接,而装载时链接我们放在装载部分讲,运行时链接忽略。

很多时候,从示例入手比较简单。我们写两个小程序a.c和b.c  

/* a.c */ extern int shared; int main() { int a = 100; swap(&a,&shared); }

/* b.c */ int shared = 1; void swap(int *a,int *b) { *a ^= *b ^= *a ^= *b; }

编译这两个文件得到“a.o”和“b.o”两个目标文件

§gcc -c a.c b.c

从代码中可以看到三个符号:share,swap和main。

静态链接的整个过程分为两步:  

第一步:空间和地址分配。扫描所有的输入目标文件,获得他们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这样,连接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。

这里可能会有一个问题:建立了什么样的映射关系。看了下面图,你可能就会有所了解。映射关系就是指可执行文件与进程虚拟地址空间之间的映射。那么,这里程序还没有执行,更不会出现进程,哪里来的进程地址空间呢?此时虚拟存储器便发挥了很大的作用:虽然此时没有进程,但是每个进程的虚拟地址空间的格式都是一致的。所以,为可执行文件的每个段甚至每个符号符号分配地址也就不会有什么错了。注意:在链接之前,目标文件中的所有段的虚拟地址都是0,因为虚拟空间还没有被分配,默认都为0.等到链接之后,可执行文件中的各个段已经都被分配到了相应的虚拟地址。仍然看下图。。。

 

综上所述。链接后可执行文件中的各个段的虚拟地址都已经确定了。那么,各个符号的地址呢?因为各个符号在段中相对位置是固定的,所以这个时候“main”,“share”及“swap”的地址也都确定了。

其中,“main”位于“text”段的最开始处,偏移量为0,所以“main”这个符号在最终的输出文件中的地址应该是 0x08048094 + 0 即0x08048094;同理,“swap”的偏移量为0x34, “swap”这个符号在最终的输出文件中的地址应该是 0x08048094 + 0x34 即0x080480c8;“shared”相对于“data”的偏移量为0, 在最终的输出文件中的地址应该是 0x08049108。这里可能会有点小小的疑问:shared怎么在.data段中呢?刚开始不是未初始化吗?是的,但是我们这里现在是已经合并好的段,它已经是合并后的文件格式状况,shared已经知道它的值为1了,仔细看上图。 如下表所示

符号

类型

虚拟地址

main

函数

0x08048094

swap

函数

0x080480c8

shared

变量

0x08049108

第二步:符号解析与重定位

首先,符号解析。解析符号就是将每个符号引用与它输入的可重定位目标文件中的符号表中的一个确定的符号定义联系起来。若找不到,则出现编译时错误。       

解释一下什么是符号定义和什么是符号引用吧:须知,这样的区分是源于某一特定的模块而言。如上所示,对a.o而言,它里面的shared即为符号定义而b.o里面的shared为符号引用;相对地, 对b.o而言,它里面的shared即为符号定义而a.o里面的shared为符号引用;

其次,重定位。  

不同的处理器指令对于地址的格式和方式都不一样。我们这里采用的是32位的x86处理器,介绍两种寻址方式。

X86基本重定位类型

宏定义

重定位修正方法

R_386_32

1

绝对寻址修正S + A

R_386_PC32

2

相对寻址修正S + A - P

注:

A:保存在被修正位置的值,对于32位cpu的话,采用 R_386_PC32寻址的话 它应该为0xFFFFFFFC即-4,它是代表地址的四个字节;而采用 R_386_32寻址,它应该为0.

P:被修正的位置。考虑以下程序

...

1023: 11 11 11

1026:e8  fc  ff ff ff

102b: 11 11 11

...

上述蓝色fc标记处即是被修正的位置,即0x1027.

S:符号的实际地址。也就是第一步中空间和地址分配时得到的符号虚拟地址。

举例来说吧!链接成的可执行文件中,假设main函数的虚拟地址为0x1000,swap函数的虚拟地址为0x2000;shared变量的虚拟地址为0x3000;

绝对地址修正:对shared变量的地址修正。

S:shared的实际地址为0x3000;

A:被修正位置的值,即0.

所以最后这个重定位修正地址为:0x3000,不变!

相对寻址修正:对符号“swap”进行修正。

S:符号swap的实际地址,即0x2000;

A:被修正位置的值,即0xFFFFFFFC(-4);

P:被修正位置,及0x1027

最后的重定位修正地址为:S + A -P = 0x2000 +(-4)- 0x1027 = 0xFD5.即修正后的程序为:

...

1023: 11 11 11

1026:e8  d5 0f 00 00

102b: 11 11 11

...

发现熟悉的规则了吗?下一条指令(PC)的地址为0x102b,加上这个修正值正好等于0x2000,

0x102b + 0xFD5 = 0x2000,刚好是swap函数的地址。

 

以上内容没有涉及到c标准库,仅仅是自己实现的两个c语言程序之间的链接状况,也就是“程序里面的printf怎么处理”没有说明。这里,我们就要提及“静态库”的概念。其实一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。与静态库链接的过程是这样的:ld链接器自动查找全局符号表,找到那些为决议的符号,然后查出它们所在的目标文件,将这些目标文件从静态库中“解压”出来,最终将它们链接在一起成为一个可执行文件。也就是说只有少数几个库和目标文件被链接入了最终的可执行文件,而非所有的库一股脑地被链接进了可执行文件。

 

装载部分:

首先,小议一下动态链接。动态链接其实有分为装载时链接和运行时链接,在这里,我们只考虑装载时链接而不考虑运行时链接。

为什么要动态链接呢?

主要原因有两个:第一,考虑内存和磁盘空间。静态链接极大地浪费内存空间。因为在静态链接的情况下,假设有两个程序共享一个模块,那么在静态链接后输出的两个可执行文件中各有一个共享模块的副本。如果同时运行这两个可执行文件,那么这个共享模块将在磁盘和内存中都有两个副本,对磁盘和内存造成极大地浪费;第二,程序的更新。一旦程序中的一个模块被修改,那么整个程序都要重新链接、发布给用户。如果这个程序相当的大,那么后果就会更加严重!

动态链接做了什么?

务必知道,动态链接是相对于共享对象而言的。动态链接器将程序所需要的所有共享库装载到进程的地址空间,并且将程序汇总所有为决议的符号绑定到相应的动态链接库(共享库)中,并进行重定位工作。

下面开始说说装载。装载的方式主要有两种:覆盖装入和页映射。因为虚拟存储器的出现,覆盖装入已经被淘汰了。而页映射是虚拟存储机制的一部分,伴随着虚拟存储器的发明而诞生。具体的页映射可以参考《深入理解计算机系统》的第十章“虚拟存储器”。

以Linux内核装载ELF为例简述一下装载过程。当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()来执行指定的ELF文件,原先的bash进程继续返回等待刚才启动时新进程结束,然后继续等待用户输入命令。这里需注意,随着一个新进程的出现,操作系统会为它创建一个独立的虚拟地址空间。

【创建虚拟地址空间】我们知道一个虚拟空间由一组映射函数将虚拟空间的各个页映射到相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的数据结构。举例来说,在x86的Linux下创建虚拟地址空间实际上只是分配一个页目录(页表)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生“缺页”时在进行设置。

在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),作用:参数的检查复制;调用do_execve(),流程:查找被执行的文件,读取文件的前128个字节以判断文件的格式是elf还是其它;调用search_binary_handle(),流程:通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理程序。ELF可执行文件的装载处理过程叫load_elf_binary(),它的主要步骤如下:

1,检查ELF可执行文件格式的有效性,比如魔数、程序头表中段的数量。

2,寻找动态链接的“.interp”段,找到动态链接器的路径,以便于后面动态链接时会用上。

3,读取可执行文件的程序头,并且创建虚拟空间与可执行文件的映射关系。

【读取可执行文件的程序头,并且创建虚拟空间与可执行文件的映射关系】创建虚拟空间时的页映射关系函数是虚拟空间到物理内存的映射关系,而这一步所做的事虚拟空间与可执行文件的映射关系。我们知道,当程序发生缺页是,操作系统会为物理内存分配一个物理页,然后将该缺页从磁盘中读取到内存,在设置缺页的虚拟页与物理页之间的映射关系,这样程序才可以得以正常运行。但是明显的一点是,当操作系统捕获到缺页错误时,他应当知道程序当前需要的页在可执行文件中的哪一个位置。而这就是虚拟存储与可执行文件之间的映射关系。实际上,这种映射关系仅仅是保存在操作系统内部的一个数据结构。当发生缺页错误是,CPU将控制权交给操作系统,操作系统利用专门的缺页处理例程来查询这个数据结构(映射关系),然后找到所需页所在的虚拟内存区域,以及在可执行文件的偏移,然后把该页加载进物理内存,同时将该虚拟页与物理页之间建立映射关系,最后把控制权还给进程,进程从刚才缺页位置重新开始执行。

4,初始化ELF进程环境。

5,将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,它就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点就是动态链接器。

【将CPU指令寄存器设置成可执行文件的入口,启动运行】对动态链接来讲,此时就启动了动态链接器。

当load_elf_binary()执行完毕,返回至do_execve()在返回至sys_execve()时,系统调用的返回地址已经被改写成了被装载的ELF程序的入口地址了。所以,当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到ELF程序的入口地址。此时,ELF可执行文件装载完成。接下来就是动态链接器对程序进行动态链接了。

动态链接基本分为三步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后重定位和初始化。

1,动态链接器自举

就我们所知道的,对普通的共享对象文件来说,它的重定位工作是由动态链接器来完成;它也可以依赖于其他共享对象,其中被依赖的共享对象由动态链接器负责链接和装载。那么,对于动态链接器本身呢,它也是一个共享对象,它的重定位工作由谁完成?它是否可以依赖于其他的共享对象文件?

动态链接器有其自身的特殊性:首先,动态链接器本身不可以依赖其他任何共享对象(人为控制);其次动态链接器本身所需要的全局和静态变量的重定位工作由它自身完成(自举代码)。

我们知道,在Linux下,动态链接器ld.so实际上也是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器。动态链接器入口地址即是自举代码的入口。动态链接器启动后,它的自举代码即开始执行。自举代码首先会找到它自己的GOT(全局偏移表,记录每个段的偏移位置)。而GOT的第一个入口保存的就是“.dynamic”段的偏移地址,由此找到动态链接器本身的“.dynamic”段。通过“.dynamic”段中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,然后将它们重定位。完成自举后,就可以自由地调用各种函数和全局变量。

2,装载共享对象

完成自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,称之为“全局符号表”。然后链接器开始寻找可执行文件所依赖的共享对象:从“.dynamic”段中找到DT_NEEDED类型,它所指出的就是可执行文件所依赖的共享对象。由此,动态链接器可以列出可执行文件所依赖的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合中取出一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将依赖的共享对象的名字放到装载集合中。如此循环,直到所有依赖的共享对象都被装载完成为止。

当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中。所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含动态链接器所需要的所有符号。

3,重定位和初始化

当上述两步完成以后,动态链接器开始重新遍历可执行文件和每个共享对象的重定位表,将表中每个需要重定位的位置进行修正,原理同前。

重定位完成以后,如果某个共享对象有“.init”段,那么动态链接器会执行“.init”段中的代码,用以实现共享对象特有的初始化过程。

此时,所有的共享对象都已经装载并链接完成了,动态链接器的任务也到此结束。同时装载链接部分也将告一段落!接下来便是程序的执行了。。。

 

执行部分

对于写过c程序的人来说,一个公认的事实是:程序是从main函数开始的。然而,真的是 这样吗?其实不然,在程序执行到main函数之前,很多事情已经由入口函数(入口点)完成了。接下来将通过一个Linux下的可执行文件p来说明它的执行过程。

Unix> ./p

因为p不是一个内置的shell命令,所以shell会认为p是一个可执行文件,通过调用某个驻留在存储器中称为“加载器”的操作系统代码来为我们运行之。装载部分已经在上述部分详细描述了。装载完成后,控制权跳转到程序的入口点,也就是符号_start的地址,在_start地址处的启动代码如下

0x080480c0 < _start> Call _libc_init_first Call _init Call main Call atexit Call _exit /* 一下不会执行 */  

首先从.init和.text节中调用初始化例程后,启动代码调用应用程序main程序,执行我们的c程序主体。在应用程序返回后,启动代码调用atexit注册的函数,然后调用_exit结束进程,将控制返回给操作系统。

一个典型的程序的运行步骤大致如下:

1,操作系统创建进程(装载了),将控制权交给程序的入口,即运行库的入口函数。

2,入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等。

3,入口函数在完成这些初始化之后,调用main函数,正式开始执行程序的主体部分。

4,main函数执行完成后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用来结束进程。

Hello程序的执行

    前面简单描述了系统的硬件组成和操作,现在开始介绍当我们运行示例程序时到底发生了些什么。在这里我们必须省略很多细节稍后再做补充,但是从现在起我们将很满意这种整体上的描述。  

    初始时,外壳程序执行它的指令,等待我们输入一个命令。当我们在键盘上输入字符串“./hello”后,外壳程序将字符逐一读入寄存器,再把它存放到存储器中,如图1-5所示。

 当我们在键盘上敲回车键时,外壳程序就知道我们已经结束了命令的输入。然后外壳执行一系列指令来加载可执行的hello文件,将hello目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串“hello, world/n”。

一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将“hello, world/n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。这个步骤如图1-7所示。

 

附录 Windows下面可执行文件a.exe的运行过程

一个microsoft的.exe程序的启动过程如下:

     (1)当我们双击a.exe图标启动程序时,系统首先做什么呢,让我们先听一听侯捷是如何说的吧“执行起来的App进程其实是shell调用CreateProcess激活的”。很多书上都是如是说的,shell又名“命令解释器”,是win32操作系统基于浏览器的一个32位用户接口,它是一个多线程的好例子,屏幕上每一个文件夹浏览窗口都是它的一个线程。它是操作系统引导时加载的系统进程,它具体表现为windows explorer.exe。explorer.exe是所有用户应用程序的创造者。你完全可以将shell看成是所有应用程序进程的父进程,就像桌面(desktop)可看成所有窗口的父窗口一样。shell的用途很多,如启动应用程序,管理文件系统,将应用程序与相应文件相关联等等。我们常见的桌面上的带有小箭头的快捷方式(shortcut)就是一个shell链接,shell负责管理一个叫"名字空间"的类似文件系统似的“超文件系统”,它允许应用程序在任何地方在不知访问对象名字和位置的前提下访问到这个对象,此类对象有:文件,目录,驱动器,打印机以及网络资源。而名字空间就是shell把这些对象有层次组织起来的一个结构。名字空间为用户和应用程序提供了一种可靠和高效的方法来访问和管理对象。好了不论它是什么,凡正它调用了CreateProcess,一切就从这里开始了。

     (2)CreateProcess这个函数可做了不少工作。a进程由此诞生。当CreateProcess这个函数被调用,系统就会创建一个“进程内核对象”。进程内核对象可以看作一个操作系统用来管理进程的内核对象,它也是系统用来存放关于进程统计信息的地方(一个小的数据结构),其实它的真正创建者是一个叫NTCreateProcess的windows2000系统服务函数(也叫执行体服务函数),他创建了进程内核对象供用户扩展。进程内核对象的初始使用计数为1。然后系统为该进程创建4GB(=2^32)的虚拟地址空间(所谓虚拟就不是真的创建4GB的物理内存空间,这些空间不是真在物理内存上).用于加载App.exe可执行文件和任何必要的dll文件的数据和代码。

     (3)下面概述一下系统的加载器(可称为loader)是如何加载这些东东的。首先了解一下系统为该进程创建4GB的虚拟地址空间是如何分配的,对于win2000/winxp来说,默认情况下每个用户进程可以占有2GB的私有地址空间;操作系统占有剩余的2GB空间。在32位x86系统上,

从0x00000000到0x7fffffff的空间中存放着 应用程序代码,全局变量,每个线程堆栈,dll

代码。

从0x80000000到0xc0000000的空间中存放着 内核和执行体,HAL(硬件抽象层),引导驱动 程序。

从0xc0000000到0xc0800000的空间中存放着 进程页表和超空间。

从0xc0800000到0xffffffff的空间中存放着 系统高速缓存,分页缓冲池,非分页缓冲池。

首先,CreateProcess打开应用程序文件(.exe),它先扫描该文件的文件头,该文件头里含有文件能运行在那个环境之下,如果是win32环境,系统就直接加载文件的代码和数据并输入(import)该文件执行所需的dll函数。如果不是win32环境比如时os/2的.exe则先加载相应的环境子系统,由该环境加载该文件的代码和数据以及该文件执行所需的dll函数。至于系统是如何知道文件的代码和数据以及该文件执行所需的dll函数所在的位置就需要你了解一下PE文件格式了,其实也很简单,PE文件拥有很多sections,数据和代码都放在不同的section里面,文件执行所需的dll也放在单独的section(.idata)里,这里就不详述了。

     (4)进程加载代码和数据完毕后,就开始创建线程来执行进程空间内的代码。进程是静态的,它只是线程的容器。一个进程至少因该有一个线程(main thread),其它线程都是主线程通过调用CreateThread函数创建的。线程也是核心对象,他的实际创建者是一个叫NtCreateThread的windows2000系统服务函数。一个线程其实只是一个线程核心对象和两个堆栈(一个核心堆栈,用于线程运行在核心态;一个用户堆栈,用于线程运行在用户态),线程与进程类似,也拥有线程核心对象计数和线程句柄。线程用于描述进程中的运行路径。每当进程被初始化时,系统就要创建一个主线程。该线程与c/c++运行时库的启动代码一道开始运行,启动代码则调用进入点函数(就是我们的main函数,它也是主线程的进入点函数),并且继续运行直到进入点函数返回并且c/c++运行时库的启动代码调用ExitProcess为止。每个线程都有自己的入口点函数,主线程入口点函数名字必须是main,wmain,WinMain或wWinMain.而其他的线程入口点函数名字可使用任何名字。每个线程函数必须有一个返回值,它将作为线程的退出代码。对于主线程来说,这个返回值将传给c/c++运行时库的启动函数。

     (5)c/c++运行时库的启动函数它其实是一个程序的真正调用的第一个函数,它是在程序链接时由链接程序选择相应的启动函数并加到程序的开始处。c/c++运行时库有四个版本的启动函数,他们分别对应不同类型的应用程序。比如,需要ANSI字符和字符串的GUI应用程序的启动函数是WinMainCRTStartup,其对应的进入点函数是WinMain,需要Unicode字符和字符串的GUI应用程序的启动函数是wWinMainCRTStartup,其对应的进入点函数是wWinMain,而需要ANSI字符和字符串的CUI应用程序(如控制台console程序)的应用程序的启动函数是mainCRTStartup,对应的入口点函数为main;需要Unicode字符和字符串的CUI应用程序(如控制台console程序)的应用程序的启动函数为wmainCRTStartup,对应的入口点函数为wmain;c/c++运行时库的启动函数的功能如下:

以wWinMainCRTStartup(大多数运行在windows2000下的应用程序的启动函数都是它)为例。它负责:

  *检索指向新进程的完整命令行指针;

  *检索指向新进程的环境变量的指针;

  *对c/c++运行时的全局变量进行初始化;

  *对c运行期的内存单元分配函数(比如malloc,calloc)和其他低层I/O例程使用的内存栈进行初始化。

  *为C++的全局和静态类调用构造函数。

当这些初始化工作完成后,该启动函数就调用wWinMain函数(相对于main函数)进入应用程序的执行。当wWinMain函数执行完毕返回时,wWinMainCRTStartup启动函数就调用c运行期的exit()函数,将返回值(nMainRetVal)传递给它。之后exit()便开始收尾工作:

  *调用由_onexit()函数调用和注册的任何函数。

  *为C++的全局和静态类调用析构函数;

  *调用操作系统的ExitProcess函数,将nMainRetVal传递给它,这使得操作系统能够撤销进 程并设置它的exit 代码。

  至此启动函数的任务完成!

 

整整两天,才把这小小一篇文章搞定,但是写的过程中感觉非常的好,不断地思考怎么写,怎么组织,不断地锻炼自己吧!

你可能感兴趣的:(Major)