XV6操作系统代码阅读心得(四):虚拟内存
本文将会详细介绍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函数中会重新建立新的页表,并把这个页表丢弃。
Xv6对虚拟内存页的管理
管理虚拟内存页的代码在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
),并在调度器中切换为新进程的页表。
XV6操作系统代码阅读心得(四):虚拟内存的更多相关文章
- XV6操作系统代码阅读心得(一):启动加载、中断与系统调用
XV6操作系统是MIT 6.828课程中使用的教学操作系统,是在现代硬件上对Unix V6系统的重写.XV6总共只有一万多行,非常适合初学者用于学习和实践操作系统相关知识. MIT 6.828的课程网 ...
- XV6操作系统代码阅读心得(二):进程
1. 进程的基本概念 从抽象的意义来说,进程是指一个正在运行的程序的实例,而线程是一个CPU指令执行流的最小单位.进程是操作系统资源分配的最小单位,线程是操作系统中调度的最小单位.从实现的角度上讲,X ...
- XV6操作系统代码阅读心得(三):锁
锁是操作系统中实现进程同步的重要机制. 基本概念 临界区(Critical Section)是指对共享数据进行访问与操作的代码区域.所谓共享数据,就是可能有多个代码执行流并发地执行,并在执行中可能会同 ...
- XV6操作系统代码阅读心得(五):文件系统
Unix文件系统 当今的Unix文件系统(Unix File System, UFS)起源于Berkeley Fast File System.和所有的文件系统一样,Unix文件系统是以块(Block ...
- VINS-Fusion代码阅读(四)
pts_i和pts_j:具体指什么含义?(分别为第l个路标点在第i, j个相机归一化相机坐标系中的观察到的坐标,P¯¯¯cil \bar{P}^{c_i}_l Pˉ lc i 和 P¯¯¯cjl ...
- 【原】AFNetworking源码阅读(四)
[原]AFNetworking源码阅读(四) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 上一篇还遗留了很多问题,包括AFURLSessionManagerTaskDe ...
- 【原】SDWebImage源码阅读(四)
[原]SDWebImage源码阅读(四) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 SDWebImage中主要实现了NSURLConnectionDataDelega ...
- 代码阅读分析工具Understand 2.0试用
Understand 2.0是一款源代码阅读分析软件,功能强大.试用过一段时间后,感觉相当不错,确实可以大大提高代码阅读效率.由于Understand功能十分强大,本文不可能详尽地介绍它的所有功能,所 ...
- Linux协议栈代码阅读笔记(二)网络接口的配置
Linux协议栈代码阅读笔记(二)网络接口的配置 (基于linux-2.6.11) (一)用户态通过C库函数ioctl进行网络接口的配置 例如,知名的ifconfig程序,就是通过C库函数sys_io ...
随机推荐
- [SDOI2005]区间
题目描述 现给定n个闭区间[ai, bi],1<=i<=n.这些区间的并可以表示为一些不相交的闭区间的并.你的任务就是在这些表示方式中找出包含最少区间的方案.你的输出应该按照区间的升序排列 ...
- Linux 中的变量
echo $PATH var =a echo $var export var1=11 echo $var 访问关系: whereis yum file /usr/bin/yum vi /usr ...
- python Multiprocessing 多进程应用
在运维工作中,经常要处理大量数据,或者要跑一些时间比较长的任务,可能都需要用到多进程,不管是管理端下发任务,还是客户端执行任务,如果服务器配置还可以,跑多进程还是挺能解决问题的 Multiproces ...
- javaScript基础语法介绍
简介 JavaScript是一种脚本语言. (脚本,一条条的文字命令.执行时由系统的一个解释器,将其一条条的翻译成机器可识别的指令,然后执行.常见的脚本:批处理脚本.T-SQL脚本.VBScript等 ...
- 【leetcode 简单】 第五十一题 有效电话号码
给定一个包含电话号码列表(一行一个电话号码)的文本文件 file.txt,写一个 bash 脚本输出所有有效的电话号码. 你可以假设一个有效的电话号码必须满足以下两种格式: (xxx) xxx-xxx ...
- Python ctypes中cast/py_object用法
class ctypes.py_object Represents the C PyObject * datatype. Calling this without an argument create ...
- 商城项目(ssm+dubbo+nginx+mysql统合项目)总结(1)
我不会在这里贴代码和详细步骤什么的,我觉得就算我把它贴出来,你们照着步骤做还是会出很多问题,我推荐你们去看一下黑马的这个视频,我个人感觉很不错,一步一步走下来可以学到很多东西.另外,视频和相关文档的话 ...
- 图片异或(xor)getflag
题目地址:https://files.cnblogs.com/files/nul1/flag_enc.png.tar 这题是源于:网鼎杯minified 经过测试隧道红色最低通道异常.其余均正常.所以 ...
- mysql安装后开启远程
操作系统为centos7 64 1.修改 /etc/my.cnf,在 [mysqld] 小节下添加一行:skip-grant-tables=1 这一行配置让 mysqld 启动时不对密码进行验证 2. ...
- python魔法函数__dict__和__getattr__的妙用
python魔法函数__dict__和__getattr__的妙用 __dict__ __dict__是用来存储对象属性的一个字典,其键为属性名,值为属性的值. 既然__dict__是个字典那么我们就 ...