第一章:温故而知新
过度优化的问题:
我们知道volatile关键字可以阻止过度优化,因为它可以完成两件事:
- 阻止编译器为了提高速度将一个变量缓存到寄存器而不写回
- 阻止编译器调整操作volatile变量的指令顺序
然而,在优化这一块,不仅编译器会做优化,CPU也会做优化。volatile就管不着了CPU了。
经典的例子当然是单例模式。单例模式有一种常规的解决方案是DCL,也就是双重检查锁,但是在C++中new的步骤有是分为三个步骤:分配内存,调用构造函数,将内存地址用指针保存下来。CPU就要来搞怪,将第二步第三步乱个序。
一种解决方案是:调用CPU提供的barrier指令阻止将barrier指令之前的代码交换到barrier之后。但是这种方案不具有可移植性。部分实现代码如下:
if(!pInst){
lock();
if(!pInst){
T* temp = new T;
barrier();
pInst = temp;
}
unlock();
}
线程:
是程序执行流的最小单元。通常意义上,一个进程由多个线程组成,各个线程之间共享程序的内部空间(包括代码段,数据段,堆等)及一些进程级的资源(如打开文件和信号)
线程的访问权限:
线程调度与优先级
- 运行(Running):此时线程正在执行
- 就绪(Ready):此时线程可以立刻运行,但CPU已经被占用
- 等待(Waiting):此时线程正在等待某一件事件发生,无法执行
可抢占线程和不可抢占线程
线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占,即之后执行的别的线程抢占了当前线程。
在早期的一些系统中,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。可以避免一些因为抢占式线程里调度时机不确定而产生的问题。非抢占式线程已经十分少见
写时复制(Copy on Write,COW)
指的是两个任务可以同时自由地读取内存,担任一一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用
线程安全
信号量:
一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行一下操作:
最后一条应该是错了,应该是信号量不小于1,唤醒一个等待中的线程
可重入(Reentrant)与线程安全
静态链接::第二章:编译与链接
被隐藏了的过程
通常将编译和链接合并到一起的过程称为构建(Build)
一个程序从代码到可以运行经过了四个步骤:预处理(Prepressing),编译(Compilation),汇编(Assembly),链接(Linking).c -> .i -> .s ->.o -> .exe
预编译
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比如“#include”,“#define”等
编译
编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后生产相应的汇编代码文件。
实际上 gcc 这个命令只是后台程序的包装,它会根据不同的参数要求调用预编译编译程序 cc1,汇编器 as,链接器 ld
词法分析:
首先源代码程序被输入到扫描器,进行简单的词法分析,运用一种类似于有限状态机的算法将源代码的字符序列分割成一系列的记号(一般有关键字,标识符,字面量,特殊符号)
语法分析:
语法分析器将对由扫描器产生的记号进行语法分析,产生语法树,采用了上下文无关语法的分析手段。
语义分析:
编译器所能分析的语义是静态语义,所谓静态语义是指在编译器可以确定的语义,与之对应的动态语义就是只有在运行期才能确定的语义
主要检查声明和定义,类型转换是否出错等。做完这一步之后,语法树的每个节点都会有对应的类型。这一步还会对符号表里的符号进行更新。
中间语言生成
图中(2+6)被优化成 8
目标代码生成与优化
主要是生成汇编代码,以及对汇编代码的优化。比如说,在优化这个阶段,可能导致指令重排序,这就引发了单例的一些问题。
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
到这一步为止,输出的是目标文件(Object File)
链接
编译器前端负责生成和机器无关的中间代码;后端负责将中间代码转化为机器目标代码。这样对于一些可跨平台的编译器而言,他们可以针对不同的平台使用相同的前端,而针对不同的机器平台有数个后端。
链接是这本书的主题。语言的发展是从编写一个代码文件到多个模块文件编写的,因此要让各个模块协同工作,就需要使用链接器。重要的一点是:在未链接之前,目标文件中的一些变量、函数地址是未决的(或者说可以这么理解:编译一个文件,可能有些变量、函数的地址确定不了,是个待定值),链接器就是来干这个事的:把一些指令对其他符号地址的引用加以修正。主要包括:地址和空间分配、符号决议和重定位。
静态链接::第三章:目标文件里有什么
目标文件的格式
- 可执行文件:Windows下的PE、Linux下的ELF
- 动态链接库:Windows的.dll,Linux的.os;静态链接库:Windows的.lib,Linux的.a
- ELF文件类型分类:1.可重定位文件;2.可执行文件;3.共享目标文件;4.核心转储文件
- 已初始化的全局变量和局部变量数据经常放在数据段.data,未初始化的全局变量和局部静态变量放在.bss段;编译后的机器指令经常放在代码段.code或.text;.rodata段存放的是只读数据,一般是程序里面的只读变量和字符串常量。.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,int a=0会放在.bss段。
- 总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据
6.ELF文件头包含:ELF魔数(魔数--用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载)、文件机器字节长度、段表的位置、程序头入口和长度、段的数量、文件是否可执行、目标硬件、目标操作系统等
7.数据和指令分段的好处:
- 不同段可以设置读写权限,可以防止程序被有意或者无意的修改
- 提高CPU的缓存命中率
- 当系统中存在多个该程序的副本,他们的指令都一样,那么内存中只需保存一份该程序的指令
8.段表:描述文件中各个段的数组,放置段的名称、段的长度、段类型、段在文件中的偏移位置、读写权限、段的属性等。编译器,链接器和装载器都是依靠段表来定位和访问各个段的属性的
9.重定位表:在处理目标文件时,必须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位信息都记录在ELF文件的重定位表里面。比如说数据的重定位表对应.rel.data
10.字符串表:因为字符串长度往往是不定的,固定结构存储较为困难。所以字符串表就是解决这个问题,使用字符串在表中的偏移来引用字符串。
11.链接过程的本质就是把多个不同的目标文件之间相互”粘“到一起,实际上拼合的是目标文件之间对地址的引用,即对函数和变量的地址的引用。我们可以将符号看作是链接中的粘合剂,整个连接过程正式基于符号才能够正确完成。每一个目标文件都有一个相应的符号表,这个表里面记录了目标文件中所用的所有符号。每个定义的符号有一个对应的值,叫做符号值。对于变量和函数来说,符号之就是它们的地址。
12.对符号进行分类:全局符号、局部符号、外部符号、段名、行号。链接只关注全局符号。
13.符号表中存放的内容有:编号、值、内存大小、类型、作用域、符号所属段、符号名字
14.特殊符号:一些内置的符号,比如_executable_start表示程序起始地址
15.函数签名:函数名、参数类型、所在类和名称空间。GCC的名称修饰方法就是:_Z + N(如果是嵌套)+n(后面的字符串长度)+str+E(结尾),比如N::C::func(int)经过名字修饰之后变成:_ZN1N1C4funcEi。由于不同编译器采用不同的名字修饰方法,必然导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。
16.对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
17.针对强弱符号的概念,编译器就会按如下规则处理与选择多次定义的全局符号:
- 不允许强符号被多次定义
- 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号
- 如果在多个文件中都是弱符号,那么选择其中占用空间最大的一个
第四章:静态链接
空间与地址分配:
对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?或者说,输出文件中的空间如何分配给输入文件?
按序叠加
最蠢的做法是顺序叠加多个目标文件的段,但是会存在内存对齐导致输出文件过大的情况;所以,大多数做法是采用相似段合并的策略。
相似段合并
将相同性质的段合并到一起,比如将所有输入文件的".text"合并到输出文件的".text"段
“链接器为目标文件分配地址和空间”这句话中的“地址和空间”有两个含义:第一个是在输出的可执行文件中的空间,第二个是在装载后的虚拟地址中的虚拟地址空间
两步链接:
第一步 空间与地址分配
扫描所有的输入目标文件,并且获得他们的各个段的长度,属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系
第二步 符号解析与重定位
使用上面第一步中收集到的所有信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程
符号解析与重定位
1.在未链接之前,目标文件中使用的其他编译单元的变量的地址未定义,会用0来给他占位,并且将这个未定义变量放进重定位表(专门用来保存与重定位相关的信息)中。
2.目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说是透明的,它只知道一个符号的名字,并不知道类型是否一致。这就会导致由多个弱符号出现的时候的一些问题。COMMOM块来解决这些问题:当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。
3.C++中模板导致的问题:一个模板在一个编译单元内进行了实例化,在另外一个单元可能也进行了实例化,当这两个单元链接的时候就会出现重复代码,进而导致空间浪费、地址出错、指令运行效率低的问题。一个比较有效的做法是将每个模板的实例化代码都单独存放在一个段里,每个段包含一个模板实例。同理,对于虚函数,内联函数,默认构造函数,默认拷贝构造函数也会存在这样的问题,解决方案都差不多。
4.main函数之前执行全局构造,对应汇编代码.init段;main函数之后执行全局析构,对应汇编代码.fint段
5.ABI:Application Binary Interfacd,对应API,层次不同,ABI主要内容是符号修饰标准、变量内存布局、函数调用方式等内容。C++一直被人诟病的一大原因是它的二进制兼容性不好。
静态库链接
一个静态库可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。但事实上也是这样,比如libc.a就是由许多.o文件打包而成的。
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有以下三种方法:
- ld命令行指定参数,之前使用的 ld 的 -o,-e 参数就属于这类
- 目标文件中放置指令
- 链接控制脚本
BFD库是一个GNU项目,它的目标是希望通过一种统一的接口来处理不同的目标文件格式。
第五章 Windows PE/COFF
映像:因为PE文件在装载时被直接映射到进程的虚拟空间中运行,它是进程的虚拟空间的映像。所以衍生出自己对映像的理解:编译链接好的程序,装进内存就能运行的文件
PE文件的段属性:段名、物理地址、虚拟地址、原始数据大小、段在文件中的位置、该段的重定位表在文件中的位置、该段的行号表在文件中的位置。总的来说和ELF文件大同小异。
PE文件和ELF文件的一些差异:.drectve段、.debug$S段。drectve段是编译器告诉链接器应该怎么链接这个目标文件的信息存放地。
装载与动态链接::可执行文件的装载与进程
可执行文件只有装载到内存之后才能被CPU执行
1.程序(侠义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime);从程序角度看,我们可以通过C语言中的指针所占的空间来判断计算机虚拟地址空间。
2.装载方式有:
- 覆盖装入:对于不互相调用的模块,可以放在同一虚拟地址。这种方式几乎被淘汰。因为,满足条件的程序少。一般使用这种方法做装载,要程序员自己分析调用依赖关系,调用路径,写覆盖管理器。
- 页映射:就是常说的页表,LRU、FIFO调度啥的~
3.从操作系统角度看可执行文件的装载:
- 进程建立:主要做三件事:1.创建一个独立的虚拟地址空间;2.读取可执行文件头,并且建立虚拟空间与执行文件的映射关系;(由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image))3.将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
- 页错误:执行完上面的步骤之后,其实可执行文件的真正指令和数据都没有被装入内存。当程序跑起来了,cpu读到入口地址了,这个时候会发现需要的页不在内存中,发生缺页中断,找,放进去,返回中断。如此循环往复。
4.当操作系统捕获缺页错误的时候,她应该知道程序当前所需要的页在可执行的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度看,这一步是整个装载过程中最重要的一步,也是传统意义上”装载“的过程。
5.ELF文件中,段的权限往往只有为数不多的几种组合:
- 以代码段为代表的权限为可读可执行的段
- 以数据段和BSS段为代表的权限为可读可写的段
- 以只读数据段位代表的权限为只读的段
对于相同权限的段,把它们合并到一起当作一个段进行映射。这样可以减少页面内部碎片,从而节省内存空间
6.Segment和Section是从不同角度来划分同一个ELF文件。从Section角度来看,ELF文件就是链接视图,从Segment角度来看就是执行视图。
7.虚拟空间的内存分布模型就不多说了;堆中最大内存申请数量会受操作系统、程序本身大小、用到的动态/共享库数量大小、程序栈数量的影响;段地址之前也存在内存对齐的情况;进程栈初始化,最主要是将程序的参数以及环境变量放进栈空间中。
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则就是将相同权限属性的,有相同映像文件的映射成一个VMA,一个进程基本上可以分为如下几种VMA区域:
VMA -- Virtual Memory Area 虚拟内存区域
8.Linux内核装载ELF过程,拿fork举例:首先在用户从层面,bash进程会调用fork()系统调用创建一个新进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
- 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段的数量
- 寻找动态链接的interp段,设置动态链接器路径
- 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射
- 初始化ELF进程环境
- 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的连接方式,对于静态链接的ELF可执行文件,这个程序的入口点就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序的入口点是动态链接器
9.每种可执行文件的格式的开头几个字节都很特殊,特别是开头4字节,常常被称为魔数,通过对魔数的判断可以确定文件的格式和类型。对于一些脚本文件,Shell、perl、python分别对应的#!/bin/sh、#!/usr/bin/perl、#!/usr/bin/python中的#!就是他们对应的魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
动态链接
1 . 为什么要使用动态链接?静态链接存在一些问题:浪费内存和磁盘空间、模块更新困难的问题。比如,我们有一个第三方库,使用静态链接,那么我们要组建链接可执行文件的时候,会把第三方库链接的内容和目标文件放在一起,重定位之类的工作。如果有两个可执行文件都要使用这个第三方库,那么装载后,内存中会有两个这个第三方库的内容,这就是浪费内存。同样情况下,要更新第三方库,那么我们给出的目标文件要重新编译啥的,耗时。动态链接的提出,就是为了解决上述两个问题。动态链接的主要思想是:把链接的这个过程推迟到了运行时(装载)在进行。 引入动态链接,能够让程序具有可扩展性和兼容性,但是如果新旧模块之间接口不兼容还是会导致一系列问题。
同时在链接输出的可执行文件 Program 1 和 Program 2 都用到了 Lib.o 这个模块,所以当我们同时运行这两个时,Lib.o在磁盘中和内存中都有两份副本
不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想
2 . 在Linux中,ELF动态链接文件被称为动态共享对象,简称共享对象,“.so"为拓展名;Windows中,动态链接文件被称为动态链接库(Dynamical Linking Library),”.dll"为拓展名。
3 . 动态库会导致程序在性能上的一些损失,每次装载时都需要重新链接,但是是存在优化策略的,比如延迟绑定。经验表明,动态链接比静态链接相比,性能损失大约在5%,这是可接受的。(时间换空间)
之前在静态链接时提到过的重定位,叫做链接时重定位,而现在的情况是装载时重定位
4 . 动态链接在链接的时候,如果一个符号是在本模块找不到,但是是在其他动态共享对象中,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
5 . libc-2.6.1.so,C语言运行库;ld-2.6.so,Linux的动态链接器,动态链接器与普通共享对象一样被映射到进程的空间地址中,在系统开始执行一个可执行文件之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交回原来的可执行文件,然后再开始执行。
6 . 共享对象的最终装载地址在编译时是不确定的,而是再装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
7 . 装载时重定位:GCC使用的-shared命令就是让程序装载的时候重定位。具体做法是将链接步骤做的事情放在装载步骤来,因为在链接的时候并不知道动态库的内存地址,装载时候我们可以确定共享对象的一些信息,将需要重定位的信息填入即可。
8 . 地址无关代码:GCC使用的-fPIC指令就是让链接器生成地址无关的代码(Position-independent Code)。ELF的做法是在数据段中建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT),当代码需要引用该全局变量或函数的时候,可以通过GOT中相对应的项间接引用。装载的时候可以确定GOT表的信息。
9 . 延迟绑定PLT(Procedure Linkage Table)思想:当函数第一次被用到时才进行绑定(查找符号、重定位),如果没有用到则不进行绑定。使用的函数是_dl_runtime_resolve()。
10 . got段用来保存全局变量引用的地址,got.plt用来保存函数引用的地址;interp段保存一个字符串,字符串就是可执行文件需要的动态链接器的路径;dynamic段保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表
11 . 动态链接时进程堆栈初始化,会比静态连接时多一些动态链接的信息,即动态链接辅助信息数组。
12 . 动态链接的步骤和实现:
- 动态链接器自举:动态链接器本身得满足两个条件:1.本身不可以依赖其他任何共享对象;2.本身所需要的全局和静态变量的重定位工作由它本身完成。可以这么理解,动态链接器本身是静态链接的,本身是不是PIC没有影响。后续的工作都要由动态链接器来完成。
- 装载共享对象:当一个新的共享对象被装载进来的时候,这个共享对象的符号表会被合并到全局符号表中。共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象叫做全局符号介入,一般是第一个被装载的符号优先,后续同名符号忽略,这种优先方式称为装载序列。
- 重定位和初始化:链接器重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT/PLT中的每个需要重定位的位置进行修正;重定位完成之后,如果某个共享对象有init段,那么动态链接器会执行init段中的代码,用以实现共享对象特有的初始化过程,
第八章 Linux 共享库的组织
1 . 再强调一遍:ABI(二进制接口 Application Binary Interface)对于不同语言来说,主要包括一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则。
2 . Linux有一套规则来命名系统中的每一个共享库,他规定共享库的文件名规则必须如下:libname.so.x.y.z
- x表示主版本号,表示有重大升级,不同主版本号的库之间是不兼容的
- y表示次版本号,表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变
- z表示发布版本号,表示库的一些错误的修正、性能的改进,并不添加任何新的接口
3 . SO-NAME:共享库的文件名去掉次版本号和发布版本号,保留主版本号,比如:libc.so.2。在Linux系统中,系统会为每个共享库在它所在的目录创建一个跟”SO-NAME“相同的并且指向它的软链接,实际上这个软链接会指向目录中版本号相同、此版本号和发布版本号最新的共享库。建立SO-NAME为名的软链接的目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的SO-NAME,而不使用详细的版本号。
4 . FHS规定,一个系统中主要有三个存放共享库的位置:
- /lib:主要存放系统中最关键和基础的共享库
- /usr/lib:这个目录下主要存放的使用一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库
- /usr/local/lib:这个目录用来防止一些跟操作系统本身就并不十分相关的库,一般是第三方库
5 . 环境变量有:
- LD_LIBARRY_PATH:共享库查找路径。在GCC中可以用-L指定
- LD_PRELOAD:无论程序是否依赖这个变量中包含的库,他们都会被提前装载
- LD_DEBUG:打开动态链接器的调试功能,对我们开发和调试共享库有很大的帮助
第九章 Windows 下的动态链接
1 . 基地址:对于任何一个PE文件来说,它都有一个优先装载的基地址
2 . 相对地址RVA(relative virtual address)
3 . DLL(Dynamic-Link Library)的符号默认是不导出的,需要显式地告诉编译器,当我们在程序中使用DLL导出的符号时,这个过程被称为导入(import)。
4 . 声明某个函数为DLL导出函数的办法:一种方式是函数使用__declspec(dllexport) 修饰,另一种是采用 .def 文件
5 . cl /LDd Math.c 会生成四个文件
- Math.dll
- Math.obj
- Math.exp:链接器在创建DLL时的临时文件
- Math.lib:描述Math.dll的导出符号,又被称为导入库
6 . 编译TestMath.c为可执行文件
- cl /c TestMath.c
- link TestMath.obj Math.lib
程序员的自我修养——链接、装载与库_程序员的自我修养:链接,装载与库 pdf-CSDN博客
库与运行库
第十章 内存
-
动态链接库映射区:这个区域用于映射装载的动态链接库。在Linux中,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。
-
造成指针使用错误的两点主要原因:
- 程序员将指针初始化为NULL,之后却没给它一个合理的值就开始使用指针
- 程序员没有初始化栈上的指针,指针的值一般会是随机数,之后就直接开始使用指针
-
栈保存了一个函数调用所需要的维护信息,这常被称为堆栈帧或活动记录。堆栈帧一般包括如下几个方面的内容:
- 函数的返回地址和参数
- 临时变量:包括函数的非局部静态变量以及编译器自动生成的其他临时变量
- 保存的上下文:包括函数调用前后需要保持不变的寄存器
- 在参数之后的数据(包含参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而变化,相反地,esp始终指向栈顶,因此随着函数的执行,esp会不断变化。固定不变的ebp可以用来定位函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8,ebp-12等。ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp可以通过读取这个值恢复到调用前的值
- 调用惯例:函数的调用方和被调用方对于函数如何调用必须有一个明确的约定,只要双方都遵守同样的约定,函数才能被正确的调用,这种约定就是调用惯例。一般调用惯例会有如下几方面的内容:函数参数的传递顺序和方式、栈的维护方式、名字修饰的策略等。常见的调用惯例有:cdecl、stdcall、fastcall、pascal、thiscall。这些调用惯例可放置在函数返回类型和函数签名之间,例如int _cdecl fun(){...}。需要说明一点是thiscall是C++的特殊调用惯例,thiscall 专门用于类成员函数的调用,对于一些编译器,this指针都当第一个参数压入栈中,但是对于VC的编译器使用ecx寄存器来存放this指针。
小于等于4字节的返回值直接通过eax寄存器返回;介于4字节到8字节的返回值,组合使用eax、edx返回返回值;大于8字节的返回值,会有一个临时对象产生,eax指向这个临时对象的首地址,使用eax返回指针。
堆分配算法:
- 空闲链表:把堆中各个空闲的块按照双向链表的方式连接起来,当用户请求一块空间时,可以遍历整个链表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中
- 位图:将整个堆划分成大量的块,每个块大小相同。当用户请求的时候,总是分配整数个块给用户,第一块成为已分配区域的头,用H标记,其余的成为已分配区域的主体,用B标记,空闲的块用F标记。我们可以使用一个整数数组来记录块的使用情况,前述有三种状态,因此需要2位来存储状态。这种方法有几种优点:速度快、稳定性好、易于管理;当然也有缺点:浪费内存,位图大,cache命中率不高
- 对象池:它假定每次总是请求一个固定单位的内存。GNUC2.9
第十一章 运行库
程序真的是从main函数开始的吗?不是的,还有一些在main之前需要干的事,这些代码的函数称为入口函数或入口点,一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交到了程序入口,这个入口往往是运行库中的某个入口函数
- 入口函数对运行库和程序运行环境进行初始化,包括堆、IO、线程、全局变量构造等等
- 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分(main)
- main函数执行完毕之后,返回入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭IO,然后进行系统调用结束进程
glibc入口函数执行流程如下:_start -> __libc_start_main -> exit -> _exit。其中,__libc_start_main的调用为:__libc_start_main(main, argc, argv, __libc_csu_init, __libc_csu_fini, edx, top of stack),各个参数含义如下:
- main就是要执行的主函数
- argc,参数个数
- argv,具体参数,后面还隐含环境变量表
- __libc_csu_init,main之前的初始化工作,比如说全局对象的构造
- __libc_csu_fini,main之后的扫尾工作,比如说全局对象的析构
- 后面的两个参数和函数栈相关
Linux中IO初始化:实际上的文件描述符对应内核态中FILE结构数组的下标,0、1、2分别对应标准输入、标准输出、标准错误,IO初始化做的事就是在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。
堆初始化工作:初始化堆的起始地址、初始化空闲链表
任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其依赖的函数所构成的函数集合。这个代码集合称之为运行时库,CRT。一个C语言运行库大致包含如下功能:
- 启动与退出:包括入口函数及入口函数所依赖的其他函数
- 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现
- IO:IO功能的封装和实现
- 堆:堆的封装和实现
- 语言实现:语言中的一些特殊功能实现
- 调试:实现调试功能的代码
线程局部变量TLS:在Windows中的实现,是通过把TLS变量放进tls段中。当系统启动一个新的线程时,他会从进程的堆中分配一块足够大小的空间,然后把tls段中的内容复制到这块空间中,于是每个线程都有自己独立的tls副本。
觉得书上这段讲缓冲的话很助于理解:缓冲最为常见于IO系统中,设想一下,当希望向屏幕输出数据的时候,由于程序逻辑的关系,可能要多次调用pringf函数,并且每次写入的数据只有几个字符,如果每次写数据都要进行一个系统调用,让内核向屏幕写数据,就明显过于低效,因为系统调用的开销是很大的,他要进行上下文切换、内核参数检查、复制等,如果频繁进行系统调用,将会严重影响程序和系统的性能。不管是读还是写,都可以用缓冲
第十二章 系统调用与API
系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。系统调用接口是通过中断来实现的,Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。为什么会有系统调用?因为用户态的代码有很多权限没有,必须通过内核态的代码才能完成任务。
系统调用得满足:明确的定义、保持稳定和向后兼容。
系统调用时,EAX寄存器用于表示系统调用的接口号(中断有编号,系统调用也有编号)。
中断具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用他。中断程序在执行完成后,CPU会继续执行之前的代码。
基于int的Linux的经典系统调用实现:
- 触发中断:x86下Linux支持的系统调用参数之多有6个,分别使用6个寄存器来传递,他们分别是EBX、ECX、EDX、ESI、EDI和EBP
- 切换堆栈:用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。因此,在用户态和内核态进行切换的时候需要保存SS、ESP、EFLAGS、CS、EIP寄存器的值,中断返回时恢复
- 中断处理程序:查系统调用表