第一章:基础知识
线程:
linux的多线程:
Windows对进程和线程的实现如同教科书一般标准。Linux下执行实体都是任务(task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过linux下不同的任务之间可以选择共享内存空间,这样可以模拟进程线程的概念。Linux下用以下方法创建一个新的任务:
fork()函数调用之后,新的任务将启动冰河本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务的pid,而新任务的fork将返回0。fork并不复制原任务的内存空间,而是和原任务共享一个写时复制(Copy On Write,COW)的内存空间。COW是指两个任务可以同时自由地读取内存,但任意一个任务试图对任务进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他任务的使用。
fork只能够产生任务的镜像,需要exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像,因此fork在产生一个新任务之后,新任务可以调用exec来执行新的可执行文件。
fork和exec通常用于产生新任务,而如果要产生新线程,则可以使用clone。使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件。
同步与锁:
二元信号量:只有两种转态,占用和非占用,同时只能被一个线程获取
多元信号量:代表资源数量,可以同时被多个线程获取
信号量可以用来调度线程,解决同步问题,比如消费者生产者问题,可以用信号量实现,必须要生产者生产,对应信号量++,然后消费者才可以消费,对应信号量--;
互斥量:和二元信号量类似,不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量只能由获取它的线程释放;
临界区:临界区和互斥量与信号量的区别在于。互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图获取该锁是合法的,然而临界区的作用范围仅限于本进程,其他进程无法获取该锁
读写锁:致力于解决特定的同步场合,对于一段数据,多个线程同时读取是没有问题的,读写锁有两种获取方式--共享的和独占的。
当锁是自由状态时,任何一种方式和获取都能成功;
当锁被共享方式获取时,其他线程已共享方式获取可以成功,但当有线程试图以独占方式获取时,只能等待锁恢复自由状态;
当锁被独占方式被获取时,无论什么方式被获取都会失败
条件变量:是一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说条件变量可以让许多线程一起等待某个事件的发生,当事件发生,所有的线程可以一起恢复执行。
线程模型:
一对一模型:一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在),这种模型的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此外一对一模型在多处理器的系统上有更好的表现;缺点是:操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制,还有一点许多操作系统内核线程调度时,开销较大,导致用户线程的执行效率下降。
多对一模型:线程之间的切换由用户态代码进行,好处是高效的上下文切换和几乎无限制的线程数量,虽然线程切换快很多,但是如果一个线程阻塞,那么所有的线程都无法执行
多对多模型:综合了一对多和多对一模型的优点,是一个较为综合的方案。
第二章:编译和链接
程序从源代码到执行一般要经过四个过程:预处理、编译、汇编和链接。
预编译主要处理规则:
- 将所有的“#define”删除,并且展开所有的宏定义;
- 处理所有的条件预编译指令,比如“#if”,“#ifdef”,“#elif”,“#else”,“#endif”;
- 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释“//”和“/* */”;
- 添加行号和文件名标识,比如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
- 保留所有的#pragma 编译器指令,因为编译器需要使用他们;
经过编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,也可以查看预编译后的文件来确定问题。
编译过程:
编译就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。
gcc只是后台程序的包装,会帮我去调对应语言的编译器,c语言的ccl,c++的cclplus,java的jcl等
编译过程一般分为六步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
汇编过程:
汇编器是将汇编测试代码转成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,他没有复杂的语法,没有复杂的语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。
重新计算各个目标的地址过程被叫做重定位;
符号随着汇编语言的诞生而出现,用来表示一个地址,可能是一段程序的起始地址,也可以是一个变量的起始地址。
在一个程序被分割成多个模块后,这些模块之间最后如何组合形成一个单一的程序是需要解决的问题。模块之间如何组合的问题,可以归结为模块之间如何通信的问题,分为两种:一种是模块间的函数调用,另一种是模块之间的变量访问。函数访问和变量访问都是要知道对应的地址,也就是模块间符号的引用,那就需要进行链接。人们把源代码独立的编译,组装模块的过程就是链接的过程。
链接过程主要包括地址和空间分配、符号决议和重定位等步骤。
符号决议也被叫做符号绑定、名称绑定或者地址绑定,指令绑定。决议倾向于静态链接,绑定倾向于动态链接。
链接的原因和过程:
加入程序模块main.c中使用另外一个模块func.c中的函数foo(),那么在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于单独编译的问题,所以在编译阶段没办法确定,这个工作就交给了链接器。
在链接阶段,链接器会根据你所引用的符号foo,自动去相应的func.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让他们的目标地址为真正的foo函数的地址,这就是静态链接的基本过程和作用。
第三章 目标文件
目标文件就是源代码编译后但未进行链接的中间文件(windows下的.obj和linux下的.o),他跟可执行文件的内容和结构很类似,windows下的称为PE-COFF,linux下的称为ELF。不光是可执行文件,动态链接库和静态链接库都是按照可执行文件格式存储。
目标文件一般包括编译后的机器指令代码、数据、符号表、调试信息和字符串等。一般编译后的信息分为两种:程序指令和程序数据。一般情况下,目标文件会将这些信息按不同的属性,以“节”的形式存储,也叫“段”,
程序指令:
程序源代码编译后的机器指令经常被放在代码段,代码段常见的名字有“.code”或“.text”;
程序数据:
已初始化的全局变量和局部静态变量经常放在数据段,数据段一般叫做“.data”;
未初始化的全局变量和局部静态变量放在“.bbs”段,它没有内容,在文件中也不占空间。
上图的为ELF格式的可执行文件(目标文件),ELF文件的开头是一个“头文件”,他描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接,及入口地址、目标硬件、目标操作系统等信息。文件头还包括一个段表,段表是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性,从段表可以得到每个段的所有信息。文件头后面就是各个段的内容。
指令段和数据段分开存储的原因:
- 指令只读,数据可读可写,防止指令被破坏
- 缓存体系是分开的,这样可以提高缓存命中率
-
当系统中运行多个程序的副本时,指令都是一样的, 可以做到数据隔离,同时共享指令也可以节省大量内存空间
static int x1=0;
static int x2=1;
x1会被放到“.bbs”段,而x2会被放到“.data”段,因为x1值为0,被认为是未初始化的;
正常情况下,gcc编译出来的目标文件中,代码会被放到“.text”,全局变量和静态变量会被放到“.data”段和“.bbs”段。如果希望变量或者部分代码能够放到指定的段中去,去实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。GCC提供了一个扩展机制,是的程序员可以指定变量所指的段:
__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo(){�}
我们在全局变量或函数之前加上“attribute((section("name")))”属性就可以把相应的变量或函数放到以“name”作为段名的段中。
ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF中和段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。接着将详细分析ELF文件头、段表等ELF关键的结构,还有一些ELF中辅助的结构,比如字符串表、符号表等。
文件头
ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量。
最开始的四个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c、0x46,第一个字节对应ACSII字符里面DEL控制符,后面三个字节刚好是ELF这个三个字母的ASCII码。这四个字节又被称为ELF文件的魔数。这汇总魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
段表
段表保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,他描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的便宜、读写权限及段的其他属性,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性。
段的类型:
段的名字只在链接和编译的过程中有意义,但他不能真正的表示段的类型。我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型的段的标志位。
段的标志位:
表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。
系统保留段
段的链接信息:
如果段的类型是与链接相关的(包括动态链接和静态链接),比如重定位表、符号表等,意义如下表。其他类型的段,这两个成员没有意义。
重定位表:
链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址引用的位置。对于每个需要重定位的代码段和数据段,都有一个响应的重定位表。
字符串表:
ELF中用到了很多字符串,比如段名、变量名等,因为字符串的长度往往是不定的。常见的做法是把字符串集中起来存放,然后使用字符串在表中的偏移来引用字符串。
符号:
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。函数和变量被称为符号,函数名和变量名就是符号名。
每一个目标文件都有一个符号表,每一个定义的符号有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是他们的地址。
符号一般分为以下几种类型:
● 定义在本目标文件的全局符号,可以被其他目标文件引用。比如SimpleSection.o里面的“func1”、“main”等;
● 在本目标文件中引用的全局符号,却没有定义在本目标文件中,一般叫做外部符号,比如“printf”;
● 段名,由编译器产生,他的值就是该段的地址;
● 局部符号,这类符号只在编译单元内部可见,比如SimpleSection.o里面的“static_var”、“static_var2”等;调试器可以使用这些符号来分析程序或崩溃时的核心存储文件,这些局部符号对于链接过程没有作用,链接器往往忽略他们;
● 行号信息,及目标文件指令与源代码中代码行的对应关系,他也是可选的。
链接过程只关心全局符号的相互粘合,局部符号、段名、行号等都是次要的。
符号表的结构:
符号类型和绑定信息,该成员低四位表示符号的类型,高28位表示符号绑定信息。
c++修饰符号:
强大而复杂的c++拥有类、继承、虚机制、重载、名称空降等这些特性,使得符号管理十分复杂。后来发明了符号修饰和符号改编的机制,来解决这个问题。
函数签名:包含了一个函数的信息,包括函数名、他的参数类型、他所在的类和名称空间及其他信息。
在编译器及链接器处理符号时,使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称。(变量的类型没有被加入到修饰名称中)
Microsoft提供了一个UnDecorateSymbolName()的API,可以将修饰后的名称转换成函数签名。
c++编译器会将在extern C的大括号内部的代码当做c语言代码处理。
extern “C” {
int var;
}
对于c/c++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
强引用和弱引用
目前我们看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,他们必须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。与之相对应的还有一种弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一摸一样,只是杜宇未定义的若医用,链接器不认为他是一个错误。
在GCC中,可以通过使用“attribute((weakref))”这个扩展关键字来声明对一个外部函数的引用为弱引用:
__attribute__((weakref)) void foo();
int main()
{
foo();
}
将上述代码编译成一个可执行文件,gcc并不会报链接错误。但是当我们运行这个可执行文件时,会发生运行错误。因为当main函数视图调用foo函数时,foo函数的地址为0,于是发生了非法地址访问错误。改进的例子为:
__attribute__((weakref)) void foo();
int main()
{
if(foo) foo();
}
这种弱符号和弱引用对于库来说很有用,比如库中定义的弱符号可以被用户定位的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用,去掉某些功能模块,程序也可以正常链接,只是缺少了相应功能,使得应用程序更加容易裁剪和组合。
调试信息
编译器会将源代码和目标代码之间建立对应关系,调试信息占用的空间很大,linux下可以使用“strip”命令去掉ELF文件中的调试信息。
ELF文件有代码段、数据段和BBS段等与程序运行密切相关的段结构;此外还有文件头、段表、重定位表、字符串表、符号表、调试表等相关结构描述用于描述目标文件。
第四章:静态链接
链接过程:
对于链接器来说,整个链接过程,就是将几个目标文件加工后合成一个输出文件。
一般,链接过程分为链两个步骤:
第一步:空间与地址分配 扫描所有的输入目标文件,并且获的他们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将他们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
第二步:符号解析与重定位 使用第一步收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。这是链接过程的核心。
链接前目标文件中的符号都没有地址,在链接之前,符号的地址都是假的,链接之后,会分配对应的虚拟空间地址。
在linux下,ELF可执行文件默认从地址0x08048000开始分配
重定位表专门用来保存与重定位相关的信息。对于可重定位的ELF文件来说,他必须包含有重定位表,用来描述如何修改响应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF中的一个段。比如代码“.text”如果有要被重定位的地方,那么会有一个相对应叫“.rel.text”的段保存了代码段的重定位表。
链接器本身不知道符号的类型,当定义的多个符号定义类型不一致时如何处理,主要分为以下三种情况:
● 两个或两个以上强符号类型不一致
● 有一个强符号,其他都是弱符号,出现类型不一致;
● 两个或两个以上弱符号类型不一致
第一种情况本身就不不支持,无需额外处理,剩下两种情况需要common机制处理(common block):针对符号都是弱符号的情况,以其中需要空间最大的那个为最后的输出空间,然后其他弱符号共用这个空间,如果有一个符号是强符号,那么最终输出结果中的符号所占空间与强符号相同;如果链接过程有弱符号大小大于强符号,那么ld链接器会报警告⚠️。
c++相关
c++的语言特性使之必须由编译器和链接器共同支持才能完成工作。主要分为两个方面,一个是c++的重复代码消除,一个是全局构造与析构。另外由于c++语言的各种特性,如虚函数、函数重载、继承、异常等,使得它背后的数据结构异常复杂,这些数据结构在不同的编译器和链接器之间相互不能通用,使得c++程序的二进制兼容除了很大的问题。
重复代码消除:
C++编译器会在很多时候产生重复代码,比如模板、外部内联函数和虚函数表都有可能在不同的编译单元里生成相同的代码。拿模板距举例,当一个模板在不同的编译单元被实例化成同一类型之后,必然会产生重复的代码。那么就会带来几个问题:
● 空间浪费,冗余
● 地址较容易出错,有可能两个指向同一个函数的指针会不相等
● 指令运行效率低,因为现在的CPU会对指令和数据进行缓存,如果同样一份指令有多个版本那么指令cache的命中率就会降低。
主流编译器解决方法:将每个模板的实例代码都单独的存放在一个段里,每个段只包含一个模板实例,比如模板函数add<T>(); 某个编译单元以int和float类型实例化了该模板,假设名字叫做.temp.add<int> 和 .temp.add<float>这样,如果其他编译单元也存在这样的模板实例段,那么链接器在组中链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。
函数级别链接:
让所有的函数像前面的模板函数一样单独保存到一个段里面,当链接器要用到某个函数时,就将他合并到对应的文件中,而不是将整个目标文件都合并。这种做法可以很大程度减小输出文件的长度,减少空间浪费。
全局构造与析构:
c/c++程序从main开始执行,在main函数被调用之前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化,线程子系统等。c++的全局对象的构造函数在main之前被执行,c++全局对象的析构函数在main之后被执行。
ELF文件中有链各个特殊的段:.init和.fint
main函数执行前会执行.init段,main函数返回后会执行.fint段。
● .init 该段里保存的是可执行指令,它构成了进程的初始化代码。因此当一个程序开始运行时,在main函数被调用之前,glibc的初始化部分安排执行这个段中的代码
● .fini 该段保存着进程终止代码指令。因此当一个程序的main函数正常退出时,glibc会安排执行这个段中的代码
ABI & API
API往往值源代码级别的接口,而ABI是二进制层面的借口,ABI的兼容程度比API更严格。比如一台x86机器,一台arm机器,他们都可以使用printf函数,但是底层printf的参数和堆栈信息在不同的机器上一定是不同的。
静态库链接
一个静态库是一组目标文件的集合。
链接就是将目标文件链接成可执行文件,输入目标文件中的各个段被合并输出到文件中,链接器为他们分配在输出文件中的空间和地址。一旦输入段的最终地址被确定,接下来就可以进行符号的解析和重定位,链接器会把各个输入目标文件中对于外部符号的引用进行解析,把每个段中须重定位的指令和数据进行“修补”,使他们都指向正确的位置。
第五章 windows PE/COFF
windows下的可执行文件和目标文件格式PE/COFF。PE/COFF与ELF文件非常相似,他们都是基于段的结构的二进制文件格式。Windows下最常见的目标文件格式就是COFF文件格式。COFF文件有一个很有意思的段叫做“.drectve段”,这个段中保存的是编译器传递给链接器的命令行参数,可以通过这个段实现指定运行库等功能。
第六章 可执行文件的装载与进程
进程和程序:
程序是一堆指令,进程是动态的概念,是程序运行的过程。
32位硬件平台有0-2^32-1 即4GB内存空间,64位硬件平台有0-2^64-1 即17179869184GB的控件
C语言指针与虚拟空间位数相同 32位平台为32位,即4字节;64位平台为64位,即8字节。
32位平台下的4GB虚拟空间,程序也不能任意使用。0xC0000000 - 0xFFFFFFFF共1GB为系统空间,用户进程最多使用0x00000000 - 0xBFFFFFFF共3GB虚拟空间。
进程只能使用操作系统分配给进程的地址,否则就会被强制关闭,linux下报错“Segmentation fault”
覆盖载入和页映射
覆盖载入:
用一个覆盖管理器辅助管理哪些模块何时应该驻留内存,何时应该被替换掉。
页映射:
把内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,以后所有的装载和操作的单位就是页。然后根据页替换算法换不同的页进来执行。
进程的建立:
创建虚拟地址空间,不设置页映射关系,(这个页映射关系指的是虚拟空间到物理内存的映射关系)
读取可执行文件头,建立虚拟空间与可执行文件的映射关系。(这里指的是虚拟空间和可执行文件的映射关系)
将CPU指令寄存器设置成可执行文件入口,启动运行。
页错误:
进程建立完成后,其实可执行文件的真正指令和数据并没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系。假设在上面的例子中,程序的入库地址是0x804800,即刚好是.text段的起始地址。当CPU开始执行时,发现这是一个空页面,于是就认为这是一个页错误。CPU将控制权交给操作系统,操作系统有专门的的页错误处理例程来处理这种情况。这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统查询数据结构,找到对应的虚拟内存段(VMA),计算出响应的页面在可执行文件中的偏移量,然后再物理内存汇总分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再交回给进程,进程从刚才页错误的位置重新开始运行。
进程虚存空间分布:
ELF文件中,段的权限往往只有为数不多的几种组合如下:
● 以代码段为代表的权限为可读可执行的段
● 以数据段为代表的权限为可读可写的段
● 以只读数据段为代表的权限为只读的段
于是为了避免一个段单独占用一页可能造成的内存浪费问题,将相同权限的段合并到一起作为一个段进行映射。
ELF文件引入一个概念“Segment”,一个“Segment”包含一个或多个属性类似的段,系统正是按照“Segment”来映射可执行文件的。
使用readelf命令查看ELF的“Segment”,正如描述“section”属性的结构叫做段表,那么描述“Segment”的结构叫做程序头。
》readelf -l SectionMapping.elf
"Segment"和"Section"是从不同角度来划分同一个ELF文件,这个在ELF中被称为不同的视图。"Section"的角度看ELF文件就是链接视图,"Segment"的角度看就是执行视图。
ELF可执行文件有一个专门的数据结构叫做程序头表用来保存"Segment"的信息,结构如下:
对应的含义如下:
这里有一个有意思的地方:
对于"LOAD"类型的"Segment"来说,p_memsz的值不可以小于p_filesz,否则就是不合常理的。但是p_memsz的值确实有可能大于p_filesz,这代表特殊的含义。如果p_memsz的值确实大于p_filesz,那么多余的部分全部填充为0,这样做的好处是,在构造ELF可执行文件时,就不需要额外设立BBS的"Segment"了,可以把数据"Segment"的p_memsz扩大,那么额外的部分就是BBS,因为数据段和BBS段的唯一区别就是:数据段从文件中初始化内容,而BBS段的内容全都初始化为0。
堆和栈:
事实上进程的栈和堆分别对应一个VMA,如下:
特别的,vdso的地址位于内核空间,事实上他是一个内核模块,进程可以通过访问这个VMA和内核进行一些通信。
段地址对齐:
假设有三个Segment需要装载,SEG0,SEG1,SEG2
理论映射如下:
但是这样十分浪费空间,产生很多内存碎片,整个可执行文件的总长度只有12014字节,却占据了5页,20480字节,空间使用率只有58.6%
为了解决这个问题,让各个Segment接壤的部分共享一个物理页面,如下图
进程栈初始化:
进程刚启动的时候,需要知道一些系统环境和进程的运行参数,操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。
进程启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main()函数,也就是我们熟知的main()函数的两个argc和argv两个参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组。
linux内核装载ELF过程:
首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
在进入execve()系统调用之后,linux内核就开始进行真正的装载工作,在内核中,execve系统调用响应的入口是sys_execve()。sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节。这里的128个字节被用来判断文件的格式。每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头四个字节,常常被称作魔数,通过对魔数的判断可以确定文件的格式和类型。比如ELF的可执行文件的头四个字节为0x7F,'e'、'l'、'f';而java的可执行文件格式的头四个字节为'c'、'a'、'f'、'e';如果被执行的是shell脚本或者perl、python等这种解释性语言的脚本,那么他的第一行往往是"#!/bin/bash" ,"#!/usr/bin/perl","#!/usr/bin/python",这时候前两个字节的'#','!'就构成了魔数,系统机会根据魔数,以确定具体的解释程序的路径。
ELF可执行文件的装载过程,主要步骤如下:
● 检查ELF可执行文件格式的有效性,比如魔数、程序头表中segment的数量
● 寻找动态链接的".interp"段,设置动态链接器路径
● 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据
● 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址
● 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式。对应静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中的e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。
综上,当sys_execve()的系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。
第七章:动态链接
静态链接的问题:
- 内存中会存在某一个库的多个副本,浪费空间;
- 静态库的更新,需要引起整个程序的重新下载安装更新,过程太复杂
动态链接:
等程序运行起来再进行链接,把这个过程推迟到运行时再进行。基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时,才将它们链接在一起形成一个完整的程序。
- 节省内存;
- 减少物理页面的换入换出,增加CPU缓存的命中率;
- 升级方便,只需要升级库就行;
- 同一程序内,模块之间耦合度小,便于不同的开发者和开发组织之间使用不同的语言独立进行开发和测试;
- 程序可以在运行时动态的选择加载各种程序模块,可以用来制作插件(比如某个公司完成了某个产品,他按照一定的规则制定好程序的接口,其他公司或者开发者按照接口编写符号要求的动态链接文件,该产品程序可以动态的载入各种由第三方开发的模块,在程序运行时动态的链接,实现程序功能的扩展)
- 加强程序的兼容性,一个程序在不同的平台运行是可以动态的链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,消除程序对不同平台之间依赖的差异性。(但实际还存在不少问题,尤其是程序兼容性问题)
缺点:
常见的问题,新模块与旧模块之间的兼容性问题,会导致程序不能运行。早期windows这种现象尤其严重,由于缺少一种有效的共享库版本管理机制,使得用户经常出现新程序安装完之后,其他某个程序无法正常工作,被称为“DLL Hell”。
动态链接的基本实现:
动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。有一点不同的是,并不是直接使用目标文件来进行动态链接的,虽然理论上是可行的,但实际中动态链接的实现方案与直接使用目标文件稍有差别。
本质上讲普通可执行程序和动态链接库中都包含指令和数据,这一点没有区别,在使用动态链接库的情况下,程序本身被分为了程序主要模块和动态链接库。在inux中常用的c语言库的运行库glibc,他的动态链接形式保存在“/lib”目录下,文件名叫做“libc.so”。整个系统只保留一份C语言库的动态链接文件“libc.so”,而所有的c语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的动态链接库装载 到进程的地址空间,并且将程序中所有未决议的符号绑定到响应的动态链接库中,并进行重定位工作。
系统在装载程序的时候对程序的指令和数据中对绝对地址的引用进行重定位,这种重定位要比静态链接中的重定位简单得多,因为整个程序是整体加载的,其中的相对位置是固定的。这种被叫做装载时重定位,也叫做基址重置。
但这种方法没办法解决共享对像的问题,因为指令部分无法在多个进程之间共享。通过引入地址无关代码技术(PIC)来解决指令共享的问题——把指令中那些需要修改的部分分离出来,跟数据部分放在一起,这样的指令部分就可以保持不变。
把共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用和外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样得到四种情况:
第一种是模块内部的函数调用、跳转等;
相对地址,无需特殊处理
第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
当前指令的地址加上相对偏移量
第三种是模块外部的数据访问,(比如其他模块中定义的全局变量。)
把地址相关的部分放到数据段中。在数据段建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT)。当指令需要访问变量b时,程序会先找到GOT,根据变量找到对应项的目标地址,由于GOT段放在数据段,所以他可以在模块装载是被修改,且每个进程都可以有独立的副本,相互不受影响。
第四种是模块外部的函数调用、跳转等。
使用和第三种类似的办法,GOT记录对应目标函数的地址。
动态链接比静态链接的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址,如此一来,程序的运行速度就会减慢。
可以使用延迟绑定的办法来进行优启动速度,当函数第一次被用到时才进行绑定,如果没有用到则不进行绑定。
动态链接的步骤和实现:
动态链接的步骤分为三步,先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。
动态链接器本身是静态链接的,不能依赖于其他共享对象
动态链接器可以是PIC的也可以不是,但如果是PIC的会简单一些,
动态链接器ld的装载地址跟一般的共享对象没有区别,即为0x00000000,这个装载地址是一个无效的地址,作为共享库,内核在装载时会选择一个合适的地址。
动态库:
动态库跟一般的共享对象没有区别,主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,对于程序本身是透明的,而动态库的装载则是通过一系列有动态链接器提供的系统API,具体的讲,有四个函数:打开动态库、查找符号、错误处理以及关闭动态库。程序可以通过这几个API对动态库进行操作。
dlopen()
用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。
dlopen的加载过程基本跟动态链接器一致,在完成装载、映射和重定位之后,就会执行“.init”段的代码然后返回
dlsym()
核心部分,通过这函数找到所需要的符号
dlerror()
通过这个函数可以知道上一次调用是否成功,如果失败会返回对应的错误消息;
dlclose()
系统会维持一个加载引用计数器,dlopen的调用响应的计数器+1,dlcose调用响应的计数器减1,只有当计数器减到0时,模块才被真正的卸载掉。,卸载的过程和加载刚好相反,先执行“.finit”段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。
本章我们首先分析了使用动态链接技术的原因,即使用动态链接可以更加有效地利用内存和磁盘资源,可以更加方便地维护升级程序,可以让程序的重用变得更加可行和有效。接着我们介绍了动态链接的基本例子,分析了动态链接中装载地址不确定时如何解决绝对地址引用的题。
装载时重定位和地址无关代码是解决绝对地址引用问题的两个方法,装载时重定位的缺点是无法共享代码段,但是它的运行速度较快:而地址无关代码的缺点是运行速度稍慢,但它可以实现代码段在各个进程之间的共享。我们还介绍了 ELF 的延迟綁定江LT 技术。
接着我们介绍了 ELF 文件中的“.interp”、•“.dyanmic”、动态符号表、重定位表等结构,它们是实现 ELF 动态链接的关键结构。我们还分析了动态链接器如何实现自举、裝载共享对象、实现重定位和初始化过程,实现动态链接。最后我们还介绍了显式动态链接的概念,并且举例展示了如何使用显式运行时链接编写一个程序运行 ELF 共享库中的函数。
第十章 内存
程序的内存布局:
● 栈:栈用于维护函数调用的上下文,离开了栈西数调用就没法实现。在 10.2 节中将对栈作详细的介绍。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。
● 堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc 或 new 分配内存时,得到的内存米自堆里。堆会在10.3 节详细介绍。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。
● 可执行文件映像:这里存储着可执行文件在内存里的映像,在第6 章已经提到过,由装载器在装载时将可执行文件的内存读取或映射到这里。在此不再详细说明。
● 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的,总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如 NULL通常C语言将无效指针赋值为 。也是出于这个考虑,四为。地址上正常情况下不可能有有效的可访问数据.
Q:我写的程序常常出现“段错误(segment faul0〞或者“非法採作,该内存地址不能read/write"的错误信息,这是怎么回事?
A:这是典型的非法指针解引用造成的错误。当指针指向一个不允许读或写的内存地址,而程序却试图利用指针来读或写该地址的时候,就会出现这个错误。在Linux 或Windows 的内存布局中,有些地址是始終不能读写的,例如0地址。还有些地址是开始不允许读写,应用程序必须事先请求获取这些地址的读写权,或者某些地址一开始并没有映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址 (commic),之后才能够自由地读写这片内存。当一个指针指向这些区域的时候,对它指向的内存进行读写就会引发错误。造成这样的最普遍原因有两种:
1.程序员将指针初始化为 NULL,之后却没有给它一个合理的值就开始使用指针。
- 程序员没有初始化栈上的指针,指针的值一般会是随机数,之后就直接开始使用指针。
栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧或活动记录。堆栈帧一般包括以下几个方面:
函数的返回地址和参数
临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
保存的上下文:在函数调用前后需要保持不变的寄存器
调用惯例:
函数的调用和被调用方对函数如何调用有着统一的理解,例如他们双方都一致认同函数的参数是按照某个固定的方式压入栈内,这个就是靠调用惯例决定的。调用惯例一般会规定如下几个方面的内容:
函数参数的传递顺序和方式:参数压栈的顺序
栈的维护方式:出栈由调用方完成还是函数本身完成
名字修饰的策略:修饰函数名
函数返回值传递:
eax寄存器可以用来传递返回值,但如果值本身过大,4字节内,eax传递,4-8字节eax+edx传递,如果大于4字节,则会存放一个地址,最终通过下列代码实现:
堆与内存管理:
brk和mmap内存分配
空闲链表分配内存
第十一章 运行库
入口函数是main吗?
其实不是,操作系统装载系统之后,首先运行的代码不是main的第一行,而是特别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候才可以在main函数里放心大胆的写各种代码:申请内存、使用系统调用、触发异常、访问IO。
一个典型的程序运行步骤如下:
操作系统在创建进程后,把控制权交到了程序额入口,这个入口是运行库中的某个入口函数。
入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等。
入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
程序装载后的流程
初始化和OS版本有关的全局变量
初始化堆
初始化I/O
获取命令行参数和环境变量
初始化c库的数据
调用mian并记录返回值
检查错误并将main的返回值返回。