第1部分 简介
第1章 温故而知新
-
程序在运行的时候先通过分段(segmentation)的方式将虚拟地址空间与真实的物理内存地址空间进行一一的映射,但是这种方式每次换入换出的是整个程序,导致IO变大,更具局部性原理,可以采用分页(Paging)来解决. 分页就是将地址空间人为的等分成固定大小的页,每页的大小(4KB或者4MB)由操作系统确定。 几乎所有操作系统都采用4KB的分页,那么对于一个32位的程序来说,最多只能有
4GB/4KB = 1048576
页, 物理空间也是同样的分法。但是当真实物理空间不够虚拟空间的页数的时候,真正有效的空间以真实内存空间为准。页的映射是由MMU部件来完成的。MMU一般集成在CPU内部。 -
外设使用总线地址,CPU使用物理地址。x86平台上,物理地址和总线地址相同。
-
线程可以分为
IO密集型线程和CPU密集型线程
,当CPU密集型线程的优先级较高的时候,可能会导致低优先级的线程被饿死,而IO密集型线程获得较高优先级的时候,由于大部分时间是处于等待状态,所以不叫不容易造成其他线程的饿死。 如果一个线程长时间得不到执行,调度系统会逐步提升它的优先级让它执行。 -
线程还可以分为抢占线程和不抢占线程,抢占线程是那些时间片用完之后会被剥夺执行权的线程,不可抢占线程是那些除非执行完毕否则不能剥夺执行权的线程。时至今日,非抢占线程已经十分罕见。
-
windows对于进程和线程的实现如同教科书一般的标准,但是在linux中其实并没有严格的线程和进程的概念,Linux中所有的执行实体都被成为任务Task,每一个任务概念上类似于一个单线程的进程,但是它的不同任务之间可以选择共享内存空间,因此在实际意义上共享了同一内存空间的多个任务构成了一个进程。
-
为了保证多线程环境下操作的原子性,有以下几种办法:
6.1 锁
6.2 二元信号量和多元信号量
6.3 互斥量,和上面两个的区别是哪个线程建立的互斥量就必须由哪个线程去释放
6.4 临界区,和上面几种的区别是,哪个进程创建临界区的锁,就由哪个进程获取,对其他进程不可见。
6.5 读写锁,有两种获取方式独占式和共享式
,区别如下
读写锁状态 | 以共享方式获取 | 以独占方式获取 |
---|---|---|
自由 | 成功 | 成功 |
共享 | 成功 | 等待 |
独占 | 等待 | 等待 |
-
可重入线程是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。
-
三种线程模型
8.1 一对一模型
8.2 多对一模型
8.3 多对多模型
第2部分 - 静态链接
第2章 编译和链接
-
程序编译运行的流程是 预编译-编译-汇编-链接, 命令分别是:
gcc -E xx.c -o xx.i
gcc -S xx.i -o xx.s 或者(cc1 xx.i)
gcc -c xx.s -o xx.o 或者(as xx.s -s xx.o)
以及 ld xxx.o xxxx.o xxxx.o等 -
目标文件即.obj文件或者.o文件,本质上是函数的集合,用于重定位, 他们的内部函数和变量的存储方式和真正的可执行文件一样只是在结构上稍有不同。
第3章 目标文件里有什么
-
PC流行的PE文件格式和ELF文件格式都是COEF格式的变种。
-
bss段只是为全局变量和局部静态变量预留位置,在elf文件中不占空间。
-
x86的cpu中字节序采用小端模式存储(所以elf文件中变量的存储采用小端), arm架构的cpu中采用大端,网络字节序(TCP/IP)采用大端传输。
-
elf文件主要包含,代码段(.text)和数据段(.data, .rodata, .bss), 未初始化的全局变量和和未初始化的局部静态变量被存放在.bss段(有时候未初始化的全局变量也会存放在符号表中)。 字符串一般在.rodata段,也有的在.data段。值得一提的是赋值为0的局部静态变量也会被认为是未赋值从而放置在bss 段而不是data段。 attribute 命令可以在代码中指定变量或者函数存放在elf文件的那一段
-
reaelf -h xx.o可以输出elf文件的文件头信息。 ELF文件最开始的16个字节代表了ELF文件的平台属性,其中前四个字节是ELF文件的魔数,不同平台的魔术不同(ELF的魔数是0x7f, 0x45, 0x4c, 0x46 即 DEL控制符,‘E’,‘L’,‘F’ PE/COEF的魔数是0x01, 0x07, 即’M’,‘Z’), 操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
第5个字节是用来标识文件类型,0x01是32位的,0x02是64位的。第6个字节表示字节序是大段或者小端,第七个字节表示ELF的主版本,一般是1。后面9个字节没有指定,表示可扩展。 -
ELF文件中段表的位置由e_shoff成员决定,即(Start of section headers的值决定段表的起始位置)。 readelf -S xx.o 可以显示真正的段表结构。
-
elf文件中段表是其中的一个段,段表里面存储了其他各个段的起始地址和大小还有其他一些信息。
-
符号表里面存储了全局变量,全局函数和行号和用于调式和核心转储的局部符号,行号等信息,链接器在链接的时候只关注全局函数和变量。 可重定位文件中包含的局部信息对其他重定位文件来说都是不可见的,只有全局函数和变可见。可以用nm来查看elf文件的符号结果。 符号表也是elf文件中的一个段,段名一般叫做.symtab
-
readelf -s xx.o可以打印输出elf包含的符号表的信息。 分别有符号的类型,值(函数或者变量的地址),大小,绑定信息(局部,全局,弱引用), Ndx表示符号所在的段的下标,该下标可以通过readelf -a xx.o 看到。 值得注意的是符号表中第一个符号,即下标为0的符号永远是一个未定义的符号。
对于STT_SECTION类型的符号,它们的符号名没有显示,其Ndx所对应的段名也就是这里的符号名, 因为他们是段名符号。可以通过 objdump -t 看到这种段名符号 -
特殊符号是由ld链接器定义的,程序中只需要申明就可以使用,程序在最终链接的时候会自动转化为正确的值,例如__executeable_start, __etext或_etext或etext, _edata或者edata, _end或者end等等。
-
为了防止函数和全局变量在各文件之间的命名冲突。但是随着操作系统和编译器的分化,GCC已经不用在符号前面加_但是windows平台下的编译器还保持着前面加_这样的传统。此外GCC在windows平台下下编译器例如cywin和mingw还保持着这样的传统。GCC本身可以通过编译器选项
-fleading-underscore
或者-fno-leading-underscore
来打开或关闭是否在C语言符号前加下划线。 -
函数签名是C++引入的区别不同类,命名空间等不同作用域名中相同名称的成员的机制 binutils提供的
c++filt
命令可以解析一个函数修饰后名称对应的真正的函数签名。一般规则是对于在命名空间或者类的中的变量和函数其前面一般是_ZN
开头,以E
(+)i/f/d结尾。最后值得说明的是不同平台的编译器的对同一函数的函数签名可能是不同的。 VC++的函数签名方法没用向外公开,但是其UndecorateSymbolName()的api可以将修饰后的名称转换成函数签名。
由于不同编译器采用不同的名字修饰方法,所以导致了不同编译器产生的目标文件无法正常的相互链接,这也是导致不同编译器之间不能互操作的主要原因之一。
目标文件即OBJ文件是跨平台的 -
extern "C"
{}语句会导致受作用的变量和函数名在修饰之后采用的是C语言的格式而不是C++. 对于同一个变量或者函数,C++和C语言的修饰不一样,为了让C++能正确引用并使用C语言的符号,通常在声明这个函数的时候会先判断当前的编译单元是C还是C++即使用下面的语句
#ifdef __cplusplus
extern "C"{
#endif
void *memset {void *, int , size_t};
#ifdef __cplusplus
}
#endif
这种技巧几乎出现在任何系统头文件中(源文件已经判断了C或者cpp所以不需要这样写)
- 强符号和弱符号,一般初始化了的全局变量和函数未强符号而未初始化的全局变量为弱符号,强若符号有以下规则
14.1 不允许强符号重复定义
14.2 如果一个符号在某个目标文件中是强符号,其他目标文件中是弱符号,则选择强符号
14.3 如果一个符号在多个目标文件中都是若符号,则选择占用空间最大的那个。
弱符号和链接器的COMMON块概念的联系很紧密
15 强引用和弱引用: 如果引用的一个库中的符号没用被定义,则链接时候会报为定义错误,这是强引用
, 这种情况不报错的就属于弱引用
。 弱引用和弱符号对库十分有用,因为这样用户可以自定义库中函数,也可以在去掉了某些模块之后程序依然可以正常链接,着使得程序更容易裁剪和组合。
16 使用gcc/g++ -g
参数可以在目标文件中保存调试信息,ELF文件的标准调试信息格式是DWARF
, 目前是 DWARF 3
, 微软的调试信息标准格式叫CodeView
. 调试信息通常数倍于ELF文件本身的内容, 发布时候必须去掉。 在Linux下,可以使用strip去掉ELF文件中的调试信息
第4章 静态链接
-
空间地址分配有按序叠加和相似段合并两种方法,一般都使用相似段合并的方法。最后的可执行文件当中包含了可重定位的.o文件里面的所有指令。
-
Linux下,ELF可执行文件默认地址从0x08048000开始分配。生成的可执行elf文件中的.text段是各个.o文件的.text段大小之和
-
elf文件中需要重定位的段都有一个相对于重定位段(表), 利用
objdump -r xx.o
可以查看目标文件的重定位表。 -
对于弱符号,即未初始化的全局变量,由于在编译成目标文件之后,编译器不能确定其大小,所以将其放在COMMON块中,但是当链接器分析完各个目标文件之后就可以确定其大小,从而将其放在BSS段,所以总体来看,未初始化的全局变量还是放在BSS段的。可以在GCC编译的时候使用
fno-common
指定未赋值的全局变量不在COMMON块中, 也可可以在代码中写__attribbute__ ((nocommon))
将其当成强符号处理。 -
重复代码消除,例如C++的模板技术使得模板可以在多个源文件中别实例化但是编译器并不能知道它在多处被同一种数据类型实例化,所以现在主流编译器例如GNU 的做法是在每一个目标文件中对于一个模板的同一种实例化使用一种相同的名称,这样在链接阶段,链接器会检查这些重复的段并只保留一份。GCC把这种段叫
"Link Once"
命名为".gnu.linkonce.name"
. VC++叫做COMDAT
,
这种做法的一个潜在的问题是,当编译器对不同的编译单元使用不同的编译优化选项的时候,可能会使得相同名称的段有不同的内容,编译器的做法是随意选择一个作为链接的输入且提供警告信息。 -
函数级别链接: 通常的链接过程都是文件或者编译单元级别的链接,但是当只需要使用某个目标为见中的一个函数或变量的时候,就需要全部包含该文件,导致体积很大,编译器为此专门提供了函数级别的链接,与重复代码消除和相似,编译器将所有函数都想模板函数一样单独保存到一个段中,需要的时候再将其包含到输出文件,其他的则直接抛弃,这虽然较小的最终文件的体积但是由于段的数目增减,减慢了编译和链接的过程。GCC使用
-fdata-sections
和-ffunction-sections
可以将变量或者函数分别保存到独立的段中。 -
全局构造与析构: 全局对象的构造在main函数之前执行,全局对象的析构在main函数之后哦执行,Linux下的入口函数是_start,用于在main执行前进行初始化。为此,ELF文件提供了两个特殊的段
.init
和.fini
。其中.init
中保存的指令是main执行之前Glibc的初始化部分,.fini
中是main函数正常退出之后Glibc会安排执行的代码。 -
为了使得不同平台的目标文件兼容,即可以相互链接,这些文件必须有一直的
ABI(Application Binary Interface)
,即二进制兼容,ABI内容包括符号修饰标准,变量内存布局,函数调用方式等等。厂商不希望用户看见自己的源代码所以会提供二进制版本,所以二进制兼容在大型项目中变得很重要。目前编译器的两大阵营 VISUAL C++和GNU 的GCC各执己见互不兼容。ABI兼容问题还有待解决 -
一个静态库文件(.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 -
BFD(Binary File Descriptor library)
是基于所有硬件平台(不同的处理器和目标文件格式)的一个抽象层,基于BFD可以不用关心具体的硬件格式,而进行统一操作,因为BFD中已经包含了这些CPU和可执行文件的格式信息,ubuntu下BFD软件包的名字叫binutils-dev
.
第5章
-
windows上的目标文件为COEF格式,而可执行文件是PE格式,PE又是COEF格式衍生出来的, 所以将这类文件统称为
PE/COEF
格式 -
64位的Windows中对PE文件格式做了一点小小的修改,叫做PE32+格式,只是将32位的字段换成了64位而已。
-
VC++有一些对C/C++的专用拓展,使用cl编译的时候,可以使用 /Za来禁用这些拓展,也可以在程序中使用宏
__STDC__
来查看VC++是否禁用了这些语法拓展 -
和GNU对象,Windows上的cl就是gcc, link就是ld, dumpbin就是objdump,
-
PE中有两个ELF文件中不存在的段,分别是
.drectve
和.debug$S
..drectve
段是编译器传递给链接器的指令,其中的flags表示了他的特点。.debug$S
表示的是符号相关的调试信息。其中有原始文件信息和编译器信息 -
可以在cl命令中通过/ZI来关掉默认C库的链接指令
-
PE文件为了兼容DOS的MZ文件结构在PE文件中加入了DOS的相关设置,所以将windows下的可执行文件在DOS上运行的时候会输出"This program cannot be run in DOS"
第3部分 装在与动态链接
第 6章
-
程序和进程的区别,程序就是菜谱,是一个静态概念, 进程就是菜,是一个动态概念。
-
程序的寻址空间由CPU的位数决定,所以32位下的程序寻址空间是 即4GB, 64位下是
17179869184 GB
, 32位下C语言指针的长度是4字节,64位系统下长度是8字节.
对于一个32位的程序,寻址空间虽然是4GB,但是程序并不能全部使用,例如在Linux下,1GB是留给操作系统的,剩下的3GB给进程,且这3GB内存程序也不能完全使用,还有一部分给其他用途; 在Windows上,默认情况下2GB留给系统,2GB留给进程, 但是可以通过修改winows根目录下Boot.ini文件调整内存分配和linux下一样。
1995年Pentium Pro CPU使用PAE(Physical Address Extension),将地址线扩充到了36位,所以理论上计算机可以寻址的空间变成了64G, 但是进程的寻址空间仍然是4G(32位系统下指针是4个字节)
为了使应用程序能使用超过32位的内存,Windows上可以使用AWE(Address Window Extension)的方式, 在Linux上可以使用mmap(),但是这只是一种补救32地址线的方法, 在原来16位的DOS上也曾有过这样的做法。
- 为了提高内存的使用效率,采用动态装入的办法,动态装入由两种方式,第一种是覆盖装入,即每次将要使用的模块装入内存,不使用的调出,这样,调用的程序可以共享同一块内存区域,使用之前先要将程序的所有调用关系组织成一个树状结构。但是这种方式需要保证两点, 1) 调用路径上的模块都应该存在,2)不能存在跨树调用。 第二种是叶映射的方式,即将内存和文件存在的磁盘空间都划分成一个一个的页(通常是4KB),在程序使用的时候将相应的页调入,决定页面替换的算法由先进先出(FIFO)和最近最长使用算法等。
由于每次将同一页程序装入内存中时的地址可能并不一样,所以加入按实际物理地址进行操作,那每次都需要重新读取地址,所以MMU就用来在实际物理地址和虚拟地址之间转换, - 进程的建立过程:
4.1 创建虚拟地址空间,也就是分配一个页目录。
4.2 读取可执行文件头,建立虚拟地址空间和可执行文件的对应关系(可执行文件被装载时其实就是被映射的虚拟空间,所以也被称为映像文件)
4.3 将CPU指令寄存器设置成可执行文件的入口(也就是可执行文件代码段的其实地址) - ELF文件的链接视图和执行视图,链接视图是按照Section分配,执行试图又是Segment,Segment是将相同属性(只读,可读写,可读可执行)的Section作为一个Segment. 一般在链接的时候说"段"指的就是Section,在装载的时候说"段"指的是Segment
readelf -S xx
可以输出可执行文件中的section,而readelf -l xx
可以输出可执行文件中的Segment(即程序头表),即怎样被装入进程空间, Segment中只有类型是LOAD的部分会被映射,这部分在装载之后又会被映射到两段VMA,分别是可读可写的部分和可读可执行的部分, - ELF可执行文件和动态链接文件都有一个结构叫程序头表,保存着程序被装载时候的Segment信息,而静态目标文件没有这个程序头表,因为目标文件不需要被装载。
- 操作系统会通过给进程空间划分出一个个的VMA来管理进程的虚拟空间,基本原则是将相同权限属性,有相同映像文件的映射成一个VMA,一般包含四个区域:代码VMA,数据VMA,堆VMA,栈VMA,栈通常也叫堆栈。
- 程序员可以操作的进程空间,理论上linux下是3GB,windows下是2GB,但是操作系统为了防止恶意攻击,使用了一种叫做随机地址空间分布的技术,使得真正的堆区可用空间比理论上要少。
- 在Linux系统中,为了使得物理内存的使用效率更高,通常不是一个虚拟页面对应一个物理页面也是会有一定的重合,重合部分的页面映射两次,所以装载视图中每一Segment 的其实地址就不再必须是4096的倍数了。
- 一个进程在刚开始运行的时候,操作系统会预先把系统的环境变量和命令行参数传递到进程的堆栈(栈)中,在main函数开始执行的时候,main函数的两个参数
args和argv[]
两个参数就是从这里传递进来的,分别表示命令参数的数量和指向命令行传入参数的指针数组。 - ELF文件的装载过程:
fork() -> execve() -> sys_execve()【系统调用,用于参数检查和复制】 -> do_execve()【读取文件头部的128字节,决定执行程序,如果第一行是#!则会解析这之后的字符串,以确定解释器的路径,例如#!/usr/bin/python】->load_elf_binary()->do_execve() -> sys_execve()【从内核态返回用户态】
- PE文件的装载过程:读取文件的第一页(段表,DOS文件头,PE文件头)-> 检查目标地址是否可用不可用则换一个装载地址(不可用一般只出现在dll的装载中)-> 按照段表一一装载各个段, 装载所以DLL -> 解析符号 -> 建立主线程并启动进程
第7章 动态链接
- 静态链接的缺点: 1). 内存和磁盘空间的浪费,(会在内存和磁盘中存在多份同一程序的拷贝),2). 程序开发和发布时不得不重新链接一遍所有文件,每次都需要用户下载新的连接之后的可执行文件
- 动态链接的优点:程序的可拓展性和兼容性,缺点:“DLL Hell"即由于dll文件接口的改变使得程序无法运行而这种错误事先难以得知, 还有由于在运行时链接所以会使得程序运行的速度相对变慢。
- 编译共享so文件的命令
gcc -fPIC -shared -o Lib.so Lib.c
, 共享目标文件so的装载地址是从0开始的,所以会在运行时确定地址。 - 静态共享库和静态链接库以及动态链接库都不同,本身属于动态库,没有被包含到一个可执行文件当中,但是它在运行时的地址是事先已经分配好的。
- 静态链接文件是链接时重定位,动态链接文件是装载时重定位,又叫基址重置;但是装载时重定位的一个大问题是无法实现多个进程的公用,解决办法是地址无关代码(PIC),先将将so文件分为四部分:1) 模块内部的函数调用 2) 模块内部的数据访问, 3) 模块外部的函数调用 4) 模块外部的数据访问。 编译器实际上没法知道一个函数或者变量是来自外部还是外部,所以编译器拓展
_declspec(dllimport)
用于指定来自外部或者内部。 - 如何判断一个动态共享目标文件(DSO)是否是PIC的代码:
readelf -d xx. so | grep TEXTREL
如果没有任何输出则是PIC的,因为TEXTREL表示代码段重定位表地址,PIC不存在这个地址。 - 地址无关技术也可以用于可执行文件,即地址无关可执行文件(PIE)相应的GCC编译参数为-fPIE或-fpie.
- 为了防止DSO文件在运行时链接过程耗费太多的时间,采用延迟绑定(Lazy Binding)机制。即在第一次用到的时候进行地址绑定。
- 可执行文件动态链接的过程时,操作系统启动动态链接器,即加载ld.so文件并将控制权交给它,它将可执行文件需要的共享文件动态加载完之后,控制权再交给可执行文件。ELF文件的。interp段里面保存着一个字符串,这个字符串是动态链接器的路径,再linux下,几乎所有ELF文件的动态链接器的路径都是
lib/ld.linux.so.2
, 其他*nix系统可能会有差异, 这个路径是一个软链接,真正的文件是Glibc库的一部分,升级Glibc库也会升级动态链接器,但是软连接总是指向动态链接器文件,不需要手动修改。 - 动态链接文件中最重要的段时.dynamic段,类似于静态链接文件的ELF文件头,里面保存了依赖于那些共享对象,动态链接符号表的位置等等,ldd命令可以查看一个共享库依赖于哪些共享库。所依赖的库中linux.gate.so.1时一个在文件系统中不存在的文件,其加载地址在进程的内核区,
- 动态链接库中往往包含了两个表.dynsym 和.symtab。其中 .symtab保存了所有的符号,包括.dynsym中的符号,和静态链接的.symtab表类似,动态链接库也需要很多辅助的表,例如静态的符号字符串表.strtab 对应动态符号字符串表.dynstr 为了加快符号查找过程,还需要,符号哈希表
.hash
可以用命令readelf -sD xx.so
查看动态符号表和它的哈希表。 - 普通共享对象的链接和装载由动态链接器完成,但是动态链接器本身也是一个共享对象,所以要求动态链接器共享对象本身不依赖任何其他的共享对象,且其需要的全局变量和静态变量的重定位工作有它自己完成,对于第二个要求,需要一段精妙的代码完成,这被称为自举。
第七章7.6节以后略去不写
第八章
- 在开发共享库的时候,使用C的接口会让事情简单很多,因为C++非常复杂,支持模板等很多复杂操作,很容易破坏共享库的兼容性(ABI)
- 为了解决兼容性,共享库的命名采用libxxx.so.x.y.z其中x表示主版本号,y表示次版本号,z表示发布版本号,x表四重大升级,y表示增量升级,z表示对一些错误的修正和改进,相同的x和y,不同的z的共享库完全兼容,
- solaris和linux等系统中,会采用SO-NAME的方式解决存在多个相同功能的共享库的问题,SO-NAME时共享库的文件名.主版本号命名,例如,假如系统中存在libxxx.so.2.6.1和libxx.so.2.5.3则,在同目录下会存在一个名为libxx.so.2的软连接,并且指向最新版本的共享库libxx.so.2.6.1。在编译的时候,SO-NAME也会写入到共享库的.dynamic段中,防止写入完整名称之后遇到共享库升级而找不到的情况。
- 在linux中安装或更新一个共享库的时候需要运行ldconfig命令,它会自动将共享库目录中的软连接指向最新的共享库,对于新建的共享库则会新建软链接。