机器指令里面的内存地址都是虚拟内存地址。程序里面的每一个进程,都有一个属于自己的虚拟内存地址空间。我们可以通过地址转换来获得最终的实际物理地址。我们每一个指令都存放在内存里面,每一条数据都存放在内存里面。因此,“地址转换”是一个高频的动作,因此其性能问题就非常重要了。
另外,因为我们的指令、数据都存放在内存里面如果被人修改/读取了内存里面的内容,我们的CPU就可能去执行我们计划之外的指令。这个指令可能是破坏我们服务器里面的数据,也可能是被人窃取了服务器里的敏感信息,也就是说内存安全是必须要保证的。
那现代CPU和操作系统,会通过什么样的方式来解决这两个问题呢?
加速地址转换:TLB
从虚拟内存地址到物理内存地址的转换,我们通过页表这个数据结构来处理。为了节约页面的内存存储空间,我们会使用多级页表数据结构。
不过,多级页表虽然节约了我们的存储空间,但是却也带来了时间上的开销。原本进程一次地址转换,只访问一次内存就能找到物理页号,算出物理内存地址。但是使用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号。
我们知道,内存访问其实比 Cache 要慢很多。我们本来只是要做一个简单的地址转换,现在反而要一下子多访问好多次内存。这种情况该怎么处理呢?“加个缓存”试一试:
- 程序所需要使用的指令,都顺序存放在虚拟内存地址里面。我们执行的指令,也是一条条顺序执行下去的。也就是说,我们对于指令地址的访问,存在“空间局部性”和“时间局部性”,而需要访问的数据也是一样。如果连续执行了5条指令,因为内存地址都是连续的,所以这5条指令往往是在同一个“虚拟页”里面的
- 因此,这连续5次的内存地址转换,其实都是来自于同一个虚拟页号,转换的结果自然也就是同一个物理页号。那么,就可以“加个缓存”,把之前的内存转换地址缓存下来,使得我们不需要反复去访问内存来进行内存地址转换。
- 于是,计算机工程师们专门在 CPU 里放了一块缓存芯片。这块缓存芯片我们称之为TLB,全称是地址变换高速缓冲(Translation-Lookaside Buffer)。这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在TLB里面查询结果,而不需要多次访问内存来完成一次转换。
TLB和CPU高速缓存类似,可以分为指令TLB和数据TLB,也就是ITLB和DTLB。同样的,我们也可以根据大小对它分级,变成L1、L2这样多层的TLB。
除此之外,还有一点和CPU里的高速缓存一样,我们需要用脏标记这样的标记位,来实现“写回”这样缓存管理策略
为了性能,我们整个内存转换过程也要有硬件来执行。在CPU芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址值转换。和TLB的访问和交互,都是由这个MMU控制的。
安全性和内存保护
进程的程序也好,数据也好,都要存放在内存里面。实际程序指令的执行,也是通过程序计数器里面的地址,去读取内存内的内容,然后运行对应的指令,使用相应的数据。
虽然现代操作系统和CPU,已经做了各种权限的管理,正常情况下,我们已经通过了虚拟内存地址和物理内存地址的区分,隔离了各个进程。但是,无论是CPU 这样的硬件,还是操作系统这样的软件,都太复杂了,难免还是会被黑客们找到各种各样的漏洞。
就像我们在软件开发过程中,常常会有一个“兜底”的错误处理方案一样,在对于内存的管理里面,计算机也有一些最底层的安全保护机制。这些机制统称为内存保护(Memory Protection)。比较常见的有两个
- 可执行空间保护(Executable Space Protection)。
- 地址空间布局随机化(Address Space Layout Randomization)。
可执行空间保护
- 这个机器是说,对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”权限。
- 因为无论是指令,还是数据,在CPU看来,都是二进制数据。我们直接把数据部分拿给CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。
黑客们想到了一些搞破坏的办法。在程序的数据区里面,放入一些要执行的指令编码后的数据,然后想方法,让CPU把它们当成指令去加载,那CPU 就能执行破坏性代码了。
通过这个方法,对进程里内存空间的执行程序进行控制,可以使得CPU只能执行指令区域的代码,对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡调。
其实,在实际的应用开发中,类似的策略也很常见。下面两个例子。
- 比如说,在用 PHP 进行 Web 开发的时候,我们通常会禁止 PHP 有 eval 函数的执行权限。这个其实就是害怕外部的用户,所以没有把数据提交到服务器,而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候,可能就会执行到预计之外被“注入”的破坏性脚本
- 还有一个例子就是 SQL 注入攻击。如果服务端执行的 SQL 脚本是通过字符串拼装出来的,那么在 Web 请求里面传输的参数就可以藏下一些我们想要执行的 SQL,让服务器执行一些我们没有想到过的 SQL 语句。这样的结果就是,或者破坏了数据库里的数据,或者被人拖库泄露了数据。
地址空间布局随机化
内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。
这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。
原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。
总结
- 为了节约页表所需要的内存空间,我们采用了多级页表这样一个数据结构。但是,多级页表虽然节省空间了,却要花费更多的时间去多次访问内存。于是,我们在实际进行地址转换的MMU 旁边放上了 TLB 这个用于地址转换的缓存。TLB 也像 CPU Cache 一样,分成指令和数据部分,也可以进行 L1、L2 这样的分层。
- 无论是数据还是代码,我们都要存放在内存里面。为了防止因为各种漏洞,导致一个进程可以访问别的进程的数据或者代码,甚至是执行对应的代码,造成严重的安全问题,我们介绍了最常用的两个内存保护措施,可执行空间保护和地址空间布局随机化。