我们可能有一堆内存(蓝色的代表内存),我们可以想象这些虚拟进程中的每一线程都有自己的栈、堆、数据和代码,它们都以某种方式分布在内存中,我们要做的就是以某种方式记录所有东西的位置。线程控制块是所有东西的所在,当我们从绿色切换到黄色,我们要做的第一件事是将所有的绿色线程的寄存器保存到它的线程控制块中,顺便说一下,那是内核内存的一部分。
我们今天要讲的第二个操作系统概念是地址空间:
它是一组可访问地址和与之相关的状态,这只是处理器对可用地址的视图。对于 32 位处理器,可以访问的地址空间是 2 的 32 次方大约是 40 亿,对于 64 位的处理器,2 的 64 次方是 18 万亿。但是这并不是说所有的空间里都有对应的物理 DRAM 内存可以使用,只有一部分有对应的物理 DRAM。
当你读或写一个地址空间中的地址时,也许它像普通内存一样运行,或者完全忽略写入操作;或者可能是系统导致一个I/O操作发生,这被称为内存映射 I/O;或者它可能会导致异常,如果你试图在我这里展示的栈和堆之间的某个地方读或写是可以的,但是如果没有物理内存分配给这个进程,就会出现页面错误(Page fault);或者写入内存的行为是为了与另一个程序通信。
- 程序计数器(PC,Program Counter)指向某个地址,意味着处理器能执行在那个地址的指令。
- 栈指针(SP,Stack Pointer)指向某个地址,通常是栈的底部(图里面栈向下增长,所以最后一个元素是栈底)
- 栈(stack):栈就是当你递归调用一个函数时,前一个函数的变量会被推到栈上,然后栈指针向下移动,当函数返回时,你把它们从栈中取出,栈指针向上移动。
- 堆(heap):你用 malloc 分配东西等的时候,通常就会放在堆上。堆的初始物理内存也比程序最终需要的要少,随着程序的增长,这会在堆上分配东西,你会遇到页面错误,然后分配实际的物理内存。
- 代码段(Code Segment):保存着要执行的代码。
- 静态数据(Static Data):静态变量,全局变量等等
操作系统必须保护自己不受用户程序的影响,这样做有很多原因:
- 可靠性:危及操作系统通常会导致系统崩溃
- 安全性:你想要限制恶意软件的作用范围
- 隐私:限制每个线程访问它应该访问的数据,不希望我的密码或者秘密被泄露
- 公平性:我不希望这样一个线程,例如它计算 PI 的最后一位,突然就能占用所有的cpu,以牺牲其他所有的线程为代价。
操作系统必须保护用户程序之间的安全,防止一个用户拥有的线程影响另一个用户拥有的线程。那么硬件能做些什么来帮助操作系统保护自己免受程序的侵害呢,这里有一个非常简单的想法,事实上非常简单,以至于微小的物联网设备可以用很少的晶体管做到这一点,这个概念我称之为基础和界限(B&B,base and bound):
我们要做的是我们有两个寄存器,一个base寄存器和一个bound寄存器,这两个寄存器记录黄色线程允许访问内存的哪一部分。程序运行的时候,磁盘上的文件被加载并移动到内存的这一部分。所以现在当程序开始执行的时候,它与程序计数器一起工作,比如在1010范围内,这就是代码所在的地方。硬件会做一个快速比较看看这个程序的计数器是否大于 base,以及它是否小于 bound。
这种方式实现很简单,但是访问里面的每一块内容,都要记录一个长地址。
但是对于这个进程分配的内存,在这个模型下是很难改变的。因为有多个进程在共享这个内存,所以当你想扩展内存的时候,可能需要将当前黄色的部分复制到内存中其他剩余空间更大的一块地方。
为了优化这些问题,我们一般不直接访问物理内存,而是加上地址空间翻译机制(Address Space Translation):
其中一种翻译设计是加入一个硬件加法器:
地址实际上是在动态转换的,所有的地址其实是一个偏移量,操作系统记录每个进程的内存基址(Base Address),之后加上这个偏移量就是实际的物理内存地址。
另一种方法是利用分段,在x86硬件中,我们有代码段,堆栈段等等各种段,每段有不同的基址与长度,即不同的 base 和 bound,即硬件寄存器,有 base 和 bound 硬编码在该段。代码段是有物理起点(即 base)和长度(即 bound)的,而实际运行的指令指针是段内的偏移量。
最后一种是我们实际中更常用的,我们要做的是,我们要把地址空间,也就是所有的DRAM,分成一堆大小相等的页(Page)。硬件会使用页表(Page Table)进行从虚拟内存地址到硬件 DRAM 内存地址的转换。
我们今天要讲的第三个操作系统概念是进程: