XV6

XV6 通过页表机制实现了对物理内存的管理。进程使用的地址都是逻辑地址,32 位机器的逻辑地址范围在 0~4GB,XV6 内存管理的代码从功能上分两部分:

  1. 内存系统的初始化。
  2. XV6 操作系统正常运行时的内存管理代码。

根据物理页帧和虚拟存储空间的不同,又分成:

  1. 物理页帧的分配、回收管理。
  2. 虚存空间分配、回收以及映射管理。

其中虚存空间又根据保护级的不同,分成内核空间和用户空间两部分。

下面将物理空间的页称为页帧,虚拟空间的页成为虚页

1. 页表机制

在 X86 体系下,有三种地址,程序中各种符号地址为虚拟地址,CPU 实际访问的内存地址为物理地址。

  1. 虚拟地址可分成多段,例如数据段、代码段、动态内存段等等。
  2. 每一段在逻辑上是线性的,称为线性地址。
  3. 段的存储在实现上使用分页机制,因此物理上段可能是不连续的。

在 XV6 中,除了每个 CPU 独立的数据具有非零的段基址外,其余包括内核数据段,内核代码段,用户数据段,用户代码段都是段基址为 0 的描述符,这样大大简化了地址转换操作和程序的编程,因为分段机制是由程序员控制,而分页机制是由操作系统负责的,在 XV6 中,虚拟地址可以直接通过分页变换到物理地址。

分页机制是通过页表来进行转换的,具体转换关系如下:

x86 page table

X86 所有的虚拟地址都经过页表来完成地址转换,页表由 CR3 寄存器指定的物理地址来表示,内存地址转换单元 MMU(memory management unit) 通过查找页表来确定最后的物理地址,通过给 CR3 赋值便能实现不同进程拥有不同的页表,也就是不同进程拥有不同的地址空间。

页表由一级的页目录项和二级的页表项组成,每个页目录项下级有 1024 个连续的页表项(每个页表项 4 Byte,刚好占用 4 K 空间,也就是一页),页目录项同时也是连续的,一共有 1024 个页目录项,由于 32 位系统地址线只有 32 条,所以最高支持 4 G 的地址空间,页目录项也是连续的,所以页目录刚好也占用一页。

每个页目录项和页表项由下一级的物理地址和相关标志位组成,页目录项拥有下一级页表的物理地址,页表项拥有实际物理地址的部分,通过设置权限位可以实现内核和用户进程代码和数据的保护。

虚拟地址前十位作为页目录的偏移找到页目录项,找到页表基地址,接下来十位作为页表项的偏移找到页表项,最后将页表项的基址 + 最后 12 位偏移得到实际的物理地址。

2. 页帧初始化

物理内存的初始化分两步:

  1. 早期布局,先分配 [0, 4MB) ,然后启动分页机制映射到整个物理空间。其中 [0x100000, PHYSTOP] 为可用物理内存。
  2. 启动分页后的空闲物理页帧初始化。也就是将可用物理内存中的空闲页帧串成一个链表,一开始 [end, PHYSTOP) 都是空闲页帧。

在初始化 main() 函数最开始处,内核代码存在于物理地址低地址的 0x100000 处,页表为 main.c 中的 entrypgdir[] 数组,其中虚拟地址 [0, 4MB) 映射物理地址 [0,4MB),虚拟地址 [KERNBASE, KERNBASE+4MB) 映射到物理地址 [0, 4MB)。可见现在内核实际能用的虚拟空间显然不足以完成正常工作的,所以初始化过程中需要重新设置页表。

### 2.1 物理内存初始化

XV6 在 main() 函数中调用 kinit1()kinit2() 来初始化物理内存,kinit1() 回收 [end, 4 M] 的空闲物理内存(end 是内核结束的地方),记录在物理页帧空闲链表。

kinit1(end, P2V(4*1024*1024)); // phys page allocator

kinit2() 回收 [4MB, PHYSTOP) 的空闲物理内存,也记录到物理页帧空闲链表。

kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()

两者的区别在于:

  1. 调用 kinit1() 时正使用最初的页表(也就是上面的内存布局),所以只能初始化 4 M,其实 kinit1() 收集的空闲物理内存正好可以分配给页表用。启动分页机制前需要分配页表,这就构成了自举问题,XV6 通过在 main() 函数最开始时 kinit1() 收集的空闲物理内存解决,由于在最开始时多核 CPU 还未启动,所以没有设置锁机制。
  2. kinit2() 在内核构建了新页表后,能够访问内核的整个虚拟地址空间,所以在这里初始化所有物理内存,并开始了锁机制保护空闲内存链表。

2.2 内核新页表初始化

当使用 entrypgdir 作为页表建立起一些空闲内存的时候,主函数立即调用 kvmalloc() 函数来实现内核新页表 kpgdir[] 的初始化。大致有下面几个步骤

  1. 申请一个页帧存储页表目录,利用 kalloc() 函数。

  2. 利用 kmap 的布局构建页表目录布局。这里使用了 mappages() 函数。

    我们查看 kmap 的第三项如下, (void*)data 就是空间的起始地址,V2P(data) 是物理空间的起始地址,PHYSTOP 是物理空间的结束地址,所以 sizePHYSTOP-V2P(data) ,最后一个是权限,PTE_W 表示可写。

    { (void*)data,     V2P(data),     PHYSTOP,   PTE_W}, // kern data+memory
    
  3. 为布局好的页表目录的每一项映射一个页表,这里调用了 walkpgdir() 函数。

    给你一个逻辑地址(一般是页表下边界),然后查看 pgdir[] 是否有相关的页表,如果 alloc 为 1,那么就给页表目录项分配相应的页表。内核页表目录全部页表项都要分配相应的页表,因为内核要映射整个物理空间,这样才可以进行内存管理。

virtual memory layout

3. 物理内存管理

XV6 对上层提供 kalloc()kfree() 接口来管理物理内存,上层无需知道具体的细节,具体实现看 kalloc.ckalloc() 返回虚拟地址空间的地址,kfree() 以虚拟地址为参数,通过 kalloc()kfree 能够有效管理物理内存,让上层只需要考虑虚拟地址空间。

XV6 通过将未分配的内存构成一个简单的链表来管理物理内存,具体看 kalloc.c#L20。XV6 使用了空闲物理内存的前 4 个字节作为指针域来指向下一页空闲内存,物理内存管理是以页(4 K)为单位进行分配的。也就是说物理内存空间上空闲的每一页,都有一个指针域(虚拟地址)指向下一个空闲页,最后一个空闲页的指针域为 NULL。 通过这种方式,只需要保存着虚拟地址空间上的 freelist 地址即可,kalloc()kfree() 操作的地址都是虚拟地址,需要通过页表来完成到物理地址的转换。

通过 kalloc()kfree(),屏蔽了对物理内存的管理,使得调用者只需要关心虚拟地址空间,在需要使用新内存空间的时候直接调用 kalloc(),在需要释放内存空间的时候直接调用 kfree()

4. 内存管理函数

XV6 通过提供几个接口来实现内核页表的控制和用户页表的控制,XV6 让每个进程都有独立的页表结构,在切换进程时总是需要切换页表,内核空间切换由 switchkvm() 实现,用户空间切换由 switchuvm() 实现。

页表和内核栈都是每个进程独有的,XV6 使用结构体 proc 将它们统一起来,在进程切换的时候,他们也往往随着进程切换而切换,内核中模拟出了一个内核线程,它独占内核栈和内核页表kpgdir,它是所有进程调度的基础。

switchuvm() 通过传入的 proc 结构负责切换相关的进程独有的数据结构,其中包括 TSS 相关的操作,然后将进程特有的页表首地址载入 CR3 寄存器,设置进程相关的虚拟地址空间环境。

进程的页表在使用前往往需要初始化,其中必须包含内核代码的映射,这样进程在进入内核时便不需要再次切换页表,进程使用虚拟地址空间的低地址部分,高地址部分留给内核,设置页表时通过调用 setupkvm()allocuvm()deallocuvm() 接口完成相关操作,均在 vm.c 中实现。

setupkvm() 通过 kalloc() 分配一页内存作为页目录,然后将按照 kmap() 数据结构映射内核虚拟地址空间到物理地址空间。

allocuvm()deallocuvm() 负责完成用户进程的内存空间,allocuvm() 在设置页表的同时还会分配物理内存供用户进程使用。这里分配的内存在虚拟空间上是连续的,XV6 通过放缩来实现堆的分配和回收。

vm.c 文件中提供了 loaduvm() 函数将文件系统上的 i 节点内容读取载入到相应的地址上,通过allocuvm() 函数为用户进程分配内存和设置页表,然后调用 loaduvm() 函数将文件系统上的程序载入到内存,便能够为 exec() 系统调用提供接口,为用户进程的正式运行做准备。

vm.c 中还有一个 inituvm() 函数,为第一个进程所使用,通过调用它能够初始化虚拟地址为 0 的 initcode.S 的虚拟地址环境,initcode.S 是独立于内核编译和链接的,它的加载地址和运行地址都为 0。

当进程销毁需要回收内存时,可以调用 freevm() 清除用户进程相关的内存环境,freevm() 首先调用 deallocuvm()[0, KERNBASE) 的虚拟地址空间回收,然后销毁整个进程的页表。

vm.c 中,copyuvm() 负责复制一个新的页表并分配新的内存,新的内存布局和旧的完全一样, XV6 使用这个函数作为 fork 的底层实现。

vm.c 的最后,还有两个函数,其中 uva2ka() 将一个用户地址转化为内核地址,也就是通过用户地址找到对应的物理地址。

copyout() 则调用 uva2ka() 则拷贝 p 地址 len 字节到用户地址 va 中。

5. 总结

XV6 对于物理内存的管理较为简单,只是将每一空闲页用链表链接起来,向上提供 kallockfree 接口来屏蔽管理物理内存的细节,XV6 将内存管理分为内核地址空间管理和用户地址空间管理,并提供几个函数供系统调用过程调用,很多需要管理内存的系统函数例如 exec()fork() 都需要使用到这些接口,vm.ckalloc.c 包含了内存管理的大部分内容,系统调用过程使用这些函数来初始化和处理页表结构。