虚拟地址空间

你应在本节中完成以下任务
  1. 反复仔细阅读本节内容,理解框架代码;
  2. 修改访存函数,实现页级地址转换,必要时实现跨页;
  3. 加载用户程序,理解和实现虚拟地址空间相关函数;
  4. 在分页机制上运行各个应用程序;
  5. 实现堆区管理;
  6. 回答所有思考题(部分思考题需要在完全阅读完本节内容后回答);

虚拟地址空间

通过 Nanos-lite 的支持, 我们已经在 NEMU 中成功把仙剑奇侠传跑起来了! 这说明我们亲自构建的 NEMU 这个看似简单的机器, 同样能支撑真实程序的运行, 丝毫不逊色于真实的机器! 不过, 我们目前还是只能在这个机器上同时运行一个程序, 这是因为 Nanos-lite 目前还只是一个单任务的操作系统. 那为了同时运行多个程序, 我们的 NEMU 和 Nanos-lite 还缺少些什么呢?

我们知道, 现在的计算机可以"同时"运行多个进程. 这里的"同时"其实只是一种假象, 并不是指在物理时间上的重叠, 而是操作系统很快地在不同的进程之间来回切换. 切换的频率大约是 10ms 一次, 一般的用户是感觉不到的. 而让多个进程"同时"运行的一个基本条件, 就是不同的进程要拥有独立的存储空间, 它们之间不能相互干扰.

一个很自然的想法, 就是让操作系统的 loader 直接把不同的程序加载到不同的内存位置就可以了. 我们在 PA3.1 中提到操作系统有管理系统资源的义务, 在多任务操作系统中, 内存作为一种资源自然也是要被管理起来: 操作系统需要记录内存的分配情况, 需要运行一个新程序的时候, 就给它分配一片空闲的内存位置, 把它加载到这一内存位置上即可.

这个方法听上去很可靠, 但对程序来说就不是这么简单了. 回想我们编译 Navy-apps 中的程序时, 我们都把它们链接到 0x4000000 的内存位置. 这意味着, 如果我们正在运行仙剑奇侠传, 同时也想运行 hello 程序, 仙剑奇侠传的内容将会被 hello 程序所覆盖! 最后的结果是, 仙剑奇侠传无法正确运行, 从而也无法实现"多个程序同时运行"的美好愿望.

或者, 我们可以尝试把不同的程序链接到不同的内存位置. 然而新问题又来了, 我们在编译链接的时候, 怎么能保证程序将来运行的时候它所用到的内存位置是空闲的呢? 况且, 我们还希望一个程序能同时运行多个进程实例, 例如在浏览器中同时打开多个页面浏览不同的网页. 这是多么合理的需求啊! 然而这种方式却没法实现.

所以如果要解决这个问题, 我们的方法就需要满足一个条件: 在程序被加载之前, 我们不能对程序被加载到的内存位置有任何提前的假设. 很自然地, 为了实现多任务, 我们必须在系统栈的某些层次满足这个条件.

一种方式是从程序本身的性质入手. 事实上, 编译器可以编译出 PIC(position-independent code, 位置无关代码). 所谓 PIC, 就是程序本身的代码不对将来的运行位置进行任何假设, 这样的程序可以被加载到任意内存位置也能正确运行. PIC 程序不仅具有这一灵活的特性, 还能在一定程度上对恶意的攻击程序造成了干扰: 恶意程序也无法提前假设 PIC 程序运行的地址. 也正是因为这一安全相关的特性, 最近的不少 GNU/Linux 的发行版上配置的 gcc 都默认生成 PIC 程序. 多神奇的功能啊! 然而, 天下并没有免费的午餐, PIC 程序之所以能做到位置无关, 其实是要依赖于程序中一个叫 GOT(global offset table, 全局偏移量表)的数据结构. 要正确运行 PIC 程序, 操作系统中的动态加载器需要在加载程序的时候往 GOT 中填写正确的内容. 但是, 先不说GOT具体如何填写, 目前 Nanos-lite 中的 loader 是个 raw program loader, 它无法在可执行文件中找到 GOT 的位置. 因此, 在 Nanos-lite 上运行 PIC 程序目前并不是一个可行的方案.

我们要寻求另一种解决方案了. 既然我们无法运行 PIC 程序, 我们还是只能让程序链接到一个固定的内存位置. 问题貌似又回到原点了. 我们来仔细琢磨一下我们的需求: 我们需要在让程序认为自己在某个固定的内存位置的同时, 把程序加载到不同的内存位置去执行. 这个看似自相矛盾的需求, 其实里面正好蕴藏着那深刻的思想. 说是自相矛盾, 是因为思维定势会让我们觉得, "固定的内存位置"和"不同的内存位置"必定无法同时满足; 说是蕴藏着深刻的思想, 我们不妨换一个角度来想想, 如果这两个所谓的"内存位置"并不是同一个概念呢?

为了让这个问题的肯定回答成为可能, 虚拟内存的概念就诞生了. 所谓虚拟内存, 就是在真正的内存(也叫物理内存)之上的一层专门给程序使用的抽象. 有了虚拟内存之后, 程序只需要认为自己运行在虚拟地址上就可以了, 真正运行的时候, 才把虚拟地址映射到物理地址. 这样,我们只要把程序链接到一个固定的虚拟地址, 加载程序的时候把它们加载到不同的物理地址, 并维护好虚拟地址到物理地址的映射关系, 就可以实现我们那个看似不可能的需求了!

绝大部分多任务操作系统就是这样做的. 不过在讨论具体的虚拟内存机制之前, 我们先来探讨最关键的一个问题: 程序运行的时候, 谁来把虚拟地址映射成物理地址呢? 我们在 PA1 中已经了解到指令的生命周期:

while (1) {
  从EIP指示的存储器位置取出指令;
  执行指令;
  更新EIP;
}

如果引入了虚拟内存机制, EIP就是一个虚拟地址了, 我们需要在访问存储器之前完成虚拟地址到物理地址的映射. 尽管操作系统管理着计算机中的所有资源, 在计算机看来它也只是一个程序而已. 作为一个在计算机上执行的程序而言, 操作系统不可能有能力干涉指令执行的具体过程. 所以让操作系统来把虚拟地址映射成物理地址, 是不可能实现的. 因此, 在硬件中进行这一映射是唯一的选择了: 我们在处理器和存储器之间添加一个新的硬件模块 MMU(Memory Management Unit, 内存管理单元), 它是虚拟内存机制的核心, 肩负起这一机制最重要的地址映射功能. 需要说明的是, 我们刚才提到的 "MMU 位于处理器和存储器之间"只是概念上的说法. 事实上, 虚拟内存机制在现代计算机中是如此重要, 以至于 MMU 在物理上都实现在处理器芯片内部了.

但是, 只有操作系统才知道具体要把虚拟地址映射到哪些物理地址上. 所以, 虚拟内存机制是一个软硬协同才能生效的机制: 操作系统负责进行物理内存的管理, 加载程序的时候决定要把程序的虚拟地址映射到哪些物理地址; 等到程序真正运行之前, 还需要配置 MMU, 把之前决定好的映射落实到硬件上, 程序运行的时候, MMU就会进行地址转换, 把程序的虚拟地址映射到操作系统希望的物理地址.

分段

关于 MMU 具体如何进行地址映射, 目前主要有两种主流的方式. 最简单的方法就是, 物理地址=虚拟地址+偏移量. 这种最朴素的方式就是段式虚拟内存管理机制, 简称分段机制. 直觉上来理解, 就是把物理内存划分成若干个段, 不同的程序就放到不同的段中运行, 程序不需要关心自己具体在哪一个段里面, 操作系统只要让不同的程序使用不同的偏移量, 程序之间就不会相互干扰了.

分段机制在硬件上的实现可以非常简单, 只需要在 MMU 中实现一个段基址寄存器就可以了. 操作系统在运行不同程序的时候, 就在段基址寄存器中设置不同的值, MMU 会把程序使用的虚拟地址加上段基址, 来生成真正用于访问内存的物理地址, 这样就实现了"让不同的程序使用不同的段"的目的. 作为教学操作系统的 Minix 就是这样工作的.

实际上, 处理器中的分段机制有可能复杂得多. 例如 i386 为了兼容它的前身 8086, 引入了段描述符, 段选择符, 全局描述符表(GDT), 全局描述符表寄存器(GDTR)等概念, 段描述符中除了段基址之外, 还描述了段的长度, 类型, 粒度, 访问权限等等的属性, 为了弥补段描述符的性能问题, 又加入了描述符 cache 等概念... 我们可以目睹一下 i386 分段机制的风采:

           15              0    31                                   0
  LOGICAL +----------------+   +-------------------------------------+
  ADDRESS |    SELECTOR    |   |                OFFSET               |
          +---+---------+--+   +-------------------+-----------------+
       +------+         V                          |
       | DESCRIPTOR TABLE                          |
       |  +------------+                           |
       |  |            |                           |
       |  |            |                           |
       |  |            |                           |
       |  |            |                           |
       |  |------------|                           |
       |  |  SEGMENT   | BASE          +---+       |
       +->| DESCRIPTOR |-------------->| + |<------+
          |------------| ADDRESS       +-+-+
          |            |                 |
          +------------+                 |
                                         V
              LINEAR  +------------+-----------+--------------+
              ADDRESS |    DIR     |   PAGE    |    OFFSET    |
                      +------------+-----------+--------------+

咋看之下真是眼花缭乱, 让人一头雾水.

在 NEMU 中, 我们需要了解什么呢? 什么都不需要. 现在的绝大部分操作系统都不再使用分段机制, 就连 i386 手册中也提到可以想办法"绕过"它来提高性能: 将段基地址设成 0, 长度设成 4GB, 这样看来就像没有段的概念一样, 这就是 i386 手册中提到的"扁平模式". 当然, 这里的"绕过"并不是简单地将分段机制关掉(事实上也不可能关掉), 我们在 PA3.1 中提到的 i386 保护机制中关于特权级的概念, 其实就是 i386 分段机制提供的, 抛弃它是十分不明智的. 不过我们在 NEMU 中也没打算实现保护机制, 因此 i386 分段机制的各种概念, 我们也不会加入到 NEMU 中来.

超越容量的界限

现代操作系统不使用分段还是有一定的道理的. 有研究表明, Google 数据中心中的 1000 台服务器在 7 分钟内就运行了上千个不同的程序, 其中有的是巨大无比的家伙(Google 内部开发程序的时候为了避免不同计算机上的动态库不兼容的问题, 用到的所有库都以静态链接的方式成为程序的一部分, 光是程序的代码段就有几百 MB 甚至上 GB 的大小, 感兴趣的同学可以阅读这篇文章), 有的只是一些很小的测试程序. 让这些特征各异的程序都占用连续的存储空间并不见得有什么好处: 那些巨大无比的家伙们在一次运行当中只会触碰到很小部分的代码, 其实没有必要分配那么多内存把它们全部加载进来; 另一方面, 小程序运行结束之后, 它占用的存储空间就算被释放了, 也很容易成为"碎片空洞" - 只有比它更小的程序才能把碎片空洞用起来. 分段机制的简单朴素, 在现实情况中也许要付出巨大的代价.

事实上, 我们需要一种按需分配的虚存管理机制. 之所以分段机制不好实现按需分配, 就是因为段的粒度太大了, 为了实现这一目标, 我们需要反其道而行之: 把连续的存储空间分割成小片段, 以这些小片段为单位进行组织, 分配和管理. 这正是分页机制的核心思想.

在分页机制中, 这些小片段称为页面, 在虚拟地址空间和物理地址空间中也分别称为虚拟页和物理页. 分页机制做的事情, 就是把一个个的虚拟页分别映射到相应的物理页上. 显然, 这一映射关系并不像分段机制中只需要一个段基址寄存器就可以描述的那么简单. 分页机制引入了一个叫"页表"的结构, 页表中的每一个表项记录了一个虚拟页到物理页的映射关系, 来把不必连续的页面重新组织成连续的虚拟地址空间. 因此, 为了让分页机制支撑多任务操作系统的运行, 操作系统首先需要以物理页为单位对内存进行管理. 每当加载程序的时候, 就给程序分配相应的物理页(注意这些物理页之间不必连续), 并为程序准备一个新的页表, 在页表中填写程序用到的虚拟页到分配到的物理页的映射关系. 等到程序运行的时候, 操作系统就把之前为这个程序填写好的页表设置到 MMU 中, MMU 就会根据页表的内容进行地址转换, 把程序的虚拟地址空间映射到操作系统所希望的物理地址空间上.

os-paging

i386 是 x86 史上首次引进分页机制的处理器, 它把物理内存划分成以 4KB 为单位的页面, 同时也采用了二级页表的结构. 为了方便叙述, i386 给第一级页表取了个新名字叫"页目录". 虽然听上去很厉害, 但其实原理都是一样的. 每一张页目录和页表都有 1024 个表项, 每个表项的大小都是 4 字节, 除了包含页表(或者物理页)的基地址, 还包含一些标志位信息. 因此, 一张页目录或页表的大小是 4KB(俗称 4K 页), 要放在寄存器中是不可能的, 因此它们要放在内存中. 为了找到页目录, i386 提供了一个 CR3(control register 3)寄存器, 专门用于存放页目录的基地址. 这样, 页级地址转换就从 CR3 开始一步一步地进行, 最终将虚拟地址转换成真正的物理地址, 这个过程称为一次 page walk.

                                                              PAGE FRAME
              +-----------+-----------+----------+         +---------------+
              |    DIR    |   PAGE    |  OFFSET  |         |               |
              +-----+-----+-----+-----+-----+----+         |               |
                    |           |           |              |               |
      +-------------+           |           +------------->|    PHYSICAL   |
      |                         |                          |    ADDRESS    |
      |   PAGE DIRECTORY        |      PAGE TABLE          |               |
      |  +---------------+      |   +---------------+      |               |
      |  |               |      |   |               |      +---------------+
      |  |               |      |   |---------------|              ^
      |  |               |      +-->| PG TBL ENTRY  |--------------+
      |  |---------------|          |---------------|
      +->|   DIR ENTRY   |--+       |               |
         |---------------|  |       |               |
         |               |  |       |               |
         +---------------+  |       +---------------+
                 ^          |               ^
+-------+        |          +---------------+
|  CR3  |--------+
+-------+

我们不打算给出分页过程的详细解释, 请你结合 i386 手册的内容和课堂上的知识, 尝试理解 i386 分页机制, 这也是作为分页机制的一个练习. i386 手册中包含你想知道的所有信息, 包括这里没有提到的表项结构, 地址如何划分等.

一些问题
  • i386 不是一个 32 位的处理器吗, 为什么表项中的基地址信息只有 20 位, 而不是 32 位?
  • 手册上提到表项(包括 CR3)中的基地址都是物理地址, 物理地址是必须的吗? 能否使用虚拟地址?
  • 为什么不采用一级页表? 或者说采用一级页表会有什么缺点?

页级转换的过程并不总是成功的, 因为 i386 也提供了页级保护机制, 实现保护功能就要靠表项中的标志位了. 我们对一些标志位作简单的解释:

  • present 位表示物理页是否可用, 不可用的时候又分两种情况:
    1. 物理页面由于交换技术被交换到磁盘中了, 这就是你在课堂上最熟悉的 Page fault 的情况之一了, 这时候可以通知操作系统内核将目标页面换回来, 这样就能继续执行了
    2. 进程试图访问一个未映射的线性地址, 并没有实际的物理页与之相对应, 因此这就是一个非法操作咯
  • R/W 位表示物理页是否可写, 如果对一个只读页面进行写操作, 就会被判定为非法操作
  • U/S 位表示访问物理页所需要的权限, 如果一个 ring 3 的进程尝试访问一个 ring 0 的页面, 当然也会被判定为非法操作

空指针真的是'空'的吗?

程序设计课上老师告诉你, 当一个指针变量的值等于 NULL 时, 代表空, 不指向任何东西. 仔细想想, 真的是这样吗? 当程序对空指针解引用的时候, 计算机内部具体都做了些什么? 你对空指针的本质有什么新的认识?

和分段机制相比, 分页机制更灵活, 甚至可以使用超越物理地址上限的虚拟地址. 现在我们从数学的角度来理解这两点. 撇去存储保护机制不谈, 我们可以把这分段和分页的过程分别抽象成两个数学函数:

  y = seg(x) = seg.base + x
  y = page(x)

可以看到, seg() 函数只不过是做加法. 如果仅仅使用分段机制, 我们还要求段级地址转换的结果不能超过物理地址上限:

   y = seg(x) = seg.base + x < PMEM_MAX
=> x < PMEM_MAX - seg.base
=> x <= PMEM_MAX

我们可以得出这样的结论: 仅仅使用分段机制, 虚拟地址是无法超过物理地址上限的. 而分页机制就不一样了, 我们无法给出 page() 具体的解析式, 是因为填写页目录和页表实际上就是在用枚举自变量的方式定义 page() 函数, 这就是分页机制比分段机制灵活的根本原因. 虽然"页级地址转换结果不能超过物理地址上限"的约束仍然存在, 但我们只要保证每一个函数值都不超过物理地址上限即可, 并没有对自变量的取值作明显的限制, 当然自变量本身也就可以比函数值还大. 这就已经把分页的"灵活"和"允许使用超过物理地址上限"这两点特性都呈现出来了.

i386 采用段页式存储管理机制. 不过仔细想想, 这只不过是把分段和分页结合起来罢了, 用数学函数来理解, 也只不过是个复合函数:

paddr = page(seg(swaddr))

而"虚拟地址空间"和"物理地址空间"这两个在操作系统中无比重要的概念, 也只不过是这个复合函数的定义域和值域而已.

最后, 支持分页机制的处理器能识别什么是页表吗? 我们以一个页面大小为 1KB 的一级页表的地址转换例子来说明这个问题:

pa = (pg_table[va >> 10] & ~0x3ff) | (va & 0x3ff);

可以看到, 处理器并没有表的概念: 地址转换的过程只不过是一些访存和位操作而已. 这再次向我们展示了计算机的本质: 一堆美妙的, 蕴含着深刻数学道理和工程原理的... 门电路! 然而这些小小的门电路操作却成为了今天多任务操作系统的基础, 支撑着千千万万程序的运行, 真不愧是人类的文明.

加入PTE

在 AM 的模型中, 由 PTE 模块来负责提供存储保护的能力. 为了在 Nanos-lite 中实现一个多任务操作系统, 我们需要在 NEMU 和 AM 中添加 PTE 的支持. 我们的第一个目标是首先让仙剑奇侠传运行在分页机制上, 然后再考虑多任务的支持.

准备内核页表

由于页表位于内存中, 但计算机启动的时候, 内存中并没有有效的数据, 因此我们不可能让计算机启动的时候就开启分页机制. 操作系统为了启动分页机制, 首先需要准备一些内核页表. 框架代码已经为我们实现好这一功能了(见 nexus-am/am/arch/x86-nemu/src/pte.c_pte_init() 函数). 只需要在 nanos-lite/src/main.c 中定义宏 HAS_PTE, Nanos-lite 在初始化的时候首先就会调用 init_mm() 函数(在 nanos-lite/src/mm.c 中定义)来初始化 MM. 这里的 MM 是指存储管理器(Memory Manager)模块, 它专门负责分页相关的存储管理.

目前初始化 MM 的工作有两项, 第一项工作是将 TRM 提供的堆区起始地址作为空闲物理页的首地址, 将来会通过 new_page() 函数来分配空闲的物理页. 第二项工作是调用 AM 的 _pte_init() 函数, 填写内核的页目录和页表, 然后设置 CR3 寄存器, 最后通过设置 CR0 寄存器来开启分页机制. 这样以后, Nanos-lite 就运行在分页机制之上了. 调用 _pte_init() 函数的时候还需要提供物理页的分配和回收两个回调函数, 用于在 AM 中获取/释放物理页. 为了简化实现, MM 中采用顺序的方式对物理页进行分配, 而且分配后无需回收.

为了在 NEMU 中实现分页机制, 你需要添加 CR3 寄存器和 CR0 寄存器, 以及相应的操作它们的指令.其中 CR0CR3 的数据结构框架已帮我们定义好,在 mmu.h 中,你只需要添加就可以. 添加之后,对于 CR0 寄存器, 我们只需要实现 PG 位即可. 如果发现 CR0 的 PG 位为 1, 则开启分页机制, 从此所有虚拟地址的访问(包括 vaddr_read(), vaddr_write())都需要经过分页地址转换. 为了让 differential testing 机制正确工作, 在 restart() 函数中我们需要对 CR0 寄存器初始化为 0x60000011, 但我们不必关心其含义.

然后你需要vaddr_read()vaddr_write() 函数作少量修改. 以 vaddr_read() 为例, 修改后如下:

uint32_t vaddr_read(vaddr_t addr, int len) {
    if(cpu.cr0.paging) {
        if (data cross the page boundary) {
            /* this is a special case, you can handle it later. */
            assert(0);
        }
        else {
            paddr_t paddr = page_translate(addr);
            return paddr_read(paddr, len);
        }
    }
    else
        return paddr_read(paddr, len);
}

你需要理解分页地址转过的过程, 然后编写 page_translate() 函数(框架没有给出函数定义,你需要自己在 nemu/src/memory/memory.c 或者另建一个新文件 nemu/src/memory/page.c 中实现. 另外由于我们不打算实现保护机制, 在 page_translate() 函数的实现中, 你务必使用 assertion 检查页目录项和页表项的 present 位, 如果发现了一个无效的表项, 及时终止 NEMU 的运行, 否则调试将会异常困难. 这通常是由于你的实现错误引起的, 请检查实现的正确性. 再次提醒, 只有进入保护模式并开启分页机制之后才会进行页级地址转换. 为了让 differential testing 机制正确工作, 你还需要实现分页机制中 accessed 位和 dirty 位的功能. 该函数原型为:

paddr_t page_translate(vaddr_t vaddr);

如何编写 page_translate()

下面我们来看一下 page_translate() 的实现:

  • 该函数用于地址转换,传入虚拟地址作为参数,函数返回值为物理地址
  • 该函数的实现过程即为我们理论课学到的页级转换过程(先找页目录项,然后取出);
  • 注意使用 assert 来验证 present 位,否则会造成调试困难
  • PDEPTE 的数据结构框架已帮我们定义好,在 mmu.h 中;
  • 注意每个页目录想和每个页表项存储在内存中的地址均为物理地址,使用 paddr_read 去读取,如果使用 vaddr_read 去读取会造成死递归(为什么?);
  • 此外,还需要实现访问位和脏位的功能;
  • 需要在 page_translate 中插入 Log 并截图表示实现成功(截图后可去除 Log 以免影响性能);
  • 如何编写这个函数?

  • 根据 CR3 寄存器得到页目录表基址(是个物理地址);

  • 用这个基址和从虚拟地址中隐含的页目录字段项结合计算出所需页目录项地址(是个物理地址);
    • 请思考一下这里所谓的“结合”需要经过哪些处理才能得到正确地地址呢?
  • 从内存中读出这个页目录项,并对有效位进行检验;
  • 将取出的 PDE 和虚拟地址的页表字段相组合,得到所需页表项地址(是个物理地址);
  • 从内存中读出这个页表项,并对有效位进行检验;
  • 检验 PDEaccessed 位,如果为 0 则需变为 1,并写回到页目录项所在地址;
  • 检验 PTEaccessed 位如果为 0,或者 PTE 的脏位为 0 且现在正在做写内存操作,满足这两个条件之一时需要将 accessed 位,然后更新 dirty 位,最后并写回到页表项所在地址;
  • 页级地址转换结束,返回转换结果(是个物理地址).

最后提醒一下页级地址转换时出现的一种特殊情况. 由于 i386 并没有严格要求数据对齐, 因此可能会出现数据跨越虚拟页边界的情况, 例如一条很长的指令的首字节在一个虚拟页的最后, 剩下的字节在另一个虚拟页的开头. 如果这两个虚拟页被映射到两个不连续的物理页, 就需要进行两次页级地址转换, 分别读出这两个物理页中需要的字节, 然后拼接起来组成一个完整的数据返回.具体实现的时候可以根据读/写的长度和本页剩余的长度来判断是否跨页。要注意的是:如果是读跨页,则需组合两次读出的数据(位操作实现);如果是写跨页,应注意两个页分别需要写的数据长度是多少,以及写到两页上的内容分别是什么 .

MIPS 作为一种 RISC 架构, 指令和数据都严格按照 4 字节对齐, 因此不会发生跨页的情况, 否则 MIPS CPU 将会抛出异常, 可见软件灵活性和硬件复杂度是计算机科学中又一对 tradeoff. 不过根据 KISS 法则, 你现在可以暂时不实现这种特殊情况的处理, 在判断出数据跨越虚拟页边界的情况之后, 先使用 assert(0) 终止 NEMU, 等到真的出现这种情况的时候再进行处理.

任务 1:在NEMU中实现分页机制

根据上述的讲义内容, 在 NEMU 中实现 i386 分页机制, 如有疑问, 请查阅 i386 手册.

让用户程序运行在分页机制上

成功实现分页机制之后, 你会发现仙剑奇侠传也同样成功运行了. 但仔细想想就会发现这其实不太对劲: 我们在 _asye_init() 中创建了内核的虚拟地址空间, 之后就再也没有切换过这一虚拟地址空间. 也就是说, 我们让仙剑奇侠传也运行在内核的虚拟地址空间之上! 这太不合理了, 虽然 NEMU 没有实现 ring 3, 但用户进程还是应该有自己的一套虚拟地址空间. 更可况, Navy-apps 之前让用户程序链接到 0x4000000 的位置, 是因为之前 Nanos-lite 并没有对空闲的物理内存进行管理; 现在引入了分页机制, 由 MM 来负责所有物理页的分配. 这意味着, 如果将来 MM 把 0x4000000 所在的物理页分配出去, 仙剑奇侠传的内容将会被覆盖! 因此, 目前仙剑奇侠传看似运行成功, 其实里面暗藏杀机.

正确的做法是, 我们应该让用户程序运行在操作系统为其分配的虚拟地址空间之上. 为此, 我们需要对工程作一些变动. 首先需要将 navy-apps/Makefile.compile 中的链接地址 -Ttext 参数改为 0x8048000重新编译,这是为了避免用户程序的虚拟地址空间与内核相互重叠, 从而产生非预期的错误. 同样的, nanos-lite/src/loader.c 中的 DEFAULT_ENTRY 也需要作相应的修改. 这时, "虚拟地址作为物理地址的抽象"这一好处已经体现出来了: 原则上用户程序可以运行在任意的虚拟地址, 不受物理内存容量的限制. 我们让用户程序的代码从 0x8048000 附近开始, 这个地址已经超过了物理地址的最大值(NEMU提供的物理内存是 128MB), 但分页机制保证了程序能够正确运行. 这样, 链接器和程序都不需要关心程序运行时刻具体使用哪一段物理地址, 它们只要使用虚拟地址就可以了, 而虚拟地址和物理地址之间的映射则全部交给操作系统的MM来管理.

然后, 我们让 Nanos-lite 通过 load_prog() 函数(在 nanos-lite/src/proc.c 中定义)来进行用户程序的加载:

--- nanos-lite/src/main.c
+++ nanos-lite/src/main.c
@@ -33,2 +33,1 @@
-  uintptr_t entry = loader(NULL, "/bin/pal");
-  ((void (*)(void))entry)();
+  load_prog("/bin/dummy");

我们先运行 dummy, 是因为让仙剑奇侠传成功运行在虚拟地址空间上还需要进行一些额外的工作. load_prog() 函数首先会通过 _protect() 函数(在 nexus-am/am/arch/x86-nemu/src/pte.c 中定义) 创建一个用户进程的虚拟地址空间, 这个虚拟地址空间除了内核映射之外就没有其它内容了. 框架代码在调用 _protect() 的时候用到了一个 PCB 的结构体, 我们会在后面再介绍它, 目前只需要知道虚拟地址空间的信息被存放在 PCB 结构体的 as 成员中即可. 然后 load_prog() 会调用 loader() 函数加载用户程序. 需要注意的是, 此时 loader() 不能直接把用户程序加载到内存位置 0x8048000 附近了, 因为这个地址并不在内核的虚拟地址空间中, 内核不能直接访问它. loader() 要做的事情是, 获取用户程序的大小之后, 以页为单位进行加载:

  • 申请一页空闲的物理页
  • 把这一物理页映射到用户程序的虚拟地址空间中
  • 从文件中读入一页的内容到这一物理页上

这一切都是为了让用户进程在将来可以正确地运行: 用户进程在将来使用虚拟地址访问内存, 在 loader 为用户进程准备的映射下, 虚拟地址被转换成物理地址, 通过这一物理地址访问到的物理内存, 恰好就是用户进程想要访问的数据. 为了提供映射一页的功能, 你需要在 AM 中实现 _map() 函数(在 nexus-am/am/arch/x86-nemu/src/pte.c 中定义). 它的函数原型如下

void _map(_Protect *p, void *va, void *pa);

功能是将虚拟地址空间 p 中的虚拟地址 va 映射到物理地址 pa. 通过 p->ptr 可以获取页目录的基地址. 若在映射过程中发现需要申请新的页表, 可以通过回调函数 palloc_f() 向 Nanos-lite 获取一页空闲的物理页.

理解 _map() 函数

_map() 原本需要同学们实现,但是现在你可以通过更新框架获取到它的代码。之后,请你阅读 _map() 函数逇实现过程,根据代码,用自己的语言描述 _map() 所做的事情,以及他们的意义。

如何修改 loader() 函数

根据上文的介绍,我们可以对 loader() 函数做如下修改:

  1. 打开待装入的文件后,还需要获取文件大小;
  2. 需要循环判断是否已创建足够的页来装入程序;
  3. 对于程序需要的每一页,做三个事情,即4,5,6步:
  4. 使用 Nanos-liteMM 提供的 new_page() 函数获取一个空闲物理页
  5. 使用映射函数 _map() 将本虚拟空间内当前正在处理的这个页和上一步申请到的空闲物理页建立映射
  6. 读一页内容,写到这个物理页上
  7. 每一页都处理完毕后,关闭文件,并返回程序入口点地址(虚拟地址)

loader() 返回后, load_prog() 会调用 _switch() 函数(在 nexus-am/am/arch/x86-nemu/src/pte.c 中定义), 切换到刚才为用户程序创建的地址空间. 最后跳转到用户程序的入口, 此时用户程序已经完全运行在分页机制上了.

任务 2:让用户程序运行在分页机制上

根据上述的讲义内容, 在 PTE 中实现 _map(), 然后修改 loader() 的内容, 通过 _map() 在用户程序的虚拟地址空间中创建虚拟页, 并把用户程序加载到虚拟地址空间上. 其中,_map() 函数已经实现完成,你可以通过更新框架获取到它的代码。

实现正确后, 你会看到 dummy 程序最后输出 GOOD TRAP 的信息, 说明它确实在虚拟地址空间上成功运行了.

作为你成功实现分页的依据,你需要在 loader() 函数里面插入一个 Log,插入到每次调用 _map() 函数前,通过 Log() 显示出每次程序调用 _map() 传入的第二个和第三个参数(vapa),代码如下:

void *pa = ???;
void *va = ???;
Log("Map va to pa: 0x%08x to 0x%08x", va, pa);
_map(???, va, pa);

参考输出的 vapa 每个人都可能会不同,但是总有一个正常范围,只要能正常运行 dummy 的一定是在正常范围内的,你的报告中一定要出现这个截图才能证明你正确实现了本节的功能.

内核映射的作用

_protect() 函数中创建虚拟地址空间的时候, 有一处代码用于拷贝内核映射:

for (int i = 0; i < NR_PDE; i ++) {
  updir[i] = kpdirs[i];
}

尝试注释这处代码, 重新编译并运行, 你会看到发生了错误. 请解释为什么会发生这个错误.

任务 3:在分页机制上运行仙剑奇侠传

之前我们让 mm_brk() 函数直接返回 0, 表示用户程序的堆区大小修改总是成功, 这是因为在实现分页机制之前, 0x4000000 之上的内存都可以让用户程序自由使用. 现在用户程序运行在虚拟地址空间之上, 我们还需要在 mm_brk() 中把新申请的堆区映射到虚拟地址空间中:

int mm_brk(uint32_t new_brk) {
  if (current->cur_brk == 0) {
    current->cur_brk = current->max_brk = new_brk;
  }
  else {
    if (new_brk > current->max_brk) {
      // TODO: map memory region [current->max_brk, new_brk)
      // into address space current->as

      current->max_brk = new_brk;
    }

    current->cur_brk = new_brk;
  }

  return 0;
}

你需要填充上述 TODO 处的代码, 其中 current 是一个特殊的指针, 我们会在后面介绍它. 你需要注意 _map() 参数是否需要按页对齐的问题(这取决于你的 _map() 实现). 为了简化, 我们也不实现堆区的回收功能了. 这部分功能已经实现,你可以通过更新框架获取到它的代码。

实现正确后, 仙剑奇侠传就可以正确在分页机制上运行了.


以上是 PA3.2 的所有内容。恭喜你,本学期 PA 的必做部分也到此结束,希望你能在完成 PA 的过程中有所收获!如果可能的话,请你阅读后续 PA4 部分的讲义,尽可能选做一部分。

results matching ""

    No results matching ""