第1部 基础知识
当成熟的操作系统出现以后,硬件被抽象成了一系列概念,在UNIX中,硬件设备的访问跟访问普通的文件形式一样,在Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象,磁盘被抽象成了普通的文件系统。繁琐的硬件操作,都交给了操作系统,具体是由操作系统的硬件驱动程序来完成的。
硬盘的基本存储单位是扇区,每个扇区512字节,逻辑扇区。
分段的基本思想,就是将各个程序进行隔离,但是没有解决内存利用率问题。利用程序的局部性原理,提出了分页的想法,大大提高了内存的使用率。奔腾系列的处理器,支持4MB和4KB大小的分页。
多线程的适用情况:
a.某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续进行,多线程可以有效利用等待的时间。典型的例子是等待网络相应,这可能要花费数秒或者数十秒的时间。
b.某个操作(计算量很大)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断,多线程可以让一个线程负责交互,一个线程负责计算。
线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他进程的堆栈,但实际运用中线程也拥有自己的私有存储空间。
线程私有:局部变量、函数的参数、TLS(Thread Local Storage)数据。
进程所有(线程共享):全局变量、堆上的数据、函数的静态变量、程序代码、打开的文件,A线程打开的文件可以由B线程读写。
线程的三种状态,运行、就绪、等待。运行到等待的转换是,时间片用尽。等待一般指的是设备IO。
linux下的多线程,linux将所有执行实体都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,linux下的不同任务之间可以选择共享内存空间,因此这些任务也就成了这个进程里的线程。系统调用,fork,复制当前进程。exec使用新的可执行映像覆盖当前可以执行的映像。clone创建子进程并从指定位置开始执行。
第2部 静态链接
(包括编译和连接、目标文件、静态链接、windows PE/COFF)
预编译:展开#define,宏代替。处理#include预编译指令,将包含的文件插到预编译指令处,可能是个递归的过程。删除所有的注释// /**/。
编译:把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。现在版本的gcc把预编译和编译合成一个步骤来执行,使用一个叫ccl的程序。
汇编:将汇编代码变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。(此时输出一个目标文件)。
链接:我们需要将一大堆的文件链接起来才可以得到a.out。链接过程主要包括了地址和空间分配、符号决议和重定位等。
模块之间的通信,一种是模块间的函数调用,另一种是模块间的变量访问,这两种都可归结为一种方式,那就是模块间符号的引用。
可执行文件格式,windows下的PE和Linux下的ELF,它们都是COFF格式的变种。
目标文件: 源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容和结构很相似,统称为PE-CPFF文件格式。(DLL、SLL也是这种格式存储)。
COFF的主要贡献是在目标文件里引入了“段”的机制,不同的目标文件可以拥有不同数量及不同类型的“段”。
程序源代码编译后的机器指令经常被放在代码段里,代码段常见的名字有“.code”或“.text”,初始化的全局变量和局部静态变量数据经常放在数据段,常见的名字叫“.data”。未初始话的全局变量和局部静态变量数据经常放“.bss”,本来可以放在“.data”,但是它们的值都为0,所以没必要放在数据段。.bss段分配空间的意义只局限于虚拟地址空间,因为它们在文件中没有内容。总体来说,程序源代码被编译以后分为两部分,程序指令和程序数据,为什么两者要分开放,一方面程序指令是只读的,而程序数据是可读写的,两个权限不一致,这样可以防止指令被无意的修改。另一方面,由于局部性原理,指令和数据分开有利于提高缓存命中率。第三个原因,也是最重要的原因,当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分,对于指令这种只读区域,都是属于可以共享的,可以节省大量的内存。
ELF格式,最前部是ELF文件头,它包含了描述整个文件的基本属性,比如ELF文件版本,程序入口地址,紧接着是ELF的各个段,其中ELF中与段有关的重要结构就是段表,该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段长、偏移、读写权限。
ELF文件三种类型,可重定位文件(一般后缀.o),可执行文件,共享目标文件(一般后缀为.so)。
.ref.text是针对.text段的重定位表,因为.text段会存在绝对地址的引用(例如,使用printf
函数)。
ELF中包括代码段、数据段、BSS段等于程序运行相关的段结构,还有ELF文件的文件头、段表、重定位表、字符串表、符号表、调试表。字符串表.strtab是把所有字符串集中存起来放到一个表中,然后使用字符串在表中的偏移值来引用字符串。符号表.symtab保存程序中每个变量和函数的符号名、大小、类型、局部/全局、符号所在段、符号值(如果符号是函数和变量,那么这个值就是函数或变量的地址)。
链接器采用两步链接,第一步 空间与地址分配 ,扫面所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表中。第二步符号解析与重定位,使用上一步收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址,该步是链接过程的核心,特别是重定位过程。
当我们执行g++ -ca.pp b.cpp时,编译了a.cpp和b.cpp,将代码转换为了汇编,生成a.o,b.o文件,这时两个.o文件的地址都是从0开始的,并且a.o中引用到b的函数和变量的地址全是0,然后我们链接a.o和b.o文件,ld a.o b.o –e main –o ab之后,在ab的所有地址就全变成了对应于操作系统的虚拟地址,a的引用地址也改为各函数和变量的地址。
链接器是怎么知道哪些指令是要被调整的呢,这些指令的哪些部分要被调整呢,这就要根据ELF中提到的重定位表来完成,每个要被重定位的地方叫一个重定位入口。
强符号与弱符号:C/C++,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。强引用与弱引用:对外部目标文件的符号引用在目标文件最终链接成可执行文件时,需要被正确决议,如果没有找到该符号的定义,链接器会报符号未定义错误,这种称谓强引用。这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数。
windows下的二进制文件格式PE,PE和ELF同根同源,它们都是由COFF格式发展而来,两者的段内容绝大部分都是相似的,但是也存在几个ELF中不存在的段,.drectve和.debug$S。
.drectve段中存放的是编译器传递给链接器的指令,即编译器希望链接器怎样链接这个目标文件。
第3部 装载与动态链接
(可执行文件的装载与进程、动态链接、linux共享库的组织、windows下的动态链接)
静态装入:程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全部装入内存中,这样程序就可以顺利运行。
动态装入:程序运行时是有局部性原理的,所有我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据放在磁盘里面,这就是动态装入的基本原理。
由于程序文件会有很多的段,而页的大小为4KB或者更大,不可能将每一个段都放到对应的一个页表中,操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间,基本原则是将相同属性的、有相同映像文件的映射成一个VMA,一个进程基本可以分为如下几种VMA区域:代码VMA,数据VMA,堆VMA,栈VMA。
静态链接的缺点:一、空间浪费,每个程序内部除了都保留这printf()函数,scanf()函数等公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。例如一个lib.o模块被多个模块公用时,lib.o在内存中就会存在多个副本,极大的浪费了内存空间。另一个问题是静态链接对程序的更新、部署和发布也带来很多麻烦,因为一旦程序中有任何模块更新时,整个程序就要重新链接发布。对于静态链接,整个进程只有一个文件要被映射,那就是可执行文件本身,而动态链接,除了自己本身还有它所依赖的共享目标文件。
动态链接:要解决空间浪费和更新困难最简单的办法就是把程序的模块相互分割开来形成独立文件,而不再将它们静态链接在一起,简单的来讲就是不对那些组成程序的目标文件进行链接,而等到程序要运行时才进行链接,也就是把链接这个过程推迟到了运行时在进行,这就是动态链接的基本思想。动态链接还有一个特点就是程序在运行时可以动态的选择加载各种程序模块。
进程栈的初始化:在进程刚开始运行时,须知道一些进程运行的环境,最基本的就是系统坏境变量个数和进程的运行参数,很常见的一种做法就是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是stack VMA)。
地址无关代码:希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本思想就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分可以保持不变,而数据部分每个进程中拥有一个副本,这就是地址无关代码(PIC)技术。在数据段里面建立一个指向这些变量的指针数组,也称为全局偏移表。当指令中需要访问变量b时,程序会先找到GOT(全局偏移表),然后根据GOT中变量所对应的项找到变量的目标地址。
优化动态链接性能:PLT(延迟绑定),当函数第一次被用时才绑定。
动态链接的步奏:一启动链接器本身。二装载所需要的共享对象。三重定位和初始化。
动态链接器:本身也是一个共享对象,但是有一些特殊性,它本身不依赖于其他任何共享对象,不使用任何系统库和运行库。这样动态链接器在启动时需要一段非常精巧的代码来完成这项工作同时又不能用到全局和静态变量,这种具有一定限制条件的启动代码被称为自举。
装载共享对象:完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,我们称之为全局符号表。然后,在“.dynamic”段中,有该执行文件所依赖的共享对象,由此链接器可以找到所需要的所有共享对象,并将这些对象名字放入到一个装载集中。当一个新的对象被装载进来的时候,它的符号表被合并到全局符号表中,所以当所有装载对象完毕时,全局符号表包含进程中所有动态链接所需要的符号。当一个符号需要加入全局符号表时,如果相同的符号名已经存在了,则后加入的符号被忽略。
重定位和初始化:当上面步奏完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。重定位完成之后,如果某个共享对象有”.init”段,那么动态链接器会执行”.init”段,而可执行文件有”.init”,那么动态链接器不会执行它,因为可执行文件的”.init”和”.fin”段由程序初试化代码负责执行。这时候动态链接器就完成了工作,将进程的控制权转交给程序的入口并且开始执行。
动态链接器本身是静态链接,它不能依赖于其他共享对象,所有无需动态链接。
运行时加载(显示运行时链接):让程序在运行时控制加载指定的模块,并且在不需要时将该模块卸载,这时候加载的共享模块,被称为动态链接库(DLL)。
DLL机制:windows下的DLL文件和EXE文件,实际上是一个概念,它们都是有PE格式的二进制文件,稍微有些不同的是PE文件头部有个符号位来表示该文件是EXE还是DLL。程序使用DLL的过程就是引用DLL中的导出函数和符号的过程,即导入过程。当一个PE需要将一些函数和变量提供给其他PE文件使用时,我们把这种行为叫做符号导出,最典型的就是一个DLL将符号导出给EXE文件使用,所有导出的符号被击中放在了导出表中。导出表结果中最后的3个成员指向的是3个数组,这3个数组是导出表中最重要的结构,他们是导出地址表、符号表和名字序号对应表。第一个叫导出地址表EAT,它存放的是各个导出函数的RVA,第二个是函数名表,所有的函数名是按照ASCII排序的,第三个是名字和序号对应表。在对应的导入文件中存在导入表。
DLL HELL:三种原因:一、旧的DLL代替了新的DLL。二、新的DLL函数没完全兼容以前版本。三、新的DLL存在BUG。解决方法:一、静态链接。二。防止DLL覆盖。三、避免DLL冲突。
函数调用方式区别:
stdcall:pascal语言调用约定,参数从右向左压入堆栈、函数自身修改堆栈、函数名自动加前导的下划线,后面紧跟@符号,其后跟着参数的尺寸,例如int _stdcall function(int a, int b),这个函数名翻译为_function@8。
cdecl:C语言调用约定,int function(int a, int b),(默认的) int_cdecl function(int a, int b),cdecl调用约定的参数压栈顺序是和stdcall是一样的,参数首先由右向左压入堆栈。所不同的是,函数本身不清理堆栈,调用者负责清理堆栈。由于这种变化,C调用约定允许函数的参数的个数是不固定的。函数名翻译为_function。
fastcall:与stdcall类似。
thiscall:C++语言的调用约定,是唯一一个不能明确指明的函数修饰,它是C++类成员函数缺省的调用约定,参数从右向左入栈,如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入堆栈。对参数个数不定的,调用者清理堆栈,否则函数自己清理堆栈。
第4部 库与运行库
(内存、运行库、系统调用与API、运行库实现)
程序的环境由内存、运行库、系统调用,此外内核也可算作运行环境的一部分,但实际上系统调用部分充当了程序与内核交互的中介。
栈:主要用于维护函数调用的上下文,离开了栈函数调用就无法实现,栈通常在用户空间的最高地址处分配,通常有数兆的字节。
堆:用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里,堆通常存在于栈的下方,堆一般比栈大很多,可以由几十至数百兆字节的容量。
可执行文件的映像:存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。
保留区:并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称。
C++的栈:
C++返回一个对象的时候,对象要经过2次拷贝构造函数的调用才能完成返回对象的传递,1次拷贝到栈上的临时对象里,另一次把临时对象拷贝到存储返回值的对象里。
堆:
光有栈对于面向过程的程序设计远远不够,因为栈上的数据在函数返回的适合就回被释放掉,所以无法将数据传递至函数外部,而全局变量没有办法动态的产生,只能在编译的时候定义,所以堆是唯一的选择,堆是一块巨大的内存空间,程序可以请求一块连续内存,并自由使用,这快内存在程序主动放弃之前都会一直保持有效。
堆总是向上增长?
在以前可能是正确的,现在在windows系统里,这个规律完全被打破了,分配堆空间完全不遵照向上增长的原则。
调用malloc会不会最后调用到系统调用或者API?
这个取决于当前进程向操作系统申请的空间还够不够用,如果够用了,那么它可以直接把当前堆空间分配给进程;如果不够用,它就只能通过系统调用或者API向操作系统申请更大的堆空间。
malloc申请的空间是不是连续的?
空间:如果指的是虚拟空间,那么分配的空间都可以看做一块连续的地址;如果指的是物理空间,则答案是不一定连续,因为一块连续的虚拟空间有可能是若干个不连续的物理页拼凑而成的。
malloc申请的空间,进程结束以后还会不会存在?
不会存在,因为当进程结束以后,所有与进程相关的资源,都会被操作系统关闭或者收回。
堆的分配算法:
空闲链表法;位图;对象池。
C运行库:任何一个C程序,它的背后都有一套庞大的代码来进行支持,以使得改程序能正常运行,这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合,还包括各种标准库函数的实现,这样一个代码集合称之为运行时库,大致包括了如下功能:
a. 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
b. 标准函数:由C语言规定的C语言标准库所用的函数实现。
c. I/O:I/O功能的封装与实现
d. 堆:堆的封装与实现
e. 语言实现:语言中一些特殊功能
f. 调试:实现调试功能的代码
C语言标准库:
ANSI C的标准库由24个C头文件组成,非常的轻量,仅仅包含了数学函数、字符处理、I/O等基本方面。
标准输入输出(stdio.h)字符串操作(string.h)格式转换(stdlib.h)等。
动手实现运行库!!!