第六课 虚拟内存

提纲

  1. 地址空间
  2. 分页硬件

虚拟内存概览

虚拟内存要解决什么问题?

考虑这样一个问题,如果shell出现了bug:可能向随机的内存地址写数据.这时候我们必须保护内核和其他进程不被破坏,我们要怎么做?

解决方案: 隔离内存空间

  1. 每个进程有专属于自己的内存空间
  2. 进程可以读写自己的内存
  3. 进程不能读写其他进程的内存或者内核内存空间

面临的挑战: 如何在一块物理内存上,实现这样的隔离.

分页硬件

  1. xv6, jos甚至是Linux kernel都是通过x86提供的硬件分页功能,实现了内存隔离.
  2. 分页硬件本质上是提供了一种间隔寻址的功能.
    CPU -> MMU -> RAM
       VA     PA
    
  3. 软件或者更直接一点CPU执行指令操作的都是虚拟地址,而不是物理地址.
  4. 内核在MMU中为每个进程分别保存了虚拟地址和物理地址的对应关系.
  5. MMU本质上就是一个表,索引是虚拟地址,值是一个特定结构,被称为page table.
  6. 通过MMU的flags标志位,可以限制每个进程能够访问的内存空间.

x86分页大小

  1. 每页大小为: 4KB.
  2. 内存地址都是4KB对齐的
  3. 因此page table可以使用虚拟地址中的前20bit就可以定位一个内存页.

PTE的组成

  1. 参考资料: x86_translation_and_registers.pdf
  2. PTE前20bit代表对应页的物理地址,也被称为physical page number,简写为PPN.
  3. MMU在将虚拟地址转换为物理地址的过程中,会将前20位虚拟地址替换为PPN.
  4. 低位的12bit是标志位,表示存在,可写,用户进程是否可以访问等等.

如果页表仅由PTE组成,是否可行?

我们来计算这样一个页表的大小:

  1. 索引项总数为2^20, 每项大小32bit.总计消耗内存为4MB.
  2. 这对于早期的计算而已,消耗太大.
  3. 事实上,这样的设计存在巨大的浪费.因为很多程序仅仅需要分配数百页内存.没必要在一开始就分配这么大的页表.

x86 二级页表

  1. 第一级是page directory(PD),简写为PDE.
  2. PDE本身组成一个1024*32的表,一共占用4KB内存.
  3. PDE高20位为PPN,指向一个PTE表.低12位为标志位.
  4. 正是因为PDE存在标志位,因此可以节约PTE表的空间.
  5. PTE表同样是一个1024*32的表,每个索引项可以指向1个内存页.

MMU定位page table在内存中的位置

  1. %cr3保存了page directory table的物理地址
  2. page directory table保存了PTE的物理地址.
  3. 这些地址在物理内存中可以是离散.

虚拟地址转换为物理地址

  1. 目标就是找到虚拟地址对应的PTE.
  2. %cr3指向PD所在的物理地址.
  3. 通过虚拟地址的前10位索引PD,得到PT所在的物理地址.
  4. 通过虚拟地址接下来的10位,索引PT得到PTE.
  5. 通过PTE的PPN和虚拟地址的低12位,得到最终的物理地址.

PTE标志位

  1. 标志位包括P, W, U等等
  2. xv6通过标志位U来阻止用户访问内核内存.

被访问PTE的标志位P未设置, 或存储时标志位W未设置

  1. MMU硬件将会产生中断,抛出page fault异常
  2. 处理中断时,CPU将会保存现场,也就是当前寄存器的值.同时将控制权移交给内核.
  3. 一种处理方式是: 内核将会生成错误信息,同时杀掉异常进程.这种情况在我们刚开始学习写代码的时候很常见,尤其是做指针相关的练习的时候.
  4. 另一种处理方式是: 如果是被置换到硬盘的内存,内核会将内存内容恢复,并更新PTE的内容.接着恢复异常进程.这种情况在Linux Kernel中经常会见到.

为什么要在虚拟地址和物理地址间建立映射关系,而不是基于起始地址做位移?

  1. 避免碎片化
  2. fork写时拷贝.
  3. 延迟分配,即真正要用到的时候,在实际分配. ...

为什么在内核中也使用虚拟内存?

  1. 出于隔离和保护的目的,很明显在用户空间使用虚拟内存是非常应该的.但是为什么在内核中,也要使用虚拟内存呢?内核可以直接使用物理内存运行么?
  2. 内核可以直接使用物理内存运行,比如Singularity内核.
  3. 以下是大多数现代内核使用虚拟内存的原因,其中一些不太站得住脚,有些有点道理,但是并没有什么不得不使用虚拟内存的原因.
    • 分页硬件难以关闭,比如每次系统调用的时候,内核处理前都要关闭分页硬件,在返回用户空间前,打开.这样比较繁琐.
    • 便于内核使用用户空间地址,比如通过系统调用将用户空间地址传递到内核中.但这同时减弱了内核和用户空间之间的隔离性.
    • 不易于产生内存碎片.比如我们想分配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 物理内存映射关系

  1. 重点关注用户空间的内存页其实被映射了两次.在内核页表中,被连续映射到了内核地址的高地址上.
  2. 在用户空间申请时,内核正是根据自己申请到的虚拟地址减去内核地址基址kernel base,才得到了PPN.

每个进程都有独立的内存空间和页表

  1. 所有进程共享相同的内核页表映射.
  2. 内核切换进程时,同样会通过设置%cr3来切换进程页表.

地址空间内存布局方式的优点

  1. 每个用户进程的虚拟地址都是从0开始,当然每个进程起始的虚拟地址对应着不同的物理页.
  2. 用户进程的堆空间占据大约2GB的连续虚拟地址空间,当然对应的物理地址不必是连续的,这样就解决了内存碎片的问题.
  3. 内核内存和用户内存都映射在同一份页表中.因此中断,系统调用时,切换用户/内核非常方便.
  4. 所有进程对内核空间的映射都是相同的,这样在内核中切换进程时非常方便.
  5. 同3,内核访问用户空间内存很方便,很容易读取系统调用所需要的参数.
  6. 便于内核从虚拟地址映射到物理地址,物理地址x映射到虚拟地址就是x+0x80000000.

本方案下,进程可以操纵的最大内存是多少?

答: 最大内存为2GB

可以通过调整内存基址0x80000000来增大最大内存空间么?

答: 不能,因为本方案下一页物理内存同时被映射到用户空间和内核空间.而虚拟地址的寻址总线为32bit,总寻址范围是4GB.二分后就是2GB.

内核必须将所有物理内存映射到自己的虚拟地址空间么?

答: 不是必须的,否则在32bit时代一般电脑能支持的内存怎么会到4GB呢? 本质上,内核只需要知道新分配的内存页的物理地址即可.

深入x86 虚拟内存 相关代码

我们再次回到我们的第一个进程,来看看虚拟内存到底在代码层面是怎么应用的.我们的代码浏览从main.c/main()开始.

重点函数

setupkvm() mappages() walkpgdir() switchkvm() inituvm() switchuvm()

内核页表初始化

函数kinit1()

  1. 初始化内核内存管理结构kmem
  2. 将内核结束处end到4MB部分的地址纳入kmem管理结构.
  3. end的地址是在kernel.ld文件中生成的.

函数kvmalloc()

  1. 在进入main()时,我们使用的页表是在entry.S中设置的entrypgdir.这个映射只是为了临时使用.
  2. 调用setupkvm生成一个内核页表,并保存到全局变量pgdir.
  3. 调用switchkvm切换到padir.

函数setupkvm()

  1. 生成了包含内核的页表.
  2. 页表定义信息为vm.c/kmap[]
  3. 通过函数mappages()来设置页表中的值.

函数mappages()

  1. 为虚拟地址va和物理地址pa之间,在pgdir中建立PTE映射.映射内存大小为size, 权限为perm.
  2. 本函数接受5个参数,分别是页表的虚拟地址,被分配内存的虚拟起始地址, 内存的大小, 物理内存的起始地址, 内存页的权限.
  3. 注意这里va和size可能不是对齐的.

函数walkpgdir()

  1. 本函数接受3个参数, 一级页表虚拟地址, 查询的虚拟地址, 是否新建二级页表.返回值为PTE的虚拟地址.
  2. 首先通过va在一级页表中查询二级页表的地址,然后判断二级页表是否已经存在.
  3. 若不存在,则新建一个二级页表.然后将二级页表纳入一级页表管理.
  4. 返回二级页表中,查询的虚拟地址对应的PTE的虚拟地址.

函数switchkvm()

  1. 通过设置%cr3寄存器的值,切换到内核专用页表.

系统调用sbrk()

  1. malloc()将会调用系统调用sbrk()来分配内存.
  2. 每个进程都有自己的内存大小,内核可以分配新的物理内存给进程,从而增大进程的内存大小.
  3. sbrk()将会分配物理内存.
  4. 新分配的物理内存将会映射到进程的页表.
  5. 返回值为 新分配进程的虚拟地址.

函数growproc()

  1. proc->sz是进程的当前内存大小.
  2. allocuvm()将完成大部分工作
  3. switchuvm()将%cr3设置为新的页表,同时也强制刷新MMU.

函数allocuvm()

  1. 高于KERNBASE是内核的内存空间,检查不通过.
  2. 分配内存向高地址增长,若newsz小于oldsize,检查不通过.
  3. 分配是以整页为单位,因此需要PGROUNDUP.
  4. 逐页分配物理页,并映射到进程的虚拟内存空间.

results matching ""

    No results matching ""