xv6 is a re-implementation of Dennis Ritchie's and Ken Thompson's Unix Version 6 (v6). xv6 loosely follows the structure and style of v6, but is implemented for a modern RISC-V multiprocessor using ANSI C.
xv6本身已经是一个可以运行的简易的操作系统了. 6.S081这门课并不是从零开始空手造一个xv6操作系统, 而是针对操作系统的不同角度, 在每个lab里让我们根据已有的功能, 对xv6进行优化.
-
隔离
虚拟内存允许每个进程之间都有独立的虚拟地址空间,不同进程间的内存不会互相干扰。实现了进程间的内存隔离,防止一个进程的错误导致其他程序的崩溃。
-
简化 & 管理
虚拟地址空间允许程序使用连续的虚拟地址空间,无需关心实际的物理地址中存在的内存碎片问题,简化了内存分配和管理问题。
此外允许操作系统对物理内存分配进行优化,将不活跃的内存数据页转移到硬盘中,释放物理内存供其他程序使用。
-
扩大
虚拟内存可以将物理内存和磁盘上的存储空间组合起来使用,使每个进程看起来都有巨大的地址空间提供使用,允许CPU运行内存占用较大的程序。
-
文件操作
虚拟内存允许将文件映射到进程的地址空间中,可以轻松的读取和写入文件,减少繁琐的文件操作。
操作系统使用 缺页故障,实现了fork写时复制,内存懒分配和磁盘交换策略,为进程提供了许多虚假的虚拟内存。这些虚假的虚拟内存可能并没有实际对应的物理内存,也可能对应的物理内存被交换到了磁盘当中。在进程的视角来看,似乎有远超过物理内存的虚拟内存量。
能使用磁盘交换的前提是:具有缺页故障机制。
- 在用户模式,调用C函数库中的系统调用的封装,如
write
- 该封装中会将具体的系统调用号加载到a7中,然后调用
ecall
指令 ecall
指令会做三件事- 切换UMode到SMode
- 将用户pc保存到sepc
- 跳转到stvec指定的位置,也就是trampoline
- 在trampoline中(切换用户页表带内核页表中)—
uservec 所做的事情
- 将用户寄存器保存到进程的trapframe中
- 从trapframe中读取内核栈、内核页表、中断处理程序
usertrap
的位置,当前CPU核心id - 加载内核栈到sp、切换satp位内核页表,跳转到陷阱处理程序
- 陷阱处理程序将trapframe中的epc写成
ecall
的下一条指令,调用syscall
执行系统调用 syscall
调用具体的在内核中的系统调用代码,然后将返回值写到a0usertrap
执行usertrapret
,为返回用户空间做准备- 比如设置进程trapframe中的内核相关的信息,内核栈、内核页表、中断处理程序位置等
- 设置
sret
指令的控制寄存器,以在sret
执行时顺利恢复到用户模式 - 设置
sepc
为trapframe的epc - 使用函数指针,调用trampoline代码中的userret,并将trapframe作为a0、用户页表位置作为a1
- trampoline中的userret做的就很简单了,切换回用户页表,从trapframe恢复用户寄存器
- 执行sret返回到UMode
-
内存的申请与释放
管理系统内存池,实现为为进程分配和释放内存。
-
虚拟内存管理
允许进程使用虚拟内存,并将虚拟内存映射到物理内存,包含地址转换,分页机制,页表管理等。
-
共享内存
允许多个进程共享相同的内存空间,以便轻松的进程进程通信和数据共享。
-
内存监控与性能优化
负责跟踪系统内存使用情况,以及性能分析和优化内存分配策略。
用户态程序通过malloc申请内存,malloc维护一个内存池。当malloc无法提供合适内存时,将使用sbrk系统调用向操作系统申请新的堆内存,sbrk会在页表中做好标记,提供足量的虚拟内存空间,并通过kalloc申请足量的物理内存映射到新的虚拟内存中,以便返回这些虚拟内存供malloc使用。
实际的物理内存中的空闲页面通过链表连接管理,kalloc在该空闲链表中获取物理内存。
// Allocate one 4096-byte page of physical memory. 申请的是4096字节的实际物理地址
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
// 申请内存操作
r = kmem.freelist; // 指向空闲内存块
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
// 将分配的内存块填充标记
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r; // 返回空闲内存指针
}
内存链表的初始大小是0,并且它在第一次调用malloc
且没有可用的空闲块时被初始化。当需要更多内存时,malloc
会调用morecore
来扩展内存。
页表作用:
-
地址映射:将虚拟地址映射到物理地址上。每个虚拟页对应了一个物理页框。
-
内存隔离:页表允许多个应用程序同时运行,每个程序拥有自己的页表,使得内存空间互相隔离。
-
内存保护:页表允许操作系统为不同的地址设置权限和保护位,限制对内存的操作,提升系统的安全性。
-
优势
- 三级结构更加节省内存:由于PTE也需要在存放在内存中,一级页表可以表示2^27个PTE,如果在大范围的虚拟地址没有被映射的情况下,这对于内存时极大地浪费。而三级页表分为三段,每一部分仅有2^9个PTE表,极大节省内存占用。
- 可以更加灵活的管理物理地址空间:三级页表将地址划分为三个层次,每个层次按需分配。
-
缺点:
- 实际转换的增加了额外的内存开销:每次进行地址映射CPU都需要从内存中加载三个PTE才可以实现地址的转换。
- 增加了内存管理复杂性:需要编写更加复杂的地址转换和操作代码,增加维护难度。
通过mappages函数,需要给定页表、虚拟地址、映射内存大小和物理地址,使用walk函数返回对应的最底级页表项的地址,然后将需要映射的物理地址填入该页表项,设置相应的标志位。
有两个好处,一是起到了一定的隔离作用,另一个是进程在内核态下可以使用mmu解析用户态的虚拟地址,而不是使用软件模拟翻译地址,减小时间开销。具体实现:首先在进程初始化时,建立一个独立的内核页表,并在fork时完成用户态和新建立内核页表的映射。并在用户申请内存时(sbrk)也进行映射。
🔥 fork()底层执行流程,获取当前进程,然后调用allocproc() 创建新进程。最后使用 **uvmcopy()** 将父进程的页表复制给子进程,此外设置其他寄存器和返回值。在内核初始化数组保存每个页表的引用计数,kfree时如果当前没有进程进行引用了再释放掉。fork时,只新建页表进行内存的映射,并标记父子进程的内存不可写,标记cow位。当父子进程中有进程需要写时,进入写时复制流程,分配一个新的内存块对原有内容进行拷贝,并更新页表映射的关系。
在xv6中,fork() 系统调用将父进程的所有地址空间内存复制到子进程中。如果父进程内存占用较大,而子进程执行exec() 系统调用转移到其他程序的执行,将会造成巨大的内存浪费。
fork写时复制涉及在父子进程fork() 操作时,并不会立即复制物理地址,而是让父进程和子进程的虚拟地址映射到同一块物理页面。只有当进程需要对这一页执行“写操作时”,才会将只读的物理页面复制一份进行修改。
- 首先是修改 uvcopy() 函数,在fork() 时会调用此函数进行父进程和子进程的页表复制。修改后,不再为子进程分配实际物理内存,只是将父进程的物理地址映射到子进程的虚拟地址空间中,同时设置“只读”标记,以及cow标记。
- 然后在子进程中,因为父子进程的共享内存页面设置为只读,当任何进程需要写这个页面时,便会触发15号缺页异常中断,在此中断处理逻辑中,为该进程复制一个新的物理内存提供写操作。
- 具体的内存申请我们编写一个cowalloc() 函数,传入页表和虚拟地址空间,返回分配的物理地址。cowallow执行的操作就很简单,找到子进程的虚拟地址空间对应的PTE,使用kalloc申请物理内存,然后使用mappages进行虚拟地址和物理地址的映射,返回虚拟内存地址。
- 进程在释放内存时需要考虑一个问题,此物理地址是否被其他进程的虚拟地址空间映射。因此还需要添加引用计数器,在 kfree() 时加入判断,只有该页面的引用计数为0后,才会实际释放该页面。