本文为MIT 6.S081课程第三章教材内容翻译加整理。
本课程前置知识主要涉及:
页表是操作系统为每个进程提供私有地址空间和内存的机制。页表决定了内存地址的含义,以及物理内存的哪些部分可以访问。它们允许xv6隔离不同进程的地址空间,并将它们复用到单个物理内存上。
页表还提供了一层抽象(a level of indirection),这允许xv6执行一些特殊操作:
本章的其余部分介绍了RISC-V硬件提供的页表以及xv6如何使用它们。
提醒一下,RISC-V指令(用户和内核指令)使用的是虚拟地址,而机器的RAM或物理内存是由物理地址索引的。RISC-V页表硬件通过将每个虚拟地址映射到物理地址来为这两种地址建立联系。
XV6基于Sv39 RISC-V运行,这意味着它只使用64位虚拟地址的低39位;而高25位不使用。
在这种Sv39配置中,RISC-V页表在逻辑上是一个由
个页表条目(Page Table Entries/PTE)组成的数组,每个PTE包含一个44位的物理页码(Physical Page Number/PPN)和一些标志。
分页硬件通过使用虚拟地址39位中的前27位索引页表,以找到该虚拟地址对应的一个PTE,然后生成一个56位的物理地址,其前44位来自PTE中的PPN,其后12位来自原始虚拟地址。
图3.1显示了这个过程,页表的逻辑视图是一个简单的PTE数组(参见图3.2进行更详细的了解)。页表使操作系统能够以 4096 (
) 字节的对齐块的粒度控制虚拟地址到物理地址的转换,这样的块称为页(page)。
在Sv39 RISC-V中,虚拟地址的前25位不用于转换;将来RISC-V可能会使用那些位来定义更多级别的转换。另外物理地址也是有增长空间的: PTE格式中有空间让物理地址长度再增长10个比特位。RISC-V 的设计者根据技术预测选择了这些数字。
字节是 512 GB,这应该足够让应用程序运行在 RISC-V 计算机上。
的物理内存空间在不久的将来足以容纳可能的 I/O 设备和 DRAM 芯片。 如果需要更多,RISC-V 设计人员定义了具有 48 位虚拟地址的 Sv48。
如图3.2所示,实际的转换分三个步骤进行。页表以三级的树型结构存储在物理内存中。该树的根是一个4096字节的页表页,其中包含512个PTE,每个PTE中包含该树下一级页表页的物理地址。这些页中的每一个PTE都包含该树最后一级的512个PTE(也就是说每个PTE占8个字节,正如图3.2最下面所描绘的)。分页硬件使用27位中的前9位在根页表页面中选择PTE,中间9位在树的下一级页表页面中选择PTE,最后9位选择最终的PTE。
如果转换地址所需的三个PTE中的任何一个不存在,页式硬件就会引发页面故障异常(page-fault exception),并让内核来处理该异常(参见第4章)。
与图 3.1 的单级设计相比,图 3.2 的三级结构使用了一种更节省内存的方式来记录 PTE。在大范围的虚拟地址没有被映射的常见情况下,三级结构可以忽略整个页面目录。举个例子,如果一个应用程序只使用了一个页面,那么顶级页面目录将只使用条目0,条目 1 到 511 都将被忽略,因此内核不必为这511个条目所对应的中间页面目录分配页面,也就更不必为这 511 个中间页目录分配底层页目录的页。 所以,在这个例子中,三级设计仅使用了三个页面,共占用
个字节。
因为 CPU 在执行转换时会在硬件中遍历三级结构,所以缺点是 CPU 必须从内存中加载三个 PTE 以将虚拟地址转换为物理地址。为了减少从物理内存加载 PTE 的开销,RISC-V CPU 将页表条目缓存在 Translation Look-aside Buffer (TLB) 中。
TLB发生在哪一步,是在地址翻译之前还是之后?
3级的page table是由操作系统实现的还是由硬件自己实现的?
硬件会完成3级 page table的查找,那为什么我们要在XV6中有一个walk函数来完成同样的工作?
每个PTE包含标志位,这些标志位告诉分页硬件允许如何使用关联的虚拟地址。
PTE_V
指示PTE是否存在:如果它没有被设置,对页面的引用会导致异常(即不允许)。当一个PTE是无效的,硬件会返回一个page fault,对于这个page fault,操作系统可以更新 page table并再次尝试指令。
PTE_R
控制是否允许指令读取到页面。PTE_W
控制是否允许指令写入到页面。PTE_X
控制CPU是否可以将页面内容解释为指令并执行它们。PTE_U
控制用户模式下的指令是否被允许访问页面;PTE_U
,PTE只能在管理模式下使用。图3.2显示了它是如何工作的。标志和所有其他与页面硬件相关的结构在(*kernel/riscv.h*)中定义。
为了告诉硬件使用页表,内核必须将根页表页的物理地址写入到satp
寄存器中(satp
的作用是存放根页表页在物理内存中的地址)。每个CPU都有自己的satp
,一个CPU将使用自己的satp
指向的页表转换后续指令生成的所有地址。每个CPU都有自己的satp
,因此不同的CPU就可以运行不同的进程,每个进程都有自己的页表描述的私有地址空间。
通常,内核将所有物理内存映射到其页表中,以便它可以使用加载/存储指令读取和写入物理内存中的任何位置。 由于页目录位于物理内存中,内核可以通过使用标准存储指令写入 PTE 的虚拟地址来对页目录中的 PTE 内容进行编程。
关于术语的一些注意事项。物理内存是指DRAM中的存储单元。物理内存以一个字节为单位划为地址,称为物理地址。指令只使用虚拟地址,分页硬件将其转换为物理地址,然后将其发送到DRAM硬件来进行读写。与物理内存和虚拟地址不同,虚拟内存不是物理对象,而是指内核提供的管理物理内存和虚拟地址的抽象和机制的集合。
PPN中存放的都是物理地址:
stap寄存器存放的也是物理地址:
Xv6为每个进程维护一个页表,用以描述每个进程的用户地址空间,外加一个单独描述内核地址空间的页表。内核配置其地址空间的布局,以允许自己以可预测的虚拟地址访问物理内存和各种硬件资源。图3.3显示了这种布局如何将内核虚拟地址映射到物理地址。文件(kernel/memlayout.h) 声明了xv6内核内存布局的常量。
上图就是内核中地址的对应关系,左边是内核的虚拟地址空间,右边上半部分是物理内存或者说是DRAM,右边下半部分是I/O设备。
QEMU模拟了一台计算机,它包括从物理地址0x80000000
开始并至少到0x86400000
结束的RAM(物理内存),xv6称结束地址为PHYSTOP
。QEMU模拟还包括I/O设备,如磁盘接口。QEMU将设备接口作为内存映射控制寄存器暴露给软件,这些寄存器位于物理地址空间0x80000000
以下。内核可以通过读取/写入这些特殊的物理地址与设备交互;这种读取和写入与设备硬件而不是RAM通信。第4章解释了xv6如何与设备进行交互。
地址0x1000是boot ROM的物理地址,当你对主板上电,主板做的第一件事情就是运行存储在boot ROM中的代码,当boot完成之后,会跳转到地址0x80000000,操作系统需要确保那个地址有一些数据能够接着启动操作系统。
这里还有一些其他的I/O设备:
地址0x02000000对应CLINT,当你向这个地址执行读写指令,你是向实现了CLINT的芯片执行读写。这里你可以认为你直接在与设备交互,而不是读写物理内存。
物理地址总共有2^56那么多,但是你不用在主板上接入那么多的内存。所以不论主板上有多少DRAM芯片,总是会有一部分物理地址没有被用到。实际上在XV6中,我们限制了内存的大小是128MB。
内核使用“直接映射”获取内存和内存映射设备寄存器;也就是说,将资源映射到等于物理地址的虚拟地址。
KERNBASE=0x80000000
。fork
为子进程分配用户内存时,分配器返回该内存的物理地址;fork
在将父进程的用户内存复制到子进程时直接将该地址用作虚拟地址。有几个内核虚拟地址不是直接映射:
"跳板页"通常用于实现一些特殊的操作或跳转,例如在用户态和内核态之间进行切换时。
PTE_V
没有设置),所以如果内核溢出内核栈就会引发一个异常,内核触发panic
。如果没有保护页,栈溢出将会覆盖其他内核内存,引发错误操作。恐慌崩溃(panic crash)是更可取的方案。*(注:Guard page不会浪费物理内存,它只是占据了虚拟地址空间的一段靠后的地址,但并不映射到物理地址空间。)*
虽然内核通过高地址内存映射使用内核栈,但是它们也可以通过直接映射的地址进入内核。另一种设计可能只有直接映射,并在直接映射的地址使用栈。然而,在这种安排中,提供保护页将涉及取消映射虚拟地址,否则虚拟地址将引用物理内存,这将很难使用。
PTE_R
和PTE_X
下映射蹦床页面和内核文本页面。内核从这些页面读取和执行指令。PTE_R
和PTE_W
下映射其他页面,这样它就可以读写那些页面中的内存。对于保护页面的映射是无效的。上面这段话大家可能没太读懂,下面我用人话来解释一下:
(注,所以,kernel text用来存代码,代码可以读,可以运行,但是不能篡改,kernel data用来存数据,数据可以读写,但是不能通过数据伪装代码在kernel中运行)
内核必须在运行时为页表、用户内存、内核栈和管道缓冲区分配和释放物理内存。xv6使用内核末尾到PHYSTOP
之间的物理内存进行运行时分配。
它一次分配和释放整个4096字节的页面。它使用链表的数据结构将空闲页面记录下来。分配时需要从链表中删除页面;释放时需要将释放的页面添加到链表中。
分配器(allocator)位于*kalloc.c*(*kernel/kalloc.c*:1)中。
struct run
(*kernel/kalloc.c*:17)。run
结构存储在空闲页本身,因为在那里没有存储其他东西。acquire
和release
的调用;第6章将详细查看有关锁的细节。Tip
上一节中我们看了boot的流程,我们跟到了main函数。main函数中调用的一个函数是kvminit,这个函数会设置好kernel的地址空间。
在start函数中将stap寄存器设置为0,用以禁止分页机制,当然启动时stap寄存器的值默认为0,所以不设置的情况下,分页机制默认也是处于禁止状态下。
但是在设置内核物理空间前,我们需要先对物理内存分配器进行初始化,这个工作由kinit函数完成。
main
函数调用kinit
(*kernel/kalloc.c*:27)来初始化分配器。
kinit
初始化空闲列表以保存从内核结束到PHYSTOP
之间的每一页。kinit
调用freerange
将内存添加到空闲列表中,在freerange
中每页都会调用kfree
。freerange
使用PGROUNDUP
来确保它只释放对齐的物理地址。kfree
的调用给了它一些管理空间。在继续深入freerange调用链之前,我们先来看一下xv6中是如何使用空闲链表法来对物理内存进行管理的:
分配器有时将地址视为整数,以便对其执行算术运算(例如,在freerange
中遍历所有页面),有时将地址用作读写内存的指针(例如,操纵存储在每个页面中的run
结构);
函数kfree
(*kernel/kalloc.c*:47)首先将内存中的每一个字节设置为1。这将导致使用释放后的内存的代码(使用“悬空引用”)读取到垃圾信息而不是旧的有效内容,从而希望这样的代码更快崩溃。然后kfree
将页面前置(头插法)到空闲列表中:
pa
转换为一个指向struct run
的指针r
,在r->next
中记录空闲列表的旧开始,并将空闲列表设置为等于r
。kalloc
删除并返回空闲列表中的第一个元素。确保分配的内存块中的所有字节都被填充为相同的垃圾值,以避免可能出现的敏感数据泄露或信息泄漏。垃圾数据填充可以增加安全性,防止未初始化的内存被访问,或者在使用内存之前,提前发现内存中的错误。
大多数用于操作地址空间和页表的xv6代码都写在 *vm.c* (kernel/vm.c:1) 中。其核心数据结构是pagetable_t
,它实际上是指向RISC-V根页表页的指针;
一个pagetable_t
可以是内核页表,也可以是一个进程页表。
最核心的函数是walk
和mappages
,前者为虚拟地址找到PTE,后者为新映射装载PTE。
kvm
开头的函数操作内核页表;uvm
开头的函数操作用户页表;copyout
和copyin
复制数据到用户虚拟地址或从用户虚拟地址复制数据,这些虚拟地址作为系统调用参数提供; 由于它们需要显式地翻译这些地址,以便找到相应的物理内存,故将它们写在vm.c中。在启动序列的前期,main
调用 kvminit
(*kernel/vm.c*:54) 以使用 kvmmake
(*kernel/vm.c*:20) 创建内核的页表。
kvmmake
首先分配一个物理内存页来保存根页表页。kvmmap
来装载内核需要的转换。PHYSTOP
,并包括实际上是设备的内存。Proc_mapstacks
(*kernel/proc.c*:33) 为每个进程分配一个内核堆栈。它调用 kvmmap 将每个堆栈映射到由 KSTACK 生成的虚拟地址,从而为无效的堆栈保护页面留出空间。kvminit的代码如下所示:
关于内核代码段和数据端映射图解说明:
kvmmap
(*kernel/vm.c*:127)调用mappages
(*kernel/vm.c*:138),mappages
将范围虚拟地址到同等范围物理地址的映射装载到一个页表中。它以页面大小为间隔,为范围内的每个虚拟地址单独执行此操作。对于要映射的每个虚拟地址,mappages
调用walk
来查找该地址的PTE地址。然后,它初始化PTE以保存相关的物理页号、所需权限(PTE_W
、PTE_X
和/或PTE_R
)以及用于标记PTE有效的PTE_V
(*kernel/vm.c*:153)。
kalloc函数和meset函数都在上面介绍过了,下面我们来看看kvmmap函数的具体实现:
什么是向下对齐,如何完成对齐操作的,具体逻辑如下所示:
关于mappages和下面要讲的walk函数汇总中用到的相关宏定义具体图例说明如下:
在查找PTE中的虚拟地址(参见图3.2)时,walk
(*kernel/vm.c*:72)模仿RISC-V分页硬件。walk
一次从3级页表中获取9个比特位。它使用上一级的9位虚拟地址来查找下一级页表或最终页面的PTE (*kernel/vm.c*:78)。如果PTE无效,则所需的页面还没有分配;如果设置了alloc
参数,walk
就会分配一个新的页表页面,并将其物理地址放在PTE中。它返回树中最低一级的PTE地址(*kernel/vm.c*:88)。
上面的代码依赖于直接映射到内核虚拟地址空间中的物理内存。
walk
降低页表的级别时,它从PTE (*kernel/vm.c*:80)中提取下一级页表的(物理)地址,然后使用该地址作为虚拟地址来获取下一级的PTE (*kernel/vm.c*:78)。walk函数负责通过软件模拟遍历页表的过程,具体代码如下:
walk流程:
一句话: 函数从level2走到level1然后到level0,如果参数alloc不为0,且某一个level的page table不存在,这个函数会创建一个临时的page table,将内容初始化为0,并继续运行。所以最后总是返回的是最低一级的page directory的PTE。
如果参数alloc没有设置,那么在第一个PTE对应的下一级page table不存在时就会返回。
PPN中保存的是物理页号
alloc参数体现出来的就是懒加载思想,上面代码调用过程中传入的alloc值为1,会在遍历到的pte还未建立映射关系时,再申请下一级页表的物理页面,即: 用到时再加载的思想。
我们可以查看一个文件叫做memlayout.h
,它将上文中的文档翻译成了一堆常量。在这个文件里面可以看到,UART0对应了地址0x10000000(注,上文中的文档是真正SiFive RISC-V的文档,而下图是QEMU的地址,所以上文中的文档地址与这里的不符)。
所以,通过kvmmap可以将物理地址映射到相同的虚拟地址(注,因为kvmmap的前两个参数一致)。
main
调用kvminithart
(*kernel/vm.c*:53)来安装内核页表。它将根页表页的物理地址写入寄存器satp
。之后,CPU将使用内核页表转换地址。由于内核使用等价映射,下一条指令的当前虚拟地址将映射到正确的物理内存地址。
这里先解释一下MAKE_SATP宏定义的含义:
这个函数首先设置了SATP寄存器,kernel_pagetable变量来自于kvminit第一行。所以这里实际上是内核告诉MMU来使用刚刚设置好的page table。当这里这条指令执行之后,下一个指令的地址会发生什么?
在这条指令之前,还不存在可用的page table,所以也就不存在地址翻译。执行完这条指令之后,程序计数器(Program Counter)增加了4。而之后的下一条指令被执行时,程序计数器会被内存中的page table翻译。
所以这条指令的执行时刻是一个非常重要的时刻。因为整个地址翻译从这条指令之后开始生效,之后的每一个使用的内存地址都可能对应到与之不同的物理内存地址。因为在这条指令之前,我们使用的都是物理内存地址,这条指令之后page table开始生效,所有的内存地址都变成了另一个含义,也就是虚拟内存地址。
这里能正常工作的原因是值得注意的。因为前一条指令还是在物理内存中,而后一条指令已经在虚拟内存中了。比如,下一条指令地址是0x80001110就是一个虚拟内存地址。
为什么这里能正常工作呢?
在等价映射的情况下,由于内核代码段的虚拟地址和物理地址是一致的,所以开启分页的下一条指令的虚拟地址经过翻译后,能够正确定位到对应的物理地址上存储的那条指令,所以可以正常执行。
如果是非等价映射情况,可能需要提前计算出虚拟地址和物理地址空间中代码段的间隔,然后开启分页后,虚拟地址减去或者加上固定间隔后,才能得到正确的物理地址,然后才能获取到对应物理地址上的正确数据,或者目标指令。
对于walk函数,在写完SATP寄存器之后,代码是通过page table将虚拟地址翻译成了物理地址,但是这个时候SATP已经被设置了,得到的物理地址不会被认为是虚拟地址吗?
管理虚拟内存的一个难点是,一旦执行了类似于SATP这样的指令,你相当于将一个page table加载到了SATP寄存器,你的世界完全改变了。现在每一个地址都会被你设置好的page table所翻译。那么假设你的page table设置错误了,会发生什么呢?
每一个进程的SATP寄存器存在哪?
为什么通过3级page table会比一个超大的page table更好呢?
main
中调用的procinit
(*kernel/proc.c*:26)为每个进程分配一个内核栈。它将每个栈映射到KSTACK
生成的虚拟地址,这为无效的栈保护页面留下了空间。kvmmap
将映射的PTE添加到内核页表中,对kvminithart
的调用将内核页表重新加载到satp
中,以便硬件知道新的PTE。
sfence.vma
,用于刷新当前CPU的TLB。
satp
寄存器后,在kvminithart
中执行sfence.vma
,并在返回用户空间之前在用于切换至一个用户页表的trampoline
代码中执行sfence.vma
(*kernel/trampoline.S*:79)。
每个进程都有一个单独的页表,当xv6在进程之间切换时,也会更改页表。如图2.3所示,一个进程的用户内存从虚拟地址零开始,可以增长到MAXVA (kernel/riscv.h:348),原则上允许一个进程内存寻址空间为256G。
当进程向xv6请求更多的用户内存时,xv6首先使用kalloc
来分配物理页面。然后,它将PTE添加到进程的页表中,指向新的物理页面。Xv6在这些PTE中设置PTE_W
、PTE_X
、PTE_R
、PTE_U
和PTE_V
标志。大多数进程不使用整个用户地址空间;xv6在未使用的PTE中留空PTE_V
。
我们在这里看到了一些使用页表的很好的例子:
图3.4更详细地显示了xv6中执行态进程的用户内存布局。
exec
创建后的初始内容。main
处开始启动的值(即main
的地址、argc
、argv
),这些值产生的效果就像刚刚调用了main(argc, argv)
一样。
为了检测用户栈是否溢出了所分配栈内存,xv6在栈正下方放置了一个无效的保护页(guard page)。如果用户栈溢出并且进程试图使用栈下方的地址,那么由于映射无效(PTE_V
为0)硬件将生成一个页面故障异常。当用户栈溢出时,实际的操作系统可能会自动为其分配更多内存。
sbrk
是一个用于进程减少或增长其内存的系统调用:
这个系统调用由函数growproc
实现(*kernel/proc.c*:239):
growproc
根据n
是正的还是负的调用uvmalloc
或uvmdealloc
。
uvmalloc
(*kernel/vm.c*:229)用kalloc
分配物理内存,并用mappages
将PTE添加到用户页表中。uvmdealloc
调用uvmunmap
(*kernel/vm.c*:174),uvmunmap
使用walk
来查找对应的PTE,并使用kfree
来释放PTE引用的物理内存。释放物理页是可选的,是因为可能存在多个虚拟地址映射到相同物理页的情况
XV6使用进程的页表,不仅是告诉硬件如何映射用户虚拟地址,也是明晰哪一个物理页面已经被分配给该进程的唯一记录。这就是为什么释放用户内存(在uvmunmap
中)需要检查用户页表的原因。
本节内容参考: 程序员的自我修养,装载,链接与库一书
ELF文件格式:
struct elfhdr
(*kernel/elf.h*:6),后面一系列的程序节头(section headers)、struct proghdr
(*kernel/elf.h*:25)组成。段表(Section Header Table)就是保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。
段表在ELF文件中的位置由ELF文件头的“ e_shoff ”成员决定。
段表的结构比较简单,它是一个以“ Elf32_Shdr ”结构体为元素的数组。数组元素的个数等于段的个数,每个“ Elf32_Shdr ”结 构体对应一个段。“ Elf32_Shdr ”又被称为段描述符(Section Descriptor)。
ELF文件里面很多地方采用了这种与段表类似的数组方式保存。一般定义一个固定长度的结构,然后依次存放。这样我们就可以使用下标来引用某个结构。
Elf32_Shdr段描述符结构如下:
Elf32_Shdr 的各个成员的含义如表3-7所示:
事实上段的名字对于编译器、链接器来说是有意义的,但是对于操作系统来说并没有实质的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段的标志位这两个成员决定。
段的类型相关常量以SHT_开头,列举如表3-8所示:
段的标志位( sh_flag ) 段的标志位表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,如表3-9所示:
ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。一个ELF文件中往往有十几个段,那么内存空间的浪费是可想而知的。有没有办法尽量减少这种内存浪费呢?
当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。
ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
那么我们可以找到一个很简单的方案就是:
ELF可执行文件引入了一个概念叫做“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。正如我们上面的例子中看到的,如果将“.text”段和“.init”段合并在一起看作是一个“Segment”,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的VMA(Virtual Memory Address),而不是两个,这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间。
Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area);
我们很难将“Segment”和“Section”这两个词从中文的翻译上加以区分,因为很多时候Section也被翻译成“段”,从链接的角度看,ELF文件是按“Section”存储的,事实也的确如此;从装载的角度看,ELF文件又可以按照“Segment”划分。我们在这里就对“Segment”不作翻译,一律按照原词。
“Segment”的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。
在ELF中把这些属性相似的、又连在一起的段叫做一个“Segment”,而系统正是按照“Segment”而不是“Section”来映射可执行文件的。
当我们的elf可执行文件被加载时,有一些段被归入可读可执行的,假设它们被统一映射到一个VMA0;另外一部分段是可读可写的,假设它们被映射到了VMA1;还有一部分段在程序装载时没有被映射的,它们是一些包含调试信息和字符串表等段,这些段在程序执行时没有用,所以不需要被映射。很明显,所有相同属性的“Section”被归类到一个“Segment”,并且映射到同一个VMA。
所以总的来说,“Segment”和“Section”是从不同的角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从“Section”的角度来看ELF文件就是链接视图(Linking View),从“Segment”的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,“段”专门指“Segment”;而在其他的情况下,“段”指的是“Section”。
ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存“Segment”的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。
跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:
Elf32_Phdr结构的各个成员的基本含义,如表6-2所示:
对于“LOAD”类型的“Segment”来说, p_memsz 的值不可以小于 p_filesz ,否则就是不符合常理的。但是,如果 p_memsz 的值大于 p_filesz 又是什么意思呢?
在操作系统里面,VMA除了被用来映射可执行文件中的各个“Segment”以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。
我们知道进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。在Linux下,我们可以通过查看“/proc”来查看进程的虚拟空间分布:
上面的输出结果中:
我们可以看到进程中有5个VMA,只有前两个是映射到可执行文件中的两个Segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。
我们可以看到有两个区域分别是堆(Heap)和栈(Stack),它们的大小分别为140 KB和88KB。这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。
另外有一个很特殊的VMA叫做“vdso”,它的地址已经位于内核空间了(即大于0xC0000000的地址),事实上它是一个内核 的模块,进程可以通过访问这个VMA来跟内核进行一些通信,这里我们就不具体展开了,有兴趣的读者可以去参考一些关于Linux内核模块的资料。
通过上面的例子,让我们小结关于进程虚拟地址空间的概念:
当我们在讨论进程虚拟空间的“Segment”的时候,基本上就是指上面的几种VMA。现在再让我们来看一个常见进程的虚拟空间是怎么样的,如图6-9所示:
exec函数都是配合fork函数使用,也就是先fork后exec,exec是执行在子进程的上下文中的
exec
是创建地址空间的用户部分的系统调用:
exec
(*kernel/exec.c*:13)使用namei
(*kernel/exec.c*:26)打开指定的二进制path
,这在第8章中有解释。struct elfhdr
(*kernel/elf.h*:6),后面一系列的程序节头(section headers)、struct proghdr
(*kernel/elf.h*:25)组成。proghdr
描述程序中必须加载到内存中的一节(section);xexec函数执行步骤:
0x7F
、“E
”、“L
”、“F
”或ELF_MAGIC
开始(*kernel/elf.h*:3)。如果ELF头有正确的幻数,exec
假设二进制文件格式良好。exec
使用proc_pagetable
(*kernel/exec.c*:38)分配一个没有用户映射的新页表uvmalloc
(*kernel/exec.c*:52)为每个ELF段分配内存loadseg
(*kernel/exec.c*:10)将每个段加载到内存中。loadseg
使用walkaddr
找到分配内存的物理地址,在该地址写入ELF段的每一页,并使用readi
从文件中读取。使用exec
创建的第一个用户程序/init
的程序节标题如下:
程序节头的filesz
可能小于memsz
,这表明它们之间的间隙应该用零来填充(对于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
将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版本中,这是不可能的,因为内核有自己独立的页表;loadseg
加载到进程的页表中,而不是内核的页表中。内核开发人员很容易省略关键的检查,而现实世界中的内核有很长一段丢失检查的历史,用户程序可以利用这些检查的缺失来获得内核特权。xv6可能没有完成验证提供给内核的用户级数据的全部工作,恶意用户程序可以利用这些数据来绕过xv6的隔离。
下面给出exec完整源码注释说明:
关于磁盘读取这块的源码,不是本节重点,没有给出。
exec函数中将elf文件加载到当前进程虚拟地址空间后的视图如下所示:
像大多数操作系统一样,xv6使用分页硬件进行内存保护和映射。大多数操作系统通过结合分页和页面故障异常使用分页,比xv6复杂得多,我们将在第4章讨论这一点。
内核通过使用虚拟地址和物理地址之间的直接映射,以及假设在地址0x8000000
处有物理RAM (内核期望加载的位置) ,Xv6得到了简化。这在QEMU中很有效,但在实际硬件上却是个坏主意;实际硬件将RAM和设备置于不可预测的物理地址,因此(例如)在xv6期望能够存储内核的0x8000000
地址处可能没有RAM。更严肃的内核设计利用页表将任意硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V支持物理地址级别的保护,但xv6没有使用这个特性。
在有大量内存的机器上,使用RISC-V对“超级页面”的支持可能很有意义。而当物理内存较小时,小页面更有用,这样可以以精细的粒度向磁盘分配和输出页面。例如,如果一个程序只使用8KB内存,给它一个4MB的物理内存超级页面是浪费。在有大量内存的机器上,较大的页面是有意义的,并且可以减少页表操作的开销。
xv6内核缺少一个类似malloc
可以为小对象提供内存的分配器,这使得内核无法使用需要动态分配的复杂数据结构。
内存分配是一个长期的热门话题,基本问题是有效使用有限的内存并为将来的未知请求做好准备。今天,人们更关心速度而不是空间效率。此外,一个更复杂的内核可能会分配许多不同大小的小块,而不是(如xv6中)只有4096字节的块;一个真正的内核分配器需要处理小分配和大分配。