本文将会详细介绍Xv6操做系统中虚拟内存的初始化过程。缓存
32位X86体系结构采用二级页表来管理虚拟内存。之因此使用二级页表, 是为了节省页表所占用的内存,由于没有内存映射的二级页表能够不用分配地址来存储。在这个二级页表结构中,每一个页的大小为4KB,每一个页表的大小也为4KB,每一个页表项的大小为4字节,一个页表包含1024个页表项。一级页表表项存储的是二级页表的地址,二级页表表项存储的是对应的物理地址。虚拟地址和物理地址的最后12位老是相同,所以页表表项中的这12位能够被用做标记其余信息。对于一个32位虚拟地址,能够经过前10位来找到其对应的一级页表表项的索引,读出二级页表表项的地址,并经过访问二级页表,获得对应的物理地址。显然,这样会使得一次虚拟内存的访问变成三次物理内存的访问,为了最小化其性能影响,CPU中额外有TLB缓存会缓存最近访问的虚拟地址所对应的页表项。虚拟地址到物理地址的转换图以下数据结构
X86还额外支持4MB大页模式,让一个一级页表表项直接映射到4MB大小的页。有些状况下,这样分配会更加方便。后文会提到Xv6系统初始化时,会使用到4MB大页。并发
须要注意的是,虚拟地址到物理地址的映射过程是由硬件完成的,不是由某个函数完成的。硬件经过cr3
控制寄存器中的一级页表地址取出对应的页表表项,自动完成虚拟地址的翻译,操做系统只负责初始化页表、设置控制寄存器和设置正确的页表表项的值。函数
main()
函数执行前内存的状况0x0000-0x7c00 引导程序的栈 0x7c00-0x7d00 引导程序的代码(512字节) 0x10000-0x11000 内核ELF文件头(4096字节) 0xA0000-0x100000 设备区 0x100000-0x400000 Xv6操做系统(未用满)
执行到main.c
中的main()
函数开头时,物理地址的具体内容如上。这里面引导程序是由BIOS负责载入内存,设备区是硬件规定占用的区域,而内核ELF文件头和Xv6操做系统是由引导程序(bootmain.c)加载进内存的。布局
索引 | 条目内容 | 条目含义 |
---|---|---|
[0] | 0 | 空条目 |
[1] | SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) |
内核代码段 |
[2] | SEG_ASM(STA_W, 0x0, 0xffffffff) |
内核数据段 |
[3] | 还没有设置 | 用户代码段 |
[4] | 还没有设置 | 用户数据段 |
[5] | 还没有设置 | Task State Segment |
X86体系结构中,全局描述符表用于分段管理内存。为了可移植性,类Unix通常只会以最少的方式使用全局描述符表对内存进行分段。在main.c里的初始化函数执行前,全局描述符表的内容如上。IA32体系结构中使用cs
、ds
、ss
、es
寄存器存放段寄存器的索引。此时cs
寄存器存的索引值是1,ds,ss,es
存的索引值是2,对应内核数据段和内核代码段。除了权限不一样外,两个条目的内容彻底相同,都是将基地址设为0,最大偏移设为4GB,这样就和通常的32位直接寻址使用起来同样了。性能
在main.c中,操做系统还会调用seginit()
函数从新设置全局描述符表,并补充未设置的内容。Task State Segment会在第一个用户进程被建立时设置(具体是在switchuvm()
函数中)。操作系统
在进入entry.S以前,系统是运行在段寻址模式下的,entry.S中设置了初始的页表并进入基于页表的虚拟寻址模式,页大小为4MB,初始的一级页表声明以下翻译
__attribute__((__aligned__(PGSIZE))) pde_t entrypgdir[NPDENTRIES] = { // Map VA's [0, 4MB) to PA's [0, 4MB) [0] = (0) | PTE_P | PTE_W | PTE_PS, // Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB) [KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS, };
注释中解释了初始的虚拟地址到物理地址的映射关系。KERNBASE
为0x80000000。PTE_P
表示这个页表项存在,PTE_W
表示可写,PTE_PS
表示这是4MB大页,没有设置PTE_U
,代表这是内核页。注意其中用于内核区域的页只有一个,所以这就限制了内核代码段+数据段的总大小不能超过4MB(其实是3MB,由于0x0-0x100000的物理地址在启动时被使用,且被设备区占用,实际的内核从物理地址0x100000开始)。指针
这只是一个初始的页表,在以后的main函数中会从新创建新的页表,并把这个页表丢弃。code
管理虚拟内存页的代码在kalloc.c
中。kalloc.c
的内存管理思想是把全部可用的空闲内存页串在一块儿造成一个大链表。每当有内存页被释放时,就将这个内存页加入这个链表(kfree()
函数);分配内存页时,就从链表头部取出一个内存页返回(kalloc()
函数)。这个内存分配器必须知道它要负责管理的内存范围,并在初始化时将整个物理地址空间都归入其管理范围。后文会提到,一开始,这个内存分配器管理的物理内存空间是[end, 0x400000],而后会扩展到[end, 0xE00000]。这就暗含了一个假设,就是物理地址0xE00000必须存在,这就要求Xv6锁运行的系统至少拥有240MB的内存。
用于内存页管理的数据结构定义以下
struct { struct spinlock lock; int use_lock; struct run *freelist; } kmem;
一开始,锁是没有启动的,直到main()
函数调用了kvinit2()
以后锁才会被使用,由于从这里以后可能会有多个进程和多个处理器并发地访问这个数据结构。 struct run *freelist
就是空闲链表的声明。
对于每个空内存页,由于这个内存页是空的,因此Xv6可使用前4个字节来保存指向下一个空内存页的地址。所以,一个空内存页的定义以下
struct run { struct run *next; };
具体对应到添加和删除操做以下(注意其中的强制类型转换)
// In kfree() // Add virtual page v to freelist r = (struct run*)v; r->next = kmem.freelist; kmem.freelist = r; // In kalloc() // Return a free page r and remove r from list r = kmem.freelist; if(r) kmem.freelist = r->next;
kalloc()
和kfree()
函数的具体实现中还有一些关于锁和错误检查的细节,在此略去。
在使用这个内存分配器时,使用kfree()
就能够向其中添加空闲的内存页,使用kalloc()
就能够从中请求一个内存页。
main()
函数中虚拟内存的初始化过程Xv6系统使用end
指针来标记Xv6的ELF文件所标记的结尾位置,这样,[PGROUNDUP(end), 0x400000]
范围内的物理内存页是能够被用做内存页分配的。Xv6调用kinit1(end, P2V(0x400000))
来首先将这部份内存归入虚拟内存页管理。虽然这部分在以前的页表中已经被映射为4MB大页,可是咱们的目标是创建一个新的页表,这个页表使用的页大小为4KB。因为这部份内存已经被分配为一个4MB内存大页,且硬件已经会自动执行虚拟内存地址翻译,故须要使用P2V()
函数将物理地址转换为虚拟地址。以后的代码里还会存在不少这样的虚拟地址到物理地址的转换。
Xv6的内存分配器必须知道它要负责管理的内存范围。因为此时虚拟内存已经开启,且页表表项只有两条,所以Xv6必须利用已有的虚拟地址空间,在其中建立新的页表。这就是main()
函数中kinit1()
和kvmalloc()
所作的事情。
kinit1()
函数会调用freerange()
函数,按照前文叙述的方式,创建从PGROUNDUP(end)
地址开始直到0x400000
为止的所有内存页的链表。这样,咱们获得了第一组可使用的虚拟内存页,而后内核就能够运行kvmalloc()
使用这些内存页了。kvmalloc()
函数得到一个虚拟内存页并将其初始化一级页表。这个一级页表的内容在vm.c
中的kmap
处被定义,具体内容以下
虚拟地址 | 映射到物理地址 | 内容 |
---|---|---|
[0x80000000, 0x80100000] | [0, 0x100000] | I/O设备 |
[0x80100000, 0x80000000+data] | [0x100000, data] | 内核代码和只读数据 |
[0x80000000+data, 0x80E00000] | [data, 0xE00000] | 内核数据+可用物理内存 |
[0xFE000000, 0] | [0xFE000000, 0] | 其余经过内存映射的I/O设备 |
注意以上映射规则会被生成为x86所要求的对应一级页表和二级页表。须要的时候,kvmalloc()
函数所调用的walkpgdir()
函数会申请新的内存页用做二级页表。
以后,main()
函数会调用seginit()
函数从新设置GDT。新的GDT与以前的GDT的主要区别在于设置了用户数据段和用户代码段。虽然这些段依然是对32位偏移进行直接映射,但其执行权限与内核的段有所不一样。GDT中的TSS表项直到第一个用户进程创立时才会被设置,而且其内容会随着当前用户进程的切换而改变。
最后,main()
函数会调用kinit2()
将[0x400000, 0xE00000]范围内的物理地址归入到内存页管理之中。至此,Xv6的内存页管理系统和内核页表已经所有创建完毕。须要注意的是,这个内核页表(kpgdir
变量)只会在调度器运行时被使用。对于每个用户进程,都会拥有本身独自的完整页表,其中也包含了一份如出一辙的内核页表。
下面咱们来看看第一个用户进程的虚拟地址空间是如何初始化的。main()
函数在kinit2()
以后紧接着调用userinit()
来初始化第一个用户进程。userinit()
在完成有关进程数据结构管理的工做后,会初始化这个进程本身的页表(struct proc
中的pgdir
)。首先,userinit()
会使用setupkvm()
生成与前述如出一辙的内核页表,而后使用inituvm()
生成第一个用户内存页(映射到虚拟地址0x0),并将用户进程初始化代码移动至这个内存页中(这就要求初始化代码不能超过4KB,初始化代码参见initcode.S)。
initcode.S中包含了一个exec系统调用,经过这个系统调用来加载进一个真正的用户进程。exec系统调用的实如今exec.c中。exec会从磁盘里加载一个ELF文件。ELF文件中包含了全部代码段和数据段的信息,而且描述了这些段应该被加载到的虚拟地址(这是在编译时就已经肯定好的,因此编译器必须遵循某些约定来分配这些虚拟地址)。
最后,exec会分配两个虚拟内存页,第一个页设置为不可访问,第二个页用做用户栈。因为栈是从上往下增加的,因此当栈的大小超过一个页(4KB)时,会触发错误,所以Xv6系统的用户进程最多只能使用4KB的栈。
这里咱们列出init进程的页表中所记录的所有虚拟地址到物理地址的映射关系。每个用户进程都有一个这样的页表。其中,有关内核的部分(也就是最后四项)对于全部用户进程都是同样的,而前面的映射会有所不一样,表中的信息根据init的进程的ELF文件信息和exec调用的代码肯定。
虚拟地址 | 映射到物理地址 | 内容 |
---|---|---|
[0x0, 0x1000] | 由分配器提供的地址 | 用户进程的代码和数据 |
[0x1000, 0x2000] | 由分配器提供的地址 | 不可访问页,用于检测栈溢出 |
[0x2000, 0x3000] | 由分配器提供的地址 | 用户进程的栈 |
[0x80000000, 0x80100000] | [0, 0x100000] | I/O设备 |
[0x80100000, 0x80000000+data] | [0x100000, data] | 内核代码和只读数据 |
[0x80000000+data, 0x80E00000] | [data, 0xE00000] | 内核数据+可用物理内存 |
[0xFE000000, 0] | [0xFE000000, 0] | 其余经过内存映射的I/O设备 |
中断发生时,使用的的页表依然是对应用户进程的页表。因为每个用户进程都有一份如出一辙的内核页表条目,所以陷入的内核代码依然能够正常执行。只有当中断处理程序决定退出当前进程或者切换到其余进程时,当前页表才会被切换为调度器的页表(全局变量kpgdir
),并在调度器中切换为新进程的页表。