文章目录
第七章 链接
- 概述
链接在软件开发中扮演关键角色 => 他们使得分离编译成为可能
7.1 编译器驱动程序
- 链接发生的时间
1.编译时:源代码翻译成机器码时(传统静态链接)
2.加载时:程序被加载器加载到内存并执行时(动态链接)
3.运行时:应用程序执行时才链接需要的部分(动态链接) - 编译链接的过程(静态)
1.ccp、ccl、as翻译后的文件后缀分别为iso
2.如何执行prog? =>shell调用操作系统中叫做加载器的函数,加载器将prog的代码和数据复制到内存,然后将控制转移到程序的开头
7.2 静态链接
- 链接器的两个任务
1.符号解析;
2.重定位
注:目标文件纯粹是字节块的集合,链接器将这些块链接起来,确定被连接块的运行时位置并且修改代码和数据块中的各种位置。
7.3 目标文件
- 三种形式的目标文件
可重定位目标文件(.o):包含二进制代码和数据,编译时与其他可重定位文件合起来创建可执行目标文件
可执行目标文件(.out):包含二进制代码和数据,可以被复制到内存直接执行
共享目标文件(.so,share之意):一种特殊的可重定位目标文件,可在加载或运行时动态地加载进内存并链接
注:编译器和汇编器生成可重定位目标文件; 链接器生成可执行文件目标文件 - 不同系统下的目标文件
windows => PE(Portable Executable)
linux => ELF(Executable and Linkable Format),重定位、可执行、共享三种目标文件都是ELF格式,但是又略有区别
7.4 可重定位目标文件(.o)
- ELF可重定位目标文件
ELF头:保存一些综合性的信息(系统字的大小、文件类型、机器类型、节头部表的偏移…等)
.text:已编译程序的机器代码
.rodata:只读数据(如printf语句中的格式串和开关语句的跳转表)
.data:已经初始化的全局和静态C变量(注意与.bss的对比;没有局部变量,因为局部变量运行时保存在栈中,不会出现在任何节)
.bss:未初始化的以及被初始化为0的全局或静态C变量。这个节不占空间(未初始化的变量不需要占据任何实际的磁盘空间,运行时直接在内存分配,初值为0,而不是从磁盘加载数据!!)=>区分已初始化和未初始化就是为了节约空间 better save space
.symtab:符号表,存放函数和全局变量信息(主要来自编译器,但区别于编译器中的符号表,不含局部变量的条目)
.rel.text:列表,存储的是.text节中的一些位置,链接时需要修改这些位置
.rel.data:被模块引用或定义的所有全局变量的重定位信息,链接时需要修改
.debug:一个调试符号表
.line:行号与机器指令间的映射
.strtab:字符串表(.symtab和.debug中并未指出符号的写法,符号对应的字符串存储在.strtab中)
节头部表:描述不同节的位置和大小
(主要从几大方面记忆=> 元数据、代码、数据、符号表) - 可重定位目标文件中为什么没有局部变量
局部C变量在运行时被保存在栈中,编译期并不会专门标识出局部变量,可简单理解为它直接被编译进了机器码,没有专门的名称,运行时相应数据出现在栈或者寄存器中,而不会出现在堆内存;所以局部变量既不出现在.data节、也不出现在.bss节,.symytab中也不存在局部变量的符号条目
可参考:data段、text段、rodata段都不存局部变量,那没有加载到内存的栈之前,局部变量到底存在哪呢? - 区分节和段
section:ELF可重定位目标文件和可执行目标文件都有的说法
segment:ELF可执行目标文件的说法,多个相关的节组成段(见7.8)
7.5 符号和符号表
- 链接器上下文中的三种符号
全局符号:由模块m定义并能被其他模块引用。对应于非静态的C函数和全局变量
外部符号:由其他模块定义并被模块m引用。对应于非静态C函数和全局变量
局部符号:只被模块m定义和引用。对应于带static属性的C函数和全局变量
进一步理解(四点都很重要):1.符号表中的符号仅包含函数和全局变量(前两者不带static属性,后者带有static属性); 2.C中被static修饰的函数和全局变量表明其是模块私有,类似于C++中的private; 3.可重定位目标文件的.symtab中不包含本地非静态程序变量的任何符号(即不含没有被static修饰的局部变量),这些符号在运行时栈中管理; 4.被static属性修饰的局部变量却不是在栈中管理。相反,链接器会在.data或者.bss中为每个定义分配空间(=> 参考7.4中ELF格式) - 符号表条目
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号(即汇编器/ELF可重定位目标文件中的符号表主要来自编译器)
符号表(.symtab)是一个数组,每一项对应的结构如下:
value字段:对于可重定位目标文件来说,value是距离定义目标的节的起始位置的偏移;对于可执行目标文件来说,该值是一个绝对运行时地址typedef struct { int name; /* String table offset => 对应的字符串在strtab中的偏移 */ char type:4, /* Function or data (4 bits) */ binding:4; /* Local or global (4 bits) */ char reserved; /* Unused */ short section; /* Section header index */ long value; /* Section offset or absolute address => 很重要,见下面解释 */ long size; /* Object size in bytes */ } Elf64_Symbol;
section字段:每个符号都被分配到目标文件的某个节中,由section字段表示;section是一个到节头部表的索引(在节头部表中查找节的详细信息)关于伪节…见下面
伪节:section字段填充的可能不是具体的索引,而是表名该条目是一个伪节的宏; => ABS代表不该被重定位的条目;UNDEF代表代表未定义的符号(即引用的外部模块中的符号);COMMON代表还未被分配位置未初始化的数据目标(P469COMMON与.bss的关系)
… - 符号表条目示例
1.上图符号表中前8个条目未显示,它们在链接器内部使用
2.全局符号main是一个位于.text(Ndx指明所在节)节中偏移量为0(value指明偏移)出的24字节函数(size指明大小)
3.全局符号array是一个位于.data节中偏移量为0处的8字节目标
4.sum是来自对外部符号sum的引用
7.6 符号解析(重要)
-
关于符号解析
1. 符号解析由链接器完成
2. 符号解析就是将代码中每个符号引用与可重定位文件的符号表中的符号定义关联起来(即找出代码中引用的符号的定义,这就是符号解析!!!)
3. 局部符号的解析比较简单;全局符号的解析麻烦 => 当编译器遇到一个不是在当前模块中定义的符号(变量或函数名),会假设该符号定义在其他模块中,生成一个连接器符号表条目,并把它交给连接器处理 -
解析多重定义的全局符号
处理多重定义全局符号的规则:函数和已初始化的全局变量是强符号;未初始化的全局变量是弱符号 =>
1. 不允许有多个同名的强符号
2. 如果有一个强符号和多个弱符号同名,那么选择强符号
3. 如果有多个弱符号同名,那么任意选择一个
示例:
-
与静态库(.a)链接
静态库:相关函数被编译为独立的可重定位目标模块(.o),然后封装成一个单独的静态库文件(.a,archive);在链接时,连接器将只复制静态库中被程序引用的目标模块,从而减少了生成的可执行目标文件在磁盘和内存中的大小;
与静态库链接示意图
图中libvector.a是自定义的静态库,其中包含addvec.o模块,会在main2.c中调用该模块中的addvec方法;libc.a则是C语言自带的静态库… -
如何使用静态库来解析引用
目的:将静态库中符号定义与用户代码中的符号引用关联起来
方法:连接器通过维护E、U、D三个集合…详见P477
注意:链接器解析静态库的方法导致命令行上的库和目标文件的顺序非常重要;在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能解析
7.7 重定位(重要)
-
概述
1. 符号解析将符号引用和符号定义联系起来,完成之后便可进行重定位(都由链接器完成)
2. 重定位的主要任务是合并输入模块,为每个符号分配运行时地址
3.* 重定位的两个步骤:重定位节和符号定义、重定位节中的符号引用 -
重定位条目
汇编器生成一个可重定位目标模块时,它并不知道代码和数据最终将放在内存的哪个位置,也不知道它引用的外部函数/全局变量的位置 => 无论何时汇编器遇到对最终位置未知的目标引用,它就生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用;代码的重定位条目放在rel.text,数据的重定位条目放在rel.data。
ELF重定位条目格式如下:typedef struct { long offset; /* 要修正的位置(到其所在节的起始地址的距离) => 要修正的位置:即引用该符号的地址 */ long type:32, /* 重定位类型:R_X86_64_PC32、R_X86_64_32*/ symbol:32; /*该引用在符号表中的索引/下标 */ long addend; /*计算重定位地址时用于偏移调整 */ } Elf64_Rela;
重定位类型:R_X86_64_PC32、R_X86_64_32
R_X86_64_PC32 => PC相对寻址;
R_X86_64_32 => 绝对寻址 -
重定位节和符号定义
重定位的第一个步骤; 链接器将所有相同类型的节合并为同一类型的新的聚合节(比如,将所有可重定位目标模块中的.data节合并为可执行目标文件中的.data节)。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节、以及赋给输入模块定义的每个符号。这一步完成时,程序中每一条指令和全局变量都有唯一的运行时内存地址了 -
重定位符号引用(较难,慢慢理解)
重定位的第二个步骤;这一步中链接器修改代码节和数据结构中对每个符号的引用,使得他们指向正确的运行时地址(为什么需要这一步骤? => 符号对应的代码/数据只有一份且存储在固定的地方。应用程序中出现符号引用时,需要找到这个符号对应的代码/数据的真实位置,才能使用这部分代码/数据)
1.重定位符号引用的算法
图中:
(1).ADDR(s)是节s重定位后的运行时地址,ADDR(r.symbol)是符号定义重定位后的运行时地址
(2).refptr是指向引用的指针,refaddr是引用的运行时地址,两者其实本质是一样的;只是前者是指针,后者是整型
(3).addend的作用:用于调整重定位结果,详见下文
2.重定位符号引用示例
2.1 重定位PC相对引用
o 以图7-11中main.o为例,它包含了符号引用sum()函数的重定位条目(只是显示工具没有将其放在ref.text显示)f:R_X86_64_PC32 sum -0x4
o 这个重定位条目告诉连接器:修改开始于偏移量0xf处的32位PC相对引用(即第六行所示的 00 00 00 00四个字节),这样在运行时它可以指向sum函数
o 假设链接器已经确定main.o模块中的.text节在运行时的地址为:ADDR(s)=ADDR(.text)=0x4004d0
假设sum.o模块中,sum()函数符号定义的运行时地址为:ADDR(r.symbol)=ADDR(sum)=0x4004e8
o 则按照PC相对寻址的重定位符号引用算法,计算过程如下:
refaddr=ADDR(s)+r.offset
=0x4004d0+0xf
= 0x4004df //符号引用的运行时地址(这个地址处存放重定位结果)
*refptr=ADDR(r.symbol)+r.addend-refaddr
=0x4004e8+(-4)-0x4004df
=0x5 //重定位结果(=>计算定义的地址,存放在引用的运行时地址处)
o 综上,重定位后的sum函数将有如下结果(两个重定位模块合并成可执行文件后):
.4004de=ADDR(s)+0xe (这行指令所在节的运行时地址+指令相对于节的偏移)
.05000000(小端)=*refptr(重定位结果,PC相对寻址用其计算函数运行时地址)
.callq 4004e8=ADDR(r.symbol),要调用的函数/符号的运行时地址(注意这个地址是运行时计算所得,而不是直接写入指令的)
o PC相对寻址的过程:在运行时,call指令存放在0x4004de处,PC值为0x4004e3(call指令的下一个指令地址),为了调用sum函数,使用PC相对寻址 => 1.将PC值(0x4004e3)存放到栈中,作为函数调用的返回地址; 2.将寄存器中的PC值更新为PC+0x5用以计算sum函数的运行时地址,并跳转到该处调用sum函数
2.2 重定位绝对引用
o 重定位绝对引用相对比较简单。以图7-11中main.o为例,它包含了符号引用array数组的重定位条目(只是显示工具没有将其放在ref.data显示)
a:R_X86_64_PC array
o 这个重定位条目告诉连接器:修改开始于偏移量0xa处的绝对引用(即第四行所示的 00 00 00 00四个字节),这样在运行时它可以指向array数组的第一个字节
o 假设链接器已经确定ADDR(r.symbol)=ADDR(array)=0x601080
o 则按照重定位符号引用的规则有:
*refptr=ADDR(r.symbol)+r.addend=0x601018+0x0=0x601018
o 重定位后的array数组结果如下(合并后的可执行目标文件):
其中的0x601080就是计算array数组的运行时地址
o 结合2.1、2.2,完整的重定位结果如下:
3.难点小结
1.符号引用在可执行文件中的存在形式:它不再是代码中函数/数据的命名符号,它只是一个地址(运行时函数/数据在虚拟内存中的位置)
2.对于函数的符号引用,使用PC相对寻址;对于数据的符号引用,使用绝对寻址
3.addend字段的作用:重定位符号引用时用于修正重定位结果。对于PC相对寻址来说,addend=-4/-8(因为PC需要更新到下一条指令,这里addend就是当前指令处符号引用的字节长度);对于绝对寻址来说,addend=0(只需找到数据的运行时地址,不必考虑这个地址的长度)。
7.8 可执行目标文件
- ELF可执行目标文件格式
1.可执行目标文件类似于可重定位目标文件(它本身就是由多个可重定位目标文件合并而来),但是又略有区别,注意区分
2.ELF头包含文件的一些元数据,还包含程序的入口点(即程序运行时要执行的第一条指令的地址)
3.注意节和段的区别 => 可执行目标文件中多个相关的节组成段
4.段头部表的作用:将连续的文件节映射到运行时内存段(与操作系统的段式内存管理相关) - 关于段头部表
段头部表描述了可执行目标文件中的节到内存段的映射关系(与段式内存管理相关),如图所示:
图中更多细节的理解参考P484 - 为什么要将节合并为段
如果对于可执行文件按节加载,那么每个节剩余不足一个page的部分都得单独分配一个物理页,这就造成较多的页内碎片;而合并成段并按段加载,则能减少很多页内碎片;注:相同权限的节合并到一个段
7.9 加载可执行目标文件
- 程序运行时的内存映像
- 加载器的过程
1.shell运行一个可执行目标文件
2.shell父进程生成一个子进程
3.子进程调用execve()启动加载器,加载器是一段内核代码
4.加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆、栈段
5.将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk)
6.最后加载器跳转到_start()函数的地址,它最终调用应用程序main函数
注意:上述加载过程中,出了一些头部信息外,没有任何从磁盘到内存的数据复制 => 直到CPU引用一个被映射的虚拟页时才会进行复制,此时操作系统利用其页面调度机制,将页面数据从磁盘传入内存 - 辨析:段、节、页、片
节(section):只是可重定位目标文件和可执行目标文件中为了管理数据的一种划分。一个节由多个片(chunk)组成,多个节又可组成段
段(segmention):是对内存映像的建模(代码段、数据段、堆栈段等),同时又是可执行目标文件中的一种数据划分,且两者存在一定程度上的对应关系(比如可执行文件中的代码段就是内存映像中的代码段)
页(frame):内存映像中的一种数据划分,段页式内存管理中,将一个段划分成若干大小相等的页
片(chunk):可执行文件中与页大小相等的数据,页与片相对应,在磁盘中称为块
7.10 动态链接共享库(对比静态库)
- 为什么需要动态共享库/静态库的缺点
1.如果使用静态库,更新静态库时必须显示地将更新后的静态库和应用程序重新链接(静态库必须在加载/运行前进行链接)
2.静态库中的一些基础函数,几乎在所有进程中都被使用(比如printf、scanf),这些函数会被复制到每个运行进程的文本段中(即同样的内容被复制了很多次),造成内存资源浪费 - 共享库
共享库是一个目标模块,可以在加载 或 运行时,加载到任意的内存地址, 并和一个在内存中的程序链接起来(而静态库必须在加载/运行前进行链接);这个过程称为动态链接,是由动态链接器执行,共享库也成共享目标(shared object,以.so为后缀)
(回忆)三种目标文件:可重定位、可执行、共享 - 共享库是如何实现共享的
1. 在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个so文件中的代码和数据;而不是像静态库的内容那样被复制和嵌入到引用它们的可执行文件中;
2. 在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享(如何做到?) - 加载时的共享库动态链接(运行时动态链接见7.11)
基本思路:生成可执行目标文件时,仅静态执行部分链接;程序加载时,再完成剩余的动态链接
1.生成可执行文件阶段:静态链接器仅复制部分重定位和符号表信息,而没有将.so文件中的代码节和数据节复制到可执行目标文件中 => 生成的是部分链接的可执行文件
2.加载阶段:部分链接的可执行文件中包含.interp节,加载器读取.interp节中的动态链接器路径,然后加载并执行动态链接器。以上图为例,加载阶段动态链接器主要完成以下任务 => a.重定位libc.so的文本和数据到某给内存段; b.重定位libvector.so的文本和数据到另一个内存段; c.重定位prog21中所有对由libc.so和libvector.so定义的符号的引用; d.动态链接器将控制传递给应用程序…
7.11 从应用程序中加载和链接共享库
-
运行时动态链接的应用(加载时动态链接见7.10)
1.分发软件:目标模块更新时,不必将应用程序和更新后的目标模块一起重新静态链接,动态链接器可以在应用程序运行时直接加载和链接共享库!
2.构建高性能web服务器:可以将每个生成动态内容的函数打包在共享库中。当请求到达时,服务器动态地加载和链接适当函数,然后直接调用它,而不是使用fork和exeve在子进程的上下文中运行函数 => 不用产生子进程,只需一个函数调用的开销,节约的资源 -
运行时链接共享库的相关系统函数
// 加载和链接共享库filename, // flag参数:RTLD_NOEW=> 让链接器立即解析对外部符号的引用 // flag参数:RTLD_LAZY=> 让链接器推迟符号解析直到执行来自库中的代码 // 返回一个指针,指向共享库句柄(此指针代表整个库,想调用库中某个函数,使用dlsym) void *dlopen(const char *filename, int flag); //输入:dopen打开的共享库的句柄,以及一个符号(函数或者全局变量)的名字 // 如果该符号存在,则返回符号的地址 void dlsym(void *handle, char *symbol); //如果没有其他共享库还在使用这个共享库,则写在该共享库 int dlclose(void *handle);
-
运行时动态链接示例
生成共享库:gcc -shared -fpic -o libvector.so addvec.c multvec.c
编译时指明动态链接:gcc -rdynamic -o prog2r dll.c -ldl
#include <stdio.h> #include <stdlib.h> #include <dlfcn.h> int x[2] = {1, 2}; int y[2] = {3, 4}; int z[2]; int main() { void *handle; void (*addvec)(int *, int *, int *, int); /*函数指针*/ char *error; /* Dynamically load the shared library that contains addvec() */ /*RTLD_LAZY参数指示动态链接器推迟符号解析,直到执行来自库中的代码*/ /*handle是指向整个共享库的句柄,想调用句柄中的函数/全局变量 => 使用dlsym函数*/ handle = dlopen("./libvector.so", RTLD_NOW); if (!handle) { fprintf(stderr, "%s\n", dlerror()); exit(1); } /* Get a pointer to the addvec() function we just loaded */ /*获取共享库中的一个函数/全局变量*/ addvec = dlsym(handle, "addvec"); if ((error = dlerror()) != NULL) { fprintf(stderr, "%s\n", error); exit(1); } /* Now we can call addvec() just like any other function */ addvec(x, y, z, 2); printf("z = [%d %d]\n", z[0], z[1]); /* Unload the shared library */ if (dlclose(handle) < 0) { fprintf(stderr, "%s\n", dlerror()); exit(1); } return 0; }
-
JNI与动态共享库的关系
基本思想:讲本地C函数(如foo.c编译成共享库(foo.so),JAVA调用本地函数foo时 => JAVA解释器利用dlopen
动态链接和加载foo.so,然后在调用该函数
7.12 位置无关代码(重要,很绕,待深究)
- 概述
位置无关代码:对于共享目标模块的代码段,可以加载到内存的任何位置而无需链接器修改 => 可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)
同一目标模块中的PIC:直接使用PC相对寻址来编译这些引用,构造目标文件时由静态链接器重定位即可
外部定义引用的PIC:使用GOT和PLT表…正是本节讨论内容 => 分为PIC数据引用和PIC函数调用 - 多个进程如何使用同一个共享库
访问了的动态链接库的进程被加载时,系统会为这个进程分配4GB的私有地址空间(如果是32位机的话),然后系统就会分析这个可执行模块,找到这个可执行模块中将所要调用的DLL,然后系统就负责搜索这些DLL找到这些DLL后便将这些DLL加载到内存中,并为他们分配虚拟内存空间,最后将DLL的页面映射到调用进程的地址空间汇总,DLL的虚拟内存有代码页和数据页,他们被分别映射到进程A的代码页面和数据页面,如果这时进程B也启动了,并且进程B也许要访问该DLL,这时,只需要将该DLL在虚拟内存中的代码页面和数据页面映射到第二个进程的地址空间即可。这也表明了在内存中,只需要存在一份DLL的代码和数据。详见:多个进程间共享动态链接库的原理
7.13 库打桩机制
- 概述
库打桩(library interpositioning)的作用:允许截获对共享库函数的调用,转而执行自己的代码 => 常用于追踪并验证函数的输入输出
三种库打桩方式:编译时、链接时、运行时
注:本节只讲理原理,如何使用gcc进行打桩详见P492 - 编译时库打桩
首先通过宏将库函数重定义为自己的函数;
然后编译自定义函数,在其中调用要跟踪的库函数;
最后用库函数名调用执行即可
- 链接时库打桩
gcc使用 --wrap f标志进行链接时库打桩 => 该标志让链接器将对符号f的引用解析为__wrap_f,对符号__real_f的引用解析为f
- 运行时库打桩
优点:编译时库打桩需要能访问程序源码,链接时库打桩需要能访问程序的可重定位目标文件 => 运行时库打桩只需要访问可执行目标文件(尚不理解?)
原理:运行时需要解析未定义的引用时,动态链接器优先搜索LD_PRELOAD环境变量对应的库 => 通过修改LD_PRELOAD环境变量(就是共享库路径名),对共享库中的任何函数打桩
实现:通过dlsym()调用共享库函数(注意运行时需要在命令行中修改环境变量)
7.14 处理目标文件的工具
AR:创建静态库
READELF:显示ELF文件的完整结构
OBJDUMP:所有二进制工具之母
…
7.15 小结
-
较大收获/易忘易混
1.链接发生的时间 => 编译时、加载时、运行时
2.什么是符号解析、什么是重定位
3.三种形式的目标文件
4.ELF可重定位目标文件格式(为什么需要bss、为什么没有局部变量)
5.链接器上下文中的三种符号(区分符号引用和符号定义)
6.如何处理多重定义的全局符号
7.什么是静态库、静态库的作用
8.什么是重定位,重定位的步骤
9.ELF可执行目标文件的格式(注意段头部表)
9.区分:段、节、页、片
10.共享库如何实现共享的、共享库链接的过程
11.为什么需要使用共享库(节约磁盘、内存)
12.运行时链接共享库
13.JNI与动态链接库的关系
14.__lib_start_main()函数 -
疑惑
为什么要使用PC相对寻址?
第八章 异常控制流
- 异常控制流(ECF)
1.正常情况下的控制流中,指令序列都是连续的(即 I k I_k Ik和 I k + 1 I_{k+1} Ik+1指令在内存中是相邻的)
2.也存在一些控制流的突变(即 I k I_k Ik和 I k + 1 I_{k+1} Ik+1指令不相邻),这些突变通常是由跳转、调用、返回等指令引起 => 将这些突变的控制流(前后指令不相邻)称为异常控制流(EFC) - 异常控制流的几种形式
异常 => 位于硬件和操作系统交界的部分
信号 => 位于操作系统和应用的交界之处
非本地跳转 => 位于应用层的ECF
8.1 异常
-
异常处理过程
1.硬件或者软件都可能导致异常,前者比如来自IO的中断
2.系统为每个异常分配了一个异常号;系统启动时也创建了一张异常表,异常表的基址放在专门的寄存器中;异常发生时,通过异常表找到异常的处理程序(过程如图所示),然后将控制交给异常处理程序…
-
异常与过程调用的区别
1.过程调用返回地址是下一条指令;而异常处理程序的返回地址可能是下一条指令也可能是当前指令(比如处理缺页故障后就应该返回当前指令)
2.普通的过程调用运行在用户模式下;而异常处理程序运行在内核模式下
3.对于异常处理,控制从用户模式转移到内核模式,压栈过程是将相关数据压入内核栈而不是用户栈!!!每个进程都有自己的内核栈,它在内核空间,作用与用户栈相同,只是在内核态下使用 -
异常的分类
1.异常可分为中断、陷阱、故障、终止4类
2.中断通常是由处理器外部的IO设备通过系统总线向处理器引脚发送信号触发,可见中断属于硬件异常;又称外部异常;异步的
3.陷阱、故障、终止是通过执行某些指令触发的,软件触发;在处理器内部,又称内部异常;同步的
4.异常的划分说法不统一,也有人将除中断外的三种异常称为(内部)异常,中断仍然称中断=> (但个人认为分为上面四类更好!!)
5.陷阱是实现系统调用的途径
6.故障处理程序结束后,要么修复了故障返回引起故障的指令处,要么没有修复故障直接终止(但是都不可能返回下一条指令),比如缺页故障 -
Linux/x86-64中的常见异常
上图中的“一般保护故障”产生的原因很多,通常是由于程序引用了一个未定义的虚拟内存区域,shell通常将其报告为Segmentation fault -
Linux/x86-64中的系统调用
1.系统调用通过陷阱实现,可见系统调用本质上就是异常处理程序;不过系统调用不使用异常表,而是有自己对应的跳转表
2.每一个系统调用都有一个唯一的整数号码,对应于一个到内核中跳转表的偏移量(不使用异常表!!)
3.通过system指令进行系统调用(注意所有系统调用的参数传递都是通过通用寄存器而不是栈!!!),常见系统调用如下:
8.2 进程
-
上下文
系统中每个程序都运行在某个进程的上下文中,这里上下文指程序运行所需要的一些状态,包括:内存中程序的代码和数据、栈、通用目的寄存器中的内容、程序计数器、环境变量、打开文件描述符等
=> shell中输入可执行目标文件后,shell会创建新的进程,在新的进程的上下文中运行程序 -
进程提供给引用程序的关键抽象
1.一个独立的逻辑控制流 => 导致程序独占地使用处理器的假象
2.一个私有的地址空间 => 导致程序独占地使用内存资源的假象 -
逻辑控制流
对于每各进程中指令的执行序列,PC值的序列就是逻辑控制流
每个进程对应一个逻辑控制流 => 所有进程加起来对应一个物理控制流
-
并发流
一个逻辑流的执行在时间上与另一个逻辑流重叠,称为并发流
下面来真正理解并发(如逻辑控制流示意图):
进程A和B属于并发
进程A和C属于并发
进程B和C不属于并发(因为B和C在时间上并没有重叠)!!! -
私有地址空间
私有的含义=> 和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的
注意:虚拟地址控制总是从0x400000开始使用;地址空间顶部保留给内核(高16位) -
用户模式和内核模式
1.进程从用户模式进入内核模式的唯一方法通过诸如中断、故障、陷入系统调用这样的异常;
2异常发生时控制传递到异常处理程序时,进程由用户模式进入内核模式;异常处理程序返回时,进程从内核模式编程用户模式;
3.用户模式访问内核数据结构的方式 => /proc,它将许多内核数据结构的内容输出到该目录下的相应文本文件中
4.处理器通过某个寄存器中的一个模式位(bit)来标志进程所处模式 -
上下文切换
1.内核为每个进程维持一个上下文
2.上下文就是进程相关的一组状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、各种内核数据结构(比如页表、进程表、打开文件表等)
3.上下文切换是由内核进行调度的,主要进行的工作有:a.保存当前进程的上下文;b.恢复之前某个进程的上下文;c.将控制传递给恢复的进程
4.进程上下文切换的触发条件
a.陷阱 => 内核代表用户执行系统调用时,如果系统调用因为等待某个时间而发生阻塞,内核可以切换到另一个进程;
b.中断 => 所有系统都有周期性定时中断的机制,每次定时器中断时,内核就能进判断当前进程时间片用完,从而切换进程
综上,进程上下文切换必须在内核模式下。系统调用时本身就处于内核模式故可以直接切换;而在用户模式运行时,定时中断的目的既是对结束时间片也是为了进入内核(区分进程上下文和中断上下文)
8.3 系统调用错误处理
- Unix的系统调用错误处理
当Unix系统级函数遇到错误时,他们通常返回-1,并设置全局整数变量errno来表示出了什么错 - 错误包装函数
本书使用了错误包装函数,如foo() => Foo()
包装函数调用基本函数,检查错误,如果有任何问题就终止 => 既能保持简洁,又能避免编程时繁杂的错误检查
8.4 进程控制(系统调用)
- 获取进程ID(getpid)
getpid()获取进程id
getppid()函数获取父进程Id - 创建和终止进程(fork、exit)
终止进程 :void exit(int status); exit()函数以status退出状态来终止进程(其实C编译器会默认在main函数返回点出调用exit函数)
fork:pid_t fork();
1.子进程得到与父进程用户及虚拟地址空间相同的一份副本(父子进程最大区别仅在于pid);
2.fork函数调用一次返回两次; - 关于fork()调用一次返回两次
1.子进程复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待返回。因此所谓fork函数返回两次是因为两个进程都调用了fork(),一次在父进程中返回,一次在子进程中返回
2.由于对父进程的拷贝,父子进程的代码段是相同的。所以需要根据fork返回的pid区分父子进程,从而两个进程执行后面各自的部分;否则两个进程将执行完全相同的代码 - 回收子进程(waitpid、wait)
1.僵死进程:一个终止了但还未被回收的进程称为僵死进程;通常进程终止时,为了向其父进程提供信息,内核并不删除其进程描述符,直到父进程回收子进程;如果父进程还未回收其子进程就终止,内核会安排init进程回收僵死进程
2.回收子进程的函数:pid_t waitpid(pid_t pid, int * statusp, int options);
默认情况(options==0),waitpid()挂起调用进程的执行,直到它等待的子进程集合中任意一个终止,然后回收子进程 =>为了回收所有子进程,调用waitpid时需要使用循环!! ;wait()函数则是waitpid()函数的简化版 - 让进程休眠(sleep、pause)
sleep:unsigned int sleep(unsigned int secs);
将一个进程挂起一段指定时间 => 若指定时间已到则返回0;若被信号中断则返回剩余时间
pause:让调用函数休眠,直到该进程收到一个信号 - 加载并运行程序(execve)
execve:int execve(const char* filename, const char *argv[], const char *envp[]);
加载并执行目标文件filename,且传递参数和环境变量;被调用的程序必须有如下格式的主函数:int main(int argc, char **argv, char **envp);
详解:这部分内容一般发生在fork()和vfork()之后,在子进程中通过系统调用execve()可以将新程序加载到子进程的内存空间。这个操作会丢弃原来的子进程execve()之后的部分,而子进程的栈、数据会被新进程(说新进程不准确,新的程序会覆盖当前进程的地址空间,但是并没有在子进程中创建新的进程)的相应部分所替换。即除了进程ID之外,这个进程已经与原来的进程没有关系了。所以常说execve不会返回;常见用处 => shell - 利用fork和execve运行程序
以shell为例…
8.5 信号
-
概述
信号:就是一条消息,用于将系统中发生的某些事件通知给进程;信号是一种软件形式的异常控制流(因为允许中断其他进程,发生控制转移,转而执行信号处理程序);
常见的Linux信号如下:
比如:子进程终止会发送SIGCHLD信号给父进程、 -
信号术语
发送信号:内核通过更新目的进程上下文中的某个状态(具体而言是task_struct中的signal字段),达到发送信号的目标
发送信号的原因:1.内核检测到一个系统事件,比如除零错误或者子进程终止; 2.一个进程可以调用kill函数(不一定是杀死进程的含义),显示地要求内核发送一个信号给目的进程 =>(理解): 1中的内核发送信号是指内核代替用户进程执行系统调用的过程中产生异常并发出信号;2中的进程发送信号是指用户进程直接通过系统调用发送信号给目的进程
接收信号:当目的进程被内核强迫以某种方式对信号做出反应(处理)时,它就接收了信号;对信号的反应(处理)有三种方式=> 忽略、执行信号处理程序、执行系统默认动作,如果不忽略信号,则会发生控制转移,如图所示:
待处理信号:已发出而尚未被接收的信号 => 任何时刻,一种类型的信号最多只会有一个待处理信号(由pendding维护);可以选择性地阻塞某种信号(由blocked维护) -
发送信号
进程组:每个进程都只属于一个进程组,所有信号发送机制都是基于进程组的;pid_t getpgrp(void); //返回进程组ID
int setpgid(pid_t pid, pid_t pgid); //改变进程所属进程组
用/bin/kill程序发送信号:kill可以向另外的进程发送任意信号
从键盘发送信号:shell使用作业(job)这个抽象概念来表示对一条命令行参数求值而创建的进程。至多只有一个前台作业,可以有多个后台作业; Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,结果是终止前台作业
用kill函数发送信号:int kill(pid_t pid, int sig);
用alarm函数发送信号:unsigned int alarm(unsigned int secs);
进程通过alarm()函数向它自己发送SIGALRM信号 => 安排内核在secs秒后发送信号给调用进程 -
接收信号
接收信号的时间:进程从内核模式返回到用户模式时!!! => 当内核把进程p从内核模式切换到用户模式时(比如系统调用返回或者上下文切换),检查待处理信号的集合(pending),选择值最小的信号,中断原有程序,控制转移到其信号处理程序
设置信号处理程序:sighandler_t signal(int signum, sighandler_t handler);
参数handler就是用户定义的函数的地址
注意:当信号处理程序执行其return语句时,控制通常传递回控制流中进程被信号接收中断位置处的指令;信号处理程序可以被其他信号处理程序中断 -
阻塞和解除阻塞信号
隐式阻塞:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
显式阻塞:应用程序可以使用sigprocmask函数及其辅助函数,明确地阻塞和解除阻塞选定的信号(被阻塞时,信号可以被发送,但是在进程中由blocked维护,不会被接收处理) -
编写信号处理程序
…略 => 主要讲一些技巧,参考书籍P533-p540 -
同步流以避免并发错误
通过阻塞信号(暂时不执行信号处理程序),完成进程间的同步,避免进程间的竞争
… => 更多参考P540-543、第12章并发编程 -
显示地等待信号
int sigsuspenfd(const sigset_t *mask);
用mask暂时替换当前的阻塞集合,然后挂起当前进程,直到收到一个信号; 作用与pause()相似,但是能有效避免竞争(因为它修改了阻塞集合,且该函数是原子的,详情参考P545-P546)
8.6 非本地跳转(应用层ecf)
-
概述
非本地跳转是纯应用层的ecf(区别于异常和信号);
本地跳转指goto语句,只能在函数内部跳转;非本地跳转可以不经过函数的调用返回就将控制转移到任意地方
实现方式=> setjmp()、longjmp()//在env缓冲区中保存当前调用环境,供后面的longjmp使用,并返回0 //调用环境包括:程序计数器、栈指针、通用目的寄存器 int setjmp(jmp_buf env); //从env缓冲区中恢复调用环境, //然后触发一个从最近一次初始化env的setjmp调用的返回 !!! //然后setjmp返回并带有返回值retval!! => setmjp调用一次返回多次 void longjmp(jmp_buf env, int retval);
-
setjmp和longjmp的返回次数问题
setjmp()函数只被调用一次,但返回多次;longjmp()函数调用一次且从不返回
原因:setjmp函数将该函数处的环境(程序计数器、栈指针、通用目的寄存器)保存到全局变量env中,并完成第一次返回; longjmp()函数实际上就是将控制跳转到env对应的代码处(即setjmp的return语句处),于是相当于再次从setjmp返回,只是此时的返回值来自longjmp => 可见setjmp相当于设置要返回到的地方,longjmp相当于控制从代码任意地方返回到之前设置的位置,二者结合使用;不同于fork()的多次返回原理! -
非本地跳转的应用
1.从一个深层嵌套的函数调用中立即返回,而不用解开调用栈,通常检测到某个错误的情况;
2.使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令位置
案例见P548、P549
非本地跳转与C++、JAVA中的软件异常
C++、JAVA中的异常机制是C语言中setjmp、longjmp的更加结构化版本;可以将try语句中catch自己看做setjmp()函数,将throw字句看做longjmp函数
8.8 操作进程的工具
STRACE:打印一个正在运行的程序和他的子进程调用的每个系统调用的轨迹
PS:列出当前系统中的进程(包括僵死进程)
TOP:答应关于当前进程资源的使用信息
/proc:虚拟文件系统,输出大量内核数据结构的内容
…略
8.8 小结
-
较大收获/易忘易混
1.异常并不等同于ECF,异常只是ECF的一种形式(另外还有信号、非本地跳转等)
2.异常的处理过程(压栈到内核、返回地址不一定是下一条指令)
3.异常的分类(四类)
4.上下文包括的内容
5.理解并发 => 在时间上有重叠才算,否则即使在同一个较大时间段内也不算
6.理解“fork()调用一次返回两次”
7.理解execve()
8.理解信号的发送与接收(接收时间是内核态返回用户态时)
9.明白什么是作业(实际上就是进程)
10.明白什么是非本地跳转 -
疑惑
如何完成私有逻辑地址空间到内存实际地址的映射?=> 参考虚拟内存和存储器
第九章 虚拟内存
- 写在前面
虚拟内存的定义似乎存在不一致的说法,很多教材上将内存+交换空间称为虚拟内存;
阅读本章不应呆着这种狭隘的看法,将虚拟内存理解成虚拟地址空间以及对应的存储器层次结构可能更恰当
9.1 物理和虚拟寻址
- 物理寻址
早期的PC使用物理寻址,而且诸如数字信号处理器、嵌入式控制器以及Cray超级计算机仍然继续使用这种寻址方式;物理寻址如图所示:
- 虚拟寻址
现代处理器使用虚拟寻址 => CPU通过生成一个虚拟地址来访问主存,这个虚拟地址被送到内存之前会先转换成适当的物理地址(地址翻译),虚拟寻址如图示:
CPU芯片上有叫做内存管理单元(MMU)的专用硬件,它利用存放在主存中的查询表(页表?)来动态翻译虚拟地址,该表的内容由操作系统管理
9.2 地址空间
- 虚拟地址空间
在一个带虚拟内存的系统中,CPU从一个有 N = 2 n N=2^n N=2n个地址的地址空间中生成虚拟地址,现代系统通常支持32位或者64位虚拟地址空间 - 物理地址空间
物理地址空间对应于物理内存的M个字节(M不要求是2的幂!) - 地址空间的意义
地址空间清楚地区分了数据对象(字节)和它们的属性(地址),从而可以将其推广
=> 允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间,这就是虚拟内存的基本思想;主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址
9.3 虚拟内存作为缓存工具
-
概述
1.虚拟内存将主存看做是存储在磁盘上的内容的高速缓存(也就是说虚拟内存将磁盘看做存储空间,主存只是一个缓存);
2.以块作为磁盘和主存间的数据传送单元(块这个数据可用在多个地方,通常表示两级存储间的数据传送单元,磁盘和主存间的一个块包含若干扇区);
3.一个块大小虚拟地址范围称为虚拟页(VP),一个块大小的物理内存范围称为物理页(PP)
4.任何时刻,虚拟页面的集合都分为三个不相交的子集=>
未分配的:即该虚拟地址尚未和磁盘关联,所以不占磁盘空间
缓存的:虚拟地址已和磁盘关联,且磁盘数据已缓存到主存
未缓存的:虚拟地址已和磁盘关联,但磁盘数据未缓存到主存
如图所示(感觉这个图不是很好,以页表示意图为准),
-
DRAM缓存(主存)的组织结构
1.L1、L2、L3缓存(cache)使用SRAM
2.主存使用DRAM
3.DRAM大约比SRAM慢10倍,磁盘大约比DRAM慢10万多倍,所以DRAM不命中开销非常大
4.因为DRAM很大的不命中开销和访问磁盘第一字节的开销,虚拟页/物理页/块往往很大(4KB~2MB,包含若干扇区的数据量)
5.由于大的不命中处罚,DRAM缓存是全相联的,即任何虚拟页对应的磁盘数据都可以放到任何物理页中
6.由于对磁盘的访问时间很长,DRAM缓存总是使用写回而不是直写 -
页表
DRAM作为一个缓存,自然需要缓存管理(缓存管理包含哪些工作?参考6.3节!),对DRAM缓存的管理由操作系统+MMU+页表共同完成
每个进程有自己的页表!!!
页表的作用:对虚拟地址做一个映射 =>
a.若该虚拟地址尚未分配给某个磁盘块,则直接映射为null;
b.若该虚拟地址已分配给某个磁盘块,且磁盘块中的内容已经缓存到主存,则映射到主存地址(通常只是页号,由MMU根据页号翻译出主存地址)
c.若该虚拟地址已分配给某个磁盘块,但是磁盘块中的内容没有缓存到主存,则映射到磁盘地址
页表示意图
图中,有效位表示磁盘数据是否缓存到DRAM(对于未分配的虚拟内存,有效位自然为0); 页表的下标代表虚拟地址,页表项代表映射后的值
可见,教材中常说的页表将虚拟地址映射到物理内存地址是不全面的! -
页命中
…略 -
缺页
1.当CPU读取虚拟地址中已分配但是未缓存的数据时,导致缺页异常(见异常控制流);
2.缺页异常调用内核中的缺页异常处理程序,程序将虚拟地址对应磁盘位置的数据缓存到DRAM(当然可能涉及到页面调度,将被替换页写回磁盘),然后修改页表项,将虚拟地址映射到数据存放的主存地址/页号
3.当缺页处理程序返回时,它会重新启动导致缺页的指令,这时候由于数据已经缓存,能够成功命中 -
分配页面(仅操作磁盘和页表)
操作系统分配新的虚拟内存页时(比如调用malloc函数)会修改页表 ,如图(对比上一张页表示意图)
图中,VP5的分配过程是在磁盘上创建空间并更新PTE5,使他指向磁盘上这个新创建的页面 -
局部性
虽然DRAM缓存不命中会带来很大的开销,但是总体而言它依然工作得很好,这依赖于局部性!!
工作集:局部性保证了在任意时刻,程序将趋向于在一个较小的活动页面集合上工作,这个集合就是工作集
抖动:如果工作集大于物理内存的大小,则会导致抖动,这时页面将不断换进换出,开销很大!
9.4 虚拟内存作为内存管理工具
- 概述
实际上,操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间,从而简化了内存管理 => 多个虚拟页面可以映射到同一个共享物理页面上(思考:如何保证两个进程写该物理页面时不会出错?)
- 独立的虚拟地址空间的作用
1.简化链接
独立的地址空间使得每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存何处。从而链接生成可执行文件时直接统一使用虚拟地址即可,非常方便
2.简化加载
Linux加载器为代码和数据段分配虚拟页(注意如何分配的,见6.3),把他们标记为未缓存的,将页面条目指向目标文件对应的磁盘位置(注意加载器不会将数据从磁盘复制到内存,而是在使用时按需调度!);这种将一组连续的虚拟页映射到任意一个磁盘文件任意位置的方法称为内存映射,可通过mmap实现
3.简化共享
通常每个进程虚拟地址空间中的数据都是私有的 => 操作系统通过将不同进程中适当的虚拟页映射到相同的物理页面,从而可以使多个进程共享代码/数据
4.简化内存分配
…见9.3=>分配页面(注意内存分配只需操作磁盘和页表,并不马上将数据加载到物理内存)
9.5 虚拟内存作为内存保护工具(重要)
提供独立的地址空间使得区分不同进程的私有内存变得容易 =>
实现方法:在页表项上添加一些额外的许可位来控制对一个虚拟页面内容的访问
图中,SUP表示内核模式下才能访问! ;如果违反了访问许可,CPU就会触发故障“段错误”
注意:思考易知在地址翻译之前就已经进行了访问控制!
9.6 地址翻译
-
概述
使用页表进行地址翻译(有效位为0,则缺页中断)
页表从何而来? => 页表存放在内存,CPU中的 页表基址寄存器(PTBR) 指向(当前进程的)页表,需要查询页表时,就从内存调用页表的一个页表项(而不是整张页表!)
翻译过程(命中和缺页)
-
结合高速缓存和虚拟内存
应该使用虚拟地址还是主存物理地址来访问SRAM高速缓存?
=>通常是使用主存物理地址来访问缓存,所以地址翻译发生在查找高速缓存之前(方便权限控制等),访存过程如图:
注意:1.高速缓存SRAM有对应的缓存管理工具完成 根据物理内存地址查找相应缓存数据 的工作(详见第6章)
2.查页表也只是访问一个页表项,而不是调用整张页表 -
利用TLB(快表)加速地址翻译
由于页表在内存中,每次地址翻译都需要访存,开销较大(即使缓存在cache中,依然不如直接在CPU片内访问) =>
在MMU中设置了一个关于PTE的小缓存,称为快表(TLB)。快表是一个小的、虚拟寻址的缓存,如图,
注意:快表在CPU芯片内,使用虚拟寻址!!是MMU的一部分 => 区别于页表 -
多级页表
由于虚拟地址空间很大,如果只有一张页表,页表项必定很多,光是页表就会占用很大的内存 => 使用多级页表可有效改善这个问题
上面二级页表的优点
1.如果一级页表中的一个PTE是空的,那么相应的二级页表根部不会存在,从而极大地节约了内存
2.只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建、页面调入或者调出,从而减小了主存压力
扩展到多级页表
注意:1.除最后一级页表外的所有页表,都是到下一级页表的索引,页表项的值是下一级页表的虚拟内存地址!!
2.为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE,看起来开销很大,然而TLB的作用使得整个系统依然工作得不错
9.7 案例研究:i7/Linux内存系统
-
Core i7内存系统
注意到:L3缓存是所有核共享;每个核都有自己的MMU -
Core i7的地址翻译
如图所示,共四级页表;对TLB是虚拟寻址,而对cache(L1、L2、L3)以及内存都是物理寻址 -
Linux虚拟内存空间的划分
1.内核中也有内核栈,用户进程切换到内存之后便使用内核栈
2.内核中有一块总量等于DRAM的连续虚拟页面 映射到 DRAM的物理页面 => 方便内核直接访问任何物理特定的物理页面(比如访问页表、执行内存映射I/O操作等)
3.用户地址空间中有共享库的内存映射区域,方便使用共享库 -
Linux虚拟内存的组织方式
概述:虚拟内存被组织成段(也称区域)的集合,比如代码段、数据段、堆栈段;每个段包含若干虚拟内存页面,没有分配的虚拟页(即没有映射到内存/磁盘)是不计算在段内的,也不能被进程引用。
上图对应一个进程中虚拟内存区域的内核数据结构(可参考《Linux内核设计与实现》):
1.task_struct:包含进程的所有信息,由内核维护,图中标蓝的mm指向内存信息
2.mm_struct:描述虚拟内存的当前状态;其中pgd指向一级页表的基址,内核运行该进程时,就将pgd放到控制寄存器CR3中(或称为页表基址寄存器);mmap指向虚拟内存段(区域)的链表vm_area_struct
3.vm_area_struct:其中的vm_port字段描述这个区域(段)的读写权限;vm_flag描述此区域(段)的页面是进程共享的还是进程私有的 -
Linux缺页异常处理
当MMU翻译虚拟地址A且触发缺页异常时,通常会进行如下处理:
1.判断虚拟地址A是否合法:将虚拟地址A与vm_area_struct结构中的起始、终止地址进行比较,如果它不在任何段的区域内,则触发段错误!!
2.判断对A操作权限是否合法:…根据vm_port字段
3.进行页面调度:当1、2步骤都合法时,说明是访问的数据没有缓存 => …进行页面调度
9.8 内存映射(重要)
-
内存映射
定义:Linux通过将一个虚拟内存段(区域)与一个磁盘上的对象(文件或文件的一部分)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存可以映射到两种对象 => 普通文件、匿名文件
映射到Linux文件系统中的普通文件:一个虚拟内存区域可以映射到普通文件的连续部分。文件被分为页大小的片(chunk或者说是块block,一片/块对应若干扇区),磁盘上该片的数据用于初始化虚拟页的内容。注意,只有程序中实际引用这部分数据时才会调入内存(按需进行页面调度)
映射到匿名文件:匿名文件并不是磁盘上的文件,它是有内核创建的二进制0;初始化映射到匿名文件的虚拟内存页时,内核直接将对应的物理内存覆盖为0即可,这个过程并不存在磁盘与内存间的数据传送 -
交换空间(swap space)
一旦一个虚拟页面被初始化(修改了对应页表),它对应的数据就在物理内存和由内核维护的交换区间(swap space)之间换来换去;
为什么需要交换空间?:物理内存虽然作为磁盘的缓存,但是进程运行时并不会隐式地将数据放回磁盘,这一点区别于cache和DRAM间的缓存关系;因为物理内存是进程真正意义上的存储空间,进程运行时产生部分临时数据放在物理内存,而这部分数据在磁盘上并无对应的初始空间!! => 所以物理内存不够时,多余的数据不是直接放回磁盘,而是放入到交换空间(虽然交换空间也在磁盘上,但是它由内核直接维护,可看作物理内存的补充)
交换的单位:通常是以页为基本单位,根据不同的页面置换算法将物理内存中的页换出的交换空间;(每次交换并没有交换出整个进程的物理页!)
可见,物理内存+交换空间 限制了能分配的虚拟页面的总数!! -
共享对象
一个对象可以被映射到虚拟内存区域的一个区域,要么进程的私有对象,要么作为进程间的共享对象(私有的还是共享的通过页表条目控制),共享对象如图示
共享同一个对象的进程都可修改该对象,且修改对其他进程而言是可见的; 不同进程映射同一对象的虚拟地址可以是不同的;应用:比如共享链接库
注:原始的共享对享在磁盘上,程序运行时会被调入内存,上图仅显示了内存中的共享对象,实际上对共享对象的修改会反映到磁盘上的原始对象中(如何做到的?) -
私有对象及写时复制
对于映射到私有对象的区域所做的改变,对其他进程而言并不可见,并且进程对这个对象所做的任何写操作都不会反映在磁盘上的对象中
写时复制
若进程不修改私有对象,多个进程可以共享私有对象的同一个内存副本;只要有一个进程修改其私有对象,就会触发保护故障,先复制一个要修改的页(该对象的一部分)的副本并修改页表条目,然后再进行修改
可见,私有对象的写时复制充分节约了物理内存 -
fork函数中的写时复制
1.内核为了给新进程创建虚拟内存,它创建了父进程的mm_struct、区域结构和页表的原样副本,但是并没有立即在物理内存中直接复制代码段、数据段等;它将两进程中的所有页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制
2.当这两个进程中的任何一个后来进行写操作时,写时复制机制才创建新页面,从而为每个进程保持了私有地址空间的抽象概念 -
execve函数的原理
很多时候fork函数调用之后就会调用execve函数,从而运行新的程序(比如shell);
execve函数在当前子进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效代替当前程序,具体过程如下:
1.删除已经存在的用户区域(用户栈等)
2.映射私有区域 => 为新程序的代码、数据、bss、栈创建新的区域结构,这些都是私有写时复制的
3.映射共享区域,共享目标动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域
4.设置程序计数器PC。设置当前进程上下文的PC值,使其指向代码区域的入口,下一调度这个进程,就从这里开始执行!
- 使用mmap函数的用户级内存映射
void *mmap(void *start, size_t length, int port, int flags, int fd, off_t offset);
mmap函数要求内核创建一个最好是从start开始的新的虚拟内存区域,并将fd指定的对象的一个连续的片(chunk)映射到这个新的区域…返回新区域的虚拟地址
注意:mmap不仅可以映射普通文件也可以映射匿名文件,由flags参数说明 => 映射匿名文件实际上就是动态分配内存了!!
更多参数细节见P586
9.9 动态内存分配(极其重要)
概述
当需要额外的虚拟内存时,使用动态内存分配器比直接使用mmap函数更加方便 ;
动态内存分配器维护着堆,对于每个进程,内核都有一个变量bkr指向堆(已分配)的顶部 => 分配器将堆视为一组不同大小的块(block)来维护,每个块就是一个连续的虚拟内存片(chunk)/页。一个已被分配的块保持分配状态直到被释放,而释放分为隐式和显式两种方式
显式分配器:比如C的malloc、free;C++的new、delete等
隐式分配器:比如java等
注:虽然本章总是强调虚拟内存,不过按照个人理解,内存分配实际上应该是在物理地址空间中找到一个没有使用的页帧,将其与逻辑地址空间中的页号联系起来,只有与实际物理页帧建立了联系的逻辑页号才能真正被使用!!这个过程需要修改页表。
9.9.1 malloc和free函数
void *malloc(size_t size);
malloc函数返回一个指针,指向大小至少为size字节的虚拟内存块;
malloc分配内存时会做数据对齐;
malloc可以使用mmap函数实现(映射匿名文件);
malloc不会初始化分配的内存 => 想初始化使用calloc、想改变块大小使用realloc。
void free(void *ptr);
free函数的ptr参数必须指向一个已分配块的起始位置,若是未分配的,可能发生一些隐藏的错误
示例
上图中一个小方框对应一个字(4字节);
注意b中多分配了一个字,目的是做对齐;
c中,在调用free返回之后,指针p2仍然指向被释放的块,应用程序有责任在p2被重新初始化之前不再使用它!
9.9.2 为什么要使用动态内存分配
最直接的原因是经常直到程序实际运行时,才知道某些数据结构的大小 => 最简单的方法是静态地定义数据结构(如固定数组大小),但是这样做并不灵活,动态内存分配能有效解决这个问题
补充:对于局部变量,存放在栈中;对于静态变量或者全局变量,则是存放到数据段;对于动态分配的变量,才是存放到堆中(所以上面提到将数据结构定义为静态的而不是局部变量,就是为了能或得更多的空间)
9.9.3 分配器的要求和目标
要求:立即响应请求、不修改已分配的块、只使用堆、对齐块
目标:最大化吞吐率、最大化内存利用率
…详见P591
9.9.4 碎片
造成堆利用率低的主要原因是碎片现象,可分为内部碎片和外部碎片
内部碎片:已分配块比有效载荷大时产生(即数据没有占满分配的空间)
外部碎片:空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时产生
外部碎片比内部碎片的量化困难很多,所以通常维护少量大空闲块而不是大量小空闲块
9.9.5 实现问题
实际的分配器要在吞吐率和利用率之间把握平衡,需要考虑:
空闲块组织:如何记录空闲块?
放置:如何选择合适的块来放置新分配的块?
分割:新分配的块放置到某个空闲块后,如何处理这个空闲块的剩余部分?
合并:如何处理一个刚被释放的块?
9.9.6 隐式空闲链表
- 以块为单位组织堆
堆以块为单位组织(再次重申,块在很多场合都被使用,具体含义视语境而定),包含以下部分:头部(四字节,指明块大小)、有效载荷(申请的大小)、填充(满足对齐要求等)
如何标志空闲还是已经分配:通常块有双字(8字节)对齐要求,所以块大小是8的整数倍,即低三位为0/或者说用不上 => 从而可以利用头部块大小的最后一位指明是否分配,如图所示
注意:1.调用malloc时指定的大小只是有效载荷; 2.头部的存在决定了块的最小限制;3.块大小包括了头部、载荷、填充 - 隐式空闲链表
如图所示:深蓝色表示对齐,浅蓝色表示已分配,白色是空闲(最后一个深蓝色已分配而大小为0表示终止)
对于隐式空闲链表这种组织方式:堆总是被组织成若干紧邻的块,不管块是否被分配 => 从而可以通过块头部的大小字段遍历整个堆,也就间接遍历了空闲块的集合,从而称为隐式空闲链表(实际上并没有链表)
9.9.7 放置已分配的块
当一个应用请求大小为k字节的块时,分配器选择一个足够大的空闲块来分配,这就涉及到放置策略:
首次适配:从头开始遍历,选择第一个合适的块
下一次适配:从上次搜索结束的地方开始,选择第一个合适的块
最佳适配:从全部空闲块中找出能满足要求且大小最小的空闲块
更多方法可参考操作系统,内存管理部分
9.9.8 分割空闲块
当找到一个合适的能满足请求大小的空闲块时,若剩余的空间还比较大,则分配器需要将原空闲块分割,一部分用于分配请求,剩余部分成为另一个空闲块
9.9.9 获取额外的堆内存
分配器不能为请求找到足够大的空闲块时:
1.合并较小的空闲块来创建一个更大的空闲块用于分配
2.若合并后依然不够,则需要向内核请求额外的堆内存(调用sbrk,将指向堆顶的brk指针增大即可),分配器将新的堆内存作为空闲块插入空闲链表,继续完成分配
9.9.10 合并空闲块
- 假碎片
当释放已分配的块后,可能导致新产生的空闲块与原来的空闲块相邻;而这两个相邻的空闲块可能因为过小而无法使用; 这就称为假碎片,如图示:
- 合并
合并相邻空闲块可以解决假碎片问题;
在合并时间的选择上,通常有立即合并和推迟合并,不过快速分配器一般是选择推迟合并从而节约时间
9.9.11 带边界标记的合并
-
释放后合并后面的块
释放当前块后,只需要根据当前块的头部块大小字段(见9.9.6),找到下一块的头部,然后判断下一块是否空闲,空闲则合并 => 将下一个空闲块的头部大小加到刚释放的块的块大小即可 -
释放后合并前面的块
按照9.9.6中的块结构,判断当前释放块的前一个块是否空闲,需要O(n)的时间进行遍历,更方便的做法是改变块结构,使用带边界标记的合并,边界标记如图所示:
带边界标记的块实际上就是将块头部复制到块末尾,两者共同组成边界标记 => 从而后面的块可以O(1)读取前一个块的边界,判断是否空闲;
带边界标记的合并如图示:
-
优化
带边界标记导致块的最小限制更大了,一种优化方法是:已经分配的块不使用块末尾的标记,空闲块在头和尾使用边界标记!!!
9.9.12 实现一个简单的分配器(重要)
-
0.概述
分配器的设计空间很大,有很多块格式,空闲链表格式,分割合策略可供选择;
本节的分配器:基于隐式空闲链表,使用立即边界标记合并,最大块大小4GB,代码是64位干净的 -
1.通用分配器设计
本节分配器使用9.9.11对应的块格式;空闲链表组织成隐式的,如图所示:
分配器对应的内存系统模型如下:#define MAX_HEAP (20*(1<<20)) /* 20 MB */ /* Private global variables */ static char *mem_heap; /* Points to first byte of heap => 指向堆底部*/ static char *mem_brk; /* Points to last byte of heap plus 1 => 指向堆顶部*/ static char *mem_max_addr; /* Max legal heap addr plus 1 =>指向堆的最大合法位置*/ /*mem_init - Initialize the memory system model */ void mem_init(void){ mem_heap = (char *)Malloc(MAX_HEAP); mem_brk = (char *)mem_heap; mem_max_addr = (char *)(mem_heap + MAX_HEAP); } /* 申请额外的堆内存空间 */ void *mem_sbrk(int incr) { char *old_brk = mem_brk; if ( (incr < 0) || ((mem_brk + incr) > mem_max_addr)) { errno = ENOMEM; fprintf(stderr, "ERROR: mem_sbrk failed. Ran out of memory...\n"); return (void *)-1; } mem_brk += incr; return (void *)old_brk; }
-
2.操作空闲链表的基本常数和宏
#define WSIZE 4 /* Word and header/footer size (bytes) =>字大小 */ #define DSIZE 8 /* Double word size (bytes) => 双字大小*/ #define CHUNKSIZE (1<<12) /* Extend heap by this amount (bytes) =>扩展堆时的默认大小、初始空闲块大小 */ #define MAX(x, y) ((x) > (y)? (x) : (y)) /* Pack a size and allocated bit into a word */ #define PACK(size, alloc) ((size) | (alloc)) /*将块大小和是否分配的标志位结合起来,可放在块头部或脚部*/ /* Read and write a word at address p */ #define GET(p) (*(unsigned int *)(p)) /*读取并返回参数p引用的字,p是 void* 指针 => 所以需要先强制转换 */ #define PUT(p, val) (*(unsigned int *)(p) = (val)) /* Read the size and allocated fields from address p */ #define GET_SIZE(p) (GET(p) & ~0x7) /*返回当前块的大小*/ #define GET_ALLOC(p) (GET(p) & 0x1) /* Given block ptr bp, compute address of its header and footer */ #define HDRP(bp) ((char *)(bp) - WSIZE) /*返回这个块头部的指针*/ #define FTRP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE) /* Given block ptr bp, compute address of next and previous blocks */ #define NEXT_BLKP(bp) ((char *)(bp) + GET_SIZE(((char *)(bp) - WSIZE))) /* 返回bp后一个块的地址 */ #define PREV_BLKP(bp) ((char *)(bp) - GET_SIZE(((char *)(bp) - DSIZE)))
-
3.创建初始空闲链表
int mm_init(void) { /* Create the initial empty heap => 从系统内存申请4个字*/ if ((heap_listp = mem_sbrk(4*WSIZE)) == (void *)-1) return -1; /* heap_listp 指向堆的第一个字节处*/ /*初始化申请的4个字*/ PUT(heap_listp, 0); /* Alignment padding */ PUT(heap_listp + (1*WSIZE), PACK(DSIZE, 1)); /* Prologue header => 序言块 */ PUT(heap_listp + (2*WSIZE), PACK(DSIZE, 1)); /* Prologue footer */ PUT(heap_listp + (3*WSIZE), PACK(0, 1)); /* Epilogue header */ heap_listp += (2*WSIZE); //line:vm:mm:endinit /* Extend the empty heap with a free block of CHUNKSIZE bytes */ if (extend_heap(CHUNKSIZE/WSIZE) == NULL) /*扩展堆,创建初始的空闲块 */ return -1; return 0; } static void *extend_heap(size_t words) { char *bp; size_t size; /* Allocate an even number of words to maintain alignment */ size = (words % 2) ? (words+1) * WSIZE : words * WSIZE; if ((long)(bp = mem_sbrk(size)) == -1) return NULL; /* Initialize free block header/footer and the epilogue header */ PUT(HDRP(bp), PACK(size, 0)); /* Free block header */ PUT(FTRP(bp), PACK(size, 0)); /* Free block footer */ PUT(HDRP(NEXT_BLKP(bp)), PACK(0, 1)); /* New epilogue header => 链表结尾*/ /* Coalesce if the previous block was free */ return coalesce(bp); /*合并前一个空闲块*/ }
-
4.释放和合并块
void mm_free(void *bp) { if (bp == 0) return; size_t size = GET_SIZE(HDRP(bp)); if (heap_listp == 0){ mm_init(); } PUT(HDRP(bp), PACK(size, 0)); /*将块标记为未分配*/ PUT(FTRP(bp), PACK(size, 0)); coalesce(bp); /*释放后合并块*/ } /* coalesce - Boundary tag coalescing. Return ptr to coalesced block => 合并块*/ static void *coalesce(void *bp) { size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp))); size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp))); size_t size = GET_SIZE(HDRP(bp)); if (prev_alloc && next_alloc) { /* Case 1 */ return bp; } else if (prev_alloc && !next_alloc) { /* Case 2 */ size += GET_SIZE(HDRP(NEXT_BLKP(bp))); PUT(HDRP(bp), PACK(size, 0)); PUT(FTRP(bp), PACK(size,0)); } else if (!prev_alloc && next_alloc) { /* Case 3 */ size += GET_SIZE(HDRP(PREV_BLKP(bp))); PUT(FTRP(bp), PACK(size, 0)); PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0)); bp = PREV_BLKP(bp); } else { /* Case 4 */ size += GET_SIZE(HDRP(PREV_BLKP(bp))) + GET_SIZE(FTRP(NEXT_BLKP(bp))); PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0)); PUT(FTRP(NEXT_BLKP(bp)), PACK(size, 0)); bp = PREV_BLKP(bp); } return bp; }
-
5.分配块
void *mm_malloc(size_t size) { size_t asize; /* Adjusted block size */ size_t extendsize; /* Amount to extend heap if no fit */ char *bp; if (heap_listp == 0) mm_init(); /* Ignore spurious requests */ if (size == 0) return NULL; /* Adjust block size to include overhead and alignment reqs. */ if (size <= DSIZE) asize = 2*DSIZE; else asize = DSIZE * ((size + (DSIZE) + (DSIZE-1)) / DSIZE); /* Search the free list for a fit */ if ((bp = find_fit(asize)) != NULL) { place(bp, asize); /*找到合适的块,分配器放置这个请求块*/ return bp; } /* No fit found. Get more memory and place the block */ extendsize = MAX(asize,CHUNKSIZE); if ((bp = extend_heap(extendsize/WSIZE)) == NULL) return NULL; place(bp, asize); return bp; }
9.9.13 显式空闲链表
- 隐式空闲链表的缺点
在隐式空闲链表中,由于已分配块和空闲块是组织在一起的,所以动态内存分配消耗的时间与堆中块的总数成线性关系 => 当块数较多时,它效率并不高 - 显式空闲链表
显式空闲链表将空闲块组织成双向链表,而已分配的块并不在显式空闲链表中,使用双向显式空闲链表的块格式如下:
分配时间:从块总数的线性时间,减少到空闲块数的线性时间
释放时间:可能是常数,可能是线性(取决于空闲链表中块的排序方式) - 显式空闲链表中块的排序策略
后进先出:将新释放的块放到空闲链表的开始处=> 释放在常数时间内完成
按地址顺序维护链表:链表中每个块的地址都小于它后继的地址 => 释放需要线性时间在链表中定位,但是有更高的内存利用率
9.9.14 分离的空闲链表
- 概述
单项空闲链表,搜索合适的空闲块需要较多的时间;一种流行的改进方法是使用分离存储
分离存储:维护多个空闲链表,每个链表中的块有大致相等的大小(称为一个大小类) - 简单分离存储
每个大小类的空闲链表中的块大小相等,每个块的大小就是这个大小类中最大元素的大小。例如,某个大小类定义为{17~32},该大小类对应空闲链表的块大小都是32
分配:如果申请的空间与这个大小类适配,简单地分配对应空闲链表的第一块(释放策略保证了第一块始终空闲),注意这里分配时不会分割空闲块
释放:释放一个块时,简单地将这个释放块插入到空闲链表的头部,不会合并相邻空闲块 - 分离适配
分离适配中空闲链表中的块大小不一定相等;
分配:对与申请的空间适配的大小类(对应的空闲链表)进行首次适配,分配时会分割空闲块中多余的部分
释放:释放后会对物理上相邻的空闲块进行合并,再将合并后的空闲块插入链表合适的位置
注:分离适配是一种常见的选择,malloc就采用这种方法 - 伙伴系统
伙伴系统是分离适配的特例,其中每个大小类都是2的幂,为每个块大小2^k维护一个分离空闲链表
分配:为了分配大小为 2 k 2^k 2k的块,需寻找第一个可用的大小为 2 j 2^j 2j的块(k<=j<=m),如果j=k则完成分配,否则递归地二分下去;每次二分时剩下半块(称为伙伴)被放入对应的空闲链表
释放:释放后,需要继续向上合并,直到遇到一个已经分配的伙伴
9.10 垃圾收集
9.10.1 垃圾收集的基本知识
- 垃圾收集器对内存的组织
如图所示,垃圾收集器将内存视为一张有向图,p—>q意味着块p引用了块q;图中方框内的节点称为堆节点,对应于堆中一个已分配的块;方框外的节点称为根节点,对应于一种不在堆中的位置,这个位置可以是寄存器、栈等,但是根节点中包含对堆节点的引用(指针) - 垃圾收集原理
当存在任意一条从根节点出发并到达p的有向路径时,称节点p是可达的;
任何时刻,不可达节点对应于垃圾,不能被再次使用(因为使用堆内存必须通过栈中对应的根节点进行引用);
垃圾收集器的角色是维护可达图的某种表示,释放不可达节点并将他们返还给空闲链表
9.10.2 Mark & Sweep垃圾收集器
标记阶段:标记出根节点的所有可达的和已分配的后继
清除阶段:释放每个未被标记的已分配块
9.10.3 C层序保守的垃圾收集器
保守的垃圾收集器:一些不可达的节点迫于某些原因,也得被错误的标记为可达
C程序的标记-清除垃圾收集器必须是保守的,根部原因的C语言不会使用类型信息来标记内存位置;因此想int或者float这样的变量可以伪装成指针=>
例:假设某个可达的已分配块在它地有效载荷中包含一个int,其值碰巧对应于某个其他已分配块b的有效载荷中的一个地址,对收集器而言,无法判断这个数据是int而不是指针。所以,垃圾收集器必须保守地将块b标记为可达,尽管事实上它可能是不可达的!!!
9.11 C程序中常见的与内存有关的错误(重要)
-
间接引用坏指针
在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。访问这部分位置将会导致错误;此外,即使指针对应的虚拟地址空间完成了映射,如果程序没有该位置的读写权限,也会导致保护故障
典型错误:scanf("%d",&val)
错写为scanf("%d",val)
-
读未初始化的内存
虽然内存bss位置总是被初始化为0;但是堆内存却不总是初始化为0,直接读取未初始化的堆内存会产生错误:int *maxvec(int **A, int *x, int n){ int j,j; int *y=(int *)Malloc(n*sizeof(int)); for(i=0;i<n;i++) for(int j=0;j<n;j++){ y[i]+=A[i][j]*x[j]; //错误! y没有初始化,应该先初始化或者使用calloc } return y; }
-
允许栈缓冲区溢出
void bufoverfolw(){ char buf[64]; gets(buf); //gets不会限制输入字符串的大小,可能导致缓冲区溢出=>改用fgets return; }
-
假设指针和他们指向的对象是相同大小的
int **makeArray(int n, int m){ int i; //由for循环可知,A[i]应该是一个指针,所以这个应该改成n*sizeof(int*) // 否则在部分机器上int与int*大小并不相同,将会引发其他错误 int **A=(int **)Malloc(n*sizeof(int)); for(i=0;i<n;i++) A[i]=(int *)Malloc(m*sizeof(int)); return A; }
-
误解指针运算
指针的算数操作是以它们指向的对象的大小为单位来进行的,容易犯的错是认为指针的加减是以字节为单位,比如:int *search(int *p, int val){ while(*p && *p!=val) p+=sizeof(int); //这里误以为指针的加减以字节为单位,所以为了访问下一个元素,使用了sizeof(int),即四字节 //实际上直接p++即可,因为指针加减的单位是对象的大小!! return p; }
-
引用不存在的变量
int *stackref(){ int val; return &val; }
这个函数返回一个指针p,p指向栈里的一个局部变量;不过函数退出时对应栈帧已经退出,虽然此时p仍然是一个合法的内存地址,但它已经不能指向一个合法的变量了; 如果程序使用返回的指针修改指向的内容,很可能改变其他函数对应的栈帧内容,导致错误!!!
-
内存泄漏
分配了内存但是没有释放!!!
9.12 小结
-
较大收获/易忘易混
1.地址转换的时间、过程!
2.块不是一专有名词,它只是两级存储间数据传输的基本单位,不同的存储级别,块的大小不一
3.DRAM是全相联的!
5.所谓的分配内存,并不马上涉及将数据复制到主存 => 分配内存(页面)其实仅需操作磁盘和页面
6.使用主存物理地址来访问缓存,所以地址翻译发生在查找高速缓存之前
7.cahce和主存是物理寻址,而快表是虚拟寻址!!!!
8.Linux的虚拟内存空间划分以及组织虚拟内存的数据结构
9.区分并理解 内存映射(9.8) 与 内存映射I/O(第6章)
10.理解交换空间的意义(为什么不直接放回磁盘)!!
11.理解存储器层次结构中:register…DRAM 与 DRAM…disk在写回低一级缓存时的区别!!!
12.写时复制
13.mmap完成映射(映射磁盘文件 或者 动态分配内存)
14.动态内存分配 -
疑惑
1.9.1中所谓的查询表是页表吗?
2.虚拟内存和磁盘是如何映射的?
3.什么时候能把缓存、内存、磁盘、虚拟内存做一个统一的总结?
4.缓存写回磁盘时,如何知道磁盘地址的? => 内存作为磁盘的缓存时,缓存的内容并不会自动写回磁盘(除交换区间)!
5.如何做到共享对象映射到多个进程?
个人总结:关于存储器层次结构的理解
待完善,目前参考9.8中的交换空间部分