【 0. 引言 】
背景
- 上一章,我们分别实现了多道程序和分时多任务系统,它们的核心机制都是任务切换。由于多道程序和分时多任务系统的设计初衷不同,它们在任务切换的时机和策略也不同。有趣的一点是,任务切换机制对于应用是完全 透明 (Transparent) 的:应用可以不对内核实现该机制的策略做任何假定(除非要进行某些针对性优化),甚至可以完全不知道这机制的存在。
- 在大多数应用(也就是应用开发者)的视角中,它们会独占一整个 CPU 和特定(连续或不连续)的内存空间。当然,通过上一章的学习,我们知道在现代操作系统中,出于公平性的考虑,我们极少会让独占CPU这种情况发生。所以应用自认为的独占CPU只是内核想让应用看到的一种 幻象 (Illusion) ,而 CPU 计算资源被 时分复用 (TDM, Time-Division Multiplexing) 的实质被内核通过恰当的抽象隐藏了起来,对应用不可见。
- 与之相对,我们目前还没有对内存管理功能进行有效的管理,仅仅是把程序放到某处的物理内存中。在内存访问方面,所有的应用都直接通过物理地址访问物理内存,这使得应用开发者需要了解繁琐的物理地址空间布局,访问内存也很不方便。在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都 直接通过物理地址访问物理内存会带来以下问题:
- 首先, 内核提供给应用的内存访问接口不够透明,也不好用。由于应用直接访问物理内存,这需要它在构建的时候就需要规划自己需要被加载到哪个地址运行。为了避免冲突可能还需要应用的开发者们对此进行协商,这显然是一件在今天看来不可理喻且极端麻烦的事情。
- 其次, 内核并没有对应用的访存行为进行任何保护措施,每个应用都有整块物理内存的读写权力。即使应用被限制在 U 特权级下运行,它还是能够造成很多麻烦:比如它可以读写其他应用的数据来窃取信息或者破坏它的正常运行;甚至它还可以修改内核的代码段来替换掉原本的 trap_handler 来挟持内核执行恶意代码。总之,这造成系统既不安全、也不稳定。
- 再次,目前应用的内存使用空间在其运行前已经限定死了, 内核不能灵活地给应用程序提供的运行时动态可用内存空间 。比如一个应用结束后,这个应用所占的空间就被释放了,但这块空间无法动态地给其它还在运行的应用使用。
- 因此,为了防止应用胡作非为,本章将更好的管理物理内存,并提供给应用一个抽象出来的更加透明易用、也更加安全的访存接口,这就是基于分页机制的虚拟内存。站在应用程序运行的角度看,就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间(Address Space)。
- 实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题:
- 硬件中物理内存的范围是什么?
- 哪些物理内存空间需要建立页映射关系?
- 如何建立页表使能分页机制?
- 如何确保OS能够在分页机制使能前后的不同时间段中都能正常寻址和执行代码?
- 页目录表(一级)的起始地址设置在哪里?
- 二级/三级等页表的起始地址设置在哪里,需要多大空间?
- 如何设置页目录表项的内容?
- 如何设置其它页表项的内容?
- 如果要让每个任务有自己的地址空间,那每个任务是否要有自己的页表?
- 代表应用程序的任务和操作系统需要有各自的页表吗?
- 在有了页表之后,任务和操作系统之间应该如何传递数据?
- 如果能解决上述问题,我们就能更好地理解地址空间,虚拟地址等操作系统的抽象概念与操作系统的虚存具体实现之间的联系。
本章任务
- 本章展现了操作系统一系列功能:
- 通过 动态内存分配,提高了应用程序对内存的动态使用效率。
- 通过页表的 虚实内存映射机制,简化了编译器对应用的地址空间设置。
- 通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全。
【 1. C 中的动态内存分配 】
- 到目前为止,如果将我们的内核也看成一个应用,那么其中所有的变量都是被静态分配在内存中的,这样在对空闲内存的使用方面缺少灵活性。我们希望能在操作系统中提供 动态申请和释放内存 的能力,这样就可以 加强操作系统对各种以内存为基础的资源分配与管理。
- 在应用程序的视角中, 动态内存分配中的内存,其实就是操作系统管理的 “堆 (Heap)”。但现在要实现操作系统,那么就需要操作系统自身能提供动态内存分配的能力。如果要实现动态内存分配的能力,需要操作系统需要有如下功能:
- 初始时能提供一块大内存空间作为初始的“堆”。在 没有分页机制情况下,这块空间是物理内存空间,否则就是虚拟内存空间。
- 提供在堆上 分配一块内存的函数接口。这样函数调用方就能够得到一块地址连续的空闲内存块进行读写。
- 提供 释放内存的函数接口。能够回收内存,以备后续的内存分配请求。
- 提供 空闲空间管理的连续内存分配算法。能够有效地管理空闲快,这样就能够动态地维护一系列空闲和已分配的内存块。
- (可选)提供 建立在堆上的数据结构和操作。有了上述基本的内存分配与释放函数接口,就可以实现类似动态数组,动态字典等空间灵活可变的堆数据结构,提高编程的灵活性。
1.1 C语言的内存分配
- 在使用C++语言的过程中,大家其实对new/delete的使用方法已经烂熟于心了。在C中,对动态内存的申请是采用如下的函数实现的:
void* malloc (size_t size);
void free (void* ptr);
- 其中,malloc函数 的作用是 从堆中分配一块大小为 size 字节的空间,并返回一个指向它的指针。而后续不用的时候,将这个 指针传给 free 即可在堆中回收这块空间。我们通过返回的指针变量来间接访问堆上的空间,而无法直接进行 访问。
- 事实上,我们在程序中能够 直接 看到的变量都是被静态分配在栈或者全局数据段上的,它们大小在编译期已知,比如这里 一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的 事情。
1.2 kalloc 中的动态内存分配
- 同一个页的地址而言它对应的物理内存是连续的。但是, 连续的虚拟地址空间不一定对应着连续的物理地址空间,因此我们需要一个数据结构来存储哪些物理内存是可用的。对于这种给不连续的情况,我们采用链表的数据结构,将空闲的每个PAGE大小的物理内存空间作为listnode来进行内存的管理。这些新增的代码在kalloc.c之中。
- 我们采用链表结构记录空闲的物理地址。因此当应用程序申请一段动态内存的时候,只需要把链表头所指向地址拿出即可。
// os/kalloc.c
struct linklist {
struct linklist *next;
};
struct {
struct linklist *freelist;
} kmem;
- 注意,我们的管理仅仅在页这个粒度进行,所以所有的地址必须是 PAGE_SIZE 对齐的。
// os/kalloc.c: 页面分配
void *
kalloc(void)
{
struct linklist *l;
l = kmem.freelist;
kmem.freelist = l->next;
return (void*)l;
}
// os/kalloc.c: 页面释放
void *
kfree(void *pa)
{
struct linklist *l;
l = (struct linklist*)pa;
l->next = kmem.freelist;
kmem.freelist = l;
}
- 那么我们的内核有那些空闲内存需要管理呢?
事实上,qemu 已经规定了内核需要管理的内存范围,可以参考这里,具体来说,需要软件管理的内存为 [0x80000000, 0x88000000),其中,rustsbi 使用了 [0x80000000, 0x80200000) 的范围,其余都是内核使用。来看看 kmem 的初始化:
我们在main函数中会执行kinit,它会初始化从ekernel到PHYSTOP的所有物理地址作为空闲的物理地址。freerange中调用的kfree函数以页为单位向对应内存中填入垃圾数据(全1),并把初始化好的一个页作为新的空闲listnode插入到链表首部。
// os/kalloc.c
// ekernel 为链接脚本定义的内核代码结束地址,PHYSTOP = 0x88000000
void
kinit()
{
freerange(ekernel, (void*)PHYSTOP);
}
// kfree [pa_start, pa_end)
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
- 注意,C语言之中要求进行内存回收,也就是malloc以及free要成对出现。但是我们的OS中不强制要求这一点,也就是如果测例本身未在申请动态内存后显式地调用free来释放内存,OS无需帮助它释放内存。
【 2. 地址空间 】
- 直到现在,我们的操作系统给应用看到的是一个非常原始的物理内存空间,可以简单地理解为一个可以随便访问的大数组。为了限制应用访问内存空间的范围并给操作系统提供内存管理的灵活性,计算机硬件引入了各种 内存保护/映射硬件机制,如: RISC-V的基址-边界翻译和保护机制、x86的分段机制、RISC-V/x86/ARM都有的 分页机制,它们的共同之处在于 CPU访问的数据和指令内存地址是虚地址,需要进行转换形成合法的物理地址或产生非法的异常 。为了用好这种硬件机制,操作系统需要升级自己的能力。
- 操作系统为了更好地管理这两种形式的内存,并给应用程序提供统一的访问接口,即应用程序不需要了解虚拟内存和物理内存的区别的,操作系统提出了 地址空间 Address Space 抽象,并在内核中建立虚实地址空间的映射机制,给应用程序提供一个虚拟的内存环境。
- 本节将结合操作系统的发展历程回顾来介绍 地址空间 Address Space 抽象的实现策略 是如何变化的。
2.1 虚拟地址和地址空间
2.1.1 地址虚拟化出现之前
- 我们之前介绍过,在最早整套硬件资源只用来执行单个裸机应用的时候,并不存在真正意义上的操作系统,而只能算是一种应用 函数库。那个时候,物理内存的一部分用来保存函数库的代码和数据,余下的部分都交给应用来使用。从功能上可以将应用 占据的内存分成几个段:代码段、全局数据段、堆和栈等。当然,由于就只有这一个应用,它想如何调整布局都是它自己的 事情。从内存使用的角度来看,批处理系统和裸机应用很相似:批处理系统的每个应用也都是独占内核之外的全部内存空间, 只不过当一个应用出错或退出之后,它所占据的内存区域会被清空,而序列中的下一个应用将自己的代码和数据放置进来。 这个时期,内核提供给应用的访存视角是一致的,因为它们确实会在运行过程中始终独占一块固定的内存区域,每个应用开发者 都基于这一认知来规划程序的内存布局。
- 后来,为了降低等待 I/O 带来的无意义的 CPU 资源损耗,多道程序出现了。而为了提升用户的交互式体验,提高生产力,分时 多任务系统诞生了。它们的特点在于:应用开始多出了一种“暂停”状态,这可能来源于它主动 yield 交出 CPU 资源,或是在 执行了足够长时间之后被内核强制性换出。当应用处于暂停状态的时候,它驻留在内存中的代码、数据该何去何从呢? 曾经有一种 做法是每个应用仍然和在批处理系统中一样独占内核之外的整块内存,当暂停的时候,内核负责 将它的代码、数据保存在磁盘或 硬盘中,然后把即将换入的应用保存在磁盘上的代码、数据恢复到内存,这些都做完之后才能开始执行新的应用。不过,由于这种做法需要大量读写内存和外部存储设备,而它们的 速度都比 CPU 慢上几个数量级,这导致任务切换的开销过大, 甚至完全不能接受。
- 既然如此,就只能像我们在第三章中的做法一样,限制每个应用的最大可用内存空间小于物理内存的容量,这样 就可以同时把多个应用的数据驻留在内存中。在任务切换的时候只需完成任务上下文保存与恢复即可,这只是在内存的帮助下保存、 恢复少量通用寄存器,甚至无需访问外存,这从很大程度上降低了任务切换的开销。在本章的引言中介绍过第三章中操作系统的做法对应用程序开发带了一定的困难。从应用开发的角度看,需要应用程序决定自己会被加载到哪个物理地址运行,需要直接访问真实的 物理内存。这就要求应用开发者对于硬件的特性和使用方法有更多了解,产生额外的学习成本,也会为应用的开发和调试带来不便。从 内核的角度来看,将直接访问物理内存的权力下放到应用会使得它 难以对应用程序的访存行为进行有效管理,已有的特权级机制亦无法 阻止很多来自应用程序的恶意行为。
2.1.2 加一层抽象加强内存管理
-
为了解决这种困境,抽象仍然是最重要的指导思想。在这里,抽象意味着内核要负责将物理内存管理起来,并为上面的应用提供 一层抽象接口,从之前的失败经验学习,这层抽象需要达成下面的设计目标:
- 透明 :应用开发者可以不必了解底层真实物理内存的硬件细节,且在非必要时也不必关心内核的实现策略, 最小化他们的心智负担;
- 高效 :这层抽象至少在大多数情况下不应带来过大的额外开销;
- 安全 :这层抽象应该有效检测并阻止应用读写其他应用或内核的代码、数据等一系列恶意行为。
-
最终,到目前为止仍被操作系统内核广泛使用的抽象被称为 地址空间 (Address Space) 。某种程度上讲,可以将它看成 一块巨大但并不一定真实存在的内存。在每个应用程序的视角里,操作系统分配给应用程序一个范围有限(但其实很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等),因此应用程序可以在划分给它的地址空间中随意规划内存布局,它的 各个段也就可以分别放置在地址空间中它希望的位置(当然是操作系统允许应用访问的地址)。 应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址作为索引来读写物理内存上的数据一样,这种地址被称为 虚拟地址 (Virtual Address) 。当然,操作系统要达到 地址空间 抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的 MMU 和 TLB 等硬件机制。
-
从此, 应用能够直接看到并访问的内存就只有操作系统提供的地址空间,且它的任何一次访存使用的地址都是虚拟地址,无论取指令来执行还是读写 栈、堆或是全局数据段都是如此。事实上,特权级机制被拓展,使得应用不再具有通过物理地址直接访问物理内存的能力。应用所处的执行环境在安全方面被进一步强化,形成了用户态特权级和地址空间的二维安全措施。
-
由于每个应用独占一个地址空间,里面只含有自己的各个段,于是它可以随意规划 各个段的分布而无需考虑和其他应用冲突;同时,它完全无法窃取或者破坏其他应用的数据,毕竟那些段在其他应用的地址空间 内,鉴于 应用只能通过虚拟地址读写它自己的地址空间 ,这是它没有能力去访问的。这是 地址空间 抽象对应用程序执行的安全性和稳定性的一种保障。
-
我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要 扩展硬件功能来加速地址转换过程(回忆 计算机组成原理 课上讲的 MMU 和 TLB )。
2.1.3 增加硬件加速虚实地址转换
- 开始回顾一下 计算机组成原理 课。如上图所示,当应用取指或者执行 一条访存指令的时候,它都是在以虚拟地址为索引读写自己的地址空间。此时, CPU 中的 内存管理单元 (MMU, Memory Management Unit) 自动将这个虚拟地址进行 地址转换 (Address Translation) 变为一个物理地址, 也就是物理内存上这个应用的数据真实被存放的位置。也就是说,在 MMU 的帮助下,应用对自己地址空间的读写才能被实际转化为 对于物理内存的访问。
- 事实上, 每个应用的地址空间都可以看成一个从虚拟地址到物理地址的映射。可以想象对于不同的应用来说,该映射可能是不同的, 即 MMU 可能会将来自不同两个应用地址空间的相同虚拟地址翻译成不同的物理地址。要做到这一点,就需要 硬件提供一些寄存器 ,软件可以对它进行设置来控制 MMU 按照哪个应用的地址空间进行地址转换。于是,将应用的数据放到物理内存并进行管理,而 在任务切换的时候需要将控制 MMU 选用哪个应用的地址空间进行映射的那些寄存器也一并进行切换,则是作为软件部分的内核需 要完成的工作。
- 回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的 幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此, 应用也只能看到它独占整个地址空间的幻象,而 藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置。
- 地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,操作系统内核如何规划应用数据放在物理内存的位置, 而 MMU 又如何进行地址转换也都是不同的。下面我们简要介绍几种曾经被使用的策略,并探讨它们的优劣。
2.2 分段内存管理
2.2.1 等量分配
- 曾经的一种做法如上图所示:每个应用的地址空间大小限制为一个固定的常数 bound ,也即 每个应用的可用虚拟地址区间 均为 [0,bound) 。随后,以这个大小为单位,将物理内存除了内核预留空间之外的部分划分为若干 个大小相同的 插槽 (Slot) ,每个应用的所有数据都被内核放置在其中一个插槽中,对应于物理内存上的一段连续物理地址 区间,假设其起始物理地址为 base ,则由于二者大小相同,这个区间实际为 [base,base+bound) 。因此地址转换很容易完成,只需检查一下虚拟地址不超过地址空间 的大小限制(此时需要借助特权级机制通过异常来进行处理),然后做一个线性映射,将虚拟地址加上 base 就得到了数据实际所在的物理地址。
- 可以看出,这种实现极其简单:MMU 只需要 base,bound 两个寄存器,在地址转换进行比较或加法运算即可;而内核只需要在任务切换的同时切换 base 寄存器(由于 bound是一个常数),内存 管理方面它只需考虑一组插槽的占用状态,可以用一个 位图 (Bitmap) 来表示,随着应用的新增和退出对应置位或清空。
- 然而,它的问题在于:浪费的内存资源过多。注意到应用地址空间预留了一部分,它是用来让栈得以向低地址增长,同时允许堆 往高地址增长(支持应用运行时进行动态内存分配)。每个应用的情况都不同,内核只能按照在它能力范围之内的消耗内存最多 的应用的情况来统一指定地址空间的大小,而其他内存需求较低的应用根本无法充分利用内核给他们分配的这部分空间,但这部分空间又是一个完整的插槽的一部分,也不能再交给其他应用使用 ,这种在地址空间内部无法被充分利用的空间被称为 内碎片 (Internal Fragment),它限制了系统同时共存的应用数目。如果应用的需求足够多样化,那么内核无论如何设置 应用地址空间的大小限制也不能得到满意的结果。这就是固定参数的弊端:虽然实现简单,但不够灵活。
2.2.2 按需分配
- 为了解决这个问题,一种分段管理的策略开始被使用,如下图所示:
- 注意到 内核开始以更细的粒度,也就是应用地址空间中的一个逻辑段作为单位来安排应用的数据在物理内存中的布局。对于每个段来说,从它在某个应用地址空间中的虚拟地址到它被实际存放在内存中的物理地址中间都要经过一个不同的线性映射,于是 MMU 需要用一对不同的 base/bound 进行区分。这里由于每个段的大小都是不同的,我们也不再能仅仅 使用一个 bound 进行简化。当任务切换的时候,这些对寄存器也需要被切换。
- 简单起见,我们这里忽略一些不必要的细节。比如应用在以虚拟地址为索引访问地址空间的时候,它如何知道该地址属于哪个段,从而硬件可以使用正确的一对 base/bound 寄存器进行合法性检查和完成实际的地址转换。这里只关注 分段管理是否解决了内碎片带来的内存浪费问题。注意到每个段都只会在内存中占据一块与它实际所用到的大小相等的空间。堆 的情况可能比较特殊,它的大小可能会在运行时增长,但是那需要应用通过系统调用向内核请求。也就是说这是一种 按需分配,而 不再是内核在开始时就给每个应用分配一大块很可能用不完的内存。由此,不再有内碎片了。
- 尽管内碎片被消除了,但内存浪费问题并没有完全解决。这是因为每个段的大小都是不同的(它们可能来自不同的应用,功能 也不同),内核就需要使用更加通用、也更加复杂的连续内存分配算法来进行内存管理,而不能像之前的插槽那样以一个比特 为单位。顾名思义,连续内存分配算法就是每次需要分配一块连续内存来存放一个段的数据。 随着一段时间的分配和回收,物理内存还剩下一些相互不连续的较小的可用连续块,其中有一些只是两个已分配内存块之间的很小的间隙,它们自己可能由于空间较小,已经无法被 用于分配 ,被称为 外碎片 (External Fragment) 。
举个例子,在内存上,分配三个操作系统分配的用于装载进程的内存区域A、B和C。假设,三个内存区域都是相连的,故而三个内存区域不会产生外部碎片。现在假设B对应的进程执行完毕了操作系统随即收回了B,这个时候A和C中间就有一块空闲区域了。
- 如果这时再想分配一个比较大的块, 就需要将这些不连续的外碎片“拼起来”,形成一个大的连续块。然而这是一件开销很大的事情,涉及到极大的内存读写开销。具体而言,这需要移动和调整一些已分配内存块在物理内存上的位置,才能让那些小的外碎片能够合在一起,形成一个大的空闲块。如果连续内存分配算法 选取得当,可以尽可能减少这种操作。课上所讲到的那些算法,包括 first-fit/worst-fit/best-fit 或是 buddy system,其具体表现取决于实际的应用需求,各有优劣。
- 那么,分段内存管理带来的外碎片和连续内存分配算法比较复杂的 问题可否被解决呢?
2.3 分页内存管理
- 问题背景:内碎片和外碎片
仔细分析一下可以发现,段的大小不一是外碎片产生的根本原因。之前我们把应用的整个地址空间连续放置在物理内存中,在 每个应用的地址空间大小均相同的情况下,只需利用类似位图的数据结构维护一组插槽的占用状态,从逻辑上 分配和回收都是以一个固定的比特为单位,自然也就不会存在外碎片了。但是这样粒度过大,不够灵活,又在地址空间内部产生了内碎片。 - 解决方法:分页内存管理
若要结合二者的优点的话,就需要 内核始终以一个同样大小的单位来在物理内存上放置应用地址空间中的数据,这样内核就可以 使用简单的插槽式内存管理,使得内存分配算法比较简单且不会产生外碎片;同时,这个 单位的大小要足够小,从而其内部没有 被用到的内碎片的大小也足够小,尽可能提高内存利用率,这便是我们将要介绍的 分页内存管理。
- 如上图所示, 内核以页为单位进行物理内存管理,每个应用的地址空间可以被分成若干个(虚拟)页面 (Page) ,可用的物理内存也同样可以被分成若干个(物理)页帧 (Frame) 。
虚拟页面和物理页帧的大小相同,每个虚拟页面中的数据实际上都存储在某个物理页帧上。