MIT 6.S081 2020 操作系统
本文为MIT 6.S081课程第三章教材内容翻译加整理。
本课程前置知识主要涉及:
- C语言(建议阅读C程序语言设计—第二版)
- RISC-V汇编
- 推荐阅读: 程序员的自我修养-装载,链接与库
页表是操作系统为每个进程提供私有地址空间和内存的机制。页表决定了内存地址的含义,以及物理内存的哪些部分可以访问。它们允许xv6隔离不同进程的地址空间,并将它们复用到单个物理内存上。
页表还提供了一层抽象(a level of indirection),这允许xv6执行一些特殊操作:
本章的其余部分介绍了RISC-V硬件提供的页表以及xv6如何使用它们。
提醒一下,RISC-V指令(用户和内核指令)使用的是虚拟地址,而机器的RAM或物理内存是由物理地址索引的。RISC-V页表硬件通过将每个虚拟地址映射到物理地址来为这两种地址建立联系。
XV6基于Sv39 RISC-V运行,这意味着它只使用64位虚拟地址的低39位;而高25位不使用。
在这种Sv39配置中,RISC-V页表在逻辑上是一个由 2 27 2^{27} 227 个页表条目(Page Table Entries/PTE)组成的数组,每个PTE包含一个44位的物理页码(Physical Page Number/PPN)和一些标志。
分页硬件通过使用虚拟地址39位中的前27位索引页表,以找到该虚拟地址对应的一个PTE,然后生成一个56位的物理地址,其前44位来自PTE中的PPN,其后12位来自原始虚拟地址。
图3.1显示了这个过程,页表的逻辑视图是一个简单的PTE数组(参见图3.2进行更详细的了解)。页表使操作系统能够以 4096 ( 2 12 2^{12} 212 ) 字节的对齐块的粒度控制虚拟地址到物理地址的转换,这样的块称为页(page)。
在Sv39 RISC-V中,虚拟地址的前25位不用于转换;将来RISC-V可能会使用那些位来定义更多级别的转换。另外物理地址也是有增长空间的: PTE格式中有空间让物理地址长度再增长10个比特位。RISC-V 的设计者根据技术预测选择了这些数字。 2 39 2^{39} 239 字节是 512 GB,这应该足够让应用程序运行在 RISC-V 计算机上。 2 56 2^{56} 256 的物理内存空间在不久的将来足以容纳可能的 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 个中间页目录分配底层页目录的页。 所以,在这个例子中,三级设计仅使用了三个页面,共占用 3 × 4096 3\times4096 3×4096个字节。
因为 CPU 在执行转换时会在硬件中遍历三级结构,所以缺点是 CPU 必须从内存中加载三个 PTE 以将虚拟地址转换为物理地址。为了减少从物理内存加载 PTE 的开销,RISC-V CPU 将页表条目缓存在 Translation Look-aside Buffer (TLB) 中。
TLB发生在哪一步,是在地址翻译之前还是之后?
- 整个CPU和MMU都在处理器芯片中,所以在一个RISC-V芯片中,有多个CPU核,MMU和TLB存在于每一个CPU核里面。RISC-V处理器有L1 cache,L2 Cache,有些cache是根据物理地址索引的,有些cache是根据虚拟地址索引的,由虚拟地址索引的cache位于MMU之前,由物理地址索引的cache位于MMU之后。
- 操作系统不需要知道TLB是如何工作的 —> 需要知道TLB存在的唯一原因是,如果切换了page table,操作系统需要告诉处理器当前正在切换page table,处理器会清空TLB。
- 因为本质上来说,如果你切换了page table,TLB中的缓存将不再有用,它们需要被清空,否则地址翻译可能会出错。所以操作系统知道TLB是存在的,但只会时不时的告诉操作系统,现在的TLB不能用了,因为要切换page table了。在RISC-V中,清空TLB的指令是sfence_vma。
3级的page table是由操作系统实现的还是由硬件自己实现的?
- 这是由硬件实现的,所以3级 page table的查找都发生在硬件中。MMU是硬件的一部分而不是操作系统的一部分。在XV6中,有一个函数也实现了page table的查找,因为时不时的XV6也需要完成硬件的工作,所以XV6有这个叫做walk的函数,它在软件中实现了MMU硬件相同的功能。
硬件会完成3级 page table的查找,那为什么我们要在XV6中有一个walk函数来完成同样的工作?
- 首先XV6中的walk函数设置了最初的page table,它需要对3级page table进行编程所以它首先需要能模拟3级page table。
- 另一个原因或许你们已经在syscall实验中遇到了,在XV6中,内核有它自己的page table,用户进程也有自己的page table,用户进程指向sys_info结构体的指针存在于用户空间的page table,但是内核需要将这个指针翻译成一个自己可以读写的物理地址。
- 如果你查看copy_in,copy_out,你可以发现内核会通过用户进程的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中存放的都是物理地址:
- 我们不能让我们的地址翻译依赖于另一个翻译,否则我们可能会陷入递归的无限循环中。所以page directory必须存物理地址
stap寄存器存放的也是物理地址:
- 这里必须是物理地址,因为我们要用它来完成地址翻译,而不是对它进行地址翻译。所以SATP需要知道最高一级的page directory的物理地址是什么
Xv6为每个进程维护一个页表,用以描述每个进程的用户地址空间,外加一个单独描述内核地址空间的页表。内核配置其地址空间的布局,以允许自己以可预测的虚拟地址访问物理内存和各种硬件资源。图3.3显示了这种布局如何将内核虚拟地址映射到物理地址。文件(kernel/memlayout.h) 声明了xv6内核内存布局的常量。
上图就是内核中地址的对应关系,左边是内核的虚拟地址空间,右边上半部分是物理内存或者说是DRAM,右边下半部分是I/O设备。
QEMU模拟了一台计算机,它包括从物理地址0x80000000
开始并至少到0x86400000
结束的RAM(物理内存),xv6称结束地址为PHYSTOP
。QEMU模拟还包括I/O设备,如磁盘接口。QEMU将设备接口作为内存映射控制寄存器暴露给软件,这些寄存器位于物理地址空间0x80000000
以下。内核可以通过读取/写入这些特殊的物理地址与设备交互;这种读取和写入与设备硬件而不是RAM通信。第4章解释了xv6如何与设备进行交互。
- 当操作系统启动时,会从地址0x80000000开始运行,这个地址是由硬件设计者决定的
- 主板的设计人员决定了,在完成了虚拟到物理地址的翻译之后,如果得到的物理地址大于0x80000000会走向DRAM芯片,如果得到的物理地址低于0x80000000会走向不同的I/O设备。这是由这个主板的设计人员决定的物理结构。如果你想要查看这里的物理结构,你可以阅读主板的手册,手册中会一一介绍物理地址对应关系。
- 首先,地址0是保留的,地址0x10090000对应以太网,地址0x80000000对应DDR内存,处理器外的易失存储(Off-Chip Volatile Memory),也就是主板上的DRAM芯片
- 上图中间是RISC-V处理器,处理器中有4个核,每个核都有自己的MMU和TLB。处理器旁边就是DRAM芯片。
地址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
的调用给了它一些管理空间。void
kinit()
{
//初始化锁资源
initlock(&kmem.lock, "kmem");
//扫描物理内存,建立好数据结构,用以管理当前物理内存
//此处的end和PHYSTOP分别为freeMemory区域的起始和结束内存地址
freerange(end, (void*)PHYSTOP);
}
void
freerange(void *pa_start, void *pa_end)
{
char *p;
//内存地址对齐---确保内存地址起始为固定物理页大小的整数倍
p = (char*)PGROUNDUP((uint64)pa_start);
//挨个遍历所有物理页,直到地址超出pa_end范围
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
在继续深入freerange调用链之前,我们先来看一下xv6中是如何使用空闲链表法来对物理内存进行管理的:
// end代表的free memoery的起始地址 ---> end符号的值由kernel.ld链表脚本在链接过程中计算得出,然后放入了符号表中
//我们可以在c语言中通过访问到存在于符号表中的符号
extern char end[]; // first address after kernel.
// defined by kernel.ld.
//简单的链表节点,比如我们平常写的Node结构体
struct run {
struct run *next;
};
//内存分配器对象
struct {
//锁和一个空闲链表
struct spinlock lock;
struct run *freelist;
} kmem;
- 链接器会将权限相同的section进行合并,得到我们常说的segment,存放于progaram headers中,因此当内核的elf可执行文件被加载时,.rodata,.data和.bss节都会被合并为一个data段。
- 链表器脚本语法中使用PROVIDER来定义一个符号,同时进行赋值,该符号会被放到符号表中,我们可以在c程序中像访问变量一样访问该符号。
//PGROUNDUP(sz) 宏的作用是将给定的大小 sz 向上取整到最接近 PGSIZE 的倍数。
#define PGROUNDUP(sz) (((sz)+PGSIZE-1) & ~(PGSIZE-1))
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
// 该函数用于释放一个指向物理内存页面的指针 pa 所指向的内存页。
// 通常情况下,这个指针应该是通过调用 kalloc() 函数分配得到的(除非在初始化分配器时,参考上面的 kinit 函数)。
void
kfree(void *pa)
{
struct run *r;
// 传入的地址不是PGSIZE的整数倍,或者说地址范围不在end到PHYSTOP之间,那么说明地址是错的,抛出异常
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
// 将内存页的内容设置为值 1
// 这样做的目的是为了在使用已释放的内存页时能够更容易地检测到错误。
// 通过将内存页填充为非零的值,如果程序在错误地访问了已释放的内存页,那么这些非零的值就可能会导致程序的行为出现异常,从而帮助开发人员尽早地发现问题。
memset(pa, 1, PGSIZE);
// 将物理页地址转换为run指针类型 --- 将物理页面的起始四个字节解释为run指针
r = (struct run*)pa;
//保护临界区资源
acquire(&kmem.lock);
// 释放的物理页面会重放回空闲链接尾部
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
分配器有时将地址视为整数,以便对其执行算术运算(例如,在freerange
中遍历所有页面),有时将地址用作读写内存的指针(例如,操纵存储在每个页面中的run
结构);
函数kfree
(*kernel/kalloc.c*:47)首先将内存中的每一个字节设置为1。这将导致使用释放后的内存的代码(使用“悬空引用”)读取到垃圾信息而不是旧的有效内容,从而希望这样的代码更快崩溃。然后kfree
将页面前置(头插法)到空闲列表中:
pa
转换为一个指向struct run
的指针r
,在r->next
中记录空闲列表的旧开始,并将空闲列表设置为等于r
。void*
memset(void *dst, int c, uint n)
{
char *cdst = (char *) dst;
int i;
for(i = 0; i < n; i++){
cdst[i] = c;
}
return dst;
}
kalloc
删除并返回空闲列表中的第一个元素。// Allocate one 4096-byte page of physical memory.
// 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;
}
确保分配的内存块中的所有字节都被填充为相同的垃圾值,以避免可能出现的敏感数据泄露或信息泄漏。垃圾数据填充可以增加安全性,防止未初始化的内存被访问,或者在使用内存之前,提前发现内存中的错误。
大多数用于操作地址空间和页表的xv6代码都写在 *vm.c* (kernel/vm.c:1) 中。其核心数据结构是pagetable_t
,它实际上是指向RISC-V根页表页的指针;
typedef uint64 *pagetable_t; // 512 PTEs
一个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的代码如下所示:
/*
* the kernel's page table.
*/
pagetable_t kernel_pagetable;
/*
* create a direct-map page table for the kernel.
*/
void
kvminit()
{
//1. 为最高一级page directory分配物理page`(注,调用kalloc就是分配物理page)`
kernel_pagetable = (pagetable_t) kalloc();
// 2. 将这段内存初始化为0
memset(kernel_pagetable, 0, PGSIZE);
// 3. 通过kvmmap函数,将每一个I/O设备映射到内核。
// uart registers --> uart
kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface --> 与磁盘交互
kvmmap(VIR TIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT --> 负责产生软件中断和定时器中断(本地中断)
kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);
// PLIC --> 负责管理外部中断
kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only. --> 将内核代码段进行直接映射,唯一的不同的,映射的页面都是只能读和执行的
kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of. --> 将内核数据的进行直接映射
kvmmap((uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
// 将trampoline代码映射到内核地址最高处,trampoline代码负责在用户态和内核态之间进行切换的
// 换个说法: 负责中断上下文的保存和恢复
kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}
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函数的具体实现:
// add a mapping to the kernel page table.
// only used when booting.
// does not flush TLB or enable paging.
// 该函数只在启动阶段使用,用于向内核页表添加映射条目,不会刷新TLB
void
//四个参数的含义: 虚拟地址,物理地址,大小,读写权限
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
// mappages函数负责具体完成映射条目的建立工作
if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
panic("kvmmap");
}
typedef uint64 pte_t;
typedef uint64 *pagetable_t; // 512 PTEs
// 这个宏的作用是将给定的地址 a 向下舍入到最接近的页面大小 PGSIZE 的较低倍数。
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa.
// va and size might not be page-aligned.
// Returns 0 on success, -1 if walk() couldn't allocate a needed page-table page.
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
//将我们需要映射的虚拟地址范围进行页面对齐操作
a = PGROUNDDOWN(va); // 向下对齐后的,要分配的虚拟地址起始地址
last = PGROUNDDOWN(va + size - 1); // 向下对齐后的,要分配的虚拟地址的结束地址
// 对要映射的虚拟地址范围中每个页面建立映射关系
for(;;){
// 遍历页表得到虚拟地址对应的叶子层页表中的页表项
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
// 判断页表项是否有效
if(*pte & PTE_V)
panic("remap");
// 向该页表向中具体写入映射信息 --> 将要映射的物理地址转换为虚拟地址,同时设置页表项的权限等信息
*pte = PA2PTE(pa) | perm | PTE_V;
// 要映射的虚拟地址范围起始和结束重叠
if(a == last)
break;
// 继续尝试为虚拟地址范围接下来的空间建立映射关系,直到与结束点重合位置
a += PGSIZE;
//注意: 物理地址也会增加
pa += PGSIZE;
}
return 0;
}
关于mappages和下面要讲的walk函数汇总中用到的相关宏定义具体图例说明如下:
#define PGSIZE 4096 // bytes per page
#define PGSHIFT 12 // bits of offset within a page
在查找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函数负责通过软件模拟遍历页表的过程,具体代码如下:
// Return the address of the PTE in page table pagetable
// that corresponds to virtual address va. If alloc!=0,
// create any required page-table pages.
//
// The risc-v Sv39 scheme has three levels of page-table
// pages. A page-table page contains 512 64-bit PTEs.
// A 64-bit virtual address is split into five fields:
// 39..63 -- must be zero.
// 30..38 -- 9 bits of level-2 index.
// 21..29 -- 9 bits of level-1 index.
// 12..20 -- 9 bits of level-0 index.
// 0..11 -- 12 bits of byte offset within the page.
pte_t *
//参数详情: 根页表地址,虚拟地址,发生缺页异常时,当页面项指向的页表页还没有加载时,是否需要创建新的页表页
walk(pagetable_t pagetable, uint64 va, int alloc)
{
// 虚拟地址比地址空间最大范围还大
if(va >= MAXVA)
panic("walk");
// 遍历一级页表和二级页表
for(int level = 2; level > 0; level--) {
// 从虚拟地址中提出第level级对应页表项的索引地址
pte_t *pte = &pagetable[PX(level, va)];
// 如果当前页表项有效
if(*pte & PTE_V) {
// 页表项转换为物理地址 --> 也就是得到页表项指向的页表的基地址
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 如果alloc传入0,或者物理页分配失败则返回
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
// 初始化得到的页表
memset(pagetable, 0, PGSIZE);
// 通过物理地址反推对应的页表项的值,同时设置页表项为有效
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 此时pagetable指向叶子层页表---提取出叶子层页表的指定页表项
return &pagetable[PX(0, va)];
}
walk流程:
从传入的根页表开始往下遍历, 首选从虚拟地址中提取出L2的值,作为根页表的索引号,从根页表中定义对应的PTE
如果当前PTE的映射关系还没有建立,则PTE_V为无效
2.1 分配一个物理页面作为当前PTE指向的下一级页表的页面,然后通过物理地址反推PTE映射内容,并设置PTE_V为有效,然后赋值给PTE
如果当前PTE有效 , 将PTE保存的内容转换为对应的物理地址,也就是下一级页表的物理页面的基地址
第二次循环就是根据L1去二级页表中定位PTE,然后分配物理页给叶子页表,建立映射关系
此时pagetable指向的是叶子页表,根据L0去叶子页表中定位PTE,然后作为结果返回
一句话: 函数从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将使用内核页表转换地址。由于内核使用等价映射,下一条指令的当前虚拟地址将映射到正确的物理内存地址。
// Switch h/w page table register to the kernel's page table,
// and enable paging.
void
kvminithart()
{
//将satp寄存器指向当前内核根页表,ton
w_satp(MAKE_SATP(kernel_pagetable));
//刷新TLB
sfence_vma();
}
这里先解释一下MAKE_SATP宏定义的含义:
这个函数首先设置了SATP寄存器,kernel_pagetable变量来自于kvminit第一行。所以这里实际上是内核告诉MMU来使用刚刚设置好的page table。当这里这条指令执行之后,下一个指令的地址会发生什么?
在这条指令之前,还不存在可用的page table,所以也就不存在地址翻译。执行完这条指令之后,程序计数器(Program Counter)增加了4。而之后的下一条指令被执行时,程序计数器会被内存中的page table翻译。
所以这条指令的执行时刻是一个非常重要的时刻。因为整个地址翻译从这条指令之后开始生效,之后的每一个使用的内存地址都可能对应到与之不同的物理内存地址。因为在这条指令之前,我们使用的都是物理内存地址,这条指令之后page table开始生效,所有的内存地址都变成了另一个含义,也就是虚拟内存地址。
这里能正常工作的原因是值得注意的。因为前一条指令还是在物理内存中,而后一条指令已经在虚拟内存中了。比如,下一条指令地址是0x80001110就是一个虚拟内存地址。
在等价映射的情况下,由于内核代码段的虚拟地址和物理地址是一致的,所以开启分页的下一条指令的虚拟地址经过翻译后,能够正确定位到对应的物理地址上存储的那条指令,所以可以正常执行。
如果是非等价映射情况,可能需要提前计算出虚拟地址和物理地址空间中代码段的间隔,然后开启分页后,虚拟地址减去或者加上固定间隔后,才能得到正确的物理地址,然后才能获取到对应物理地址上的正确数据,或者目标指令。
对于walk函数,在写完SATP寄存器之后,代码是通过page table将虚拟地址翻译成了物理地址,但是这个时候SATP已经被设置了,得到的物理地址不会被认为是虚拟地址吗?
- walk函数在设置完SATP寄存器后,还能工作的原因是,内核设置了虚拟地址等于物理地址的映射关系,这里很重要,因为很多地方能工作的原因都是因为内核设置的地址映射关系是相同的。
管理虚拟内存的一个难点是,一旦执行了类似于SATP这样的指令,你相当于将一个page table加载到了SATP寄存器,你的世界完全改变了。现在每一个地址都会被你设置好的page table所翻译。那么假设你的page table设置错误了,会发生什么呢?
每一个进程的SATP寄存器存在哪?
- 每个CPU核只有一个SATP寄存器,但是在每个proc结构体,如果你查看proc.h,里面有一个指向page table的指针,这对应了进程的根page table物理内存地址。
为什么通过3级page table会比一个超大的page table更好呢?
- 原因是,3级page table中,大量的PTE都可以不存储。比如,对于最高级的page table里面,如果一个PTE为空,那么你就完全不用创建它对应的中间级和最底层page table,以及里面的PTE。所以,这就是像是在整个虚拟地址空间中的一大段地址完全不需要有映射一样。
- 所以3级page table采用按需分配这些映射块,最开始你只有3个page table,一个是最高级,一个是中间级,一个是最低级的。随着代码的运行,我们会创建更多的page table diretory。
main
中调用的procinit
(*kernel/proc.c*:26)为每个进程分配一个内核栈。它将每个栈映射到KSTACK
生成的虚拟地址,这为无效的栈保护页面留下了空间。kvmmap
将映射的PTE添加到内核页表中,对kvminithart
的调用将内核页表重新加载到satp
中,以便硬件知道新的PTE。
每个RISC-V CPU都将页表条目缓存在转译后备缓冲器(快表/TLB)中,当xv6更改页表时,它必须告诉CPU使相应的缓存TLB条目无效。
如果没有这么做,那么在某个时候TLB可能会使用旧的缓存映射,指向一个在此期间已分配给另一个进程的物理页面,这样会导致一个进程可能能够在其他进程的内存上涂鸦。
RISC-V有一个指令sfence.vma
,用于刷新当前CPU的TLB。
xv6在重新加载satp
寄存器后,在kvminithart
中执行sfence.vma
,并在返回用户空间之前在用于切换至一个用户页表的trampoline
代码中执行sfence.vma
(*kernel/trampoline.S*:79)。
首先来看一下函数中使用到的相关的宏定义
// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
//它表示在使用39位虚拟地址空间(Sv39)的系统中,最大的可寻址虚拟地址。
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
// map the trampoline page to the highest address,
// in both user and kernel space.
// 将"跳板页"(trampoline page)映射到最高地址,包括用户空间和内核空间。
//"跳板页"通常用于实现一些特殊的操作或跳转,例如在用户态和内核态之间进行切换时。
#define TRAMPOLINE (MAXVA - PGSIZE)
// map kernel stacks beneath the trampoline,
// each surrounded by invalid guard pages.
// 将内核栈映射到"跳板页"下方,每个内核栈周围都有无效的保护页面 --- p代表是第几个进程的内核栈,按顺序往下映射
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
// initialize the proc table at boot time.
//在启动的时候初始化进程表
void
procinit(void)
{
struct proc *p;
initlock(&pid_lock, "nextpid");
// 依次处理进程表中每个进程
for(p = proc; p < &proc[NPROC]; p++) {
initlock(&p->lock, "proc");
// Allocate a page for the process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
// 为每个进程分配一个内核栈,映射在内存的高处,每个内核栈下面紧接着一个gurad page,用于进行溢出检测
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
// 内核栈按顺序映射到"跳板页"下方,每个内核栈周围都有无效的保护页面
uint64 va = KSTACK((int) (p - proc));
// 建立内核栈虚拟地址空间和上面分配的物理地址空间的映射关系
kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
// 设置当前进程的内核栈地址
p->kstack = va;
}
// 设置stap寄存器,然后刷新tlb -->上面已经讲解过了,但是此处是否有必要刷新,个人存疑,或者可以将procinit上面那段kvminithart调用逻辑删除
//将分页机制开启,刷新TLB逻辑放到此处
kvminithart();
}
每个进程都有一个单独的页表,当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
是一个用于进程减少或增长其内存的系统调用:
// sys_sbrk系统调用通过C中的一个包装函数brk来访问
// 该函数接受一个参数,指定要增加或减少程序数据段的内存量。作为返回值,它提供一个指向新分配内存起始位置的指针
uint64
sys_sbrk(void)
{
int addr;
int n;
//从a0系统调用参数寄存器中取出参数值
if(argint(0, &n) < 0)
return -1;
// 返回一个指向新分配内存起始位置的指针 -- 当前进程堆顶位置
addr = myproc()->sz;
// 增加内存
if(growproc(n) < 0)
return -1;
return addr;
}
这个系统调用由函数growproc
实现(*kernel/proc.c*:239):
// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
uint sz;
//获取当前进程的结构体
struct proc *p = myproc();
// 获取当前进程堆顶位置
sz = p->sz;
// 扩大内存
if(n > 0){
// 分配内存,返回新的堆顶位置---返回的是对齐后的新堆顶地址
if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
} else if(n < 0){
// 缩小内存,返回新的堆定位置---返回的是对齐后的新堆顶地址
sz = uvmdealloc(p->pagetable, sz, sz + n);
}
//更新当前进程堆顶位置
p->sz = sz;
return 0;
}
growproc
根据n
是正的还是负的调用uvmalloc
或uvmdealloc
。
uvmalloc
(*kernel/vm.c*:229)用kalloc
分配物理内存,并用mappages
将PTE添加到用户页表中。// Allocate PTEs and physical memory to grow process from oldsz to
// newsz, which need not be page aligned. Returns new size or 0 on error.
uint64
// 当前进程根页表基地址,旧的堆顶地址,新的堆顶地址
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
char *mem;
uint64 a;
// 如果旧的堆顶地址比新的堆顶地址还大,那么不进行扩展,返回旧的堆顶地址
if(newsz < oldsz)
return oldsz;
//旧的堆顶地址进行页面向上对齐: 将一个值(sz)向上舍入到最接近的PGSIZE的倍数
oldsz = PGROUNDUP(oldsz);
// 将旧堆顶和新堆顶范围之间的页面建立映射关系
for(a = oldsz; a < newsz; a += PGSIZE){
//分配空闲物理页面
mem = kalloc();
//没有剩余的空闲物理页面了
if(mem == 0){
// 释放已经扩展的内存区域
uvmdealloc(pagetable, a, oldsz);
return 0;
}
// 初始化物理页
memset(mem, 0, PGSIZE);
// 建立虚拟地址a和物理页面mem的映射关系 --- 该页面的权限是可读-可写-可执行-用户态可访问
if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
// 映射建立失败,将物理页释放
kfree(mem);
// 释放已经扩展的内存区域
uvmdealloc(pagetable, a, oldsz);
return 0;
}
}
// 返回新的堆顶地址
return newsz;
}
uvmdealloc
调用uvmunmap
(*kernel/vm.c*:174),uvmunmap
使用walk
来查找对应的PTE,并使用kfree
来释放PTE引用的物理内存。// Deallocate user pages to bring the process size from oldsz to
// newsz. oldsz and newsz need not be page-aligned, nor does newsz
// need to be less than oldsz. oldsz can be larger than the actual
// process size. Returns the new process size.
uint64
uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
// 如果新的堆顶地址比旧的大,那么直接返回旧的
if(newsz >= oldsz)
return oldsz;
// 确保新的堆顶地址在对齐后比旧的堆顶地址小
if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
// 获取需要释放的解除映射的页面数量
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
// 解除从对齐后的堆顶地址开始的n个页面映射---并释放物理内存
uvmunmap(pagetable, PGROUNDUP(newsz), npages, 1);
}
return newsz;
}
// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
void
// 解除从va开始的映射,va必须是对齐的,映射必须存在,是否释放物理内存是可选的
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;
//传入的虚拟地址需要是对齐后的
if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");
//遍历虚地址范围,建立映射关系
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
//遍历定位当前虚拟地址对应的PTE --- 最后一个参数值为0,表示遇到未建立映射的PTE情况下,直接返回0
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
// 是否建立了映射
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
// 如果pte并非叶子层的,则说明walk定位返回的有问题
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
// 是否释放物理内存
if(do_free){
//通过pte得到物理页面起始地址
uint64 pa = PTE2PA(*pte);
//释放该物理页
kfree((void*)pa);
}
//清空pte内容
*pte = 0;
}
}
释放物理页是可选的,是因为可能存在多个虚拟地址映射到相同物理页的情况
XV6使用进程的页表,不仅是告诉硬件如何映射用户虚拟地址,也是明晰哪一个物理页面已经被分配给该进程的唯一记录。这就是为什么释放用户内存(在uvmunmap
中)需要检查用户页表的原因。
本节内容参考: 程序员的自我修养,装载,链接与库一书
ELF文件格式:
- 在计算机科学中,是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。
- ELF是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface,ABI)而开发和发布的,也是Linux的主要可执行文件格式。
- ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。
- 实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
struct elfhdr
(*kernel/elf.h*:6),后面一系列的程序节头(section headers)、struct proghdr
(*kernel/elf.h*:25)组成。// File header
struct elfhdr {
//魔数
uint magic; // must equal ELF_MAGIC
uchar elf[12];
//文件类型
ushort type;
// ELF文件的平台属性
ushort machine;
// ELF版本号,一般为常数1
uint version;
// 程序执行的入口虚拟地址
uint64 entry;
// program headers在elf文件中的偏移位置
uint64 phoff;
// section headers在elf文件中的偏移位置
uint64 shoff;
// 描述elf文件的属性和特征
uint flags;
// elf文件头的大小,以字节为单位
ushort ehsize;
// progaram header的大小,以字节为单位
ushort phentsize;
// progaram header的数量
ushort phnum;
// section header的大小,以字节为单位
ushort shentsize;
// section header的数量
ushort shnum;
// 包含节名称字符串表的节的索引
ushort shstrndx;
};
magic:
elf:
type:
machine
version:
entry:
phoff:
shoff:
段表(Section Header Table)就是保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。
段表在ELF文件中的位置由ELF文件头的“ e_shoff ”成员决定。
段表的结构比较简单,它是一个以“ Elf32_Shdr ”结构体为元素的数组。数组元素的个数等于段的个数,每个“ Elf32_Shdr ”结
构体对应一个段。“ Elf32_Shdr ”又被称为段描述符(Section Descriptor)。
ELF文件里面很多地方采用了这种与段表类似的数组方式保存。一般定义一个固定长度的结构,然后依次存放。这样我们就可以使用下标来引用某个结构。
Elf32_Shdr段描述符结构如下:
typedef struct{
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
事实上段的名字对于编译器、链接器来说是有意义的,但是对于操作系统来说并没有实质的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段的标志位这两个成员决定。
段的类型相关常量以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的可执行文件和共享库文件都有。
跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
//下面是xv6中给出的proghdr定义
// Program section header
struct proghdr {
uint32 type;
uint32 flags;
uint64 off;
uint64 vaddr;
uint64 paddr;
uint64 filesz;
uint64 memsz;
uint64 align;
};
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
的程序节标题如下:
# objdump -p _init
user/_init: file format elf64-littleriscv
Program Header:
LOAD off 0x00000000000000b0 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**3
filesz 0x0000000000000840 memsz 0x0000000000000858 flags rwx
STACK off 0x0000000000000000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**4
filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-
程序节头的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执行在子进程的上下文中的
// 可执行文件的路径,和传递给可执行程序的参数
int
exec(char *path, char **argv)
{
char *s, *last;
int i, off;
//sz表示新进程的当前可用内存起始地址,sp指向新进程用户栈栈顶,stackbase代表栈的基地址
uint64 argc, sz = 0, sp, ustack[MAXARG+1], stackbase;
// 用于接收elf文件头
struct elfhdr elf;
// 用于接收可执行文件对应的inode
struct inode *ip;
// 用于接收program header头
struct proghdr ph;
// 给子进程的准备的新页表,和子进程的旧页表-->其实也就是copy的父进程的页表
pagetable_t pagetable = 0, oldpagetable;
// 获取当前子进程的结构体
struct proc *p = myproc();
begin_op();
// 通过文件名定位其inode
if((ip = namei(path)) == 0){
end_op();
return -1;
}
// 为当前Inode加锁
ilock(ip);
// Check ELF header
// 从磁盘读取文件的elf头信息
if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
goto bad;
// 检验可执行文件的魔数是否合法
if(elf.magic != ELF_MAGIC)
goto bad;
// 为当前子进程分配一个新页表
if((pagetable = proc_pagetable(p)) == 0)
goto bad;
// Load program into memory.
// 遍历program header数组 -- 依次加载每个segement到内存
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
// 从elf文件中依次读取每个program header
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
// 只加载类型为LOAD的段--其他用于提供辅助信息的段不进行加载
if(ph.type != ELF_PROG_LOAD)
continue;
// 段在elf文件中占的大小不能比其在内存中占的大
if(ph.memsz < ph.filesz)
goto bad;
// 溢出检测
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
// 从sz地址处开始为每个段分配物理页,并建立与当前段虚地址的映射关系
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
// 段被加载后,sz可用内存空间指针上移
sz = sz1;
// 如果当前段在程序头中设置的虚地址不对齐,那么也是错误的行为
if(ph.vaddr % PGSIZE != 0)
goto bad;
// 加载段的内容到指定的虚拟地址
if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
//为当前Inode解锁
iunlockput(ip);
end_op();
ip = 0;
// 获取当前子进程结构体
p = myproc();
// 子进程旧的内存使用堆顶
uint64 oldsz = p->sz;
// Allocate two pages at the next page boundary.
// Use the second as the user stack.
// sz代表新进程的目前可用内存的起始地址 --> segement不断被加载,sz不断上推
sz = PGROUNDUP(sz);
uint64 sz1;
// 在sz地址基础上继续分配两个页面,第二个页面作为用户栈,第一个作为guard page
if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE)) == 0)
goto bad;
sz = sz1;
// 设置guard page
uvmclear(pagetable, sz-2*PGSIZE);
// sp指向用户栈栈顶---> 栈是向下扩展的
sp = sz;
//栈基地址
stackbase = sp - PGSIZE;
// Push argument strings, prepare rest of stack in ustack.
// 将传递给当前程序的参数都推入上面分配的用户栈中
for(argc = 0; argv[argc]; argc++) {
// 判断传递的参数个数是否超过了限制
if(argc >= MAXARG)
goto bad;
// 腾出空间
sp -= strlen(argv[argc]) + 1;
// sp指针指向的栈顶地址必须要16字节对齐
sp -= sp % 16; // riscv sp must be 16-byte aligned
// 栈溢出
if(sp < stackbase)
goto bad;
// 参数入栈
if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
// ustack记录每个参数对应的栈中位置
ustack[argc] = sp;
}
// 标记结束
ustack[argc] = 0;
// push the array of argv[] pointers.
// 将argv指针入栈,此时ustack用于表示argv --> ustack数组被压栈
sp -= (argc+1) * sizeof(uint64);
sp -= sp % 16;
if(sp < stackbase)
goto bad;
if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
goto bad;
// arguments to user main(argc, argv)
// argc is returned via the system call return
// value, which goes in a0.
// a1寄存器作为系统调用参数寄存器,此处保存main函数中需要的第二个参数地址,即argv参数地址
// 也就是当前栈顶--因为ustack是最后一个被压栈的
p->trapframe->a1 = sp;
// Save program name for debugging.
// 保存程序名,用于debug
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
// 将程序名赋值给p->name
safestrcpy(p->name, last, sizeof(p->name));
// Commit to the user image.
// 或者子进程的旧页表,也就是继承父进程的页表
oldpagetable = p->pagetable;
// 子进程的页表指针指向新的页表
p->pagetable = pagetable;
// 更新子进程的内存使用顶部位置
p->sz = sz;
// 设置mepc的值为elf的entry,也就是可执行程序的入口地址
p->trapframe->epc = elf.entry; // initial program counter = main
// 设置用户栈栈顶指针
p->trapframe->sp = sp; // initial stack pointer
// 释放旧的页表
proc_freepagetable(oldpagetable, oldsz);
//返回传递给当前程序的参数个数,根据系统调用规范,返回值由a0寄存器存放
return argc; // this ends up in a0, the first argument to main(argc, argv)
// 加载过程中出现错误
bad:
// 释放分配给新进程的页表
if(pagetable)
proc_freepagetable(pagetable, sz);
// 释放inode锁
if(ip){
iunlockput(ip);
end_op();
}
return -1;
}
exec函数中将elf文件加载到当前进程虚拟地址空间后的视图如下所示:
// Create a user page table for a given process,
// with no user memory, but with trampoline pages.
pagetable_t
proc_pagetable(struct proc *p)
{
pagetable_t pagetable;
// An empty page table.
// 创建一个新的空页表
pagetable = uvmcreate();
if(pagetable == 0)
return 0;
// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
// 将trampoline code代码映射到用户程序虚拟地址空间顶部-->进行用户态和内核态之间的切换
// trampoline code只能在s态下访问
if(mappages(pagetable, TRAMPOLINE, PGSIZE,(uint64)trampoline, PTE_R | PTE_X) < 0){
uvmfree(pagetable, 0);
return 0;
}
// map the trapframe just below TRAMPOLINE, for trampoline.S.
// 将当前进程的trapframe映射到trampoline下面,方便在trampoline执行上下文保存与恢复过程中进行访问
if(mappages(pagetable, TRAPFRAME, PGSIZE,
(uint64)(p->trapframe), PTE_R | PTE_W) < 0){
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
return pagetable;
}
// create an empty user page table.
// returns 0 if out of memory.
pagetable_t
uvmcreate()
{
pagetable_t pagetable;
// 为新页表分配一个物理页面
pagetable = (pagetable_t) kalloc();
if(pagetable == 0)
return 0;
// 初始化页表
memset(pagetable, 0, PGSIZE);
return pagetable;
}
// Load a program segment into pagetable at virtual address va.
// va must be page-aligned
// and the pages from va to va+sz must already be mapped.
// Returns 0 on success, -1 on failure.
static int
// 当前进程根页表,加载段的起始虚地址,对应段数据所在的Inode,段在elf文件中的偏移位置,段长度
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{
uint i, n;
uint64 pa;
// 虚拟地址必须对齐
if((va % PGSIZE) != 0)
panic("loadseg: va must be page aligned");
// 按页读取数据,如果剩余数据不够一页,则全部读取出来
for(i = 0; i < sz; i += PGSIZE){
// 通过遍历传入的根页表,返回虚拟地址对应的物理地址 -- 这里的前提是虚拟地址和物理地址直接已经建立了映射关系
pa = walkaddr(pagetable, va + i);
if(pa == 0)
panic("loadseg: address should exist");
// 如果剩余读取字节数小于PAGE_SIZE,那么本次将剩余字节全部读取出来
if(sz - i < PGSIZE)
n = sz - i;
else
//否则每次读取PAGE_SIZE大小的字节数据
n = PGSIZE;
// 从当前传入的Inode中offset+i的偏移位置开始读取n字节的数据到pa地址处
if(readi(ip, 0, (uint64)pa, offset+i, n) != n)
return -1;
}
return 0;
}
// Look up a virtual address, return the physical address,
// or 0 if not mapped.
// Can only be used to look up user pages.
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
uint64 pa;
// 越界检测
if(va >= MAXVA)
return 0;
// 定位当前虚拟地址对应的pte
pte = walk(pagetable, va, 0);
// 还没有建立映射
if(pte == 0)
return 0;
// 无效
if((*pte & PTE_V) == 0)
return 0;
// 用户态无权访问
if((*pte & PTE_U) == 0)
return 0;
// 返回虚拟地址对应的物理地址
pa = PTE2PA(*pte);
return pa;
}
// mark a PTE invalid for user access.
// used by exec for the user stack guard page.
void
uvmclear(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
//定位虚地址的pte
pte = walk(pagetable, va, 0);
if(pte == 0)
panic("uvmclear");
//设置pte为u态不可访问
*pte &= ~PTE_U;
}
// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
// 将数据从内核态 copy 到用户态
int
// 根页表地址,copy到的目标虚地址,数据源地址,copy数据的长度
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
//这个宏的作用是将给定的地址 a 向下舍入到最接近的页面大小 PGSIZE 的较低倍数
va0 = PGROUNDDOWN(dstva);
// 得到目标虚地址的物理地址
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
// 向下对齐可能会丢失部分数据,所以这里需要计算
n = PGSIZE - (dstva - va0);
// 该条件成立,说明剩余未copy字节数小于PGSIZE
if(n > len)
n = len;
// 将src源地址处的n字节数据copy到pa0+destva-va0地址开始处
memmove((void *)(pa0 + (dstva - va0)), src, n);
// 剩余待copy字节数
len -= n;
// 数据copy起始地址往前推
src += n;
// copy到的目标虚拟地址地址同样前推
dstva = va0 + PGSIZE;
}
return 0;
}
// Free a process's page table, and free the
// physical memory it refers to.
void
//传入的sz是旧的虚拟地址空间中,使用的内存当前使用到的最高位置
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
//TRAMPOLINE和TRAPFRAME这两个代码页对应的物理页是所有进程共享,所以解除当前进程旧页表与之映射时,实际物理页不释放
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);
//释放0~sz这段虚拟地址空间的映射
uvmfree(pagetable, sz);
}
// Free user memory pages,
// then free page-table pages.
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
if(sz > 0)
//释放旧页表所管理的虚拟地址空间从0到sz内的所有映射,同时释放对应的物理页---释放叶子层的所有映射关系
uvmunmap(pagetable, 0, PGROUNDUP(sz)/PGSIZE, 1);
//释放旧页表占据的物理页
freewalk(pagetable);
}
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void
//由下至上,递归释放整个多级页表占据的所有物理页
// 叶子层的所有映射关系必须已经都被移除了
freewalk(pagetable_t pagetable)
{
// there are 2^9 = 512 PTEs in a page table.
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
//满足下面条件,说明还没有递归到叶子层
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
// this PTE points to a lower-level page table.
uint64 child = PTE2PA(pte);
freewalk((pagetable_t)child);
pagetable[i] = 0;
} else if(pte & PTE_V){
panic("freewalk: leaf");
}
}
//释放当前页表占据的物理页面
kfree((void*)pagetable);
}
像大多数操作系统一样,xv6使用分页硬件进行内存保护和映射。大多数操作系统通过结合分页和页面故障异常使用分页,比xv6复杂得多,我们将在第4章讨论这一点。
内核通过使用虚拟地址和物理地址之间的直接映射,以及假设在地址0x8000000
处有物理RAM (内核期望加载的位置) ,Xv6得到了简化。这在QEMU中很有效,但在实际硬件上却是个坏主意;实际硬件将RAM和设备置于不可预测的物理地址,因此(例如)在xv6期望能够存储内核的0x8000000
地址处可能没有RAM。更严肃的内核设计利用页表将任意硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V支持物理地址级别的保护,但xv6没有使用这个特性。
在有大量内存的机器上,使用RISC-V对“超级页面”的支持可能很有意义。而当物理内存较小时,小页面更有用,这样可以以精细的粒度向磁盘分配和输出页面。例如,如果一个程序只使用8KB内存,给它一个4MB的物理内存超级页面是浪费。在有大量内存的机器上,较大的页面是有意义的,并且可以减少页表操作的开销。
xv6内核缺少一个类似malloc
可以为小对象提供内存的分配器,这使得内核无法使用需要动态分配的复杂数据结构。
内存分配是一个长期的热门话题,基本问题是有效使用有限的内存并为将来的未知请求做好准备。今天,人们更关心速度而不是空间效率。此外,一个更复杂的内核可能会分配许多不同大小的小块,而不是(如xv6中)只有4096字节的块;一个真正的内核分配器需要处理小分配和大分配。