您的位置:首页 > 运维架构

MIT6.828 Lab4 Part A: Multiprocessor Support and Cooperative Multitasking

2016-07-06 15:35 549 查看
Introduction

  在这次试验中,我们将在同时运行的多用户环境实现抢占式多任务机制。

  在Part A部分,我们将为JOS增加多处理器特性,实现轮转(RR)调度,增加用户环境管理的系统调用(创建和销毁环境,分配和映射内存)。

  Lab 4添加的源文件:

  kern/cpu.h 对多处理器支持的内核私有定义

  kern/mpconfig.c 读取多处理器配置的代码

  kern/lapic.c 引导每个CPU上的Local APIC的内核代码

  kern/mpentry.S 非启动CPU的入口汇编代码

  kern/spinlock.h 内核自旋锁定义

kern/spinlock.c 自旋锁实现的内核代码

  kern/sched.c 调度器代码框架

  

Multiprocessor Support

  我们将使JOS支持对称多处理器(SMP),一种多处理器架构,所有的CPU对等地访问系统资源。在SMP中所有CPU的功能是相同的,但是在启动过程中会被分为2类:引导处理器(BSP)负责初始化系统来启动操作系统,当操作系统被启动后,应用处理器(APs)被引导处理器激活。引导处理器是由硬件和BIOS决定的,我们目前所有的代码都是运行在BSP上。

  在1个SMP系统中,每个CPU有1个附属的Local APIC单元。LAPIC单元负责处理系统中的中断,同时为它关联的CPU提供独一无二的标识符。在这次实验中,我们将使用LAPIC单元以下基本功能(在/kern/lapic.c):

  

读取LAPIC的标识符(ID)来告诉我们正在哪个CPU上运行代码(cpunum()).

从BSP发送STARTUP的处理器间中断(IPI) 到APs来唤醒其它CPU(lapic_startup()).

在Part C部分,我们将编程LAPIC内置的计时器来触发时钟中断来支持多任务抢占(apic_init).

  处理器访通过内存映射IO(MMIO)的方式访问它的LAPIC。在MMIO中,一部分物理地址被硬连接到一些IO设备的寄存器,导致操作内存的指令load/store可以直接操作设备的寄存器。我们已经看到过1个IO hole在物理地址0xA0000(我们用这来写入VGA显示缓存)。LAPIC的空洞开始于物理地址0xFE000000(4GB之下地32MB),但是这地址太高我们无法访问通过过去的直接映射(虚拟地址0xF0000000映射0x0,即只有256MB)。但是JOS虚拟地址映射预留了4MB空间在MMIOBASE处,我们需要分配映射空间。

  Exercise 1:

  在kern/pmap.c中实现mmio_map。可以查看lapic_init函数的开头来搞清楚这是怎么实现的。

  回答:

  在lapic_init函数的开头就会调用mmio_map.

// lapicaddr is the physical address of the LAPIC's 4K MMIO
// region.  Map it in to virtual memory so we can access it.
lapic = mmio_map_region(lapicaddr, 4096);


  在kern/pmap.c中,有具体的提示,设置个静态变量记录每次变化后的虚拟基地址,使用boot_map_region函数将[pa,pa+size)的物理地址映射到[base,base+size),记得把size roundup到PGSIZE。由于这是设备内存并不是正常的DRAM,所以使用cache缓存访问是不安全的,我们可以用页的标志位来实现。

//kern/pmap.c
void *
mmio_map_region(physaddr_t pa, size_t size)
{
static uintptr_t base = MMIOBASE;
void *ret = (void *)base;

size = ROUNDUP(size, PGSIZE);
if (base + size > MMIOLIM || base + size < base)
panic("mmio_map_region: reservation overflow\n");

boot_map_region(kern_pgdir, base, size, pa, PTE_P | PTE_PCD | PTE_PWT);
base += size;

return ret;
}


Application Processor Bootstrap

  在启动APs之前,BSP应该先收集关于多处理器系统的配置信息,比如CPU总数,CPUs的APIC ID,LAPIC单元的MMIO地址等。在kern/mpconfig文件中的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语言的setup函数mp_main。

   总结:

  


   在i386init函数中进行BSP启动的一些配置,经由lab2的 mem_init,lab3的env_init和trap_init,lab4的mp_init和lapic_init,然后boot_aps函数启动所有的CPU。

   多核处理器的初始化都在mp_init函数中完成,首先是调用mpconfig函数,主要功能是寻找一个MP 配置条目,然后对所有的CPU进行配置,找到启动的处理器。

  


   在启动过程中,mp_init和lapic_init是和硬件以及体系架构紧密相关的,通过读取某个特殊内存地址(当然前提是能读取的到,所以在mem_init中需要修改进行相应映射),来获取CPU的信息,根据这些信息初始化CPU结构。

   在boot_aps函数中首先找到一段用于启动的汇编代码,该代码和上一章实验一样是嵌入在内核代码段之上的一部分,其中mpentry_start和mpentry_end是编译器导出符号,代表这段代码在内存(虚拟地址)中的起止位置,接着把代码复制到MPENTRY_PADDR处。随后调用lapic_startap来命令特定的AP去执行这段代码。

  


   Exercise 2:

   修改page_init函数(kern/page.c)的实现,来避免将MPENTRY_PADDR处的物理页加入到空闲链表中。故此我们能安全地拷贝和运行AP的启动代码。

   回答:

   在boot_aps函数中将启动代码放到了MPENTRY_PADDR处,而代码的来源则是在kern/mpentry.S中,功能与boot.S中的非常类似,主要就是开启分页,转到内核栈上去,当然这个时候实际上内核栈还没建好。在执行完mpentry.S中的代码之后,将会跳转到mp_main函数中去。而这里需要提前做的,就是将MPENTRY_PADDR处的物理页表标识为已用,这样不会讲这一页放在空闲链表中分配出去。只需要在page_init中添加一个判断就可以。

//kern/pmap.c
void page_init(void)
{
......
for (i = 0; i < npages; i++) {
......
else if (i == MPENTRY_PADDR / PGSIEZ)
continue;
......
}
}


   Qusetion 1:

   仔细比较kern/mpentry.S与boot/boot.S,想想kern/mpentry.S是被编译链接来运行在KERNBASE之上的,那么定义MPBOOTPHYS宏的目的是什么?为什么在kern/mpentry.S中是必要的,在boot/boot.S中不是呢?换句话说,如果我们在kern/mpentry.S中忽略它,会出现什么错误?

   回答:

   #define MPBOOTPHYS(s) ((s) - mpentry_start + MPENTRY_PADDR))))

   MPBOOTPHYS is to calculate symobl address relative to MPENTRY_PADDR. The ASM is executed in the load address above KERNBASE, but JOS need to run mp_main at 0x7000 address! Of course 0x7000’s page is reserved at pmap.c.

   在AP的保护模式打开之前,是没有办法寻址到3G以上的空间的,因此用MPBOOTPHYS是用来计算相应的物理地址的。

但是在boot.S中,由于尚没有启用分页机制,所以我们能够指定程序开始执行的地方以及程序加载的地址;但是,在mpentry.S的时候,由于主CPU已经处于保护模式下了,因此是不能直接指定物理地址的,给定线性地址,映射到相应的物理地址是允许的。

**Per-CPU State and Initialization

   当编写一个多进程OS时,这是非常重要的去区分哪些是每个进程私有的CPU状态,哪些是整个系统共享的全局状态。在kern/cpu.h中定义了大部分的per-CPU状态。

   每个CPU独有的变量应该有:

内核栈,因为不同的核可能同时进入到内核中执行,因此需要有不同的内核栈

TSS描述符

每个核的当前执行的任务

每个核的寄存器

   Exercise 3:

   修改mem_init_mp函数来映射开始于KSTACKTOP的per-CPU栈。

   回答:

   多CPU的内存分布

  


  

   需要为每个核都分配一个内核栈,每个内核栈的大小是KSTKSIZE,而内核栈之间的间距是KSTKGAP,起到保护作用。

//kern/pmap.c
static void
mem_init_mp(void)
{
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,
ROUNDUP(KSTKSIZE, PGSIZE),
PADDR(&percpu_kstacks[i]),
PTE_W | PTE_P);
}
}


   Exercise 4:

   在trap_init_percpu函数中为BSP初始化TSS和TSS描述符,这在Lab3中是可行的,但是在这里会有问题,修改代码使它可以运行在所以CPU上。

   回答:

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

void
trap_init_percpu(void)
{
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cpunum() * (KSTKGAP + KSTKSIZE);
thiscpu->cpu_ts.ts_ss0 = GD_KD;

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

// Load the TSS selector (like other segment selectors, the
// bottom three bits are special; we leave them 0)
ltr(GD_TSS0 + sizeof(struct Segdesc) * cpunum());

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


Locking

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

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

在启动的时候,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–>释放锁…..

   Exercise5:

   在上述位置应用大内核锁。

   回答:

//i386_init
lock_kernel();
boot_aps();

//mp_main
lock_kernel();
sched_yield();

//trap
if ((tf->tf_cs & 3) == 3) {
lock_kernel();
assert(curenv);
......
}

//env_run
lcr3(PADDR(curenv->env_pgdir));
unlock_kernel();
env_pop_tf(&(curenv->env_tf));


   Question2:

   既然大内核锁保证了只有1个CPU能运行在内核,为什么我们还要为每个CPU准备1个内核栈。

   回答:

   因为不同的内核栈上可能保存有不同的信息,当1个CPU从内核退出来之后,有可能在内核栈中留下了一些将来还有用的数据,所以一定要有单独的栈。

   Challenge 1:

   大内核锁简单便于应用,但是它取消了内核模式的并行。大多数现代操作系统使用不同的锁来保护共享状态的不同部分,这称之为细粒度锁。细粒度锁能有效地提高性能,但是也更困难地去实现和检测错误。所以你可以去掉大内核锁,在JOS中实现内核并发。

   回答:

   实验指导中提供了一些JOS内核中的共享结构,具体实现就是在保证在使用这些结构体时保证互斥。

  

Round-Robin Scheduling

   接下来的任务是改变JOS内核,实现round-robin调度算法。

   主要是在sched_yield函数内实现,从该核上一次运行的进程开始,在进程描述符表中寻找下一个可以运行的进程,如果没找到而且上一个进程依然是可以运行的,那么就可以继续运行上一个进程,同时将这个算法实现为了一个系统调用,进程可以主动放弃CPU。

   Exercise 6:

   实现sched_yield函数,并添加系统调用。

void
sched_yield(void)
{
uint32_t i, j, start;
struct Env *runenv;

idle = thiscpu->cpu_env;
start = (idle != NULL) ? ENVX(idle->env_id) : 0;
runenv = NULL;

for (i = 0; i < NENV; i++) {
j = (start + i) % NENV;
if (envs[j].env_status == ENV_RUNNABLE) {
if (runenv == NULL || envs[j].env_priority < runenv->env_priority)
runenv = &envs[j];
}
}
if ((idle && idle->env_status == ENV_RUNNING) && (runenv == NULL || idle->env_priority < runenv->env_priority)){
env_run(idle);
return;
}
if (runenv) {
env_run(runenv);
return;
}
// sched_halt never returns
sched_halt();
}


   然后修改kern/syscall.c添加相关的系统调用分发机制。

   Question 3:

   在lcr3运行之后,这个CPU对应的页表就立刻被换掉了,但是这个时候的参数e,也就是现在的curenv,为什么还是能正确的解引用?

   回答:

   因为当前是运行在系统内核中的,而每个进程的页表中都是存在内核映射的。每个进程页表中虚拟地址高于UTOP之上的地方,只有UVPT不一样,其余的都是一样的,只不过在用户态下是看不到的。所以虽然这个时候的页表换成了下一个要运行的进程的页表,但是curenv的地址没变,映射也没变,还是依然有效的。

   Question 4:

   在用户环境进行切换时,为什么旧进程的寄存器一定要被保存以便之后重新装载?在哪里发生这样的操作?

   回答:

   因为不进行保存,旧进程运行时的状态就丢失了,运行就不正确了。每次进入到内核态的时候,当前的运行状态都是在一进入的时候就保存了的。如果没有发生调度,那么之前trapframe中的信息还是会恢复回去,如果发生了调度,恢复的就是被调度运行的进程的上下文了。

   Challenge 2:

  添加固定优先级的调度策略,确保高优先级的进程总是先于低优先级的进程。

  回答:

  思路就是为每个进程的结构体添加1个标志优先级的变量,在sched_yield时遍历链表选取优先级最高的可运行进程作为下一个运行进程。

  1、在inc/env.h中给Env结构添加1个成员int env_priority

  2、在kern/env.c中的env_alloc中给进程赋值为默认优先级ENV_PRIOR_DEFAULT,一共设立了四个基本的优先级,数字越小优先级越高

//inc/env.h
#define ENV_PRIOR_SUPER 0
#define ENV_PRIOR_HIGH 10
#define ENV_PRIOR_NORMAL 100
#define ENV_PRIOR_LOW 1000


  3、修改kern/sched.c中sched_yield函数的调度策略

//kern/sched.c  sched_yield()
idle = thiscpu->cpu_env;
start = (idle != NULL) ? ENVX(idle->env_id) : 0;
runenv = NULL;

for (i = 0; i < NENV; i++) {
j = (start + i) % NENV;
if (envs[j].env_status == ENV_RUNNABLE) {
if (runenv == NULL || envs[j].env_priority < runenv->env_priority)
runenv = &envs[j];
}
}
if ((idle && idle->env_status == ENV_RUNNING) && (runenv == NULL || idle->env_priority < runenv->env_priority)){
env_run(idle);
return;
}
if (runenv) {
env_run(runenv);
return;
}


  4、添加1个系统调用sys_env_set_priority(envid, priority),允许进程的父进程或者自己修改自己的优先级。这里的修改与前面添加系统调用类似。

static int
sys_env_set_priority(envid_t envid, int priority)
{
int ret;
struct Env *env;

if ((ret = envid2env(envid, &env, 1)) < 0)
return ret;
env->env_priority = priority;
return 0;
}


  

  Challenge3:

  现在的JOS内核不支持应用来使用x86处理器的浮点数单元(FPU),MMX指令和SSE扩展指令。扩展Env结构提供保存处理器浮点数状态的空间,扩展进程上下文交换代码来保存和恢复浮点数状态 。

  回答:

  给中断加入保存浮点寄存器的功能。

  1、给inc/trap.h文件中的Trapframe结构新增char tf_fpus[512]成员,并增加uint32_t tf_padding0[3]来对齐

  2、修改kern/trapentry.S文件中的_alltraps函数,加入保存fpu寄存器的功能

_alltraps:
pushl %ds
pushl %es
pushal

// save FPU
subl $524, %esp
fxsave (%esp)

movl $GD_KD, %eax
movl %eax, %ds
movl %eax, %es

push %esp
call trap


  3、修改kern/env.c文件中的env_pop_tf函数,加入恢复fpu功能

__asm __volatile("movl %0,%%esp\n"
"\tfxrstor (%%esp)\n"
"\taddl $524,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret"
: : "g" (tf) : "memory");


System Calls for Environment Creation

  虽然你的内核现在有能力运行和切换多用户级进程,但是它仍然只能跑内核初始创建的进程。你现在将实现必要的JOS系统调用来运行用户进程来创建和启动其它新的用户进程。

  Unix提供了fork系统调用来创建进程,它拷贝父进程的整个地址空间到新创建的子进程。两个进程之间唯一的区别是它们的进程ID,在父进程fork返回的是子进程ID,而在子进程fork返回的是0。

  你将实现1个不同的更原始的JOS系统调用来创建进程。利用这些系统调用能实现类似Unix的fork函数。

  用户级fork函数在user/dumbfork.c中的dumbfork中,该函数将父进程中所有页的内容全部复制过来,唯一不同的地方就是返回值不同。

  Exercise 7:

  实现ker/syscall.c中的系统调用。

  回答:

  首先是sys_exofork函数,这个系统调用将创建1个新的空白进程,没有映射的用户空间且无法运行。在调用函数时新进程的寄存器状态与父进程相同,但是在父进程会返回子进程的ID,而子进程会返回0。通过设置子进程的eax为0,来让系统调用的返回值为0。

static envid_t
sys_exofork(void)
{
int ret;
struct Env *env;

if ((ret = env_alloc(&env, sys_getenvid())) < 0)
return ret;
env->env_status = ENV_NOT_RUNNABLE;
env->env_tf = curenv->env_tf;
env->env_tf.tf_regs.reg_eax = 0;
return env->env_id;
}


  接着是sys_env_set_status函数,设置进程的状态为ENV_RUNNABLE或者ENV_NOT_RUNNABLE。

static int
sys_env_set_status(envid_t envid, int status)
{
implemented");
int ret;
struct Env *env;

if (status != ENV_RUNNABLE && status != ENV_RUNNING)
return -E_INVAL;
if ((ret = envid2env(envid, &env, 1)) < 0)
return ret;
env->env_status = status;
return 0;
}


  然后是env_page_alloc函数,分配1个物理页并映射到给定进程的进程空间的虚拟地址。

static int
sys_page_alloc(envid_t envid, void *va, int perm)
{
struct Env *env;
struct PageInfo *pp;

if (envid2env(envid, &env, 1) < 0)
return -E_BAD_ENV;
if ((uintptr_t)va >= UTOP || PGOFF(va))
return -E_INVAL;
if ((perm & PTE_U) == 0 || (perm & PTE_P) == 0)
return -E_INVAL;
if ((perm & ~(PTE_U | PTE_P | PTE_W | PTE_AVAIL)) != 0)
return -E_INVAL;
if ((pp = page_alloc(ALLOC_ZERO)) == NULL)
return -E_NO_MEM;
if (page_insert(env->env_pgdir, pp, va, perm) < 0) {
page_free(pp);
return -E_NO_MEM;
}
return 0;
}


  接着是sys_page_map函数,从1个进程的页表中拷贝1个页映射到另1个进程的页表中。将进程id为srcenvid的进程的srcva处的物理页的内容,映射到进程id为dstenvid的进程的dstva处。

static int
sys_page_map(envid_t srcenvid, void *srcva,
envid_t dstenvid, void *dstva, int perm)
{
struct Env *srcenv, *dstenv;
struct PageInfo *pp;
pte_t *pte;

if (envid2env(srcenvid, &srcenv, 1) < 0 || envid2env(dstenvid, &dstenv, 1) < 0)
return -E_BAD_ENV;
if ((uintptr_t)srcva >= UTOP || PGOFF(srcva) || (uintptr_t)dstva >= UTOP || PGOFF(dstva))
return -E_INVAL;
if ((perm & PTE_U) == 0 || (perm & PTE_P) == 0 || (perm & ~PTE_SYSCALL) != 0)
return -E_INVAL;
if ((pp = page_lookup(srcenv->env_pgdir, srcva, &pte)) == NULL)
return -E_INVAL;
if ((perm & PTE_W) && (*pte & PTE_W) == 0)
return -E_INVAL;
if (page_insert(dstenv->env_pgdir, pp, dstva, perm) < 0)
return -E_NO_MEM;
return 0;
}


  最后是sys_page_unmap函数,解除指定进程中的1个页映射。

static int
sys_page_unmap(envid_t envid, void *va)
{
struct Env *env;

if (envid2env(envid, &env, 1) < 0)
return -E_BAD_ENV;
if ((uintptr_t)va >= UTOP || PGOFF(va))
return -E_INVAL;

page_remove(env->env_pgdir, va);
return 0;
}


  至此所有系统调用都完成了,可以运行一下usr/umbfork.c。首先看一下主函数,逻辑是父进程创建1个子进程,然后每次打印1条信息后交出控制权,并且让父进程重复10次而子进程重复20次。

void
umain(int argc, char **argv)
{
envid_t who;
int i;

// fork a child process
who = dumbfork();

// print a message and yield to the other a few times
for (i = 0; i < (who ? 10 : 20); i++) {
cprintf("%d: I am the %s!\n", i, who ? "parent" : "child");
sys_yield();
}
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: