理解内存

3.6 理解内存

上部分: 虚拟内存和内存保护是什么

计算机有五大组成部分,分别是:运算器、控制器、存储器、输入设备和输出设备。 如果说计算机最重要的组件,是承担了运算器和控制器作用的 CPU,那内存就是我们第二重要的组件了。内存是五大组成部分里面的存储器,我们的指令和数据,都需要先加载到内存里面,才会被 CPU 拿去执行。

在我们日常使用的 Linux 或者 Windows 操作系统下,程序并不能直接访问物理内存。

我们的内存需要被分成固定大小的页(Page),然后再通过虚拟内存地址(Virtual Address)到物理内存地址(Physical Address)的地址转换(Address Translation),才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址,都是虚拟内存地址。

简单页表

一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作页表(Page Table)。

页表这个地址转换的办法,会把一个内存地址分成页号(Directory)偏移量(Offset)两个部分。

其实,前面的高位,就是内存地址的页号。后面的低位,就是内存地址里面的偏移量。做地址转换的页表,只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存,在物理层面是连续的。以一个页的大小是 4K 字节(4KB)为例,我们需要 20 位的高位,12 位的低位。

页表
页表

对于一个内存地址转换,其实就是这样三个步骤:

  1. 把虚拟内存地址,切分成页号和偏移量的组合;
  2. 从页表里面,查询出虚拟页号,对应的物理页号;
  3. 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

算一算,这样一个页表需要多大的空间吗?我们以 32 位的内存地址空间为例,

页表一共需要记录 2^20 个到物理页号的映射关系。这个存储关系,就好比一个 2^20 大小的数组。一个页号是完整的 32 位的 4 字节(Byte),这样一个页表就需要 4MB 的空间。听起来 4MB 的空间好像还不大啊,毕竟我们现在的内存至少也有 4GB,服务器上有个几十 GB 的内存和很正常。

不过,这个空间可不是只占用一份哦。我们每一个进程,都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。不管我们这个进程,是个本身只有几 KB 大小的程序,还是需要几 GB 的内存空间,都需要这样一个页表。

这还只是 32 位的内存地址空间,现在大家用的内存,多半已经超过了 4GB,也已经用上了 64 位的计算机和操作系统。这样的话,用上面这个数组的数据结构来保存页面,内存占用就更大了。那么,我们有没有什么更好的解决办法呢?


多级页表

仔细想一想,我们其实没有必要存下这 2^20 个物理页表啊。大部分进程所占的内存是有限的,所需要的页也自然是很有限的。我们只需要去存那些用到的页之间的映射关系就好了。

在实践中,我们其实采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。这是为什么呢?为什么我们不用哈希表而用多级页表呢?接下来我会慢慢解释

一个进程的内存地址空间是怎么分配的。在整个进程的内存地址空间,通常是“两头实、中间空”。 在程序运行的时候,内存地址从顶部往下,不断占用栈的空间,而堆的空间,内存地址这是从底部往上,是不断分配占用的。

所以,在一个实际的程序进程里,虚拟内存占用的地址空间,通常是两段连续的空间。 而不是完全散落的随机的内存地址。 多级列表,就比较适合这样的内存地址分布。

以一个 4 级的多级页表为例,来看一下。同样一个虚拟内存地址,偏移量的部分和上面简单页表一样不变,但是原先的页号部分,我们把它拆成四段,从高到低,分成 4 级到 1 级这样 4 个页表索引。

内存地址分布

对应的,一个进程会有一个 4 级页表。我们先通过 4 级页表索引,找到 4 级页表里面对应的条目(Entry)。这个条目里存放的是一张 3 级页表所在的位置。4 级页面里面的每一个条目,都对应着一张 3 级页表,所以我们可能有多张 3 级页表。

找到对应这张 3 级页表之后,我们用 3 级索引去找到对应的 3 级索引的条目。3 级索引的条目再会指向一个 2 级页表。同样的,2 级页表里我们可以用 2 级索引指向一个 1 级页表。

而最后一层的 1 级页表里面的条目,对应的数据内容就是物理页号了。在拿到了物理页号之后,我们同样可以用“页号 + 偏移量”的方式,来获取最终的物理内存地址。

事实上,多级页表就像一个多叉树的数据结构,所以我们常常称它为页表树(Page Table Tree)。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 2 级、3 级的页表。找到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。

页表树

以这样的分成 4 级的多级页表来看,每一级如果都用 5 个比特表示。那么每一张某 1 级的页表,只需要 2^5=32 个条目。如果每个条目还是 4 个字节,那么一共需要 128 个字节。而一个 1 级索引表,对应 32 个 4KB 的也就是 128KB 的大小。一个填满的 2 级索引表,对应的就是 32 个 1 级索引表,也就是 4MB 的大小。

可以一起来测算一下,一个进程如果占用了 8MB 的内存空间,分成了 2 个 4MB 的连续空间。

那么,它一共需要 2 个独立的、填满的 2 级索引表,也就意味着 64 个 1 级索引表,2 个独立的 3 级索引表,1 个 4 级索引表。一共需要 69 个索引表,每个 128 字节,大概就是 9KB 的空间。比起 4MB 来说,只有差不多 1/500。


虽然多级页表节约了我们的存储空间,却带来了时间上的开销,其实是一个“以时间换空间的”策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。但是,用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号了。

我们在前面两讲讲过,内存访问其实比 Cache 要慢很多。我们本来只是要做一个简单的地址转换,反而是一下子要多访问好多次内存。对于这个时间层面的性能损失,我们有没有什么更好的解决办法呢?

总结延伸

我们从最简单的进行虚拟页号一一映射的简单页表说起,仔细讲解了现在实际应用的多级页表。多级页表就像是一颗树。因为一个进程的内存地址相对集中和连续,所以采用这种页表树的方式,可以大大节省页表所需要的空间。而因为每个进程都需要一个独立的页表,这个空间的节省是非常可观的。

在优化页表的过程中,我们可以观察到,数组这样的紧凑的数据结构,以及树这样稀疏的数据结构,在时间复杂度和空间复杂度的差异。另外,纯粹理论软件的数据结构和硬件的设计也是高度相关的。

注解:

哈希表有哈希冲突 并且顺序乱(无序) 不符合局部性原理 所以页表存储更复合计算机运行特点 64位系统的快表应该是对页表快速查询的一个优化

下部分 解析TLB和内存保护


程序里面的每一个进程,都有一个属于自己的虚拟内存地址空间。我们可以通过地址转换来获得最终的实际物理地址。我们每一个指令都存放在内存里面,每一条数据都存放在内存里面。因此,“地址转换”是一个非常高频的动作,“地址转换”的性能就变的是非重要了。 今天要讲的第一个问题,也就是性能问题

因为我们的指令、数据都存放在内存里面,这里就会遇到我们今天要谈的第二个问题,也就是内存安全问题。 如果被人修改了内存里面的内容,我们的 CPU 就可能会去执行我们计划之外的指令。这个指令可能是破坏我们服务器里面的数据,也可能是被人获取到服务器里面的敏感信息。

加速地址转换 : TLB

上一节我们说了,从虚拟内存地址到物理内存地址的转换,我们通过页表这个数据结构来处理。为了节约页表的内存存储空间,我们会使用多级页表数据结构。

不过,多级页表虽然节约了我们的存储空间,但是却带来了时间上的开销,变成了一个“以时间换空间”的策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。但是用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号。

我们知道,内存访问其实比 Cache 要慢很多。我们本来只是要做一个简单的地址转换,现在反而要一下子多访问好多次内存。这种情况该怎么处理呢?你是否还记得之前讲过的“加个缓存”的办法呢?我们来试一试。

程序所需要使用的指令,都顺序存放在虚拟内存里面。我们执行的指令,也是一条条顺序执行下去的。也就是说,我们对于指令地址的访问,存在前面几讲所说的“空间局部性”和“时间局部性”,而需要访问的数据也是一样的。我们连续执行了5条指令。因为地址都是连续的, 所以这5个指令通常是在同一个 “虚拟页” 里。

因此,这连续 5 次的内存地址转换,其实都来自于同一个虚拟页号,转换的结果自然也就是同一个物理页号。可以用前面几讲说过的,用一个“加个缓存”的办法。把之前的内存转换地址缓存下来,使我们不需要反复去访问内存来进行内存地址转换。

相邻的内存地址

于是,计算机工程师们专门在 CPU 里放了一块缓存芯片。这块缓存芯片我们称之为 TLB,全称是地址变换高速缓冲(Translation - Lookaside Buffer)。这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候, 我们可以直接在TLB 里面查询结果,而不需要多次访问内存来完成一次转换。

TLB 和我们前面讲的 CPU 的高速缓存类似,可以分成指令的 TLB 和数据的 TLB,也就是 ITLB 和 DTLB。同样的,我们也可以根据大小对它进行分级,变成 L1、L2 这样多层的 TLB。

除此之外,还有一点和 CPU 里的高速缓存也是一样的,我们需要用脏标记这样的标记位,来实现“写回”这样缓存管理策略。

TLB

为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。

内存保护和安全性

进程的程序也好,数据也好,都要存放在内存里面。实际程序指令的执行,也是通过程序计数器里面的地址,去读取内存内的内容,然后运行对应的指令,使用相应的数据。


虽然我们现代的操作系统和 CPU,已经做了各种权限的管控。正常情况下,我们已经通过虚拟内存地址和物理内存地址的区分,隔离了各个进程。但是,无论是 CPU 这样的硬件,还是操作系统这样的软件,都太复杂了,难免还是会被黑客们找到各种各样的漏洞。

在对于内存的管理里面,计算机也有一些最底层的安全保护机制。这些机制统称为 内存保护 ,这里就为你简单介绍两个。

可执行空间保护

这个机制是说,我们对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限。因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。 我们 直接把数据部分拿给 CPU ,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。

这个时候,黑客们想到了一些搞破坏的办法。我们在程序的数据区里,放入一些要执行的指令编码后的数据,然后找到一个办法,让 CPU 去把它们当成指令去加载,那 CPU 就能执行我们想要执行的指令了。对于进程里内存空间的执行权限进行控制,可以使得 CPU 只能执行指令区域的代码。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉。

其实,在实际的应用开发中,类似的策略也很常见。我下面给你举两个例子。

比如说,在用 PHP 进行 Web 开发的时候,我们通常会禁止 PHP 有 eval 函数的执行权限。这个其实就是害怕外部的用户,所以没有把数据提交到服务器,而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候,可能就会执行到预计之外被“注入”的破坏性脚本。

还有一个例子就是 SQL 注入攻击。如果服务端执行的 SQL 脚本是通过字符串拼装出来的,那么在 Web 请求里面传输的参数就可以藏下一些我们想要执行的 SQL,让服务器执行一些我们没有想到过的 SQL 语句。这样的结果就是,或者破坏了数据库里的数据,或者被人拖库泄露了数据。


地址空间布局随机化

第二个常见的安全机制,叫地址空间布局随机化(Address Space Layout Randomization)。

内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。

这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。

原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。

地址空间随机化

这样的“随机化”策略,其实也是我们日常应用开发中一个常见的策略。一个大家都应该接触过的例子就是密码登陆功能。网站和 App 都会需要你设置用户名和密码,之后用来登陆自己的账号。然后,在服务器端,我们会把用户名和密码保存下来,在下一次用户登陆的时候,使用这个用户名和密码验证。

我们的密码当然不能明文存储在数据库里,不然就会有安全问题。如果明文存储在数据库里,意味着能拿到数据库访问权限的人,都能看到用户的明文密码。这个可能是因为安全漏洞导致被人拖库,而且网站的管理员也能直接看到所有的用户名和密码信息。

于是,大家会在数据库里存储密码的哈希值,比如用现在常用的 SHA256,生成一一个验证的密码哈希值。但是这个往往还是不够的。因为同样的密码,对应的哈希值都是相同的,大部分用户的密码又常常比较简单。于是,拖库成功的黑客可以通过彩虹表的方式,来推测出用户的密码。

这个时候,我们的“随机化策略”就可以用上了。我们可以在数据库里,给每一个用户名生成一个随机的、使用了各种特殊字符的盐值(salt). 这样,我们的哈希值就不再是仅仅使用密码来生成的了,而是密码和盐值放在一起生成的对应的哈希值。 哈希值的生成中,包括了一些类似于“乱码”的随机字符串,所以通过彩虹表碰撞来猜出密码的办法就用不了了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
例子:

$password = "goodmorning12345";
// 我们的密码是明文存储的

$hashed_password = hash('sha256', password);
// 对应的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
// 但是这个hash值里可以用彩虹表直接“猜出来”原始的密码就是goodmorning12345

$salt = "#21Pb$Hs&Xi923^)?";
$salt_password = $salt.$password;
$hashed_salt_password = hash('sha256', salt_password);
// 这个hash后的slat因为有部分随机的字符串,不会在彩虹表里面出现。
// 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f


可以看到,通过加入“随机”因素,我们有了一道最后防线。即使在出现安全漏洞的时候,我们也有了更多的时间和机会去补救这些问题。

虽然安全机制似乎在平时用不太到,但是在开发程序的时候,还是要有安全意识。毕竟谁也不想看到,被拖库的新闻里出现的是自己公司的名字,也不希望用户因为我们的错误遭受到损失。

总结延伸

为了节约页表所需要的内存空间,我们采用了多级页表这样一个数据结构。但是,多级页表虽然节省空间了,却要花费更多的时间去多次访问内存。于是,我们在实际进行地址转换的 MMU 旁边放上了 TLB 这个用于地址转换的缓存。TLB 也像 CPU Cache 一样,分成指令和数据部分,也可以进行 L1、L2 这样的分层。

然后,我为你介绍了内存保护。无论是数据还是代码,我们都要存放在内存里面。为了防止因为各种漏洞,导致一个进程可以访问别的进程的数据或者代码,甚至是执行对应的代码,造成严重的安全问题,我们介绍了最常用的两个内存保护措施,可执行空间保护和地址空间布局随机化。

通过让数据空间里面的内容不能执行,可以避免了类似于“注入攻击”的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。

-------------本文结束感谢您的阅读-------------
作者水平有限,文中难免存在一些错误,欢迎邮件@交流讨论~
Zongpeng Lin 微信 微信
Zongpeng Lin 支付宝 支付宝