MIT6.828_Lab3

Introduction

在这个实验中,你将实现基本的内核功能,以便建立一个受保护的用户模式环境(即“进程”)。你将增强 JOS 内核,设置数据结构来跟踪用户环境,创建单个用户环境,将程序映像加载到其中,并启动它运行。你还将使 JOS 内核能够处理用户环境发出的任何系统调用,并处理它引发的任何其他异常。

注意:在这个实验中,环境和进程这两个术语是可以互换的 - 它们都指的是一种允许你运行程序的抽象。我们引入“环境”这个术语,而不是传统的“进程”术语,是为了强调 JOS 环境和 UNIX 进程提供不同的接口,并且不提供相同的语义。

Getting Started

  • 操作流程

    athena% git commit -am 'changes to lab2 after handin'在这个之前要先指定,通过下面的代码

    git config --global user.email "leetong155@gmail.com"
    git config --global user.name "Your Name"
    

    git merge lab2会打开新窗口,直接退出 Ctrl + X

使用 Git 在你提交 Lab 2 后(如果有的话)提交你的更改,获取课程仓库的最新版本,然后基于我们课程仓库的 lab3 分支(origin/lab3)创建一个本地分支叫做 lab3。

User
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%

实验三包含了许多新的源文件,你应该浏览一下这些文件:

  • 新文件

此外,在实验三中修改了一些我们在实验二中提供的源文件。要查看这些差异,你可以键入:

$ git diff lab2

你可能还想再看一下实验工具指南,因为它包含了有关调试用户代码的信息,在这个实验中这些内容会变得相关。

Lab Requirements

Inline Assembly

在这个实验中,你可能会发现 GCC 的内联汇编语言特性很有用,尽管也有可能在不使用它的情况下完成实验。至少,你需要能够理解我们提供给你的源代码中已经存在的内联汇编语言("asm" 语句)片段。你可以在课程参考资料页面上找到有关 GCC 内联汇编语言的几个信息来源。

Part A: User Environments and Exception Handling

新的包含文件 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 定义的一个常量。)一旦分配完成,envs 数组将包含 NENV 个可能环境的单个 Env 数据结构实例。

JOS 内核将所有非活跃的 Env 结构保留在 env_free_list 上。这种设计允许轻松地分配和释放环境,因为它们只需要被添加到或从空闲列表中移除即可。

内核使用 curenv 符号来追踪任何给定时间当前正在执行的环境。在启动期间,在第一个环境运行之前,curenv 最初被设置为 NULL。

envs指向一个ENV结构的数组,curenv指向当前正在运行的环境,env_free_list指向一个ENV结构的链表,保存未在运行的环境。

Environment State

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_tf:

这个结构在 inc/trap.h 中定义,它保存了环境的寄存器值,当环境不在运行时(也就是说,当内核或其他环境正在运行时)。内核在从用户模式切换到内核模式时保存这些值,以便稍后能够恢复环境并从上次停止的地方继续执行。

env_link:

这是指向 env_free_list 上下一个空闲 Env 结构的链接。env_free_list 指向列表中第一个空闲的环境。

env_id:

内核在这里存储一个唯一标识当前使用此 Env 结构的环境的值(即使用 envs 数组中的特定槽位)。当用户环境终止后,内核可以将同一个 Env 结构重新分配给另一个环境 - 但新环境的 env_id 与旧环境的不同,即使新环境重新使用了 envs 数组中的同一槽位。

你可以这样理解 env_idenv_id 是为了区分正在使用 Env 结构的不同环境而设置的唯一标识符。每个正在运行的环境都有自己独特的 env_id,它标识了环境正在使用的特定 Env 结构。这个标识符在内核中用于确保对于每个活跃的环境,它们都有一个不同的 env_id,即使它们共享相同的 Env 结构(比如某个环境结束后,其 Env 结构可以被重新分配给另一个环境使用)。这有助于确保内核能够正确地跟踪和管理每个环境。

env_parent_id:

内核在这里存储创建此环境的环境的 env_id。通过这种方式,环境可以形成一个“家族树”,这将有助于制定关于允许哪些环境对哪些对象执行何种操作的安全决策。当前用户环境父节点的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。

我们的 Env 结构类似于 xv6 中的 struct proc 结构。这两种结构都使用 Trapframe 结构来保存环境(即进程)的用户模式寄存器状态。在 JOS 中,各个环境并没有像 xv6 中的进程那样拥有自己的内核栈。在内核中一次只能有一个 JOS 环境处于活动状态,所以 JOS 只需要一个内核栈。

Allocating the Environments Array

在实验 2 中,你在 mem_init() 中为 pages[] 数组分配了内存,该数组是内核用来跟踪哪些页面是空闲的,哪些是被占用的。现在你需要进一步修改 mem_init(),来分配一个类似的 Env 结构数组,称为 envs。

Exercise 1.

修改 kern/pmap.c 中的 mem_init() 函数,以分配并映射 envs 数组。这个数组包含了恰好 NENV 个 Env 结构的实例,分配方式类似你分配 pages 数组的方式。与 pages 数组类似,用于支持 envs 的内存也应该被映射为用户只读的 UENVS(在 inc/memlayout.h 中定义),这样用户进程可以从这个数组中读取数据。

你应该运行你的代码,并确保 check_kern_pgdir() 成功。

思路和lab2中的pages数组的分配一样:

在mem_init()分配完pages数组后,添加如下语句:

envs = (struct Env*)boot_alloc(sizeof(struct Env) * NENV);
memset(envs, 0, sizeof(struct Env) * NENV);

这样就完成了envs的初始化。

同样在mem_init()中映射完UPAGES后,映射UENVS:

boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);
  • 样执行完mem_init()后内核线性地址空间到物理地址空间的映射关系可用下图表示

由于现在还没有文件系统,我们将直接把用户二进制程序直接嵌入到内核中。obj/kern/kernel.sym中类似_binary_obj_user_hello_start,_binary_obj_user_hello_end,_binary_obj_user_hello_size这种符号就是用户程序的起始线性地址,终止线性地址。

观察kern/init.c中的i386_init()函数会发现多了如下语句:

env_init();
#if defined(TEST)
	// Don't touch -- used by grading script!
	ENV_CREATE(TEST, ENV_TYPE_USER);
#else
	// Touch all you want.
	ENV_CREATE(user_hello, ENV_TYPE_USER);		//会调用env_create(_binary_obj_user_hello_start, ENV_TYPE_USER)
#endif // TEST*
	env_run(&envs[0]);							//envs[0]已经在env_create的时候初始化过了
ENV_CREATE(user_hello, ENV_TYPE_USER);

这个宏相当于调用

env_create(_binary_obj_user_hello_start, ENV_TYPE_USER)

env_init(), env_create(), env_run()这三个函数都没有实现,需要在Exercise2中完成。

Creating and Running Environments

你现在需要编写 kern/env.c 中必要的代码来运行一个用户环境。因为我们还没有文件系统,所以我们将设置内核来加载一个静态的二进制映像,这个映像被嵌入在内核本身中。JOS 将这个二进制映像作为 ELF 可执行映像嵌入到内核中。

实验 3 的 GNUmakefile 在 obj/user/ 目录生成了许多二进制映像。如果你查看 kern/Makefrag 文件,你会注意到一些 "链接" 这些二进制映像直接嵌入内核可执行文件中的特殊处理。链接器命令行中的 -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.c 的 i386_init() 函数中,你会看到运行其中一个二进制映像的代码。然而,用于设置用户环境的关键函数尚未完全编写;你需要补充这部分内容。

Exercise 2.

在文件 env.c 中,完成以下函数的编码:

env_init() 初始化 envs 数组中所有的 Env 结构并将它们添加到 env_free_list 中。还调用 env_init_percpu,该函数配置分段硬件,为特权级别 0(内核)和特权级别 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);

会产生 "env_alloc: out of memory" 的 panic 错误信息。

env_init()

初始化 envs 数组中所有的 Env 结构并将它们添加到 env_free_list 中。还调用 env_init_percpu,该函数配置分段硬件,为特权级别 0(内核)和特权级别 3(用户)设置独立的段。

// Mark all environments in 'envs' as free, set their env_ids to 0,
// and insert them into the env_free_list.
// Make sure the environments are in the free list in the same order
// they are in the envs array (i.e., so that the first call to
// env_alloc() returns envs[0]).
//
void
env_init(void)
{
	// Set up envs array
	// LAB 3: Your code here.
	env_free_list = NULL;
	for (int i = NENV - 1; i >= 0; i--) {	//前插法构建链表
		envs[i].env_id = 0;
		envs[i].env_link = env_free_list;
		env_free_list = &envs[i];
	}

	// Per-CPU part of the initialization
	env_init_percpu();    //加载全局描述符表(GDT)
}

env_init_percpu()

加载全局描述符表并且初始化段寄存器gs, fs, es, ds, ss。GDT定义在kern/env.c中

struct Segdesc gdt[] =
{
	// 0x0 - unused (always faults -- for trapping NULL far pointers)
	SEG_NULL,

	// 0x8 - kernel code segment
	[GD_KT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 0),

	// 0x10 - kernel data segment
	[GD_KD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 0),

	// 0x18 - user code segment
	[GD_UT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 3),

	// 0x20 - user data segment
	[GD_UD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 3),

	// 0x28 - tss, initialized in trap_init_percpu()
	[GD_TSS0 >> 3] = SEG_NULL
};

struct Pseudodesc gdt_pd = {
	sizeof(gdt) - 1, (unsigned long) gdt
};

env_setup_vm()

参数:

  1. struct Env *e:ENV结构指针

返回值:0表示成功,-E_NO_MEM表示失败,没有足够物理地址。

作用:初始化e指向的Env结构代表的用户环境的线性地址空间,设置e->env_pgdir字段。为新环境分配一个页目录,并初始化新环境地址空间的内核部分。

// Initialize the kernel virtual memory layout for environment e.
// Allocate a page directory, set e->env_pgdir accordingly,
// and initialize the kernel portion of the new environment's address space.
// Do NOT (yet) map anything into the user portion
// of the environment's virtual address space.
//
// Returns 0 on success, < 0 on error.  Errors include:
//	-E_NO_MEM if page directory or table could not be allocated.
//
static int
env_setup_vm(struct Env *e)
{
	int i;
	struct PageInfo *p = NULL;

	// Allocate a page for the page directory
	if (!(p = page_alloc(ALLOC_ZERO)))  //分配一个物理页
		return -E_NO_MEM;
// 现在,设置 e->env_pgdir 并初始化页目录。
//
// 提示:
//    - 所有 envs 的虚拟地址空间在 UTOP 以上是相同的
//      (除了在 UVPT 处,我们已经设置了)。
//      参见 inc/memlayout.h 获取权限和布局信息。
//      你能使用 kern_pgdir 作为模板吗?提示:可以。
//      (确保在实验 2 中设置权限正确。)
//    - UTOP 以下的初始虚拟地址是空的。
//    - 你不需要再调用 page_alloc。
//    - 注意:一般来说,对于仅映射在 UTOP 以上的物理页面,
//      pp_ref 不会被维护,但 env_pgdir 是一个例外 —— 你需要增加
//      env_pgdir 的 pp_ref,以使 env_free 正确工作。
//    - kern/pmap.h 中的函数很有用。

	// LAB 3: Your code here.
	p->pp_ref++;// 用于记录当前指向该物理页的指针的数量
	e->env_pgdir = (pde_t *) page2kva(p);  //刚分配的物理页作为页目录使用
	memcpy(e->env_pgdir, kern_pgdir, PGSIZE); //继承内核页目录

	// UVPT maps the env's own page table read-only.
	// Permissions: kernel R, user R
	e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;  
//唯一需要修改的是UVPT需要映射到当前环境的页目录物理地址e->env_pgdir处,而不是内核的页目录物理地址kern_pgdir处

	return 0;
}

总的思路就是给e指向的Env结构分配页目录,并且继承内核的页目录结构,唯一需要修改的是UVPT需要映射到当前环境的页目录物理地址e->env_pgdir处,而不是内核的页目录物理地址kern_pgdir处。设置完页目录也就确定了当前用户环境线性地址空间到物理地址空间的映射。

region_alloc()

参数:

  1. struct Env *e:需要操作的用户环境
  2. void *va:虚拟地址
  3. size_t len:长度

作用:操作e->env_pgdir,为[va, va+len)分配物理空间。

// Allocate len bytes of physical memory for environment env,
// and map it at virtual address va in the environment's address space.
// Does not zero or otherwise initialize the mapped pages in any way.
// Pages should be writable by user and kernel.
// Panic if any allocation attempt fails.
//
static void
region_alloc(struct Env *e, void *va, size_t len)
{
	// LAB 3: Your code here.
	// (But only if you need it for load_icode.)
	//
	// Hint: It is easier to use region_alloc if the caller can pass
	//   'va' and 'len' values that are not page-aligned.
	//   You should round va down, and round (va + len) up.
	//   (Watch out for corner-cases!)
	void *begin = ROUNDDOWN(va, PGSIZE), *end = ROUNDUP(va+len, PGSIZE);
	while (begin < end) {
		struct PageInfo *pg = page_alloc(0); //分配一个物理页
		if (!pg) {
			panic("region_alloc failed\\n");
		}
		page_insert(e->env_pgdir, pg, begin, PTE_W | PTE_U);   //修改e->env_pgdir,建立线性地址begin到物理页pg的映射关系
		begin += PGSIZE;    //更新线性地址
	}
}

总的来说还是用lab2实现的函数操作e->env_pgdir结构。

ROUNDDOWNROUNDUP 是用于对指定数值进行向下或向上取整的宏定义。

  • ROUNDDOWN(va, PGSIZE):这个宏用于将 va 向下取整到最接近的 PGSIZE 的倍数。即,如果 va 不是 PGSIZE 的整数倍,那么它将被调整为最接近的小于 vaPGSIZE 的倍数。例如,如果 va0x1300PGSIZE0x1000,那么 ROUNDDOWN(va, PGSIZE) 将返回 0x1000
  • ROUNDUP(va+len, PGSIZE):这个宏用于将 va+len 向上取整到最接近的 PGSIZE 的倍数。即,它会将 va+len 调整为最接近且大于 va+lenPGSIZE 的倍数。例如,如果 va+len0x1300PGSIZE0x1000,那么 ROUNDUP(va+len, PGSIZE) 将返回 0x2000

load_icode()

参数:

  1. struct Env *e:需要操作的用户环境
  2. uint8_t *binary:可执行用户代码的起始地址

作用:加载binary地址开始处的ELF文件。

static void
load_icode(struct Env *e, uint8_t *binary)
{
    // ELF 格式的头部
    struct Elf *ELFHDR = (struct Elf *) binary;
    struct Proghdr *ph;  // ELF 文件中的程序头部
    int ph_num;          // 程序头部的数量
    // 检查 ELF 文件的魔数,确保这是一个有效的 ELF 文件
    if (ELFHDR->e_magic != ELF_MAGIC) {
        panic("binary is not ELF format\\n");
    }

    // 获取 ELF 文件中第一个程序头部的位置
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
    // 获取程序头部的数量
    ph_num = ELFHDR->e_phnum;

    // 切换到新环境的页目录
    lcr3(PADDR(e->env_pgdir));      
    for (int i = 0; i < ph_num; i++) {
        if (ph[i].p_type == ELF_PROG_LOAD) {
            // 为每个可加载段分配内存并清零
            region_alloc(e, (void *)ph[i].p_va, ph[i].p_memsz);
            memset((void *)ph[i].p_va, 0, ph[i].p_memsz);
            // 从 ELF 文件复制段内容到分配的内存
            memcpy((void *)ph[i].p_va, binary + ph[i].p_offset, ph[i].p_filesz); 
        }
    }

    // 切换回内核的页目录
    lcr3(PADDR(kern_pgdir));
    // 设置环境的入口点为 ELF 文件中指定的入口
    e->env_tf.tf_eip = ELFHDR->e_entry;
    // 为程序的初始栈分配一页内存
    region_alloc(e, (void *) (USTACKTOP - PGSIZE), PGSIZE);
}

这里相当于实现一个ELF可执行文件加载器,不熟悉ELF文件结构的同学可以参考我之前的笔记

ELF格式

ELF文件以一个ELF文件头开始,通过ELFHDR->e_magic字段判断该文件是否是ELF格式的,然后通过ELFHDR->e_phoff获取程序头距离ELF文件的偏移,ph指向的就是程序头的起始位置,相当于一个数组,程序头记录了有哪些Segment需要加载,加载到线性地址的何处?ph_num保存了总共有多少Segment。遍历ph数组,分配线性地址p_va开始的p_memsz大小的空间。并将ELF文件中

binary + ph[i].p_offset

偏移处的Segment拷贝到线性地址p_va处。

有一点需要注意,在执行for循环前,需要加载e->env_pgdir,也就是这句

lcr3(PADDR(e->env_pgdir));

因为我们要将Segment拷贝到用户的线性地址空间内,而不是内核的线性地址空间。

加载完Segment后需要设置

e->env_tf.tf_eip = ELFHDR->e_entry;

也就是程序第一条指令的位置。

最后

region_alloc(e, (void *) (USTACKTOP - PGSIZE), PGSIZE);

为用户环境分配栈空间。

env_create()

参数:

  1. uint8_t *binary:将要加载的可执行文件的起始位置
  2. enum EnvType type:用户环境类型

作用:从env_free_list链表拿一个Env结构,加载从binary地址开始处的ELF可执行文件到该Env结构。

// Allocates a new env with env_alloc, loads the named elf
// binary into it with load_icode, and sets its env_type.
// This function is ONLY called during kernel initialization,
// before running the first user-mode environment.
// The new env's parent ID is set to 0.
//
void
env_create(uint8_t *binary, enum EnvType type)
{
	// LAB 3: Your code here.
	struct Env *e;
	int r;
	if ((r = env_alloc(&e, 0) != 0)) {
		panic("create env failed\\n");
	}

	load_icode(e, binary);
	e->env_type = type;
}

env_alloc()

int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
        int32_t generation;
        int r;
        struct Env *e;

        if (!(e = env_free_list))
                return -E_NO_FREE_ENV;

        // Allocate and set up the page directory for this environment.
        if ((r = env_setup_vm(e)) < 0)
                return r;

        // Generate an env_id for this environment.
        generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
        if (generation <= 0)    // Don't create a negative env_id.
                generation = 1 << ENVGENSHIFT;
        e->env_id = generation | (e - envs);

        // Set the basic status variables.
        e->env_parent_id = parent_id;
        e->env_type = ENV_TYPE_USER;
        e->env_status = ENV_RUNNABLE;
        e->env_runs = 0;

        // Clear out all the saved register state,
        // to prevent the register values
        // of a prior environment inhabiting this Env structure
        // from "leaking" into our new environment.
        memset(&e->env_tf, 0, sizeof(e->env_tf));

        // Set up appropriate initial values for the segment registers.
        // GD_UD is the user data segment selector in the GDT, and
        // GD_UT is the user text segment selector (see inc/memlayout.h).
        // The low 2 bits of each segment register contains the
        // Requestor Privilege Level (RPL); 3 means user mode.  When
        // we switch privilege levels, the hardware does various
        // checks involving the RPL and the Descriptor Privilege Level
        // (DPL) stored in the descriptors themselves.
        e->env_tf.tf_ds = GD_UD | 3;
        e->env_tf.tf_es = GD_UD | 3;
        e->env_tf.tf_ss = GD_UD | 3;
        e->env_tf.tf_esp = USTACKTOP;
        e->env_tf.tf_cs = GD_UT | 3;
        // You will set e->env_tf.tf_eip later.

        // commit the allocation
        env_free_list = e->env_link;
        *newenv_store = e;

        cprintf("[%08x] new env %08x\\n", curenv ? curenv->env_id : 0, e->env_id);
        return 0;
}

这个函数的作用是为一个新的环境(struct Env 结构)进行分配和初始化,并设置其基本的状态变量和寄存器值。

具体的步骤包括:

  1. 检查环境列表中是否有空闲的环境,如果没有则返回错误码 E_NO_FREE_ENV
  2. 为新环境分配并设置页目录(通过调用 env_setup_vm(e) 函数)。
  3. 生成一个独特的环境标识符 env_id,确保其为正值。
  4. 设置环境的基本状态变量,如父环境的标识符 (env_parent_id)、环境类型 (env_type)、环境状态 (env_status) 和运行次数 (env_runs)。
  5. 清除保存的寄存器状态,以防止之前使用这个 struct Env 结构的环境的寄存器值“泄漏”到新环境中。
  6. 为段寄存器设置适当的初始值,这些寄存器包括数据段寄存器(tf_dstf_estf_ss)、栈指针寄存器(tf_esp)和代码段寄存器(tf_cs)。这些设置确保新环境在用户模式下运行。

最后,函数会将新分配的环境从空闲环境列表中移除,并将其指针存储在 newenv_store 指向的地址中,并返回成功分配的标志 0。同时,函数还会打印出新环境的信息,如其 env_id

env_run()

参数:

  1. struct Env *e:需要执行的用户环境

作用:执行e指向的用户环境

// Context switch from curenv to env e.
// Note: if this is the first call to env_run, curenv is NULL.
//
// This function does not return.
//
void
env_run(struct Env *e)
{
	// Step 1: If this is a context switch (a new environment is running):
	//	   1. Set the current environment (if any) back to
	//	      ENV_RUNNABLE if it is ENV_RUNNING (think about
	//	      what other states it can be in),
	//	   2. Set 'curenv' to the new environment,
	//	   3. Set its status to ENV_RUNNING,
	//	   4. Update its 'env_runs' counter,
	//	   5. Use lcr3() to switch to its address space.
	// Step 2: Use env_pop_tf() to restore the environment's
	//	   registers and drop into user mode in the
	//	   environment.

	// Hint: This function loads the new environment's state from
	//	e->env_tf.  Go back through the code you wrote above
	//	and make sure you have set the relevant parts of
	//	e->env_tf to sensible values.

	// LAB 3: Your code here.
	if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
		curenv->env_status = ENV_RUNNABLE;
	}
	curenv = e;
	e->env_status = ENV_RUNNING;
	e->env_runs++;
	lcr3(PADDR(e->env_pgdir));    //加载线性地址空间
	env_pop_tf(&e->env_tf);       //弹出env_tf结构到寄存器
}

该函数首先设置curenv,然后修改e->env_status,e->env_runs两个字段。

接着加载线性地址空间,最后将e->env_tf结构中的寄存器快照弹出到寄存器,这样就会从新的%eip地址处读取指令进行解析。

  • Trapframe结构和env_pop_tf()函数如下

    struct PushRegs {
    	/* registers as pushed by pusha */
    	uint32_t reg_edi;
    	uint32_t reg_esi;
    	uint32_t reg_ebp;
    	uint32_t reg_oesp;		/* Useless */
    	uint32_t reg_ebx;
    	uint32_t reg_edx;
    	uint32_t reg_ecx;
    	uint32_t reg_eax;
    } __attribute__((packed));
    
    struct Trapframe {
    	struct PushRegs tf_regs;
    	uint16_t tf_es;
    	uint16_t tf_padding1;
    	uint16_t tf_ds;
    	uint16_t tf_padding2;
    	uint32_t tf_trapno;
    	/* below here defined by x86 hardware */
    	uint32_t tf_err;
    	uintptr_t tf_eip;
    	uint16_t tf_cs;
    	uint16_t tf_padding3;
    	uint32_t tf_eflags;
    	/* below here only when crossing rings, such as from user to kernel */
    	uintptr_t tf_esp;
    	uint16_t tf_ss;
    	uint16_t tf_padding4;
    } __attribute__((packed));
    
    // Restores the register values in the Trapframe with the 'iret' instruction.
    // This exits the kernel and starts executing some environment's code.
    //
    // This function does not return.
    //
    void
    env_pop_tf(struct Trapframe *tf)
    {
    	asm volatile(
    		"\\tmovl %0,%%esp\\n"				//将%esp指向tf地址处
    		"\\tpopal\\n"						//弹出Trapframe结构中的tf_regs值到通用寄存器
    		"\\tpopl %%es\\n"					//弹出Trapframe结构中的tf_es值到%es寄存器
    		"\\tpopl %%ds\\n"					//弹出Trapframe结构中的tf_ds值到%ds寄存器
    		"\\taddl $0x8,%%esp\\n" /* skip tf_trapno and tf_errcode */
    		"\\tiret\\n"						//中断返回指令,具体动作如下:从Trapframe结构中依次弹出tf_eip,tf_cs,tf_eflags,tf_esp,tf_ss到相应寄存器
    		: : "g" (tf) : "memory");
    	panic("iret failed");  /* mostly to placate the compiler */
    }
    

    PushRegs结构保存的正是通用寄存器的值,env_pop_tf()第一条指令,将%esp指向tf地址处,也就是将栈顶指向Trapframe结构开始处,Trapframe结构开始处正是一个PushRegs结构,popalPushRegs结构中保存的通用寄存器值弹出到寄存器中,接着按顺序弹出寄存器%es, %ds。最后执行iret指令,该指令是中断返回指令,具体动作如下:从Trapframe结构中依次弹出tf_eip,tf_cs,tf_eflags,tf_esp,tf_ss到相应寄存器。你会发现和Trapframe结构从上往下是完全一致的。

函数的调用关系

env_create()//从env_free_list链表拿一个Env结构,加载从binary地址开始处的ELF可执行文件到该Env结构。
    -->env_alloc()//为一个新的环境(struct Env 结构)进行分配和初始化,并设置其基本的状态变量和寄存器值
        -->env_setup_vm()//初始化e指向的Env结构代表的用户环境的线性地址空间,设置e->env_pgdir字段。为新环境分配一个页目录,并初始化新环境地址空间的内核部分。
    -->load_icode()//加载binary地址开始处的ELF文件。
        -->region_alloc()//操作e->env_pgdir,为[va, va+len)分配物理空间

下面是到用户代码被调用的代码调用图。确保你理解每个步骤的目的。

  • start (kern/entry.S)
  • i386_init (kern/init.c)
    • cons_init
    • mem_init
    • env_init
    • trap_init (still incomplete at this point)
    • env_create
    • env_run
      • env_pop_tf

完成后,您应该编译内核并在 QEMU 下运行它。如果一切顺利,您的系统应该进入用户空间并执行 hello 二进制文件,直到它使用 int 指令进行系统调用。在这一点上可能会出现问题,因为 JOS 尚未设置硬件来允许从用户空间进入内核的任何类型的过渡。当 CPU 发现它没有设置来处理这个系统调用中断时,它会生成一个通用保护异常(general protection exception),然后发现它不能处理这个异常,随后生成一个双重故障异常(double fault exception),再次发现无法处理,最终放弃并导致发生所谓的“三重故障”(triple fault)。通常情况下,然后你会看到 CPU 重置并系统重新启动。虽然这对于旧版应用程序很重要(请参阅这篇博文以了解原因),但对于内核开发来说很麻烦,因此在带有 6.828 修补的 QEMU 中,你将看到一个寄存器转储和一个 "Triple fault." 消息。

我们将很快解决这个问题,但现在我们可以使用调试器来检查我们是否进入了用户模式。使用 make qemu-gdb 并在 env_pop_tf 处设置一个 GDB 断点,这应该是在实际进入用户模式之前最后执行的函数。使用 si 逐步执行此函数;在 iret 指令之后,处理器应该进入用户模式。然后,您应该看到用户环境可执行代码中的第一条指令,即位于 lib/entry.S 中标签 start 处的 cmpl 指令。现在,使用 b *0x... 在 hello 的 sys_cputs() 函数中设置一个断点,这个断点位于 int $0x30 的位置(参见 obj/user/hello.asm 获取用户空间地址)。这个 int 是向控制台显示字符的系统调用。如果您无法执行到 int,那么您的地址空间设置或程序加载代码可能出了问题;在继续之前,请返回并修复它。

Handling Interrupts and Exceptions

到了这一步,用户空间中第一个 int $0x30 系统调用指令是个死胡同:一旦处理器进入用户模式,就没有办法再回到内核模式。现在您需要实现基本的异常和系统调用处理,以便内核可以从用户模式代码中恢复对处理器的控制。首先,您应该彻底熟悉 x86 中断和异常机制。

Exercise 3. Read Chapter 9, Exceptions and Interrupts in the 80386 Programmer's Manual (or Chapter 5 of the IA-32 Developer's Manual), if you haven't already.

在这个实验中,我们通常遵循英特尔关于中断、异常等方面的术语。然而,“异常”、“陷阱”、“中断”、“故障”和“中止”等术语在各种架构或操作系统中并没有标准的含义,通常在某个特定架构(如 x86)中它们之间的微妙差异会被忽略。当你在这个实验之外看到这些术语时,它们的含义可能会略有不同。

Basics of Protected Control Transfer

异常和中断都是“受保护的控制转移”,它们会导致处理器从用户模式切换到内核模式(CPL=0),而不给用户模式代码任何机会来干扰内核或其他环境的运行。在英特尔的术语中,中断是由通常是处理器外部的异步事件引起的受保护的控制转移,例如外部设备 I/O 活动的通知。相比之下,异常是由当前正在运行的代码同步引起的受保护的控制转移,例如由于除以零或无效内存访问。

为了确保这些受保护的控制转移确实是受保护的,处理器的中断/异常机制被设计成使得当中断或异常发生时,当前正在运行的代码无法任意选择内核的进入点或方式。相反,处理器确保内核只能在受到严格控制的条件下进入。在 x86 上,有两种机制共同提供这种保护:

中断描述符表。

处理器确保中断和异常只能导致内核进入到由内核自身确定的少数特定、明确定义的入口点,而不是由中断或异常被接收时运行的代码确定。

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

  • 要加载到指令指针(EIP)寄存器的值,指向被指定用于处理该类型异常的内核代码。
  • 要加载到代码段(CS)寄存器的值,其中位0-1包含异常处理程序应该运行的特权级别。(在 JOS 中,所有异常都在内核模式下处理,特权级别为0。)

任务状态段(TSS)。

处理器需要一个地方来保存中断或异常发生之前的旧处理器状态,例如处理器调用异常处理程序之前的EIP和CS的原始值,以便异常处理程序稍后可以恢复那个旧状态,并从中断的位置继续中断的代码。但是,这个保存旧处理器状态的区域必须受到保护,不受特权级别较低的用户模式代码的影响;否则,有错误或恶意的用户代码可能会危及内核。

因此,当x86处理器接收到一个导致从用户模式到内核模式特权级别变化的中断或陷阱时,它也会切换到内核内存中的一个堆栈。一个叫做任务状态段(TSS)的结构指定了这个堆栈所在的段选择器和地址。处理器在这个新堆栈上推入(SS、ESP、EFLAGS、CS、EIP和可选的错误代码)。然后,它从中断描述符加载CS和EIP,并将ESP和SS设置为引用新的堆栈。

尽管TSS很大,可以潜在地服务于各种目的,但JOS仅用它来定义处理器在从用户模式切换到内核模式时应该切换到的内核堆栈。由于在JOS中,“内核模式”是x86上的特权级别0,处理器使用TSS的ESP0和SS0字段来定义进入内核模式时的内核堆栈。JOS不使用任何其他TSS字段。

  • CPU的各种寄存器

    1. TSS选择器就是刚才用ltr指令设置的。中断发生时,自动通过该寄存器找到TSS结构(JOS中是ts这个变量),将栈寄存器SS和ESP分别设置为其中的SS0和ESP0两个字段的值,这样栈就切换到了内核栈。
    2. GDTR就是全局描述符表寄存器,之前已经设置过了。
    3. PDBR是页目录基址寄存器,通过该寄存器找到页目录和页表,将虚拟地址映射为物理地址。
    4. IDTR是中断描述符表寄存器,通过这个寄存器中的值可以找到中断表。

Types of Exceptions and Interrupts

x86处理器内部可以生成的所有同步异常都使用0到31之间的中断向量,因此映射到IDT的条目0到31。例如,页面错误总是通过向量14引发异常。大于31的中断向量仅由软件中断使用,可以由int指令生成,或者由外部设备引起的异步硬件中断,当它们需要处理时会触发。

在这一部分,我们将扩展JOS以处理在0到31向量中生成的x86内部异常。在下一部分中,我们将让JOS处理软件中断向量48(0x30),JOS(相当随意地)将其用作系统调用中断向量。在第4实验中,我们将扩展JOS以处理外部生成的硬件中断,如时钟中断。

0-31号中断都是同步中断,缺页中断就是14号,31号以上的中断可以由int指令,或者外部设备触发。在JOS中,将用48号中断作为系统调用中断。

An Example

假设处理器正在执行代码,这时遇到一条除法指令尝试除以0,处理器将会做如下动作:

  1. 将栈切换到TSS的SS0和ESP0字段定义的内核栈中,在JOS中两个值分别是GD_KD和KSTACKTOP。
  2. 处理器在内核栈中压入如下参数:
                     +--------------------+ KSTACKTOP             
                     | 0x00000 | old SS   |     " - 4
                     |      old ESP       |     " - 8
                     |     old EFLAGS     |     " - 12
                     | 0x00000 | old CS   |     " - 16
                     |      old EIP       |     " - 20 <---- ESP 
                     +--------------------+
  1. 除以0的异常中断号是0,处理器读取IDT的第0项,从中解析出CS:EIP。

4.CS:EIP处的异常处理函数执行。对于一些异常来说,除了压入上图五个word,还会压入错误代码,如下所示:

                     +--------------------+ KSTACKTOP             
                     | 0x00000 | old SS   |     " - 4
                     |      old ESP       |     " - 8
                     |     old EFLAGS     |     " - 12
                     | 0x00000 | old CS   |     " - 16
                     |      old EIP       |     " - 20
                     |     error code     |     " - 24 <---- ESP
                     +--------------------+

仔细观察压入的数据和Trapframe结构,你会发现是一致的。

  • 更详细地解释

    当处理器执行代码并遇到一条尝试除以零的除法指令时,将触发一个异常,操作系统通过中断和异常处理机制来响应。在 JOS(一个教学用的简易操作系统)中,这个过程遵循 x86 架构的中断和异常处理规范。下面是详细解释:

    1. 栈切换到内核栈:
      • 当用户模式代码触发异常时,处理器自动切换到内核模式,并切换到内核栈。这是通过任务状态段(TSS)中的 SS0ESP0 字段实现的,它们分别定义了内核栈段选择器和内核栈的顶部地址。在 JOS 中,这些通常设置为 GD_KD(内核数据段选择器)和 KSTACKTOP(内核栈顶部地址)。
    2. 处理器压栈操作:
      • 处理器自动将当前环境的上下文保存到内核栈中。这包括当前的栈段选择器(SS)、栈指针(ESP)、标志寄存器(EFLAGS)、代码段选择器(CS)和指令指针(EIP)。这些值代表了异常发生时的处理器状态。
    3. 异常中断号和IDT解析:
      • 除以零异常对应的中断号是0。处理器使用这个中断号来访问中断描述符表(IDT),找到相应的中断或异常处理函数的地址。
      • 从 IDT 的第0项中,处理器解析出异常处理函数的代码段选择器(CS)和指令指针(EIP)。
    4. 异常处理函数执行:
      • 一旦处理器从 IDT 中获取了异常处理函数的地址,它就开始执行该函数。这个函数负责处理除以零的异常。
    5. 错误代码的压栈(如果有的话):
      • 对于某些异常,处理器还会在栈中压入一个错误代码。除以零的异常(中断号0)通常不会生成错误代码,但其他一些异常(如页面错误)会生成。
    6. Trapframe结构的一致性:
      • 当你检查 JOS 中 Trapframe 结构时,会发现它与处理器在异常发生时压入内核栈的上下文格式相匹配。这意味着 Trapframe 可以用来访问和处理保存在栈中的上下文信息,例如在异常处理完毕后恢复到先前的状态。

    总的来说,这个过程是 x86 处理器响应异常的标准方式,操作系统通过设置 TSS 和 IDT 来协助这个过程,并通过定义与处理器压栈格式相匹配的 Trapframe 结构来方便地处理异常。

Nested Exceptions and Interrupts 嵌套异常和中断

处理器可以从内核模式和用户模式同时接收异常和中断。然而,仅当从用户模式进入内核时,x86处理器在将旧的寄存器状态推送到堆栈并通过IDT调用适当的异常处理程序之前会自动切换堆栈。如果处理器在中断或异常发生时已经处于内核模式(CS寄存器的低2位已经为零),那么CPU将在同一个内核堆栈上推送更多的值。通过这种方式,内核可以优雅地处理由内核代码引起的嵌套异常。这种能力是在实现保护方面的重要工具,后面我们会在系统调用的部分中看到。

“CPU将在同一个内核堆栈上推送更多的值”是指当处理器在内核模式下发生中断或异常时,如果它已经处于内核模式并且需要处理嵌套的中断或异常,它会将新的寄存器状态(如EIP、CS等)压入同一个内核堆栈,而不是切换到一个新的堆栈。这意味着内核将在同一个堆栈上存储多个中断或异常的信息,以便在处理内核代码中的嵌套中断或异常时,能够有效地管理和处理这些信息。

如果处理器已经处于内核模式并发生了嵌套异常,由于它不需要切换堆栈,因此不会保存旧的SS或ESP寄存器。因此,在进入异常处理程序时,对于不推送错误代码的异常类型,内核堆栈看起来像是这样:

                 +--------------------+ <---- 旧ESP
                 |     旧EFLAGS       |     " - 4
                 | 0x00000 | 旧CS    |     " - 8
                 |      旧EIP        |     " - 12
                 +--------------------+

对于推送错误代码的异常类型,处理器将错误代码立即推送到旧的EIP之后,与之前相同。

处理器嵌套异常能力的一个重要注意事项是,如果处理器在内核模式下发生异常,由于缺少堆栈空间等任何原因,无法将其旧状态推送到内核堆栈上,则处理器无法恢复,因此它会简单地重置自身。毫无疑问,内核应该设计得不会发生这种情况。

Setting Up the IDT

你现在应该已经获得了设置IDT和处理JOS中的异常所需的基本信息。目前,你将设置IDT来处理中断向量0-31(处理器异常)。我们稍后在本实验室中处理系统调用中断,并在以后的实验室中添加中断向量32-47(设备IRQ)。

头文件 inc/trap.h 和 kern/trap.h 包含与中断和异常相关的重要定义,你需要熟悉这些定义。文件 kern/trap.h 包含严格私有于内核的定义,而 inc/trap.h 包含对用户级程序和库也可能有用的定义。

注意:范围在0-31之间的一些异常由Intel定义为保留。由于处理器永远不会生成它们,你处理它们的方式并不重要。可以按照你认为最合适的方式处理。

下面是控制流的整体流程示意图:

IDT                   trapentry.S         trap.c
   
+----------------+                        
|   &handler1    |---------> handler1:          trap (struct Trapframe *tf)
|                |             // do stuff      {
|                |             call trap          // handle the exception/interrupt
|                |             // ...           }
+----------------+
|   &handler2    |--------> handler2:
|                |            // do stuff
|                |            call trap
|                |            // ...
+----------------+
       .
       .
       .
+----------------+
|   &handlerX    |--------> handlerX:
|                |             // do stuff
|                |             call trap
|                |             // ...
+----------------+
  • 介绍

    这一段描述了一个操作系统(例如 JOS)中的中断和异常处理机制的概览,特别是中断描述符表(IDT)的设置和使用,以及中断/异常处理函数的组织方式。下面是每个部分的详细解释:

    IDT(中断描述符表)

    • IDT 是操作系统中一个关键的数据结构,用于定义中断和异常处理函数的入口点。每个条目对应一个中断或异常向量(编号),指向特定的处理函数。
    • 在展示的结构中,IDT 包含了多个条目,如 &handler1, &handler2, ..., &handlerX。每个条目包含了一个处理函数的地址。

    trapentry.S

    • trapentry.S 是一个汇编文件,包含了中断和异常处理函数的入口点。对于每种中断或异常,都有一个对应的处理函数入口点,如 handler1, handler2, ..., handlerX
    • 这些处理函数通常以汇编语言编写,其主要任务是保存处理器的状态(例如寄存器值),然后调用一个更通用的处理函数(通常是 C 语言编写的),并传递一个包含了保存状态的 Trapframe 结构的指针。

    trap.c

    • trap.c 是一个 C 语言文件,包含了 trap 函数,这是一个更高级别的异常/中断处理函数。
    • 当处理器遇到中断或异常时,经过 trapentry.S 中的处理函数入口点,处理器的状态被保存在 Trapframe 结构中,然后 trap 函数被调用。
    • trap 函数接收一个指向 Trapframe 的指针作为参数,这允许它访问在异常/中断发生时处理器的状态。这个函数负责确定中断或异常的类型,并执行相应的处理逻辑。

    总结

    整个流程如下:

    1. 当发生中断或异常时,处理器根据中断向量号查找 IDT,找到对应的处理函数入口点(如 handler1)。
    2. handler1(在 trapentry.S 中定义)执行,保存处理器状态,并调用 trap 函数。
    3. trap 函数(在 trap.c 中定义)接收一个 Trapframe 参数,它包含了发生异常时的处理器状态。trap 函数根据这些信息处理异常或中断。

    这种设计使得中断和异常处理既高效又灵活,因为它结合了汇编语言(用于低级处理和状态保存)和 C 语言(用于复杂的逻辑处理)。

每个异常或中断都应在 trapentry.S(在kern下面) 中有自己的处理函数,并且 trap_init() 应该使用这些处理函数的地址来初始化 IDT。每个处理函数都应在堆栈上构建一个结构体 Trapframe(参见 inc/trap.h),并使用指向 Trapframe 的指针调用 trap()(在 trap.c 中)。trap() 然后处理异常/中断或者分派到特定的处理函数。

Exercise 4.

编辑 trapentry.S 和 trap.c 并实现上述描述的功能。trapentry.S 中的宏 TRAPHANDLER 和 TRAPHANDLER_NOEC 以及 inc/trap.h 中的 T_* 定义将对您有所帮助。您需要为 inc/trap.h 中定义的每个陷阱在 trapentry.S 中添加一个入口点(使用这些宏),并且必须提供 _alltraps,TRAPHANDLER 宏会引用它。您还需要修改 trap_init(),将 idt 初始化为指向 trapentry.S 中定义的每个入口点;在这里使用 SETGATE 宏将很有帮助。

您的 _alltraps 应该:

  • 推送值,使堆栈看起来像一个 struct Trapframe 结构
  • 将 GD_KD 加载到 %ds 和 %es 寄存器中
  • pushl %esp 以将指向 Trapframe 的指针作为参数传递给 trap()
  • 调用 trap (trap 是否会返回?) 考虑使用 pushal 指令;它与 struct Trapframe 的布局相吻合。

使用 user 目录中会在执行任何系统调用之前引发异常的一些测试程序来测试您的陷阱处理代码,例如 user/divzero。您应该能够让 make grade 成功通过 divzero、softint 和 badsegment 测试。

trapentry.S

#define TRAPHANDLER(name, num)                      \\
    .globl name;        /* define global symbol for 'name' */   \\
    .type name, @function;  /* symbol type is function */       \\
    .align 2;       /* align function definition */     \\
    name:           /* function starts here */      \\
    pushl $(num);                           \\
    jmp _alltraps
/* Use TRAPHANDLER_NOEC for traps where the CPU doesn't push an error code.
* It pushes a 0 in place of the error code, so the trap frame has the same
* format in either case.
*/
#define TRAPHANDLER_NOEC(name, num)                 \\
    .globl name;                            \\
    .type name, @function;                      \\
    .align 2;                           \\
    name:                               \\
    pushl $0;                           \\
    pushl $(num);                           \\
    jmp _alltraps
.text

/*
 * Lab 3: Your code here for generating entry points for the different traps.
 */
	TRAPHANDLER_NOEC(th0, 0)
	TRAPHANDLER_NOEC(th1, 1)
	TRAPHANDLER_NOEC(th3, 3)
	TRAPHANDLER_NOEC(th4, 4)
	TRAPHANDLER_NOEC(th5, 5)
	TRAPHANDLER_NOEC(th6, 6)
	TRAPHANDLER_NOEC(th7, 7)
	TRAPHANDLER(th8, 8)
	TRAPHANDLER_NOEC(th9, 9)
	TRAPHANDLER(th10, 10)
	TRAPHANDLER(th11, 11)
	TRAPHANDLER(th12, 12)
	TRAPHANDLER(th13, 13)
	TRAPHANDLER(th14, 14)
	TRAPHANDLER_NOEC(th16, 16)

	TRAPHANDLER_NOEC(th_syscall, T_SYSCALL)

/*
 * Lab 3: Your code here for _alltraps
 */
	//参考inc/trap.h中的Trapframe结构。tf_ss,tf_esp,tf_eflags,tf_cs,tf_eip,tf_err在中断发生时由处理器压入,所以现在只需要压入剩下寄存器(%ds,%es,通用寄存器)
	//切换到内核数据段
	_alltraps:
	pushl %ds
	pushl %es
	pushal
	pushl $GD_KD
	popl %ds
	pushl $GD_KD
	popl %es
	pushl %esp	//压入trap()的参数tf,%esp指向Trapframe结构的起始地址
	call trap       //调用trap()函数
  • TRAPHANDLER_NOEC(th0, 0)宏的作用

    TRAPHANDLER_NOEC 宏用于在 trapentry.S 文件中定义一个异常处理程序的入口点,该入口点对应于给定的异常向量。在这里,th0 是该异常处理程序的名称,0 是异常的向量号。

    TRAPHANDLER_NOEC 宏与 TRAPHANDLER 类似,不同之处在于它处理的是没有错误码的异常。在处理异常时,有些异常会产生错误码,而有些则不会。TRAPHANDLER_NOEC 适用于不需要错误码的异常情况,它创建一个入口点,并将执行的代码与异常处理程序相关联。

我们使用TRAPHANDLER和TRAPHANDLER_NOEC宏创建0~16号中断的中断处理函数。TRAPHANDLER和TRAPHANDLER_NOEC创建的函数都会跳转到_alltraps处,这里参考inc/trap.h中的Trapframe结构,tf_ss,tf_esp,tf_eflags,tf_cs,tf_eip,tf_err在中断发生时由处理器压入,所以现在只需要压入剩下寄存器(%ds,%es,通用寄存器)。然后将%esp压入栈中(也就是压入trap()的参数tf),这里不明白的同学回顾下lab1函数调用的过程。最后跳转到trap()函数执行。

trap_init()

#define SETGATE(gate, istrap, sel, off, dpl)            \\
{                               \\
    (gate).gd_off_15_0 = (uint32_t) (off) & 0xffff;     \\
    (gate).gd_sel = (sel);                  \\
    (gate).gd_args = 0;                 \\
    (gate).gd_rsv1 = 0;                 \\
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    \\
    (gate).gd_s = 0;                    \\
    (gate).gd_dpl = (dpl);                  \\
    (gate).gd_p = 1;                    \\
    (gate).gd_off_31_16 = (uint32_t) (off) >> 16;       \\
}

void
trap_init(void)
{
	extern struct Segdesc gdt[];

	// LAB 3: Your code here.
	void th0();
	void th1();
	void th3();
	void th4();
	void th5();
	void th6();
	void th7();
	void th8();
	void th9();
	void th10();
	void th11();
	void th12();
	void th13();
	void th14();
	void th16();
	void th_syscall();
	SETGATE(idt[0], 0, GD_KT, th0, 0);		//格式如下:SETGATE(gate, istrap, sel, off, dpl),定义在inc/mmu.h中
	SETGATE(idt[1], 0, GD_KT, th1, 0);  //设置idt[1],段选择子为内核代码段,段内偏移为th1
	SETGATE(idt[3], 0, GD_KT, th3, 3);
	SETGATE(idt[4], 0, GD_KT, th4, 0);
	SETGATE(idt[5], 0, GD_KT, th5, 0);
	SETGATE(idt[6], 0, GD_KT, th6, 0);
	SETGATE(idt[7], 0, GD_KT, th7, 0);
	SETGATE(idt[8], 0, GD_KT, th8, 0);
	SETGATE(idt[9], 0, GD_KT, th9, 0);
	SETGATE(idt[10], 0, GD_KT, th10, 0);
	SETGATE(idt[11], 0, GD_KT, th11, 0);
	SETGATE(idt[12], 0, GD_KT, th12, 0);
	SETGATE(idt[13], 0, GD_KT, th13, 0);
	SETGATE(idt[14], 0, GD_KT, th14, 0);
	SETGATE(idt[16], 0, GD_KT, th16, 0);

	SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);		//为什么门的DPL要定义为3,参考《x86汇编语言-从实模式到保护模式》p345

	// Per-CPU setup 
	trap_init_percpu();
}

该函数会在进入内核时由i386_init()调用。我们添加的代码就是建立IDT,trap_init_percpu()中的lidt(&idt_pd);正式加载IDT。

挑战!您现在可能有很多非常相似的代码,在 trapentry.S 中的 TRAPHANDLER 列表和它们在 trap.c 中的安装之间。清理这些代码。修改 trapentry.S 中的宏以自动生成 trap.c 中使用的表格。请注意,您可以通过使用 .text 和 .data 指令在汇编程序中切换代码和数据。

Questions

为每个异常/中断单独设置处理函数的目的是什么?(即,如果所有异常/中断都发送到同一个处理程序,当前实现中存在的哪个功能将无法提供?) 为使用户/softint 程序正常工作,你是否需要进行任何操作?grade 脚本期望它产生一般保护错误(trap 13),但 softint 的代码中使用的是 int $14。为什么这会产生中断向量 13?如果内核实际允许 softint 的 int $14 指令调用内核的页错误处理程序(这是中断向量 14),会发生什么?

为每个异常/中断单独设置处理函数的目的是提供针对特定异常类型的精细控制和处理。如果所有异常/中断都发送到同一个处理程序,那么无法提供根据异常类型进行个性化处理的能力。每个异常/中断可能需要不同的处理方式和行为,因此,使用单独的处理函数可以更好地处理每种情况,允许内核针对不同的中断类型采取适当的措施。

在x86架构中,当用户空间执行**int指令时,将触发一个中断。int指令的操作数即为中断号,这个中断号会被映射到中断描述符表(IDT)中的一个条目。根据x86处理器的规则,当执行int**指令时,中断向量号(即中断号乘以8)与IDT中的相应条目相关联。

softint 程序中使用的是 int $14 指令,它代表一个软件中断,并且 14 * 8 = 112,这意味着它会映射到中断描述符表的第112个条目。然而,根据x86处理器的规则,软件中断会映射到IDT中的相应条目,而 softint 中的 int $14 实际上是引发了一个中断向量 13(112对应的实际中断向量)。

在JOS内核中,中断向量 13 对应一般保护错误(General Protection Fault),这是一个意料之中的异常类型,与 softint 程序中使用 int $14 所期望的保护错误相符合。因此,JOS内核应该能够正确地处理软件中断向量 13,而不是错误地将其解释为中断向量 14,这样才能按照预期对软件中断进行处理。

Part B: Page Faults, Breakpoints Exceptions, and System Calls

现在您的内核具有基本的异常处理功能,您将对其进行改进,以提供依赖于异常处理的重要操作系统基元。

Handling Page Faults

页错误异常,中断向量14(T_PGFLT),是我们在本实验和下一个实验中将大量使用的一个特别重要的异常。当处理器发生页面错误时,它将导致错误的线性(即虚拟)地址存储在一个特殊的处理器控制寄存器CR2中。在trap.c文件中,我们提供了一个名为page_fault_handler()的特殊函数的开头,用于处理页错误异常。

Exercise 5.

修改trap_dispatch() 函数,将页面错误异常分派给page_fault_handler() 函数。现在,您应该能够使make gradefaultreadfaultreadkernelfaultwritefaultwritekernel 测试中成功运行。如果其中任何一个测试未能成功,请找出原因并修复。请记住,您可以使用 make run-xmake run-x-nox 将 JOS 引导到特定的用户程序。例如,make run-hello-nox 运行名为 hello 的用户程序。

// LAB 3: Your code here.
	if (tf->tf_trapno == T_PGFLT) {
		page_fault_handler(tf);
		return;
	}

The Breakpoint Exception

断点异常,中断向量3(T_BRKPT),通常用于允许调试器通过临时替换相关程序指令为特殊的1字节int3软件中断指令,在程序代码中插入断点。在 JOS 中,我们会稍微滥用这个异常,将其转变为一个基本的伪系统调用,任何用户环境都可以使用它来调用 JOS 内核监视器。如果我们将 JOS 内核监视器视为基本调试器,这种用法实际上是相当合适的。例如,lib/panic.c 中 panic() 的用户模式实现在显示其 panic 消息后执行 int3。

Exercise 6.修改 trap_dispatch() 函数,使断点异常调用内核监视器。现在,您应该能够使 make grade 在断点测试中成功运行。

if (tf->tf_trapno == T_BRKPT) {
		monitor(tf);
		return;
	}

Challenge!

挑战!修改 JOS 内核监视器,以便您可以从当前位置“继续”执行(例如,在通过断点异常调用内核监视器后),并且可以逐步执行一条指令。您需要了解 EFLAGS 寄存器的某些位,以便实现单步执行。

可选:如果您感到非常有冒险精神,可以找一些 x86 反汇编器源代码 - 例如,从 QEMU 中提取,或者从 GNU binutils 中提取,或者自己编写 - 并扩展 JOS 内核监视器,以便在逐步执行指令时能够反汇编和显示它们。结合实验 1 中的符号表加载,这就是真正的内核调试器所需的内容。

断点测试用例将根据您在 IDT 中初始化断点条目的方式(即,在 trap_init 中调用 SETGATE)而生成断点异常或通用保护故障。为什么会这样?您需要如何设置它才能使断点异常按上述规定工作,以及哪种不正确的设置会导致触发通用保护故障? 在考虑用户/softint测试程序的情况下,您认为这些机制的目的是什么?

System calls

用户进程通过调用系统调用向内核请求执行某些操作。当用户进程调用系统调用时,处理器进入内核模式,处理器和内核协同工作以保存用户进程的状态,内核执行适当的代码来执行系统调用,然后恢复用户进程。用户进程如何引起内核关注以及如何指定要执行的调用的确切细节因系统而异。

在 JOS 内核中,我们将使用 int 指令来引发处理器中断。特别地,我们将使用 int $0x30 作为系统调用中断。我们已经为您定义了常量 T_SYSCALL 为 48(0x30)。您需要设置中断描述符以允许用户进程引发该中断。需要注意的是,中断0x30无法由硬件生成,因此允许用户代码生成它不会产生歧义。

应用程序将在寄存器中传递系统调用号和系统调用参数。这样,内核就不需要在用户环境的堆栈或指令流中进行搜索。系统调用号将放在 %eax 中,而参数(最多五个)将分别放在 %edx%ecx%ebx%edi%esi 中。内核通过 %eax 将返回值传回。用于调用系统调用的汇编代码已经为您编写在 lib/syscall.c 中的 syscall() 函数中。您应该阅读该函数并确保理解其执行的操作。

这段文字描述了用户进程如何通过系统调用请求内核执行特定操作的机制。当用户进程需要内核执行某些特权操作时,它会触发一个系统调用。在JOS内核中,使用**int指令来触发一个处理器中断(即系统调用中断),使用的中断号是0x30**。这个特殊的中断不会由硬件触发,因此可以安全地用于用户代码的系统调用请求。

为了执行系统调用,用户进程需要将系统调用号和参数放入指定的寄存器中。系统调用号存放在 %eax 中,而最多五个参数则分别存放在 %edx%ecx%ebx%edi%esi 中。内核在执行完系统调用后,将返回值存放在 %eax 中,供用户进程使用。 对上面那段话的解释

Exercise 7.

练习 7. 在内核中为中断向量 T_SYSCALL 添加一个处理程序。您需要编辑 kern/trapentry.Skern/trap.c 中的 trap_init()。还需要更改 trap_dispatch() 函数,通过使用适当的参数调用 syscall()(在 kern/syscall.c 中定义),然后安排将返回值传递回用户进程的 %eax 中。最后,您需要在 kern/syscall.c 中实现 syscall()。确保如果系统调用号无效,则 syscall() 返回 -E_INVAL。为了确认对系统调用接口的理解,您应该阅读并理解 lib/syscall.c(特别是内联汇编例程)。

处理 inc/syscall.h 中列出的所有系统调用,通过调用每个调用对应的内核函数来完成。

在您的内核下运行 user/hello 程序(使用 make run-hello)。它应该在控制台上打印出 "hello, world",然后在用户模式下引发页面错误。如果未发生这种情况,可能意味着您的系统调用处理程序存在问题。现在,您也应该能够使 make gradetestbss 测试中成功运行。

练习7需要进行的操作

  1. 为中断号T_SYSCALL添加一个中断处理函数
  2. 在trap_dispatch()中判断中断号如果是T_SYSCALL,调用定义在kern/syscall.c中的syscall()函数,并将syscall()保存的返回值保存到tf->tf_regs.reg_eax等将来恢复到eax寄存器中。
  3. 修改kern/syscall.c中的syscall()函数,使能处理定义在inc/syscall.h中的所有系统调用。

步骤一如下:分别在trapentry.S和trap.c的trap_init()函数中添加如下代码:

TRAPHANDLER_NOEC(th_syscall, T_SYSCALL)
SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);		//为什么门的DPL要定义为3,参考《x86汇编语言-从实模式到保护模式》p345//在最后一句的上面

步骤二:在trap.c的trap_dispatch()中添加如下代码:

	if (tf->tf_trapno == T_SYSCALL) { //如果是系统调用,按照前文说的规则,从寄存器中取出系统调用号和五个参数,传给kern/syscall.c中的syscall(),并将返回值保存到tf->tf_regs.reg_eax
		tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, tf->tf_regs.reg_edx, tf->tf_regs.reg_ecx,
			tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi);
		return;
	}

步骤三:修改kern/syscall.c中的syscall()

// Dispatches to the correct kernel function, passing the arguments.
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	// Call the function corresponding to the 'syscallno' parameter.
	// Return any appropriate return value.
	// LAB 3: Your code here.
	int32_t ret;
	switch (syscallno) {    //根据系统调用号调用相应函数
		case SYS_cputs:
			sys_cputs((char *)a1, (size_t)a2);
			ret = 0;
			break;
		case SYS_cgetc:
			ret = sys_cgetc();
			break;
		case SYS_getenvid:
			ret = sys_getenvid();
			break;
		case SYS_env_destroy:
			ret = sys_env_destroy((envid_t)a1);
			break;
		default:
			return -E_INVAL;
	}

	return ret;
}

现在回顾一下系统调用的完成流程:以user/hello.c为例,其中调用了cprintf(),注意这是lib/print.c中的cprintf,该cprintf()最终会调用lib/syscall.c中的sys_cputs(),sys_cputs()又会调用lib/syscall.c中的syscall(),该函数将系统调用号放入%eax寄存器,五个参数依次放入in DX, CX, BX, DI, SI,然后执行指令int 0x30,发生中断后,去IDT中查找中断处理函数,最终会走到kern/trap.c的trap_dispatch()中,我们根据中断号0x30,又会调用kern/syscall.c中的syscall()函数(注意这时候我们已经进入了内核模式CPL=0),在该函数中根据系统调用号调用kern/print.c中的cprintf()函数,该函数最终调用kern/console.c中的cputchar()将字符串打印到控制台。当trap_dispatch()返回后,trap()会调用env_run(curenv);,该函数前面讲过,会将curenv->env_tf结构中保存的寄存器快照重新恢复到寄存器中,这样又会回到用户程序系统调用之后的那条指令运行,只是这时候已经执行了系统调用并且寄存器eax中保存着系统调用的返回值。任务完成重新回到用户模式CPL=3。

Challenge!

挑战!使用 sysentersysexit 指令来实现系统调用,而不是使用 int 0x30iret

sysentersysexit 指令是由英特尔设计的,旨在比 intiret 更快。它们使用寄存器而不是堆栈,并对段寄存器的使用做出了一些假设。有关这些指令的确切细节可以在英特尔参考手册的第二卷中找到。

在 JOS 中添加对这些指令的支持的最简单方式是在 kern/trapentry.S 中添加一个 sysenter_handler,它保存关于用户环境的足够信息以便返回到它,设置内核环境,将参数传递给 syscall() 并直接调用 syscall()。一旦 syscall() 返回,设置一切以执行 sysexit 指令。您还需要在 kern/init.c 中添加代码来设置必要的模型特定寄存器(MSR)。AMD架构程序员手册第2卷的6.1.2节和英特尔参考手册第2B卷关于SYSENTER的参考给出了相关MSR的很好描述。您可以在这里找到用于向这些MSR写入的wrmsr实现,以添加到inc/x86.h中。

最后,lib/syscall.c 必须更改以支持使用 sysenter 进行系统调用。下面是 sysenter 指令的可能寄存器布局:

eax                - 系统调用号
edx, ecx, ebx, edi - 参数1,参数2,参数3,参数4
esi                - 返回程序计数器
ebp                - 返回堆栈指针
esp                - 被 sysenter 破坏

GCC 的内联汇编器将自动保存您告诉它直接加载值的寄存器。不要忘记保存(推送)和恢复(弹出)其他您破坏的寄存器,或者告诉内联汇编器您破坏了它们。内联汇编器不支持保存 %ebp,因此您需要添加代码来自行保存和恢复它。返回地址可以通过使用 leal after_sysenter_label, %%esi 等指令放入 %esi

需要注意的是,这种方法只支持4个参数,所以您需要保留旧的通过传递参数执行系统调用的方法,以支持5个参数的系统调用。此外,由于这种快速路径不更新当前环境的中断帧,因此对于稍后在其他实验中添加的某些系统调用可能不适用。

在下一个实验中,一旦我们启用异步中断,您可能需要重新审视您的代码。具体来说,在返回到用户进程时,需要启用中断,而 sysexit 指令并不会为您执行这个操作。

User-mode startup

用户程序开始在 lib/entry.S 的顶部运行。经过一些设置之后,此代码调用 libmain(),位于 lib/libmain.c 中。您应该修改 libmain() 函数,将全局指针 thisenv 初始化为指向当前环境在 envs[] 数组中的 struct Env 结构体。请注意,lib/entry.S 已经定义了 envs,指向您在第A部分设置的 UENVS 映射。(提示:查看 inc/env.h,并使用 sys_getenvid。)

然后,libmain() 调用 umain,在 user/hello.c 中,对于 hello 程序来说。请注意,在打印完 "hello, world" 之后,它试图访问 thisenv->env_id。这就是为什么之前会发生错误。现在您已经正确初始化了 thisenv,它不应该再出现错误。如果它仍然发生错误,可能是您没有将 UENVS 区域映射为用户可读的(在第A部分的 pmap.c 中;这是我们第一次实际使用 UENVS 区域)。

Exercise 8

添加所需的代码到用户库中,然后启动您的内核。您应该看到 user/hello 打印 "hello, world",然后打印 "i am environment 00001000"。user/hello 然后尝试通过调用 sys_env_destroy() 来 "退出"(参见 lib/libmain.clib/exit.c)。由于内核当前仅支持一个用户环境,它应该报告已销毁唯一的环境,然后进入内核监视器。您应该能够使 make gradehello 测试上成功运行。

用户程序执行后都会走到lib/libmain.c中的libmain(),需要修改该函数初始化其中的const volatile struct Env *thisenv;变量。

void
libmain(int argc, char **argv)
{
	// set thisenv to point at our Env structure in envs[].
	// LAB 3: Your code here.
	envid_t envid = sys_getenvid();    //系统调用,我们已经在Exercise 7中实现了
	thisenv = envs + ENVX(envid);      //获取Env结构指针

	// save the name of the program so that panic() can use it
	if (argc > 0)
		binaryname = argv[0];

	// call user main routine
	umain(argc, argv);

	// exit gracefully
	exit();
}

如果一切顺利:user/hello.c:

void
umain(int argc, char **argv)
{
	cprintf("hello, world\\n");
	cprintf("i am environment %08x\\n", thisenv->env_id);  //现在我们已经初始化了thisenv变量了,所以可以打印处来了O(∩_∩)O
}

将先打印出'hello, world',然后打印'i am environment 00001000'。

Page faults and memory protection

内存保护是操作系统的关键特性,确保一个程序的错误不会损坏其他程序或破坏操作系统本身。

操作系统通常依赖硬件支持来实现内存保护。操作系统会告知硬件哪些虚拟地址是有效的,哪些是无效的。当程序尝试访问无效地址或没有权限的地址时,处理器会停止程序在引起错误的指令处,并陷入内核,并携带有关尝试操作的信息。如果错误是可以修复的,内核可以修复它并让程序继续运行。如果错误无法修复,程序就无法继续运行,因为它永远不会通过引起错误的指令。

举例来说,考虑一个可以自动扩展的堆栈。在许多系统中,内核最初分配一个单个堆栈页面,如果程序在访问更深的堆栈页面时出现错误,内核将自动分配这些页面并让程序继续运行。通过这种方式,内核只分配程序所需的堆栈内存,但程序可以在具有任意大堆栈的幻觉下运行。

系统调用对于内存保护提出了一个有趣的问题。大多数系统调用接口允许用户程序向内核传递指针。这些指针指向要读取或写入的用户缓冲区。然后内核在执行系统调用时对这些指针进行解引用。这里有两个问题:

  1. 内核中的页面错误可能比用户程序中的页面错误更为严重。如果内核在操作自己的数据结构时发生页面错误,那就是内核的错误,错误处理程序应该使内核(以及整个系统)处于紧急状态。但当内核对用户程序提供的指针进行解引用时,它需要一种方法来记住这些解引用引起的任何页面错误实际上是代表用户程序发生的。
  2. 内核通常拥有比用户程序更多的内存权限。用户程序可能会传递一个指向内核可以读取或写入但程序无法访问的内存的指针给系统调用。内核必须小心,不要被欺骗进解引用此类指针,因为这可能会泄露私人信息或破坏内核的完整性。

因为这两个原因,内核在处理用户程序提供的指针时必须非常小心谨慎。

现在,您将使用单一机制解决这两个问题,该机制会仔细审查从用户空间传递到内核的所有指针。当程序将一个指针传递给内核时,内核将检查地址是否在地址空间的用户部分,并且页表是否允许内存操作。

因此,内核永远不会由于解引用用户提供的指针而出现页面错误。如果内核出现页面错误,则应该发生紧急情况并终止。

总结

  • 进程建立,可以加载用户ELF文件并执行

    • 内核维护一个名叫envs的Env数组,每个Env结构对应一个进程,Env结构最重要的字段有Trapframe env_tf(该字段中断发生时可以保持寄存器的状态),pde_t *env_pgdir(该进程的页目录地址)

    • 进程对应的内核结构

      第二个步骤描述了处理中断时CPU的操作流程,主要是关于中断处理函数如何在内核栈上保存和恢复中断状态信息的过程。

      1. 中断处理过程
        • 当中断发生时,CPU会自动将当前被打断的程序的状态信息保存到一个叫做Trapframe结构的数据结构中。这个结构保存了当前进程的一些关键信息,比如寄存器的值、中断号等。
        • 在进入中断处理函数之前,CPU将Trapframe结构中的最后两部分(通常是CPU自动压入的一些寄存器值,比如错误码等)压入当前使用的内核栈中。
        • 接下来,中断处理函数由操作系统内核来执行。内核会继续将一些额外的信息压入内核栈中,比如ds、es寄存器的值、中断号、以及通用寄存器的值等。
        • 这样,在中断处理函数开始执行时,内核栈上的Trapframe结构中就包含了中断前的程序状态以及一些附加信息,作为中断处理函数的参数。
      2. iret指令的作用
        • 在中断处理函数执行完毕后,需要将处理完的状态信息恢复到原来的进程中,使得程序可以回到中断前的状态继续执行。
        • 使用iret指令能够完成这一操作。iret指令从Trapframe结构中依次弹出tf eip、tf cs、tf eflags、tf esp、tf ss这些值,并将它们加载到相应的寄存器中。
        • 这些值包括了被打断程序在被中断前的执行状态,比如程序计数器eip、代码段寄存器cs、标志寄存器eflags、栈指针寄存器esp以及栈段寄存器ss等。
        • 执行iret指令后,CPU会根据恢复的状态信息继续执行被打断的程序,使得程序能够在被中断处继续执行下去。

      综上所述,第二个步骤描述了中断处理函数如何在内核栈中保存和恢复中断状态信息,以及使用iret指令将保存的状态信息加载到相应的寄存器中,从而使得被打断的程序能够恢复执行。

    • 定义了env_init(),env_create()等函数,初始化Env结构,将Env结构Trapframe env_tf中的寄存器值设置到寄存器中,从而执行该Env。

    • 上面那段话的意思

      这段描述是在操作系统内核中,特别是涉及到进程(或任务)切换时常见的步骤。在这段描述中,Env结构表示一个进程的状态信息,而Trapframe是该进程在发生中断或异常时CPU自动保存的状态信息。

      "将Env结构Trapframe env_tf中的寄存器值设置到寄存器中,从而执行该Env" 这个描述的意思是:

      1. Env结构通常用来保存一个进程(或任务)的所有信息,包括但不限于寄存器的值、页表、当前运行状态等。这个结构可能在操作系统内核中用于维护、切换不同进程的状态。
      2. Trapframe是Env结构中的一部分,用于保存中断或异常发生时CPU自动保存的状态。这些状态包括寄存器值、当前指令指针(比如eip)、代码段寄存器(cs)、标志寄存器(eflags)、栈指针(esp)等。
      3. "将Env结构Trapframe env_tf中的寄存器值设置到寄存器中,从而执行该Env" 的意思是,在进行进程切换时,需要将Env结构中保存的上一个进程的状态(也就是Trapframe中的寄存器值)加载到CPU的相应寄存器中,这样 CPU 就能够从保存的状态继续执行该进程的代码。这个过程可能包括将Env结构中的寄存器值分别加载到CPU的通用寄存器(比如eax、ebx、ecx等)、指令指针寄存器(eip)、栈指针寄存器(esp)等,以便CPU继续执行该进程的代码。

      总的来说,描述中提到的步骤是为了实现进程间的切换。当操作系统需要暂停当前进程并切换到另一个进程时,它会使用Env结构中保存的Trapframe中的寄存器值加载到CPU的相应寄存器中,从而让CPU继续执行另一个进程的代码。

  • 创建异常处理函数,建立并加载IDT,使JOS能支持中断处理。要能说出中断发生时的详细步骤。需要搞清楚内核态和用户态转换方式:通过中断机制可以从用户环境进入内核态。使用iret指令从内核态回到用户环境。

    • 中断发生过程以及中断返回过程和系统调用原理

  • 利用中断机制,使JOS支持系统调用。要能说出遇到int 0x30这条系统调用指令时发生的详细步骤。见上图。

  • 36
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值