第六课 虚拟内存
提纲
- 地址空间
- 分页硬件
虚拟内存概览
虚拟内存要解决什么问题?
考虑这样一个问题,如果shell出现了bug:可能向随机的内存地址写数据.这时候我们必须保护内核和其他进程不被破坏,我们要怎么做?
解决方案: 隔离内存空间
- 每个进程有专属于自己的内存空间
- 进程可以读写自己的内存
- 进程不能读写其他进程的内存或者内核内存空间
面临的挑战: 如何在一块物理内存上,实现这样的隔离.
分页硬件
- xv6, jos甚至是Linux kernel都是通过x86提供的硬件分页功能,实现了内存隔离.
- 分页硬件本质上是提供了一种间隔寻址的功能.
CPU -> MMU -> RAM VA PA
- 软件或者更直接一点CPU执行指令操作的都是虚拟地址,而不是物理地址.
- 内核在MMU中为每个进程分别保存了虚拟地址和物理地址的对应关系.
- MMU本质上就是一个表,索引是虚拟地址,值是一个特定结构,被称为page table.
- 通过MMU的flags标志位,可以限制每个进程能够访问的内存空间.
x86分页大小
- 每页大小为: 4KB.
- 内存地址都是4KB对齐的
- 因此page table可以使用虚拟地址中的前20bit就可以定位一个内存页.
PTE的组成
- 参考资料: x86_translation_and_registers.pdf
- PTE前20bit代表对应页的物理地址,也被称为physical page number,简写为PPN.
- MMU在将虚拟地址转换为物理地址的过程中,会将前20位虚拟地址替换为PPN.
- 低位的12bit是标志位,表示存在,可写,用户进程是否可以访问等等.
如果页表仅由PTE组成,是否可行?
我们来计算这样一个页表的大小:
- 索引项总数为2^20, 每项大小32bit.总计消耗内存为4MB.
- 这对于早期的计算而已,消耗太大.
- 事实上,这样的设计存在巨大的浪费.因为很多程序仅仅需要分配数百页内存.没必要在一开始就分配这么大的页表.
x86 二级页表
- 第一级是page directory(PD),简写为PDE.
- PDE本身组成一个1024*32的表,一共占用4KB内存.
- PDE高20位为PPN,指向一个PTE表.低12位为标志位.
- 正是因为PDE存在标志位,因此可以节约PTE表的空间.
- PTE表同样是一个1024*32的表,每个索引项可以指向1个内存页.
MMU定位page table在内存中的位置
- %cr3保存了page directory table的物理地址
- page directory table保存了PTE的物理地址.
- 这些地址在物理内存中可以是离散.
虚拟地址转换为物理地址
- 目标就是找到虚拟地址对应的PTE.
- %cr3指向PD所在的物理地址.
- 通过虚拟地址的前10位索引PD,得到PT所在的物理地址.
- 通过虚拟地址接下来的10位,索引PT得到PTE.
- 通过PTE的PPN和虚拟地址的低12位,得到最终的物理地址.
PTE标志位
- 标志位包括P, W, U等等
- xv6通过标志位U来阻止用户访问内核内存.
被访问PTE的标志位P未设置, 或存储时标志位W未设置
- MMU硬件将会产生中断,抛出page fault异常
- 处理中断时,CPU将会保存现场,也就是当前寄存器的值.同时将控制权移交给内核.
- 一种处理方式是: 内核将会生成错误信息,同时杀掉异常进程.这种情况在我们刚开始学习写代码的时候很常见,尤其是做指针相关的练习的时候.
- 另一种处理方式是: 如果是被置换到硬盘的内存,内核会将内存内容恢复,并更新PTE的内容.接着恢复异常进程.这种情况在Linux Kernel中经常会见到.
为什么要在虚拟地址和物理地址间建立映射关系,而不是基于起始地址做位移?
- 避免碎片化
- fork写时拷贝.
- 延迟分配,即真正要用到的时候,在实际分配. ...
为什么在内核中也使用虚拟内存?
- 出于隔离和保护的目的,很明显在用户空间使用虚拟内存是非常应该的.但是为什么在内核中,也要使用虚拟内存呢?内核可以直接使用物理内存运行么?
- 内核可以直接使用物理内存运行,比如Singularity内核.
- 以下是大多数现代内核使用虚拟内存的原因,其中一些不太站得住脚,有些有点道理,但是并没有什么不得不使用虚拟内存的原因.
- 分页硬件难以关闭,比如每次系统调用的时候,内核处理前都要关闭分页硬件,在返回用户空间前,打开.这样比较繁琐.
- 便于内核使用用户空间地址,比如通过系统调用将用户空间地址传递到内核中.但这同时减弱了内核和用户空间之间的隔离性.
- 不易于产生内存碎片.比如我们想分配64KB内存,但是物理内存中并没有连续的64KB,使用虚拟内存可以将不连续的物理内存映射成连续的虚拟内存.
- 内核必须兼容大多数硬件平台,它们的物理内存分布各式各样.通过虚拟内存可以屏蔽这些差异.
学习案例: xv6中的x86分页硬件
xv6 进程地址空间
0x00000000:0x80000000 -- user addresses below KERNBASE
0x80000000:0x80100000 -- map low 1MB devices (for kernel)
0x80100000:? -- kernel instructions/data
? :0x8E000000 -- 224 MB of DRAM mapped here
0xFE000000:0x00000000 -- more memory-mapped devices
xv6 物理内存映射关系
- 重点关注用户空间的内存页其实被映射了两次.在内核页表中,被连续映射到了内核地址的高地址上.
- 在用户空间申请时,内核正是根据自己申请到的虚拟地址减去内核地址基址kernel base,才得到了PPN.
每个进程都有独立的内存空间和页表
- 所有进程共享相同的内核页表映射.
- 内核切换进程时,同样会通过设置%cr3来切换进程页表.
地址空间内存布局方式的优点
- 每个用户进程的虚拟地址都是从0开始,当然每个进程起始的虚拟地址对应着不同的物理页.
- 用户进程的堆空间占据大约2GB的连续虚拟地址空间,当然对应的物理地址不必是连续的,这样就解决了内存碎片的问题.
- 内核内存和用户内存都映射在同一份页表中.因此中断,系统调用时,切换用户/内核非常方便.
- 所有进程对内核空间的映射都是相同的,这样在内核中切换进程时非常方便.
- 同3,内核访问用户空间内存很方便,很容易读取系统调用所需要的参数.
- 便于内核从虚拟地址映射到物理地址,物理地址x映射到虚拟地址就是x+0x80000000.
本方案下,进程可以操纵的最大内存是多少?
答: 最大内存为2GB
可以通过调整内存基址0x80000000来增大最大内存空间么?
答: 不能,因为本方案下一页物理内存同时被映射到用户空间和内核空间.而虚拟地址的寻址总线为32bit,总寻址范围是4GB.二分后就是2GB.
内核必须将所有物理内存映射到自己的虚拟地址空间么?
答: 不是必须的,否则在32bit时代一般电脑能支持的内存怎么会到4GB呢? 本质上,内核只需要知道新分配的内存页的物理地址即可.
深入x86 虚拟内存 相关代码
我们再次回到我们的第一个进程,来看看虚拟内存到底在代码层面是怎么应用的.我们的代码浏览从main.c/main()
开始.
重点函数
setupkvm() mappages() walkpgdir() switchkvm() inituvm() switchuvm()
内核页表初始化
函数kinit1()
- 初始化内核内存管理结构kmem
- 将内核结束处end到4MB部分的地址纳入kmem管理结构.
- end的地址是在kernel.ld文件中生成的.
函数kvmalloc()
- 在进入
main()
时,我们使用的页表是在entry.S
中设置的entrypgdir.这个映射只是为了临时使用. - 调用
setupkvm
生成一个内核页表,并保存到全局变量pgdir. - 调用
switchkvm
切换到padir.
函数setupkvm()
- 生成了包含内核的页表.
- 页表定义信息为
vm.c/kmap[]
- 通过函数
mappages()
来设置页表中的值.
函数mappages()
- 为虚拟地址va和物理地址pa之间,在pgdir中建立PTE映射.映射内存大小为size, 权限为perm.
- 本函数接受5个参数,分别是页表的虚拟地址,被分配内存的虚拟起始地址, 内存的大小, 物理内存的起始地址, 内存页的权限.
- 注意这里va和size可能不是对齐的.
函数walkpgdir()
- 本函数接受3个参数, 一级页表虚拟地址, 查询的虚拟地址, 是否新建二级页表.返回值为PTE的虚拟地址.
- 首先通过va在一级页表中查询二级页表的地址,然后判断二级页表是否已经存在.
- 若不存在,则新建一个二级页表.然后将二级页表纳入一级页表管理.
- 返回二级页表中,查询的虚拟地址对应的PTE的虚拟地址.
函数switchkvm()
- 通过设置%cr3寄存器的值,切换到内核专用页表.
系统调用sbrk()
malloc()
将会调用系统调用sbrk()
来分配内存.- 每个进程都有自己的内存大小,内核可以分配新的物理内存给进程,从而增大进程的内存大小.
sbrk()
将会分配物理内存.- 新分配的物理内存将会映射到进程的页表.
- 返回值为 新分配进程的虚拟地址.
函数growproc()
- proc->sz是进程的当前内存大小.
allocuvm()
将完成大部分工作switchuvm()
将%cr3设置为新的页表,同时也强制刷新MMU.
函数allocuvm()
- 高于
KERNBASE
是内核的内存空间,检查不通过. - 分配内存向高地址增长,若newsz小于oldsize,检查不通过.
- 分配是以整页为单位,因此需要
PGROUNDUP
. - 逐页分配物理页,并映射到进程的虚拟内存空间.