开始学 CSAPP的第九章

逐步更新:

  • 2023-1-30:开始写
  • 2023-2-01:完成

梗概

虚拟内存是当今操作系统最重要的概念之一,它提供了三个重要的能力:

  • 它将主存视为硬盘的缓存,主存只保留活动区域(局部性原理)
  • 它为每个进程提供一致的地址空间
  • 它保护了进程的内存,防止被随意覆盖

物理和虚拟寻址

计算机的内存被组织为一个由 M 个连续的字节大小的单元组成的数组,每个字节都有唯一的物理地址。CPU 访问内存最自然的方式就是使用物理地址,我们称为物理寻址 。早期的 CPU 是使用物理寻址的,而现代的处理器是使用虚拟寻址。

虚拟寻址与物理寻址相比,多了一步将虚拟地址翻译成物理地址的过程,翻译是通过一个内存管理单元(MMU)的专用硬件,通过查表得知的物理地址,这张表由操作系统管理。

地址空间

地址空间是一个非负整数地址的有序集合:

如果地址空间中的整数是连续的,那么我们说它是线性地址空间

为了简化讨论,我们假设地址空间是线性地址空间。

在带虚拟存储器的系统中,CPU从一个有 $N=2^n$ 个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间

一个地址空间的大小是由表示最大地址所需要的位数来描述的。

例如,一个包含 $N=2^n$ 个地址的虚拟地址空间叫做一个n位地址空间。

现代系统典型地支持32位或者64位虚拟地址空间

一个系统还有物理地址空间,它与系统中物理存储器的M个字节相对应:

M不要求是2的幂,但为了简化讨论,一般假设 $M=2^m$。物理地址空间对应于系统中实际拥有DRAM容量。地址空间的概念非常重要。它清楚地区分了数据对象(字节)和它们的属性(地址)。

主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。

允许每个数据对象有多个独立的地址,其中每一个地址都选自一个不同的地址空间。这就是虚拟存储器的基本思想

虚拟内存作为缓存的工具

这里提出将物理内存分页,设 $2^p$ 字节的内存为一页,称为物理页,也叫页帧。

在任何时刻,虚拟页的集合都分为三部分:

  • 未分配的:它们名存实亡,没有实际数据相关联
  • 缓存的:当前已缓存在内存中的已分配页
  • 未缓存的:为缓存在物理内存中的已分配页

下图的示例展示了一个有 8 个虚拟页的小虚拟内存。

虚拟页 0 和 3 还没有被分配,因此在磁盘上还不存在。虚拟页 1、4 和 6 被缓存在物理内存中。页 2、5 和 7 已经被分配了,但是当前并未缓存在主存中。

DRAM缓存的组织结构

DRAM 比 SRAM 慢 10 倍,而磁盘读取速度比 DRAM 慢了 100 000 倍,因此主存不命中带来的惩罚是非常高的。

因此 DRAM 作为磁盘的高速缓存,采用全相联高速缓存,即任何虚拟页可以放在任意的物理页中。我们的替换策略也很重要,因为替换错了的成本也非常高。最后因为访问时间的关系,往往采用写回而不是直写。

页表

页表是由页表条目(PTE)组成的数组。PTE 由一个有效位和 n 位地址字段组成,如果设置了有效位,那么 n 位的字段表示 DRAM 中相应的物理页的起始位置(已缓存)。如果没设置有效位,那要么这个页表条目未被占据条目为 NULL(未分配),要么指向了一个虚拟内存(未缓存)。

如下图所示:

图中展示了一个有 8 个虚拟页和 4 个物理页的系统的页表。四个虚拟页(VP 1、VP 2、VP 4 和 VP 7)当前被缓存在 DRAM 中。两个页(VP 0 和 VP 5 )还未被分配,而剩下的页(VP 3 和 VP 6)已经被分配了,但是当前还未被缓存。图 9-4 中有一个要点要注意,因为 DRAM 缓存是全相联的,所以任意物理页都可以包含任意虚拟页。

页命中

当尝试读取一个已存在于物理内存中的页时,会发生页命中,此时会把物理内存中的值直接传送到寄存器当中

如图是读取 VP2 页时候的情况,此时发生页命中,它会直接使用 PTE2 条目中的物理地址读取内存。

缺页

DRAM 缓存不命中被称为缺页,当 CPU 尝试访问一个已分配但未缓存的页面时会引发缺页异常,缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页被修改过,那么写回磁盘,且内核会修改页表把牺牲页替换掉,把当前要用的页在页表中替换上物理地址,有效位置 1,牺牲页有效位置 0。

在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入(或者页面调入)DRAM 和从 DRAM 换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度(demand paging)。也可以采用其他的方法,例如尝试着预测不命中,在页面实际被引用之前就换入页面。然而,所有现代系统都使用的是按需页面调度的方式。

分配页面

那么这里讲的就是未分配页的处理方式了,我们的做法就是分配一个页,分配过程是在磁盘上创建空间并更新页表,使它指向磁盘上这个新创建的页面。

又是局部性救了我们

虽然看上去上面的工作过程效率很低,但是实际上由于程序的局部性,我们程序趋向于只在很小一部分范围的页面中使用。在初始开销,也就是将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。只要我们的程序有好的时间局部性,虚拟内存系统就能工作得相当好。但是,当然不是所有的程序都能展现良好的时间局部性。如果工作集的大小超出了物理内存的大小,那么程序将产生一种不幸的状态,叫做抖动(thrashing),这时页面将不断地换进换出。虽然虚拟内存通常是有效的,但是如果一个程序性能慢得像爬一样,那么聪明的程序员会考虑是不是发生了抖动。

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

虚拟内存提供了一种保护机制,之前我们假设只有一个页表,现在是每个进程会有一个页表,不同进程可以映射到同一物理内存用于进程通信,如果要起到隔绝的作用,我们不把两个进程映射到同一物理内存那么就一定不会发生进程之间的安全问题,因为他们所操作的所有地址空间都无法映射到其它进程所占用的物理内存。

它简化了链接,简化了加载,简化了共享,简化了内存分配。

  • 简化链接:我们可以看到,在 Linux x86-64 下面链接得到的可执行文件,它的地址几乎是一样的,都是从 0x400000 开始的代码段,正是由于虚拟内存导致我们可以不需要考虑当时电脑的运行状态,因为不同进程的 0x400000 地址所映射到的物理内存地址都是不一样的。如果直接使用物理地址的话,那么不同进程必须划分地址出来,占用相同地址空间的程序将不能同时运行。
  • 简化加载:加载运行一个文件时,我只需要将页表标记到磁盘的指定位置,并标记为无效的(未缓存的),CPU 尝试访问这个虚拟内存的时候,发现没有被缓存,自动从磁盘调入数据到内存中,虚拟内存机制帮我们简化了从磁盘复制数据到内存的过程。
  • 简化共享:如果我们想加载一个动态链接库到另外一个内存,我们只需要先看看有没有已加载的内存,如果有直接让希望加载动态链接库的进程直接映射一块虚拟内存过去即可。
  • 简化内存分配:由于虚拟内存机制的存在,我们想要分配一个很大的连续的虚拟地址空间可以允许我们用不同的物理内存页,更高效地利用了碎片化的内存。

虚拟内存作为内存保护工具

在页表当中,除了那些位以外,还有三个位(READ,WRITE,SUP),读写和是否需要使用内核模式运行。并且每个进程也只允许访问它页表当中已经分配的内存,未分配的内存是无法访问的。

如果有指令违反了这些条件,那么 CPU 就会触发一个一般保护故障,把控制权转给内核中的异常处理程序,Linux 会把这个错误报告为段错误(segment fault)

地址翻译

先介绍一些参数

基本参数:

符号 描述
$N=2^n$ 虚拟地址空间中的地址数量
$M=2^m$ 物理地址空间中的地址数量
$P=2^p$ 页的大小(字节)

虚拟地址的组成部分:

符号 描述
VPO 虚拟页面偏移量(字节)
VPN 虚拟页号
TLBI TLB 索引
TLBT TLB 标记

物理地址的组成部分:

符号 描述
PPO 物理页面偏移量(字节)
PPN 物理页号
CO 缓冲块内的字节偏移量
CI 高速缓存索引
CT 高速缓存标记

形式上来说,地址翻译是一个 N 元素的虚拟地址空间(VAS)中的元素和一个 M 元素的物理地址空间(PAS)中元素之间的映射。

下面一张图也很好地反映了这关系:

通常情况下,访问是这样进行的:

  • 第 1 步:处理器生成一个虚拟地址,并把它传送给 MMU。
  • 第 2 步:MMU 生成 PTE 地址,并从高速缓存/主存请求得到它。
  • 第 3 步:高速缓存/主存向 MMU 返回 PTE。
  • 第 4 步:MMU 构造物理地址,并把它传送给高速缓存/主存。
  • 第 5 步:高速缓存/主存返回所请求的数据字给处理器。

而缺页情况下,从第四步开始:

  • 第 4 步:PTE 中的有效位是零,所以 MMU 触发了一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。
  • 第 5 步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
  • 第 6 步:缺页处理程序页面调入新的页面,并更新内存中的 PTE。
  • 第 7 步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU 将引起缺页的虚拟地址重新发送给 MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,之后就跟正常一样,主存会将所请求字返回给处理器。

结合高速缓存和虚拟内存

这里可能会有一个疑问,既然到了主存和磁盘这一阶段才有的地址翻译,那么 SRAM 这边是不是就都使用虚拟内存来访问呢?其实不是,关于到底使用物理地址还是虚拟地址高速缓存,记住一点:因为地址翻译它间接做了权限检查的问题,所以为了方便我们做好权限检查我们选择使用物理地址,也就是说 CPU 发出访存指令的时候,第一步一定是先翻译成物理地址,然后再查高速缓存,一直往下到主存。

利用 TLB 加速地址翻译

TLB 可以理解为一个 MMU 的高速缓存,采用组相联映射的方式进行。

在这里

VPN 的低 t 位是索引,剩余高 n-p-t 位为标记。

命中时,可以快速找到地址。不命中时,需要重新来查表然后更新 TLB 条目。

这里应该就是防止在页表中寻址,因为页表它也是在内存当中的,如果可以直接在TLB中查询得到那么便不需要经过页表了。

多级页表

直到看到这里,对之前的一些疑问才算解开了,我先罗列一下之前产生的疑问:

  • 假设 32 位的系统下,有 4GB 的虚拟地址空间,那么就有 $2^{20}$ 张页,如果单级页表需要有 $2^{20}$ 个PTE,假设每个 PTE 就占四个字节那么总共就是 $4MB$,每个进程都占用 $4MB$ 感觉开销有点太大了。
  • 第二个考量就是——难道说页表不是连续的?

对于这两个疑问,先回答第二个,那肯定是连续的,第 0 个页表项表示的就是虚拟地址从 0 开始的页表项,这样才能做到快速查找,如果每次查找要遍历内存那就适得其反了。

那么第一个问题其实在这一节就会解答了,在虚拟地址过长的时候会采用多级页表,因为在实际应用的时候,大部分的虚拟地址无法使用,这里就可以压缩。

如图所示,一个页表具有 1024个 PTE,这就只占了 4KB,但是能映射出 1K 个二级页表。它们再映射出 1M 个页,这能表示出足足 4GB 的内存。

那么对于一个 32 位的内存地址,我们可以这么看:前 10 位表示一级页表偏移,中间 10 位表示二级页表偏移,那么剩下 12 位就是页内偏移了。

对于更多位的地址,同样可以使用 k 级页表的思路。

访问 k 个 PTE,第一眼看上去昂贵而不切实际。然而,这里 TLB 能够起作用,正是通过将不同层次上页表的 PTE 缓存起来。实际上,带多级页表的地址翻译并不比单级页表慢很多。

案例:端到端的地址翻译

这里给我们讲解了一个例子,我觉得还是挺好的,跟着思路就能理解这个地址翻译的原理了。

首先系统参数如下:

  • 内存是按字节寻址的。
  • 内存访问是针对 1 字节的字的(不是 4 字节的字)。
  • 虚拟地址是 14 位长的($n=14$)。
  • 物理地址是 12 位长的($m=12$)。
  • 页面大小是 64 字节($P=64$)。
  • TLB 是四路组相联的,总共有 16 个条目。
  • L1 d-cache 是物理寻址、直接映射的,行大小为 4 字节,而总共有 16 个组。

那么对于物理地址和虚拟地址,我们这样子去划分:

  • TLB。TLB 是利用 VPN 的位进行虚拟寻址的。因为 TLB 有 4 个组,所以 VPN 的低 2 位就作为组索引(TLBI)。VPN 中剩下的高 6 位作为标记(TLBT),用来区别可能映射到同一个 TLB 组的不同的 VPN。
  • 页表。这个页表是一个单级设计,一共有 $2^8=256$ 个页表条目(PTE)。然而,我们只对这些条目中的开头 16 个感兴趣。为了方便,我们用索引它的 VPN 来标识每个 PTE;但是要记住这些 VPN 并不是页表的一部分,也不储存在内存中。另外,注意每个无效 PTE 的 PPN 都用一个破折号来表示,以加强一个概念:无论刚好这里存储的是什么位值,都是没有任何意义的。
  • 高速缓存。直接映射的缓存是通过物理地址中的字段来寻址的。因为每个块都是 4 字节,所以物理地址的低 2 位作为块偏移(CO)。因为有 16 组,所以接下来的 4 位就用来表示组索引(CI)。剩下的 6 位作为标记(CT)。

下面是 TLB 的条目和虚拟地址划分

下图是页表的条目

下图是 Cache 的参数和物理地址的划分。

让我们来看看当 CPU 执行一条读地址 0x03d4 处字节的加载指令时会发生什么。

首先按照虚拟地址的形式划分一下字段

首先我们要去找找 TLB,在第四组找到标记为 0x3 的记录,显然我们能找到一个 PPN 为 0xD 的记录,那么拿到这个 PPN 之后就可以拼接页内偏移算出物理地址(0x354)了。

然后再去 cache 中寻找,这个 0x354 物理地址拆分出来是:

先组选择,得到组下标为 5,然后再行匹配,看看是否有 0xD 的记录,显然有,那么高速缓存命中,再根据块偏移找到第 0 块的值,传送给 CPU。

案例研究:Intel Core i7 / Linux 内存系统

内存映射

Linux 通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)

内存映射可以映射到两种类型的对象:

  • Linux 的普通文件:简单来说,我们尝试去打开一个文件或者是执行一个文件时,都会创建数个虚拟内存页,并把它标记到这个文件所在磁盘的位置,那么尝试读这块内存的时候由缺页处理程序负责把磁盘中的文件加载到内存中。
  • 匿名文件:匿名文件是由内核创建的,包含的全是二进制零,匿名文件存在的意义是用于父子进程之间通信而不被其它进程获取。

一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点是,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。

再看共享对象

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象,如果一个进程将一个共享对象映射到他的虚拟地址的一个区域,那么它对这个区域的操作对其他进程可见,同时可以反映到磁盘上,但是如果映射到了私有区域,那么其他进程不可见,而且不会在磁盘上发生变化。

共享对象在虚拟区域:

这就好比 glibc 作为共享对象时,另一个进程也需要加载,那么发现这个共享对象在内存中便直接映射到自己的虚拟内存区域。

私有区域的写时复制:

私有区域的写时复制感觉可以看成 glibc 中的 data 段,上面的值肯定不可能所有进程共享,因此那一段被标记为写时复制。即第一次写内存区域时,重新分配新的物理内存,并把此段内存拷贝到上面去,然后重新改变那个进程的页表,把对应的虚拟内存区域映射改到我们新复制的物理内存区域。

再看fork函数

在复制一个进程的时候会进行浅拷贝,即不开辟新的物理内存区域,两个进程共享同一物理内存的代码段,但是 fork 之后,所有的段都会变成只读和写拷贝,一旦要写,就会复制出一份区域再在那段区域上操作。

当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

再看 execve 函数

execve 函数一次调用,除非调用错误否则不会返回。

加载运行的时候会进行如下几个步骤:

  1. 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 a.out 文件中的. text 和. data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 a.out 中。栈和堆区域也是请求二进制零的,初始长度为零。图中概括了私有区域的不同映射。
  3. 映射共享区域。如果 a.out 程序与共享对象(或目标)链接,比如标准 C 库 libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

使用 mmap 函数的用户及内存映射

1
2
3
4
5
6
7
#include <unistd.h>
#include <sys/mman.h>

void *mmap(void *start, size_t length, int prot, int flags,
int fd, off_t offset);

// 返回:若成功时则为指向映射区域的指针,若出错则为 MAP_FAILED(-1)。

mmap 函数要求内核创建一个新的虚拟内存区域,最好是从地址 start 开始的一个区域,并将文件描述符 fd 指定的对象的一个连续的片(chunk)映射到这个新的区域。连续的对象片大小为 length 字节,从距文件开始处偏移量为 offset 字节的地方开始。start 地址仅仅是一个暗示,通常被定义为 NULL。为了我们的目的,我们总是假设起始地址为 NULL。

下图讲述了大部分参数的描述。

其中参数 prot 包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的 vm_prot 位)。

  • PROT_EXEC:这个区域内的页面由可以被 CPU 执行的指令组成。
  • PROT_READ:这个区域内的页面可读。
  • PROT_WRITE:这个区域内的页面可写。
  • PROT_NONE:这个区域内的页面不能被访问

参数 flags 由描述被映射对象类型的位组成。如果设置了 MAP_ANON 标记位,那么被映射的对象就是一个匿名对象,而相应的虚拟页面是请求二进制零的。MAP_PRI-VATE 表示被映射的对象是一个私有的、写时复制的对象,而 MAP_SHARED 表示是一个共享对象。

比如

1
bufp = Mmap(NULL, size, PROT_READ, MAP_PRIVATE|MAP_ANON, 0, 0);

让内核创建一个新的包含 size 字节的只读、私有、请求二进制零的虚拟内存区域。如果调用成功,那么 bufp 包含新区域的地址。

使用 ummap 对我们隐射的内存区域进行取消。

1
2
3
4
5
6
#include <unistd.h>
#include <sys/mman.h>

int munmap(void *start, size_t length);

// 返回:若成功则为 0,若出错则为 -1。

取消之后再引用会引发段错误。

之后有个练习题:

编写一个 C 程序 mmapcopy.c,使用 mmap 将一个任意大小的磁盘文件复制到 stdouto 输入文件的名字必须作为一个命令行参数来传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<string.h>
#include<sys/stat.h>
int main(int argc,char *argv[]){
if(argc!=2){
printf("usage: mmapcopy <fileanme>");
return 0;
}
int fd=open(argv[1],O_RDONLY);
if(fd<0){
int len1=strlen(argv[0]);
int len2=strlen(argv[1]);

char *s=(char *)malloc(len1+len2+5);
sprintf(s,"%s: %s",argv[0],argv[1]);
perror(s);
return 0;
}
int len;
struct stat st;
fstat(fd,&st);
char *buffer=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,fd,0);
write(1,buffer,st.st_size);
}

动态内存分配

这个我之前写过 malloc 源码分析,应该对这个还是比较了解的,至于 malloc lab mark一下有时间再来做吧。

垃圾收集

使用 malloc 分配内存,要负责在不用的时候进行释放,否则会造成内存资源泄露。

垃圾收集器(garbage collector)是一种动态内存分配器,它自动释放程序不再需要的已分配块。这些块被称为垃圾(garbage)(因此术语就称之为垃圾收集器)。自动回收堆存储的过程叫做垃圾收集(garbagecollection)。在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显示地释放它们。在 C 程序的上下文中,应用调用 malloc,但是从不调用 free。反之,垃圾收集器定期识别垃圾块,并相应地调用 free,将这些块放回到空闲链表中。

本书主要研究 Mark&Sweep(标记 & 清除)算法。

垃圾收集器的基本知识

其实就跟 C++ 的智能指针差不多,我们要识别什么是垃圾,当我们在外部的内存中,没有指针能够访问的时候,它就是一个“垃圾”了。

后面也想咕咕了,这个后面有时间再了解吧。

C 程序中常见的与内存有关的错误

间接引用坏指针

比如新手写 scanf 经常写成:

1
scanf("%d",val);

错把 val 的值当指针传进去了,如果恰好 val 没有初始化指向了一个正确的内存区域,那么会造成很奇怪的错误。

读未初始化的内存

malloc 申请过来的内存不一定是干净的,我们需要 memset 手动清空,或者是拿到内存就直接写而不进行任何读的操作。

允许栈缓冲区溢出

错误的使用了类似 gets 函数,造成缓冲区溢出。

假设指针和它们指向的对象是相同大小的

比如下面这个程序

1
2
3
4
5
6
7
8
9
10
/* Create an nxm array */
int **makeArray1(int n, int m)
{
int i;
int **A = (int **)Malloc(n * sizeof(int));

for (i = 0; i < n; i++)
A[i] = (int *)Malloc(m * sizeof(int));
return A;
}

这个程序只会在 32 位环境下运行良好,如果在 64 位的环境下会出现不可预测的错误。

造成错位错误

错位(off-by-one)错误是另一种很常见的造成覆盖错误的来源。

最常见的就是 int a[n];for(int i=1;i<=n;i++) 这种类型的代码,很显然,int a[n] 的定义中不包括下标 n,后面内存是什么咱也不知道,反正造成的错误也是不可预估的。

引用指针,而不是它所指向的对象

比如常见的 *p++,虽然说 ++ 和取值运算符优先级一样,但是它是从右往左结合的,所以实际上我们并没有给 p 所指向的值 +1 而是让指针后移了一位再取值。

误解指针运算

比如下面这个

1
2
3
4
5
6
int *search(int *p, int val)
{
while (*p && *p != val)
p += sizeof(int); /* Should be p++ */
return p;
}

因为指针与 int 运算已经重载了 + - += -= 等运算符,所以我们没必要多此一举乘上一个 sizeof。

引用不存在的变量

1
2
3
4
5
6
int *stackref ()
{
int val;

return &val;
}

这个如果了解过局部变量的就很能发现这个问题所在了,它虽然地址还有效,但是在此基础上只要在此调用其它函数便有可能会覆盖这个变量。

引用空闲堆块中的数据

其实就是我们熟知的 UAF 漏洞了。

free 之后没有清空指针,并且有继续使用的可能性。

引起内存泄漏

内存泄漏理解为内存资源泄露,就是没有释放不使用的空间,导致程序所占用的空间非常大,但是大部分空间又不使用,那这一部分的空间就会被浪费了。

小结

虚拟内存是一个抽象的概念,可以把主存理解为对磁盘的缓存。

主要思想是我们假设每个进程的内存都是独占的(虚拟空间),实际上只有被用到的内存会被页表分配实际的物理地址,当CPU要访问某个虚拟地址时会查找页表得到物理地址并取值返还给CPU。

虚拟内存可以分为三类:已分配,未分配,已分配中又可以细分已缓存和未缓存。

  • 已缓存是指已存在于物理内存中的页,我们通过地址翻译可以直接找到这个页。
  • 未缓存是指存在但没有实际分配的页,访问这个页会导致一个缺页异常,异常处理程序会把正确的页调入其中。
  • 访问未分配的虚拟内存会直接导致一个段错误。

它保证了不同进程之间私有地址互相看不到,也能很轻松地共享一些内存。

页表也有缓存,页表的缓存是 TLB,和 cache 的思路类似。

内存映射是指把磁盘内容和内存内容关联起来,读的时候通过虚拟内存机制把磁盘内容调入物理内存。

再就是指针造成的常见的错误,都遇见过,原理差不多都懂。