MIT-JOS系列8:多任务处理(一)

Part A:多处理器支持和协作式多任务处理

注:根据MIT-JOS的lab指导手册,以下不明确区分“环境”和“进程”

在这部分实验中,我们将扩展JOS使之能在多处理器系统上运行,并实现一些新的系统调用以允许用户环境新建新的环境。我们还将通过协作轮询调度(cooperative round-robin scheduling)实现内核在当前环境放弃对CPU的占用(或退出)时切换到另一个环境。在之后的C部分实验中我们还会实现抢占式调度,即使用户环境不合作,内核也将在一定时间后重新获得控制权

实验前小吐槽

首先吐槽一下编译的问题。。。。

在之前的实验中(lab1-lab3)我都是使用gcc-7进行实验的,本身MIT-JOS系列的实验用高版本gcc进行编译后,lab1的运行就会出现问题(具体是无法进入内核,一直在循环执行某段代码,qemu文字闪动),但我们贴心的老师的贴心的PPT给出了解决方法:

修改lab代码:lab/kernel/kernel.ld(否则用高版本gcc编译时运行异常)

-	PROVIDE(edata = .);
-
 	.bss : {
+		PROVIDE(edata = .);
 		*(.bss)
+		PROVIDE(end = .);
+		BYTE(0)
 	}
 
-	PROVIDE(end = .);

它有效。然后直到lab3完成都一切正常

至于我为什么用gcc-7。。因为我的机子是ubuntu18.04,虽然之前为了某个东西编译不过去装了gcc-4.8,默认gcc使用4.8,但忽然有一天系统提醒我更新。。。。我还手贱点了更新。。。。。是的,然后gcc就被改回去了我还一直没发现。。。。我只是以为对这个实验4.8也有点高。。。

然后直到今天进行lab4实验的时候,切换到lab4分支,和lab3做完merge我make一下想看看有没有哪里冲突解决漏了,结果喜提一屏幕错误:

+ cc[USER] user/faultregs.c
user/faultregs.c: Assembler messages:
user/faultregs.c:106: 错误: junk `(%ebx)+0x24' after expression
user/faultregs.c:108: 错误: junk `(%ebx)+0x20' after expression
user/faultregs.c:110: 错误: junk `(%ebx)+0x00' after expression
user/faultregs.c:111: 错误: junk `(%ebx)+0x04' after expression
user/faultregs.c:112: 错误: junk `(%ebx)+0x08' after expression
user/faultregs.c:113: 错误: junk `(%ebx)+0x10' after expression
user/faultregs.c:114: 错误: junk `(%ebx)+0x14' after expression
user/faultregs.c:115: 错误: junk `(%ebx)+0x18' after expression
user/faultregs.c:116: 错误: junk `(%ebx)+0x1c' after expression
user/faultregs.c:117: 错误: junk `(%ebx)+0x28' after expression
user/faultregs.c:119: 错误: junk `(%ebx)+0x00' after expression
user/faultregs.c:120: 错误: junk `(%ebx)+0x04' after expression
user/faultregs.c:121: 错误: junk `(%ebx)+0x08' after expression
user/faultregs.c:122: 错误: junk `(%ebx)+0x10' after expression
user/faultregs.c:123: 错误: junk `(%ebx)+0x14' after expression
user/faultregs.c:124: 错误: junk `(%ebx)+0x18' after expression
user/faultregs.c:125: 错误: junk `(%ebx)+0x1c' after expression
user/faultregs.c:126: 错误: junk `(%ebx)+0x28' after expression
user/faultregs.c:127: 错误: junk `(%ebx)+0x00' after expression
user/faultregs.c:128: 错误: junk `(%ebx)+0x04' after expression
user/faultregs.c:129: 错误: junk `(%ebx)+0x08' after expression
user/faultregs.c:130: 错误: junk `(%ebx)+0x10' after expression
user/faultregs.c:131: 错误: junk `(%ebx)+0x14' after expression
user/faultregs.c:132: 错误: junk `(%ebx)+0x18' after expression
user/faultregs.c:133: 错误: junk `(%ebx)+0x1c' after expression
user/faultregs.c:134: 错误: junk `(%ebx)+0x28' after expression
user/faultregs.c:138: 错误: junk `(%ebx)+0x24' after expression
user/Makefrag:7: recipe for target 'obj/user/faultregs.o' failed
make: *** [obj/user/faultregs.o] Error 1

我:????

于是终于发现了gcc版本的问题。。。就滚回gcc-4.8。。顺便给它装了个专属的multilib:(这里如果不敲4.8会识别到已安装multilib,装在gcc-7里头,gcc-4.8并用不上)sudo apt-get install gcc-4.8-multilib

然后重新编译就好了。。。

事实证明 搞这种东西 不要企图用什么新版本 它们不更新是有理由的

多处理器支持

我们要使JOS支持对称多处理(symmetric multiprocessing, SMP),这是一种所有CPU都对系统资源(例如存储和I/O总线)能够平等访问的多处理器模型。虽然在SMP中所有CPU功能相同,但在boot过程中它们能被区分成两种类型:

  • 引导处理器(bootstrap processor,BSP):负责初始化系统和引导操作系统
  • 应用处理器(application processors,APs):仅在操作系统启动和运行后,BSP才激活APs

哪个处理器是BSP是由BIOS决定的。到目前位置,我们所有的代码都是运行在BSP上

在SMP系统中,每个CPU都伴有一个局部高级可编程中断控制器(local Advanced Programmable Interrupt Controller,LAPIC),它负责为整个系统发送中断信号,此外,LAPIC还为它所关联的CPU提供唯一标识符。在本实验中,我们利用LAPIC的基本功能完成如下工作:(kern/lapic.c

  • 读取LAPIC标识码(APIC ID)获取当前代码运行的CPU(cpunum()

  • 从BSP向APs发送处理器间中断(interprocessor interrupt,IPI)启动APs

    IPI:在SMP环境下,可以被任意一个处理器用来对另一个处理器产生中断

  • 在Part C中我们编写LAPIC的内置定时器出发时钟中断,以支持抢占式多任务处理

内存映射I/O(MMIO)

程序使用内存映射I/O(memory-mapped I/O ,MMIO)访问它的LAPIC。在MMIO中,物理内存硬连线到部分I/O设备的寄存器去,所以一些用于访存的load/store指令也可以被用于访问设备寄存器。在之前的实验中,IO hole在0xA0000开始的物理地址中(0xA0000-0xC0000存放VGA显示缓存)。LAPIC存在于物理地址0xFE000000(4GB-32MB)处。在kernel初始化期间我们仅对物理内存的0-0x0FFFFFFF(前256MB)映射到虚拟内存的0xF0000000-0xFFFFFFFF,LAPIC的物理地址显然太高了,因此在JOS虚拟内存映射中,我们预留了MMIOBASE起始的4MB内存用于映射这类设备。

现在我们编写一个函数mmio_map_region()分配这一块空间并映射设备内存

函数功能:将物理内存pasize个字节映射到虚拟内存的MMIOBASE区域,返回映射虚拟地址的首地址

  • 该函数能被多次调用,映射的虚拟地址baseMMIOBASE随调用增长,即每次调用都能得到一个新的页面,但不超过MMIOLIM
  • 函数用于内存映射I/O,访问的物理地址非正常的DRAM,因此需要禁用cache,权限为PTE_PCD|PTE_PWT(cache-disable and write-through)和PTE_W
  • 可以利用boot_map_region实现

函数实现如下:

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;

	// Reserve size bytes of virtual memory starting at base and
	// map physical pages [pa,pa+size) to virtual addresses
	// [base,base+size).  Since this is device memory and not
	// regular DRAM, you'll have to tell the CPU that it isn't
	// safe to cache access to this memory.  Luckily, the page
	// tables provide bits for this purpose; simply create the
	// mapping with PTE_PCD|PTE_PWT (cache-disable and
	// write-through) in addition to PTE_W.  (If you're interested
	// in more details on this, see section 10.5 of IA32 volume
	// 3A.)
	//
	// Be sure to round size up to a multiple of PGSIZE and to
	// handle if this reservation would overflow MMIOLIM (it's
	// okay to simply panic if this happens).
	//
	// Hint: The staff solution uses boot_map_region.
	//
	// Your code here:
	size = ROUNDUP(size, PGSIZE);
	if (base + size >= MMIOLIM)
		panic("overflow MMIOLIM\n");
	boot_map_region(kern_pgdir, base, size, pa, PTE_PCD|PTE_PWT|PTE_W);
	uintptr_t result = base;
	base += size;

	return (void *)result;
}

应用处理器启动

在应用处理器APs启动之前,引导处理器BPS应该首先收集多处理器系统的信息,例如CPU的总数、它们的APIC ID、LAPIC的MMIO地址等。mp_init()从内存的BIOS区域中读取MP配置表来获得这些信息

boot_aps()启动AP引导程序。APs从实模式启动,类似于boot/boot.S中bootloader的启动过程,boot_aps()把AP的入口代码kern/mpentry.S拷贝到物理地址0x7000处,mpentry.S做的事儿与boot.S类似:

  • 初始化各寄存器
  • 加载GDT表
  • 设置cr0进入实模式
  • 载入简易页表
  • 设置cr3cr0进入分页保护模式
  • 初始化堆栈
  • 调用c编写的MP初始化函数mp_main()

mp_main()中做的事儿如下:

  • 设置页表目录为kern_pgdir
  • 建立内存映射I/O,使能够访问当前CPU的LAPIC
  • 初始化当前CPU的环境(GDT表)
  • 初始化当前CPU的TSS
  • 设置当前CPU的状态cpu_statusCPU_STARTED
  • 进入自旋

boot_aps()忙等待直到AP在它对应的struct CpuInfo中的cpu_status设置CPU_STARTED标识,然后继续启动下一个AP

这里我们需要在原代码的基础上实现:

  • 修改page_init()避免把MPENTRY_PADDR的地址所在的页面列入空闲页面,保证能够安全地将AP的引导代码装载到这个物理位置

代码如下:(很简单,跳过一页就行了)

// 跳过MPENTRY_PADDR所在的页面
for (i=1; i<MPENTRY_PADDR/PGSIZE; i++) {
    pages[i].pp_ref = 0;
    pages[i].pp_link = page_free_list;
    page_free_list = &pages[i];
}
pages[i++].pp_ref = 1;

for (; i < npages; i++) {
    // 页为 IO hole 区域或extended memory(两者是连续的, 占用0xA0000~kernel .bss段的end区域)
    if (i >= npages_basemem && i < npages_basemem + npages_io_hole + npages_used_entmem) {
        pages[i].pp_ref = 1;
    } else {
        pages[i].pp_ref = 0;
        pages[i].pp_link = page_free_list;
        page_free_list = &pages[i];
    }
}

Question

  1. Compare kern/mpentry.S side by side with boot/boot.S. Bearing in mind that kern/mpentry.S is compiled and linked to run above KERNBASE just like everything else in the kernel, what is the purpose of macro MPBOOTPHYS? Why is it necessary in kern/mpentry.S but not in boot/boot.S? In other words, what could go wrong if it were omitted in kern/mpentry.S?
    Hint: recall the differences between the link address and the load address that we have discussed in Lab 1.

A:boot_aps()mpentry.S放置在0xc000对应的虚拟地址上,从代码code = KADDR(MPENTRY_PADDR);可以看出是转换成虚拟地址放进去的,由于做了地址映射,memmove使用虚拟地址将这段数据写到code处时实际写到了物理地址0xc000处。因此对于mpentry.S的代码来说,它的链接地址是0xc000对应的虚拟地址,里面的标号如gdtdescstart32对应的地址也是虚拟地址(高于0xf0000000)。但mpentry.S初期是在实模式下工作的,它无法访问到高的虚拟地址,因此宏MPBOOTPHYS将这些标号的链接地址(虚拟地址)转化成对应的物理地址,使mpentry.S能够正常访存

每个CPU的私有状态和初始化

当编写一个多处理器操作系统时,区分每个CPU的私有状态(per-CPU state)全局状态(global state)非常重要。kern/cpu.h定义了CPU的大部分私有状态。我们使用struct CpuInfo cpus[]数组存放每个CPU的私有状态,使用cpunum()获取当前CPU的ID(即它在cpu数组中的下标),使用thiscpu宏获取当前CPU的CpuInfo结构在cpus中的地址

重要的CPU私有状态(per-CPU state)列举如下:

  • Per-CPU 内核栈:由于多CPU可能同时陷入内核态,因此我们需要为它们各自分配独立的内核栈空间,以防止它们之间的执行相互干扰。数组percpu_kstacks[NCPU][KSTKSIZE]为最多NCPU个CPU保留了内核栈空间

    回顾memlayout.h给出的虚拟内存映射示意

    /*
     * Virtual memory map:                                Permissions
     *                                                    kernel/user
     *
     *    4 Gig -------->  +------------------------------+
     *                     |                              | RW/--
     *                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     *                     :              .               :
     *                     :              .               :
     *                     :              .               :
     *                     |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
     *                     |                              | RW/--
     *                     |   Remapped Physical Memory   | RW/--
     *                     |                              | RW/--
     *    KERNBASE, ---->  +------------------------------+ 0xf0000000      --+
     *    KSTACKTOP        |     CPU0's Kernel Stack      | RW/--  KSTKSIZE   |
     *                     | - - - - - - - - - - - - - - -|                   |
     *                     |      Invalid Memory (*)      | --/--  KSTKGAP    |
     *                     +------------------------------+                   |
     *                     |     CPU1's Kernel Stack      | RW/--  KSTKSIZE   |
     *                     | - - - - - - - - - - - - - - -|                 PTSIZE
     *                     |      Invalid Memory (*)      | --/--  KSTKGAP    |
     *                     +------------------------------+                   |
     *                     :              .               :                   |
     *                     :              .               :                   |
     *    MMIOLIM ------>  +------------------------------+ 0xefc00000      --+
     *                     :              .               :                   |
     *                     :              .               :                   |
     */
    

    lab2中我们将BSP的内核栈映射到KSTACKTOP下方KSTKSIZE大小处,现在对每个AP的栈空间都要映射到这个区域,每两个栈之间预留KSTKGAP大小的内存作为避免溢出的缓冲区

  • Per-CPU 的TSS和TSS描述符:每个CPU都有一个任务状态段(TSS)指出该CPU对应的内核栈的位置。对于CPU i,它的TSS存放在cpus[i].cpu_ts中,并定义TSS段描述符在它对应的GDT表中gdt[(GD_TSS0 >> 3) + i]之前定义在kern/trap.c中的struct Taskstate ts不再有用

  • Per-CPU 的当前环境指针:由于多个CPU能同时运行它们各自的用户环境,因此修改引用当前环境的标识curenv,利用cpus[cpunum()].cpu_env(或thiscpu->cpu_env)获取当前环境,它指向当前CPU(代码正在运行的CPU)上正在运行的环境(在代码中,利用宏将curenv全部展开为thiscpu->cpu_env,因此代码逻辑完全不变)

  • Per-CPU 的系统寄存器:包括系统寄存器在内的所有寄存器是CPU私有的,因此初始化这些寄存器的指令(例如lcr3(), ltr(), lgdt(), lidt()等)必须在每个CPU上各执行一次,env_init_percpu()trap_init_percpu()的目的就在于此

根据以上所述,我们需要编写mem_init_mp()映射各CPU的内核栈到percpu_kstacks,并编写trap_init_percpu()重新初始化包括BSP在内的各CPU的TSS和TSS描述符

mem_init_mp()实现如下:

static void
mem_init_mp(void)
{
	// Map per-CPU stacks starting at KSTACKTOP, for up to 'NCPU' CPUs.
	//
	// For CPU i, use the physical memory that 'percpu_kstacks[i]' refers
	// to as its kernel stack. CPU i's kernel stack grows down from virtual
	// address kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP), and is
	// divided into two pieces, just like the single stack you set up in
	// mem_init:
	//     * [kstacktop_i - KSTKSIZE, kstacktop_i)
	//          -- backed by physical memory
	//     * [kstacktop_i - (KSTKSIZE + KSTKGAP), kstacktop_i - KSTKSIZE)
	//          -- not backed; so if the kernel overflows its stack,
	//             it will fault rather than overwrite another CPU's stack.
	//             Known as a "guard page".
	//     Permissions: kernel RW, user NONE
	//
	// LAB 4: Your code here:
	int i;
	uintptr_t va = KSTACKTOP - KSTKSIZE;
	for (i=0; i<NCPU; i++) {
		boot_map_region(kern_pgdir, 
						va, 
						KSTKSIZE, 
						PADDR(percpu_kstacks[i]), 
						PTE_W);
		va -= KSTKGAP+KSTKSIZE;
	}
}

trap_init_percpu()实现如下

void
trap_init_percpu(void)
{
	// The example code here sets up the Task State Segment (TSS) and
	// the TSS descriptor for CPU 0. But it is incorrect if we are
	// running on other CPUs because each CPU has its own kernel stack.
	// Fix the code so that it works for all CPUs.
	//
	// Hints:
	//   - The macro "thiscpu" always refers to the current CPU's
	//     struct CpuInfo;
	//   - The ID of the current CPU is given by cpunum() or
	//     thiscpu->cpu_id;
	//   - Use "thiscpu->cpu_ts" as the TSS for the current CPU,
	//     rather than the global "ts" variable;
	//   - Use gdt[(GD_TSS0 >> 3) + i] for CPU i's TSS descriptor;
	//   - You mapped the per-CPU kernel stacks in mem_init_mp()
	//
	// ltr sets a 'busy' flag in the TSS selector, so if you
	// accidentally load the same TSS on more than one CPU, you'll
	// get a triple fault.  If you set up an individual CPU's TSS
	// wrong, you may not get a fault until you try to return from
	// user space on that CPU.
	//
	// LAB 4: Your code here:
    
    // Setup a TSS so that we get the right stack
	// when we trap to the kernel.
	int id = thiscpu->cpu_id;
	uintptr_t kstacktop = KSTACKTOP - id*(KSTKSIZE+KSTKGAP);
	thiscpu->cpu_ts.ts_esp0 = kstacktop;
	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) + id] = SEG16(STS_T32A, (uint32_t) (&thiscpu->cpu_ts),
									sizeof(struct Taskstate) - 1, 0);
	gdt[(GD_TSS0 >> 3) + id].sd_s = 0;

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

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

Q:为什么是GD_TSS0>>3?并且gdt表中能看到设置其他GDT段描述符的时候也偏移了三位?

A:观察gdt表的结构是struct Segdesc gdt[],表中每一项是一个段描述符,占8个字节。GD_KTGD_TSS0等用的都是段描述符的地址,它们应该是对齐到8字节边界的,即最低3位是0。在利用gdt表的索引填入gdt表时,索引是0,1,2,3...的连续整数,因此把地址右移三位转化成索引

Q:为每个cpu初始化tss的时候,为什么是gdt[(GD_TSS0>>3)+id]

A:对每个cpu而言,gdt表中只有tss描述符是不一样的,因为要指向它们各自的内核栈。tss描述符在gdt的最后项,因此根据其id顺序累加写入gdt表即可。之后用ltr载入tss描述符到寄存器的时候,载入的地址(tss描述符首地址)为((GD_TSS0>>3)+id)<<3=GD_TSS0+(id<<3)

目前的代码中应用处理器AP在mp_main()完成初始化后进入自旋(死循环)。在让它继续向下执行之前,我们首先要解决多个CPU同时运行内核代码时的竞争情况。最简单的方法就是设置一个大内核锁(big kernel lock)。它是一个全局锁,在用户环境进入内核态时获得锁,回到用户态时释放锁。在这种模式下,多个用户态的环境能在不同CPU上同时运行,但同一时间只有一个环境可以进入内核态,此时其他企图进入内核态的环境都将等待

kern/spinlock.h中定义了一个名为kernel_lock的大内核锁,并提供lock_kernel()unlock_kernel()用于获得和释放锁。接下来,利用内核锁实现以下功能:

  • i386_init()中:在BSP启动其他CPU前获得锁
  • mp_main()中:初始化AP后获得锁,并调用sched_yield()运行AP的环境
  • trap()中:从用户态陷入内核态时获得锁。利用tf_cs检查异常是用户态发生的还是内核态发生的
  • env_run()中:在切换到用户态运行前释放锁

代码不贴了,在这几个位置加锁就完事儿了

这里重新理解一下从BSP启动和初始化各AP的过程和加解锁的意义:

首先BSP从boot_aps()循环启动各AP:

  1. 拷贝mpentry.S的代码到物理内存0x7000

  2. 循环启动每个AP

    for cpu in cpus:
    	从mpentry.S启动AP(与boot相似),进入mp_main初始化AP
    		1. 内存映射I/O,使能正确访问LAPIC
    		2. 环境初始化(GDT)
    		3. 中断初始化
    		4. 设置cpu_status为CPU_STARTED
    		5. while(1)自旋
    	// BSP忙等待AP初始化完毕
    	while cpu.cpu_status != CPU_STARTED:
    		pass
    

在这个过程中BSP按顺序启动和初始化每个AP,由于它忙等待AP的cpu_status被设置为CPU_STARTED,因此若不考虑AP进入自旋这一步,以上过程都是顺序的;换句话说,各AP设置完cpu_statusCPU_STARTED,接下来的代码才是与其他CPU并行执行的。由于现在AP初始化后只是空转,什么也不干,因此不会造成什么冲突,但如果在这里让AP直接创建并执行各自的环境,由于它们对kernel的并行访问,势必发生竞争

因此像前文说的那样,为了保证各环境同一时间内只有一个能够访问内核,我们要加锁限制访问。

首先在BSP启动AP前,即boot_aps()前加锁,在BSP准备进入自己的用户环境前解锁。因为BSP启动和初始化AP及它创建、启动自己的环境都是在内核态发生的,这之间对内核的访问权都应该属于BSP。在BSP进入到用户态、释放锁之前,所有AP在等待锁

BSP释放锁后,只有一个AP能获得锁并创建、运行它的环境,然后释放锁;其他AP等待上一个AP释放锁后再依次获得锁创建、运行环境

在这套逻辑下,如果发生了中断或异常,若发生时处在内核态,除非是在启动任何一个AP前(即只有BSP运行,此时不会有竞争发生,也不必要加锁),则一定获得过大内核锁,在trap()中不需要再次加锁;否则,它就是从用户态因为异常陷入了内核态,为了防止其他用户态在它处理异常时陷入内核态发生竞争,需要给他加个锁

Question

  1. It seems that using the big kernel lock guarantees that only one CPU can run the kernel code at a time. Why do we still need separate kernel stacks for each CPU? Describe a scenario in which using a shared kernel stack will go wrong, even with the protection of the big kernel lock.

A:大内核锁是加在trap()函数里的。在中断触发、进入中断处理程序入口到trap()之间是没有锁的,但程序已经通过TSS寻找到内核栈,并向内核栈压入中断号、错误码以及各寄存器参数。如果没有为各CPU区分内核栈,若多个CPU同时触发异常陷入内核态,压栈过程将会冲突造成混乱

轮询调度

接下来我们要修改JOS内核,使其能够以循环使用的方式在多个不通的环境之间切换。原理如下:

  • kern/sched.c中的sched_yield()函数选择一个环境来运行。它从上一个运行完毕的环境开始,顺序地循环查找envs数组,直到找到第一个状态为ENV_RUNNABLE的环境来运行
  • sched_yield()不允许将同一个环境同时运行在两个CPU上。若一个环境已经运行在某个CPU上,它的状态为ENV_RUNNING
  • 我们要实现一个新的系统调用sys_yield(),通过这个系统调用,用户环境可以主动唤起内核的sched_yield()并将CPU资源移交给另一个环境

sched_yield()实现如下

void
sched_yield(void)
{
	struct Env *idle;

	// Implement simple round-robin scheduling.
	//
	// Search through 'envs' for an ENV_RUNNABLE environment in
	// circular fashion starting just after the env this CPU was
	// last running.  Switch to the first such environment found.
	//
	// If no envs are runnable, but the environment previously
	// running on this CPU is still ENV_RUNNING, it's okay to
	// choose that environment.
	//
	// Never choose an environment that's currently running on
	// another CPU (env_status == ENV_RUNNING). If there are
	// no runnable environments, simply drop through to the code
	// below to halt the cpu.

	// LAB 4: Your code here.
	if (curenv == NULL)
		idle = &envs[0];
	else
		idle = curenv + 1;
	
	int i = 0;
	for (i=0; i<NENV; i++) {
		if (idle->env_status == ENV_RUNNABLE) {
			// env_run不会返回
			env_run(idle);
		}
		if (++idle > &envs[NENV-1])
			idle = &envs[0];
	}

	if (curenv && curenv->env_status == ENV_RUNNING)
		env_run(curenv);

	// sched_halt never returns
	sched_halt();
}

这里要注意,如果在envs数组内没有找到状态为ENV_RUNNABLE的环境可以切换,将继续执行curenv,因为是在同一个CPU上运行同一个环境,所以不会有问题。

syscall()中补充对sys_yield()的系统调用非常简单,此处不赘述

Question

  1. In your implementation of env_run() you should have called lcr3(). Before and after the call to lcr3(), your code makes references (at least it should) to the variable e, the argument to env_run. Upon loading the %cr3 register, the addressing context used by the MMU is instantly changed. But a virtual address (namely e) has meaning relative to a given address context--the address context specifies the physical address to which the virtual address maps. Why can the pointer e be dereferenced both before and after the addressing switch?

  2. Whenever the kernel switches from one environment to another, it must ensure the old environment's registers are saved so they can be restored properly later. Why? Where does this happen?

A to 3:引用的环境的地址curenv(包括后来宏展开得到的&cpus[cpunum()]->cpu_env都是kernel的变量,在kernel的虚拟内存初始化过程中已经完成了对它们的映射,映射地址在0xf0000000以上,属于内核空间。在每个新的环境被创建时,调用了env_setup_vm()初始化新环境的页表,其中有一段代码为

for (i=PDX(UTOP); i<NPDENTRIES; i++)
    e->env_pgdir[i] = kern_pgdir[i];

这里对UTOP以上的地址空间,直接复制了kernel的页表,虽然用户对其没有访问权限,但env_run()处于内核态,在切换了cr3的值之后对kernel内部变量的映射关系没有改变且仍有权限继续访问,因此不会出错

A to 4:旧寄存器的值放在旧环境的e->env_tf。一个进程通过系统调用陷入内核态,因此会经历中断触发的过程,即在trapentry.S中保存寄存器的值到它的内核栈,然后将内核栈的这些值作为参数tf调用trap(),并在函数中赋值给curenv->env_tf;当一个环境恢复执行时,通过env_pop_tf(&curenv->env_tf);恢复该环境结构体中的env-tf到相应寄存器

系统调用创建进程:fork()

现在kernel已经可以在多个用户进程之间运行和切换了,但仍只能运行由内核初始化过的进程。这里我们将实现一个JOS的系统调用来允许用户环境创建和启动其他用户进程

Unix提供系统调用fork()作为进程创建原语。Unix的fork()复制调用进程(即父进程)的整个地址空间来创建子进程。从用户空间来看,父进程和子进程的唯一区别就是它们的进程号(process ID)。在父进程中,调用fork()返回其子进程的pid,在子进程中,创建出该子进程的这条fork()语句返回0。默认情况下任意一个进程都有它独立的地址空间,它们之间的访存不会相互干扰

我们将为JOS编写一个更原始的系统调用创建新的用户进程。利用这些系统调用,你可以实现在用户空间调用类似fork()的函数创建新进程。我们将编写:

  • sys_exofork():这个系统调用用于创建一个新的空白环境:其用户地址空间没有映射到任何物理内存,并且它是不可运行的。sys_exofork调用后父进程返回子进程的envid_t(或负的错误码),子进程返回0(由于子进程不可运行,因此事实上直到父进程标记子进程可运行后才返回)。子进程将与父进程拥有相同的寄存器状态
  • sys_env_set_status():设置制定进程的状态为ENV_RUNNABLEENV_NOT_RUNNABLE
  • sys_page_alloc():为指定的进程分配一个物理页并映射到指定的虚拟地址
  • sys_page_map():从一个进程地址空间拷贝一个页面映射(不可以是页面内容)到另一个进程,使两个进程共享同一物理页面的内存数据
  • sys_page_unmap():删除制定环境指令虚拟地址处的页面映射

以上系统调用接受一个参数作为环境的ID,0指代当前环境

代码实现如下:(实现这些系统调用后别忘了补充syscall()调用这些函数)

sys_exofork()

// Allocate a new environment.
// Returns envid of new environment, or < 0 on error.  Errors are:
//	-E_NO_FREE_ENV if no free environment is available.
//	-E_NO_MEM on memory exhaustion.
static envid_t
sys_exofork(void)
{
	// Create the new environment with env_alloc(), from kern/env.c.
	// It should be left as env_alloc created it, except that
	// status is set to ENV_NOT_RUNNABLE, and the register set is copied
	// from the current environment -- but tweaked so sys_exofork
	// will appear to return 0.

	// LAB 4: Your code here.
	struct Env *child_env = NULL;
	int err = env_alloc(&child_env, curenv->env_id);

	if (err < 0)
		return err;

	child_env->env_status = ENV_NOT_RUNNABLE;
	child_env->env_tf = curenv->env_tf;

	// 子进程的返回值:返回值是放在eax里的,这里的返回修改的是父进程的tf帧栈
	// 手动修改子进程的帧栈实现一个函数两个返回
	child_env->env_tf.tf_regs.reg_eax = 0;

	return child_env->env_id;
}

这里要特别注意,在系统调用结束后syscall()返回的值放在帧栈的eax寄存器中,即tf->tf_regs.reg_eax,当原用户进程恢复运行时,将从tf中恢复所有寄存器的值。sys_exofork()将返回给父进程,返回的值是子进程的envid放在当前trapframe的eax中。对父进程而言,走的是正常的函数返回流程;子进程应该是sys_exofork()分配的一个新环境,当这个新环境被运行后,将会读取到child_env->env_tf里所有的寄存器的值到子进程环境的寄存器,因此子进程能够收到返回值0,是我们设置的child_env->env_tf.tf_regs.reg_eax = 0;

sys_env_set_status()

// Set envid's env_status to status, which must be ENV_RUNNABLE
// or ENV_NOT_RUNNABLE.
//
// Returns 0 on success, < 0 on error.  Errors are:
//	-E_BAD_ENV if environment envid doesn't currently exist,
//		or the caller doesn't have permission to change envid.
//	-E_INVAL if status is not a valid status for an environment.
static int
sys_env_set_status(envid_t envid, int status)
{
	// Hint: Use the 'envid2env' function from kern/env.c to translate an
	// envid to a struct Env.
	// You should set envid2env's third argument to 1, which will
	// check whether the current environment has permission to set
	// envid's status.

	// LAB 4: Your code here.
	if (status != ENV_NOT_RUNNABLE && status != ENV_RUNNABLE) {
		return -E_INVAL;
	}

	struct Env *env = NULL;

	if (envid2env(envid, &env, 1) < 0)
		return -E_BAD_ENV;
	
	env->env_status = status;
	return 0;
}

sys_page_alloc()

// Allocate a page of memory and map it at 'va' with permission
// 'perm' in the address space of 'envid'.
// The page's contents are set to 0.
// If a page is already mapped at 'va', that page is unmapped as a
// side effect.
//
// perm -- PTE_U | PTE_P must be set, PTE_AVAIL | PTE_W may or may not be set,
//         but no other bits may be set.  See PTE_SYSCALL in inc/mmu.h.
//
// Return 0 on success, < 0 on error.  Errors are:
//	-E_BAD_ENV if environment envid doesn't currently exist,
//		or the caller doesn't have permission to change envid.
//	-E_INVAL if va >= UTOP, or va is not page-aligned.
//	-E_INVAL if perm is inappropriate (see above).
//	-E_NO_MEM if there's no memory to allocate the new page,
//		or to allocate any necessary page tables.
static int
sys_page_alloc(envid_t envid, void *va, int perm)
{
	// Hint: This function is a wrapper around page_alloc() and
	//   page_insert() from kern/pmap.c.
	//   Most of the new code you write should be to check the
	//   parameters for correctness.
	//   If page_insert() fails, remember to free the page you
	//   allocated!

	// LAB 4: Your code here.
	if ((perm & (PTE_U | PTE_P)) == 0) return -E_INVAL;
	if ((perm & ~(PTE_U | PTE_P | PTE_AVAIL | PTE_W)) != 0) return -E_INVAL;
	if ((uintptr_t)va >= UTOP || ((uintptr_t)va & 0xFFF))
		return -E_INVAL;

	struct Env *env = NULL;
	if (envid2env(envid, &env, 1) < 0)
		return -E_BAD_ENV;
	
	struct PageInfo *page = page_alloc(ALLOC_ZERO);
	if (page_insert(env->env_pgdir, page, va, perm)) {
		page_free(page);
		return -E_NO_MEM;
	}
	return 0;
}

sys_page_map()

// Map the page of memory at 'srcva' in srcenvid's address space
// at 'dstva' in dstenvid's address space with permission 'perm'.
// Perm has the same restrictions as in sys_page_alloc, except
// that it also must not grant write access to a read-only
// page.
//
// Return 0 on success, < 0 on error.  Errors are:
//	-E_BAD_ENV if srcenvid and/or dstenvid doesn't currently exist,
//		or the caller doesn't have permission to change one of them.
//	-E_INVAL if srcva >= UTOP or srcva is not page-aligned,
//		or dstva >= UTOP or dstva is not page-aligned.
//	-E_INVAL is srcva is not mapped in srcenvid's address space.
//	-E_INVAL if perm is inappropriate (see sys_page_alloc).
//	-E_INVAL if (perm & PTE_W), but srcva is read-only in srcenvid's
//		address space.
//	-E_NO_MEM if there's no memory to allocate any necessary page tables.
static int
sys_page_map(envid_t srcenvid, void *srcva,
	     envid_t dstenvid, void *dstva, int perm)
{
	// Hint: This function is a wrapper around page_lookup() and
	//   page_insert() from kern/pmap.c.
	//   Again, most of the new code you write should be to check the
	//   parameters for correctness.
	//   Use the third argument to page_lookup() to
	//   check the current permissions on the page.

	// LAB 4: Your code here.
	if ((perm & (PTE_U | PTE_P)) == 0) return -E_INVAL;
	if ((perm & ~(PTE_U | PTE_P | PTE_AVAIL | PTE_W)) != 0) return -E_INVAL;

	if ((uintptr_t)srcva >= UTOP || ((uintptr_t)srcva & 0xFFF)) return -E_INVAL;
	if ((uintptr_t)dstva >= UTOP || ((uintptr_t)dstva & 0xFFF)) return -E_INVAL;

	struct Env *src_env = NULL;
	struct Env *dst_env = NULL;
	if (envid2env(srcenvid, &src_env, 1) < 0 || envid2env(dstenvid, &dst_env, 1) < 0)
		return -E_BAD_ENV;
	
	struct PageInfo *page = NULL;
	pte_t *pte = NULL;
	page = page_lookup(src_env->env_pgdir, srcva, &pte);

	if (page == NULL) return -E_INVAL;
	if ((*pte & PTE_W) == 0 && (perm & PTE_W)) return -E_INVAL;

	return page_insert(dst_env->env_pgdir, page, dstva, perm);
}

这两个函数的权限和错误检查逻辑稍微复杂一点,需要多多注意

sys_page_unmap()

// Unmap the page of memory at 'va' in the address space of 'envid'.
// If no page is mapped, the function silently succeeds.
//
// Return 0 on success, < 0 on error.  Errors are:
//	-E_BAD_ENV if environment envid doesn't currently exist,
//		or the caller doesn't have permission to change envid.
//	-E_INVAL if va >= UTOP, or va is not page-aligned.
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 *env = NULL;
	if ((uintptr_t)va >= UTOP || ((uintptr_t)va & 0xFFF))
		return -E_INVAL;
	if (envid2env(envid, &env, 1) < 0)
		return -E_BAD_ENV;
	
	page_remove(env->env_pgdir, va);
	return 0;
}

顺便理解一下dumbfork.c中复制父进程页面数据到子进程的过程:

void
duppage(envid_t dstenv, void *addr)
{
	int r;

	// This is NOT what you should do in your fork.
	if ((r = sys_page_alloc(dstenv, addr, PTE_P|PTE_U|PTE_W)) < 0)
		panic("sys_page_alloc: %e", r);
	if ((r = sys_page_map(dstenv, addr, 0, UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
		panic("sys_page_map: %e", r);
	memmove(UTEMP, addr, PGSIZE);
	if ((r = sys_page_unmap(0, UTEMP)) < 0)
		panic("sys_page_unmap: %e", r);
}

首先为子进程在addr虚拟地址处分配一个页面,然后将该物理页面映射到父进程的临时交换区UTEMP,这样父进程对UTEMP的写入等于对子进程addr地址处的写入,最后将父进程addr处的数据拷贝到子进程addr处,最后删除UTEMP的映射

这里要这样写的原因是父进程地址addr和子进程地址addr虽然在地址的值上是相同的,但对应的物理页面是不通的,映射关系也是不同的

posted @ 2019-04-16 10:46  sssaltyfish  阅读(452)  评论(0编辑  收藏  举报