本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统通过页表机制实现为每个进程提供私有地址空间和内存。页表确定了内存地址的含义、可以访问物理内存的哪些部分。页表机制使得xv6能够隔离不同进程的地址空间,并将它们多路复用到单块物理内存中。另外,xv6使用了一些简单的技巧:比如把不同地址空间的多段内存映射到同一段物理内存(trampoline page)或者在同一地址空间中多次映射同一段物理内存(用户部分的每一页都会映射到内核部分),以及通过一个没有映射的page保护内核和用户栈。本文的其余部分将解释RISC-V硬件提供的页表以及xv6如何使用它们。
xv6页表的逻辑视图
RISC-V指令(不论是用户还是内核)操作的都是虚拟地址,而机器的RAM(或物理内存)使用物理地址进行索引。RISC-V的页表硬件将每个虚拟地址映射到物理地址。
①xv6运行在Sv39 RISC-V上,这意味着64位的虚拟地址只使用了低39位,不使用高25位。
②在Sv39的配置中,RISC-V页表在逻辑上是一个由 2 27 = 134217728 \rm{2^{27}}=134217728 227=134217728个PTE(Page Table Entry)
组成的数组。每个PTE包含一个44位的PPN(physical page node)
和一些flag。
③页表硬件通过使用39位虚拟地址中的前27位索引到具体某一个页表中的某一个PTE,并生成一个56位物理地址,其前44位来自PTE中的PPN,后12位直接复制原始虚拟地址的低12位即可。
④图3.1将页表的逻辑视图显示为一个简单的PTE数组。页表使操作系统能够以 4096 ( 2 12 ) 4096(2^{12}) 4096(212)字节的对齐块为颗粒度来控制虚拟地址到物理地址的转换,这样的一个块称为一个page。
⑤在Sv39 RISC-V中,虚拟地址的前25位不用于映射。将来,RISC-V 可能会使用这些位来定义更多的映射级别。物理地址同样也有增长空间,在PTE格式中有空间让物理页号再增长10位。
xv6页表的工作流程
页表的工作流程:
如图3.2所示,页表以三级树的形式存储在物理内存中,实际的映射过程分三个步骤进行:
①第一级的Page Directory保存在satp寄存器中,通过satp定位到第一级的Page Directory,然后通过虚拟地址的高9位(L2)定位到第一级的PTE。
②通过第一级PTE中的PPN定位到第二级的Page Directory,然后通过虚拟地址的中间9位(L1)定位到第二级的PTE。
③通过第二级PTE中的PPN定位到第三级的Page Directory,然后通过虚拟地址的后9位(L0)定位到第三级的PTE。
最后,第三级PTE中的PPN加上虚拟地址的低12位就构成了最终的物理地址。
注:如果这3个PTE中的任何一个不存在,页表硬件就会引发page-fault exception
,并让内核来处理异常(详情见第4章)。
三级页表结构的优点:
三级页表的结构是一种对于内存空间的巨大节约。一方面,页表需要加载到内存中,如果一个程序的一级页表的PTE为空,那么相应的二级页表根本不会存在,而对于一个典型的程序而言,虚拟内存的大半部分均为未映射的。另一方面,只有一级页表才需要放在缓存中,二级页表只需按需调入调出,不必将整个页表也放入缓存中。
PTE的标志位:
每个PTE都包含标志位,这些标志位告诉页表硬件如何使用相关虚拟地址,这些标志和所有其他与页表硬件相关的结构体在(kernel/riscv.h)中定义。:
PTE_V
指示PTE是否存在。如果未设置,则对page的操作会导致异常。
PTE_R
控制是否允许指令读取到page。
PTE_W
控制是否允许指令写入到page。
PTE_X
控制CPU是否可以将page中的内容解释为指令并执行它们。
PTE_U
控制是否允许用户态指令访问页面。如果未设置,则PTE只能在kernel模式下使用。
satp寄存器:
为了告诉硬件使用页表,内核必须将页表的根物理地址(一级页表的物理地址)写入satp寄存器。每个CPU都有自己的satp,CPU使用自己的satp指向的页表来映射后续指令生成的所有地址。由于每个CPU都有自己的satp,因此不同的CPU可以运行不同的进程,每个进程都有自己的页表描述的私有地址空间。
关于术语的一些注释:
①物理内存是指DRAM中的存储单元。物理内存的每一个字节都有一个对应的地址,称为物理地址。
②指令仅使用虚拟地址,页表硬件将其转换为物理地址,然后发送到DRAM硬件进行读取或写入。
③与物理内存和虚拟地址不同,虚拟内存不是物理对象,而是指内核提供的用于管理物理内存和虚拟地址的抽象和机制的集合。
内核地址空间
xv6为每个进程维护一个页表,描述对应的用户地址空间,还维护了一个描述内核地址空间的页表。内核配置其地址空间的布局,来使自己能够在可预测的虚拟地址上访问对应的物理内存和各种硬件资源。图3.3显示了这种布局如何将内核虚拟地址映射到物理地址。文件(kernel/memlayout.h)声明了xv6内核内存布局的常量。
①QEMU模拟一台包含RAM(物理内存)的计算机,RAM从物理地址0x80000000
开始并持续到至少0x86400000(PHYSTOP)
。
②QEMU还模拟了I/O设备,例如磁盘接口。QEMU将设备接口作为内存映射控制寄存器暴露给软件,这些寄存器位于物理地址空间中0x80000000
以下。内核可以通过读/写这些特殊的物理地址与设备进行交互,此类读写与设备硬件通信而不是与RAM进行通信。第4章解释了xv6如何与设备交互。
③内核使用直接映射获取RAM和内存映射设备寄存器。也就是说,内核空间中虚拟地址和物理地址是相同的。例如,内核本身在虚拟地址空间和物理内存中都位于KERNBASE=0x80000000
。直接映射简化了读取或写入物理内存的内核代码。例如,当fork
为子进程分配用户内存时,分配器返回该内存的物理地址。fork
在将父进程的用户内存复制到进程时,直接将该地址作为虚拟地址。
④有一些内核虚拟地址不是直接映射的:
----trampoline page
:它被映射到虚拟地址空间的顶部。用户页表具有相同的映射。第4章讨论了trampoline page
的作用,但我们在这里看到了一个有趣的页表用例:物理page(保存trampoline page代码)在内核的虚拟地址空间中被映射两次,一次在虚拟地址空间的顶部,一次使用直接映射。
----kernel stack page
:每个进程都有自己的内核栈,它被映射到比较高的位置,这样xv6可以在它下面留下一个未映射的保护页。保护页的PTE无效(即PTE_V
未设置),因此如果内核溢出内核堆栈,很可能会导致异常,内核会崩溃。如果没有保护页,溢出的堆栈会覆盖其他内核内存,从而导致错误操作。因此,panic crash
是可取的。
⑤虽然内核通过高位内存映射使用其堆栈,但内核也可以通过直接映射地址访问它们。另一种设计可能只有直接映射,并使用直接映射地址处的堆栈。然而,在这种安排中,提供保护页将涉及取消映射虚拟地址,否则这些虚拟地址将引用物理内存,从而难以使用。
⑥内核将trampoline
和kernel text
的页面映射为具有PTE_R
和PTE_X
权限的页面。内核从这些page中读取并执行指令。内核使用权限PTE_R
和PTE_W
映射其他page,以便它可以读取和写入这些页面中的内存。保护页的映射无效。
创建地址空间
页表数据结构:
大多数用于操作地址空间和页表的xv6代码都在vm.c
(ker nel/vm.c:1)中。核心的数据结构是pagetable_t
,它实际上是一个指向RISC-V根页表page的指针。这些页表可以是内核页表,也可以是每个进程的页表。
通用的函数操作:
①核心功能是walk
,它为虚拟地址找到PTE。
②mappages
为新映射提供PTE,即在页表中建立一段虚拟内存到一段物理内存的映射。
③以kvm
开头的函数操作内核页表,以uvm
开头的函数操作用户页表,其他功能用于两者。
④copyout
和copyin
将数据复制到(或从这里复制数据出来)作为系统调用参数提供的用户虚拟地址。它们位于vm.c
中,因为它们需要显式转换这些地址才能找到相应的物理内存。
创建地址空间的流程
在启动序列的早期,main
调用kvminit
(kernel/vm.c:22)来创建内核的页表。此调用发生在xv6启用RISC-V的分页之前,因此地址直接引用物理内存。kvinit
首先分配一个物理内存page来保存根页表page。然后它调用kvmmap
来提供内核需要的映射,包括内核的指令和数据、物理内存以及实际上是设备的内存范围。
kvmmap
kvmmap
(kernel/vm.c:118)调用mappages
(kernel/vm.c:149),它将虚拟地址范围到相应物理地址范围的映射加入到页表中。它以page间隔为范围内的每个虚拟地址单独执行此操作。对于每个要映射的虚拟地址,mappages
调用walk
来查找该地址的PTE地址。然后它初始化PTE以保存相关的物理页号、所需的权限(PTE_W
、PTE_X
、PTE_R
)和PTE_V
以将PTE标记为有效(kernel/vm.c:161)。
walk
walk
(kernel/vm.c:72)实现类似于RISC-V分页硬件的功能,即在PTE中查找虚拟地址对应的物理地址(见图3.2)。walk
每次将3级页表下移动9位。它使用每一级的9位虚拟地址来查找下一级页表或最后一页(kernel/vm.c:78)的PTE。如果PTE无效,则说明需要的页面尚未分配。如果设置了alloc
参数,walk
将分配一个新的页表page并将其物理地址放入PTE。它返回树中最低层的PTE地址(kernel/vm.c:88)。
注:上面的代码依赖于直接映射到内核虚拟地址空间的物理内存。例如,当walk
从页表的各个级别向下移动时,它会从PTE(kernel/vm.c:80)中提取下一级页表的(物理)地址,然后将该地址用作虚拟地址获取下一层的PTE(kernel/vm.c:78)。
kvminithart
main
调用kvminithart
(kernel/vm.c:53)来安装内核页表。它将根页表page的物理地址写入寄存器satp
。此后,CPU将使用内核页表转换地址。由于内核使用直接映射,下一条指令的当前虚拟地址将映射到正确的物理内存地址。
procinit
从main
调用的procinit
(kernel/proc.c:26)为每个进程分配一个内核堆栈。它将每个堆栈映射到由KSTACK
生成的虚拟地址,从而为无效的堆栈保护页面留出空间。kvmmap
将映射PTE添加到内核页表中,并且对kvminithart
的调用将内核页表重新加载到satp
中,以便硬件知道新的PTE。
TLB
每个RISC-V CPU都将页表条目缓存在TLB(Translation Look Aside Buffer)中,当xv6更改页表时,它必须告诉CPU使相应的缓存TLB条目无效。如果没有,那么稍后TLB可能会使用旧的缓存映射,指向同时已分配给另一个进程的物理page。结果,一个进程可能占用一些其他进程的内存。RISC-V 有一条指令sfence.vma
刷新当前CPU的TLB。xv6在重新加载satp寄存器后在kvminithart
中执行sfence.vma
,在返回用户空间之前在切换到用户页表的trampoline
代码中执行sfence.vma
(kernel/trampoline.S:79)。
物理内存分配
①在运行时,内核必须为页表、用户内存、内核堆栈和管道缓冲区分配和释放物理内存。
②xv6使用内核末端和PHYSTOP
之间的物理内存进行运行时分配。它一次分配和释放的基本单位是4096字节的page。
③xv6还会通过维护一个物理页组成的链表来寻找空闲页。分配内存需要将page移出该链表,释放内存需要将page添加到该链表中。
物理内存分配器
分配器位于
kalloc.c
(kernel/kalloc.c:1)中。分配器的数据结构是可用于分配的物理内存page的空闲链表。每个空闲page的链表元素都是一个struct run
(kernel/kalloc.c:17)。
问: 分配器从哪里获得内存来保存该数据结构?
答: 它将每个空闲page的run
结构存储在空闲page本身中,因为那里没有存储任何其他内容。空闲列表受自旋锁保护(kernel/kalloc.c:21-24)。列表和锁被封装在一个结构中,以明确锁在结构体中保护的字段。现在,忽略锁以及对acquire
和release
的调用,第 6 章将详细研究锁。
kinit
main
函数调用kinit
(kernel/kalloc.c:27)来初始化分配器。kinit
初始化空闲列表以保存内核末尾到PHYSTOP
之间的每一个page。xv6应该通过解析硬件提供的配置信息来确定有多少物理内存可用。相反,xv6假定机器有128MB的RAM。kinit
调用freerange
将内存添加到空闲列表中,在freerange
中每个page都会调用kfree
。PTE只能引用在4096字节边界上对齐的物理地址(是4096的倍数,因此freerange
使用PGROUNDUP
来确保它只释放对齐的物理地址。分配器开始时没有内存可用,正是对kfree
的调用将可用内存交给了分配器来管理。
分配器代码中的类型转换:
分配器有时将地址视为整数以便对其执行算术运算(例如,遍历freeranfe
中的所有page),有时将地址用作读写内存的指针(例如,操作存储在每个页面中的run
结构体)。这种对地址的双重使用是分配器代码充满C类型转换的主要原因。另一个原因是释放和分配内存固有地改变了内存的类型。
kfree
函数kfree
(kernel/kalloc.c:47)首先将内存中被释放的每个字节设置为1。这使得访问已被释放内存读取到的是垃圾数据而不是旧的有效内容。这样做能让这种错误的代码尽早崩溃。然后kfree
将page添加到空闲列表:它将pa
转换为指向struct run
的指针,在r->next
中记录空闲列表的旧标通,并将空闲列表设置为等于r
。kalloc
删除并返回空闲列表中的第一个元素。
进程地址空间
进程寻址范围
每个进程都有一个单独的页表,当xv6在进程之间切换时,也会改变页表。如图2.3所示,进程的用户内存从虚拟地址0开始,可以增长到MAXVA
(kernel/riscv.h:348),原则上允许进程寻址256GB的内存。
进程申请用户内存
当一个进程向xv6请求更多的用户内存时,xv6首先使用kalloc
来分配物理page。然后它将PTE添加到进程的页表中,指向新的物理page。xv6在这些PTE中设置PTE_W
、PTE_X
、PTE_R
、PTE_U
和PTE_V
标志。大多数进程不使用整个用户地址空间,xv6将未使用的PTE中的PTE_V
清除。
页表使用案例:
我们在这里看到了一些使用页表的好例子。首先,不同进程的页表将用户地址转换为物理内存不同的page,这样每个进程都有私有的用户内存。其次,每个进程将其内存视为具从0开始的连续虚拟地址,而进程的物理内存可以是不连续的。最后,内核在用户地址空间的顶部映射一个带有trampoline code的页面,这样所有地址空间都可以看到一个单独的物理内存页面。
xv6进程的用户内存布局:
图3.4更详细地显示了xv6中执行进程的用户内存布局。栈是一个单独的page,显示的是exec
创建后的初始内容。包含命令行参数的字符串以及指向它们的指针数组位于堆栈的最顶部。再往下是允许程序从main
开始的值,(即main
的地址、argc
、argv
),这些值产生的效果就像刚刚调用了main(argc, argv)
一样。
guard page
为了检测用户栈溢出分配的栈内存,xv6在栈正下方放置了一个无效的保护page。如果用户栈溢出并且进程试图使用栈下方的地址,由于映射无效,硬件将生成页面错误异常。实际上,操作系统可能会在用户栈溢出时自动为其分配更多内存。
sbrk
①
sbrk
是进程缩小或增长其内存的系统调用。该系统调用由函数growproc
(kernel/proc.c:239)实现。根据n
是正数还是负数,growproc
调用uvmalloc
或uvmdealloc
。
----uvmalloc
(kernel/vm.c:229)使用kalloc
分配物理内存,并使用mappages
将PTE添加到用户页表中。
----uvmdealloc
调用uvmunmap
(kernel/vm.c:174),它使用walk
来查找对应的PTE,并使用kfree
来释放PTE引用的物理内存。
②xv6使用进程的页表不仅告诉硬件如何映射用户虚拟地址,是明晰哪一个物理页面已经被分配给该进程的唯一记录。这就是释放用户内存(在uvmunmap
中)需要检查用户页表的原因。
exec
exec
exec
是创建地址空间的用户部分的系统调用。它使用一个存储在文件系统中的文件初始化地址空间的用户部分。exec
(kernel/exec.c:13) 使用namei
(kernel/exec.c:26)打开二进制path
,这在第8章中有解释。然后,它读取ELF头。
ELF文件格式
xv6应用程序以广泛使用的ELF格式进行描述,定义在(kernel/elf.h)中。一个ELF二进制文件包含一个ELF头struct elfhdr
(kernel/elf.h:6)、一系列程序节的头struct proghdr
(kernel/elf.h:25)。每个proghdr
描述了必须加载到内存中的应用程序节。xv6的程序只有一个程序节的头,但其他系统对于指令和数据部分可能有单独的头。
工作流程:
①第一步是快速检查该文件是否可能包含ELF二进制文件。ELF二进制文件以4字节的"magic number”:0x7F
、“E”
、“L”
、“F”
或ELF_MAGIC
(kernel/elf.h:3)开头。如果ELF头有正确的magic number,则exec假定二进制文件格式正确。
②exec
使用proc_pagetable
(kernel/exec.c:38)分配一个没有用户映射的新页表,使用uvmalloc
(kernel/exec.c:52)为每个ELF段分配内存,并使用loadseg
(kernel/exec.c:10)将每个段加载进内存。loadseg
使用walkaddr
查找已分配内存的物理地址,在该物理地址处写入ELF段的每个page,并使用readi
从文件中读取。
程序节标题
/init
(使用exec
创建的第一个用户程序)的程序节标题如下所示:
程序节头的filesz
可能小于memsz
,表明它们之间的间隙应该用0填充(对于C全局变量)而不是从文件中读取。对于/init
,filesz
是2112字节,memsz
是2136字节,因此uvmalloc
分配了足够的物理内存来容纳2136字节,但从文件/init
中仅读取2112字节。
③现在exec
分配并初始化用户栈。它只分配一个栈页。exec
一次将参数中的一个字符串复制到栈顶,并将指向它们的指针记录在ustack
中。它在传递给main
的argv
列表的末尾放置一个空指针。ustack
中的前三个条目是fake return program counter、argc
和argv
指针。
④exec
在堆栈页面的下方放置一个不可访问的页面,因此尝试使用多个页面的程序会出错。这个不可访问的页面还允许exec处理过大的参数,在这种情况下,exec
用于将参数复制到栈的copyout
(kernel/vm.c:355)函数将注意到目标页面不可访问,并将返回-1。
⑤在准备新的内存图像的过程中,如果exec
检测到像无效程序段这样的错误,它会跳转到标签bad
,释放新的图像,并返回-1。exec
必须等待系统调用将成功后再释放旧图像:如果旧图像消失,系统调用无法返回-1。exec
中唯一的错误情况发生在创建图像期间。图像完成后,exec
可以提交到新页表(kernel/exec.c:113)并释放旧页表(kernel/exec.c:117)。
exec的风险:
----exec
将ELF文件中的字节加载到ELF文件指定地址的内存中。用户或进程可以将它们想要的任何地址放入ELF文件中。因此exec
是有风险的,因为ELF文件中的地址可能会意外或故意引用内核。对一个设计不严谨的内核来说,后果可能是一次崩溃,甚至是内核的隔离机制被恶意破坏(即安全漏洞)。xv6执行了许多检查来避免这些风险。例如,if(ph.vaddr + ph.memsz < ph.vaddr)
检查总和是否溢出64位整数。危险在于用户可能会构造一个ELF二进制文件,其中的ph.vaddr
指向用户选择的地址,并且ph.memsz
足够大以至于总和溢出到0x1000,这看起来像是一个有效值。在旧版本的xv6中,用户地址空间也包含内核(但在用户模式下不可读写),用户可以选择与内核内存对应的地址,从而将数据从ELF二进制文件中复制到内核中。在xv6的RISC-V版本中,这不会发生,因为内核有自己的单独页表。loadeg
加载到进程的页表中,而不是内核的页表中。
----内核开发人员很容易忽略关键检查,而现实世界的内核长期以来一直缺少检查,用户程序可以利用这些缺陷来获取内核权限。xv6很可能无法完整地验证提供给内核的用户级数据,恶意用户程序可能会利用这些数据来绕过xv6的隔离。
与大多数操作系统一样,xv6使用页表硬件进行内存保护和映射。大多数操作系统通过结合分页和页面错误异常来使用比xv6更复杂的分页,我们将在第4章讨论。
xv6的内核使用虚拟地址和物理地址之间的直接映射来进行简化,并假设在地址0x8000000处存在物理RAM(内核期望加载该地址)。这适用于QEMU,但在真正的硬件上它被证明是一个坏主意。真正的硬件将RAM和设备放置在不可预测的物理地址上,因此(例如)在xv6期望能够存储内核的0x8000000处可能没有RAM。更严格的内核设计利用页表将任意硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V支持物理地址级别的保护,但xv6不使用该功能。
在具有大量内存的机器上,使用RISC V对“super page”的支持可能是有意义的。当物理内存较小时,小页面是有意义的,这样可以以细粒度向磁盘分配和输出页面。例如,如果一个程序只使用8KB的内存,那么给它一个完整的4M字节的物理内存超级页面是一种浪费。较大的页面在具有大容量RAM的机器上是有意义的,并且可以减少页表操作的开销。
xv6内核缺少一个类似malloc
可以为小对象提供内存的分配器,这使内核无法使用需要动态分配的复杂数据结构。
内存分配是一个长期的热门话题,基本问题是有效利用有限的内存和为将来未知的请求做准备。今天,人们更关心速度而不是空间效率。此外,更精细的内核可能会分配许多不同大小的小块,而不是(如在xv6中)只有4096字节块。真正的内核分配器需要处理小分配和大分配。