40 | 理解内存(上):虚拟内存和内存保护是什么?
这些虚拟内存地址究竟是怎么转换成物理内存地址的呢?
简单页表
想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作页表(Page Table)。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量的组合;
- 从页表里面,查询出虚拟页号,对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
32 位的内存地址空间,页表一共需要记录 2^20 个到物理页 号的映射关系。这个存储关系,就好比一个 2^20 大小的数组。一个页号是完整的 32 位的 4 字节(Byte),这样一个页表就需要 4MB 的空间。
每一个进程,都有属于自己独立的虚拟内存地址 空间。这也就意味着,每一个进程都需要这样一个页表。不管我们这个进程,是个本身只有 几 KB 大小的程序,还是需要几 GB 的内存空间,都需要这样一个页表。
多级页表
其实没有必要存下这 2^20 个物理页表啊。大部分进程所占用的内存是 有限的,需要的页也自然是很有限的。我们只需要去存那些用到的页之间的映射关系就好了。
为什么我们不用哈希表而用多级页表呢?
在整个进程的内存地址空间,通 常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。
在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而 不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
同样一个虚拟内存地址,偏移量的部分和上 面简单页表一样不变,但是原先的页号部分,我们把它拆成四段,从高到低,分成 4 级到 1 级这样 4 个页表索引。
对应的,一个进程会有一个 4 级页表。我们先通过 4 级页表索引,找到 4 级页表里面对应 的条目(Entry)。这个条目里存放的是一张 3 级页表所在的位置。4 级页面里面的每一个 条目,都对应着一张 3 级页表,所以我们可能有多张 3 级页表。
我们可能有很多张 1 级页表、2 级页表,乃至 3 级页表。但是,因为实际的虚拟内存空间 通常是连续的,我们很可能只需要很少的 2 级页表,甚至只需要 1 张 3 级页表就够了。
多级页表就像一个多叉树的数据结构,所以我们常常称它为页表树(Page Table Tree)。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就 不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 2 级、3 级的页表。找 到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。
以这样的分成 4 级的多级页表来看,每一级如果都用 5 个比特表示。那么每一张某 1 级的 页表,只需要 2^5=32 个条目。如果每个条目还是 4 个字节,那么一共需要 128 个字节。
而一个 1 级索引表,对应 32 个 4KiB 的也就是 16KB 的大小。一个填满的 2 级索引表,对应的就是 32 个 1 级索引表,也就是 512KB 的大小。 一个进程如果占用了 1MB 的内存空间,分成了 2 个 512KB 的 连续空间。那么,它一共需要 2 个独立的、填满的 2 级索引表,也就意味着 64 个 1 级索引表,2 个独立的 3 级索引表,1 个 4 级索引表。一共需要 69 个索引表,每个 128 字 节,大概就是 9KB 的空间。比起 4MB 来说,只有差不多 1/500。
不过,多级页表虽然节约了我们的存储空间,却带来了时间上的开销,所以它其实是一 个“以时间换空间”的策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。
41 | 理解内存(下):解析TLB和内存保护
程序里面的每一个进程,都有一个属于自己的 虚拟内存地址空间。我们可以通过地址转换来获得最终的实际物理地址。我们每一个指令都 存放在内存里面,每一条数据都存放在内存里面。因此,“地址转换”是一个非常高频的动作,“地址转换”的性能就变得至关重要了。这就是我们今天要讲的第一个问题,也就是性能问题。 因为我们的指令、数据都存放在内存里面,这里就会遇到我们今天要谈的第二个问题,也就是内存安全问题。如果被人修改了内存里面的内容,我们的 CPU 就可能会去执行我们计划 之外的指令。这个指令可能是破坏我们服务器里面的数据,也可能是被人获取到服务器里面 的敏感信息。
加速地址转换:TLB
程序所需要使用的指令,都顺序存放在虚拟内存里面。我们执行的指令,也是一条条顺序执 行下去的。也就是说,我们对于指令地址的访问,存在前面几讲所说的“空间局部 性”和“时间局部性”,而需要访问的数据也是一样的。我们连续执行了 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)。
可执行空间保护
对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的, 对于其他部分,比如数据部分,不给予“可执行”的权限。因为无论是指令,还是数据,在 我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。
对于进程里内存空间的执行权限进行控制,可以使得 CPU 只能 执行指令区域的代码。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行, 也会因为没有权限而被阻挡掉。
地址空间布局随机化
原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里, 程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。 而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配 这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找 到想要修改的内容的位置。
42 | 总线:计算机内部的高速公路
计算机是用什么样的方式来完成,CPU 和内存、以及外部输入输出设备的通信呢?
降低复杂性:总线的设计思路来源
如果各个设备间的通信,都是互相之间单独进行的。如果我们有个不同的设备,他们之间需要各自单独连接,那么系统复杂度就会变成。每一个设备或者功能电路模块,都要和其他个设备去通信。为了简化系统的复杂度,我们就引入了总线,把这个的复杂度,变成一个的复杂度。
与其让各个设备之间互相单独通信,不如我们去设计一个公用的线 路。CPU 想要和什么设备通信,通信的指令是什么,对应的数据是什么,都发送到这个线 路上;设备要向 CPU 发送什么信息呢,也发送到这个线路上。这个线路就好像一个高速公 路,各个设备和其他设备之间,不需要单独建公路,只建一条小路通向这条高速公路就好 了。
理解总线:三种线路和多总线架
CPU 和内存以及高速缓存通信的总线,这里面通常有两种总线。这种方式,我们称之为双独立总线(Dual Independent Bus,缩写为 DIB)。CPU 里,有一个快速的本地总线(Local Bus),以及一个速度相对较慢的前端总线(Front-side Bus)。
现代的 CPU 里,通常有专门的高速缓存芯片。这里的高速本地总线,就是用来和高速缓存通信的。
前端总线,则是用来和主内存以及输入输出设备通信的。
有时候,我们会把本地总线也叫作后端总线(Back-side Bus),和前面的前端总线对应起来。
而前端总线也有很多其他名字,比如处理器总线(Processor Bus)、内存总线 (Memory Bus)。
CPU 里面的北桥芯片,把我们上面说的前端总线,一分为二,变成了三个总线。
我们的前端总线,其实就是系统总线。CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器(I/O Bridge)。这个 I/O 桥接器,一边接入了我们的内存总线,使得我们的 CPU 和内存通信;另一边呢,又接入了一个 I/O 总线,用来连接 I/O 设备。
在物理层面,其实我们完全可以把总线看作一组“电线”。不过呢,这些电线之间也是有分工的,我们通常有三类线路
- 数据线(Data Bus),用来传输实际的数据信息,也就是实际上了公交车的“人”。
- 地址线(Address Bus),用来确定到底把数据传输到哪里去,是内存的某个位置,还 是某一个 I/O 设备。这个其实就相当于拿了个纸条,写下了上面的人要下车的站点。
- 控制线(Control Bus),用来控制对于总线的访问。虽然我们把总线比喻成了一辆公 交车。那么有人想要做公交车的时候,需要告诉公交车司机,这个就是我们的控制信号。
总线不能同时给多个设备提供通信功能。 我们的总线是很多个设备公用的,那多个设备都想要用总线,我们就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制,就叫作总线裁决(Bus Arbitraction)。
43 | 输入输出设备:我们并不是只能用灯泡显示“0”和“1”
接口和设备:经典的适配器模式
实际上,输入输出设备,并不只是一个设备。大部分的输入输出设备,都有两个组成部分。 第一个是它的接口(Interface),第二个才是实际的 I/O 设备(Actual I/O Device)。我 们的硬件设备并不是直接接入到总线上和 CPU 通信的,而是通过接口,用接口连接到总线 上,再通过总线和 CPU 通信。
接口本身就是一块电路板。CPU 其实不是和实际的硬件设备打交道,而是和这个接口电路 板打交道。我们平时说的,设备里面有三类寄存器,其实都在这个设备的接口电路上,而不在实际的设备上。
那这三类寄存器是哪三类寄存器呢?它们分别是状态寄存器(Status Register)、 命令寄 存器(Command Register)以及数据寄存器(Data Register)
把接口和实际设备分离,这个做法实际上来自于计算机走向开放架构(Open Architecture)的时代。 当我们要对计算机升级,我们不会扔掉旧的计算机,直接买一台全新的计算机,而是可以单 独升级硬盘这样的设备。我们把老硬盘从接口上拿下来,换一个新的上去就好了。各种输入 输出设备的制造商,也可以根据接口的控制协议,来设计和制造硬盘、鼠标、键盘、打印机 乃至其他种种外设。正是这样的分工协作,带来了 PC 时代的繁荣。
CPU 是如何控制 I/O 设备的?
无论是内置在主板上的接口,还是集成在设备上的接口,除了三类寄存器之外,还有对应的控制电路。正是通过这个控制电路,CPU 才能通过向这个接口电路板传输信号,来控制实际的硬件。
- 首先是数据寄存器(Data Register)。CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是“GeekTime”,我们就要先发送一个“G”给到对应的 I/O 设备。
- 然后是命令寄存器(Command Register)。CPU 发送一个命令,告诉打印机,要进行打印工作。这个时候,打印机里面的控制电路会做两个动作。第一个,是去设置我们的状态寄存器里面的状态,把状态设置成 not-ready。第二个,就是实际操作打印机进行打印。
- 而状态寄存器(Status Register),就是告诉了我们的 CPU,现在设备已经在工作了,所以这个时候,CPU 你再发送数据或者命令过来,都是没有用的。直到前面的动作已经完成,状态寄存器重新变成了 ready 状态,我们的 CPU 才能发送下一个字符和命令。
信号和地址:发挥总线的价值
CPU 到底要往总线上发送一个什么样的命令,才能和 I/O 接口上的设备通信呢?
CPU 和 I/O 设备的通信,一样是通过 CPU 支持的机器指令来执行的。
MIPS 的机器指令的分类,你会发现,我们并没有一种专门的 和 I/O 设备通信的指令类型。那么,MIPS 的 CPU 到底是通过什么样的指令来和 I/O 设备来通信呢?
答案就是,和访问我们的主内存一样,使用“内存地址”。为了让已经足够复杂的 CPU 尽 可能简单,计算机会把 I/O 设备的各个寄存器,以及 I/O 设备内部的内存地址,都映射到主内存地址空间里来。主内存的地址空间里,会给不同的 I/O 设备预留一段一段的内存地 址。CPU 想要和这些 I/O 设备通信的时候呢,就往这些地址发送数据。这些地址信息,就 是通过上一讲的地址线来发送的,而对应的数据信息呢,自然就是通过数据线来发送的了。
I/O 设备呢,就会监控地址线,并且在 CPU 往自己地址发送数据的时候,把对应的数据线里面传输过来的数据,接入到对应的设备里面的寄存器和内存里面来。CPU 无论 是向 I/O 设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式呢, 叫作内存映射IO(Memory-Mapped I/O,简称 MMIO)。
MMIO 是不是唯一一种 CPU 和设备通信的方式呢?答案是否定的。精简指令集 MIPS 的 CPU 特别简单,所以这里只有 MMIO。而我们有 2000 多个指令的 Intel X86 架 构的计算机,自然可以设计专门的和 I/O 设备通信的指令,也就是 in 和 out 指令。
Intel CPU 虽然也支持 MMIO,不过它还可以通过特定的指令,来支持端口映射 I/O(Port-Mapped I/O,简称 PMIO)或者也可以叫独立输入输出(Isolated I/O)。
其实 PMIO 的通信方式和 MMIO 差不多,核心的区别在于,PMIO 里面访问的设备地址, 不再是在内存地址空间里面,而是一个专门的端口(Port)。这个端口并不是指一个硬件 上的插口,而是和 CPU 通信的一个抽象概念。
无论是 PMIO 还是 MMIO,CPU 都会传送一条二进制的数据,给到 I/O 设备的对应地 址。设备自己本身的接口电路,再去解码这个数据。解码之后的数据呢,就会变成设备支持 的一条指令,再去通过控制电路去操作实际的硬件设备。对于 CPU 来说,它并不需要关心 设备本身能够支持哪些操作。它要做的,只是在总线上传输一条条数据就好了。
小结
CPU 并不是发送一个特定的操作指令来操作不同的 I/O 设备。因为如果是那样的话,随着新的 I/O 设备的发明,我们就要去扩展 CPU 的指令集了。
在计算机系统里面,CPU 和 I/O 设备之间的通信,是这么来解决的。
首先,在 I/O 设备这一侧,我们把 I/O 设备拆分成,能和 CPU 通信的接口电路,以及实际 的 I/O 设备本身。接口电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓 冲区和设备内存等等。接口电路通过总线和 CPU 通信,接收来自 CPU 的指令和数据。而 接口电路中的控制电路,再解码接收到的指令,实际去操作对应的硬件设备。
而在 CPU 这一侧,对 CPU 来说,它看到的并不是一个个特定的设备,而是一个个内存地址或者端口地址。CPU 只是向这些地址传输数据或者读取数据。所需要的指令和操作内存 地址的指令其实没有什么本质差别。通过软件层面对于传输的命令数据的定义,而不是提供特殊的新的指令,来实际操作对应的 I/O 硬件。
总结
虚拟地址转换、多级页表;TLB(地址变换高速缓冲)、内存保护;总线;IO设备通信(接口电路+IO设备本身)。