全部学习汇总: GreyZhang/g_unix: some basic learning about unix operating system. (github.com)
说明:这一份笔记是我对xv6-book-rev11第二章节的翻译整理。翻译的主要方式就是机器翻译,我对其中阅读有一些障碍性或者有较大误导性的地方做了修正。这份笔记是为了能够让我更好理解这一部书而整理的,而本章节并不是从最开头准备的。
以下是部分翻译的正文:
Xv6包含了内核在每个进程的页表中运行所需的所有映射;这些映射都出现在KERNBASE
之上。完成了KERNBASE:KERNBASE+PHYSTOP to 0:PHYSTOP的区间映射。进行这种映射的一个原因是,内核可以使用自己的指令和数据。另一个原因是,内核有时需要能够写一个给定的物理内存页面,例如在创建页面表页面时;让每个物理页面出现在一个可预测的虚拟地址,这很方便。这种安排的一个缺陷是,xv6不能使用超过2gb的物理内存,因为地址空间的核心部分是2gb。因此,xv6要求停止小于2g,即使计算机有超过2g的物理内存。
一些使用内存映射I/O的设备会出现在从0xFE000000开始的物理地址上,因此xv6页表中包括对它们的直接映射。因此,系统停止容量必须小于2G-16MB(用于设备内存)。
Xv6没有在KERNBASE之上的pte中设置PTE_U标志,所以只有内核可以使用它们。
在系统调用和中断期间从用户代码切换到内核代码时,让每个进程的页表都包含针对用户内存和整个内核的映射是很方便的:这样的切换不需要页表切换。在大多数情况下,内核没有自己的页表;它几乎总是借用一些进程的页表。
为了查看,xv6确保每个进程只能使用它自己的内存。并且,每个进程都认为其内存具有从零开始的连续虚拟地址,而进程的物理内存可能是非连续的。xv6首先实现通过只在引用进程自身内存的虚拟地址的pte上设置PTE_U位。第二,通过分页的能力把连续的虚拟地址分配给任意可能的物理地址。
代码:创建一个地址空间
Main用kvmalloc(1840)来创建并切换到一个页面表,其中具有内核运行所需的KERNBASE以上的映射。大部分的工作都发生在setupkvm(1818)中。它首先分配一个页面内存来保存页面目录。然后调用mappage来安装内核所需的转换,转换关系这kmap(1809)数组中描述。这些转换包括内核的指令和数据、停止的物理内存(实际上是I/O设备的内存范围)。setupkvm不为用户内存安装任何映射;这将在以后发生。
mappages(1760)将一个虚拟地址范围的映射安装到一个相应的物理地址范围的页表中。它以页面间隔对范围内的每个虚拟地址分别执行此操作。对于每个要映射的虚拟地址,映射页调用walkpgdir来查找该地址的PTE的地址。然后,它初始化PTE以保存相关的物理页号、所需的权限(PTE_W和/或PTE_U),以及PTE_P以将PTE标记为有效(1772)。
walkpgdir(1735)在查找虚拟地址时模拟x86查找硬件的操作(参见图2-1)。walkpgdir使用虚拟地址的上10位来查找页面目录条目(1740)。如果页面目录条目不存在,则尚未分配所需的页表页面;如果设置了alloc参数,walkpgdir将分配它,并将其物理地址放在页面目录中。最后,它使用虚拟地址的下一个10位来在页表页(1753)中找到PTE的地址。
物理内存分配
内核必须在运行时为页表、进程用户内存、内核堆栈和管道缓冲区分配和释放物理内存。
Xv6运行时的存储分配采用内核结束地址到PHYSTOP区间的存储。它一次分配和释放整个4096字节的页面。它通过在页面本身中浏览一个链接列表来跟踪哪些页面是未使用的。分配包括从链接列表中删除一个页面;释放功能包括将已释放的页面添加到该链表中。
有一个引导问题:为了让分配器初始化空闲列表,必须映射所有的物理内存,但是使用这些映射创建一个页面表涉及到分配页表页面。xv6通过在进入内核期间使用单独的页面分配器来解决这个问题,该分配器在内核数据段结束之后分配内存。这个分配器不支持释放,并且受到pntrypgdir中的4 MB映射的限制,但这足以分配第一个内核页面表。
代码:物理内存分配器
分配器的数据结构是一个可用于分配的物理内存页面的空闲列表。每个未使用页面的列表元素都是一个结构体运行(3115)。分配器从哪里获取内存来保存该数据结构?它将每个未使用页面的运行结构存储在未使用页面本身中,因为那里没有存储任何其他内容。未使用列表由一个自旋锁(3119-3123)保护。列表和锁被包装在一个结构中,以明确锁保护结构中的字段。现在,忽略锁和要获取和释放的调用;第4章将详细检查锁定。
函数main调用kinit1和kinit2来初始化分配器(3131)。有两次调用的原因是,对于许多主调,一个不能使用超过4兆字节的锁或内存。对kinit1的调用设置了在前4兆字节内的无锁分配,而对kinit2的调用支持锁定,并安排更多的内存进行可分配。main应该确定有多少物理内存可用,但这在x86上是很困难的。相反,它假设机器有224兆字节(PHYSTOP)的物理内存,并使用内核末端和PHYSTOP之间的所有内存作为可用内存的初始池。kinit1和kinit2调用freerange,通过每页调用kfree将未使用列表添加到内存。PTE只能引用在4096字节边界上对齐的物理地址(是4096的倍数),所以freerange使用PGROUNDUP来确保它只释放对齐的物理地址。分配器开始时没有内存;这些对kfree的调用给了它一些内存来管理。
分配器通过高内存中映射的虚拟地址引用物理页面,而不是物理地址,这就是为什么kinit使用P2V(PHYSTOP)将(物理地址)转换为虚拟地址。分配器有时将地址作为整数,以便对它们进行算术(例如,在kinit中遍历所有页面),有时使用地址作为读写内存的指针(例如,操作存储在每个页面中的运行结构);这种地址的双重使用是分配器代码充满C类型转换的主要原因。另一个原因是释放和分配本质地改变了内存的类型。
函数kfree(3164)首先将被释放的内存中的每个字节设置为值1。这将导致代码在释放内存后使用内存(使用“悬垂引用”)来读取垃圾,而不是旧的有效内容;希望这能使这些代码能够更快地崩溃。然后kfree将v强制转换到一个指向结构运行的指针,记录接下来在r->中自由列表的旧开始,并设置自由列表等于r. kalloc删除并返回自由列表中的第一个元素。
地址空间的用户部分
图2-3显示了xv6中一个执行进程的用户内存的布局。每个用户进程都从地址0开始。地址空间的底部包含用户程序的文本、其数据及其堆栈。堆位于堆栈之上,以便在进程调用sbrk时堆可以扩展。请注意,text、数据和堆栈部分在进程的地址空间中是连续布局的,但是xv6可以自由地为这些部分使用非连续的物理页面。例如,当xv6扩展进程的堆时,它可以为新的虚拟页面使用任何未使用的物理页面,然后对页面表硬件进行编程,以将虚拟页面映射到已分配的物理页面。这种灵活性是使用分页硬件的一个主要优点。
堆栈是一个单个页面,显示exec创建的初始内容。包含命令行参数的字符串以及指向它们的指针数组位于堆栈的最顶部。就在这些值的下面,它们允许程序从main开始启动,就像函数调用main(argc,argv)刚刚启动一样。为了保护从堆栈页面上不断增长的堆栈,xv6在堆栈的正下方放置了一个保护页面。保护页没有被映射,因此,如果堆栈从堆栈页上运行,硬件将生成一个异常,因为它无法转换错误的地址。一个现实世界的操作系统可能会为堆栈分配更多的空间,以便它可以增长超过一个页面。
Code: sbrk
Sbrk是对进程收缩或增加其内存的系统调用。系统调用由函数growproc(2558)实现。如果n是正数,那么growproc将分配一个或多个物理页面,并将它们映射到进程的地址空间的顶部。如果n为负数,grogproc将从进程的地址空间解映射一个或多个页面,并释放相应的物理页面。若要进行这些更改,xv6将修改进程的页表。进程的页面表存储在内存中,因此内核可以用普通的赋值语句更新表,这就是alacuvm和分配uvm所做的。x86硬件将页表条目缓存到翻译查找备用缓冲区(TLB)中,当xv6更改页表时,它必须使缓存的条目无效。如果它没有无效缓存条目,然后在某个时候以后TLB可能使用一个旧的映射,指向一个物理页面,同时被分配给另一个进程,因此,一个进程可能能够在篡改其他进程的内存。Xv6通过重新加载保存当前页表地址的寄存器cr3,使陈旧的缓存条目无效。
Code: exec
Exec是创建用户部分地址空间的系统调用。它从存储在文件系统中的文件中初始化地址空间的用户部分。Exec(6610)使用namei(6623)打开命名的二进制路径,这将在第6章中进行了解释。然后,它读取ELF报头。Xv6应用程序以广泛使用的ELF格式描述,在elf.h中定义。一个ELF二进制文件由一个ELF头,结构体elfhdr(0905)组成,后面是一个程序部分头序列,结构体proghdr(0924)。每个程序描述了必须加载到内存中的应用程序的一部分;xv6程序只有一个程序部分头,但其他系统可能有单独的指令和数据部分。
第一步是快速检查该文件是否可能包含一个ELF二进制文件。ELF二进制文件以四字节的“魔法数字”“0x7F”、“E”、“L”、“F”或ELF_MAGIC(0902)开始。如果ELF头有正确的魔术数字,exec假设二进制是良好的。
Exec使用setupkvm(6637)分配一个没有用户映射的新页表,使用alacuvm(6651)为每个ELF段分配内存,并使用loaduvm(6655)将每个段加载到内存中。Aalecuvm检查请求的虚拟地址是否位于KERNBASE.下面。loaduvm(1903)使用walkpgdir来查找已分配的内存的物理地址,以写入ELF段的每一页,并准备从文件中读取。
/init的程序部分标头,用exec创建的第一个用户程序,如下所示:
程序部分头的filesz可能小于memsz,这表明它们之间的间隙应该用零来填充(对于C全局变量),而不是从文件中读取。对于/init,filesz是2240字节,memsz是2252字节,因此,分配uvm分配了足够的物理内存来容纳2252字节,但只从文件/init中读取2240字节。
现在,exec将分配并初始化用户堆栈。它只分配了一个堆栈页面。Exec一次将参数字符串复制到堆栈的顶部,并在用户堆栈中记录指向它们的指针。它在传递给main的argv列表的末尾放置一个空指针。用户堆栈中的前三个条目是假返回PC、argc和argv指针。
Exec在堆栈页面的正下方放置了一个不可访问的页面,因此尝试使用多个页面的程序将会出错。这个不可访问的页面还允许exec处理太大的参数;在这种情况下,exec用于将参数复制到堆栈的copyout(2118)函数将注意到目标页面不可访问,并将返回-1。
在准备新内存镜像的过程中,如果exec检测到类似于无效程序段的错误,它会跳转到标签bad,释放新镜像,并返回-1。Exec必须等待释放旧镜像,直到确定系统调用将成功:如果旧镜像消失,系统调用将无法返回-1。exec中唯一的错误情况发生在镜像的创建过程中。一旦镜像完成,exec就可以安装新的镜像(6701)并释放旧的镜像(6702)。最后,exec返回0。
Exec将ELF文件中的字节从ELF文件指定的地址加载到内存中。用户或进程可以将他们想要的任何地址放到ELF文件中。因此,exec是有风险的,因为ELF文件中的地址可能会意外地或有意地引用了内核。粗心的内核的后果可能导致从崩溃到对内核隔离机制的恶意颠覆(即安全漏洞)。xv6执行了许多检查,以避免这些风险。要理解这些检查的重要性,请考虑如果xv6没有检查是否(ph.vaddr + ph.memsz < ph.vaddr)可能会发生什么。这是一个检查的总和是否溢出了一个32位的整数。危险在于,用户可以用一个指向内核的ph.vaddr构造一个ELF二进制文件,并且ph.memsz足够大,使和溢出到0x1000。由于和很小,它将通过检查,如果(nessz>=KERNBASE))在分配uvm。随后对loaduvm的调用单独传递ph.vaddr,不添加ph.memsz,也不对内核库检查ph.vaddr,因此将从ELF二进制数据复制到内核中。一个用户程序可以利用它来运行具有内核特权的任意用户代码。
Real world
与大多数操作系统一样,xv6使用分页硬件进行内存保护和映射。大多数操作系统使用x86的64位分页硬件(它有三级转换)。64位地址空间允许比xv6更少的内存布局;例如,很容易删除xv6对物理内存的2gb限制。大多数操作系统对分页的使用比xv6要复杂得多;例如,xv6缺乏来自磁盘的需求分页、写入时复制分叉、共享内存、延迟分配的页和自动扩展堆栈。x86支持使用分段的地址转换(参见附录B),但是xv6只使用分段来实现每个cpu变量的常见技巧,例如在固定地址但在不同的cpu上有不同的值(参见seginit)。在非段架构上实现每个cpu(或每个线程)存储将专用于一个寄存器来保存指向每个cpu数据区域的指针,但是x86的通用寄存器很少,因此使用分割所需的额外努力是值得的。
Xv6将内核映射到每个用户进程的地址空间中,但将其设置为当处理器处于用户模式时地址空间的内核部分不可访问。这个设置很方便,因为在进程从用户空间切换到内核空间后,内核可以通过直接读取内存位置轻松地访问用户内存。但是,对于安全而言,最好为内核提供一个单独的页表,并在从用户模式进入内核时切换到该页表,以便内核和用户进程之间更加分离。例如,这种设计将有助于减轻由崩溃漏洞暴露的侧通道,并允许用户进程读取任意内核内存。
在内存大量内存的机器上,使用x86的4兆“超级页面”可能是有意义的。当物理内存很小时,小页面是有意义的,允许以精细粒度分配和页面交换出去到磁盘。例如,如果一个程序只使用8kb的内存,那么给它一个4mb的物理页面是一种浪费。更大的页面在具有大量内存的机器上是有意义的,并可能减少页面表操作的开销。Xv6在一个地方使用超级页面:初始页面表(1306)。数组初始化设置1024个PDE中的两个,在索引为零和512((KERNBASE>>PDXSHIFT),留下其他PDE为零。Xv6在这两个PDE中设置PTE_PS位,以将它们标记为超级页面。内核还通过在%cr4中设置CR_PSE位(页面大小扩展)来告诉分页硬件允许超级页面。
Xv6应该确定实际的RAM配置,而不是假设为224 MB。在x86上,至少有三种常见的算法:第一种是探测物理地址空间,寻找类似内存的区域,保留写入它们的值;第二种是从PC的非易失性RAM的已知16位位置读取内存数;第三种是在BIOS内存中寻找作为多处理器表的一部分的内存布局表。读取内存布局表很复杂。
内存分配很久以前是一个热门话题,基本问题是有效地使用有限的内存和为未知的未来请求做准备;参见Knuth。今天,人们更关心速度,而不是空间效率。此外,一个更复杂的内核可能会分配许多不同大小的小块,而不是(如在xv6中)只分配40个96字节的块;一个真正的内核应该既可以分配小的内存块也可以分配大的内存块。