0x00 概述

暂时没想到写啥(x

0x01 计算机系统漫游

  1. 计算机系统是由硬件和系统软件组成

  2. 编译和解释的区别:

编译程序(Complier):将高级语言源程序转换为机器级目标程序,执行时只要启动目标程序即可。
解释程序(Interpreter):将高级语言语句逐条翻译成机器指令并立即执行,不生成目标文件。

  1. 程序可以被其他程序翻译为不同的格式:
  • 预处理阶段:预处理器根据以字符#开头的命令,修改原始的C程序。以.i作为文件扩展名;
  • 编译阶段:编译器将.i文件翻译成汇编.s文件;
  • 汇编阶段:汇编器将.s文件翻译成机器语言指令.o
  • 链接阶段:有些程序调用了别的库中的函数,链接器负责将别的库合并到我们的程序。

image-20210131224820506

  1. 系统的硬件组成:
  • 总线:在各个部件中传输数据时用的管道;
  • I/O设备:输入输出设备,通过一个控制器或适配器与I/O总线相连;
  • 主存:临时存储设备,在处理器执行程序时,用来暂存程序处理的数据;
  • 处理器:中央处理单元(CPU),简称处理器,是执行存储在主存中指令的引擎。

  1. 高速缓存:处理器的运行速度很快,但数据从主存运送到CPU里却相当的慢。这其中速度可能相差百倍以上,大大拖累了CPU的速度。针对这种处理器与主存之间的差异,系统设计者引入了更小更快的存储设备——高速缓存存储器。

  2. 进程:进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。

  3. 线程:进程由线程组成,多线程之间比多进程之间更容易共享数据。线程一般来说也比进程更高效。

  4. 虚拟内存:虚拟内存为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。

image-20210203225412444

  1. 文件:每个I/O设备,包括磁盘、键盘、显示器,甚至网络都可以看成是文件。

0x02 信息的表示和处理

1.字节顺序

Suppose the variable x of type int and at address 0x100 has a hexadecimal value of 0x01234567 . The ordering of the bytes within the address range 0x100 through 0x103 depends on the type of machine:

image-20210203233606025

2.浮点数

浮点数是如何存储到内存中的:

float的存储方式

image-20200828213506250.png

  1. 先将这个浮点数的绝对值值化为二进制;
  2. 将这个二进制数的小数点左移或者右移n位,直到小数点移动到前面只剩一个1;
  3. 从小数点右边第一位开始数出二十三位数字放入第22到第0位;
  4. 如果实数是正的,则在第31位放入0,否则放入1;
  5. 如果n是左移得到的,说明指数是正的,第30位放入1。如果n是右移得到的或n=0,则在第30位放入0;
  6. 将n减去1后化为二进制,把值去掉第一位放入第29到第23位(7位)。

因为float尾数部分只有24位,所以能精确到小数点后六位。不存在无符号float,因为第一位就是符号位了。

double是64位的,8个字节(float是4个字节)

如何把浮点数转换为二进制:

例:8.25

1
2
3
4
5
6
7
8
9
10
整数部分转换:
8/2 = 4 0
4/2 = 2 0
2/2 = 1 0
1/2 = 0 1
1000
小数部分转换:
0.25*2 = 0.5 0
0.5*2 = 1.0 1
1000.01

8.25可以用二进制表示为1000.01

但如果是8.4就会进入死循环

1
2
3
4
0.4 * 2 = 0.8		0
0.8 * 2 = 1.6 1
0.6 * 2 = 1.2 1
0.2 * 2 = 0.4 0

这时候需要精确位数。

接着8.25继续,将小数点右移或者左移

1.00001 * 2^3

然后把00001存到尾数部分,后面跟着步骤来。

31符号位sign 23~30指数部分exp 0~22位数部分frac
0 10000010 00001000000000000000000

所以在内存中存入的值是41040000。

如果是-8.25,也只需要变符号位

31符号位sign 23~30指数部分exp 0~22位数部分frac
1 10000010 00001000000000000000000

在内存中存入的值就是c1040000。

浮点数精度问题:

当输入的浮点数是一个不可表示的数时,机器会将其转换为最邻近的可表示数。

例如61.419997和61.419999都会被转换为61.419998。

浮点数的值有4种不同的情况:

  1. 当exp≠0xFF,也≠0x00时,为规格化的;

  2. 当exp = 0x00 时,为非规格化的(表示那些接近0.0的数)。当exp=0且frac=0时,表示±0.0

  3. 当exp = 0xFF 时,若frac全为0,表示±∞;若frac不全为0,则表示NaN(Not A Number,例如根号-1这些不存在的数字).

浮点数因为精度有限,所有存在舍入问题:就近舍入(round-to-nearest)和向偶数舍入(round-to-even).

3.无符号数与有符号数

若同时有无符号和带符号整数,则C编译器将带符号整数强制转换为无符号数。

有符号数就算最高位表示符号,其他位置表示数值大小;无符号数就是所有位都表示数的大小。

一个字节可以表达的数:
00000000-11111111(0-255)

1
2
3
4
	0000 0001				 1
+ 1111 1111 -1
________________
1 0000 0000 0

因为如果在计算机内部,这个数是8个bit的,那么多出来的数字就会被丢掉
所以这里-1为1111 1111

1111 1111被当作纯二级制看待的时候,是255。被当作补码看待时是-1

(1)0000 0000 - 0000 0001 = 1111 1111

对于-a,其补码就是0-a,实际是2的n次方-a,n是这种类型的位数

  • 补码的意义就是拿补码和原码可以加出一个溢出的”零”

原码

原码就是第一位表示符号,其余位置表示值

1000 0001

0000 0001

反码

反码就是在其原码的基础上,符号位不变,其余位取反。正数的反码是其本身

1000 0001 反码 -> 1111 1110

0000 0001 反码 -> 0000 0001

补码

正数的补码就是其本身

负数的补码是在其反码的基础上加1

1000 0001 反码 -> 1111 1110 补码 -> 1111 1111 -127

0000 0001 反码 -> 0000 0001 补码 -> 0000 0001 1

4.编译器处理常量时的默认类型

img

  • 所以在32位C90标准下系统中,-2147483648 > 2147483647,因为2147483648会变成unsigned类型(负号是另外处理的)。
  • 但是如果定义了int i=-2147483648;i就会小于2147483647。这是因为i变成了long类型。
  • 如果写成-2147483647-1 < 2147483647,因为-2147483647是属于有符号数,这个式子可以理解为一个有符号数减掉有符号数,所以最后会按照int类型比较。但是如果写成-2147483648加减,则会按照无符号数来计算。

0x03 程序的机器级表示

1.寄存器

一个x86-64的中央处理单元包含16个存储64位值的通用寄存器

63 31 15 7 0
%rax %eax %ax %axl 返回值
%rbx %ebx %bx %bxl 被调用者保存
%rcx %ecx %cx %cxl 第4个参数
%rdx %edx %dx %dxl 第3个参数
%rsi %esi %si %sil 第2个参数
%rdi %edi %di %dil 第1个参数
%rdp %edp %dp %dpl 被调用者保存
%rsp %esp %sp %spl 栈指针
%r8 %r8d %r8w %r8b 第5个参数
%r9 %r9d %r9w %r9b 第6个参数
%r10 %r10d %r10w %r10b 调用者保存
%r11 %r11d %r11w %r11b 调用者保存
%r12 %r12d %r12w %r12b 被调用者保存
%r13 %r13d %r13w %r13b 被调用者保存
%r14 %r14d %r14w %r14b 被调用者保存
%r15 %r15d %r15w %r15b 被调用者保存
  1. 传送指令的两个操作数不能都指向内存位置
  2. 64位和32位传递参数的方式不同:32位的将参数入栈来传递参数,64位的使用寄存器进行传递参数,用来传递参数的寄存器为rdi,rsi,rdx,rcx,r8,r9,这六个寄存器分别用来存储第一至第六个参数,如果参数多余6个会先将后面的参数先入栈,前六个参数放在对应的寄存器中。

2.缓冲区溢出

缓冲区溢出攻击给计算机系统造成了许多麻烦,现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击。

栈随机化:栈随机化使栈的位置在程序每次运行时都发生变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。

栈破坏检测:最近的GCC版本在产生的代码加入了一种栈保护者机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的金丝雀值。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个操作改变了。如果是的,那么程序异常中止。

限制可执行代码区域:最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。

0x04 处理器体系结构

本章定义了一个指令集Y86-64,主要介绍了处理器硬件的设计。
一个处理器支持的指令和指令的字节级编码被称为它的指令集体系结构(Instruction-Set Architecture, ISA)。不同处理器有不同ISA,一个程序编译后在一种机器上运行,就不能在另一种机器上运行。

流水线:CPU流水线技术是一种将指令分解为多步,并让不同指令的各布操作重叠,从而实现几条指令并行处理,以加速程序运行速度过程的技术。

1.Y86指令集体系结构

指令编码

image-20210401183233949

PC存放当前正在执行指令的地址;内存存放着程序和数据;Stat是状态码,它表示程序执行的总体状态,它会指示是正常运行还是出现了某种异常。

image-20210401200654057

rrmovq指令是无条件传送

寄存器标识符:

image-20210401201421875

指令集的一个重要性质就是字节编码必须有唯一的解释。

异常

此处的异常指的是状态码Stat,它描述程序执行的状态。

image-20210401211504591

0x05 优化程序性能

编写高效的程序需要选择适当的算法和数据结构,也需要写出编译器能够有效优化的代码。

1.程序优化

  1. 消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。
  2. 利用处理器提供的指令级并行能力,同时执行多条指令。

2.消除循环的低效率

1
2
3
4
5
// 优化前
for(size_t i = 0; i < strlen(str); i++)
// 优化后
size_t str_len = strlen(str);
for(size_t i = 0; i < str_len; i++)

3.循环展开

  • 循环展开是一种程序变换,通过增加每次迭代计算的元素数量,减少循环的迭代次数。
1
2
3
4
5
6
7
8
9
// 循环展开前
for(int i = 0; i < lmits; i++)
acc0 = acc0 OP data[i];
// 循环展开后
for(i = 0; i < lmits; i += 2)
{
acc0 = acc0 OP data[i];
acc0 = acc0 OP data[i+1];
}

0x06 存储器层次结构

存储器系统是一个具有不同容量、成本和访问时间的存储设备的层次结构。

image-20210924075823446.png

1.存储技术

存储器:

  • 随机访问存储器(Random-Access Memory)分为两类:静态的和动态的。静态SRAM比动态DRAM更快,但是也更贵。SRAM用于高速缓存存储器,DRAM用于主存和图形系统的帧缓冲区。
  • 如果断电,RAM会丢失他们的信息。但是非易失性存储器ROM即使在关电之后,仍然会保存信息。由于历史原因,虽然有的ROM可读可写,但是整体都被称为只读存储器(Read-Only Memory,ROM)。
  • 闪存(flash memory)是一类非易失性存储器,基于EEPROM,它为大量的电子设备提供快速且持久的非易失性存储。包含数码相机,手机,笔记本等等。固态硬盘也是一种基于闪存的磁盘驱动器。
  • 存储在ROM设备中的程序被称为固件,当设备通电后,它会运行存储在ROM中的固件。

2.局限性

一个编写良好的计算机程序常常具有良好的局限性。

局限性有两种不同的形式:时间局限性和空间局限性。在一个具有良好时间局限性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。在一个具有良好空间局部性的程序中,如果一个内存位置被引用过一次,那么程序很可能在不远的将来引用附近的一个内存位置。

  • 重复引用相同变量的程序有良好的时间局限性
  • 对于具有步长为k的引用模式的程序,步长越小,空间局部性越好。具有步长为1的引用模式的程序有很好的空间局部性。在内存中以大步长跳来跳去的程序空间局部性会很差(参考多维数组)
  • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

0x07 链接

1.静态链接

静态链接的过程:首先预处理器cpp将源程序main.c翻译成一个ASCII码的中间文件main.i,接下来编译器ccl将main.i翻译成ASCII码的汇编语言文件main.s,然后汇编器as将main.s翻译成一个可重定位的目标文件main.o,最后运行链接器ld将main.o和其他目标文件组合,创建一个可执行目标文件a.out。最后shell调用可执行目标文件时,shell调用操作系统中一个叫加载器(loader)的函数,它将可执行目标文件中的代码和数据复制到内存,然后将控制转移到这个程序的开头。

image-20210429224129668

每个可重定位目标文件都有一个符号表,符号有:

  • 由当前可重定位目标文件定义的全局符号,可以被其他模块引用,对应于非静态的函数和全局变量;
  • 由其它文件定义并被当前文件引用的全局符号,这些符号称为外部符号,对应于在其它模块中定义的非静态函数和全局变量;
  • 只被当前文件定义和引用的局部符号,它们对应带static的函数和全局变量,这些符号在当前文件中可见,但是不能被其它文件引用。

特殊的节:

  • ABS:不该被重定位的符号
  • UNDEF:表示未定义的符号
  • COMMON:未初始化的全局变量

.bss段和COMMON的区别是:.bss存的是未初始化的静态变量,以及初始化后为0的全局或静态变量

想要深刻理解,可以去看csapp练习题7.1

2.全局符号

每个模块定义一组符合,有些只对定义该符号的模块可见,我们称之为弱符号,有的是全局的,对其他模块也可见,我们称之为强符号

image-20210511142703890

3.小结

  • 链接可以在编译时由静态编译器完成,也可以在加载时和运行时由动态链接器完成。
  • 链接器处理的目标文件有三种形式:可重定位,可执行和共享。
  • 可重定位文件由静态链接器合并,共享目标文件在运行时由动态链接器链接和加载。
  • 链接器的两个任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。

0x08 异常控制流

1.异常

控制流:处理器从开机到关机,程序计数器都会依次读取一个值,这个值是相应指令的地址。这样持续不断的读取执行的过程,就是处理器的控制流。

目前有两种机制可以改变控制流:

  1. 跳转和分支;

  2. 调用和返回。

但是对于系统来说是不够的,因为某些情况下需要对系统状态改变做出相应。所以就有了异常控制流。

异常控制流(ECF):异常控制流发生在操作系统各个层次。

当处理器检测到有事件发生时,它会通过一张叫异常表的跳转表,跳转到处理异常的程序。完成异常处理后,根据异常的类型会发生以下3种情况:

  • 处理程序将控制返回给当前指令;
  • 处理程序将控制返回给下一条要执行的指令;
  • 处理程序中止被中断的程序。

异常表:

  • 每种类型的事件都有唯一的异常号
  • 发生异常时通过异常号来调用处理程序

异常的类别:

类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
中止 不可恢复的错误 同步 不会返回

异步异常指的是处理器外部的I/O设备中的事件产生的。同步异常是执行一条指令的直接产物。

中断:

  1. 定时器中断

每隔几毫秒,一个外部计时器芯片就会触发一个中断
内核用来从用户程序取回控制权

  1. 来自外部设备的I / O中断

在键盘上按Ctrl-C
来自网络的数据包到达
磁盘中的数据到达

陷阱:

陷阱是一种有意的异常,其最重要的用途是在用户程序与内核间提供一个系统调用接口。利用该接口可进行读写文件,加载程序等等。

故障:

故障由错误情况引起,可能能够被故障处理程序修正。如果程序能修正就会重新执行。如果不能修正就会终止程序。

一个很经典的故障是缺页异常,当指令引用一个虚拟地址,但是与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出,就会发生故障。当程序从磁盘加载出来到内存之后,就可以把控制返回给引起故障的指令。然后指令再次执行即可。

终止:

终止是不可恢复的致命错误造成的,通常是硬件错误。

当故障不能修正时,会和终止一样返回到abort程序,由该程序终止整个应用程序。

2.进程

进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文。

上下文由程序正确运行所需要的状态组成,包括内存中的代码和数据,栈,通用寄存器,程序寄存器,环境变量和文件描述符的集合。

运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。

进程给予应用程序一个假象,好像我们的程序可以独占使用处理器和内存系统。

  1. 进程轮流使用处理器,每一个进程执行一部分指令后,就被抢占,轮到其他进程。它们交错执行。这样它看起来就像是在独占地使用处理器。
  2. 如果两个流的执行在时间上重合,称为并发流

多个流并发地执行称为并发

多个进程轮流运行称为多任务

一个进程执行它的控制流的一部分的每一时间段叫做时间片,多任务叫做时间分片

  1. 如果两个流并发地运行在不同的处理器或者计算机,我们称它们为并行流
  2. 进程为每个程序提供自己的私有地址空间。

image-20210514172622134

  1. 内核模式和用户模式

处理器通常用某个控制寄存器中的模式位提供一种功能,就是描述当前进程享有的权限。当设置该模式位时,进程就运行在内核模式中,没有设置,就运行在用户模式中。

用户模式的进程不允许执行特权指令,比如停止处理器改变模式位,或者发起I/O操作等。

内核模式可以执行任何指令并且访问任何位置。

在异常发生时,处理器可以把用户模式变成内核模式。

上下文切换:

内核通过调度器抢占进程。当内核选择一个新进程运行时,我们说内核调度了这个进程。然后使用上下文切换的机制将控制转移给新的进程。

上下文切换:1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制转移给这个新恢复的进程。

3.进程控制

进程的三种状态:

  • 运行:进程要么在CPU上执行,要么等待被执行且最终会被内核调度
  • 停止:进程的执行被挂起且不会被调度。当收到SIGSTOPSIGTSTPSIGTTIN或者SIGTTOU信号时,进程停止,直到它收到SIGCONT信号才会再次运行。
  • 终止:进程永远停止。进程因为三种原因终止:1.受到信号,该信号的默认行为是终止进程。2.从主程序返回。3.调用exit函数
  1. 父进程通过调用fork函数创建一个新的子进程,子进程可以得到一份和父进程用户级虚拟地址空间的一份副本,包括代码和数据段、堆、共享库以及用户栈。它们最大的区别是有不同的PID。
  2. fork函数被调用一次,会返回两次。一次是在调用父进程的时候,一次是在新创建子进程的时候。
  3. 子进程fork返回0,父进程fork返回子进程的PID,出错fork返回负值。
  4. 父进程和子进程是并发运行的独立进程

回收子进程:

  • 当一个进程终止时,内核并不是立即把它从系统中清除,相反,进程被保持在一种已经终止的状态中,直到它的父进程回收。
  • 一个终止了但还未被回收的进程称为僵死进程。
  • 如果父进程在没有回收的情况下终止,则该子进程称为孤儿进程,内核会安排init进程成为该子进程的父进程,并回收该僵尸进程。
  • init进程的pid为1,它不会终止,是所有进程的祖先。
  • sleep函数会使当前进程休眠。需要注意的是,休眠的进程可能因为一个信号中断而提前返回。

waitpid:

父进程可以用waitpid等待子进程死亡,也可以用它获得子进程死亡原因。

waitpid的参数status会自动收集子进程死亡原因,然后回收了所有子进程之后,父进程再调用waitpid就返回-1。

返回结果:如果函数成功返回子进程的pid,如果WNOHANG返回0,如果其他错误返回-1。

execve函数调用另外一个程序,它会把新程序加载到当前进程的内存空间内,然后丢弃当前的进程。

4.信号

信号通知进程系统发生了一个某种类型的事件。

Linux信号是一种更高层的软件形式的异常。它允许进程和内核中断其他进程。

image-20210515174747041

传送信号到目的进程的步骤:

  • 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。

  • 接收信号。当进程被内核强制对信号的发送做出反应时,它就接收了信号。

进程可以忽略信号,终止或者通过信号处理程序的用户层函数捕获信号。

发出但是没被接收的信号叫做待处理信号,一种类型只能有一个待处理信号,剩下的会被丢弃掉。

每个信号类型都有一个预定义的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止直到SIGCONT信号重启
  • 进程忽略该信号

0x09 虚拟内存

image-20210607234242291

1.物理和虚拟寻址

  • 为了方便管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存。它为每个进程提供了一个大的、一致的、私有的地址空间。

  • 现代系统通常支持32位和64位的虚拟地址空间,一个包含了2^n的虚拟地址空间就被叫做一个n位地址空间。

  • 计算机系统的主存是一个由M个连续的单元组成的数组。每个单元都有一个唯一的物理地址。

  • 物理寻址:早先计算机直接对物理内存的位置进行寻址,这些地址段组成了物理地址空间。

  • 虚拟寻址:现代处理器使用的是虚拟寻址,CPU通过生成虚拟地址访问主存,这个虚拟地址在被传送到内存之前会先被转换成物理地址。

  • 将一个虚拟地址转换为物理地址的任务叫做地址翻译,地址翻译需要CPU硬件和操作系统才能完成。CPU芯片上负责地址翻译的硬件叫做内存管理单元,利用存在主存的查询表动态翻译虚拟地址,这个表的内容由操作系统管理。

2.页

虚拟内存被分割成虚拟页,物理内存被分割成物理页。

物理页装载到虚拟页里运行程序,好处是程序员编程时不需要考虑内存容量的大小。这样的原理是程序员在虚拟空间编写程序,然后程序在真正的内存中运行。

页表:每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表,页表描述了虚拟页和物理页框之间的映射关系。操作系统负责维护页表的内容,以及在磁盘和虚拟空间的缓存之间来回传送页。

内存中存放页的区域叫做页框。

上图的进程只有4页,页表对应着虚拟页和主存的页框之间的映射关系。如果我们现在访问虚拟页中第1页的第30个地址,就通过页表访问物理内存的14页的第30个地址。

根据程序的活跃性我们可以把不用的放到磁盘,活跃的放到主存。按照需要调用页。

虚拟地址空间中的每个页在页表中都有一个PTE(Page Table Entry),我们可以假设每个PTE都是由一个有效位和一个n位地址字段组成的。有效位表示当前虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始地址。如果没设置有效位,那么会是一个空地址。

3.页命中与缺页

页命中:地址翻译硬件将虚拟地址作为索引,定位PTE,并从内存中读它。可以通过有效位得知是否被缓存在DRAM中。

缺页:DRAM缓存不命中就是缺页。地址翻译硬件读取PTE时,通过有效位得知虚拟页未被缓存,会触发一个缺页异常,程序会在物理内存选择一个牺牲页,如果牺牲页已经被修改,内核会把它复制回磁盘,否则直接从磁盘复制虚拟页到物理内存。然后更新PTE,重新启动缺页异常,指令会把虚拟地址发给地址翻译软件。(此时启动缺页异常,程序将会正常执行,而不会产生异常)

4.虚拟内存作为内存管理的工具

对于64位地址空间,代码段总是从虚拟地址0x400000开始,数据段跟在代码段之后,中间有对齐的空白。栈占据用户进程地址空间的最高部分,向下生长。

操作系统给每个进程都提供了自己私有的代码、数据、堆以及栈。不和其它进程共享。但是在一些情况下,进程会共享代码和数据,比如每个进程调用的printf,操作系统会把不同进程的虚拟页面都映射到相同的物理页面,也就是printf。

5.虚拟内存作为内存保护的工具

我们通过对PTE添加一些额外的许可位来控制一个虚拟页面的访问。sup位表示进程是否必须运行在内核模式下才能访问此页,READ和WRITE位控制页的读写权限。如果有指令违反了许可条件,CPU就会触发一个保护故障,Linux shell一般将这个异常称为**段错误(segmentation fault)**。

image-20211004112124700

6.多级页表

如果一级页表中的PTE是空的,那么相应的二级页表也不会存在。只有一级页表和最经常使用的二级页表才存在主存上。当需要二级页表时,虚拟内存系统才会创建调用二级页表。

image-20211004194303289.png

7.动态内存分配

动态内存分配器维护着一个进程的虚拟内存区域,称为堆,堆向上生长,变量brk(读作break)指向堆的顶部。

image-20211005095501380

  • 显式分配器:要求程序显示地释放任何已分配的块,c语言通过malloc函数来分配一个块,并通过调用free释放
  • 隐式分配器:当分配器检测到一个分配块不再被程序使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,自动释放未使用的已分配块就叫做垃圾收集。
1
2
#include <stdlib.h>
void *malloc(size_t size);

malloc函数从堆中分配块,返回一个指向大小至少为size字节的内存块的指针,32位返回的块的地址是8的倍数,64位返回地址是16的倍数。

造成堆利用率低的主要原因是因为碎片现象,当有可以使用的内存,但是不能满足分配请求时,就会发生这种现象。有两种形式的碎片:

  • 内部碎片:分配器可能增加块大小,用于满足内存对齐。
  • 外部碎片:当空闲内存合计起来能够满足一个分配需求,但是没有一个单独的空闲块足够大可以处理这个请求时。

image-20211007145344132

image-20211007145251954

  • 首次适配(first fit):从头开始搜索空闲链表,选择第一个合适的空闲块。
  • 下一次适配(next fit):下次查询从当前结束的地方开始,其余和首次适配一样。
  • 最佳适配(best fit):检查每个空闲块,选择最适合的。

如果分配器不能给请求块找到合适的空闲块,就会通过合并在内存中物理相邻的空闲块。如果这样还不够,就会调用sbrk函数申请额外的内存。

合并内存中物理相邻的块有两种情况:

  1. 立即合并:每当一个块被释放,就合并所有的相邻块
  2. 推迟合并:等某个分配请求失败再扫描堆进行合并。

隐式空闲链表:第一个字是用于对齐的填充字,填充后面是序言块,8字节永远不释放,后面跟着零个或多个chunk,最后跟着一个结尾块。

image-20220217214209125