一、实验内容
二、源码分析
1. 理解nachos单线程地址映射机制
Machine::Run()
中调用Machine::OneInstruction(Instruction *instr)
逐条执行可执行文件中的指令,执行指令过程中和获取下一条指令时如果访问内存,通过machine->ReadMem(…)/WriteMem(…)
完成,这个函数先用Translate(addr, &physicalAddress, size, FALSE)
测试是否会发生异常,如有异常则获取异常类型,在Translate(…)外调用machine->RaiseException(…)
处理异常;如无异常,则完成虚拟地址到物理地址的转换,把物理地址放到指针参数中传出,并在Translate(…)
外用物理地址直接读mainMemory内容。Translate(…)
中有两种地址互斥的转换工具,一个是pageTable,一个是TLB(正常操作系统中应该是二者共存,pageTable中是完整的页表,TLB中是部分pageTable的缓存),在使用pageTable情况下,如果addr对应的逻辑页的表项valid==FALSE
,就会返回PageFault,而且有且仅有这一种情况会返回PageFault。pageTable中保存了逻辑页和物理页的对应关系,由此完成虚拟地址到物理地址转换。在使用TLB情况下,会对整个TLB进行遍历来比对给出的逻辑页和TLB中存的每个逻辑页,然后找到对应页的物理页,如果找不到就返回PageFault。我们只需要做pageTable这种就可以。
现在问题在于,这个addr是从指令中读出的地址,这个地址肯定是逻辑地址,但它是相对于本程序开始位置的地址还是它在整个内存空间中的逻辑地址呢?编写用户程序的人写程序的时候肯定不知道自己的程序运行时会被加载到哪个位置,但问题在于加载器是否会去改动汇编程序中的地址,来让它变成一个全局的地址呢?网上资料众说纷纭。问了老师,老师说是一种“粗粒度”的位置无关代码,即这是相对于程序开始位置的地址。
这个观点在AddrSpace中得到证实。AddrSpace是用executable初始化的,也就是跟当前进程直接相关,并且在AddrSpace初始化的时候才new了一个新的页表,即不同进程有不同的页表,而且查看system文件中也没有存全局的页表,所以用户程序中的地址只能是相对于本程序起始位置的地址。也就是说,nachos直接跳过了全局逻辑地址这一步,直接完成了从相对于程序起始位置的逻辑地址到主存中实际的物理地址的映射。(这和我理解的正常操作系统也不一样,《操作系统》教材中讲的是,首先有个操作系统管理的全局的页表,每个进程获取这个全局页表中本进程部分的副本,最后交给机器执行的程序中的内存地址是全局的逻辑地址,所以可能出现访问到其它进程表项的危险,所以才有各种保护措施,所以多进程情况下可能出现因为访问了没有权限访问的内存位置而出现的异常)。PageTable每个页表项中有virtualPage, physicalPage, valid, use, dirty, readOnly六项,在AddrSpace构造器中初始化,其中物理页与虚拟页相同(我很不理解为什么要有虚拟页这一项,因为对于所有进程和所有情况,包括多道程序,virtualPage都等于该项的索引,并且没有任何机会修改它。单进程并使用pageTable情况下,初始化AddrSpace时,先计算了该可执行程序的代码段长度、已初始化数据段长度和用户栈长度(1024)之和,如果这个总长度大于主存大小,就根本不让加载这个程序。所以可以断定,在nachos本来支持的单进程方式中,一定不会出现PageFault。
另外很有趣的一点是,Nachos没有PCB,是AddrSpace充当了PCB来描述进程。进程切换时的上下文是在它所在的内核线程中保存的。另外Scheduler没有提供迫使某个Thread放弃CPU的操作,即Nachos是非抢占的。
2. 多进程问题与解决方案
核心是两个问题:如何让多个进程在主存中共存,并在执行指令时完成相对于本进程的逻辑地址到主存中物理地址的转换;如何完成进程的创建和切换。
对于第一个问题的前半部分,能带起来exec.noff的最简单的方案是连续分配内存,即先跑起来的进程装载在前面,后跑起来的进程装载在后面,但这样对于更多进程的情况就会出现问题,首先是,新装载的进程的起始物理地址是哪里?这个可以通过维护一个系统变量记录上一个进程的占用截至位置解决。但如果这个位置之后的空内存不够了怎么办?以及如何判断它不够了?光维护一个记录占用帧数的系统变量,是无法应对由于位置靠前的进程结束、撤出而带来的内存孔洞问题的。所以我的解决方案是,使用bitmap数据结构,每占用一个物理帧,就把它在bitmap中的相应位置置1,当某个进程撤出系统,就把它占用的所有物理帧在bitmap中的相应位置置0,给新进程分配物理帧时,使用bitmap中的Find函数找到一个空帧给它,并存在该进程的pageTable中。逻辑地址到物理地址的映射和nachos原本提供的单进程情况完全一样(就因为没有到全局逻辑地址这一层映射)。
第二个问题涉及系统调用中的操作。我们需要讨论两种初始化进程的方式,一种是如同main函数创建用户进程,它直接调用了progtest.cc中的StartProcess,运行命令行中提到的用户程序,StartProcess中是用新创建的进程的地址空间直接挂到了当前Thread的地址空间指针上,相当于就把自己替换掉了,虽然它和新进程共存于内存中,但它就永远无法再被执行了,还占着一块内存。形象来说,startProcess的做法中,currentThread就像一个出租车,用户进程就像是乘客,只有乘客A下车了,乘客B才能上车,上下车之后车还是那个车。但我们想要的不是这样,我们想要的是乘客A和乘客B能一起走在路上,这样就需要两辆出租车一起开。乘客B上车的时候,乘客A不能下车,那么乘客B就应该坐另一辆出租车,也就是另外Fork一个内核Thread,并将子进程的AddrSpace挂在新的Thread上。尽管在非抢占的情况下,而且针对于我们测试用的exec.noff,前一种方式的结果和正确的结果是差不多的,但前一种方案明显非常不合理。
还有一个小问题,AddrSpace的初始化应该由当前thread完成,还是交给新Fork的Thread?我觉得是后者。在exception.cc中Fork新thread,并且把ProcessStub作为要执行的函数、从r4寄存器中拿到的filename地址作为参数给新的thread。已经注意到,对于做系统调用的进程,它会在异常处理结束后回到RaiseException函数,这个函数调用ExceptionHandler(which)
之前把系统模态设为SystemMode,调用之后设为UserMode,所以不用担心。
三、实现方案
对于第一个问题,我修改了AddrSpace构造器中初始化物理页为通过machine->phymem_bitmap->Find()
来获取一个空物理帧,这个phymem_bitmap是我新增的machine的一个成员变量(事实上它应该属于system更好一些,因为machine是对机器硬件的模拟,system是管理者),并且用code和initData填充物理帧时,也通过页表进行从逻辑地址到物理地址转换(以应对不连续分配的问题)。并且维护一个系统变量remainVirMemPage计算剩余的物理帧(因为要为lab7做准备,所以命名为Vir…),每占用一个物理帧就-1,每释放一个物理帧就+1(析构AddrSpace时逐个地放一批)。
对于第二个问题,解决方案上文已经讲明。实现中,由于创建线程时只能传一个int类型参数,所以不能把待运行程序的文件名直接作为参数传给新线程,而且创建线程也需要运行一个函数,而不是运行一个文件。所以我的ExceptionHandler先从寄存器4中获取待运行程序的文件名;然后创建一个新线程,把这个地址和一个ProcessStub函数交给新线程;本线程调用advancePC()
完成PC+1的操作(这是因为mipssim.cc中,当涉及访存操作时,处理完都是直接return而非break,使得它没有执行函数末尾的PC+1,因为有些异常需要重新执行当前指令,比如pageFault);然后让当前线程放弃CPU而引发调度让其它(新)进程执行(这里应该不放弃也行,就是当前进程结束后自然放弃CPU让其它(新)进程执行)。ProcessStub中,我先根据传进来的文件名所在内存的起始地址获取文件名(如果遇到\0就结束读取字符串),并从文件系统中获取这个可执行文件,然后用这个可执行文件初始化AddrSpace,并把这个addrSpace挂载到“当前线程”上,然后删掉一些过程中的变量,初始化寄存器和状态,然后开始运行。需要特别注意的是,这个“当前进程”并非父进程,尽管把ProcessStub交给新线程的时候本线程还没有Yield,但这只是将这个新线程加入到了就绪队列,本线程只要不放弃CPU新线程是不会执行的。所以上文中的“当前进程”其实是这个新线程,是新进程的“出租车”。
另外一个小问题,就是Exec()
系统调用需要返回一个SpaceId(是个int),所以我在addrspace.h中加上一个int id成员变量,在addrSpace初始化时获取一个值赋给id。并且在machine中维护一个int pidCounter
。每调用一次Exec()
,pidCounter都要+1。那么在哪里做这个+1:① 在抢占式的策略中,不可以在ExceptionHandler中Fork之后做pidCounter++的工作,因为不一定新进程和原进程谁先执行。② Fork之前完成,这样pidCounter是从1开始的。③ 在AddrSpace初始化的时候,用machine->pidCounter给id赋值之后,就给它++。我使用了第三种,因为第一种不稳妥,第二种可能出现没有Fork成功,但pidCounter已经++的情况。
新的问题是,获取pidCounter和pidCounter++之间是否应该关中断。我认为这里没必要,因为如果两个进程都在获取pidCounter,而其中一个获取到pidCounter,这时发生了interrupt,nachos处理interrupt的时候甚至完全没有离开当前线程,直接把interrupt从队列中拿出来扔掉了(nachos是非抢占式调度,即使离开当前线程去处理interrupt,处理完毕之后也会回来的)。但在抢占式调度中,必须关中断,否则就可能出现两个进程获取到相同的pidCounter的情况。nachos源码中的关中断操作,一般是因为中断处理的过程中可能改变当前的操作关注的机器属性。而对于pidCounter,没有来自外部的中断对这个值感兴趣。而内部对它感兴趣的中断没有机会被执行,所以它很安全。
另外考虑到一个问题,关中断期间出现的程序性中断是否可能造成中断打不开?结论是不可能。int是32位的,即pidCounter的最大值不能超过2^32,在极端情况下,关中断期间如果发生的溢出,将在开中断后的第一条用户指令后被处理。
四、关键代码
void ExceptionHandler(ExceptionType which){
……
case SC_Exec:
{
DEBUG('a', "Execute a new user process.\n");
int addr=machine->ReadRegister(4);
//rent a car for the new process
Thread *t = new Thread("NewUserProcess");
t->Fork(ProcessStub, addr);//only responsible for putting new thread in ready queue, and will go back.
advancePC();
currentThread->Yield();
break;
}
default:
{
DEBUG('a',"wrong type!!!!!!!!\n");
break;
}
……
}
void advancePC(){
machine->WriteRegister(PCReg,machine->ReadRegister(PCReg)+4);
machine->WriteRegister(NextPCReg,machine->ReadRegister(NextPCReg)+4);
}
void ProcessStub(int filenameAddr){
char * filename=new char[20];
for(int i=0;i<20;i++){
int value;
machine->ReadMem(filenameAddr+i,1,&value);
if(value=='\0')break;
filename[i]= value;
}
filename=%c%c%c%c%c%c%c%c\n",filename_chars[0],filename_chars[1],filename_chars[2],filename_chars[3],filename_chars[4],filename_chars[5],filename_chars[6],filename_chars[7]);
OpenFile *executable = fileSystem->Open(filename);
AddrSpace *space;
if (executable == NULL) {
return;
}
space = new AddrSpace(executable);
currentThread->space = space; //say goodbye to the stub
delete executable; // close file
space->InitRegisters(); // set the initial register values
space->RestoreState(); // load page table register
machine->Run(); // jump to the user progam
ASSERT(FALSE); // machine->Run never returns;
}
AddrSpace(OpenFile *executable){
……
ASSERT(numPages <= machine->remainVirMemPage);
……
pageTable[i].physicalPage = machine->phymem_bitmap->Find();//可能不连续
……
if (noffH.code.size > 0) {
DEBUG('a', "Initializing code segment, at 0x%x, size %d\n",
noffH.code.virtualAddr, noffH.code.size);
int next_to_load_virtualAddr=noffH.code.virtualAddr;
int remain_code_bytes=noffH.code.size;
while(remain_code_bytes>0){
int load_bytes=remain_code_bytes<PageSize?remain_code_bytes:PageSize; executable->ReadAt(&(machine->mainMemory[virAddr2phyAddr(next_to_load_virtualAddr)]),load_bytes,noffH.code.inFileAddr+next_to_load_virtualAddr-noffH.code.virtualAddr);
remain_code_bytes-=load_bytes;
next_to_load_virtualAddr+=load_bytes;
}
}
if (noffH.initData.size > 0) {
DEBUG('a', "Initializing data segment, at 0x%x, size %d\n",
noffH.initData.virtualAddr, noffH.initData.size);
int next_to_load_virtualAddr=noffH.initData.virtualAddr;
int remain_initData_bytes=noffH.initData.size;
while(remain_initData_bytes>0){
int load_bytes=remain_initData_bytes<PageSize?remain_initData_bytes:PageSize;
executable->ReadAt(&(machine->mainMemory[virAddr2phyAddr(next_to_load_virtualAddr)]),
load_bytes, noffH.initData.inFileAddr+next_to_load_virtualAddr-noffH.initData.virtualAddr);
remain_initData_bytes-=load_bytes;
next_to_load_virtualAddr+=load_bytes;
}
}
id=machine->pidCounter;
machine->pidCounter++;
machine->remainVirMemPage-=numPages;//newly added. it's save at anywhere unless use preemptive strategy
printf("spaceId:%d\n",id);
Print();
}
五、实验结果