调用sched_yield,sched_yield调用env_run, env_run检测到curenv不为空, 就需要进行进程切换的过程, 进程切换时, 首先要保护这个进程的上下文(上下文指当前进程的寄存器内容, 页表的详细信息等)。页表什么的已经在PCB块里了,不需要再自己进行操作去保护了,但是这些寄存器的值都没有得到保护,这咋办呢,其实操作系统帮我们把这些东西存在了TIMESTACK里(至于怎么存进去的接下来会细说)。所以我们要做的就是把这些东西存到PCB块里去,毕竟PCB块是我们辨识一个进程的唯一标准,所以PCB块绝对不会因为进程的调度发生变化。来看看PCB块
struct Env { struct Trapframe env_tf; // Saved registers LIST_ENTRY(Env) env_link; // Free list u_int env_id; // Unique environment identifier u_int env_parent_id; // env_id of this env's parent u_int env_status; // Status of the environment Pde *env_pgdir; // Kernel virtual address of page dir u_int env_cr3; LIST_ENTRY(Env) env_sched_link; u_int env_pri; // Lab 4 IPC u_int env_ipc_value; // data value sent to us u_int env_ipc_from; // envid of the sender u_int env_ipc_recving; // env is blocked receiving u_int env_ipc_dstva; // va at which to map received page u_int env_ipc_perm; // perm of page mapping received // Lab 4 fault handling u_int env_pgfault_handler; // page fault state u_int env_xstacktop; // top of exception stack // Lab 6 scheduler counts u_int env_runs; // number of times been env_run'ed u_int env_nop; // align to avoid mul instruction };
至于这些寄存器要存在哪里,是TrapFrame的简写(tf),tf也是一个结构体,我们来看看
struct Trapframe { //lr:need to be modified(reference to linux pt_regs) TODO /* Saved main processor registers. */ unsigned long regs[32]; /* Saved special registers. */ unsigned long cp0_status; unsigned long hi; unsigned long lo; unsigned long cp0_badvaddr; unsigned long cp0_cause; unsigned long cp0_epc; unsigned long pc; };
可以清晰地看出,tf存的就是各种寄存器(进程上下文)
当然不要遗漏的操作是将pc值设置为epc的值。之后就是要切换进城了,切换到想要运行的进程,需要做的是恢复它的进程上下文,恢复cr3寄存器,进程上下文。
env_run会不会返回立刻呢,答案是不会的,这辈子都不可能返回,pc值被人家偷了,直接就去执行目标进程了。
来看看证据,然我们去看看pop_tf最后的操作是个啥:
lw k1,TF_PC(k0) lw k0,TF_STATUS(k0) nop mtc0 k0,CP0_STATUS j k1 #正大光明的跳槽了 rfe nop
中断处理的过程,也许上面留有最大的疑惑莫过于TIMESTACK了,那让我们来看看这个TIMESTACK是怎么回事,首先来看看时间中断会发生些什么。
这个时钟中断,我个人感觉是硬件级的,联想计组,计时器的outptu直接练到了CP0上,每当触发了时钟中断,就会触发mips的中断。硬件,注意是硬件(CPU)会帮你把PC调到0x80000080,跳转到.text.exc_vec3代码段执行,让我们来看看代码都做了什么。
NESTED(except_vec3, 0, sp) .set noat .set noreorder 1: mfc0 k1,CP0_CAUSE # 取cause寄存器 la k0,exception_handlers # 这个exception_handlers是一个字端,记录着中断的类型 andi k1,0x7c addu k0,k1 lw k0,(k0) # 现在k0存的就是处理这个中断的pc值了 nop jr k0 # 跳过去,结束。 nop END(except_vec3)
那么时钟中断会被分发到哪里呢,课程组告诉我们了,是handle_int,来看看
NESTED(handle_int, TF_SIZE, sp) .set noat //1: j 1b nop SAVE_ALL # !!!!! 当当当当,我们之前的疑惑会在这里被解决!!!!! CLI .set at mfc0 t0, CP0_CAUSE mfc0 t2, CP0_STATUS and t0, t2 andi t1, t0, STATUSF_IP4 bnez t1, timer_irq # 跳到时钟中断处理函数里去了 nop END(handle_int)
.macro SAVE_ALL mfc0 k0, CP0_STATUS sll k0, 3 /* extract cu0 bit */ bltz k0, 1f nop /* * Called from user mode, new stack */ //lui k1,%hi(kernelsp) //lw k1,%lo(kernelsp)(k1) //not clear right now 1: move k0, sp get_sp # 关键,不然sp是啥呢,随便存个地不好好记录,结果一定是,找不到了。 move k1, sp subu sp, k1, TF_SIZE sw k0, TF_REG29(sp) sw $2, TF_REG2(sp) mfc0 v0, CP0_STATUS sw v0, TF_STATUS(sp) mfc0 v0, CP0_CAUSE sw v0, TF_CAUSE(sp) mfc0 v0, CP0_EPC sw v0, TF_EPC(sp) mfc0 v0, CP0_BADVADDR sw v0, TF_BADVADDR(sp) mfhi v0 sw v0, TF_HI(sp) mflo v0 sw v0, TF_LO(sp) sw $0, TF_REG0(sp) sw $1, TF_REG1(sp) # …… # 省略一下,不然太冗杂了,这里就是存寄存器的值 sw $31, TF_REG31(sp) .endm
.macro get_sp mfc0 k1, CP0_CAUSE andi k1, 0x107C xori k1, 0x1000 bnez k1, 1f # 判断是不是时钟中断 nop li sp, 0x82000000 # TIMESTACK就在这了 j 2f nop 1: bltz sp, 2f nop lw sp, KERNEL_SP nop 2: nop .endm
接下来看看时钟中断是怎么处理的
timer_irq: 1: j sched_yield nop # 大摇大摆,去让别的进程跑起来 # 这时候,也许你会疑惑,根据前面的知识我明确的知道调用了env_run之后, # 别的进程就跑了,根本不会返回,后面这两句话, # ret_from_exception它有啥用捏 # 再仔细回想,我们调用sched_yield,调用run函数时第一件做的事是什么 # 如果当前有进程在跑,就要保护它的进程上下文啊 # 那想想如果这个之前被时钟中断了的进程恢复了上下文,第一件干的事是啥啊, # 从run那挨个返回,最后肯定会到这啊 # 接下来 j ret_from_exception就会大显神威 j ret_from_exception # 恢复栈帧,然后回到调用这个yield的地方去 nop
那么调用链我们就捋顺了,接下来就可以快乐lab4了
syscall
![]()
syscall_lib中做的唯一的事情实际上就是去调用msyscall函数;
当然msyscall也很简单啦LEAF(msyscall) // TODO: you JUST need to execute a `syscall` instruction and return from msyscall syscall jr ra nop END(msyscall)
执行到这个syscall可就有学问了,在收到这个syscall之后,PC就跑到0x80000080里去了,然后经过分发,会到handle_sys这个函数里,来看看handle_sys,handle_sys是一个体量不小的汇编函数,来慢慢分析
NESTED(handle_sys,TF_SIZE, sp) SAVE_ALL # 存到拿了呢,分析get_sp可以知道存在了KERNEL_SP CLI // Clean Interrupt Mask nop .set at // Resume use of $at // TODO: Fetch EPC from Trapframe, calculate a proper value and store it back to trapframe. lw t0, TF_EPC(sp) addiu t0, t0, 4 sw t0, TF_EPC(sp) # 计算EPC // TODO: Copy the syscall number into $a0. lw a0, TF_REG4(sp) addiu a0, a0, -__SYSCALL_BASE // a0 <- relative syscall number sll t0, a0, 2 // t0 <- relative syscall number times 4 la t1, sys_call_table // t1 <- syscall table base addu t1, t1, t0 // t1 <- table entry of specific syscall lw t2, 0(t1) // t2 <- function entry of specific syscall lw t0, TF_REG29(sp) // t0 <- user's stack pointer lw t3, 16(t0) // t3 <- the 5th argument of msyscall lw t4, 20(t0) // t4 <- the 6th argument of msyscall // TODO: Allocate a space of six arguments on current kernel stack and copy the six arguments to proper location lw a0, TF_REG4(sp) lw a1, TF_REG5(sp) lw a2, TF_REG6(sp) lw a3, TF_REG7(sp) addiu sp, sp, -24 sw a0, 0(sp) sw a1, 4(sp) sw a2, 8(sp) sw a3, 12(sp) sw t4, 20(sp) sw t3, 16(sp) # mips的传递变量的特点 jalr t2 # 跳转到特定的系统调用处理处 // Invoke sys_* function nop // TODO: Resume current kernel stack addiu sp, sp, 24 # 恢复栈帧 sw v0, TF_REG2(sp) // Store return value of function sys_* (in $v0) into trapframe j ret_from_exception // Return from exeception nop END(handle_sys)
最后就回到我们写的syscall_all这个文件里执行特定的系统调用了,这一出现了一个sys_yield时bcopy存在的原因,我们在SAVE_ALL的时候,把上下文都存在KERNEL_SP里,而我们之前分析过,env_run以为你把上下文存在了哪里呢,存在了TIMESTACK里,所以我们不得不做一个操作了,下面来看看sys_yield的代码
void sys_yield(void) { bcopy((void *)(KERNEL_SP - sizeof(struct Trapframe)), (void *)(TIMESTACK - sizeof(struct Trapframe)), sizeof(struct Trapframe)); sched_yield(); }
IPC
接下来看看IPC,IPC是进程间通信,进程间怎么通信呢,想要通信,必须要有的就是传递数据吧,而我们又知道每个进程之间的虚拟地址都是独立的,好像传递数据是件很困难的事,但是,要记得我们这个操作系统没有真正意义上的内核进程,都是用户进程改变权限拥有了内核的能力,所以每个用户进程都拥有着同一个“内核”,所以进程间通信的关键,就是这个内核空间(实际上是指UTOP上的空间),那把数据存在哪里合适呢,最好是和进程有紧密的对应关系吧,所以,进程控制块又一次成为了关键。
FORK
![]()
调用fork函数,来看看fork函数做了什么
int fork(void) { // Your code here. u_int newenvid; extern struct Env *envs; extern struct Env *env; // 这个env在用户空间,随着页表的切换,取出来的值也会不同 u_int i; //The parent installs pgfault using set_pgfault_handler set_pgfault_handler(pgfault); /** * 这个函数可是很有些东西 * 因为它要实现的可是让父子进程拥有不同的返回值 * 这个功能可谓是很厉害也很难实现 * 试想你想用一个函数返回两个不同的值 * 返回到父子进程里,关键是父子进程之后执行的可是同一份代码 * 这个函数很好地利用了TRAPFRAME这个东西 * 来好好看看这个函数 */ newenvid = syscall_env_alloc(); if (newenvid == 0) { env = envs + ENVX(syscall_getenvid()); // 子进程第一次造出来,得设置好env return 0; } for (i = 0; i < UTOP - BY2PG * 2; i += BY2PG) { if (((*vpd)[PDX(i)] & PTE_V) && ((*vpt)[PTX(i)] & PTE_V)) { duppage(newenvid, VPN(i)); } } // 设置好内存映射,写时复制机制 int ret = 0; ret = syscall_mem_alloc(newenvid, UXSTACKTOP - BY2PG, PTE_V | PTE_R); if (ret < 0) { user_panic("error"); } // 设置好异常处理栈 ret = syscall_set_pgfault_handler(newenvid, __asm_pgfault_handler, UXSTACKTOP); if (ret < 0) { user_panic("error"); } ret = syscall_set_env_status(newenvid, ENV_RUNNABLE); if (ret < 0) { user_panic("error"); } return newenvid; }
int sys_env_alloc(void) { // Your code here. int r; struct Env *e; r = env_alloc(&e, curenv->env_id); if (r != 0) { return r; } bcopy((void *)(KERNEL_SP - sizeof(struct Trapframe)), &(curenv->env_tf), sizeof(struct Trapframe)); bcopy((void *)(&(curenv->env_tf)), (void *)(&(e->env_tf)), sizeof(struct Trapframe)); // 拷贝进程控制块 e->env_tf.pc = e->env_tf.cp0_epc; e->env_status = ENV_NOT_RUNNABLE; e->env_pri = curenv->env_pri; e->env_tf.regs[2] = 0; // 这是给子进程返回的 return e->env_id; // 这是给父进程返回的 }
下面来好好分析下是怎么返回的两个值,我们知道这个时候子进程的进程控制块取的是父进程的进程控制块,而且是在父进程设置好返回值之前,之后一句e->env_tf.regs[2] = 0可是很关键,2号寄存器是存返回值的意思,这时候给子进程的返回值设置成了0,而且子进程的EPC是啥啊,是系统调用之前的pc,所以子进程执行的时候,带着0就回去了,而父进程还要走一步,return e->env_id,至此父子进程走上了各自的旅程。
写时复制缺页处理机制
经过异常的分发,回到handle_mod函数里,这个函数会跳转到page_fault_handler函数中
page_fault_handler(struct Trapframe *tf) { struct Trapframe PgTrapFrame; extern struct Env *curenv; bcopy(tf, &PgTrapFrame, sizeof(struct Trapframe)); if (tf->regs[29] >= (curenv->env_xstacktop - BY2PG) && tf->regs[29] <= (curenv->env_xstacktop - 1)) { tf->regs[29] = tf->regs[29] - sizeof(struct Trapframe); bcopy(&PgTrapFrame, (void *)tf->regs[29], sizeof(struct Trapframe)); } else { tf->regs[29] = curenv->env_xstacktop - sizeof(struct Trapframe); bcopy(&PgTrapFrame,(void *)curenv->env_xstacktop - sizeof(struct Trapframe),sizeof(struct Trapframe)); } // TODO: Set EPC to a proper value in the trapframe tf->cp0_epc = curenv->env_pgfault_handler; // 关键就是在这个env_pgfault_handler怎么来的 // 不难发现,在fork的时候用了set_pgfault_handler和syscall_set_pgfault_handler // 这也就是这个函数的来源,那设置成了什么呢,是__asm_pgfault_handler return; }
拷贝完tf,然后跳转到__asm_pgfault_handler(在fork里设置好的)函数中
__asm_pgfault_handler: lw a0, TF_BADVADDR(sp) lw t1, __pgfault_handler # 跳到真正的处理函数去,这里面存的啥呢,课程组说是pgfault jalr t1 nop lw v1, TF_LO(sp) mtlo v1 lw v0, TF_HI(sp) lw v1, TF_EPC(sp) mthi v0 mtc0 v1, CP0_EPC lw $31, TF_REG31(sp) ... lw $1, TF_REG1(sp) lw k0, TF_EPC(sp) jr k0 lw sp, TF_REG29(sp)
从内核返回后,此时的栈指针是由内核设置的在异常处理栈的栈指针,而且指向一个由内核复制好的Trapframe 结构体的底部。通过宏TF_BADVADDR 用lw 指令取得了Trapframe 中的cp0_badvaddr 字段的值,这个值也正是发生缺页中断的虚拟地址。将这个地址作为第一个参数去调用了__pgfault_handler 这个字内存储的函数,不难看出这个函数是真正进行处理的函数。函数返回后就是一段类似于恢复现场的汇编,最后非常巧妙地利用了MIPS 的延时槽特性跳转的同时恢复了栈指针。
那么接下来就来看看这个真正的处理函数
static void pgfault(u_int va) { u_int *tmp; tmp = USTACKTOP; va = ROUNDDOWN(va, BY2PG); // writef("fork.c:pgfault():\t va:%x\n",va); Pte perm = (*vpt)[va >> PGSHIFT] & 0xfff; if ((perm & PTE_COW) == 0) { // 首先要判断是不是写时复制 user_panic("not COW"); } //map the new page at a temporary place if (syscall_mem_alloc(0, tmp, (PTE_V | PTE_R)) != 0) { //申请一个新的物理页面 user_panic("alloc error"); } //copy the content user_bcopy(va, tmp, BY2PG); //map the page on the appropriate place if (syscall_mem_map(0, tmp, 0, va, (PTE_V | PTE_R))) { // 这里一定要把写时复制的标志给去了 user_panic("map error"); } //unmap the temporary place if (syscall_mem_unmap(0, tmp) != 0) { user_panic("unmap error"); } }
来分析一下流程,首先我发现这个页面是写时复制的了,现在我要让我这块位置拥有一个属于自己的物理页面,这样我xjbx的时候,才不会导致把别人家的进程也给写了,第一步,理所当然,分配一个全新的物理页面,之后关键来了,为什么要先映射到一个tmp上,我直接映射到va上多舒服?
想想,映射到va上,va映射的是个啥,是个空空如也的页面,数据全丢了,心痛不,心痛。所以要显映射到一个tmp上,把va对应的物理页面给复制好了,在映射过去。这时候,这个进程就拥有了属于自己的页面,可以开始胡作非为了。这也标志着缺页处理到此结束,该迭盒子返回了。这时候我们也应该对fork函数里那些处理有了更加深刻的理解,再看看fork函数
int fork(void) { // Your code here. u_int newenvid; u_int i; //The parent installs pgfault using set_pgfault_handler set_pgfault_handler(pgfault); // …… 子进程处理在这里不看了 for (i = 0; i < UTOP - BY2PG * 2; i += BY2PG) { if (((*vpd)[PDX(i)] & PTE_V) && ((*vpt)[PTX(i)] & PTE_V)) { duppage(newenvid, VPN(i)); } } // 设置好内存映射,写时复制机制 int ret = 0; ret = syscall_mem_alloc(newenvid, UXSTACKTOP - BY2PG, PTE_V | PTE_R); if (ret < 0) { user_panic("error"); } // 设置好异常处理栈 ret = syscall_set_pgfault_handler(newenvid, __asm_pgfault_handler, UXSTACKTOP); if (ret < 0) { user_panic("error"); } ret = syscall_set_env_status(newenvid, ENV_RUNNABLE); if (ret < 0) { user_panic("error"); } return newenvid; }
把细节展开,看看set_pgfault_handler在干啥
void set_pgfault_handler(void (*fn)(u_int va)) { if (__pgfault_handler == 0) { if (syscall_mem_alloc(0, UXSTACKTOP - BY2PG, PTE_V | PTE_R) < 0 || syscall_set_pgfault_handler(0, __asm_pgfault_handler, UXSTACKTOP) < 0) { writef("cannot set pgfault handler\n"); return; } // 这是在给自己分配缺页处理函数,注意传进去的envid是0,也就是获得的是curenv,父进程 // 而且这个判断了现在pgfault_handler是不是空 // 这是为了之后这个再次调用fork准备,就不用再分配错误栈,所以子进程必须得为再次分配 // 也就有了fork下面的syscall_set_pgfault_handler了 // 在这里设置好父进程的缺页中断处理,也为接下来alloc时可能出现的缺页作了准备 } // Save handler pointer for assembly to call. __pgfault_handler = fn; }
至此,缺页中断的介绍结束
4.1
- 内核在保存现场的时候是如何避免破坏通用寄存器的?
通过SAVE_ALL,将通用寄存器存到一个TrapFrame的结构里,返回的时候再pop
- 系统陷入内核调用后可以直接从当时的 a 0 − a0- a0−a3 参数寄存器中得到用户调用msyscall 留下的信息吗?
不可以,因为可能在这个过程中,a0-a3参数寄存器的值发生了变化,所以应该从TrapFrame中再次取出
- 我们是怎么做到让sys 开头的函数“认为”我们提供了和用户调用msyscall 时同样的参数的?
先取出a0-a3,之后从用户栈中取出其他参数,放到内核栈中,就是handle_sys的操作
- 内核处理系统调用的过程对Trapframe 做了哪些更改?这种修改对应的用户态的变化是?
epc变成了执行系统调用的下一条语句的pc值,系统调用的返回值存到v0中。
4.2
- 子进程完全按照fork() 之后父进程的代码执行,说明了什么?
说明了子进程和父进程有用相同的代码段,数据段。
- 但是子进程却没有执行fork() 之前父进程的代码,又说明了什么?
说明了子进程执行前PC已经设置成**fork()**的下一条语句。
4.3 关于fork 函数的两个返回值,下面说法正确的是:©
A. fork 在父进程中被调用两次,产生两个返回值
B. fork 在两个进程中分别被调用一次,产生两个不同的返回值
C. fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D. fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
4.4我们并不是对所有的用户空间页都使用duppage 进行了保护。那么究竟哪些用户空间页可以保护,哪些不可以呢,请结合include/mmu.h 里的内存布局图谈谈你的看法。
USTACKTOP以下,UTEXT以上的页可以保护,[UXSTACKTOP-BY2PG,UXSTACKTOP],这是错误栈,如果把这个保护了,再发生缺页中断的时候,想在错误栈写东西,会导致什么,会导致再次爆缺页中断,就出不去了,所以必须为子进程单独分配错误栈。**[USTACKTOP,USTACKTOP+BY2PG]**这个是invalid的,所以缺页中断的时候tmp可以映射到这里。这两个区域不用保护,UTOP上区域就更不用说了,本来就该一起写,加上了反而会导致错误。
4.5
- vpt 和vpd 的作用是什么?怎样使用它们?
**vpd=7fdff000=(UVPT+(UVPT>>12)4)
*vpt=7fc00000
*vpd是指向页目录的指针, *vpt是指向页表的指针, 可以当作数组来使用, 获取对应的页目录项和页表项
- 从实现的角度谈一下为什么能够通过这种方式来存取进程自身页表?
- 它们是如何体现自映射设计的?
- 进程能够通过这种存取的方式来修改自己的页表项吗?
不可以,这只提供访问,用户态下不能修改
4.6 page_fault_handler 函数中,你可能注意到了一个向异常处理栈复制Trapframe 运行现场的过程,请思考并回答这几个问题:
- 这里实现了一个支持类似于“中断重入”的机制,而在什么时候会出现这种“中断重入”?
在处理缺页终端的时候,可能回来外部中断,这就会把KERNEL_SP的东西覆盖,所以先暂存KERNEL_SP,真的需要用的时候从暂存区取出来。
- 内核为什么需要将异常的现场Trapframe 复制到用户空间?
我们写的操作系统是一个微内核结构,处理缺页中断实打实处理的是用户态下的函数,所以要把TramFrame拷到用户空间,可以让函数自如的访问。
4.7
- 用户处理相比于在内核处理写时复制的缺页中断有什么优势?
这里体现了微内核的思想,将内核的功能分派给用户进程处理,这样就算“内核”的一部分出了问题,也不会直接使整个操作系统over。
- 从通用寄存器的用途角度讨论用户空间下进行现场的恢复是如何做到不破坏通用寄存器的?
通过将通用寄存器的值压入栈中,然后在使用时取出,可以避免破坏通用寄存器中所存储的值。在取出的时候先取出除sp之外的其他寄存器,然后在跳转返回的同时,利用延时槽的特性恢复sp。
4.8
- 为什么需要将set_pgfault_handler 的调用放置在syscall_env_alloc 之前?
在alloc的时候就会产生缺页中断
- 如果放置在写时复制保护机制完成之后会有怎样的效果?
若写时复制机制已完成,则缺页中断处理时不会达到更新效果,因为写时复制是依赖于缺页中断的。
- 子进程需不需要对在entry.S 定义的字__pgfault_handler 赋值?
不需要,父子进程共享那次赋值操作的结果
本次lab4可以说是体量非常大,而且注释存在着不足,所以想要单纯的逐个攻破非常难,必须有一个非常清晰地调用关系,也就是功能操作的前因后果。除此以外,lab4中部分代码运用了一些技巧,不仔细分析很难立即,比如在sys_env_alloc的时候,巧妙的运用TrapFrame产生了两个返回值。处理缺页中断的时候对于那一小块InValid区域的运用,设置PTE_COW时对于空间的考量,都非常细节。除此以外,lab4还需要阅读大量的汇编代码,比如get_sp理解如何分配TrapFrame等等。
到现在我也只能说对这个操作系统有了一个自己的看法,不能说准确,不能说正确,也仍旧有很多没有透彻的点
- KERNEL_SP在初始化的时候到底做了什么
- 中断重入,个人感觉即使采取了暂存的策略,但是在跳转函数的过程中,如果再次发生中断也会产生数据丢失。