MIT6.828 Lab3 User Environments

2021年7月6日 3点热度 0条评论 来源: 周小伦

Lab3

这个实验分成了两个大部分。

1. PartA User Environments and Exception Handling

kernel使用Env这个数据结构来trace每一个user enviroment,你需要设计JOS来支持多environments。

kernel维护三个主要的全局变量来完成上面的内容

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

1.1Creating and Running Environments

1. 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
};

具体的解释在实验指导书中有,后面用到了在来解释

2 Allocating the Environments Array

这里要求我们修改mem_init来为env结构体分配空间

其实这个分配空间以及映射什么的lab2都熟了。但是这里我遇到了一个问题。

就是切换到lab3之后直接make qemu会报下面的错误



这个问题我修了好久好久。。。。我刚开始是以为我lab2有bug但是lab2的评测没有测出来,然后就去疯狂printf找问题。。。后面google了一下发现好像是一个很简单的问题。只需要修改kern/kernel.ld里面多加一行就可以了

--- a/kern/kernel.ld
+++ b/kern/kernel.ld
@@ -50,6 +50,7 @@ SECTIONS
        .bss : {
                PROVIDE(edata = .);
                *(.bss)
+               *(COMMON)
                PROVIDE(end = .);
                BYTE(0)
        }

然后就是lab3的内容了

第一部分非常简单

//your lab3 code	
sizes = sizeof(struct Env) * NENV;
envs = (struct Env*)boot_alloc(sizes); 
memset(envs, 0, sizes);
//your lab3 code
boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);

这样就可以过掉第一部分的代码,但这里其实是把低地址和高地址的一部分都映射到了相同的物理地址。应该是为了用户模式的方便(猜的)

3 Creating and Runing Environments

您现在将在运行用户环境所需的kern / env.c中编写代码。 由于我们尚未拥有文件系统,因此我们将设置内核以加载嵌入在内核本身内的静态二进制镜像。 JOS将该二进制文件嵌入内核中作为ELF可执行镜像

i386_init()这个函数中有在一个环境中运行这些二进制镜像的代码,但是它们还不完整。在Exercise2中你需要补齐下面的函数

3.1 env_init

这个函数的实现比较简单,基本上根据提示可以秒写。但是注意提示说我们从free_env_list返回的env应该是有顺序的.如先返回env[0]、env[1]以此类推。。所以要用尾插法

void
env_init(void)
{
	// Set up envs array
	// LAB 3: Your code here.
	size_t i = 0;
	for (; i < NENV; i++) {
		envs[i].env_id = 0;
		// ATTENTION: must tail insert 
		if (i == 0) {
			env_free_list = &envs[0];
		} else {
			env_free_list->env_link = &envs[i];
		}
	}
	// Per-CPU part of the initialization
	env_init_percpu();
}

这里补充一些关于gdt和ldt的知识

主要看下面这张图

LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。LDTR记录局部描述符表的起始位置,与GDTR不同LDTR的内容是一个段选择子。由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个表述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt指令。如上图,如果装载的是Selector 2则LDTR指向的是表LDT2。

其实只要知道LDT就是GDT中的一些段。然后我们有LDTR来指向LDT的起始地址,所以LDTR里面装的是段选择子

下面具体分析一下GDT

它具体的结构如下

在代码中表现形式如下

// Segment Descriptors
struct Segdesc {
	unsigned sd_lim_15_0 : 16;  // Low bits of segment limit
	unsigned sd_base_15_0 : 16; // Low bits of segment base address
	unsigned sd_base_23_16 : 8; // Middle bits of segment base address
	unsigned sd_type : 4;       // Segment type (see STS_ constants)
	unsigned sd_s : 1;          // 0 = system, 1 = application
	unsigned sd_dpl : 2;        // Descriptor Privilege Level
	unsigned sd_p : 1;          // Present
	unsigned sd_lim_19_16 : 4;  // High bits of segment limit
	unsigned sd_avl : 1;        // Unused (available for software use)
	unsigned sd_rsv1 : 1;       // Reserved
	unsigned sd_db : 1;         // 0 = 16-bit segment, 1 = 32-bit segment
	unsigned sd_g : 1;          // Granularity: limit scaled by 4K when set
	unsigned sd_base_31_24 : 8; // High bits of segment base address
};

上面的图和这里的设置完全一致。具体的细节这里就不放了这篇博客写的非常好

3.2 env_setup_vm

这里说实话,我刚开始没太看懂注释的意思,什么在utop之上是完全一样。。balabala的

但是总体的思路就是给e指向的Env结构分配页目录,并且继承内核的页目录结构,这里唯一需要修改的就是UVPT要映射到当前环境的页目录物理地址e->env_pgdir处。而不是内核的页目录物理地址kern_pgdir处。

同时这个实验要求。物理映射只需要映射utop之上的。也就是要把从uenvs - utop这一部分初始化为0就好。

 p->pp_ref++;
	e->env_pgdir = page2kva(p);
	memcpy(e->env_pgdir,kern_pgdir,PGSIZE);
	size_t i = 0;
	for (; i < PDX(UTOP); i++) {
		e->env_pgdir[i] = 0;
	}
	// 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;

3.3 region_alloc

要给当前环境分配和映射物理内存。只要向虚拟地址va分配并映射物理页就行.

主要是根据提示以及lab2的一些知识就可以完成下面的内容

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!)
	struct PageInfo *p;
	for (size_t i = ROUNDDOWN(va, PGSIZE); i < ROUNDUP(va + len, PGSIZE); i+= PGSIZE) {
		if (!(p = page_alloc(0))) {
			panic("region_alloc error");
		} else {
			if (!page_insert(e->env_pgdir,p,i,(PTE_P | PTE_U | PTE_W))) {
				panic("region_alloc map  error");
			}
		}
	}
}

3.4 load_icode

你需要将ELF的binary imgae parse进用户空间的新的环境中

这里需要参照boot_main读取elf方式进行读取。

  1. 首先在内核态加载ELF文件,然后利用lcr3函数跳转到用户态获取文件内容。读取完后在用lcr3跳转回到内核态
  2. 设置入口elf-entry,分配一个页作为用户进程的栈,这里只考虑一个用户进程,设置从USTACKTOP - PGSIZE开始的PGSIZE大小的地址空间

这个地方还是比较难写的。这里我又去看了看csapp的第七章

主要是下面的内容,就是我们把下面的二进制elf可执行目标文件读到当前的环境变量中。

上面有一些参数对于的解释就是。

  1. 程序段头表,描述了我们要load进内存的段的信息,因此我们先找到程序段头表

  2. 上图中描述了两个段分别是只读代码段喝读写内存段,其中vaddr喝paddr分别表示这一段段物理地址和虚拟地址,这里可以发现它们是完全一样的。

    因为这里这些段还没有被读进对应进程的虚拟地址空间内,也就不知道他们执行的时候对应的物理地址空间是什么,所以它们在此时是和虚拟地址完全一样的。filesz表示这一段的大小,而memsz表示这段存入内存后要占用的大小。这两个值大部分时间都是一样的,但是当有.bss这种占位符存在的时候就变得不一样了。

  3. off表示的我们要把可执行目标文件中偏移off位置处的filesz大小的数据来初始化(其实就是把这部分的data直接copy过去)而这部分的data其实就是一些映射关系。当前进程环境变量。如果filesz < memsz剩余的地方将被初始化成0.

	struct Elf *ELF_Header = (struct Elf *) binary;
	if (ELF_Header->e_magic != ELF_MAGIC) {
		panic("The binary is not a ELF magic!\n");
	}
	if (ELF_Header->e_entry == 0) {
		panic("The program can't be executed because the entry point is invalid!\n");
	}
	// e->env_tf holds the saved register values for the environment
	e->env_tf.tf_eip = ELF_Header->e_entry;
	lcr3(PADDR(e->env_pgdir));
	struct Proghdr *ph,eph;
	ph = (struct Proghdr *)((uint8_t *) ELF_Header + ELF_Header->e_phoff); // program sector table
	eph = ph + ELF_Header->e_phnum;
	for (; ph < eph; ph++) {
		if (ph->p_type == ELF_PROG_LOAD) {
			if (ph->p_memsz < ph->p_filesz) {
				panic("segment out of memory!\n");
			}
			region_alloc(e, (void *)ph->p_va, ph->p_memsz);
			memset((void *)ph->p_va, 0, ph->p_memsz);
			memcpy((void *)ph->p_va, binary+ph->p_offset, ph->p_filesz);//主要是binary+ph->p_offset
		}
	}
	lcr3(PADDR(kern_pgdir));
	// Now map one page for the program's initial stack
	// at virtual address USTACKTOP - PGSIZE.
	// LAB 3: Your code here.
	region_alloc(e, (void *)(USTACKTOP - PGSIZE), PGSIZE);
}

3.5 env_create

使用Env_Alloc分配环境并调用Load_icode以将ELF二进制加载到其中。

void
env_create(uint8_t *binary, enum EnvType type)
{
	// LAB 3: Your code here.
	struct Env * e;
	if (env_alloc(&e, 0)) {
		panic("env_create: env alloc failed!\n");
	}
	load_icode(e,binary);
	e->env_type = type;
}

3.6 env_run

在用户态运行一个给定的环境

if (curenv != e) {
		if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
			curenv->env_status = ENV_RUNNABLE;
		}
		curenv = e;
		curenv->env_status = ENV_RUNNING;
		curenv->env_runs++;
		lcr3(PADDR(curenv->env_pgdir));
		env_pop_tf(&curenv->env_tf);
	}
	env_pop_tf(&curenv->env_tf);
	panic("env_run not yet implemented");

1.2 recall上面

首先我们跟随实验指导书的说明,来确认一下我们是否进入了user mode

  1. env_pop_tf处设置一个断点,这个是你进入user mode之前的最后一个函数

  2. 逐步执行你会发现在执行完iter指令进入用户态

  3. 用户态的第一条指令就是label start in lib/entry.S.

  4. 然后在obj/user/heelo.asm的SYS_cputs所在的地方打上断点(此int是系统调用,以向控制台显示字符。)

  5. 这里发现确实可以进入int 30这里。感觉前面应该米问题。

1.3 Handling Interrupts and Exceptions

在刚才我们看见了用户空间中的第一个INT $ 0x30系统调用指令是一个死胡同:一旦处理器进入用户模式,就无法退出。现在需要实现基本的异常和系统调用处理,以便内核可以从用户模式代码中恢复处理器的控制。 您应该做的第一件事就是彻底熟悉X86中断和异常机制。

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.

1. Basics of Protected Control Transfer

异常和中断都是“受保护的控制传输”,它导致处理器从用户切换到内核模式(CPL = 0)。 在英特尔的术语中,中断是一种受保护的控制传输,其由通常在处理器外部的异步事件引起的,例如外部设备I / O导致的终端。 而异常是由当前运行的代码引起的受保护控制传输,例如由于除零或无效的内存访问。

2. Types of exceptiopns and interrupts

x86处理器可以在内部使用0到31之间的中断向量,因此映射到IDT条目0-31。 例如,页面故障始终通过向量14引起异常。大于31的中断向量仅被软件中断使用,该软件中断可以由INT指令或异步硬件中断,或者在需要注意时由外部设备引起的。

在本节中,我们将扩展JOS在第0-31页中处理内部生成的X86异常。 在下一部分中,我们将使JOS处理软件中断向量48(0x30),JOS(相当任意)用作其系统调用中断向量。 在Lab 4中,我们将扩展JOS以处理外部生成的硬件中断,例如时钟中断。

An Example

 +--------------------+ KSTACKTOP             
                     | 0x00000 | old SS   |     " - 4
                     |      old ESP       |     " - 8
                     |     old EFLAGS     |     " - 12
                     | 0x00000 | old CS   |     " - 16
                     |      old EIP       |     " - 20 <---- ESP 
                     +--------------------+             
	
  1. 处理器切换到由TSS中的SS0(包含GD_KD)与ESP0(包含KSTACKTOP)指向的stack。
  2. 处理器将old ss、old ESP、异常数据EFLAGS等推入堆栈
  3. 除零异常的中断向量是0,所以处理器读取IDT条目0并设置'CS:EIP'指向条目0描述的the handler function(处理函数)。
  4. 处理函数控制和处理这个exception,如结束这个用户环境

对于某些x86异常,除零这种five words "standard",处理器还会把"error code"推入堆栈,如The page fault exception, number 14

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

3. Nested Exceptions and interrupts

处理器可以从内核和用户模式采用异常和中断。 但是,只有当从用户模式进入内核模式时,X86处理器保存当前寄存器状态之前。会自动切换堆栈并通过IDT调用相应的异常处理程序。 如果发生中断或异常的处理器已处于内核模式(CS寄存器的低2位已经为零),则CPU只需在同一内核堆栈上推动更多值。 通过这种方式,内核可以优雅地处理由内核本身内的代码引起的嵌套异常。 此功能是实现保护的重要工具,因为我们将在系统调用的部分中看到。

如果处理器已经处于内核模式并呈现嵌套异常,因为它不需要切换堆栈,它不会保存旧的SS或ESP寄存器。 也就不用push error code,因此内核堆栈如此看起来像是进入异常处理程序的以下内容:

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

对于需要push error code 的异常,处理器如前所述在旧EIP之后立即push error code

4. Setting Up the IDT

以便在JOS中设置IDT和处理异常。 目前,您将设置IDT来处理中断向量0-31(处理器异常)。 我们将在此实验室后面处理系统调用中断,并在后面的实验室中添加中断32-47(设备IRQ)。

在文件Inc / Trap.h和kern / trap.h包含与您需要熟悉的中断和异常相关的重要定义。

注意:0-31范围内的一些例外由英特尔定义为保留。 由于它们永远不会被处理器生成,因此您如何处理它们并不重要。

您应该实现的整体控制流程如下所示:

      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
|                |             // ...

1.4 Exercise4

好了又到了写代码的地方了。

这里要求我们在trap.ctrapentry.S实现IDT表的初始化,由于执行中断处理程序要从用户模式切换到内核模式,因此在用户模式中当前进程的信息必须要以trapframe的结构存储在栈上,当中断处理程序执行完毕后则进行返回。

1. 在trap.c中初始化IDT表

这里初始化IDT要用到SETGATE这个宏定义,下面先看一下这个宏定义的功能

#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;		\
}

下面是函数参数的说明

Sel : 表示对于中断处理程序代码所在段的段选择子

off:表示中断处理程序代码的段内偏移

(gate).gd_off_15_0 : 存储偏移值的低16位

(gate).gd_off_31_16 : 存储偏移值的高16位

(gate).gd_sel : 存储段选择子

(gate).gd_dpl : dpl 表示该段对应的

熟悉了这些之后参考intel的开发手册找一下istrap的值,这里注意系统调用的dpl = 3不然我们无法从用户模式进去

这里只要按照上述宏定义的格式书写就好,而且这里的中断处理函数我们都不用关心怎么实现,只用给他一个占位符。

SETGATE(idt[T_DIVIDE],0,GD_KT,divide_handler,0);
SETGATE(idt[T_DEBUG],0,GD_KT,debug_handler,0);
SETGATE(idt[T_NMI],0, GD_KT,nmi_handler,0);
SETGATE(idt[T_BRKPT],0,GD_KT,brkpt_handler,3);
SETGATE(idt[T_OFLOW],0,GD_KT,overflow_handler,0);
SETGATE(idt[T_BOUND],0,GD_KT,bounds_handler,0);
SETGATE(idt[T_ILLOP],0,GD_KT,illegalop_handler,0);
SETGATE(idt[T_DEVICE],0,GD_KT,device_handler,0);
SETGATE(idt[T_DBLFLT],0,GD_KT,double_handler,0);
SETGATE(idt[T_TSS],0,GD_KT,taskswitch_handler,0);
SETGATE(idt[T_SEGNP],0,GD_KT,segment_handler,0);
SETGATE(idt[T_STACK],0,GD_KT,stack_handler,0);
SETGATE(idt[T_GPFLT],0,GD_KT,protection_handler,0);
SETGATE(idt[T_PGFLT],0,GD_KT,page_handler,0);
SETGATE(idt[T_FPERR],0,GD_KT,floating_handler,0);
SETGATE(idt[T_ALIGN],0,GD_KT,aligment_handler,0);
SETGATE(idt[T_MCHK],0,GD_KT,machine_handler,0);
SETGATE(idt[T_SIMDERR],0,GD_KT,simd_handler,0);
SETGATE(idt[T_SYSCALL],1,GD_KT,syscall_handler,3);
SETGATE(idt[T_DEFAULT],0,GD_KT,default_handler,0);

2. 在trapentry.S中实现对于不同trap的entry point

实验指导书中提示我们使用TRAPHANDLER and TRAPHANDLER_NOEC这两个宏定义。它们的作用都是把传入的trap number入栈然后跳转到我们后面要实现的__alltraps中。唯一的区别是前者cpu会自动把error code入栈。而对于后者则要手动入栈一个0当作错误码.

因此这里按照上面宏定义的要求初始化所有trap和对应的entry point

TRAPHANDLER_NOEC(divide_handler, T_DIVIDE);
TRAPHANDLER_NOEC(debug_handler, T_DEBUG);
TRAPHANDLER_NOEC(nmi_handler, T_NMI);
TRAPHANDLER_NOEC(brkpt_handler, T_BRKPT);
TRAPHANDLER_NOEC(overflow_handler, T_OFLOW);
TRAPHANDLER_NOEC(bounds_handler, T_BOUND);
TRAPHANDLER_NOEC(illegalop_handler, T_ILLOP);
TRAPHANDLER_NOEC(device_handler, T_DEVICE);

TRAPHANDLER(double_handler, T_DBLFLT);
TRAPHANDLER(taskswitch_handler, T_TSS);
TRAPHANDLER(segment_handler, T_SEGNP);
TRAPHANDLER(stack_handler, T_STACK);
TRAPHANDLER(protection_handler, T_GPFLT);
TRAPHANDLER(page_handler, T_PGFLT);

TRAPHANDLER_NOEC(floating_handler, T_FPERR);
TRAPHANDLER_NOEC(aligment_handler, T_ALIGN);
TRAPHANDLER_NOEC(machine_handler, T_MCHK);
TRAPHANDLER_NOEC(simd_handler, T_SIMDERR);
TRAPHANDLER_NOEC(syscall_handler, T_SYSCALL);
TRAPHANDLER_NOEC(default_handler, T_DEFAULT);

3. 在trapentry.S中实现alltraps

这里说的是要push values to make the stack look like a struct Trapframe。这个意思就是栈内的数据排列要和Trapframe是一样的。因为这样当回复环境的时候,才能以正确的顺序把栈内的值恢复到Trapframe中。

.global _alltraps_alltraps:// make the stack look like a struct Trapframe	pushl %ds;	pushl %es;	pushal;// load GD_KD into %ds and %es	movl $GD_KD, %edx	movl %edx, %ds	movl %edx, %es// push %esp as an argument to trap()	pushl %esp;	call trap;

1.5 partA整理

这里是时候停下来,来看目前为止我们做了什么。

  1. 首先计算机的开始是从BIOS开始,BIOS会做一些关于硬件的检查,以及初始化之后。它搜索可引导设备,如软盘,硬盘驱动器或CD-ROM。 最终,当它找到可启动磁盘时,BIOS将引导加载程序从磁盘读取。随后转移到引导启动程序上去。

  2. 而主引导程序所在地址就是0x7c00也就是boot/boot.S

  3. 主引导程序会把处理器从实模式转换为32bit的保护模式,因为只有在这种模式下软件可以访问超过1MB空间的内容。

  4. 随后主引导程序会load内核。会把内核load到0x10000处

  5. 随后到内核执行,内核调用i386_init随即转移到c语言中

  6. i386_init中我们要调用各种初始化。有lab1实现的cons_init和lab2实现的mem_init

  7. 以及partA实现的env_init()、和刚才实现的trap_init。

  8. 随后我们要调用env_run不过在调用env_run之前要先ENV_CREATE(user_hello, ENV_TYPE_USER);

  9. ENV_CREATE根据提供的二进制elf文件创建一个env。

  10. 随后调用env_run执行我们刚才创建的env(这个时候我们只有一个env)

  11. 这个时候我们进入env_run继续跟踪。在调用env_pop_tf之前我们输出当前的env_tf

  1. 进入env_pop_tf之后我们把当前的env_tf存取trapframe中.然后执行iret指令进入用户态

  2. 用户态的第一条指令就是label start in lib/entry.S.

    首先会比较一下esp寄存器的值是否小于用户栈。因为这表示我们已经处于用户态。

  3. 随后调用libmain然后进入libmain.c。在此调用umain(argc, argv);进入user routine。如果是shell的话就会进入shell

  4. 这里我们测试用的是一个hello.c在里面我们会cprinf很多东西,而cprinf会陷入系统调用。

  5. 这里我们直接在obj/user/hello.asm去找一下系统调用的地址吧。。一行一行执行好慢。。。。

  6. 这里通过系统调用我们就会陷入

    

    可以发现这里就是我们刚才设置的对于syscall的处理。

    这里是如何准备准确的找到trapentry.S中对应的条目,是通过我们在前面trap_init设置好的IDT表来找到对应的entry

    图片来自于一位大佬的简书

    所以通过IDT和我们设置的段选择子(其实这里就是内核的代码段)以及偏移就可以找到对应的中断处理程序。

    因此这里我们进入TRAPHANDLER_NOEC的宏定义。因为syscall是没有error number所以我们进入这个宏定义

    1. 进入之后把trap number入栈随即调用trap这个函数
    2. 对于trap的实现后面的lab涉及到了之后在进行整理

1.6 partA的一些疑惑

1. 对trap_init_percpu的分析

我们在trap_init中设置了对不同中断/陷阱对应的在IDT中的一些信息。随即我们就调用了trap_init_percpu

下面来详细解释一下这个函数

void
trap_init_percpu(void)
{
	// Setup a TSS so that we get the right stack
	// when we trap to the kernpel.
  // 这里设置
	ts.ts_esp0 = KSTACKTOP;
	ts.ts_ss0 = GD_KD;
	ts.ts_iomb = sizeof(struct Taskstate);
  

上面的代码是用来设置任务状态段的一些信息。因为任务状态段TSS是内核用来任务管理的,所以它的栈帧指针esp是指向内核栈的。它的数据段指向内核的数据段。

	// Initialize the TSS slot of the gdt.
	gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts),
					sizeof(struct Taskstate) - 1, 0);
	gdt[GD_TSS0 >> 3].sd_s = 0;

这里刚开始看的时候真的非常疑惑,这里我们要秉持一个概念,就是gdt的下标是段选择子。那我们看一下段选择子的结构

因此这里我们把任务状态段存入gdt中的时候他对应的索引就是前13位,因此这里我们要GD_TSS0 >> 3来表示对应的索引

至于SEG16则是按照gdt段描述符占的格式来设置这一段。具体格式我们上面已经提到了。同样这里我们要把这一段的sd_s位设置为0

来表示system,因为这个是内核用来任务管理的

// Load the TSS selector (like other segment selectors, the	// bottom three bits are special; we leave them 0)	ltr(GD_TSS0);	// Load the IDT	lidt(&idt_pd);	}

这里是两个load操作分别load 段选择子和IDT表,IDT表为我们接下来的操作做准备

2. lgdt和lidt是如何工作的

下图为GDTR和IDTR的结构

  • 先看lgdt

    就以env_init_cpu这个中的lgdt为例

    env_init_percpu(void){	lgdt(&gdt_pd);
    

    这里的lgdt会调用下面的内联汇编

    static inline voidlgdt(void *p){	asm volatile("lgdt (%0)" : : "r" (p));}
    

    这里是调用LGDT这条汇编指令,将p所指向的值加载到GDTR。那这里传过去的p就是执向gdt_pd的指针

    因此上面的执行实际上等价于把gdt_pd加载到GDTR。

    下面看一下gdt_pd分别表示了什么

    // Pseudo-descriptors used for LGDT, LLDT and LIDT instructions.struct Pseudodesc {	uint16_t pd_lim;		// Limit	uint32_t pd_base;		// Base address} __attribute__ ((packed));
    
    struct Pseudodesc gdt_pd = {	sizeof(gdt) - 1, (unsigned long) gdt};
    

    emmm这里其实就想当于定义了一个结构体存储了gdt的大小和基地址。而这正好和gdtr相对应。

  • 再看lidt

    通过上面的操作其实可以速推这个操作,就是调用LIDT把对应的LDTR寄存器初始化

    同样还是利用了ldt_pd这样的结构体,整体操作和上面完全一样

    // Load the IDT	lidt(&idt_pd);
    
    struct Pseudodesc idt_pd = {	sizeof(idt) - 1, (uint32_t) idt};
    

    3. lcr3是如何工作的

    我们在之前的lab中利用了lcr3来改变page_dir。那么它到底是如何工作的

    其实查了一下非常简单。crx寄存器一家

    cr3级存取是页目录基地寄存器,保存页目录表的物理地址

    可以发现lrc3的代码就是把val -> cr3寄存器。

    static inline voidlcr3(uint32_t val){	asm volatile("movl %0,%%cr3" : : "r" (val));}
    

    4. user hello的系统调用是如何处理的

    前面我们分析了IDT表的构建,以及我们如何找到trap对应的条目

    那么我们分析一下整个系统调用的全过程

    1. umain --> lib/cprintf --> vcprintf --> lib/systemcall/sys_cputs --> syscallsystemcall 中使用 int 0x30 陷入内核态
      这里我们在0x800bcb打一个断点就会进入到系统调用

    2. 然后就是int指令的执行过程。。这里我不知道怎么debug追踪就去网上查了一下

      1. 取中断类型码n;

      2)标志寄存器入栈(pushf),IF=0,TF=0(重置中断标志位);

      3)CS、IP入栈;

      4)查中断向量表, (IP)=(n x 4),(CS)=(n x 4+2)。

    3. 所以整个的执行流程就如下图

2. PartB Page Faults, Breakpoints Exceptions, and System Calls

2.1 Handing Page Faults

有了前面的铺垫之后这个exercise就非常简答了。就是要让我们修改trap_dispatch中针对页故障执行已经提供好的page_falut_handler。所以只需要2行

if (tf->tf_trapno == T_PGFLT) {		page_fault_handler(tf);	}

2.2 The Breakpoint Exception

断点异常(具有中断向量3)通常用于允许调试器在程序代码中插入断点,即在相关的代码位置暂时使用int $3来代替原本应该执行的指令。在jos中我们将会大量使用这个异常来实现一个原始的伪系统调用,使得用户环境可以使用它来调用jos内核监视器(如果我们将jos内核监视器视为原始调试器,这种做法是适当的)。比如说lib/panic.c中user mode下的panic()函数,实际上就是在显示了panic信息之后使用了int $3

Exercise6 修改trap_dispatch()来实现内核监视器中的断点异常。

这个和上面一样也是2行

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

2.3 Systemcalls

为内核增加系统调用处理函数。我们需要修改kern/trapentry.S以及kern/trap.c中的trap_init()函数。我们还需要修改trap_dispatch(),使其能够以正确参数调用syscall()(这个是kern/syscall.c下的而非之前lib中的)并将返回结果存放在%eax中返回给用户(调用者)。

我们还需要实现kern/syscall.c下的syscall(),使得调用号无效的时候返回-E_INVAL。通过系统调用函数处理inc/syscall.h中的所有系统调用。

实验指导书中的提示

应用程序将会通过寄存器传递系统调用号以及相应的系统调用参数。这种方式下内核就不会访问用户环境栈或者指令流。系统调用号存放在%eax寄存器中,其余参数(最多五个)相应地存放在 %edx, %ecx, %ebx, %edi, %esi中。内核将返回值存放在寄存器%eax中。用于唤醒系统调用的汇编代码已经实现在lib/syscall.c中的syscall()。我们需要阅读这个函数以确保理解了如何唤醒系统调用。

.4 User-mode startup

用户程序在lib/entry.S的顶部开始运行,经过一些操作之后,代码会调用lib/libmain.c中的libmain()。我们需要修改libmain()以初始化指向当前环境struct Env(在envs[]数组中)的全局指针thisenv(注意lib/entry.S已经定义了我们在part A中指向UENVS的映射envs)。

提示:我们可以查看inc/env.h以及使用sys_getenvid()

随后libmain调用umain,对于hello程序而言,打印出”hello world”之后,其试图访问thisenv->env_id,这就是为什么hello程序会出现fault(我们还没有初始化thisenv)。

exercise 8

实际上就是要求我们需要修改libmain()函数使其初始化thisenv,指向envs中代表当前用户环境的Env结构体。

这里我们去inc/env.h可以看到下面这段话

The environment index ENVX(eid) equals the environment's index in the 'envs[]' array.

所以我们需要使用ENVX(eid)总这个宏定义来获取当前用户环境的Env结构体位于envs中的索引。同时sys_getenvid就可以获取到当前环境的eid了。。加上一行就可以过

thisenv = envs + ENVX(sys_getenvid());

2.5 Page faults and memory protection

真的看不懂一些英语。。。烦死了

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

  1. 内核的缺页错误潜在地比用户程序的缺页错误更加严重。如果内核在操作其私有数据结构的时候发生了缺页错误,那么内核产生bug,错误处理程序应该panic内核。但是当内核解引用由用户程序传递的指针时,需要某种方式来标记由解引用导致的缺页实际上代表的是用户程序引发的
  2. 内核比用户程序具有更多的地址权限。在这种情况下用户程序可能会传递一个指针,这个指针指向的地址只能由内核读写而不能通过用户程序读写。在这种情况下内核不能对这个指针进行解引用(这样做显然会暴露内核的私有信息)。

因此我们需要解决这两个问题,通过检查传递从用户空间传递到内核的指针是否应该被解引用。

exercise 9

修改kern/trap.c,使得内核在内核代码触发缺页错误的时候panic。

提示:为了确认引发异常的代码是用户代码还是内核代码,可以检查tf_cs的寄存器值的低位。

  1. 阅读kern/pmap.c中的user_mem_assert然后在相同的的文件中实现user_mem_check

    intuser_mem_check(struct Env *env, const void *va, size_t len, int perm){	// LAB 3: Your code here.	// 1. must below ULIM	if ((uintptr_t) va >= ULIM) {		user_mem_check_addr = (uintptr_t)va;		return -E_FAULT;	}	size_t start = (size_t) ROUNDDOWN(va, PGSIZE);	size_t end = (size_t) ROUNDUP(va + len, PGSIZE);	while (start < end) {		pte_t *pte = pgdir_walk(env->env_pgdir, (void *) start, 0);		if (start >= ULIM || !pte || !(*pte & PTE_P) || ((*pte & perm) != perm)) {			if(start <= (uintptr_t)va){				user_mem_check_addr = (uintptr_t)va;			}			else if(start >= (uintptr_t)va + len){				user_mem_check_addr = (uintptr_t)va + len;			}			else{				user_mem_check_addr = start;			}			return -E_FAULT;		}		start += PGSIZE;	}    return 0;}
    
  2. 修改kern/syscall.c来仔细检查系统调用的参数。

    在syscall中增加这一assert函数

    	// Return any appropriate return value.	// LAB 3: Your code here.	switch (syscallno) {	case SYS_cputs:		user_mem_assert(curenv,(void *)a1, (size_t)a2, PTE_U);		sys_cputs((const char *)a1, a2);		return 0;
    

    sys_cputs中增加mem_check

    static voidsys_cputs(const char *s, size_t len){	// Check that the user has permission to read memory [s, s+len).	// Destroy the environment if not.	// LAB 3: Your code here.	user_mem_check(curenv,(void *)s, len,PTE_U);	// Print the string supplied by the user.	cprintf("%.*s", len, s);}
    
  3. 修改kern/kedebug.c中的debuginfo_eip函数,使其在usd, stabs, stabstr调用user_mem_check

    原文作者:周小伦
    原文地址: https://www.cnblogs.com/JayL-zxl/p/14974083.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。