MIT6.828——lab3:User Environment

主要介绍JOS中的进程,异常处理,系统调用。内容上分为三部分:

  1. 用户环境建立,可以加载用户ELF文件并执行。(目前还没有文件系统,需要在内核代码硬编码需要加载的用户程序)
  2. 建立异常处理机制,异常发生时能从用户态进入内核进行处理,然后返回用户态。
  3. 借助异常处理机制,提供系统调用的能力。

Part A: User Environments and Exception Handling

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
								  // (linked by Env->env_link)

Environment Status

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,该链表的第一个free的Env结构体。

env_id:
  这个值可以唯一的确定使用这个结构体的用户环境是什么。当这个用户环境终止,内核会把这个结构体分配给另外一个不同的环境,这个新的环境会有不同的env_id值。

env_parent_id:
  创建这个用户环境的父用户环境的env_id

env_type:
  用于区别出来某个特定的用户环境。对于大多数环境来说,它的值都是 ENV_TYPE_USER.

env_status:
  这个变量存放以下可能的值
  ENV_FREE: 代表这个结构体是不活跃的,应该在链表env_free_list中。
  ENV_RUNNABLE: 代表这个结构体对应的用户环境已经就绪,等待被分配处理机。
  ENV_RUNNING: 代表这个结构体对应的用户环境正在运行。
  ENV_NOT_RUNNABLE: 代表这个结构体所代表的是一个活跃的用户环境,但是它不能被调度运行,因为它在等待其他环境传递给它的消息。
  ENV_DYING: 代表这个结构体对应的是一个僵尸环境。一个僵尸环境在下一次陷入内核时会被释放回收。
进程状态转换图

env_pgdir:
  这个变量存放着这个环境的页目录的虚拟地址

Allocating the Environments Array

在mem_init()函数中初始化envs数组,与lab2中初始化pages数组基本一样:

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

在物理地址上分配NENVstruct Env结构的空间,并用envs指针指向起始位置,初始化为零。

envs数组表示可创建的最大进程数,env_free_list指向的是还可以创建的进程数

boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);

将虚拟地址UENVS和物理地址envs进行映射,大小为PTSIZE,并将映射关系写入kern_pgdir

执行完以上代码后,虚拟地址到物理地址映射关系如下:
分配envs数组后映射关系

Creating and Running Environments

完成kern/env.c下的函数:

env_init(void)

初始化envs数组,将envs数组构建成env_free_list链表,注意顺序,envs[0]应该在链表头部位置

void env_init(void)
{
	int i;
	env_free_list = NULL;
	for (i = NENV - 1; i >= 0; --i)
	{
		envs[i].env_id = 0;
		envs[i].env_status = ENV_FREE;
		envs[i].env_link = env_free_list;
		env_free_list = &envs[i];
	}

	// Per-CPU part of the initialization
	env_init_percpu();
}

示意图:
在这里插入图片描述

env_setup_vm(struct Env *e)

初始化e指向的Env结构代表的用户环境的页目录表,设置e->env_pgdir字段

这个函数就是设置用户环境的虚拟地址到物理地址的映射。在上一个lab中,我们设置了内核空间的虚拟地址到物理地址的映射。用户环境与内核空间的设置方法是一致的,唯一的区别在于页目录不一样。

每个进程(内核也是一个进程)都有属于自己的页目录,因此,在这个函数中,需要修改目录表,其他的与内核空间的一致。

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 = (pte_t *)page2kva(p);
	p->pp_ref++;//UTOP以上的被映射的物理页是不需要pp_ref的,因为这些物理页是必须被使用的。而env_pgdir会随着进程的结束而释放(kern_pgdir不能被释放)

	for (i = 0; i < PDX(UTOP); ++i)
		e->env_pgdir[i] = 0;
    					//一页page directory的页目录项数
	for (i = PDX(UTOP); i < NPDENTRIES; ++i)
		e->env_pgdir[i] = kern_pgdir[i];

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

	return 0;
}
region_alloc(struct Env *e, void *va, size_t len)

在进程e中,为[va,va+len]分配物理空间,并将映射关系写入env_pgdir

static void
region_alloc(struct Env *e, void *va, size_t len)
{
	void *begin = ROUNDDOWN(va, PGSIZE);//需要进行页对齐
	void *end = ROUNDUP(va + len, PGSIZE);
	while (begin < end)
	{
		struct PageInfo *pg = page_alloc(0);//获得一个物理页,不用置零
		if (pg == NULL)
			panic("page allocation failed!");
		page_insert(e->env_pgdir, pg, begin, PTE_W | PTE_U);//添加映射关系
		begin += PGSIZE;
	}
}
load_icode(struct Env *e, uint8_t *binary)

内核想要运行一个用户进程是需要执行一个程序的,因此为进程e加载binary地址开始处的ELF文件

ELF文件结构描述
ELF HEADER
.test
.data
.bss
… other sections
section header table
string tables symbol tables …
文件头(ELF HEADER)
成员readelf输出结果
e_identMagic: Class: Data: Version: OS/ABI ABI Version:
e_typeType:
e_machineMachine:
e_versionVersion:
e_entryEntery point address:ELF程序的入口虚拟地址。可重定位文件一般没有,值为0
e_phoffStart of program headers:
e_shoffStart of section headers:
e_flagsFlages:
e_ehsizeSize of this header:
e_phentsizeSize of program headers:
e_phnumNumber of program headers:
e_shentsizeSize of section headers:段表描述符大小
e_shnumNumber of section headers:段表描述符数量
e_shstrndxSection header string table index:段表字符串表所在的段在段表中的下标

Segment概念是从装载(从磁盘加载到内存中)的角度重新划分了ELF的各个段(Section),将目标文件链接成可执行文件的时候,连接器会尽量把相同权限属性的段分配在同一空间

Section是从链接角度划(数据属性)分ELF:比如划分为代码段、数据段、.bss段

描述Section的结构叫做段表(section header)

描述Segment的结构叫程序头(program header):ELF可执行文件有程序头表(Program Header Table)描述了ELF文件该如何被操作系统映射到进程的虚拟空间,其结构体如下:

typedef struct {
    Elf32_Word 	p_type;		//segment类型,只关注“LOAD”类型
    Elf32_Off 	p_offset;	//segment在elf文件中的偏移
    Elf32_Addr 	p_vaddr;	//segment的第一个字节在虚拟地址空间的起始位置
    Elf32_Addr	p_paddr;	//segment的物理装载地址
    Elf32_Word	p_filesz;	//segment在ELF文件中所占空间长度
    Elf32_Word	p_memsz;	//segment在虚拟地址空间中所占长度
    Elf32_Word	p_flags;	//segment权限属性
    Elf32_Word	p_align;	//segment对齐属性
}Elf32_Phdr
static void
load_icode(struct Env *e, uint8_t *binary)
{
	struct Elf *header = (struct Elf *)binary;
	if (header->e_magic != ELF_MAGIC)
		panic("load_icode failed:the binary is not elf.\n");
	if (header->e_entry == 0)
		panic("load_icode failed:the elf can't be excuted.\n");
	
    //进程e起始指令地址就是header的入口
	e->env_tf.tf_eip = header->e_entry;

    //segment的结构——program header
	struct Proghdr *ph = (struct Proghdr *)((uint8_t *)header + header->e_phoff);
	int ph_num = header->e_phnum;//Number of program headers
	
    //此时是内核初始化用户进程,为内核页目录表,加载进程e的页目录表从而实现地址映射
	lcr3(PADDR(e->env_pgdir)); //加载用户空间页目录

	int i;
	for (i = 0; i < ph_num; ++i)
	{
		if (ph[i].p_type == ELF_PROG_LOAD)
		{
			if (ph[i].p_memsz < ph[i].p_filesz)
				panic("load_icode failed:p_memsz<p_filesz.\n");
            //为每一个segment分配一个物理页
			region_alloc(e, (void *)ph[i].p_va, ph[i].p_memsz);
			memset((void *)ph[i].p_va, 0, ph[i].p_memsz);
            //将segment从elf中装载到虚拟地址上		segment在elf文件中的偏移
			memcpy((void *)ph[i].p_va, binary + ph[i].p_offset, ph[i].p_filesz);
		}
	}
	//设置完成后,需要重新载入kern_pgdir,因为此时还是在内核态下
	lcr3(PADDR(kern_pgdir));
	
    //为进程e分配一个大小为PGSIZE的物理页作为stack
	region_alloc(e, (void *)(USTACKTOP - PGSIZE), PGSIZE);
}
env_create(uint8_t *binary, enum EnvType type)

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

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

执行进程e

进程上下文切换是在内核中进行的,而用户态需要通过系统调用或中断到内核态。则在进入内核态之前就需要保存用户态的各类寄存器值,因此,env_run()函数(用户进入内核,内核再调用该函数)中,上下文切换时,不需要再保存进程curenv的寄存器值

void env_run(struct Env *e)
{
	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));
    //将进程e的各类寄存器值加载到各类寄存器中
    //至于当前进程(希望调用env_run()函数的进程)寄存器值在进入内核态之前会被保存
	env_pop_tf(&e->env_tf);

	panic("env_run not yet implemented");
}

用户环境的代码被调用前,操作系统一共按顺序执行了以下几个函数:

start (kern/entry.S)

---->i386_init (kern/init.c)

​ ---->cons_init

---->mem_init

---->env_init

---->trap_init (目前还未实现)

---->env_create

---->env_run

---->env_pop_tf

目前,trap_init还未实现,当用户调用iret指令进行系统调用时不能成功。当CPU发现失败时会触发一个保护异常,保护异常也无法处理,从而产生一个错误异常,仍然无法解决问题,最后放弃,此种情况称为“triple fault”

Handling Interrupts and Exceptions

Basics of Protected Control Transfer

一个中断指的是由外部异步事件引起的处理器控制权转移,比如外部IO设备发送来的中断信号。

一个异常则是由于当前正在运行的指令所带来的同步的处理器控制权的转移,比如除零溢出异常。

中断或异常发生时,不能由用户随意选择进入内核的位置。因此,提供两种机制使得当中断或异常发生时能从指定位置进入内核:

  1. **中断向量表(Interrupt Descriptor Table,IDT):**通过中断向量表选择处理程序,并加载到CSEIP寄存器,从而运行处理程序。

    处理器保证中断和异常只能够引起内核进入到一些特定的,被事先定义好的程序入口点,而不是由触发中断的程序来决定中断程序入口点。
      X86允许多达256个不同的中断和异常,每一个都配备一个独一无二的中断向量。一个向量指的就是0到255中的一个数。一个中断向量的值是根据中断源来决定的:不同设备,错误条件,以及对内核的请求都会产生出不同的中断和中断向量的组合。CPU将使用这个向量作为这个中断在中断向量表中的索引,这个表是由内核设置的,放在内核空间中,和GDT很像。通过这个表中的任意一个表项,处理器可以知道:
      *需要加载到EIP寄存器中的值,这个值指向了处理这个中断的中断处理程序的位置。
      *需要加载到CS寄存器中的值,里面还包含了这个中断处理程序的运行特权级。(即这个程序是在用户态还是内核态下运行。)

  2. 任务状态段(Task State Segment,TSS):

    处理器还需要一个地方来存放,当异常/中断发生时,处理器的状态,比如EIP和CS寄存器的值。这样的话,中断处理程序一会可以重新返回到原来的程序中。这段内存自然也要保护起来,不能被用户态的程序所篡改。

    正因为如此,当一个x86处理器要处理一个中断或异常并且使运行特权级从用户态转为内核态时,它也会把它的堆栈切换到内核空间中。一个叫做 “任务状态段(TSS)”的数据结构将会详细记录这个堆栈所在的段的段描述符和地址。处理器会把SS,ESP,EFLAGS,CS,EIP以及一个可选错误码等等这些值压入到这个堆栈上。然后加载中断处理程序的CS,EIP值,并且设置ESP,SS寄存器指向新的堆栈。
    尽管TSS非常大,并且还有很多其他的功能,但是JOS仅仅使用它来定义处理器从用户态转向内核态所采用的内核堆栈,由于JOS中的内核态指的就是特权级0,所以处理器用TSS中的ESP0,SS0字段来指明这个内核堆栈的位置,大小。

Types of Exceptions and Interrupts

​ 所有的由X86处理器内部产生的异常的向量值是0到31之间的整数。比如,page fault所对应的向量值是14.而大于31号的中断向量对应的是软件中断,由int指令生成;外部中断,由外部设备生成。

An Example

假设处理器正在用户状态下运行代码,但是遇到了一个除法指令,并且除数为0。

  1. 处理器会首先切换堆栈,切换到由TSS的SS0,ESP0字段所指定的内核堆栈区,这两个字段分别存放着GD_KD和KSTACKTOP的值。

  2. 处理器把异常参数(即需要保存的寄存器值)压入到内核堆栈中,起始于地址KSTACKTOP

    +--------------------+ KSTACKTOP             
    | 0x00000 | old SS   |     " - 4	----|
    |      old ESP       |     " - 8	  	|切换堆栈,需要保存原来的堆栈以及标志
    |     old EFLAGS     |     " - 12	----|
    | 0x00000 | old CS   |     " - 16			----|
    |      old EIP       |     " - 20 <---- ESP ----|执行中断处理程序,需要保存原来的指令
    +--------------------+             
    
  3. 除以0的异常中断号是0,处理器读取IDT的第0项,从中解析出CS:EIP。

  4. CS:EIP处的异常处理函数执行。

    对于一些异常来说,除了压入上图五个值,还会压入错误代码,如下所示:

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

Nested Exceptions and Interrupts

​ 处理器在用户态下和内核态下都可以处理异常或中断。只有当处理器从用户态切换到内核态时,才会自动地切换堆栈,并且把一些寄存器中的原来的值压入到堆栈上,并且触发相应的中断处理函数。但如果处理器已经处在内核态下时,因为它不需要切换堆栈,所以它不需要存储SS,ESP寄存器的值:

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

trapframe结构对比一下,可以发现压入的值和从下往上看的结构体中的值一样!

Setting Up the IDT

应该实现的整体控制流:

      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. trap_init() 先将所有中断处理函数的起始地址放到中断向量表IDT中。
  2. 当中断发生时,不管是外部中断还是内部中断,处理器捕捉到该中断,进入核心态
  3. 保存被中断的程序的上下文到内核堆栈中(SS,ESP,EFLAGS,CS,EIP)
    4. 根据中断向量去查询中断向量表,找到对应的表项,调用这个表项中指明的中断处理函数。
    5. 执行中断处理函数。
    5. 执行完成后,恢复被中断的进程的上下文,返回用户态,继续运行这个进程。

trapentry.Strap.c中建立异常处理函数,在trap_init()中建立并且加载IDT。

/*在trapentry.S中的代码*/

.text

/*
 * Lab 3: Your code here for generating entry points for the different traps.
 */
 /*这里实际上只是压入了tf_trapno,即中断向量,并没有实际的处理函数
  *后续是通过trap_dispatch()函数,根据压入的tf_trapno选择处理函数
  */
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(oflow_handler,T_OFLOW)
TRAPHANDLER_NOEC(bound_handler,T_BOUND)
TRAPHANDLER_NOEC(illop_handler,T_ILLOP)
TRAPHANDLER_NOEC(device_handler,T_DEVICE)
TRAPHANDLER(dblflt_handler,T_DBLFLT)
TRAPHANDLER(tss_handler,T_TSS)
TRAPHANDLER(segnp_handler,T_SEGNP)
TRAPHANDLER(stack_handler,T_STACK)
TRAPHANDLER(gpflt_handler,T_GPFLT)
TRAPHANDLER(pgflt_handler,T_PGFLT)
TRAPHANDLER_NOEC(fperr_handler,T_FPERR)
TRAPHANDLER(align_handler,T_ALIGN)
TRAPHANDLER_NOEC(mchk_handler,T_MCHK)
TRAPHANDLER_NOEC(simderr_handler,T_SIMDERR)
TRAPHANDLER_NOEC(syscall_handler,T_SYSCALL)


/*
 * Lab 3: Your code here for _alltraps
 */
_alltraps:
/*保存上下文,根据trapframe结构,SS、ESP、EFLAGS、CS、EIP在之前便由处理器自动压入
 *在这里需要压入DS、ES以及其他通用寄存器的值
 */
	pushl %ds
	pushl %es
	pushal	/*压入tf_regs,即通用寄存器值,此时ESP指向这里,为Trapframe结构的起始地址*/

	movl $GD_KD, %eax	/*数据可以直接送入基本寄存器,但不能直接送入段寄存器*/
	movw %ax, %ds		/*切换到内核数据段*/
	movw %ax, %es

	push %esp			/*压入trap()的参数tf*/
	call trap			/*调用trap(struct Trapframe *tf)时
						 *将前面压入的值整体当作trapframe结构
						 */

梳理一下用户态到内核态的上下文切换压入过程:

​ 由用户程序切换到内核,我们需要保存用户程序的各个寄存器信息,这些信息都被保存到用户程序的Trapframe里面

  1. 用户态发出一个异常,此时,需要进入内核态进行处理

  2. CPU首先切换堆栈(用户栈—>内核栈),怎么切换?

    通过TSS寄存器获得SS0和ESP0,这两个值描述了内核堆栈的位置,那么如何保存这两个值?

    当然是需要保存在SS寄存器和ESP寄存器中。因此,CPU需要先将用户态的SS与ESP压入到内核栈上,同时还要压入EFLAGS。

  3. 因为要运行内核指令,所以CPU还需要压入用户态的CS和EIP

  4. 接下来,通过中断向量在IDT中查找对应的中断处理函数,其trapno(中断值)由TRAPHANDLERTRAPHANDER_NOEC这两个宏函数压入

  5. 然后执行_alltraps,其作用就是压入剩下的寄存器:DS、ES以及tf_regs结构中的所有寄存器

  6. 此时ESP会指向内核栈的栈顶,压入ESP,表示完成了trapframe结构的构建,这个会作为参数传递给trap()函数

  7. 最后,call trap,此时,上下文已经切换完成了,可以调用trap()函数从而进入中断处理函数,进行处理了
    上下文切换压入过程

现在,已经设置好了中断处理函数,并且可以进行上下文切换了,但还没有建立IDT,因此,需要在trap_init()函数中进行IDT初始化:

void divide_handler();
void debug_handler();
void nmi_handler();
void brkpt_handler();
void oflow_handler();
void bound_handler();
void illop_handler();
void device_handler();
void dblflt_handler();
void tss_handler();
void segnp_handler();
void stack_handler();
void gpflt_handler();
void pgflt_handler();
void fperr_handler();
void align_handler();
void mchk_handler();
void simderr_handler();
void syscall_handler();

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

	// LAB 3: Your code here.
	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, oflow_handler, 0);
	SETGATE(idt[T_BOUND], 0, GD_KT, bound_handler, 0);
	SETGATE(idt[T_ILLOP], 0, GD_KT, illop_handler, 0);
	SETGATE(idt[T_DEVICE], 0, GD_KT, device_handler, 0);
	SETGATE(idt[T_DBLFLT], 0, GD_KT, dblflt_handler, 0);
	SETGATE(idt[T_TSS], 0, GD_KT, tss_handler, 0);
	SETGATE(idt[T_SEGNP], 0, GD_KT, segnp_handler, 0);
	SETGATE(idt[T_STACK], 0, GD_KT, stack_handler, 0);
	SETGATE(idt[T_GPFLT], 0, GD_KT, gpflt_handler, 0);
	SETGATE(idt[T_PGFLT], 0, GD_KT, pgflt_handler, 0);
	SETGATE(idt[T_FPERR], 0, GD_KT, fperr_handler, 0);
	SETGATE(idt[T_ALIGN], 0, GD_KT, align_handler, 0);
	SETGATE(idt[T_MCHK], 0, GD_KT, mchk_handler, 0);
	SETGATE(idt[T_SIMDERR], 0, GD_KT, simderr_handler, 0);
	SETGATE(idt[T_SYSCALL], 0, GD_KT, syscall_handler, 3);

	// Per-CPU setup
	trap_init_percpu();
}

SETGATE宏的定义:
  #define SETGATE(gate, istrap, sel, off, dpl)
  其中gate是idt表的index入口,istrap判断是异常还是中断,sel为代码段选择符,off表示对应的处理函数地址,dpl(Descriptor Privilege Level)表示触发该异常或中断的用户权限。GD_KT为内核代码段,每一个处理函数的地址是相对与内核代码段的偏移

​ IDT中的dpl设置为0或3有很大的区别:

  • 设置为0,表示只能由内核调用,用于处理中断或异常(用户是被动的)
  • 设置为3,表示可以由用户主动申请让内核进行调用,即syscall

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

volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,告诉编译器对该变量不做优化,都会直接从变量内存地址中读取数据,从而可以提供对特殊地址的稳定访问。

如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。(简洁的说就是:volatile关键词影响编译器编译的结果,用volatile声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化,以免出错)

const, volatile同时修饰一个变量

(1)合法性

​ “volatile”的含义并非是“non-const”,volatile 和 const 不构成反义词,所以可以放一起修饰一个变量。

(2)同时修饰一个变量的含义

​ 表示一个变量在程序编译期不能被修改且不能被优化;在程序运行期,变量值可修改,但每次用到该变量的值都要从内存中读取,以防止意外错误。

volatile一般用处:

1)并行设备的硬件寄存器(如:状态寄存器)

2)中断服务程序中修改的供其它程序检测的变量,需要加volatile;

3)多任务环境下各任务间共享的标志,应该加volatile;

4)存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义

Handling Page Fault

缺页中断——中断向量为14:当缺页中断发生时,系统会把引起中断的线性地址存放到控制寄存器 CR2 中

trap_dispatch()函数中,添加page_fault_hander()函数,使得可以处理缺页异常

switch (tf->tf_trapno)
	{
	case T_PGFLT:
		page_fault_handler(tf);
		break;
	}

从用户态进入内核态的中断处理过程:
trap调用过程

Breaking Points Exception

断点异常,值为3,这个异常可以让调试器能够给程序加上断点。加断点的基本原理就是把要加断点的语句用一个 INT3 指令替换,执行到INT3时,会触发软中断。在JOS中,将通过把这个异常转换成一个伪系统调用,这样的话任何用户环境都可以使用这个伪系统调用来触发JOS kernel monitor。

trapdispatch()函数中添加一个case:

case T_BRKPT:
		monitor(tf);

System Calls

应用程序会把系统调用号以及系统调用的参数放到寄存器中。通过这种方法,内核就不需要去查询用户程序的堆栈了。系统调用号存放到 %eax 中,参数则存放在 %edx, %ecx, %ebx, %edi, 和 %esi 中。内核会把返回值送到 %eax中。

中断、异常、系统调用都是使用同一个机制,只是用处不一样,因而叫法不一样。

为中断向量T_SYSCALL(其值为48)编写一个中断处理函数:

//在kern/trapentry.S中添加
TRAPHANDLER_NOEC(syscall_handler,T_SYSCALL)
    
//在kern/trap.c的trap_init()函数中添加
void syscall_handler();
SETGATE(idt[T_SYSCALL], 0, GD_KT, syscall_handler, 3);

//在kern/trap.c的trap_dispatch()函数中添加
case T_SYSCALL:
		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);	//----|

//在kern/syscall.c的syscall()函数中添加
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	// panic("syscall not implemented");

	switch (syscallno)
	{
	case SYS_cputs:
		sys_cputs((char *)a1, (size_t)a2);
		return 0;
	case SYS_cgetc:
		return sys_cgetc();
	case SYS_getenvid:
		return sys_getenvid();
	case SYS_env_destroy:
		return sys_env_destroy((envid_t)a1);
	default:
		return -E_INVAL;
	}
}

在这里,kernlib含有很多相同的函数,弄得云里雾里的。下面将以user/hello.c为例,详细说明系统调用过程:

首先必须明确一点,kern里的函数只能是由内核进行调用,用户不能直接调用。用户能够调用的是lib中的函数,通过调用lib中的函数向内核发出系统调用,最后由内核调用kern中的函数来完成用户要求
系统调用过程

User-mode startup

用户程序真正开始运行的地方在lib/entry.S文件中。该文件中,首先会进行一些设置,然后就会调用lib/libmain.c文件中的libmain()函数。修改一下libmain()函数,使它能够初始化全局指针 thisenv,让它指向当前用户环境的Env结构体:

通过查看inc/env.h知道:env_id的值包含三部分,第31位被固定为0;第1030这21位是标识符,标示这个用户环境;第09位代表这个用户环境所采用的 Env 结构体,在envs数组中的索引。

void libmain(int argc, char **argv)
{
	// set thisenv to point at our Env structure in envs[].
	// LAB 3: Your code here.
	thisenv = &envs[ENVX(sys_getenvid())];

	// 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();
}

Page faults and memory protection

系统调用也为内存保护带来了问题。大部分系统调用接口让用户程序传递一个指针参数给内核。这些指针指向的是用户缓冲区。通过这种方式,系统调用在执行时就可以解引用这些指针。但是这里有两个问题:

  1. 在内核中的page fault要比在用户程序中的page fault更严重。如果内核在操作自己的数据结构时出现 page faults,这是一个内核的bug,而且异常处理程序会中断整个内核。但是当内核在解引用由用户程序传递来的指针时,它需要一种方法去记录此时出现的任何page faults都是由用户程序带来的。

  2. 内核通常比用户程序有着更高的内存访问权限。用户程序很有可能要传递一个指针给系统调用,这个指针指向的内存区域是内核可以进行读写的,但是用户程序不能。此时内核必须小心不要去解析这个指针,否则的话内核的重要信息很有可能被泄露。

修改kern/trap.c文件,使其能够实现:当在内核模式下发现页错误,trap.c文件会panic:

  1. 首先如果页错误发生在内核态时应该直接panic。

    //在page_fault_handler中添加
    if ((tf->tf_cs & 3) == 0)//发生在内核态
    		panic("page_falut in kernel mode, fault address %d\n", fault_va);
    
  2. 实现kern/pmap.c中的user_mem_check()工具函数,该函数检测用户环境是否有权限访问线性地址区域[va, va+len)。然后对在kern/syscall.c中的系统调用函数使用user_mem_assert()工具函数进行内存访问权限检查。

    int user_mem_check(struct Env *env, const void *va, size_t len, int perm)
    {
    	// LAB 3: Your code here.
    	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 ||	//ULIM以上为内核空间
    			!pte ||				//对应的页表项为NULL
    			!(*pte & PTE_P) ||	//对应的页表项无效
    			((*pte & perm) != perm))//对应的页表项不满足perm权限
    		{
    			user_mem_check_addr = start > (size_t)va ? start : (size_t)va;
    			return -E_FAULT;
    		}
    
    		start += PGSIZE;
    	}
    	return 0;
    }
    

    kern/syscall.c中,对需要进行指针检测的添加user_mem_assert()函数:

    static void
    sys_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_assert(curenv, s, len, 0);
    
    	// Print the string supplied by the user.
    	cprintf("%.*s", len, s);
    }
    

缺页错误补充:

缺页中断会交给Page_Fault_Handler处理,其根据缺页中断的不同类型会进行不同的处理:

  • Hard Page Fault 也被称为Major Page Fault,翻译为硬缺页错误/主要缺页错误,这时物理内存中没有对应的页帧,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立VA和PA的映射
  • Soft Page Fault 也被称为Minor Page Fault,翻译为软缺页错误/次要缺页错误,这时物理内存中是存在对应页帧的,只不过可能是其他进程调入的,发出缺页异常的进程不知道而已,此时MMU只需要建立映射即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区域
  • Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,又比如对空指针解引用内核就会报segment fault错误中断进程直接挂掉

参考文章

MIT 6.828 JOS学习笔记17. Lab 3.1 Part A User Environments - fatsheep9146 - 博客园 (cnblogs.com)

MIT 6.828 JOS学习笔记18. Lab 3.2 Part B: Page Faults, Breakpoints Exceptions, and System Calls - fatsheep9146 - 博客园 (cnblogs.com)

MIT-6.828-JOS-lab3:User Environments - gatsby123 - 博客园 (cnblogs.com)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

nobugnolife

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值