赞
踩
编译包含预处理、编译、汇编和链接
预处理主要处理那些源代码中以#开头的预编译指令,“#include”、“#define”、“#if”、“#ifdef’、”#pragma"
接着由编译程序将程序输出为汇编语言的文件,再由汇编器将汇编代码转换成机器可执行的指令。经过预编译、编译和汇编后,输出了一个目标文件即.o文件。
最后再由链接器将所有目标文件(.o文件)链接成一个可执行文件。
实际上,“C语言代码–汇编代码–机器码”这个过程,在我们的计算机上进行的时候是由两部分组成的。
第一个部分由编译(Compile)、汇编(Assemble)以及链接(Link)三个阶段组成。在这三个阶段完成之后,我们就生成了一个可执行文件。第二部分,我们通过装载器(Loader)把可执行文件装载(Load)到内存中。CPU从内存中读取指令和数据,来开始真正执行程序。
现代流行的操作系统分Windows和Linux两种,因此我们主要介绍这两种平台下的文件格式。在Windows下的可执行文件格式称为PE(Portabe Erectabe),在Linux下则称为ELF (Executable Linkable Format),其实它们都是COFF(Common file format)格式的变种。ELF(Executable and Linkable Format)是一种通用的文件格式,用于表示可执行文件、动态链接库和目标文件。它是一种格式化的文件格式,用于表示二进制文件,通常以.elf扩展名结尾。ELF文件包含一个程序头表(Program Header Table),用于描述文件中的段(section)和符号(symbol)。ELF文件还包含一个符号表,用于描述程序中定义的符号及其地址。
不光是可执行文件,动态链接库(DLL,Dynamic Linking Library)和静态链接库(Static Linking Library)都是按照可执行文件格式存储的。
静态链接库稍有不同,它把很多目标文件捆绑在一起形成一个文件,可以简单把它理解为一个包含很多目标文件的文件包。
一个简单的例子:
File Header |
---|
.text section 程序指令段 |
.data section 已初始化数据段 |
.bss section 未初始化数据段 |
我们先以目标文件为例,来举一个简单的文件ELF结构。目标文件是最常见的编译单位,它将指令代码、数据以Section的形式存储在文件中。
还有很多类似的section
.bss:包含程序运行时未初始化的数据(全局变量和静态变量)。当程序运行时,这些数据初始化为0。
.data和.data1,包含初始化的全局变量和静态变量。
.dynamic,包含了动态链接的信息,包括链接器地址、需要的动态库、段地址信息,类型为SHT_DYNAMIC。
.dynstr,包含了动态链接用的字符串,通常是和符号表中的符号关联的字符串,类型为SHT_STRTAB。
.dynsym,包含动态链接函数符号表和地址,没有地址的则为0,标志SHF_ALLOC,类型为SHT_DYNSYM.
.fini,正常结束时要执行的析构程序,类型为SHT_PROGBITS。
.got,全局偏移表(global offset table),类型为SHT_PROGBITS。
.hash,包含符号hash表,用于快速查找函数名的。标志SHF_ALLOc,类型为SHT_HASH。
.init,程序运行或加载时初始化程序。类型为SHT_PROGBITS。
.interp,该节内容是一个字符串,指定了程序链接器的路径名。如果文件中有一个可加载的segnent包含该节,属性就包含SHE_ALLOC,否则不包含。类型为SHT_PROGBITS.
.plt过程链接表(Procedure Linkage Table),类型为SHT_PROGBITS.
.rodata和.rodata1,包含只读数据,组成不可写的段。标志SHF_ALLOC,类型为SHT_PROGBITS。
.shstrtab,包含section的名字,真正的字符串存储在.shstrtab中,其他都是索引。类型为SHT_STRTAB。
.strtab,包含字符串,通常是符号表中符号对应的变量名字和函数名。类型为SHT_STRTAB。
.Symtab, Symbol Table,符号表。包含了所有符号信息,包括变量、函数、定位、重定位符号定义和引用时需要的信息。符号表是一个数组,Index0第一个入口,它的含义是undefined
.rela.dyn,包含了除PLT以外的 RELA 类型的动态库重定向信息。
.rela.plt,包含了PLT中的RELA类型的动态库重定向信息
.rela.text 代码重定位表
.rela.data 数据重定位表
.line,调试时的行号表,即源代码行号与编译后指令的对应表
ELF文件格式把各种信息,分成一个一个的Section保存起来。ELF有一个基本的文件头(File Header),用来表示这个文件的基本属性,比如是否是可执行文件,对应的CPU、操作系统等等。
装载器会把对应的指令和数据加载到内存里面来,让CPU去执行。
装载器需要满足两个要求。
要满足这两个基本的要求,我们很容易想到一个办法。那就是我们可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
我们把指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Adess),实际在内存硬件里面的空间地址,我们叫物理内存地址(Physcal Memor Adress) ,每个进程都拥有一个自己想象的虚拟空间,地址从0到OxFFFFFFFFF (32位设备)
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
操作系统上的使用的内存空间都是虚拟地址空间,而非实际物理空间,它是由操作系统虚构出来的一个地址空间。
这就像是操作系统给了每个进程一个世界那样,在这个世界里,进程可以自由的申请和释放内存,而不需要理会物理内存如何分配和释放。
每个进程中的内存从虚煎地址都从0开始,到0xFFFFFFFF结束,其中有1G的空间专门为内核空间所用,用户空间也做了不同的分段。因此整个虚拟空间地址可以分为:
装载可执行文件的过程:
注意,创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录就可以了。
当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于CPU的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。这种方式,使得我们可以运行那些远大于我们实际物理内存的程序。同时,这样一来,任何程序都不需要—次性加载完所有指令和数据,只需要加载当前需要用到就行了。
程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。这个链接的方式,让我们在写代码的时候做到了“复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。这就好比,假设每个人都有骑自行车的需要,那我们给每个人都生产一辆自行车带在身边,固然大家都有自行车用了,但是马路上肯定会特别拥挤。
如果我们能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊!
这个思略就引入一种新的链接方法,叫作动态链接(Dynamic Link)。相应的,我们之前说的合并代码段的方法,就是静态链接(Static Link)。
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。顾名思义,这里的共享库重在“共享“这两个字。
这个加载到内存中的共享库会被很多个程序的指令调用到。在Windows下,这些共享库文件就是.dll文件,也就是Dynamic-Link Libary(DLL,动态链接库)。在Linux下,这些共享库文件就是.so文件,也就是Shared Object (一般我们也称之为动态链接库)。这两大操作系统下的文件名后缀,一个用了“动态链接”的意思,另一个用了“共享”的意思,正好覆盖了两方面的含义。
我们在开启一个进程时,在装载完执行文件后,进程首先会把控制权交给动态链接器,由它完成所有的动态链接工作,再把控制权交给进程开始执行。
当我们在代码中写下动态库的函数时,在程序模块动态装载时,应该不需要因为装载地址的改变而改变。
所以实现动态链接的基本想法就是把指令中那些需要被修改的部分分离出来,放到数据段去,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
这种方案称为,地址无关代码(PIC,Position-Independent Code)技术方案,通常我们在编译时会加上PIC这个标记,就是告诉编译器,我们这个库是地址无关的。
如果一个库不是以地址无关(PIC)模式编译的,那么毫无疑问,它需要在装载时被重定位,即在启动执行时就需要装载库文件并且重定位所有与库文件有关的函数调用地址。
如果一个库是以地址无关(PIC)模式编译的,那么就不会在装载时对整个库函数相关调用进行重定位,而是会用延迟绑定(PLT)的方式实时定位函数。
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1;//模块内数据访问
b = 2;//外部模块数据访问
}
void foo(){
bar();//模块内函数调用
ext();//外部模块函数调用
}
以上面这段代码为例模块内和模块间的数据访问和函数调用方法,
大致的方案得从编译说起,我们知道在使用printf ,scanf() ,strlen()这样的公用库函数时都需要加载公共库libc.so,但公共库并没有被合并到可执行文件中,也就没有可依赖的地址规则。
所以编译器在编译这些外部函数的时候,其实并不知道它们的调用地址是多少,无法填充真实地址。
但编译器会填充一个地址指向一段动态程序,当这个函数真正被调用时,先调用到动态程序,再由动态程序去寻找真正的调用地址,最后再调用真实地址的函数。
这种方法就是延迟绑定(PLT)程序链接表(Procedure Link Table)
全局偏移表(GOT,Global Offset Table)
在动态链接对应的共享库,我们在共享库的data section里面,保存了一张全局偏移表((GOT,Global Ofiset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。所有需要引用当前共享库外部地址的指令,都会查询GOT,来找到当前运行程序的虚拟内存里的对应位置。而GOT表里的数据,则是在我们加载一个个共享库的时候写进去的。
不同的进程,调用同样的lib.so,各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。
我们的GOT表位于共享库自己的数据段里,GOT表在内存里和对应的代码段位置之间的偏移量,始终是确定的。这样,我们的共享库就是地址无关的代码,对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时,生成的各不相同的GOT表,来找到它需要调用到的外部变量和函数的地址。
API是Application Programming Interface的缩写,即应用程序接口。 一个API是不同代码片段的连接纽带。它定义了一个函数的参数,函数的返回值,以及一些属性比如继承是否被允许。 因此API是用来约束编译器的:一个API是给编译器的一些指令,它规定了源代码可以做以及不可以做哪些事。
ABI是Application Binary Interface的缩写,应用程序二进制接口。 一个ABI是不同二进制片段的连接纽带。 它定义了函数被调用的规则:参数在调用者和被调用者之间如何传递,返回值怎么提供给调用者,库函数怎么被应用,以及程序怎么被加载到内存。 因此ABI是用来约束链接器的:一个ABI是无关的代码如何在一起工作的规则。 一个ABI也是不同进程如何在一个系统中共存的规则。 举例来说,在Linux系统中,一个ABI可能定义信号如何被执行,进程如何调用syscall,使用大端还是小端,以及栈如何增长。
ABI是系统与应用之间的协议. 一个BINARY(EXEC, LIB)必需符合ABI才能在相应的系统上运行。比如在PC上不管用什么样的COMPILER, 只要产生符合LINUX的ELF文件, 用相应的INSTRUCTION SET(比如INTEL, PPC, SPARC),就可以在一个LINUX机器上运行, 调用系统或别人的LIB。
ABI定义了BINARY的文件格式、内容、 以及装载/卸载程序的要求, 函数调用时参数传递规则, 寄存器, 堆栈的使用等。
保持一个稳定的 ABI 要比保持稳定的 API 要难得多。比如,在内核中 int register_netdevice(struct net_device *dev) 这个内核函数原型基本上是不会变的,所以保持这个 API 稳定是很简单的,但它的 ABI 就未必了,就算是这个函数定义本身没变,即 API 没变,而 struct net_device 的定义变了,里面多了或者少了某一个字段,它的 ABI 就变了,你之前编译好的二进制模块就很可能会出错了,必须重新编译才行。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。