MIT6.828——Lab 4: Preemptive Multitasking

Part A: Multiprocessor Support and Cooperative Multitasking

  • 使JOS支持多处理器(CPU)
  • 实现新的系统调用——允许用户级环境创建其他环境
  • 实现协作轮询调度——允许内核从一个主动放弃CPU的环境切换到另一个环境

Multiprocessor Support

把SMP技术应用在单个CPU中,为了与SMP区分,单个物理CPU内,等同原来单个CPU的模块称之为Core(核心),这样的CPU称之为多核CPU。

我们将让 JOS 支持对称多处理器(symmetric multiprocessing,SMP),这是一种多处理器模型,其中所有CPU都具有对系统资源(如内存和I / O总线)的等效访问。虽然所有CPU在SMP中功能相同,但在引导过程中它们可分为两种类型:

  • 引导处理器(BSP):负责初始化系统和引导操作系统;
  • 应用程序处理器(AP):只有在操作系统启动并运行后,BSP才会激活应用程序处理器。

在SMP系统中,每个CPU都有一个附带的本地APIC(LAPIC)单元。

APIC:Advanced Programmable Interrupt Controller高级可编程中断控制器 。APIC 是装置的扩充组合用来驱动 Interrupt 控制器 。在目前的建置中,系统的每一个部份都是经由 APIC Bus 连接的。“本机 APIC” 为系统的一部份,负责传递 Interrupt 至指定的处理器;举例来说,当一台机器上有三个处理器则它必须相对的要有三个本机 APIC。

LAPIC单元负责在整个系统中提供中断。 LAPIC还为其连接的CPU提供唯一标识符。 在本实验中,我们使用LAPIC单元的以下基本功能(在kern/lapic.c中):

  • 根据LAPIC识别码(APIC ID)区别我们的代码运行在哪个CPU上。(cpunum()
  • 从BSP向APs发送STARTUP处理器间中断(IPI)去唤醒其他的CPU。(lapic_startap()
  • 在Part C,我们编写LAPIC的内置定时器来触发时钟中断,以支持抢占式多任务(pic_init())。

MMIO

处理器通过内存映射IO(MMIO)的方式访问它的LAPIC。在MMIO中,一部分物理地址被硬连接到一些IO设备的寄存器,导致操作内存的指令load/store可以直接操作设备的寄存器。

内存映射IO与端口映射IO

MMIO(Memory mapping I/O)即内存映射I/O,它是PCI规范的一部分,I/O设备被放置在内存空间而不是I/O空间。从处理器的角度看,内存映射I/O后系统设备访问起来和内存一样。这样访问AGP/PCI-E显卡上的帧缓存,BIOS,PCI设备就可以使用读写内存一样的汇编指令完成,简化了程序设计的难度和接口的复杂性。

PortIO和MMIO 的主要区别
1)前者不占用CPU的物理地址空间,后者占有(这是对x86架构说的,一些架构,如IA64,port I/O占用物理地址空间)。

2)前者是顺序访问。也就是说在一条I/O指令完成前,下一条指令不会执行。例如通过Port I/O对设备发起了操作,造成了设备寄存器状态变化,这个变化在下一条指令执行前生效。uncache的MMIO通过uncahce memory的特性保证顺序性。

3)使用方式不同

由于port I/O有独立的64K I/O地址空间,但CPU的地址线只有一套,所以必须区分地址属于物理地址空间还是I/O地址空间。

kern/pmap.c::mmio_map_region(physaddr_t pa, size_t size)

将[pa,pa+size)映射到MMIO区域

在前面的lab中提到虚拟内存的分布图(memlayout.h中),有一部分是[MMIOBASE,MMIOLIM]——这就是内存映射IO区

void *
mmio_map_region(physaddr_t pa, size_t size)
{
	// Where to start the next region.  Initially, this is the
	// beginning of the MMIO region.  Because this is static, its
	// value will be preserved between calls to mmio_map_region
	// (just like nextfree in boot_alloc).
	static uintptr_t base = MMIOBASE;

	// Your code here:
	void *start = (void *)base;
	size = ROUNDUP(size, PGSIZE);//对齐
	if (base + size >= MMIOLIM || base + size < base)//越界
		panic("mmio_map_region failed: size is too big!\n");
	boot_map_region(kern_pgdir, base, size, pa, PTE_W | PTE_PCD | PTE_PWT);
	base += size;
	return start;
	// panic("mmio_map_region not implemented");
}

Application Processor Bootstrap

在启动APs之前,BSP应该先收集关于多处理器系统的配置信息,比如CPU总数,CPUs的APIC ID,LAPIC单元的MMIO地址等。在kern/mpconfig.c文件中的mp_init()函数通过读BIOS设定的MP配置表获取这些信息。
boot_aps(kern/init.c)函数驱使AP引导程序。APs开始于实模式,跟BSP的开始相同,故此boot_aps函数拷贝AP入口代码(kern/mpentry.S)到实模式下的内存寻址空间。但是跟BSP不一样的是,我们需要有一些控制当AP开始执行时。我们将拷贝入口代码到0x7000(MPENTRY_PADDR)。
之后,boot_aps函数通过发送STARTUP的IPI(处理器间中断)信号到AP的LAPIC单元来一个个地激活AP。在kern/mpentry.S中的入口代码跟boot/boot.S中的代码类似。在一些简短的配置后,它使AP进入开启分页机制的保护模式,调用C语言的mp_main函数进行setup。

为AP的启动代码保存一个物理页,不能被其他使用:

	// LAB 4:
	// Change your code to mark the physical page at MPENTRY_PADDR
	// as in use
	else if (i == mpentry)
	{
		pages[i].pp_ref = 1;
		pages[i].pp_link = NULL;
	}

多CPU下的page_init

整理一下程序运行过程,此过程一直都运行在CPU0,即BSP上,工作在保护模式。

  • i386_init调用了boot_aps(),也就是在BSP中引导其他CPU开始运行
  • boot_aps调用memmove将每个CPU的boot代码加载到固定位置
  • 最后调用lapic_startap执行其bootloader启动对应的CPU

Per-CPU State and Initialization

在kern/cpu.h中定义了大部分的per-CPU状态。每个CPU独有的变量应该有:

  1. 内核栈:不同的核可能同时进入到内核中执行,因此需要有不同的内核栈
  2. TSS和TSS描述符:每个CPU都需要单独的TSS和TSS描述符来指定该CPU对应的内核栈。
  3. 进程结构指针:每个CPU都会独立运行一个进程的代码,所以需要Env指针。
  4. 系统寄存器:比如cr3, gdt, ltr这些寄存器都是每个CPU私有的,每个CPU都需要单独设置。

任务状态段TSS及TSS描述符、局部描述符表LDT及LDT描述符

GDT_LDT_TSS

kern/pmap.c : :mem_init_mp:

将内核栈线性地址映射到percpu_kstacks处的物理地址处。

static void
mem_init_mp(void)
{
	// LAB 4: Your code here:
	int i;
	uintptr_t kstacktop_i;
	for (i = 0; i < NCPU; ++i)
	{
		kstacktop_i = KSTACKTOP - i * (KSTKGAP + KSTKSIZE);
		boot_map_region(kern_pgdir,
						kstacktop_i - KSTKSIZE,
						KSTKSIZE,
						PADDR(&percpu_kstacks[i]),
						PTE_W);
	}
}

kern/trap.c : :trap_init_percpu:

由于有多个CPU,所以在这里不能使用原先的全局变量ts,应该利用thiscpu指向的CpuInfo结构体和cpunum函数来为每个核的TSS进行初始化

// Initialize and load the per-CPU TSS and IDT
void trap_init_percpu(void)
{
	// LAB 4: Your code here:

	// Setup a TSS so that we get the right stack
	// when we trap to the kernel.
	int i = cpunum();
	thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
	thiscpu->cpu_ts.ts_ss0 = GD_KD;
	thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);

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

	// Load the TSS selector (like other segment selectors, the
	// bottom three bits are special; we leave them 0)
	ltr(GD_TSS0 + (i << 3));

	// Load the IDT
	lidt(&idt_pd);
}

Locking

在mp_main函数中初始化AP后,代码就会进入自旋。在让AP进行更多操作之前,我们首先要解决多CPU同时运行在内核时产生的竞争问题。最简单的办法是实现1个大内核锁,1次只让一个进程进入内核模式,当离开内核时释放锁。

在kern/spinlock.h中声明了大内核锁,提供了lock_kernel和unlock_kernel函数来快捷地获得和释放锁。总共有四处用到大内核锁:

  • i386_init()在启动的时候,BSP启动其余的CPU之前,BSP需要取得内核锁
  • mp_main中,也就是CPU被启动之后执行的第一个函数,这里应该是调用调度函数,选择一个进程来执行的,但是在执行调度函数之前,必须获取锁
  • trap函数也要修改,因为可以访问临界区的CPU只能有一个,所以从用户态陷入到内核态的话,要加锁,因为可能多个CPU同时陷入内核态
  • env_run函数,也就是启动进程的函数,之前在试验3中实现的,在这个函数执行结束之后,就将跳回到用户态,此时离开内核,也就是需要将内核锁释放

加锁后,将原有的并行执行过程在关键位置变为串行执行过程,整个启动过程大概如下:

i386_init–>BSP获得锁–>boot_ap–>(BSP建立为每个cpu建立idle任务、建立用户任务,mp_main)—>BSP的sched_yield–>其中的env_run释放锁–>AP1获得锁–>执行sched_yield–>释放锁–>AP2获得锁–>执行sched_yield–>释放锁……

spin lock VS sleep lock

  • 当一个进程获取spin lock时,如果发现锁住的情况,则会进入忙等待,一直占用CPU而不主动让出,知道解锁或时间片用完
  • 当一个进程获取sleep lock时,如果发现锁住的情况,则会进入阻塞状态,主动让出CPU,等待下次被唤醒,再检查锁情况

Round-Robin Scheduling

现在需要让CPU能在进程间切换,需要完成两件事:

  • 实现sched_yield(),该函数选择一个新的进程运行,从当前正在运行进程对应的Env结构下一个位置开始循环搜索envs数组,找到第一个cpu_status为ENV_RUNNABLE的Env结构,然后调用env_run()在当前CPU运行这个新的进程。

    // Choose a user environment to run and run it.
    void sched_yield(void)
    {
    	struct Env *idle;
    
    	// LAB 4: Your code here.
    	idle = curenv;
    	size_t i = idle != NULL ? ENVX(idle->env_id) + 1 : 0;
    	size_t j;
    	for (j = 0; j != NENV; j++, i = (i + 1) % NENV)
    	{
    		if (envs[i].env_status == ENV_RUNNABLE)
    		{
    			envs[i].env_cpunum = cpunum();
    			env_run(envs + i);
    		}
    	}
    	if (idle && idle->env_status == ENV_RUNNING)
    	{
    		curenv->env_cpunum = cpunum();
    		env_run(idle);
    	}
    	// sched_halt never returns
    	sched_halt();
    }
    
  • 实现一个新的系统调用sys_yield(),使得用户程序能在用户态通知内核,当前进程希望主动让出CPU给另一个进程。

    kern/syscall.c::syscall中添加:

    case SYS_yield:
    		sys_yield();
    

System Calls for Environment Creation

目前,内核可以在多进程间切换,但仅限于内核创建好的进程。

因此,接下来,为进程提供系统调用,从而使进程能创建新进程。

现在需要实现如下系统调用:

  1. sys_exofork():创建一个新的进程,用户地址空间没有映射,不能运行,寄存器状态和父环境一致。在父进程中sys_exofork()返回新进程的envid,子进程返回0。

    static envid_t
    sys_exofork(void)
    {
    	// LAB 4: Your code here.
    	struct Env *e;
    	int ret = env_alloc(&e, curenv->env_id);
    	if (ret < 0)
    		return ret;
    	e->env_status = ENV_NOT_RUNNABLE;
    	e->env_tf = curenv->env_tf;
    	e->env_tf.tf_regs.reg_eax = 0;//前面的lab说过,eax作为系统调用号,也作为返回值,子进程会返回零
    	return e->env_id;
    	// panic("sys_exofork not implemented");
    }
    
  2. sys_env_set_status:设置一个特定进程的状态为ENV_RUNNABLEENV_NOT_RUNNABLE

    sys_env_set_status(envid_t envid, int status)
    {
    	// LAB 4: Your code here.
    	struct Env *e;
    	int ret = envid2env(envid, &e, 1);
    	if (ret < 0)
    		return ret;
    	if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE)
    		return -E_INVAL;
    	e->env_status = status;
    	return 0;
    	// panic("sys_env_set_status not implemented");
    }
    
  3. sys_page_alloc:为特定进程分配一个物理页,映射指定线性地址va到该物理页。

    static int
    sys_page_alloc(envid_t envid, void *va, int perm)
    {
    	// LAB 4: Your code here.
    	struct Env *e;
    	int ret = envid2env(envid, &e, 1);
    	if (ret < 0)
    		return ret;
    	if ((uintptr_t)va >= UTOP || PGOFF(va))
    		return -E_INVAL;
    	int flag = PTE_U | PTE_P;
    	if ((perm & flag) != flag || (perm & ~(PTE_SYSCALL)) != 0)
    		return -E_INVAL;
    	struct PageInfo *pg = page_alloc(1);
    	if (pg == NULL)
    		return -E_NO_MEM;
    	if (page_insert(e->env_pgdir, pg, va, perm) < 0)
    	{
    		page_free(pg);
    		return -E_NO_MEM;
    	}
    	return 0;
    	// panic("sys_page_alloc not implemented");
    }
    
  4. sys_page_map:拷贝页表,使指定进程共享当前进程相同的映射关系。本质上是修改特定进程的页目录和页表。

    static int
    sys_page_map(envid_t srcenvid, void *srcva,
    			 envid_t dstenvid, void *dstva, int perm)
    {
    	// LAB 4: Your code here.
    	struct Env *se, *de;
    	int ret = envid2env(srcenvid, &se, 1);
    	if (ret < 0)
    		return ret;
    	ret = envid2env(dstenvid, &de, 1);
    	if (ret < 0)
    		return ret;
    	if ((uintptr_t)srcva >= UTOP || PGOFF(srcva) ||
    		(uintptr_t)dstva >= UTOP || PGOFF(dstva))
    		return -E_INVAL;
    	int flag = PTE_U | PTE_P;
    	if ((perm & flag) != flag || (perm & ~(PTE_SYSCALL)) != 0)
    		return -E_INVAL;
    	pte_t *entry;
    	struct PageInfo *pg = page_lookup(se->env_pgdir, srcva, &entry);
    	if (pg == NULL)
    		return -E_INVAL;
    	if ((perm & PTE_W) && (*entry & PTE_W) == 0)
    		return -E_INVAL;
    	if (page_insert(de->env_pgdir, pg, dstva, perm) < 0)
    		return -E_NO_MEM;
    	return 0;
    	// panic("sys_page_map not implemented");
    }
    
  5. sys_page_unmap:解除页映射关系。本质上是修改指定用户环境的页目录和页表。

    static int
    sys_page_unmap(envid_t envid, void *va)
    {
    	// Hint: This function is a wrapper around page_remove().
    
    	// LAB 4: Your code here.
    	struct Env *e;
    	int ret = envid2env(envid, &e, 1);
    	if (ret < 0)
    		return ret;
    	if ((uintptr_t)va >= UTOP || PGOFF(va))
    		return -E_INVAL;
    	page_remove(e->env_pgdir, va);
    	return 0;
    	// panic("sys_page_unmap not implemented");
    }
    

Part B: Copy-on-Write Fork

一般fork()会将父进程的地址空间完全复制到子进程中,但子进程通常会立即调用exec()函数族,用新的程序替换复制的内容。因此,复制父进程是非常浪费时间的。

写时复制(COW):将父进程的地址映复制给子进程,即父子进程共享同一个地址空间,将共享的空间标记为只读并设置COW位。当其中一个进程尝试写入共享页面时,该进程会出现页错误,从而进入内核进行处理。

User-level page fault handling

用户级写时复制需要了解写保护页面上的页面错误,写时复制只是用户级缺页中断处理的一种。

通常建立地址空间以便page fault提示何时需要执行某些操作。例如大多数Unix内核初始只给新进程的栈映射一个页,以后栈增长会导致page fault从而映射新的页。一个典型的Unix内核必须记录在进程地址空间的不同区域发生page fault时,应该执行什么操作。例如栈上缺页,会实际分配和映射新的物理内存。BSS区域缺页会分配新的物理页,填充0,然后映射。这种设计在定义他们的内存区域的时候具有极大的灵活度。

Setting the Page Fault Handler

1个用户级写时拷贝的fork函数需要在写保护页时触发page fault,所以我们第一步应该先规定或者确立一个page fault处理例程,每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可,sys_env_set_pgfault_upcall函数将当前进程的page fault处理例程设置为func指向的函数。

kern/syscall.c : :sys_env_set_pgfault_upcall(envid_t envid, void *func):

该系统调用为指定的用户环境设置env_pgfault_upcall。缺页中断发生时,会执行env_pgfault_upcall指定位置的代码。当执行env_pgfault_upcall指定位置的代码时,栈已经转到异常栈,并且压入了UTrapframe结构。

static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
	// LAB 4: Your code here.
	struct Env *e;
	if (envid2env(envid, &e, 1) < 0)
		return -E_BAD_ENV;
	e->env_pgfault_upcall = func;
	return 0;
	// panic("sys_env_set_pgfault_upcall not implemented");
}

//还要将其加入到syscall()函数中
case SYS_env_set_pgfault_upcall:
		return sys_env_set_pgfault_upcall(a1, (void *)a2);

Normal and Exception Stacks in User Environments

在正常运行期间,用户进程运行在用户栈上,栈顶寄存器ESP指向USTACKTOP处,堆栈数据位于USTACKTOP-PGSIZE 与USTACKTOP-1之间的页。当在用户模式发生1个page fault时,内核将在专门处理page fault的用户异常栈上重新启动进程。

到目前为止出现三个栈:

[KSTACKTOP-KSTKSIZE,  KSTACKTOP) 
  内核态系统栈

[UXSTACKTOP - PGSIZE, UXSTACKTOP )
  用户态错误处理栈

[UTEXT, USTACKTOP)
  用户态运行栈

Invoking the User Page Fault Handler

用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈,整个过程如下:

  1. 首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler()
  2. 当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe的大小。
  3. 把栈切换到用户错误栈,运行响应的用户中断处理程序,中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理。处理完成之后,返回到用户运行栈。

page_fault_handler(struct Trapframe *tf)

在该函数中应该做如下几件事:

  1. 判断curenv->env_pgfault_upcall是否设置,如果没有设置也就没办法修复,直接销毁该进程。

  2. 判断esp位置,切换到用户异常栈。

    ​ 当正常执行过程中发生了页错误,那么栈的切换是
       用户运行栈—>内核栈—>异常栈
      而如果在异常处理程序中发生了也错误,那么栈的切换是
       异常栈—>内核栈—>异常栈

  3. 在异常栈上压入一个UTrapframe结构,其结构如下:

                        <-- UXSTACKTOP
    trap-time esp
    trap-time eflags
    trap-time eip
    trap-time eax	----|       start of struct PushRegs
    trap-time ecx		|
    trap-time edx		|
    trap-time ebx		|
    trap-time esp		|
    trap-time ebp		|
    trap-time esi		|
    trap-time edi	----|       end of struct PushRegs
    tf_err (error code)
    fault_va            <-- %esp when handler is run
    
  4. 将eip设置为curenv->env_pgfault_upcall,然后回到用户态执行curenv->env_pgfault_upcall处的代码。

void page_fault_handler(struct Trapframe *tf)
{
	uint32_t fault_va;

	// Read processor's CR2 register to find the faulting address
	fault_va = rcr2();

	// Handle kernel-mode page faults.
	// LAB 3: Your code here.
	if ((tf->tf_cs & 3) == 0)
		panic("page_falut in kernel mode, fault address %d\n", fault_va);

	// LAB 4: Your code here.
	struct UTrapframe *utf;
	if (curenv->env_pgfault_upcall) //判断
	{
		if (tf->tf_esp >= UXSTACKTOP - PGSIZE && tf->tf_esp < UXSTACKTOP)
		{
			// on exception user
			utf = (struct UTrapframe *)(tf->tf_esp - sizeof(struct UTrapframe) - 4);
		}
		else
		{
			// on normal user
			utf = (struct UTrapframe *)(UXSTACKTOP - sizeof(struct UTrapframe));
		}
		//检查异常栈是否溢出
		user_mem_assert(curenv, (const void *)utf, sizeof(struct UTrapframe), PTE_P | PTE_W | PTE_U);
		utf->utf_fault_va = fault_va;
		utf->utf_err = tf->tf_err;
		utf->utf_regs = tf->tf_regs;
		utf->utf_eflags = tf->tf_eflags;

        //保存上下文,方便从异常栈退出时找到之前的位置
		utf->utf_eip = tf->tf_eip;
		utf->utf_esp = tf->tf_esp;
		
        //切换下一个指令:即运行处理函数
		curenv->env_tf.tf_eip = (uint32_t)curenv->env_pgfault_upcall;
        //切换到异常栈上
		curenv->env_tf.tf_esp = (uint32_t)utf;
		env_run(curenv);
	}
	else
	{
		// Destroy the environment that caused the fault.
		cprintf("[%08x] user fault va %08x ip %08x\n",
				curenv->env_id, fault_va, tf->tf_eip);
		print_trapframe(tf);
		env_destroy(curenv);
	}
}

压入异常栈过程:

异常栈压入过程

User-mode Page Fault Entrypoint

用户page fault注册流程:

在这里插入图片描述

当从用户定义的处理函数返回之后,如何从用户错误栈直接返回到用户运行栈。

lib/pfentry.S : :_pgfault_upcall:

是所有用户页错误处理程序的入口,在这里调用用户自定义的处理程序,并在处理完成后,从错误栈中保存的UTrapframe中恢复相应信息,然后跳回到发生错误之前的指令,恢复原来的进程运行。

_pgfault_handler完成但返回到先前状态之前的内容:
在这里插入图片描述

_pgfault_upcall:
	// Call the C page fault handler.
	pushl %esp			// function argument: pointer to UTF
	movl _pgfault_handler, %eax
	call *%eax
	addl $4, %esp			// pop function argument
	
	//当_pgfault_handler处理结束后,需要返回到之前的状态
	// LAB 4: Your code here.
	movl 48(%esp),%eax	//取出栈中的trap-time esp,即上文esp
	subl $4,%eax		// 将其减4,如果是用户栈的esp则自动空一格
						//如果是异常栈的esp,因前面保存了一个4字节空间,所以正合适
	movl %eax,48(%esp)	//再存回trap-time esp中
	movl 40(%esp),%ebx	//取出上文eip
	movl %ebx,(%eax)	//放入上文esp指向的空间,即保留的4字节空间

	// Restore the trap-time registers.  After you do this, you
	// can no longer modify any general-purpose registers.
	// LAB 4: Your code here.
	addl $8,%esp	//将esp上移两个4字节,此时指向struct PushRegs end
	popal			//将struct PushRegs整个pop出来,此时esp指向trap-time eip

	// Restore eflags from the stack.  After you do this, you can
	// no longer use arithmetic operations or anything else that
	// modifies eflags.
	// LAB 4: Your code here.
	addl $4,%esp	//上移一个4字节,此时esp指向trap-time eflags
	popfl			//将efalgs pop出来,此时指向tarp-time esp

	// Switch back to the adjusted trap-time stack.
	// LAB 4: Your code here.
	popl %esp	//将trap-time esp(即上文esp) pop出来,由esp寄存器保存
				//此时指向的是预留的那4字节,由上面可知,保存了上文eip

	// Return to re-execute the instruction that faulted.
	// LAB 4: Your code here.
	ret			//ret会读取esp指向的内容,并保存在eip中,即将上文eip保存在eip寄存器中
lib/pgfault.c::set_pgfault_handler

用户通过调用此函数进行设置pgfault_handler

void set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
	int r;

	if (_pgfault_handler == 0)
	{
		// First time through!
		// LAB 4: Your code here.
		if ((r = sys_page_alloc(thisenv->env_id,
								(void *)(UXSTACKTOP - PGSIZE),
								PTE_P | PTE_W | PTE_U)) < 0)
			panic("set_pgfault_handler: %e", r);
		sys_env_set_pgfault_upcall(thisenv->env_id, _pgfault_upcall);
		// panic("set_pgfault_handler not implemented");
	}

	// Save handler pointer for assembly to call.
	_pgfault_handler = handler;
}

Implementing Copy-on-Write Fork

dumfork不同的是,fork 只复制了页映射关系,并且只在尝试对页进行写操作时才进行页内容的 copy。

fork控制流如下:

  1. 父进程注册handler
  2. 父进程call sys_exofork 创建子进程
  3. 对每个处于 UTOP之下的可写或COW页进行复制duppage它应该将 copy-on-write 页面映射到子进程的地址空间,然后重新映射 copy-on-write 页面到它自己的地址空间。

此处的顺序十分重要——先将子进程的页标记为COW,然后将父进程的页标记为COW

猜测:如果先将父进程标记为COW,此时,如果有另一个进程抢占CPU,那么此时的子进程此时的状态并没有COW,则如果子进程被改变时,并不会发生COW,以至于修改了与父进程共享的内存

lib/fork.c::pgfault(struct UTrapframe *utf)

  1. pgfault()检查错误是否为写入FEC_WR(在错误代码中检查),并且页面的 PTE 是否标记为 PTE_COW。如果没有,请panic。
  2. pgfault()在临时位置分配映射的新页面,并将错误页面的内容复制到其中。然后,错误处理程序使用读/写权限将新页面映射到会修改该页的进程中的地址,以代替旧的只读映射。
static void
pgfault(struct UTrapframe *utf)
{
	void *addr = (void *)utf->utf_fault_va;
	uint32_t err = utf->utf_err;
	int r;

	// LAB 4: Your code here.
	if (!((err & FEC_WR) && (uvpt[PGNUM(addr)] & PTE_COW)))
		panic("pgfault:not writabled or a COW page!\n");

	// LAB 4: Your code here.
	envid_t envid = sys_getenvid();
    //为PFTEMP分配一个物理页
	if ((r = sys_page_alloc(envid, (void *)PFTEMP, PTE_P | PTE_W | PTE_U)) < 0)
		panic("pgfault:page allocation failed: %e", r);
	addr = ROUNDDOWN(addr, PGSIZE);
    //将addr上的物理页内容拷贝到PFTEMP指向的物理页上
	memmove((void *)PFTEMP, (void *)addr, PGSIZE);
    //更改addr映射的物理页,改为与PFTEMP指向相同
	if ((r = sys_page_map(envid, PFTEMP, envid, addr, PTE_P | PTE_W | PTE_U)) < 0)
		panic("pgfault:page map failed: %e", r);
    //取消PFTEMP的映射
	if ((r = sys_page_unmap(envid, PFTEMP)) < 0)
		panic("pgfault: page unmap failed: %e", r);

	// panic("pgfault not implemented");
}

lib/fork.c::duppage(envid_t envid, unsigned pn):

进行COW方式的页复制(相当于复制映射,而不是复制页本身),所以将当前进程的第pn页对应的物理页映射到envid的第pn页上去,同时将这一页都标记为COW。

static int
duppage(envid_t envid, unsigned pn)
{
	int r;

	// LAB 4: Your code here.
	void *addr = (void *)(pn * PGSIZE);
	if ((uvpt[pn] & PTE_W) || (uvpt[pn] & PTE_COW))
	{
		if ((r = sys_page_map(0, addr, envid, addr, PTE_P | PTE_U | PTE_COW)) < 0)
			return r;
		if ((r = sys_page_map(0, addr, 0, addr, PTE_P | PTE_U | PTE_COW)) < 0)
			return r;
	}
	else if ((r = sys_page_map(0, addr, envid, addr, PTE_P | PTE_U)) < 0)
		return r;
	// panic("duppage not implemented");
	return 0;
}

lib/fork.c::fork(void)

设置异常处理函数,创建子进程,映射页面到子进程,为子进程分配用户异常栈并设置 pgfault_upcall 入口,将子进程设置为可运行的

envid_t
fork(void)
{
	// LAB 4: Your code here.
	set_pgfault_handler(pgfault); //为进程创建异常栈,并设置处理函数
	envid_t eid = sys_exofork();  //创建一个空进程,与父进程状态一致
	if (eid < 0)
		panic("fork failed: sys_exofork faied: %e", eid);
	if (eid == 0)
	{
		// child
		thisenv = &envs[ENVX(sys_getenvid())];
		return 0;
	}
	// parent
	size_t pn;
	int r;
	for (pn = PGNUM(UTEXT); pn < PGNUM(USTACKTOP); ++pn)
	{
		if ((uvpd[pn >> 10] & PTE_P) && (uvpt[pn] & PTE_P))
		{
			if ((r = duppage(eid, pn)) < 0)
				return r;
		}
	}
	// alloc a page and map for child exception stack
    //每个进程都必须有自己的异常栈,这样才能进行COW
	if ((r = sys_page_alloc(eid, (void *)(UXSTACKTOP - PGSIZE), PTE_U | PTE_P | PTE_W)) < 0)
		return r;
	extern void _pgfault_upcall(void);
	if ((r = sys_env_set_pgfault_upcall(eid, _pgfault_upcall)) < 0)
		return r;
	if ((r = sys_env_set_status(eid, ENV_RUNNABLE)) < 0)
		panic("sys_env_set_status failed: %e", r);
	return eid;
	// panic("fork not implemented");
}

Part C: Preemptive Multitasking and Inter-Process communication (IPC)

实现抢占非协作式环境,并且实现进程间通信

Clock Interrupts and Preemption

如果一个进程获得CPU后一直死循环而不主动让出CPU的控制权, 整个系统都将 halt。为了允许内核抢占正在运行的环境,强行重获CPU控制权,我们必须扩展JOS内核以支持来自时钟的外部硬件中断。

Interrupt discipline

外部中断(如设备中断)被称为 IRQs。 IRQ号到 IDT 项的映射不是固定的,其会加上一个IRQ_OFFSET的偏移,在picirq.c pic_init中进行了这个映射过程。外部中断的初始化,实际上就是对硬件 8259A的初始化。

修改kern/trapentry.Skern/trap.c 来初始化IDT中IRQs0-15的入口和处理函数。然后修改env_alloc函数来确保进程在用户态运行时中断是打开的:

//kern/trapentry.S
TRAPHANDLER_NOEC(irq0_handler,IRQ_OFFSET+0)
TRAPHANDLER_NOEC(irq1_handler,IRQ_OFFSET+1)
TRAPHANDLER_NOEC(irq2_handler,IRQ_OFFSET+2)
TRAPHANDLER_NOEC(irq3_handler,IRQ_OFFSET+3)
TRAPHANDLER_NOEC(irq4_handler,IRQ_OFFSET+4)
TRAPHANDLER_NOEC(irq5_handler,IRQ_OFFSET+5)
TRAPHANDLER_NOEC(irq6_handler,IRQ_OFFSET+6)
TRAPHANDLER_NOEC(irq7_handler,IRQ_OFFSET+7)
TRAPHANDLER_NOEC(irq8_handler,IRQ_OFFSET+8)
TRAPHANDLER_NOEC(irq9_handler,IRQ_OFFSET+9)
TRAPHANDLER_NOEC(irq10_handler,IRQ_OFFSET+10)
TRAPHANDLER_NOEC(irq11_handler,IRQ_OFFSET+11)
TRAPHANDLER_NOEC(irq12_handler,IRQ_OFFSET+12)
TRAPHANDLER_NOEC(irq13_handler,IRQ_OFFSET+13)
TRAPHANDLER_NOEC(irq14_handler,IRQ_OFFSET+14)
TRAPHANDLER_NOEC(irq15_handler,IRQ_OFFSET+15)

//kern/trap.c 
void irq0_handler();
void irq1_handler();
void irq2_handler();
void irq3_handler();
void irq4_handler();
void irq5_handler();
void irq6_handler();
void irq7_handler();
void irq8_handler();
void irq9_handler();
void irq10_handler();
void irq11_handler();
void irq12_handler();
void irq13_handler();
void irq14_handler();
void irq15_handler();
//trap_init()
SETGATE(idt[IRQ_OFFSET + 0], 0, GD_KT, irq0_handler, 0);
SETGATE(idt[IRQ_OFFSET + 1], 0, GD_KT, irq1_handler, 0);
SETGATE(idt[IRQ_OFFSET + 2], 0, GD_KT, irq2_handler, 0);
SETGATE(idt[IRQ_OFFSET + 3], 0, GD_KT, irq3_handler, 0);
SETGATE(idt[IRQ_OFFSET + 4], 0, GD_KT, irq4_handler, 0);
SETGATE(idt[IRQ_OFFSET + 5], 0, GD_KT, irq5_handler, 0);
SETGATE(idt[IRQ_OFFSET + 6], 0, GD_KT, irq6_handler, 0);
SETGATE(idt[IRQ_OFFSET + 7], 0, GD_KT, irq7_handler, 0);
SETGATE(idt[IRQ_OFFSET + 8], 0, GD_KT, irq8_handler, 0);
SETGATE(idt[IRQ_OFFSET + 9], 0, GD_KT, irq9_handler, 0);
SETGATE(idt[IRQ_OFFSET + 10], 0, GD_KT, irq10_handler, 0);
SETGATE(idt[IRQ_OFFSET + 11], 0, GD_KT, irq11_handler, 0);
SETGATE(idt[IRQ_OFFSET + 12], 0, GD_KT, irq12_handler, 0);
SETGATE(idt[IRQ_OFFSET + 13], 0, GD_KT, irq13_handler, 0);
SETGATE(idt[IRQ_OFFSET + 14], 0, GD_KT, irq14_handler, 0);
SETGATE(idt[IRQ_OFFSET + 15], 0, GD_KT, irq15_handler, 0);

//kern/env.c::env_alloc()
// Enable interrupts while in user mode.
// LAB 4: Your code here.
e->env_tf.tf_eflags |= FL_IF;

Handling Clock Interrupts

目前还需要在trap_dispatch()函数中添加时钟中断处理代码,这样才能重新进入内核,并切换另一个进程:

	// Handle clock interrupts. Don't forget to acknowledge the
	// interrupt using lapic_eoi() before calling the scheduler!
	// LAB 4: Your code here.
	if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER)
	{
		lapic_eoi();
		sched_yield();
	}

Inter-Process communication (IPC)

IPC in JOS

我们将要实现sys_ipc_recv()sys_ipc_try_send()这两个系统调用,来实现进程间通信。并且实现两个包装函数ipc_recv() ipc_send()
JOS中进程间通信的“消息”包含两部分:

  1. 一个32位的值。
  2. 可选的页映射关系。

Sending and Receiving Messages

sys_ipc_recv()sys_ipc_try_send()是这么协作的:

  1. 当某个进程调用sys_ipc_recv()后,该进程会阻塞(状态被置为ENV_NOT_RUNNABLE),直到另一个进程向它发送“消息”。当进程调用sys_ipc_recv()传入dstva参数时,表明当前进程准备接收页映射。
  2. 进程可以调用sys_ipc_try_send()向指定的进程发送“消息”,如果目标进程已经调用了sys_ipc_recv(),那么就发送数据,然后返回0,否则返回-E_IPC_NOT_RECV,表示目标进程不希望接受数据。当传入srcva参数时,表明发送进程希望和接收进程共享srcva对应的物理页。如果发送成功了发送进程的srcva和接收进程的dstva将指向相同的物理页。

Transferring Pages

当用户环境使用有效的dstva参数(低于UTOP)调用sys_ipc_recv时,环境表示它愿意接收页面映射。如果发送者发送一个页面,那么该页面应该在接收者的地址空间中的dstva映射。如果接收者已经在dstva上映射了一个页面,那么之前的页映射被取消。

当用户环境以有效的srcva(在UTOP下面)以及权限perm为参数调用sys_ipc_try_send时,这意味着发送者想要将当前映射到srcva的页面发送给接收者。在成功的IPC之后,发送方在其地址空间中的srcva保持页面的原始映射,但是接收方在其地址空间最初指定的dstva处获得了与发送者同一物理页的映射。因此,该页面在发送者和接收者之间共享。

如果发送者或接收者没有指示一个页面应该被传送,那么没有页面被传送。在任何IPC之后,内核将接收者的Env结构中的新字段env_ipc_perm设置为接收到的页面的权限,如果没有接收到页面,则为零。

在Env结构中增加了5个成员来实现IPC:

env_ipc_recving:当进程使用env_ipc_recv函数等待信息时,会将这个成员设置为1,然后堵塞等待;当一个进程向它发消息解除堵塞后,发送进程将此成员修改为0。

env_ipc_dstva:如果进程要接受消息并且是传送页,保存页映射的地址,且该地址<=UTOP。

env_ipc_value:若等待消息的进程接收到消息,发送方将接收方此成员设置为消息值。

env_ipc_from:发送方负责设置该成员为自己的envid号。

env_ipc_perm:如果进程要接收消息并且传送页,那么发送方发送页之后将传送的页权限赋给这个成员。

kern/syscall.c::sys_ipc_recv(void *dstva)

当一个进程试图去接收信息的时候,应该将自己标记为正在接收信息,而且为了不浪费CPU资源,应该同时标记自己为ENV_NOT_RUNNABLE,只有当有进程向自己发了信息之后,才会重新恢复可运行。最后将自己标记为不可运行之后,调用调度器运行其他进程。

static int
sys_ipc_recv(void *dstva)
{
	// LAB 4: Your code here.
	if (dstva < (void *)UTOP && PGOFF(dstva))
		return -E_INVAL;
	curenv->env_ipc_recving = 1;
	curenv->env_ipc_dstva = dstva;
	curenv->env_status = ENV_NOT_RUNNABLE;
	curenv->env_ipc_from = 0;
	sched_yield();

	// panic("sys_ipc_recv not implemented");
	return 0;
}

kern/syscall.c::sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)

权限是否符合要求,要传送的页有没有,能不能将这一页映射到对方页表中去等等。如果srcva是在UTOP之下,那么说明是要共享内存,那就首先要在发送方的页表中找到srcva对应的页表项,然后在接收方给定的虚地址处插入这个页表项。接收完成之后,重新将当前进程设置为可运行,同时把env_ipc_recving设置为0,以防止其他的进程再发送,覆盖掉当前的内容。

static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
	// LAB 4: Your code here.
	struct Env *dste;
	pte_t *pte;
	struct PageInfo *pg;
	int ret = envid2env(envid, &dste, 0);
	if (ret < 0)
		return ret;
	if (dste->env_ipc_recving == 0 || dste->env_ipc_from != 0)
		return -E_IPC_NOT_RECV;

	if (srcva < (void *)UTOP)
	{
		if (PGOFF(srcva) || (perm & PTE_P) == 0 || (perm & PTE_U) == 0 || (perm & ~(PTE_SYSCALL)))
			return -E_INVAL;
		if ((pg = page_lookup(curenv->env_pgdir, srcva, &pte)) == NULL)
			return -E_INVAL;
		if ((perm & PTE_W) && !(*pte & PTE_W))
			return -E_INVAL;
		if (dste->env_ipc_dstva)
		{
			if ((ret = page_insert(dste->env_pgdir, pg, dste->env_ipc_dstva, perm)) < 0)
				return ret;
			dste->env_ipc_perm = perm;
		}
	}
	dste->env_ipc_from = curenv->env_id;
	dste->env_ipc_recving = 0;
	dste->env_ipc_value = value;
	dste->env_status = ENV_RUNNABLE;
	dste->env_tf.tf_regs.reg_eax = 0;
	return 0;

	// panic("sys_ipc_try_send not implemented");
}

还需要在syscall函数中添加,才能最终实现系统调用:

case SYS_ipc_try_send:
		return sys_ipc_try_send((envid_t)a1, (uint32_t)a2, (void *)a3, (unsigned)a4);
	case SYS_ipc_recv:
		return sys_ipc_recv((void *)a1);

接下来实现lib/ipc.c里面的ipc_recvipc_send

int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
	// LAB 4: Your code here.
	if (pg == NULL)
		pg = (void *)UTOP;
	int ret = sys_ipc_recv(pg);
	if (from_env_store)
		*from_env_store = ret < 0 ? 0 : thisenv->env_ipc_from;
	if (perm_store)
		*perm_store = ret < 0 ? 0 : thisenv->env_ipc_perm;
	if (ret < 0)
		return ret;
	return thisenv->env_ipc_value;
	// panic("ipc_recv not implemented");
}

void ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
	// LAB 4: Your code here.
	if (pg == NULL)
		pg = (void *)UTOP;
	int ret;
	while (true)
	{
		ret = sys_ipc_try_send(to_env, val, pg, perm);
		if (ret == 0)
			return;
		else if (ret == -E_IPC_NOT_RECV)
			sys_yield();//通信失败,先主动放弃CPU,轮询运行后继续通信
		else
			panic("ipc_send failed: %e", ret);
	}
	// panic("ipc_send not implemented");
}

参考文章:

MIT6.828 Part B: Copy-on-Write Fork_bysui的博客-CSDN博客

MIT-6.828-JOS-lab4:Preemptive Multitasking - gatsby123 - 博客园 (cnblogs.com)

MIT 6.828 (四) Lab 4: Preemptive Multitasking_EW_DUST的博客-CSDN博客_lapic_eoi

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

nobugnolife

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

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

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

打赏作者

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

抵扣说明:

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

余额充值