第1章 温故而知新
在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回0。
分页机制:
第2章 编译和链接
预编译:源代码.c/cpp文件和相关的头文件.h等被预编译器cpp预编译成一个.i/ii文件。过程相当于命令: gcc -E hello.c -o hello.i (-E表示只进行预编译)。预编译过程主要处理以“#”开头的预编译指令。
编译:把预处理完的文件编译成汇编代码文件。gcc -S hello.i -o hello.s
汇编:将汇编代码转变成机器可以执行的指令,也就是目标代码。gcc -c hello.s -o hello.o
链接:c/c++模块间的通信就是符号的引用,把每个源代码模块独立地编译,组装模块的过程就是链接。链接过程主要包括了地址和空间分配、符号决议和重定位。目标文件和库一起链接形成最终可执行文件。最常见的库是运行时库,库其实是一组目标文件的包。
当A模块要使用B模块里的函数或变量时,A模块在生成目标代码的过程中无法知道变量或函数的地址就暂时搁置。链接器在链接A、B目标文件时就会把A模块里使用到的变量或函数重定位到B目标文件的正确地址。当B模块重新编译时,A目标文件也需要重新链接到新的B目标文件,这就是静态链接。
第3章 目标文件里有什么
目标文件和可执行文件的内容和结构很相似,目标文件可能有些符号或者有些地址还没有被调整,它们一般采用一种格式存储。windows下称为PE-COFF文件格式。Linux下称为ELF(executable linkabke format)。
动态链接库(DLL dynamic linking library,windows的.dll和Linux的.so)和静态链接库(static linking library,windows的.lib和Linux的.a)文件都按照可执行文件格式存储。
目标文件将信息按不同的属性,以节(section,或叫段segment)的形式存储。
总体来讲,程序源代码经过编译以后主要分为两种段:程序指令和程序数据。
程序指令:代码段,常见的名字有.code或.text
程序数据:.data段和.bss段
bss段只是为全局变量和局部静态变量预留位置,在elf文件中不占空间。
ELF 头部包含了描述整个文件的基本属性。
段表是保存段的基本属性的结构,比如段名、段的长度、在文件中的偏移、读写权限等等。段的结构是由段表决定的。
重定位(relocation)表 .rel.text/data 包含代码段和数据段中那些对绝对地址的引用的位置。
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。
符号表记录了目标文件中所用到的所有符号。每个定义的符号有符号值,对于变量和函数来说,符号值就是它们的地址。
特殊符号是定义在ld链接器的链接脚本中,程序中只需要声明就可以使用,程序在最终链接的时候会自动转化为正确的值。
由于不同编译器采用不同的名称修饰方法,所以导致了不同编译器产生的目标文件无法正常的相互链接,这也是导致不同编译器之间不能互操作的主要原因之一。
extern "C"{}语句会导致受作用的变量和函数名在修饰之后采用的是C语言的格式而不是C++. 对于同一个变量或者函数,C++和C语言的修饰不一样,为了让C++能链接到C语言的符号,通常在声明这个函数的时候会先判断当前的编译单元是C还是C++,C++编译器回在编译C++的程序时默认定义"__cplusplus"这个宏,通过它来判断是C还是C++,然后再决定是否使用extern "C"来包含变量或函数名。
这种技巧几乎出现在任何系统头文件中(源文件已经判断了C或者cpp所以不需要这样写)
第4章 静态链接
链接得到的可执行文件的空间地址分配有按序叠加和相似段合并两种方法,一般都使用相似段合并的方法,例如可执行文件的.text段是所有目标文件的.text段的合并。最后的可执行文件当中包含了可重定位的.o文件里面的所有指令。
编译器在生成目标文件时,把对其他模块的引用地址暂时用一些其他值代替,真正的地址计算工作留给了链接器。链接器在链接多个目标文件时先进行空间与地址分配,完成这个步骤后,多个目标文件里的函数或变量的虚拟空间地址已经确定了,这个时候就会进行符号解析和重定位。重定位表含有要进行重定位的位置。
全局构造与析构: 全局对象的构造在main函数之前执行,全局对象的析构在main函数之后执行,Linux下的入口函数是_start,用于在main执行前进行初始化。为此,ELF文件提供了两个特殊的段.init和.fini。其中.init中保存的指令是main执行之前Glibc的初始化部分, .fini中是main函数正常退出之后Glibc会安排执行的代码。
一个静态库文件(.a)是由许多.o文件合并而来的,linux下使用ar -t xx.a
可以查看.a文件中包含的.o文件。在windows平台下可以使用lib /LIST xx.lib
查看
可以使用objdump -t xx.a | grep xxx查找特定的目标文件。使用gcc -static --verbose -fno-builtin hello.c可以将编译链接过程的中间步骤打印出来, 即使我们写的代码非常简单,这也是一个非常长的依赖关系。这个过程会链接部分会显示collect2这是ld的一个包装,会调用ld
重复代码消除,例如C++的模板技术使得模板可以在多个源文件中别实例化但是编译器并不能知道它在多处被同一种数据类型实例化,所以现在主流编译器例如GNU 的做法是在每一个目标文件中对于一个模板的同一种实例化使用一种相同的名称,这样在链接阶段,链接器会检查这些重复的段并只保留一份。GCC把这种段叫"Link Once"命名为".gnu.linkonce.name" . VC++叫做COMDAT,
这种做法的一个潜在的问题是,当编译器对不同的编译单元使用不同的编译优化选项的时候,可能会使得相同名称的段有不同的内容,编译器的做法是随意选择一个作为链接的输入且提供警告信息。
第6章 可执行文件的装载与进程
可执行文件只有装载到内存以后才能被CPU执行。
程序和进程的区别,程序就是菜谱,是一个静态概念,它就是一些预先编译好的指令和数据集合的一个文件, 进程就是炒菜的过程,是一个动态概念,是程序运行时的一个过程。
装载的方式有静态装入和动态装入。静态装入就是直接把指令和数据都放到内存中。当内存不够用时,动态装入就运用了程序的局部性原理,用到啥就先装啥,没用到的就先存放在磁盘中。
页映射的方式,即将内存和文件存在的磁盘空间都划分成一个一个的页(通常是4KB),在程序使用的时候将相应的页调入,决定页面替换的算法有先进先出(FIFO)和最近最少使用算法等。由于每次将同一页程序装入内存中时的地址可能并不一样,所以假如按实际物理地址进行操作,那每次都需要重新读取地址,所以MMU就用来在实际物理地址和虚拟地址之间转换。
进程的建立过程:
1 创建虚拟地址空间,也就是分配一个页目录。创建虚拟空间到物理内存的映射函数所需要的相应的数据结构。
2 读取可执行文件头,建立虚拟地址空间和可执行文件的对应关系(可执行文件被装载时其实就是被映射的虚拟空间,所以也被称为映像文件)
3 将CPU指令寄存器设置成可执行文件的入口(也就是可执行文件代码段的起始地址),然后就可以开始运行。
ELF文件的链接视图和执行视图,链接视图是按照Section分配,执行视图又是Segment,Segment是将相同属性(只读,可读写,可读可执行)的Section作为一个Segment. 一般在链接的时候说"段"指的就是Section,在装载的时候说"段"指的是Segment
readelf -S xx可以输出可执行文件中的section,而readelf -l xx可以输出可执行文件中的Segment(即程序头表),即怎样被装入进程空间, Segment中只有类型是LOAD的部分会被映射,这部分在装载之后又会被映射到两段VMA,分别是可读可写的部分和可读可执行的部分。
操作系统会通过给进程空间划分出一个个的VMA来管理进程的虚拟空间,基本原则是将相同权限属性,有相同映像文件的映射成一个VMA,一般包含四个区域:代码VMA,数据VMA,堆VMA,栈VMA,栈通常也叫堆栈。
一个进程在刚开始运行的时候,操作系统会预先把系统的环境变量和命令行参数传递到进程的堆栈(栈)中,在main函数开始执行的时候,main函数的两个参数args和argv[]
两个参数就是从这里传递进来的,分别表示命令参数的数量和指向命令行传入参数的指针数组。
ELF文件的装载过程: fork() -> execve() -> sys_execve()【系统调用,用于参数检查和复制】 -> do_execve()【读取文件头部的128字节,决定执行程序,如果第一行是#!则会解析这之后的字符串,以确定解释器的路径,例如#!/usr/bin/python】->load_elf_binary()->do_execve() -> sys_execve()【从内核态返回用户态】
第7章 动态链接
可执行程序在运行时才动态加载库进行链接。
动态库安装:移动到/lib、/usr/lib然后运行ldconfig即可。
静态链接的缺点: 1). 内存和磁盘空间的浪费,(会在内存和磁盘中存在多份同一程序的拷贝),2). 程序开发和发布时不得不重新链接一遍所有文件,每次都需要用户下载新的连接之后的可执行文件
动态链接的优点:程序的可拓展性和兼容性,缺点:“DLL Hell"即由于dll文件接口的改变使得程序无法运行而这种错误事先难以得知, 还有由于在运行时链接所以会使得程序运行的速度相对变慢。
编译共享so文件的命令gcc -fPIC -shared -o Lib.so Lib.c
, 共享目标文件so的装载地址是从0开始的,所以会在运行时确定地址。
静态链接文件是链接时重定位,动态链接文件是装载时重定位,又叫基址重置;但是装载时重定位的一个大问题是无法实现多个进程的公用,解决办法是地址无关代码(PIC),先将so文件分为四部分:1) 模块内部的函数调用 2) 模块内部的数据访问, 3) 模块外部的函数调用 4) 模块外部的数据访问。 编译器实际上没法知道一个函数或者变量是来自外部还是外部,所以编译器拓展 _declspec(dllimport)用于指定来自外部或者内部。
模块内部函数调用跳转
模块内部数据访问(全局变量,静态变量)-相对寻址(加上偏移量)
模块外部数据访问-建立GOT(Global Offset Table,全局偏移表,用指针数组保存变量和对应的目标地址并放在数据段中)
模块外部函数调用跳转-建立GOT(全局偏移表中保存目标函数和对应地址)
共享对象编译时,默认将所有定义于模块内部的全局变量当做定义在其他模块的全局变量,于是就像前面所说PIC中的类型三,通过GOT来进行数据的访问。在装载时,会对主模块中的变量进行判断,如果某个全局变量有副本,那么就把GOT中的相应项指向这个副本,位于.bss。于是得以使这个变量的位置统一,如果这个变量在共享对象中进行了初始化,那么就将这个初始化的值也放入副本中;如果没有这个变量的副本,那么就自然指向了共享对象模块内部的那个唯一的副本。
动态链接库是怎么实现相同部分内存被不同进程共享的呢?
重点就在内核中的mmap处理和缺页异常处理上面。
Linux提供了内存映射函数mmap, 它把文件内容映射到一段内存上(准确说是虚拟内存上), 通过对这段内存的读取和修改, 实现对文件的读取和修改,mmap()系统调用使得进程之间可以通过映射一个普通的文件实现共享内存。普通文件映射到进程地址空间后,进程可以向访问内存的方式对文件进行访问,不需要其他系统调用(read,write)去操作。
大致思路如下(需要知道进程虚拟内存,进程页表,页表项,物理内存页框等概念):
- 为进程分配一段虚拟地址空间,并将此空间与libc.so关联起来。此时还没有分配物理内存,进程页表相应页表项也为空。
- 进程开始执行的时候,发现相应页表项为空,此时产生缺页异常。
- 内核中会为每个文件节点维持一个radix树(Page Cache),该树记录了为加载该文件已经分配的物理内存页框。因此在缺页异常的处理过程中,会去查询对应文件内容部分的页框是否已经分配,如果没有则进入步骤4,如果已经分配了进入步骤5.
- 首先分配物理内存页框,设置进程页表对应页表项,然后从磁盘读取libc.so的相应内容到对应的物理内存页框中。并将该页框加入到radix树中。
- 找到已经分配的物理内存页框,设置进程页表对应页表项。此时便实现了相应物理内存页框的共享,也就是说一个存在有文件内容的物理内存页框被映射进了不同进程的虚拟地址空间。
延迟绑定(PLT),即当函数第一次被用到时才进行绑定(符号查找、重定位等操作),如果用不到就不进行绑定。程序开始执行时,模块间函数的调用都没有进行绑定,而是等到需要使用时才会由动态链接器负责绑定,于是大大加快了程序的启动速度。
Linux中/usr/lib和/lib是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib是非系统所需的第三方程序的共享库,例如/usr/local/lib/python。
可执行文件动态链接的过程时,操作系统启动动态链接器,即加载ld.so文件并将控制权交给它,它将可执行文件需要的共享文件动态加载完之后,控制权再交给可执行文件。ELF文件的。interp段里面保存着一个字符串,这个字符串是动态链接器的路径,再linux下,几乎所有ELF文件的动态链接器的路径都是lib/ld.linux.so.2, 其他*nix系统可能会有差异, 这个路径是一个软链接,真正的文件是Glibc库的一部分,升级Glibc库也会升级动态链接器,但是软连接总是指向动态链接器文件,不需要手动修改。
动态链接文件中最重要的段是.dynamic段,类似于静态链接文件的ELF文件头,里面保存了依赖于那些共享对象,动态链接符号表的位置等等,ldd命令可以查看一个共享库依赖于哪些共享库。所依赖的库中linux.gate.so.1是一个在文件系统中不存在的文件,其加载地址在进程的内核区。
动态库的装载是通过一系列由动态链接器提供的API。实现在/lib/libdl.so.2,声明和常量定义在<dlfcn.h>。
dlopen()函数用来打开一个动态库,加载到进程的地址空间。定义为 void * dlopen(const char * filename, int flag)。返回值是被加载模块的句柄。第一个参数是路径。如果是绝对路径直接打开,相对路径会以以下顺序去查找:
1.查找 LD_LIBRARY_PATH环境变量指定的目录
2.查找/etc/ld.so.cache里面指定的共享库路径。
3./lib、/usr/lib
第二个参数是表示函数符号的解析方式,常量RTLD_LAZY表示使用延迟绑定,RTLD_NOW表示模块被加载时就完成函数绑定。常量RTLD_GLOBAL 可以通过或|一起使用,表示被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号。执行'.init'段代码。
dlsym()函数找到所需要的符号。定义: void * dlsym(void * handle, char * symbol)。第一个参数是句柄,第二个是查找的符号的名字。可以返回函数地址,变量地址,常量返回值。
dlerror()判断上一次调用是否成功,成功返回NULL,否则返回字符串。
dlclose()参数是句柄,将一个已经加载的模块卸载,有引用计数器 ,当计数值为0时,才真正卸载,执行'.finit'段代码。
共享库构造和析构函数,分别在 共享库装载和退出时运行:
void __attribute__((constructor(优先级小优先))) init_function(void)
void __attribute__((destructorr(优先级大优先))) fini_function(void)
第10章 内存
堆分配算法
空闲链表
堆中空闲块连接,需要时找到合适大小拆卸下来,释放时合并上去,头部4字节记录该分配的大小
优点:实现简单
缺点:链表和记录大小的4字节被破坏则无法工作
位图
分成大量相同大小的块,分配时第一块为头11,其余为主体10,未分配为空闲00,三种状态,每两位表示一块,一个整数数组存储
优点:速度快-容易命中cache,稳定性好-易备份,部分破坏影响小
缺点:块太大容易产生碎片浪费,块太小位图很大,失去cache命中率高
对象池
每次分配一个固定大小的块
第11章 运行库
程序开始运行
- 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数
- 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造
- 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分
- main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等
- 进行系统调用结束进程
操作系统设计一个句柄(Handle)(在Linux里叫做文件描述符,file description)原因在于防止用户随意读写系统内核的文件对象,内核可通过句柄计算内核文件对象地址。
用户通过某个函数打开文件以获得句柄,通过句柄来操控文件。在Linux中,值为0、1、2的fd分别代表标准输入、标准输出和标准错误输出。在程序中打开文件得到的fd从3开始增长。内核中,每一个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd,就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。这个表在内核,用户只能通过系统函数来访问。标准输入、标准输出和标准错误输出均是FILE结构的指针。I/O初始化函数需要在用户空间中建立标准输入、标准输出和标准错误输出及其对应的FILE结构
一个C语言运行库大致包含了如下功能:
启动与退出:包括入口函数及入口函数所依赖的其他函数等。
标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。
I/O:I/O功能的封装和实现,参见上一节中I/O初始化部分。
堆:堆的封装和实现,参见上一节中堆初始化部分。
语言实现:语言中一些特殊功能的实现。
调试:实现调试功能的代码。
C语言标准库是C语言标准化的基础函数库,我们平 时使用的printf、exit等都是标准库中的一部分。标准库定义了C语言中普遍存在的函数集合,我们可以放心地使用标准库中规定的函数而不用担心在将 代码移植到别的平台时对应的平台上不提供这个函数。Linux和Windows平台下的两个主要C语言运行库分别为glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)glibc和MSVCRT事实上是标准C语言运行库的超集,它们各自对C标准库进行了一些扩展。
运行库是平台相关的,因为它与操作系统结合得非常紧密。C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平 台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。比如我们可以在不同的操作系统平台下使用fread来读取文件,而事实上fread在不同 的操作系统平台下的实现是不同的,但作为运行库的使用者我们不需要关心这一点。
glibc
glibc即GNU C Library,是Linux平台的C标准库。glibc的发布版本主要由两部分组成,一部分是头文件, 比如stdio.h、stdlib.h等,它们往往位于/usr/include;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准 库,它有静态和动态两个版本。动态的标准库我们及在本书的前面章节中碰到过了,它位于/lib/libc.so.6;而静态标准库位于/usr/lib /libc.a。事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正的“运行库”。它们就是/usr/lib /crt1.o、/usr/lib/crti.o和/usr/lib/crtn.o。是不是对这几个文件还有点印象呢?我们在第2章讲到静态库链接的时候 已经碰到过它们了,虽然它们都很小,但这几个文件都是程序运行的最关键的文件。
glibc启动文件
crt1.o 里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。实 际上最初开始的时候它并不叫做crt1.o,而是叫做crt.o,包含了基本的启动、退出代码。crt1.o是改进过后,支持“.init”和“.finit”的版本。crti.o和crtn.o这两个 目标文件中包含的代码实际上是_init()函数和_finit()函数的开始和结尾部分,当这两个文件和其他目标文件安装顺序链接起来以后,刚好形成两 个完整的函数_init()和_finit()。
GCC平台相关目标文件
就这样,在第2章中我们在链接时碰到过的诸多输入文件中,已经解决了crt1.o、crti.o和crtn.o,剩下的 还有几个crtbeginT.o、libgcc.a、libgcc_eh.a、crtend.o。严格来讲,这几个文件实际上不属于glibc,它们是 GCC的一部分,它们都位于GCC的安装目录下:
l /usr/lib/gcc/i486-Linux-gnu/4.1.3/crtbeginT.o
l /usr/lib/gcc/i486-Linux-gnu/4.1.3/libgcc.a
l /usr/lib/gcc/i486-Linux-gnu/4.1.3/libgcc_eh.a
l /usr/lib/gcc/i486-Linux-gnu/4.1.3/crtend.o
首先是crtbeginT.o及crtend.o,这两个文件是真正用于实现C++全局构造和析构的目标文件。那么为什 么已经有了crti.o和crtn.o之后,还需要这两个文件呢?我们知道,C++这样的语言的实现是跟编译器密切相关的,而glibc只是一个C语言运 行库,它对C++的实现并不了解。而GCC是C++的真正实现者,它对C++的全局构造和析构了如指掌。于是它提供了两个目标文件crtbeginT.o 和crtend.o来配合glibc实现C++的全局构造和析构。事实上是crti.o和crtn.o中的“.init”和“.finit”提供一个在 main()之前和之后运行代码的机制,而真正全局构造和析构则由crtbeginT.o和crtend.o来实现。我们在后面的章节还会详细分析它们的 实现机制。
由于GCC支持诸多平台,能够正确处理不同平台之间的差异性也是GCC的任务之一。比如有些32位平台不支持64位的 long long类型的运算,编译器不能够直接产生相应的CPU指令,而是需要一些辅助的例程来帮助实现计算。libgcc.a里面包含的就是这种类似的函数,这 些函数主要包括整数运算、浮点数运算(不同的CPU对浮点数的运算方法很不相同)等,而libgcc_eh.a则包含了支持C++的异常处理 (Exception Handling)的平台相关函数。另外GCC的安装目录下往往还有一个动态链接版本的libgcc.a,为libgcc_s.so。