Lab2 虚拟内存
任务完成情况
Exercise | Y/N |
---|---|
Exercise1 | Y |
Exercise2 | Y |
Exercise3 | Y |
Exercise4 | Y |
Exercise5 | Y |
Exercise6 | Y |
Exercise7 | Y |
*challenge | Y |
Exercise 1 源代码阅读
一些值得注意的细节
如果要让Nachos运行用户程序,需要在MakeFile中添加-DUSER_PROGRAM
。之后,一切的#ifdef USER_PROGRAM范围内的内容都会被编译。system.cc中初始化了一个Machine类型的对象machine,作为Nachos的模拟硬件系统,用来执行用户的程序。Nachos机器模拟的核心部分是内存和寄存器的模拟。
nachos寄存器模拟了MIPS机(R2/3000)的全部的32个寄存器,此外还包括8个用于调试的寄存器。一些特殊寄存器描述如下表:
寄存器名 | 编号 | 描述 |
---|---|---|
StackReg | 29 | 进程栈顶指针 |
RetAddrReg | 31 | 存放返回地址 |
HiReg | 32 | 存放乘法结果高32位 |
LoReg | 33 | 存放乘法结果低32位 |
PCReg | 34 | 存放当前指令的地址 |
NextPCReg | 35 | 存放下一条执行指令的地址 |
PrevPCReg | 36 | 存放上一条执行指令的地址 |
LoadReg | 37 | 存放延迟载入的寄存器编号 |
LoadValueReg | 38 | 存放延迟载入的值 |
BadAddReg | 39 | 发生Exception(异常或syscall)时用户程序逻辑地址 |
Nachos 用宿主机的一块内存模拟自己的内存。由于 Nachos 是一个教学操作系统,在内存分配上和实际的操作系统是有区别的,且作了很大幅度的简化。
Nachos中每个内存页的大小同磁盘扇区的大小相同,而整个内存的大小远远小于模拟磁盘的大小,仅为32 * 128 = 4KB
。事实上,Nachos 的内存只有当需要执行用户程序时用户程序的一个暂存地,而作为Nachos内核的数据结构并不存放在 Nachos 的模拟内存中,而是申请 Nachos 所在宿主机的内存。所以 Nachos 的一些重要的数据结构如线程控制结构等的容量可以是无限的,不受 Nachos 模拟内存大小的限制。
参考资料《Nachos中文教程》
progtest.cc
在terminal中输入nachos -x filename
,main()将调用StartProcess(char *filename)函数,其执行流程为:打开文件->为当前线程分配地址空间(调用addrSpace函数,根据程序的数据段、程序段和栈的总和来分配地址空间,具体的流程为:将头文件内容加载到NoffHeader的结构体中,它定义了Nachos目标代码的格式,并且在必要的时候进行大小端的转换->计算用户程序所需内存空间大小,并计算所需页数->建立虚拟地址到物理地址的转换机制(目前Nachos的地址转换是完全对等的,即采用绝对装入的方式,只适用于单道程序)->调用machine->mainMemory初始化物理空间,将程序的数据段和代码段拷贝到内存中->关闭文件->初始化寄存器(InitRegisters)->将当前线程对应的页表始址和页目录数写入“页表寄存器”(在Machine中用两个public成员变量:pageTable
和pageTableSize
来实现“页表寄存器”的功能(即存储currentThread的页表始址和页表大小)->调用machine->run函数,该函数会调用OneInstruction函数逐条执行用户指令,同时调用OneTick函数,让系统时间前进,直到当前进程执行结束或者主动/被动让出CPU。
machine.h(cc)
machine.h中定义了一组与Nachos模拟寄存器和内存有关的参数,比如物理页面大小、内存页面数、寄存器大小等等。枚举了Nachos的异常类型:
异常 | 描述 |
---|---|
NoException | 无异常 |
SyscallException | 系统调用(个人意见,系统调用不应该属于异常) |
PageFaultException | 缺页异常(除了页表,使用tlb时同样会抛出此异常) |
ReadOnlyException | 对只读对象做写操作异常 |
BusErrorException | 地址变换结果超过物理内存异常 |
AddressErrorException | 地址不能对齐或者地址越界异常 |
OverflowException | 整型数加减法结果越界异常 |
IllegalInstrException | 非法指令异常 |
成员变量/函数 | 描述 |
---|---|
char *mainMemory; | 模拟内存,用于运行用户程序 |
int registers[NumTotalRegs]; | 模拟寄存器,用于运行用户程序 |
TranslationEntry *tlb; | 快表,系统中只有一个快表 |
TranslationEntry *pageTable;unsigned int pageTableSize; | 当前线程的页表起始地址和页目录数,相当于执行“页表寄存器”的职能。 注意:pageTable变量在两个地方均有定义,一个是在thread.cc中,表示某个线程的页表,这个pageTable可以有多个,并且与线程是一一对应的;另一个是在machine.cc中,表示currentThread的页表,这个pageTable只有一个,且始终指向currentThread的页表。注意:无论是tlb还是pageTable,都不存放在模拟的mainMemory之中,而是借用宿主主机内存存放,换言之,mainMemory只用来存放用户程序指令、数据和栈。 |
Machine(bool debug) | 初始化用户程序执行的模拟硬件环境,包括内存,寄存器 |
void Run(); | 逐条执行用户程序指令 |
ReadRegister(int num) | 读取寄存器的值 |
WriteRegister(int num, int value) | 将value写入寄存器 |
void OneInstruction(Instruction *instr); | 执行一条用户程序指令,其步骤为:读取一条指令的二进制编码->decode()解码->获得对应的操作符opCode,rs,rt和rd寄存器->根据opCode执行相应的运算->更新寄存器PC,nextPC,prevPC的值,如果有异常,调用raiseException函数处理异常,然后调用异常处理程序处理异常。在Run函数中无限循环此函数直到用户程序执行到最后一条指令。 |
void DelayedLoad(int nextReg, int nextVal); | 结束当前运行的一切东西,与mips模拟有关 |
translate.h(cc)
定义了页目录项,实现了读写内存,地址转换的功能。
变量/函数 | 描述 |
---|---|
int virtualPage; | 逻辑地址 |
int physicalPage; | 物理地址 |
bool valid; | 此页是否已在内存 |
bool readOnly; | 此页是否为只读 |
bool use; | 此页是否被读或者修改过 |
bool dirty; | 此页是否被修改过 |
bool ReadMem(int addr, int size, int* value); | 读取内存 |
bool WriteMem(int addr, int size, int value); | 写入内存 |
ExceptionType Translate(int virtAddr, int* physAddr, int size,bool writing); | 实现逻辑地址到物理地址的转换,其流程为:通过virtualAddr计算出vpn和offset->通过vpn来查找ppn(在tlb或页表中查询,否则抛出异常,调用异常处理函数,目前这部分还未实现)->将ppn与offset相加,得到物理地址。 Nachos中只允许tlb和页表二选一,如果要实现二者并存,需要将ASSERT(tlb == NULL || pageTable == NULL)和ASSERT(tlb != NULL || pageTable != NULL) 注释掉。其实现流程与我们之前学习的地址变换机构相一致。 |
exception.cc
定义了异常处理函数ExceptionHandler,用来对相应的异常(系统调用,地址越界,算数结果溢出等)做出处理。nachos对于异常或者系统调用的处理过程是:调用machine->raiseException函数,该函数得到异常类型和发生错误的虚拟地址,然后将发生错误的虚拟地址存入BadVAddrReg寄存器,然后调用DelayedLoad函数来结束当前运行中的所有东西,之后设置系统状态为内核态,然后调用ExceptionHandler函数进行异常处理,处理完异常之后重新将系统设置为用户态。
Exercise 2 TLB MISS异常处理
要使用tlb,需要在userprog/MakeFile
中添加-DUSE_TLB
。
DEFINES = -DUSE_TLB
Nachos是如何获取单条指令的(重要)
在 code/machine/mipssim.cc
中
Machine::Run() {
...
while (1) {
OneInstruction(instr);
...
}
}
Machine::OneInstruction(Instruction *instr)
{
...
// Fetch instruction
if (!machine->ReadMem(registers[PCReg], 4, &raw))
return; // 异常发生
instr->value = raw;
instr->Decode();
...
// 计算下一个pc的值,但是为了避免异常发生,暂时不装载
int pcAfter = registers[NextPCReg] + 4;
...
// 现在已经成功执行指令
...
// 更新PC寄存器的值,为了执行下一条指令,PC会自增4,即PC+=4
registers[PrevPCReg] = registers[PCReg];
registers[PCReg] = registers[NextPCReg];
registers[NextPCReg] = pcAfter;
}
如果Nachos获取指令失败(一般是因为PageFaultException),之后它并不会执行PC+4,因为machine->ReadMem()
会返回false
。因此,我们只需要在exceptionHandler中更新tlb,这样Nachos下一次还会执行同一条指令并且再次尝试地址转换。这一知识点非常重要,我还会在Exercise7中提到它。
TLB miss
bool
Machine::ReadMem(int addr, int size, int *value)
{
...
exception = Translate(addr, &physicalAddress, size, FALSE);
if (exception != NoException)
{
machine->RaiseException(exception, addr);
return FALSE;
}
...
}
当执行code/machine/translate.cc
里的 Machine::ReadMem()
或者 Machine::WriteMem()
时,如果translate()返回PageFaultException,会调用Machine::RaiseException()
并返回FALSE。
void
Machine::RaiseException(ExceptionType which, int badVAddr)
{
...
registers[BadVAddrReg] = badVAddr;
...
interrupt->setStatus(SystemMode);
ExceptionHandler(which);
interrupt->setStatus(UserMode);
}
Machine::RaiseException()
会将发生异常的虚拟地址存入BadVAddrReg
,因此我们可以从BadVAddrReg
中获取发生异常的虚拟地址。最后调用exception.cc
里的ExceptionHandler
函数对异常进行处理,目前它只有对Halt
系统调用的处理。这里需要添加对TLB异常的PageFaultException
处理语句:
void ExceptionHandler(ExceptionType which)
{
if (which = PageFaultException)
{
ASSERT(machine->tlb);//保证tlb存在
int badVAddr = machine->ReadRegister(BadVAddrReg);//获取发生错误的虚拟地址
TLBMissHandler(badVAddr);//调用页面置换算法对该虚拟地址进行处理
}
//syscall
...
}
Exercise 3 置换算法
准备工作
-
TLB命中率
为了获取TLB命中率,我在
code/machine/machine.h
中定义了tlbVisitCnt
和tlbHitCnt
, 并在code/machine/machine.cc
中对它们初始化为0。每当调用code/machine/translate.cc
中的translate
函数时tlbVisitCnt++
,每当tlb命中时,tlbHitCnt++
;ExceptionType Machine::Translate(int virtAddr, int *physAddr, int size, bool writing) { tlbVisitCnt++;//每当调用translate函数时,tlbVisitCnt++ ... for (entry = NULL, i = 0; i < TLBSize; i++) if (tlb[i].valid && (tlb[i].virtualPage == vpn)) { tlbHitCnt++;//每当tlb命中时,tlbHitCnt++ ... } ... }
-
自定义用户程序
在code/test中有几个用于测试的用户程序,其中halt太小了,只会用到三页,因此对任意的页面置换算法,使用halt测试出来的结果都是一样的;而其他几个又太大了,装载进mainMemory的时候会报错,例如matmult,但是我会在Exercise7实现了lazy-loading之后测试它。
//运行matmult报错 vagrant@precise32:/vagrant/nachos/nachos-3.4/code/userprog$ ./nachos -d a -x ../test/matmult //a 表示 address,它是Nachos自带的debug flag,后面还会用到m,表示machine Assertion failed: line 81, file "../userprog/addrspace.cc" Aborted
所以我自己写了一个简单的用户程序——计算99乘法表
code/test/99table.c
。/* Author: litang Description: 99table.c,用于简单测试用户程序,内容为计算99乘法表 Since: 2020/11/8 11:33:38 */ #include "syscall.h" #define N 9 int main() { int table[N][N]; for (int i = 1; i <= N; ++i) for (int j = 1; j <= N; ++j) table[i - 1][j - 1] = i * j; Exit(table[N - 1][N - 1]); }
并且将下面的内容加入
code/test/Makefile
all: ... 99table ... # For Lab2: Test the TLB Hit Rate 99table.o: 99table.c $(CC) $(CFLAGS) -c 99table.c 99table: 99table.o start.o $(LD) $(LDFLAGS) start.o 99table.o -o 99table.coff ../bin/coff2noff 99table.coff 99table
完成上述步骤之后,需要在
Nachos3.4/code/test
下重新make
一遍。 -
不同算法的转换
我用了一些宏来作为不同算法切换的开关
- TLB_FIFO
- TLB_LRU
需要注意的是:每次通过修改code/machine/Makefile中的开关来调试程序时,需要对任意代码做一些修改,然后再改回来,否则Nachos编译的时候无法检测MakeFile的改动。
LRU算法
LRU(Least recently used,最近最少使用)。
- 用tlb来记录最近访问的页
- 若页在tlb中已存在,则将该页移到tlb头部,这样就能保证头部页是最近访问的,尾部页是最早访问的,当需要置换页的时候,直接丢弃尾部页,然后将新页插入头部即可;
- 若页在TLB中不存在,要分两种情况讨论:若TLB还有空位,需要先在页表中查找该页,且valid为True,表明此页在内存中存在,再将该页插入TLB的头部;若TLB没有空位,需要丢弃尾部页,然后将新页插入TLB头部。
- Nachos默认TLB是数组,每次插入都需要移动元素,而移动元素需要较大的时间开销。
优化
- 为了减少移动元素的时间开销,可以在页表项TranslationEntry中添加成员变量lastVisitedTime,每当访问某页时,更新此成员变量为当前的totalTicks,每次需要替换页面时,需要遍历整个TLB,找出lastVisitedTime最小的表项,替换之。此方法和原始方法相比,每次更新页的访问时间时,只需要更新其lastVisitedTime即可,节约了移动元素的时间,但是增加了页表项的空间开销和查找最小的lastVisitedTime页的时间(需要遍历整个TLB,时间复杂度O(n))。考虑到Nachos的tlb很小,只有4,因此我在本次试验中将使用此方法。
- 用数组实现LRU需要多次移动元素,而使用链表则不需要考虑这个问题。若要使用链表,则需要使用双向链表(为了维护rear指针,后面会说明),需要在TranslationEntry 中添加next和prior指针,还需要改动machine.cc中对于tlb数组的初始化,将数组改为链表。还需要一个全局变量currTLBSize来记录当前TLB的大小,维护front和rear指针指向TLB的链头和链尾,每次从尾部移除元素时,rear都需要指向前一个元素,所以需要双向链表。此方法和添加lastVisitedTime的方法相比,增加了一个额外的空间开销(next/prior指针),并增加了currTLBSize/front/rear变量,空间复杂度为O(n)+O(1)~=O(n);另一方面,由于页面的访问时间在链表中是顺序排列的,最早访问的页就在链尾,查找最早访问页面的时间复杂度为O(1),可以大大减少查找最早访问页的时间,故此方法是综合性能最优的。(TOBEDONE)
FIFO算法
和LRU算法相比,FIFO算法实现比较简单。
- 维护一个FIFO队列,按照时间顺序将各数据(已分配页面)链接起来组成队列,并将置换指针指向队列的队首。
- 进行置换时,只需把置换指针所指的数据(页