在lab4中多了如下文件:kern/cpu.h、kern/mpconfig.c、kern/lapic.c、kern/mpentry.S、kern/spinlock.h、kern/spinlock.c、kern/sched.c

Part A: Multiprocessor Support and Cooperative Multitasking

在这个lab的第一部分,我们首先拓展JOS让其能在多处理器系统上运行,然后实现一些新的JOS内核系统调用以允许用户级环境创建额外的新环境。还将实现协作循环调度,当当前环境自愿放弃CPU(或退出)时,允许内核从一种环境切换到另一种环境。在第三部分中,还将实现抢占式调度,即使环境不合作,它也允许内核在经过一定时间后从环境中重新控制CPU。

Multiprocessor Support

我们将让JOS支持”对称多处理”(SMP),一种多处理器模型,其中所有CPU都具有对系统资源(如内存和IO总线)的同等访问权限。虽然在SMP模型中所有的CPU的功能都是相同的,但是启动的过程中,还是可以分为两种类型:引导处理器(BSP)负责初始化系统和引导操作系统;另一种是只有在操作系统启动并允许后,应用处理器(AP)才会被BSP激活。哪个处理器作为BSP是由硬件和BIOS决定。到目前为止,我们所有的JOS代码都已在BSP上允许。

在SMP系统中,每个CPU都有一个伴随的本地APIC(LAPIC) 单元。LAPIC单元负责在整个系统中传送中断。LAPIC还为其连接的 CPU提供唯一标识符,在本lab中,我们使用LAPIC单元的以下基本功能(kern/lapic.c):

  • 读取LAPIC标识符(APIC ID)来判断我们的代码现在允许在哪个CPU上(查看cpunum())
  • 从BSP向AP发送STARTUP处理器间中断(IPI)以启动其他CPU(查看lapic_startap())
  • 在第三部分,我们对LAPIC的内置定时器进行编程以触发时钟中断以支持抢占式多任务处理(查看apic_init())

处理器访问LAPIC使用内存映射IO(MMIO),这样就能通过访问内存达到访问设备寄存器的目的。LAPIC从物理地址0xFE000000开始,JOS将通过MMIOBASE虚拟地址访问该物理地址。

exercise1

实现文件kern/pmap.c中的mmio_map_region函数。这个函数管理内存映射IO地址,输入一个在范围内的物理地址,函数返回一个虚拟地址,那么这个物理地址就被映射到这个虚拟地址上。这个也是一个分配器,比较原始,原理就和boot_alloc类似。从MMIOBASE开始分配,每次分配都是以页为单位。故函数维持了一个全局变量,表示当前分配到的地址,并将参数上调到4096的边界。这些操作和boot_alloc一样。

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:
//panic("mmio_map_region not implemented");
void *ret = (void*)base;
size = ROUNDUP(size,PGSIZE);
if(base + size > MMIOLIM || base + size < base){
panic("mmio_map_region(): overflow");
}
boot_map_region(kern_pgdir,base,size,pa,PTE_W|PTE_PCD|PTE_PWT);
base += size;
return ret;
}

Application Processor Bootstrap

在启动AP之前,BSP需要搜集多处理器的信息,比如总共有多少个CPU,它们的LAPIC ID以及LAPIC MMIO地址。mp_init函数从BIOS中读取这些信息。具体代码在mp_init中,该函数会在进入内核后由i386_init函数调用,主要作用就是读取mp configuration table中保存的CPU信息,初始化cpus数组,ncpu(总共可用的CPU个数),bootcput指针(指向BSP对应的CpuInfo结构)。

boot_aps函数驱动AP引导程序,AP以实模式启动,很像引导程序在boot/boot.S中的启动方式,因此boot_aps函数将AP入口代码复制到实模式下寻址的内存位置。与引导加载程序不同的是,我们可以控制AP开始执行代码的位置,我们将入口代码复制到0x7000,但任何未使用的、页面对齐的低于640kb的物理地址都可以使用。

之后,boot_aps函数通过发送STARTUP的IPI(处理器间中断)信号到AP的LAPIC单元来一个个激活AP。在kern/mpentry.S中的入口代码跟boot/boot.S中的代码类似。在一些简短的配置后,它使AP进入开启分页机制的保护模式,调用C语言的setup函数mp_main。boot_aps 等待AP在其结构CpuInfo的cpu_status字段中发出CPU_STARTED标志信号,然后再唤醒下一个。

exercise2

我们需要修改我们kern/pmap.c中page_init函数的代码,来表示MPENTRY_PADDR处的地址已经不再是free的状态。因为这段地址已经被AP的引导器所占用。

void
page_init(void)
{
// LAB 4:
// Change your code to mark the physical page at MPENTRY_PADDR
// as in use

// The example code here marks all physical pages as free.
// However this is not truly the case. What memory is free?
// 1) Mark physical page 0 as in use.
// This way we preserve the real-mode IDT and BIOS structures
// in case we ever need them. (Currently we don't, but...)
// 2) The rest of base memory, [PGSIZE, npages_basemem * PGSIZE)
// is free.
// 3) Then comes the IO hole [IOPHYSMEM, EXTPHYSMEM), which must
// never be allocated.
// 4) Then extended memory [EXTPHYSMEM, ...).
// Some of it is in use, some is free. Where is the kernel
// in physical memory? Which pages are already in use for
// page tables and other data structures?
//
// Change the code to reflect this.
// NB: DO NOT actually touch the physical memory corresponding to
// free pages!
/*size_t i;
for (i = 0; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}*/

//mark page 0 as in use --1
pages[0].pp_ref = 1;

//mark page [1,npages_basemem_before) in use
size_t i;
size_t npages_basemem_before = MPENTRY_PADDR / PGSIZE;
for(i = 1;i < npages_basemem_before;i++){
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
// skip the MPENTRY_PADDR
extern unsigned char mpentry_start[], mpentry_end[];
size_t mpentry_lent = mpentry_end - mpentry_start;
size_t npages_mpentry = mpentry_lent / PGSIZE;
size_t mpentry_more = mpentry_lent % PGSIZE;
if(mpentry_more){
npages_mpentry++;
}
i = npages_basemem_before + npages_mpentry;
for(;i < npages_basemem;i++){
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

//[IOPHYSMEM, EXTPHYSMEM) is never be allocated --3
for(;i<EXTPHYSMEM/PGSIZE;i++){
pages[i].pp_ref = 1;
}

//[EXTPHYSMEM, ...) --4
//in this part,we need to know which pages has been used for pages-table or page-directory or kernel
physaddr_t lasted_in_use_address = PADDR(boot_alloc(0));
size_t last_number = lasted_in_use_address/PGSIZE;
for(;i<last_number;i++){
pages[i].pp_ref = 1;
}

//other pages for free
for(;i < npages;i++){
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}

此时我们又能看见check_page_free_list() succeeded!了。

Question

将kern/mpentry.S与boot/boot.S并排比较。 请记住,就像内核中的其他内容一样,kern/mpentry.S被编译、链接并运行在KERNBASE之上,宏MPBOOTPHYS的目的是什么? 为什么这在在kern/mpentry.S很关键?换句话说,如果在kern/mpentry.S中省略了什么可能会出错?提示:回忆链接地址与加载地址的区别。

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

Per-CPU State and Initialization

在编写多处理器操作系统时,区分每个处理器私有的CPU状态和整个系统共享的全局状态很重要。kern/cpu.h定义了大多数的per-CPU状态,包括CpuInfo结构体,这个结构体存储了per-CPU的变量。cpunum() 总是返回调用它的CPU的ID,它可以作为cpus数组的索引。thiscpu宏是当前CPU的结构CpuInfo的简写。

JOS使用CpuInfo结构体来记录CPU的信息:

struct CpuInfo {
uint8_t cpu_id; // Local APIC ID; index into cpus[] below
volatile unsigned cpu_status; // The status of the CPU
struct Env *cpu_env; // The currently-running environment.
struct Taskstate cpu_ts; // Used by x86 to find stack for interrupt
};

每个CPU如下信息是当前CPU私有的:

  • 内核栈:因为多个CPU可以同时陷入内核,所以我们需要为每个处理器使用单独的内核堆栈,以防止它们被彼此干扰。percpu_kstacks[NCPU][KSTKSIZE]数组为每个CPU都保留了KSTKSIZE大小的内核栈
  • TSS和TSS描述符:每个CPU都需要单独的TSS和TSS描述符来指定该CPU对应的内核栈
  • 进程结构指针:每个CPU都会独立允许一个进程的代码,所以需要Env指针
  • 系统寄存器:比如cr3,gdt,ltr这些寄存器都是每个CPU私有的,每个CPU都需要单独设置

到目前为之CpuInfo和Env的关系可以总结如下:

Env和CpuInfo关系

exercise 3

处理器同时运行,不能共享一个栈,每个处理器都要有自己的栈。当然,这种区分是在虚拟地址层面上的,不是在物理地址层面上的,不同虚拟地址可以映射到相同物理地址,也可以映射到不同。在这里,我们当然希望能够映射到不同地址上。

主要工作在函数mem_init_mp,这个函数在mem_init初始化完成BSP使用的栈后调用,为各个AP映射栈地址。

讲义和代码注释要求我们给每个栈分配KSTKSIZE大小,中间留出KSTKGAP作为保护,使得一个栈溢出一定不会影响相邻的栈。

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:
for (int i = 0; i < NCPU; i++) {
boot_map_region(kern_pgdir,KSTACKTOP - KSTKSIZE - i * (KSTKSIZE + KSTKGAP),KSTKSIZE,PADDR(percpu_kstacks[i]),PTE_W);
}
}

exercise4

在文件kern/trap.c中函数trap_init_percpu对BSP的TSS和TSS描述符进行初始化。上一个Lab留下的版本,不能正确的处理多处理器的情况,我们需要更改它,让它能够正确初始化每个AP的中断。在之前的lab中,trap_init_percpu函数在trap_init中调用,trap_init在i386_init中调用,这是给BSP初始化中断。AP内核的入口函数mp_main调用了trap_init_percpu,这是给各个AP初始化中断。在BSP调用的trap_init函数中,中断描述符表已经初始化完成了,在各个AP中也就没比要再做,故没有调用trap_init。

注意此时的代码已经执行在不同的CPU上了,而不是要初始化所有CPU,只需要初始化自身就可以了。用thiscpu->cpu_ts代替全局变量cpu_ts。

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()
// - Initialize cpu_ts.ts_iomb to prevent unauthorized environments
// from doing IO (0 is not the correct value!)
//
// 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:
int cid = thiscpu->cpu_id;
// Setup a TSS so that we get the right stack
// when we trap to the kernel.
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cid * (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)+cid] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)),
sizeof(struct Taskstate), 0);
gdt[(GD_TSS0 >> 3)+cid].sd_s = 0;

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

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

Locking

目前我们已经有多个CPU同时在执行内核代码了,我们必须要处理竞争条件。最简单粗暴的办法就是使用”big kernel lock”,”big kernel lock”是一个全局锁,进程从用户态进入内核后获取该锁,退出内核释放该锁。这样就能保证只有一个CPU在执行内核代码,但缺点也很明显就是一个CPU在执行内核代码时,另一个CPU如果也想进入内核,就会处于等待的状态。

锁的数据结构在kern/spinlock.h中:

struct spinlock {
unsigned locked; // Is the lock held?
};

这是一种spin-locks。让我们来看看自旋锁的实现原理
我们最容易想到的获取自旋锁的代码如下:

void
acquire(struct spinlock *lk)
{
for(;;) {
if(!lk->locked) {
lk->locked = 1;
break;
}
}
}

但是这种实现是有问题的,假设两个CPU同时执行到5行,发现lk->locked是0,那么会同时获取该锁。问题出在5行和6行是两条指令。

我们的获取锁,释放锁的操作在kern/spinlock.c中:

void
spin_lock(struct spinlock *lk)
{
// The xchg is atomic.
// It also serializes, so that reads after acquire are not
// reordered before it.
while (xchg(&lk->locked, 1) != 0) //原理见:https://pdos.csail.mit.edu/6.828/2018/xv6/book-rev11.pdf chapter 4
asm volatile ("pause");
}

void
spin_unlock(struct spinlock *lk)
{
// The xchg instruction is atomic (i.e. uses the "lock" prefix) with
// respect to any other instruction which references the same memory.
// x86 CPUs will not reorder loads/stores across locked instructions
// (vol 3, 8.2.2). Because xchg() is implemented using asm volatile,
// gcc will not reorder C statements across the xchg.
xchg(&lk->locked, 0);
}


static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)
{
uint32_t result;
// The + in "+m" denotes a read-modify-write operand.
asm volatile("lock; xchgl %0, %1"
: "+m" (*addr), "=a" (result)
: "1" (newval)
: "cc");
return result;
}

对于spin_lock()获取锁的操作,使用xchgl这个原子指令,xchg()封装了该指令,交换lk->locked和1的值,并将lk-locked原来的值返回。如果lk-locked原来的值不等于0,说明该锁已经被别的CPU申请了,继续执行while循环吧。因为这里使用的xchgl指令,从addr指向的位置读数据保存到result,然后将newval写到该位置,但是原子的,相当于之前25和26行的结合,所以也就不会出现上述的问题。对于spin_unlock()释放锁的操作,直接将lk->locked置为0,表明我已经用完了,这个锁可以被别人获取了。

有了获取锁和释放锁的函数,我们看下哪些地方需要加锁,和释放锁:

  1. i386_init()中,BSP唤醒其它AP前需要获取内核锁。
  2. mp_main()中,AP需要在执行sched_yield()前获取内核锁。
  3. trap()中,需要获取内核锁,因为这是用户态进入内核的唯一入口。
  4. env_run()中,需要释放内核锁,因为该函数使用iret指令,从内核返回用户态。

这些添加我们就不放代码了,但是它们的意义值得思考。i386_init, mp_main函数的lock都发生在初始化完成,准备通过sched_yield进入用户进程之前。这时候加锁,让处理器依次加载用户进程,保证同一时刻只有一个处理器在内核态运行。

其它操作内核锁发生在进入和退出内核态的时候。处理器进入内核态后处在函数trap,故在trap开头加锁,等待其它处理器退出内核态。处理器要进入用户态时放开锁,也就是在env_run的最后,允许其它处理器进入内核态。

Question

big kernel lock似乎已经确保每次仅仅一个CPU能允许内核代码,为什么我们仍然需要为每个CPU设定一个内核栈

因为在alltraps到lock_kernel()的过程中,进程已经切换到了内核态,但并没有上内核锁,此时如果有其他CPU进入内核,如果用同一个内核栈,则_alltraps中保存的上下文信息会被破坏,所以即使有大内核栈,CPU也不能用用同一个内核栈。同样的,解锁也是在内核态内解锁,在解锁到真正返回用户态这段过程中,也存在上述这种情况

Round-Robin Scheduling

现要JOS内核需要让CPU能在进程之间切换。目前先实现一个非抢占式的进程调度,需要当前进程主动让出CPU,其他进程才有机会在当前CPU运行。具体实现如下:

  1. 实现sched_yield(),该函数选择一个新的进程运行,从当前正在运行进程对应的Env结构下一个位置开始循环搜索envs数组,找到第一个cpu_status为ENV_RUNNABLE的Env结构,然后调用env_run()在当前CPU运行这个新的进程。
  2. 我们需要实现一个新的系统调用sys_yield(),使得用户程序能在用户态通知内核,当前进程希望主动让出CPU给另一个进程。
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.
size_t start = 0;
int j;
if(curenv){
start = ENVX(curenv->env_id) + 1;
}
for(int i = 0;i < NENV; i++){
j = (start + i) % NENV;
if(envs[j].env_status == ENV_RUNNABLE){
env_run(&envs[j]);
}
}
if(curenv && curenv->env_status == ENV_RUNNING){
env_run(curenv);
}
// sched_halt never returns
sched_halt();
}

需要注意的是,当前CPU在envs数组中找了一圈后没找到合适的Env去执行,需要重新执行之前运行的进程,否则当前CPU就会进入停机状态。

关于Question3、4

在函数env_run中通过lcr3切换了页表之后,后面的代码依旧可以访问envs数组的成员。这是因为envs在kern_pgdir中被设置为用户态只读,而我们后面的每个用户进程的页表都是通过kern_pgdir为模板来复刻出来的,除了自身的部分,内核的部分肯定都是一样的,也就可以读取这段地址了

System Calls for Environment Creation

尽管现在的内核有能力在多进程之间切换,但是仅限于内核创建的用户进程。目前的JOS还没有提供系统调用,使用户进程能够创建新的进程。

UNIX提供fork()系统调用来创建新进程,fork()拷贝父进程的地址空间和寄存器状态到子进程。父进程从fork()返回的是子进程的进程ID,而子进程从fork()返回的是0。

我们将实现一组不同的、更原始的JOS系统调用来创建新的用户模式环境。我们需要完成如下函数:

  1. sys_exofork():
    创建一个新的进程,用户地址空间没有映射,不能运行,寄存器状态和父环境一致。在父进程中sys_exofork()返回新进程的envid,子进程返回0。
  2. sys_env_set_status:设置一个特定进程的状态为ENV_RUNNABLE或ENV_NOT_RUNNABLE。
  3. sys_page_alloc:为特定进程分配一个物理页,映射指定线性地址va到该物理页。
  4. sys_page_map:拷贝页表,使指定进程共享当前进程相同的映射关系。本质上是修改特定进程的页目录和页表。
  5. sys_page_unmap:解除页映射关系。本质上是修改指定用户环境的页目录和页表。

exercise7

实现上述的系统调用

首先是sys_exofork函数,这个函数其实就是env_alloc函数的封装,就是创建一个空白进程,非常简单

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.
//panic("sys_exofork not implemented");
struct Env *e;
int ret = env_alloc(&e,curenv->env_id);
if(ret < 0){
return ret;
}
e->env_tf = curenv->env_tf;//寄存器状态一致
e->env_status = ENV_NOT_RUNNABLE;
e->env_tf.tf_regs.reg_eax = 0;//子进程返回0
return e->env_id;
}

然后是sys_env_set_status函数。要使得进程从sys_exofork创建得到的状态ENV_NOT_RUNNABLE变为别的状态,也需要一个系统调用来实现。这个系统调用就是对设置Env状态的改变。

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.
//panic("sys_env_set_status not implemented");
switch (status)
{
case ENV_NOT_RUNNABLE:
case ENV_RUNNABLE:
break;
default:
return -E_INVAL;
}
struct Env *e;
int ret = envid2env(envid, &e, 1);
if (ret < 0 || e == NULL) {
return ret;
}
e->env_status = status;
return 0;
}

然后是sys_page_alloc通过分配器拿到一些page,然后把他们映射进程的地址空间。

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.
//panic("sys_page_alloc not implemented");
int ret;
struct Env *e;
ret = envid2env(envid,&e,1);
if(ret < 0 || e == NULL){
return ret;
}
if ((va >= (void*)UTOP) || (ROUNDDOWN(va, PGSIZE) != va))
return -E_INVAL; //一系列判定
int flag = PTE_U | PTE_P;
if ((perm & flag) != flag)
return -E_INVAL;

struct PageInfo *pp = page_alloc(1);
if(pp == NULL){
return -E_NO_MEM;
}
ret = page_insert(e->env_pgdir,pp,va,perm);
if(ret < 0){
page_free(pp);
return ret;
}
return 0;
}

sys_page_map将一个进程的Page Directory拷贝给另一个进程,让另一个进程获得相同的地址空间。这是对page_insert的封装。

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.
//panic("sys_page_map not implemented");
int ret;
struct Env *srce,*dste;
ret = envid2env(srcenvid,&srce,1);
if(ret < 0 || srce == NULL){
return ret;
}
ret = envid2env(dstenvid,&dste,1);
if(ret < 0 || dste == NULL){
return ret;
}
if (srcva >= (void*)UTOP || dstva >= (void*)UTOP || ROUNDDOWN(srcva,PGSIZE) != srcva || ROUNDDOWN(dstva,PGSIZE) != dstva)
return -E_INVAL;

int flag = PTE_U|PTE_P;
if ((perm & flag) != flag)
return -E_INVAL;
pte_t *pte;
struct PageInfo *pp = page_lookup(srce->env_pgdir,srcva,&pte);
if(pp == NULl){
return -E_INVAL;
}
if(perm | PTE_W){
if (!(*pte & PTE_W)) {
return -E_INVAL;
}
}
ret = page_insert(dste->env_pgdir,pp,dstva,perm);
if(ret < 0){
return ret;
}
return 0;
}

最后是sys_page_unmap,就是page_remove的封装。

static int
sys_page_unmap(envid_t envid, void *va)
{
// Hint: This function is a wrapper around page_remove().

// LAB 4: Your code here.
//panic("sys_page_unmap not implemented");
int ret;
struct Env *e;
ret = envid2env(envid,&e,1);
if(ret < 0 || e == NULL){
return ret;
}
if ((va >= (void*)UTOP) || (ROUNDDOWN(va, PGSIZE) != va))
return -E_INVAL;
page_remove(e->env_pgdir,va);
return 0;
}

别忘记在syscall函数中加上接口。

到这里我们就已经完成了part A的所有部分。

Part B: Copy-on-Write Fork

实现fork的方式有两种,一种是将父进程的内容全部拷贝一次给子进程,这样的话子进程和父进程就能实现进程隔离,但是这种方式非常的耗时,需要在物理内存中复制父进程的内容。

另一种方式叫做写时复制,父进程将自己的页目录和页表复制给子进程,这样父进程和子进程就能访问相同的内容。只有当子进程执行写操作时,才复制这一物理页。这样既能做到地址空间隔离,又能节省大量的拷贝工作。用来图来对比这两种方式:

非写时拷贝vs写时拷贝fork

要实现写时复制的fork需要先实现用户级别的缺页中断处理函数,这里我们默认认为内核一定正确,没有缺页错误。

User-level page fault handling

Copy-on-Write 只是用户级页面错误处理的许多可能用途之一。

我们将利用用户级页面错误处理方式,来决定如何处理用户空间中的每个页面错误,而不采用传统的Unix方法,因为其产生的错误的破坏性较小。 这种设计的另一个好处是允许程序在定义内存区域时具有很大的灵活性; 稍后我们将使用用户级页面错误处理来映射和访问基于磁盘的文件系统上的文件。

exercise8

实现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.
//panic("sys_env_set_pgfault_upcall not implemented");
int ret;
struct Env *e;
ret = envid2env(envid,&e,1);
if(ret < 0 || e == NULL){
return -E_BAD_ENV;
}
e->env_pgfault_upcall = func;
return 0;
}

Normal and Exception Stacks in User Environments

当缺页中断发生时,内核会返回用户模式来处理该中断。我们需要一个用户异常栈,来模拟内核异常栈。JOS的用户异常栈被定义在虚拟地址UXSTACKTOP。

Invoking the User Page Fault Handler

缺页中断发送时会进入内核的trap(),然后分配page_fault_handler来处理缺页中断。在该函数中应该做如下几件事:

  1. 判断curenv->env_pgfault_upcall是否设置,如果没有设置也就没办法修复,直接销毁该进程。
  2. 修改esp,切换到用户异常栈。
  3. 在栈上压入一个UTrapframe结构。
  4. 将eip设置为curenv->env_pgfault_upcall,然后回到用户态执行curenv->env_pgfault_upcall处的代码。

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

exercise9

按照上面的描述实现page_fault_handler()。

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 && 0x1) == 0 ){
panic("page fault in kernel-mode,fault address %d\n",fault_va);
}
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// Call the environment's page fault upcall, if one exists. Set up a
// page fault stack frame on the user exception stack (below
// UXSTACKTOP), then branch to curenv->env_pgfault_upcall.
//
// The page fault upcall might cause another page fault, in which case
// we branch to the page fault upcall recursively, pushing another
// page fault stack frame on top of the user exception stack.
//
// It is convenient for our code which returns from a page fault
// (lib/pfentry.S) to have one word of scratch space at the top of the
// trap-time stack; it allows us to more easily restore the eip/esp. In
// the non-recursive case, we don't have to worry about this because
// the top of the regular user stack is free. In the recursive case,
// this means we have to leave an extra word between the current top of
// the exception stack and the new stack frame because the exception
// stack _is_ the trap-time stack.
//
// If there's no page fault upcall, the environment didn't allocate a
// page for its exception stack or can't write to it, or the exception
// stack overflows, then destroy the environment that caused the fault.
// Note that the grade script assumes you will first check for the page
// fault upcall and print the "user fault va" message below if there is
// none. The remaining three checks can be combined into a single test.
//
// Hints:
// user_mem_assert() and env_run() are useful here.
// To change what the user environment runs, modify 'curenv->env_tf'
// (the 'tf' variable points at 'curenv->env_tf').

// LAB 4: Your code here.
if (curenv->env_pgfault_upcall) {
uintptr_t stacktop = UXSTACKTOP;
if (UXSTACKTOP - PGSIZE < tf->tf_esp && tf->tf_esp < UXSTACKTOP) {
stacktop = tf->tf_esp;
}
uint32_t size = sizeof(struct UTrapframe) + sizeof(uint32_t);
user_mem_assert(curenv, (void *)stacktop - size, size, PTE_U | PTE_W);
struct UTrapframe *utr = (struct UTrapframe *)(stacktop - size);
utr->utf_fault_va = fault_va;
utr->utf_err = tf->tf_err;
utr->utf_regs = tf->tf_regs;
utr->utf_eip = tf->tf_eip;
utr->utf_eflags = tf->tf_eflags;
utr->utf_esp = tf->tf_esp; //UXSTACKTOP栈上需要保存发生缺页异常时的%esp和%eip

curenv->env_tf.tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
curenv->env_tf.tf_esp = (uintptr_t)utr;
env_run(curenv); //重新进入用户态
}
// 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

exercise10

现在需要实现lib/pfentry.S中的_pgfault_upcall函数,该函数会作为系统调用sys_env_set_pgfault_upcall()的参数。

// LAB 4: Your code here.
addl $8, %esp // esp+8 -> PushRegs over utf_fault_va utf_err
movl 0x20(%esp), %eax // eax = (esp+0x20 -> utf_eip )
subl $4, 0x28(%esp) // for trap time eip 保留32bit, esp+48 = utf_esp
movl 0x28(%esp), %edx // %edx = utf_esp-4
movl %eax, (%edx) // %eax = eip ----> esp-4 以至于ret可以直接读取其继续执行的地址

// Restore the trap-time registers. After you do this, you
// can no longer modify any general-purpose registers.
// LAB 4: Your code here.
popal // after popal esp->utf_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 // esp+4 -> utf_eflags
popfl

// Switch back to the adjusted trap-time stack.
// LAB 4: Your code here.
popl %esp

// Return to re-execute the instruction that faulted.
// LAB 4: Your code here.
ret

exercise11

完成lib/pgfault.c中的set_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.
//panic("set_pgfault_handler not implemented");
sys_page_alloc(sys_getenvid(), (void *) (UXSTACKTOP - PGSIZE), PTE_SYSCALL);
sys_env_set_pgfault_upcall(sys_getenvid(), _pgfault_upcall);
}

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

缺页处理小结:

  1. 引发缺页中断,执行内核函数链子:trap()->trap_dispatch()->page_fault_handler()
  2. page_fault_handler()切换到用户异常栈,并且压入UTrapframe结构,然后调用curenv->env_pgfault_upcall(系统调用sys_env_set_pgfault_upcall()设置,之前已经设置为_pgfault_upcall)处的代码。又重新回到用户态。
  3. 执行_pgfault_upcall处的代码,调用pgfault_handler(库函数set_pgfault_handler()设置)处的代码,最后返回到缺页处理中断发生时的那条指令重新执行。

JOS缺页异常处理逻辑

Implementing Copy-on-Write Fork

到目前已经可以实现用户级别的写时拷贝fork函数了。fork流程如下:

  1. 使用set_pgfault_handler()设置缺页处理函数。
  2. 调用sys_exofork()系统调用,在内核中创建一个Env结构,复制当前用户环境寄存器状态,UTOP以下的页目录还没有建立,新创建的进程还不能直接运行。
  3. 拷贝父进程的页表和页目录到子进程。对于可写的页,将对应的PTE的PTE_COW位设置为1。
  4. 为子进程设置_pgfault_upcall。
  5. 将子进程状态设置为ENV_RUNNABLE。

缺页处理函数pgfault()流程如下:

  1. 如果发现错误是因为写造成的(错误码是FEC_WR)并且该页的PTE_COW是1,则进行执行第2步,否则直接panic。
  2. 分配一个新的物理页,并将之前出现错误的页的内容拷贝到新的物理页,然后重新映射线性地址到新的物理页。

exercise12

实现lib/fork.c中的fork, duppage and pgfault。

static void
pgfault(struct UTrapframe *utf)
{
void *addr = (void *) utf->utf_fault_va;
uint32_t err = utf->utf_err;
int r;

// Check that the faulting access was (1) a write, and (2) to a
// copy-on-write page. If not, panic.
// Hint:
// Use the read-only page table mappings at uvpt
// (see <inc/memlayout.h>).

// LAB 4: Your code here.
if (!((err & FEC_WR) && (uvpt[PGNUM(addr)] & PTE_COW))) { //只有因为写操作写时拷贝的地址这中情况,才可以抢救。否则一律panic
panic("pgfault():not cow");
}


// Allocate a new page, map it at a temporary location (PFTEMP),
// copy the data from the old page to the new page, then move the new
// page to the old page's address.
// Hint:
// You should make three system calls.

// LAB 4: Your code here.
addr = ROUNDDOWN(addr, PGSIZE);
if ((r = sys_page_map(0, addr, 0, PFTEMP, PTE_U|PTE_P)) < 0) //将当前进程PFTEMP也映射到当前进程addr指向的物理页
panic("sys_page_map: %e", r);
if ((r = sys_page_alloc(0, addr, PTE_P|PTE_U|PTE_W)) < 0) //令当前进程addr指向新分配的物理页
panic("sys_page_alloc: %e", r);
memmove(addr, PFTEMP, PGSIZE); //将PFTEMP指向的物理页拷贝到addr指向的物理页
if ((r = sys_page_unmap(0, PFTEMP)) < 0) //解除当前进程PFTEMP映射
panic("sys_page_unmap: %e", r);
}

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

// LAB 4: Your code here.
void *addr = (void*) (pn * PGSIZE);
if (uvpt[pn] & PTE_SHARE) {
sys_page_map(0, addr, envid, addr, PTE_SYSCALL); //对于表示为PTE_SHARE的页,拷贝映射关系,并且两个进程都有读写权限
} else if ((uvpt[pn] & PTE_W) || (uvpt[pn] & PTE_COW)) { //对于UTOP以下的可写的或者写时拷贝的页,拷贝映射关系的同时,需要同时标记当前进程和子进程的页表项为PTE_COW
if ((r = sys_page_map(0, addr, envid, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
if ((r = sys_page_map(0, addr, 0, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
} else {
sys_page_map(0, addr, envid, addr, PTE_U|PTE_P); //对于只读的页,只需要拷贝映射关系即可
}
return 0;
}

envid_t
fork(void)
{
// LAB 4: Your code here.
extern void _pgfault_upcall(void);
set_pgfault_handler(pgfault); //设置缺页处理函数
envid_t envid = sys_exofork(); //系统调用,只是简单创建一个Env结构,复制当前用户环境寄存器状态,UTOP以下的页目录还没有建立
if (envid == 0) { //子进程将走这个逻辑
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
if (envid < 0) {
panic("sys_exofork: %e", envid);
}

uint32_t addr;
for (addr = 0; addr < USTACKTOP; addr += PGSIZE) {
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P)
&& (uvpt[PGNUM(addr)] & PTE_U)) {
duppage(envid, PGNUM(addr)); //拷贝当前进程映射关系到子进程
}
}
int r;
if ((r = sys_page_alloc(envid, (void *)(UXSTACKTOP-PGSIZE), PTE_P | PTE_W | PTE_U)) < 0) //为子进程分配异常栈
panic("sys_page_alloc: %e", r);
sys_env_set_pgfault_upcall(envid, _pgfault_upcall); //为子进程设置_pgfault_upcall

if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0) //设置子进程为ENV_RUNNABLE状态
panic("sys_env_set_status: %e", r);
return envid;
}

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

在partC部分,我们要实现抢占非协作式环境,并且实现进程间通信。

Clock Interrupts and Preemption

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

Interrupt discipline

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

我们必须确保在用户环境中运行时设置FL_IF标志,以便在中断到达时,它将被传递到处理器并由中断代码处理。 否则,中断将被屏蔽或被忽略,直到重新启用中断为止。Bootloader 的第一条指令屏蔽了中断,到目前为止,我们还没有重新使能它们。

exercise13

首先修改Trapentry.s,当调用硬件中断处理时,处理器不会传入错误代码,因此我们需要调用TRAPHANDLER_NOEC宏。添加如下代码:

TRAPHANDLER_NOEC(timer_handler, IRQ_OFFSET + IRQ_TIMER);
TRAPHANDLER_NOEC(kbd_handler, IRQ_OFFSET + IRQ_KBD);
TRAPHANDLER_NOEC(serial_handler, IRQ_OFFSET + IRQ_SERIAL);
TRAPHANDLER_NOEC(spurious_handler, IRQ_OFFSET + IRQ_SPURIOUS);
TRAPHANDLER_NOEC(ide_handler, IRQ_OFFSET + IRQ_IDE);
TRAPHANDLER_NOEC(error_handler, IRQ_OFFSET + IRQ_ERROR);

然后修改trap.c,注册IDT

void timer_handler();
void kbd_handler();
void serial_handler();
void spurious_handler();
void ide_handler();
void error_handler();
...
SETGATE(idt[IRQ_OFFSET + IRQ_TIMER],0,GD_KT,timer_handler,0);
SETGATE(idt[IRQ_OFFSET + IRQ_KBD],0,GD_KT,kbd_handler,0);
SETGATE(idt[IRQ_OFFSET + IRQ_SERIAL],0,GD_KT,serial_handler,0);
SETGATE(idt[IRQ_OFFSET + IRQ_SPURIOUS],0,GD_KT,spurious_handler,0);
SETGATE(idt[IRQ_OFFSET + IRQ_IDE],0,GD_KT,ide_handler,0);
SETGATE(idt[IRQ_OFFSET + IRQ_ERROR],0,GD_KT,error_handler,0);

在env_alloc中加入以下代码, 同时取消 sched_halt()中sti的注释,使能中断。

// Enable interrupts while in user mode.
// LAB 4: Your code here.
e->env_tf.tf_eflags |= FL_IF;

Handling Clock Interrupts

目前程序一旦进入用户模式,除非发生中断,否则CPU永远不会再执行内核代码。我们需要开启时钟中断,强迫进入内核,然后内核就可以切换另一个进程执行。
lapic_init()和pic_init()设置时钟中断控制器产生中断。需要写代码来处理中断。

exercise14

修改内核的trap_dispatch()函数,使其在发生时钟中断时调用 sched_yield()以查找并运行不同的环境。

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

此时make grade ,我们能够得到65/80。

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将指向相同的物理页

exercise15

首先是两个系统调用

static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
// LAB 4: Your code here.
//panic("sys_ipc_try_send not implemented");
struct Env* rcvenv;
int ret = envid2env(envid,&rcvenv,0);
if(ret) return ret;
if(!rcvenv->env_ipc_recving) return -E_IPC_NOT_RECV;

if(srcva < (void*)UTOP){
pte_t *pte;
struct PageInfo *pp = page_lookup(curenv->env_pgdir,srcva,&pte);

//按注释顺序
if(srcva != ROUNDDOWN(srcva,PGSIZE)) return -E_INVAL;//不是页对齐
if((*pte & perm) != perm) return -E_INVAL; //权限问题 perm应该是要包含于*pte中的
if (!pp) return -E_INVAL; //src物理页未映射
if((perm & PTE_W) && !(*pte & PTE_W)) return -E_INVAL; //没有写权限

if(rcvenv->env_ipc_dstva < (void*)UTOP){
ret = page_insert(rcvenv->env_pgdir,pp,rcvenv->env_ipc_dstva,perm);//共享相同的映射关系
if(ret) return ret;
rcvenv->env_ipc_perm = perm;
}
}

//send successed and update the target's ipc
rcvenv->env_ipc_recving = 0; //标记接受进程可再次接受信息
rcvenv->env_ipc_from = curenv->env_id;
rcvenv->env_ipc_value = value;
rcvenv->env_status = ENV_RUNNABLE;
rcvenv->env_tf.tf_regs.reg_eax = 0;
return 0;

}
static int
sys_ipc_recv(void *dstva)
{
// LAB 4: Your code here.
//panic("sys_ipc_recv not implemented");
if(dstva < (void*)UTOP){
if(dstva != ROUNDDOWN(dstva,PGSIZE)){
return -E_INVAL;
}
}
curenv->env_ipc_recving = 1;
curenv->env_status = ENV_NOT_RUNNABLE;
curenv->env_ipc_dstva = dstva;
sys_yield();
return 0;
}

然后把他们封装给用户态

void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
// LAB 4: Your code here.
//panic("ipc_send not implemented");
if(pg == NULL){
pg = (void*)-1;
}
int r;
while(1){
r = sys_ipc_try_send(to_env,val,pg,perm);
if(r == 0){
//发送成功
return;
}else if(r == -E_IPC_NOT_RECV){//接受进程为准备好
sys_yield();
}else{
panic("ipc_send(): %e\n",r);
}
}
}
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
// LAB 4: Your code here.
//panic("ipc_recv not implemented");
if(pg == NULL){
pg = (void*)-1;
}
int r = sys_ipc_recv(pg);
if(r < 0){ //error
if(from_env_store) *from_env_store = 0;
if(perm_store) *perm_store = 0;
return r;
}
if(from_env_store){
*from_env_store = thisenv->env_ipc_from;
}
if(perm_store){
*perm_store = thisenv->env_ipc_perm;
}
return thisenv->env_ipc_value;
}

IPC总结图如下:

JOS IPC原理

这里其实是有两个功能,传值和映射共同地址,但是映射地址的功能不一定是要用上的,具体怎么操作看代码和注释吧,注释写得挺详细的。

image-20210815165636781

总结

本实现主要是围绕进程这个概念来展开的,主要介绍四部分:

  1. 支持多处理器。现代的处理器一般都是多核的,并且会有多个处理器,这样每个CPU能同时允许不同的进程,实现并行。。需要用锁解决多CPU的竞争。 CPU和进程在内核中的数据结构如下图所示:

    Env和CpuInfo关系

  2. 实现进程调度。 一种是非抢占式式的,另一种是抢占式的,借助时钟中断实现,时钟中断到来时,内核调用sched_yield()选择另一个Env结构执行。

  3. 实现写时复制fork(进程创建)。fork是库函数,会调用sys_exofork这个系统调用,该系统调用在内核中为子进程创建一个新的Env结构,然后将父进程的寄存器状态复制给该Env结构,复制页表,对于PTE_W为1的页表,复制的同时,设置PTE_COW标志。为父进程和子进程设置缺页处理函数,处理逻辑就是:当缺页中断发生是因为写时拷贝的地址,分配一个新的物理页,然后将该虚拟地址映射到新的物理页。

    原理图上面有

  4. 实现进程间通信。本质还是进入内核修改Env结构的页映射关系。原理图见上。