Wonder奇迹奇迹

导航

基础知识巩固笔记(链接、装载与库)

一.软硬件基本知识

1.在计算机多如牛毛的硬件部件中最重要的三个是:中央处理器(CPU)、内存和I/O芯片。下图为现代计算机的硬件结构框架

PCI bridge被称为北桥,是为了让内存等设备能够跟上CPU的频率。ISA bridge为南桥,让低速设备可以连接到北桥上。

2.计算机软件的体系结构:

应用程序调用运行库提供的应用程序编程接口,而运行库调用操作系统提供的系统调用接口。

3.目前所有的操作系统对CPU的分配方式都是以抢占式进行的,每个程序会以进程的方式运行,每个进程有自己独立的地址,每个进程根据自己优先级的高低都有机会得到CPU资源,但当CPU运行超过超过一段时间后,就会让给其它等待的进程。如果操作系统分配给每个进程的时间都很短,那么CPU会频繁的切换任务,从而造成多任务同时运行的假象。

4.硬件驱动来搞定硬件操作的繁琐细节,通常由硬件厂商开发,通常应遵守操作系统的提供的接口跟框架。硬件驱动可以看做是操作系统的一部分,它与操作系统内核一起运行在特权级。

5.硬盘基本知识:一个硬盘往往有多个盘片,一个盘片有两面,每面按照同心圆划分为多个磁道,每个磁道划分为若干扇区,一个扇区一般为512字节。每个扇区有一个逻辑编号

6.程序与内存:

地址空间可以分为两种:虚拟地址空间和物理地址空间,物理地址空间是在计算机中实实在在存在的、唯一的内存地址,虚拟空间是指虚拟的、想象出的地址,每个进程都有自己独立的虚拟地址空间,这是程序隔离的方法。

(1)最开始,人们采用分段的方法将一段程序所需大小的虚拟空间映射到物理空间,一个字节对应一个字节的严格映射,例如:

这样虽然隔离了程序,但内存使用效率太低。

(2)分页的方法后来被发明,其原理是将地址空间人为的划分为页(page,大小由硬件或操作系统决定,大部分为4k),下面举一个简单例子:

设1页的大小为1KB,设两个程序的虚拟空间地址有8KB,即8个虚拟页

假设这是一个32位的电脑,即拥有2^32个物理寻址能力(4G),但假设目前只有6KB的内存(6个物理页可用),当我们将进程里虚拟空间按也划分,常用的代码或数据页装到内存,不常用的装到磁盘(磁盘页中),需要时再取出来。上图中的process1的VP0/1/7倍映射到了物理页PP0/2/3,VP2/3却存储在了磁盘的DP0/1(磁盘页)中,而其他的注入VP4等可能还没有被用到过。

不同的进程可用将自己的虚拟页映射到同一物理页,实现了内存的重用,当进程需要使用DP1的数据或代码时,操作系统会复负责将其从磁盘中调取出来到内存,并为其与VP3建立映射关系。

下面是虚拟存储的实现方式:

7.线程:基本组成包括线程ID、当前指令指针(PC)、寄存器集合和栈堆。

(1)从C/C++的角度来讲数据在线程中是否私有的关系如下:

单个处理器多线程是一种模拟出来的状态,多个处理器中,当线程数量小于处理器个数时,是真正的并发运行,当线程数超过处理器个数时,会存在线程调度,这时线程也是一种抢占式的方式占有处理器一段时间(时间片)后释放、等待。

注:抢占的含义就在于运行完指定的时间后会强制释放CPU资源。

(2)线程的三种状态,及其切换:

(3)优先级,一般情况下,线程都是有优先级的,这个可以由开发者设定,同时操作系统也会为线程设定优先级,IO密集型线程的优先级大于CPU密集型的线程。IO密集型线程会频繁进入等待状态,不耗CPU。

另外长时间得不到执行的线程也会被提高优先级。

(4)线程模型:大多数操作系统都在内核中对线程进行了支持(内核线程),但是在用户开发的应用程序中的线程(用户态线程)并不一定对应一个内核线程。用户态线程与内核线程的对应关系有三种模型:

一对一、一对多、多对多。一般操作系统API创建的都是一对一线程,如windows的createTread();

二、编译与链接

1.gcc编译过程分解:

(1)预编译的过程包括:展开宏,展开#include引用的h文件(递归进行的),处理条件预编译指令:#if、#ifdef...,删除所有注释,添加行号和文件标识,保留所有#pragma指令。

(2)编译:一系列词法分析、语法分析、语义分析和优化后生成汇编代码文件。

(3)汇编:汇编器将汇编语言转化为机器可以执行的机器指令(汇编后便是目标代码,存在目标文件中)。

(4)链接:将独立编译的源代码模块组装起来,即将模块之间相互引用的地方处理好。

2.编译:扫描(词法分析)->语法分析->语义分析->源代码优化->代码生成->目标代码优化

3.链接:

不同的模块(即不同的文件)之间编译是相互独立的,当文件A调用了文件B中定义的全局变量n时,在编译A的时候,并不知道n的具体地址(前面说的虚拟地址),因此用0代替。当B编译完成后,知道了n的具体地址,链接开始进行时,在A中调用n的地方将n的地址替换回去,这个过程叫做重地位。

库就是一些目标文件的包,最基本的便是系统的运行时库,它是支持函数运行的基本函数集合,我们自己写的程序往往被编译成目标文件后,都要与运行时库链接后运行。

 三.目标文件

1.目标文件就是经过编译后,但未进行链接的那些中间文件(windows下的.obj和linux下的.o,又叫可重定位文件),它的格式和最终的可执行文件格式(windows下的exe和linux下的ELF可执行文件)采用同一种格式。同时动态链接库(windows下的.dll和linux下的.so)和静态链接库(windows下的.lib和linux下的.a)文件都按照可执行文件格式存储。静态链接库稍有不同,它将多个目标文件捆绑在一起形成一个文件并加上一些索引。

2.目标文件组成:

源代码编译后代码存放在代码段(名字为.code或.text),数据存储在数据段(.data),还有只读数据段(.rodata)存放常量用的,如下面程序中的字符常量“%d\n”,这些段也是要按page对齐的,一个简单程序编译后结果如图:

目标文件(或可执行文件等)的file header描述了整个文件的属性:包括文件是否可执行、是静态连接、是动态链接?,入口地址(如果是可执行文件),目标硬件,目标操作系统等信息。还包括一个段表,用于描述文件中各个段的数组,描述了各个段的偏移位置以及属性。初始化的全局变量和局部静态变量存储在数据段,但未初始化的全局变量和局部静态变量存储在一个叫“.bss”的段里,由于未初始化的变量在程序中都默认为0,所以在.data里都存放一些0是没有必要的撒。在程序运行时这些变量是要占内存的,但在文件中,我们只记录所有未初始化的全局变量和局部静态变量所需空间的总和,.bss段相当于是位置的预留,并没有内容,在文件中不占据空间。bss是不占用.exe文件空间的,其内容由操作系统初始化(清零),比如int a[100],在可执行文件中没有记录100个0,而只是记录了a符号和a所用内存的大小,程序开始运行后,才会在内存中申请这么大的地方。bss段的大小存储在段表里。

将数据与指令(函数)分开存储,便于分开装载,便于同一程序多个副本同时运行时指令的共享,但数据的独立,以节省内存。

3.ELF可执行文件格式(linux下的可执行文件就是这个格式,windows下是PE,他们都是COFF的变种,所以很类似)。

(1)文件头(ELF header)包含:ELF魔数(确认文件类型,ELF的16进制型)、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型(可重定位文件,即编译后形成的目标文件(.o),可执行文件,共享目标文件)、硬件平台、硬件平台版本、入口地址、程序入口和长度等。

(2)段表(header section table),描述ELF各段的信息(段名、段的长度、在文件中的偏移、读写权限及其他属性),编译器、链接器和装载器都是依靠段表来定位和访问段的属性的。其结果如下图:

段表的字段包括(依次为:名字、类型、段的标志位(是否可写?可执行?需要分配空间?)、地址、偏移、大小、段的链接信息(sh_link/sh_info)。。。):

(3)重定位表:.rel.text,专门记录需要重定位的位置,如果.data中有数据需要重定位,重定位表为.rel.data

4.符号表(段名:.symtab):链接的关键,尤其是全局变量和函数的符号名

表示一个符号的结构体(Elf32_Sym):

5.强符号与弱符号

(1)对于C/C++来说,编译器默认函数以及初始化了的全局函数为强符号,未初始化的全局变量为弱符号。位于不同文件的两个强符号名字相同,链接时会报错。GCC中_attribute_((week))可以定义任何一个强符号为弱符号:

(6)强引用与弱引用

链接时,如果没有找到该符号,会报符号未定义错误,这就是一种强引用,相反,不会报错则为弱引用,对于未定义的弱引用,链接器默认它为0.

四.静态连接

1.静态链接就是将几个输入目标文件加工合并成一个输出目标文件,那么输出的目标文件中的空间是如何分配给几个输入目标文件的呢?

首先,需要解释一下这里的分配地址与空间,这里的地址与空间有两层含义:一.输出到可执行文件的中的空间;二.装载后的虚拟地址中的虚拟地址空间。对于包含实际数据的段如.text和.data等,他们在文件和虚拟空间中都要分配空间,因此他们在这两者中都存在。而对于.bbs这样的段来说,分配的意义仅限于虚拟地址空间的分配,因为它在文件中并没有内容。事实上,我们这里说到的空间分配只关注于虚拟地址空间分配(链接时才分配),这个关系到链接器关于地址计算的步骤。

有2种分配方案:

(1)按序叠加,当输入的文件过多是,会产生很多零散的段,由于段要页对齐的原因,还会产生大量空间浪费。

(2)相似段合并,现在链接器基本都有采用这种策略,这种策略连接步骤分为两步:

第一步:空间与地址分配。获取所有文件所有段的属性和长度,获取所有符号表并统一生成全局符号表,计算合并后各个段的长度与位置,并建立映射关系。

第二步:符号解析与重定位(链接的关键)。使用上步的信息,获取文件中的数据与重定位信息,并进行符号解析与重定位、调整代码中的地址。

当一个目标文件声明或调用了其它文件里的变量或函数时,编译时赋予一些如0x00000000或0xFFFFFFFC来代替,链接的时候,将所有的目标文件合并,重定位便会将真正的地址去替换。重定位信息包含在重定位表(段)里。

2.C++语言的特有问题(关于链接器的)

(1)重复代码消除。C++由于支持模板、外部内联、虚函数表等特性,会产生大量的重复代码,如模板,他或许会在不同的编译单元被实例化。重复代码会造成空间浪费、地址易出错、指令运行效率低等。

目前主流的做法是:每个模板的实例都存在单独的段里(如:.temp.add<int>与temp.add<float>),链接的时候判断需不需要将相同代码合并。

目前一些链接器还支持函数级别的链接,在库文件很大,但只用其中一两个函数的时候很实用,其原理是丢弃了用不到的函数,可以有效减小可执行文件的大小,但减慢了编译与链接的速度。(也就是在exe中只加载lib里用到的函数)

3.ABI(application binary interface)指的是符号修饰标准、变量内存布局、函数调用方式等跟可执行代码二进制兼容性相关的内容。其影响因素包括:硬件、语言、编译器、链接器、操作系统等。

由于ABI差异的问题,让不同编译器产生的结果很难链接到一起。 C++最为人诟病的便是这种兼容性问题。

4.链接过程控制的方法有:(1)命令行的方式(如linux下使用Id命令时加 -o,-e等)(2)编译指令存储在目标文件的特定段里(如windows采用的PE/COFF里的.drectve段)(3)使用链接控制脚本

五.windows COFF/PE

1.windows引入了PE格式的可执行文件(是COFF的一种扩展),其实与ELF一样也是来源与COFF,因此PE与ELF格式非常相似,在windows中目标文件默认为COFF,可执行文件为PE。PE文件在装载的时候是被直接映射到虚拟空间中运行,它是虚拟空间的映像,所以PE可执行文件也被称为映像文件。

同时,PE也是基于段的,一个PE文件至少要包含代码段:.code,同时在程序中也可以自定义段名,如:

#pragma data_seg(".FOO")

int global -0;

#pragma data_seg(".data")

表示将全局变量global放在.FOO里,然后再切换回.data. 

以下为COFF格式:

跟前面讲的ELF文件一样,段表仍然记录的是各个段的信息,如:段名、物理地址、虚拟地址、原始数据大小、段在文件中的位置、标志位等等。

2.COFF中的大部分段都与ELF文件相似,唯有两个是独特的:

(1)链接指示信息(.drectiva)包含了编译器想传递给链接器的指令。比如说告诉链接器要使用哪个库。

(2)调试信息。

3.PE文件结构

它与COFF相比,它的开头不是COFF的都文件而是DOS MZ可执行文件头和桩代码(很大部分是历史遗留问题,为了兼容DOS);而COFF原来的头文件被扩展为PE头文件:

 六.可执行文件的装载与进程

1.进程的虚拟地址空间。32位CPU下,程序的虚拟空间不能超过4GB,因为32位CPU只能使用32位指针,其寻址范围为0-4GB。但是从硬件层面来说,原先的32位地址线只能访问4GB物理内存,但Intel公司将地址线拓展为36位,并修改了页映射的方式可以访问更多物理内存(可达64G),这种地址扩展方式叫做PAE。

2.由于通常情况下程序所需内存大于物理内存,因此静态装载肯定不合适,根据程序的局部性原理,我们只需要将常用的部分装入内存即可,即动态装载。有两种动态装载的方法:

(1)覆盖载入,在虚拟内存没发明前广泛使用,现已被淘汰。这种方式内,分割程序的工作是程序员完成的,晕...

(2)页映射,内存被划分为页,程序的地址空间也被划分为页。

3.进程创建的过程:

(1)创建独立的虚拟地址空间。并不是真正的创建一块实在的空间,而是创建映射函数所需的相应的数据结构,比如页目录。

(2)读取可执行文件头,建立虚拟空间与可执行文件的映射关系。其关系如图所示:

很明显,这种映射关系只是保存在操作系统内部的一个数据结构。

需要注意的是这里只是可执行文件和虚拟页之间的映射关系,虚拟页与物理页之间的映射会在页错误时发生。

(3).将CPU指令寄存器设置成可执行文件入口,启动运行。

4.页错误

上面的步骤执行完后,并没有任何程序装载到内存,只是建立了虚拟地址空间与可执行文件的映射,并指定了程序的入口,当CPU开始执行这个入口指令的时候,发现是一个空白页,这被认为是一个页错误,发生页错误后,CPU将控制权交给操作系统,操作系统会查找装载时建立的那个数据结构,找到应该被加载进来的程序虚拟地址空间,并分配物理页面,建立虚拟页与物理页的映射,进而将程序指令加载进来。随着进程的执行,会有页错误不断发生。

5.进程虚拟存储的分布

(1)由于映射都是以页为单位的,因此为了避免空间地址被过多浪费,可以将相同权限的段合并成一个段进行映射。段的权限主要有三种:可读可执行(如:.text等)、可读可写段(如.data,.BBS等)、只读(只读数据段等)。合并的段被称为segment,他们在一起映射之后,在虚拟空间中只有一个地址呦。所以根据section(段)和segment划分可执行文件,可以被称为不同的视图(view),从section来看就是elf的链接视图,从segment来看就是elf的执行视图。

(2)堆和栈。一个进程中的栈和堆都有对应的虚拟空间地址。C语言中的malloc()是从堆里分配的。一个进程包含以下几种VMA(虚拟空间地址),讨论segment,基本也就指这几种VMA:

程序的运行是根据虚拟空间(可执行文件的一种映射)来进行的。

 七.动态链接

1.静态链接对计算机内存和磁盘空间的浪费严重,例如,linux中一个程序所需的C语言静态库至少1M,那么如果机器中运行着100个这样的程序,就要浪费近100M内存空间。如果磁盘中有两千个这样的程序文件,得占2G磁盘。

也就是说同一个目标文件被两个程序都静态链接时,它会在内存和磁盘中出现两个副本,这就是一种浪费。

另外,如果对任意一个静态链接库进行修改,那么整个程序就要全部重新链接,这不利于程序的发布,因此那种万年不会变的库采用动态链接。

2.动态链接的基本思想是将程序的模块拆分成相对独立的模块(主程序(可执行文件)、动态链接库都是模块),当程序运行时才将他们链接在一起形成完整的程序。而不像静态链接一个将所有需要的模块都链接成一个完整的可执行文件。当某一个动态链接库被加载到内存中后,如果其他程序在运行过程中也需要加载它,那么直接链接已经在内存中存在的动态链接库就可以了,这样一个动态链接库总是在内存中只有一个副本。动态链接让程序开发更加灵活。

3.动态链接的过程:程序编程成目标文件->静态链接形成可执行文件,同时将程序中需要动态链接的符号标记一下->将可执行文件与动态链接库(linux中叫动态共享对象dso)装载链接运行。

这里需要注意的是,静态链接(链接器)过程中,动态链接库仍然会被作为输入文件之一,因为链接器将会利用它的符号表将程序中需要动态链接的地方做标记。

4.动态链接库的虚拟地址空间无法预先固定,举个例子:某个程序,模块A(可能是动态链接库或可执行文件)的地址为0x1000-0x2000,模块B的地址为0x2000-0x3000,另外一个人写了一个程序,要调用调用A里的函数,但不调用B,这时对于改程序而言,0x2000-0x3000这块地址是空闲的,于是程序将这块地址分配给一个开发的新的模块C,如果其他程序要调用B和C时会发生严重的目标地址冲突。

为了解决这个问题,程序中动态链接对象的虚拟地址确定应在动态链接库装载完成后,在进行重定位。当动态链接库装载地址确定后,系统会对目标程序中所有标记了动态链接对象的地方进行重定位。例如,当动态链接库被装载到进程虚拟空间的0x10000000地址后,假设其foo()函数位于0x100000100处,这时系统将遍历目标程序的重定位表,将所有调动foo的地方全部替换为0x100000100。这种重定位原理与静态链接的重定位一样,静态链接是:程序编译时不知道的指令地址在连接时重定位,动态链接是:链接后仍不知道的指令地址在装载时重定位。

我觉得上述重定位的过程只是把原来在静态链接时的重定位延后到装载时进行了,其余并没有什么区别。

这种方法有一个问题就是不同的进程之间将不能共享指令部分,原因是重定位时有指令会被修改,比如,模块A调用了模块B,模块B重定位后需要修改A的指令,其他进程调用A时,显然不能共享前面已经装载的A。这样丧失了其节省内存的优势。

5.为了解决上述问题,使用一种地址无关代码的技术。

我们根据各种类型的地址引用方式来分别介绍代码无关技术:

(1)模块内部函数的调用、跳转等。这个最简单,模块内部的函数调用处于同一模块,相对位置固定,可以直接利用相对地址调用。因此这本身就是一种地址无关的代码。

(2)模块内数据访问。虽然代码段占若干页、数据段占若干页,但他们的页之间的相对位置也是固定的。这也是一种地址无关代码。

(3)模块间的数据访问。由于要访问的数据被定义在另外的模块,只能在装载的时候再确定,为了使指令部分的地址与代码无关,将与地址有关的代码全部放到数据段里面。这样数据段(包含一部分代码)就是地址相关的咯,而代码段为地址无关的。ELF文件会在数据段里面建立一些指向需要调用的外部变量的指针数组,称为全局偏移表(GOT),当代码需要该全局变量(或定义在其它模块的静态变量)时可以通过GOT间接引用。每个变量的地址在GOT中占4字节,装载完成的时候,链接器会找到这些变量的地址,将它们填充到GOT。由于GOT放在数据段,所以即使它在模块装载时需要修改也不受影响,因为每个进程中,被调模块的数据段总是有独立的副本。

(4)模块间的调用、跳转等。方法与上面类似,只不过GOT中保存的是函数的位置。

地址无关的共享对象叫做PIC,linux可以在编译时指定参数 -fPIC来实现。同理还可以实现地址无关的可执行文件PIE。

 6.动态链接的过程

1)动态链接器的自举。首先动态链接器本身也是一个共享对象,首要工作是先将自己重定位。

2)装载共享对象。a.动态链接器将可执行文件和链接器本身的符号表合并成全局符号表。b。动态链接器寻找可执行文件依赖的共享对象,并将它们的名字放入到装载集合中。c.链接器开始从装载集合中取出一个名字,打开该文件,将其代码段和数据段映射到进程的内存空间中来。新共享对象的符号表会与全局符号表合并。d.判断如若该共享对象还依赖于其他共享对象则对其进行上述循环。

(3)重定位与初始化。

以上三个过程完成后,动态链接器就会将进程的控制权转交给入口程序。

八.windows下的动态链接

dll文件和exe文件实际上是一个概念。dll与so相比更加注重模块化设计,使得模块之间可以松散耦合、重用和升级,Windows上大量的软件都是通过升级dll进行完善,微软经常将这些升级补丁累计到一个软件升级包,如office、VS、甚至windows操作系统等。

九.程序的内存布局

1.一般来讲,应用程序在内存中有以下“默认”区域:

(1)栈,用于维护函数调用上下文。通常在程序的最高地址处分配,通常有数兆字节。windows默认一个线程是1M的栈

(2)堆,用来容纳应用程序动态分配的内存区域(malloc,new),堆一般比栈大很多几十甚至数百兆。

(3)可执行文件映像,存储可执行文件在内存中的映像(注意,采用的是页映射),包括代码段、数据段等。

(4)保留区,并不是指一个单一的区域,而是内存中受到保护而禁止访问的内存区域总称。

(5)动态链接库映射区,用于映射装载的动态链接库。

下图是一个典型的linux内存分布图:图中的箭头代表大小可变区域的尺寸增长方向。

 

2.栈。通常保存了一个函数调用所需要的维护信息,包括:

(1)函数的返回地址与参数。

(2)临时变量。包括函数的非静态局部变量,以及编译器生成的临时变量。

(3)保存的上下文。包括函数调用前后需要保持不变的寄存器。

每一个函数都有一块栈区,我们称之为栈帧。

下面说说函数p调用函数q时的具体情况。当执行call q(y1)时,会为函数q创建一个新的栈帧,具体过程是:先保存上一帧的地址,如果有返回值的话为返回值分配存储空间,然后保存返回地址。然后为y1分配空间并把它初始化为调用q时给的参数。接着分配另一个参数的空间y2,这个参数用于在函数内部计算。

3.堆。申请的堆在询空间中是连续的,但在物理空间中就不一定是连续的了。

十.运行库

1.C/C++程序运行步骤:

(1)操作系统创建进行,将控制权交给入口函数,注意这里入口函数指的并不是main函数,往往是运行库中的某个入口函数。

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

(3)入口函数在初始化完成后调用main函数,执行程序主体。

(4)main函数执行结束后,返回入口函数,进行各种清理工作。

posted on 2015-10-25 21:56  Wonder奇迹奇迹  阅读(640)  评论(0编辑  收藏  举报