Caffeinated 6.828:实验 3:用户环境

Csail.mit 的头像

·

·

·

4,852 次阅读

简介

在本实验中,你将要实现一个基本的内核功能,要求它能够保护运行的用户模式环境(即:进程)。你将去增强这个 JOS 内核,去配置数据结构以便于保持对用户环境的跟踪、创建一个单一用户环境、将程序镜像加载到用户环境中、并将它启动运行。你也要写出一些 JOS 内核的函数,用来处理任何用户环境生成的系统调用,以及处理由用户环境引进的各种异常。

注意: 在本实验中,术语“环境”“进程” 是可互换的 —— 它们都表示同一个抽象概念,那就是允许你去运行的程序。我在介绍中使用术语“环境”而不是使用传统术语“进程”的目的是为了强调一点,那就是 JOS 的环境和 UNIX 的进程提供了不同的接口,并且它们的语义也不相同。

预备知识

使用 Git 去提交你自实验 2 以后的更改(如果有的话),获取课程仓库的最新版本,以及创建一个命名为 lab3 的本地分支,指向到我们的 lab3 分支上 origin/lab3

athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'changes to lab2 after handin'
Created commit 734fab7: changes to lab2 after handin
 4 files changed, 42 insertions(+), 9 deletions(-)
athena% git pull
Already up-to-date.
athena% git checkout -b lab3 origin/lab3
Branch lab3 set up to track remote branch refs/remotes/origin/lab3.
Switched to a new branch "lab3"
athena% git merge lab2
Merge made by recursive.
 kern/pmap.c |   42 +++++++++++++++++++
 1 files changed, 42 insertions(+), 0 deletions(-)
athena% 

实验 3 包含一些你将探索的新源文件:

inc/    env.h       Public definitions for user-mode environments
        trap.h      Public definitions for trap handling
        syscall.h   Public definitions for system calls from user environments to the kernel
        lib.h       Public definitions for the user-mode support library
kern/   env.h       Kernel-private definitions for user-mode environments
        env.c       Kernel code implementing user-mode environments
        trap.h      Kernel-private trap handling definitions
        trap.c      Trap handling code
        trapentry.S Assembly-language trap handler entry-points
        syscall.h   Kernel-private definitions for system call handling
        syscall.c   System call implementation code
lib/    Makefrag    Makefile fragment to build user-mode library, obj/lib/libjos.a
        entry.S     Assembly-language entry-point for user environments
        libmain.c   User-mode library setup code called from entry.S
        syscall.c   User-mode system call stub functions
        console.c   User-mode implementations of putchar and getchar, providing console I/O
        exit.c      User-mode implementation of exit
        panic.c     User-mode implementation of panic
user/   *           Various test programs to check kernel lab 3 code

另外,一些在实验 2 中的源文件在实验 3 中将被修改。如果想去查看有什么更改,可以运行:

$ git diff lab2

你也可以另外去看一下 实验工具指南,它包含了与本实验有关的调试用户代码方面的信息。

实验要求

本实验分为两部分:Part A 和 Part B。Part A 在本实验完成后一周内提交;你将要提交你的更改和完成的动手实验,在提交之前要确保你的代码通过了 Part A 的所有检查(如果你的代码未通过 Part B 的检查也可以提交)。只需要在第二周提交 Part B 的期限之前代码检查通过即可。

由于在实验 2 中,你需要做实验中描述的所有正则表达式练习,并且至少通过一个挑战(是指整个实验,不是每个部分)。写出详细的问题答案并张贴在实验中,以及一到两个段落的关于你如何解决你选择的挑战问题的详细描述,并将它放在一个名为 answers-lab3.txt 的文件中,并将这个文件放在你的 lab 目标的根目录下。(如果你做了多个问题挑战,你仅需要提交其中一个即可)不要忘记使用 git add answers-lab3.txt 提交这个文件。

行内汇编语言

在本实验中你可能发现使用了 GCC 的行内汇编语言特性,虽然不使用它也可以完成实验。但至少你需要去理解这些行内汇编语言片段,这些汇编语言(asm 语句)片段已经存在于提供给你的源代码中。你可以在课程 参考资料 的页面上找到 GCC 行内汇编语言有关的信息。

Part A:用户环境和异常处理

新文件 inc/env.h 中包含了在 JOS 中关于用户环境的基本定义。现在就去阅读它。内核使用数据结构 Env 去保持对每个用户环境的跟踪。在本实验的开始,你将只创建一个环境,但你需要去设计 JOS 内核支持多环境;实验 4 将带来这个高级特性,允许用户环境去 fork 其它环境。

正如你在 kern/env.c 中所看到的,内核维护了与环境相关的三个全局变量:

struct Env *envs = NULL;            // All environments
struct Env *curenv = NULL;          // The current env
static struct Env *env_free_list;   // Free environment list

一旦 JOS 启动并运行,envs 指针指向到一个数组,即数据结构 Env,它保存了系统中全部的环境。在我们的设计中,JOS 内核将同时支持最大值为 NENV 个的活动的环境,虽然在一般情况下,任何给定时刻运行的环境很少。(NENV 是在 inc/env.h 中用 #define 定义的一个常量)一旦它被分配,对于每个 NENV 可能的环境,envs 数组将包含一个数据结构 Env 的单个实例。

JOS 内核在 env_free_list 上用数据结构 Env 保存了所有不活动的环境。这样的设计使得环境的分配和回收很容易,因为这只不过是添加或删除空闲列表的问题而已。

内核使用符号 curenv 来保持对任意给定时刻的 当前正在运行的环境 进行跟踪。在系统引导期间,在第一个环境运行之前,curenv 被初始化为 NULL

环境状态

数据结构 Env 被定义在文件 inc/env.h 中,内容如下:(在后面的实验中将添加更多的字段):

struct Env {
    struct Trapframe env_tf;   // Saved registers
    struct Env *env_link;      // Next free Env
    envid_t env_id;            // Unique environment identifier
    envid_t env_parent_id;     // env_id of this env's parent
    enum EnvType env_type;     // Indicates special system environments
    unsigned env_status;       // Status of the environment
    uint32_t env_runs;         // Number of times environment has run

    // Address space
    pde_t *env_pgdir;          // Kernel virtual address of page dir
};

以下是数据结构 Env 中的字段简介:

  • env_tf: 这个结构定义在 inc/trap.h 中,它用于在那个环境不运行时保持它保存在寄存器中的值,即:当内核或一个不同的环境在运行时。当从用户模式切换到内核模式时,内核将保存这些东西,以便于那个环境能够在稍后重新运行时回到中断运行的地方。
  • env_link: 这是一个链接,它链接到在 env_free_list 上的下一个 Env 上。env_free_list 指向到列表上第一个空闲的环境。
  • env_id: 内核在数据结构 Env 中保存了一个唯一标识当前环境的值(即:使用数组 envs 中的特定槽位)。在一个用户环境终止之后,内核可能给另外的环境重新分配相同的数据结构 Env —— 但是新的环境将有一个与已终止的旧的环境不同的 env_id,即便是新的环境在数组 envs 中复用了同一个槽位。
  • env_parent_id: 内核使用它来保存创建这个环境的父级环境的 env_id。通过这种方式,环境就可以形成一个“家族树”,这对于做出“哪个环境可以对谁做什么”这样的安全决策非常有用。
  • env_type: 它用于去区分特定的环境。对于大多数环境,它将是 ENV_TYPE_USER 的。在稍后的实验中,针对特定的系统服务环境,我们将引入更多的几种类型。
  • env_status: 这个变量持有以下几个值之一:
    • ENV_FREE: 表示那个 Env 结构是非活动的,并且因此它还在 env_free_list 上。
    • ENV_RUNNABLE: 表示那个 Env 结构所代表的环境正等待被调度到处理器上去运行。
    • ENV_RUNNING: 表示那个 Env 结构所代表的环境当前正在运行中。
    • ENV_NOT_RUNNABLE: 表示那个 Env 结构所代表的是一个当前活动的环境,但不是当前准备去运行的:例如,因为它正在因为一个来自其它环境的进程间通讯(IPC)而处于等待状态。
    • ENV_DYING: 表示那个 Env 结构所表示的是一个僵尸环境。一个僵尸环境将在下一次被内核捕获后被释放。我们在实验 4 之前不会去使用这个标志。
  • env_pgdir: 这个变量持有这个环境的内核虚拟地址的页目录。

就像一个 Unix 进程一样,一个 JOS 环境耦合了“线程”和“地址空间”的概念。线程主要由保存的寄存器来定义(env_tf 字段),而地址空间由页目录和 env_pgdir 所指向的页表所定义。为运行一个环境,内核必须使用保存的寄存器值和相关的地址空间去设置 CPU。

我们的 struct Env 与 xv6 中的 struct proc 类似。它们都在一个 Trapframe 结构中持有环境(即进程)的用户模式寄存器状态。在 JOS 中,单个的环境并不能像 xv6 中的进程那样拥有它们自己的内核栈。在这里,内核中任意时间只能有一个 JOS 环境处于活动中,因此,JOS 仅需要一个单个的内核栈。

为环境分配数组

在实验 2 的 mem_init() 中,你为数组 pages[] 分配了内存,它是内核用于对页面分配与否的状态进行跟踪的一个表。你现在将需要去修改 mem_init(),以便于后面使用它分配一个与结构 Env 类似的数组,这个数组被称为 envs

练习 1、修改在 kern/pmap.c 中的 mem_init(),以用于去分配和映射 envs 数组。这个数组完全由 Env 结构分配的实例 NENV 组成,就像你分配的 pages 数组一样。与 pages 数组一样,由内存支持的数组 envs 也将在 UENVS(它的定义在 inc/memlayout.h 文件中)中映射用户只读的内存,以便于用户进程能够从这个数组中读取。

你应该去运行你的代码,并确保 check_kern_pgdir() 是没有问题的。

创建和运行环境

现在,你将在 kern/env.c 中写一些必需的代码去运行一个用户环境。因为我们并没有做一个文件系统,因此,我们将设置内核去加载一个嵌入到内核中的静态的二进制镜像。JOS 内核以一个 ELF 可运行镜像的方式将这个二进制镜像嵌入到内核中。

在实验 3 中,GNUmakefile 将在 obj/user/ 目录中生成一些二进制镜像。如果你看到 kern/Makefrag,你将注意到一些奇怪的的东西,它们“链接”这些二进制直接进入到内核中运行,就像 .o 文件一样。在链接器命令行上的 -b binary 选项,将因此把它们链接为“原生的”不解析的二进制文件,而不是由编译器产生的普通的 .o 文件。(就链接器而言,这些文件压根就不是 ELF 镜像文件 —— 它们可以是任何东西,比如,一个文本文件或图片!)如果你在内核构建之后查看 obj/kern/kernel.sym ,你将会注意到链接器很奇怪的生成了一些有趣的、命名很费解的符号,比如像 _binary_obj_user_hello_start_binary_obj_user_hello_end、以及 _binary_obj_user_hello_size。链接器通过改编二进制文件的命令来生成这些符号;这种符号为普通内核代码使用一种引入嵌入式二进制文件的方法。

kern/init.ci386_init() 中,你将写一些代码在环境中运行这些二进制镜像中的一种。但是,设置用户环境的关键函数还没有实现;将需要你去完成它们。

练习 2、在文件 env.c 中,写完以下函数的代码:

  • env_init()

初始化 envs 数组中所有的 Env 结构,然后把它们添加到 env_free_list 中。也称为 env_init_percpu,它通过配置硬件,在硬件上为 level 0(内核)权限和 level 3(用户)权限使用单独的段。

  • env_setup_vm()

为一个新环境分配一个页目录,并初始化新环境的地址空间的内核部分。

  • region_alloc()

为一个新环境分配和映射物理内存

  • load_icode()

你将需要去解析一个 ELF 二进制镜像,就像引导加载器那样,然后加载它的内容到一个新环境的用户地址空间中。

  • env_create()

使用 env_alloc 去分配一个环境,并调用 load_icode 去加载一个 ELF 二进制

  • env_run()

在用户模式中开始运行一个给定的环境

在你写这些函数时,你可能会发现新的 cprintf 动词 %e 非常有用 – 它可以输出一个错误代码的相关描述。比如:

    r = -E_NO_MEM;
    panic("env_alloc: %e", r);

中 panic 将输出消息 env_alloc: out of memory。

下面是用户代码相关的调用图。确保你理解了每一步的用途。

  • start (kern/entry.S)
  • i386_init (kern/init.c)
    • cons_init
    • mem_init
    • env_init
    • trap_init(到目前为止还未完成)
    • env_create
    • env_run
      • env_pop_tf

在完成以上函数后,你应该去编译内核并在 QEMU 下运行它。如果一切正常,你的系统将进入到用户空间并运行二进制的 hello ,直到使用 int 指令生成一个系统调用为止。在那个时刻将存在一个问题,因为 JOS 尚未设置硬件去允许从用户空间到内核空间的各种转换。当 CPU 发现没有系统调用中断的服务程序时,它将生成一个一般保护异常,找到那个异常并去处理它,还将生成一个双重故障异常,同样也找到它并处理它,并且最后会出现所谓的“三重故障异常”。通常情况下,你将随后看到 CPU 复位以及系统重引导。虽然对于传统的应用程序(在 这篇博客文章 中解释了原因)这是重大的问题,但是对于内核开发来说,这是一个痛苦的过程,因此,在打了 6.828 补丁的 QEMU 上,你将可以看到转储的寄存器内容和一个“三重故障”的信息。

我们马上就会去处理这些问题,但是现在,我们可以使用调试器去检查我们是否进入了用户模式。使用 make qemu-gdb 并在 env_pop_tf 处设置一个 GDB 断点,它是你进入用户模式之前到达的最后一个函数。使用 si 单步进入这个函数;处理器将在 iret 指令之后进入用户模式。然后你将会看到在用户环境运行的第一个指令,它将是在 lib/entry.S 中的标签 start 的第一个指令 cmpl。现在,在 hello 中的 sys_cputs()int $0x30 处使用 b *0x...(关于用户空间的地址,请查看 obj/user/hello.asm )设置断点。这个指令 int 是系统调用去显示一个字符到控制台。如果到 int 还没有运行,那么可能在你的地址空间设置或程序加载代码时发生了错误;返回去找到问题并解决后重新运行。

处理中断和异常

到目前为止,在用户空间中的第一个系统调用指令 int $0x30 已正式寿终正寝了:一旦处理器进入用户模式,将无法返回。因此,现在,你需要去实现基本的异常和系统调用服务程序,因为那样才有可能让内核从用户模式代码中恢复对处理器的控制。你所做的第一件事情就是彻底地掌握 x86 的中断和异常机制的使用。

练习 3、如果你对中断和异常机制不熟悉的话,阅读 80386 程序员手册的第 9 章(或 IA-32 开发者手册的第 5 章)。

在这个实验中,对于中断、异常、以其它类似的东西,我们将遵循 Intel 的术语习惯。由于如 异常 exception 陷阱 trap 中断 interrupt 故障 fault 中止 abort 这些术语在不同的架构和操作系统上并没有一个统一的标准,我们经常在特定的架构下(如 x86)并不去考虑它们之间的细微差别。当你在本实验以外的地方看到这些术语时,它们的含义可能有细微的差别。

受保护的控制转移基础

异常和中断都是“受保护的控制转移”,它将导致处理器从用户模式切换到内核模式(CPL=0)而不会让用户模式的代码干扰到内核的其它函数或其它的环境。在 Intel 的术语中,一个中断就是一个“受保护的控制转移”,它是由于处理器以外的外部异步事件所引发的,比如外部设备 I/O 活动通知。而异常正好与之相反,它是由当前正在运行的代码所引发的同步的、受保护的控制转移,比如由于发生了一个除零错误或对无效内存的访问。

为了确保这些受保护的控制转移是真正地受到保护,处理器的中断/异常机制设计是:当中断/异常发生时,当前运行的代码不能随意选择进入内核的位置和方式。而是,处理器在确保内核能够严格控制的条件下才能进入内核。在 x86 上,有两种机制协同来提供这种保护:

  1. 中断描述符表 处理器确保中断和异常仅能够导致内核进入几个特定的、由内核本身定义好的、明确的入口点,而不是去运行中断或异常发生时的代码。

x86 允许最多有 256 个不同的中断或异常入口点去进入内核,每个入口点都使用一个不同的中断向量。一个向量是一个介于 0 和 255 之间的数字。一个中断向量是由中断源确定的:不同的设备、错误条件、以及应用程序去请求内核使用不同的向量生成中断。CPU 使用向量作为进入处理器的中断描述符表(IDT)的索引,它是内核设置的内核私有内存,GDT 也是。从这个表中的适当的条目中,处理器将加载:

* 将值加载到指令指针寄存器(EIP),指向内核代码设计好的,用于处理这种异常的服务程序。
* 将值加载到代码段寄存器(CS),它包含运行权限为 0—1 级别的、要运行的异常服务程序。(在 JOS 中,所有的异常处理程序都运行在内核模式中,运行级别为 0。)
  1. 任务状态描述符表 处理器在中断或异常发生时,需要一个地方去保存旧的处理器状态,比如,处理器在调用异常服务程序之前的 EIPCS 的原始值,这样那个异常服务程序就能够稍后通过还原旧的状态来回到中断发生时的代码位置。但是对于已保存的处理器的旧状态必须被保护起来,不能被无权限的用户模式代码访问;否则代码中的 bug 或恶意用户代码将危及内核。

基于这个原因,当一个 x86 处理器产生一个中断或陷阱时,将导致权限级别的变更,从用户模式转换到内核模式,它也将导致在内核的内存中发生栈切换。有一个被称为 TSS 的任务状态描述符表规定段描述符和这个栈所处的地址。处理器在这个新栈上推送 SSESPEFLAGSCSEIP、以及一个可选的错误代码。然后它从中断描述符上加载 CSEIP 的值,然后设置 ESPSS 去指向新的栈。

虽然 TSS 很大并且默默地为各种用途服务,但是 JOS 仅用它去定义当从用户模式到内核模式的转移发生时,处理器即将切换过去的内核栈。因为在 JOS 中的“内核模式”仅运行在 x86 的运行级别 0 权限上,当进入内核模式时,处理器使用 TSS 上的 ESP0SS0 字段去定义内核栈。JOS 并不去使用 TSS 的任何其它字段。

异常和中断的类型

所有的 x86 处理器上的同步异常都能够产生一个内部使用的、介于 0 到 31 之间的中断向量,因此它映射到 IDT 就是条目 0-31。例如,一个页故障总是通过向量 14 引发一个异常。大于 31 的中断向量仅用于软件中断,它由 int 指令生成,或异步硬件中断,当需要时,它们由外部设备产生。

在这一节中,我们将扩展 JOS 去处理向量为 0-31 之间的、内部产生的 x86 异常。在下一节中,我们将完成 JOS 的 48(0x30)号软件中断向量,JOS 将(随意选择的)使用它作为系统调用中断向量。在实验 4 中,我们将扩展 JOS 去处理外部生成的硬件中断,比如时钟中断。

一个示例

我们把这些片断综合到一起,通过一个示例来巩固一下。我们假设处理器在用户环境下运行代码,遇到一个除零问题。

  1. 处理器去切换到由 TSS 中的 SS0ESP0 定义的栈,在 JOS 中,它们各自保存着值 GD_KDKSTACKTOP
  2. 处理器在内核栈上推入异常参数,起始地址为 KSTACKTOP
+--+ KSTACKTOP             
| 0x00000 | old SS   |     " - 4
|      old ESP       |     " - 8
|     old EFLAGS     |     " - 12
| 0x00000 | old CS   |     " - 16
|      old EIP       |     " - 20 <- ESP
    +--+             

嵌套的异常和中断

处理器能够处理来自用户和内核模式中的异常和中断。当收到来自用户模式的异常和中断时才会进入内核模式中,而且,在推送它的旧寄存器状态到栈中和通过 IDT 调用相关的异常服务程序之前,x86 处理器会自动切换栈。如果当异常或中断发生时,处理器已经处于内核模式中(CS 寄存器低位两个比特为 0),那么 CPU 只是推入一些值到相同的内核栈中。在这种方式中,内核可以优雅地处理嵌套的异常,嵌套的异常一般由内核本身的代码所引发。在实现保护时,这种功能是非常重要的工具,我们将在稍后的系统调用中看到它。

如果处理器已经处于内核模式中,并且发生了一个嵌套的异常,由于它并不需要切换栈,它也就不需要去保存旧的 SSESP 寄存器。对于不推入错误代码的异常类型,在进入到异常服务程序时,它的内核栈看起来应该如下图:


    +--+ <-+                        
|   &handler1    |-+
|   &handler2    |-+
       .
       .
       .
+-> handlerX:
|                |         // do stuff
|                |         call trap
|                |         // ...
+

via: <https://pdos.csail.mit.edu/6.828/2018/labs/lab3/>

作者:[csail.mit](https://pdos.csail.mit.edu) 选题:[lujun9972](https://github.com/lujun9972) 译者:[qhwdw](https://github.com/qhwdw) 校对:[wxy](https://github.com/wxy)

本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注