XV6 - Virtual Memory

内存保护,xv6 采用 x86 的分页机制。分段采用的是平坦模式,只是象征性的使用,所以逻辑地址=线性地址(虚拟地址),然后通过 MMU,将线性地址转换为物理地址。

分页步骤

要使用分页机制,需要做如下事情:

  1. 设置页目录表和页表(如果是扩展分页,只需要设置页目录表)
  2. 将页目录表的物理地址存放到 cr3 寄存器上
  3. 设置 cr0 寄存器的最高位(PG位),开启分页功能(只有在保护模式下才能设置)

xv6的过渡页表

xv6 被 boot loader加载到物理地址 0x10000(bootmain.c:bootmain()),内核做的第一件事情,就是启用分页(entry.S)。为了让分页能正常工作,需要设置虚拟地址空间到物理地址空间的映射,这样内核的代码才能正常工作。

xv6 的过渡页表采用扩展分页(大小4MB)1,所以 kernel 不能超过 4MB,严格意义上说是不能超过 4MB-64K,因为内核从 0x10000(64K)开始加载。过渡页表主要让分页后的内核能正常运行,主要是内存分配器的代码。后面 kernel 会重新设置页表。

过渡页表具体的映射关系在 main.c

__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,
};

一共有两个映射

|      VA      |     P   |
|--------------|---------|
|0 ~ 4MB       | 0 ~ 4MB |
|2GB ~ 2GB+4MB | 0 ~ 4MB |

entrypgdir第一项

启用分页后,内核接下来的指令地址还是物理地址,所以页表需要建立一个线性地址=物理地址的映射。分页后所有指令的地址都被看作线性地址,都需要通过 MMU 转换为物理地址。如果没有这个映射,分页后的第一条指令 movl $(stack + KSTACKSIZE), %es无法执行

第一项,直接把虚拟地址映射到相同的物理地址上

  // Map VA's [0, 4MB) to PA's [0, 4MB)
  [0] = (0) | PTE_P | PTE_W | PTE_PS,

entrypgdir第二项

物理内存可能比较小,所以 boot loader 把内核装载到 0x00100000 处,可以看到内核的 LMA=0x00100000。但是在进程的虚拟地址空间中,kernel 在 KERNBASE 之上(2GB之上),可以看到 kernel 的 VMA2 3 都在 KERNBASE 之上,也就是说分页后运行 kernel 指令时,指令的地址是 VMA,在高地址处。

# objdump -h kernel

kernel:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         000085e4  80100000  00100000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

页表第二项就是将内核映射到虚拟地址空间的高地址处。

// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
  [KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,

code & debug

分页启用前

设置断点 b * 0x0010000c,运行到此断点处,查看分页和寄存器信息

(qemu) info mem
PG disabled
(qemu) info pg
PG disabled
(qemu) info registers
...
CR0=00000011 CR2=00000000 CR3=00000000 CR4=00000010

可以看到分页没有启动,cr3 地址是 0。

启用分页

entry.S 中,内核启用分页

  • 设置页表是扩展分页
  • 存储页目录表的物理地址到 cr3 寄存器中
  • 设置 cr0 寄存器的 PG 位,开启分页

做完这三件事情后,分页功能启用,以后所有的地址都是线性地址,必须通过 MMU 转换,才能得到物理地址。

可以看到,mov %eax,%cr0 后分页开始启用,但是接下来的3条指令地址还是低地址,所以需要第一项映射。

(gdb) x/7i $eip
=> 0x10001a:	mov    %eax,%cr3
   0x10001d:	mov    %cr0,%eax
   0x100020:	or     $0x80010000,%eax
   0x100025:	mov    %eax,%cr0
   0x100028:	mov    $0x8010c650,%esp
   0x10002d:	mov    $0x80103889,%eax
   0x100032:	jmp    *%eax
(gdb) si
=> 0x10001d:	mov    %cr0,%eax
0x0010001d in ?? ()
(gdb) si
=> 0x100020:	or     $0x80010000,%eax
0x00100020 in ?? ()
(gdb) si
=> 0x100025:	mov    %eax,%cr0
0x00100025 in ?? ()
(gdb) si
=> 0x100028:	mov    $0x8010c650,%esp
0x00100028 in ?? ()
(gdb) si
=> 0x10002d:	mov    $0x80103889,%eax
0x0010002d in ?? ()
(gdb) si
=> 0x100032:	jmp    *%eax
0x00100032 in ?? ()
(gdb) si
=> 0x80103889 <main>:	push   %ebp
main () at main.c:19

要让内核在虚拟地址空间中,这里采用了 indirect jump,如果采用 direct jump,则 main.c 的指令地址不会变化,还是在低地址处。jmp *%eax后,指令地址变成高地址 0x80103889

此时页目录有两项内容,第 0 项是低地址映射,第 512(0x200)项是高地址映射,同时 cr0,cr3内容都变化了。

(qemu) info mem
0000000000000000-0000000000400000 0000000000400000 -rw
0000000080000000-0000000080400000 0000000000400000 -rw
(qemu) info pg
VPN range     Entry         Flags        Physical page
[00000-003ff]  PDE[000]     --S-A---WP 00000-003ff
[80000-803ff]  PDE[200]     --S-----WP 00000-003ff
(qemu) info registers
...
CR0=80010011 CR2=00000000 CR3=0010a000 CR4=00000010

物理内存分配器

下面分析的代码都在 main.c,vm.c,kalloc.c当中

数据结构

xv6 用链表表示空闲的物理内存,其中链表头节点是 struct kmem->freelist,链表节点是 struct run,链表的每个节点是空闲页物理内存的起始地址,但是链表存放的是虚拟地址,不是物理地址。

关于链表的操作主要有两个:

  1. 释放内存
    kalloc

    释放内存的函数是 kfree(char *v),首先将 char *v 开始的页物理内存初始化为1,然后将这空闲页物理内存加到链表头。

    还有一个辅助函数 freerange(void *vstart, void *vend) 用来释放 start->end 之间的内存。首先将 start 地址按4K对齐,然后释放物理内存直到 end 处,释放后的物理内存加到链表当中。

  2. 申请内存 kfree

    kalloc(void *)用来分配内存,功能很简单,就是从空闲链表头分配1页物理内存。

初始化内存操作

前面讲到,过渡页表有4MB,其中 kernel 占了一部分,剩下还有一部分物理内存。 main() -> kinit1() 将 kernel 之后的这部分物理内存释放掉,加到空闲物理内存的链表当中。

xv6 只能使用 PHYSTOP(240MB) 物理内存,4MB ~ 240MB的物理内存初始化由 main() -> kinit2() 完成,不过这个需要等到所有 CPU 都启动之后才能进行,所以这部分初始化需要加锁。

kernel页表

初始化 kernel 之后的物理内存,接下来 kvmalloc() 创建页表。第一步设置页目录项和页表,第二步切换4MB页表到新创建的页表。

数据结构

内核内存映射通过数组 kmap[] 表示,主要映射4段内存区域,最后的devices是直接映射的。根据映射关系,设置每一页的页表。

kmap[] = {
 { (void*)KERNBASE, 0,             EXTMEM,    PTE_W}, // I/O space
 { (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0},     // kern text+rodata
 { (void*)data,     V2P(data),     PHYSTOP,   PTE_W}, // kern data+memory
 { (void*)DEVSPACE, DEVSPACE,      0,         PTE_W}, // more devices
}

code & debug

setupkvm() 首先分配一页物理内存存放页目录,然后通过 mappages() 建立页表。这里主要涉及函数 walkpgdir(),目的是找到物理页在页表中存放的位置。

  • 根据虚拟内存的高10位,找到页表是在页目录的第几项,如果页表不存在,新建页表,并设置页表的属性。pde 就是页目录项的虚拟地址,* pde 是页目录项存放的内容,也就是页表的物理地址,pgtab 是转换后的虚拟地址。
  • 找到页表后,在根据虚拟地址的中间10位,找到页表项,也就是页存放在页表中的第几项,并返回页表项的虚拟地址。
  • 最后在 mappages() 中,将页的物理地址存放到页表项当中

这样就完成了虚拟地址到物理地址的映射,设置页目录项的内容(页表的物理地址),设置页表项的内容(页的物理地址)。设置完成后,switchkvm() 将新的页目录存放到 cr3 寄存器中,这样就切换到新的页表当中。

(qemu) info mem
0000000080000000-0000000080100000 0000000000100000 -rw
0000000080100000-0000000080109000 0000000000009000 -r-
0000000080109000-000000008e000000 000000000def7000 -rw
00000000fe000000-0000000100000000 0000000002000000 -rw
(qemu) info pg
VPN range     Entry         Flags        Physical page
[80000-803ff]  PDE[200]     ----A--UWP
  [80000-800ff]  PTE[000-0ff] --------WP 00000-000ff
  [80100-80106]  PTE[100-106] ---------P 00100-00106
  [80107-80107]  PTE[107]     ----A----P 00107
  [80108-80108]  PTE[108]     ---------P 00108
  [80109-8010b]  PTE[109-10b] --------WP 00109-0010b
  [8010c-8010c]  PTE[10c]     ----A---WP 0010c
  [8010d-803ff]  PTE[10d-3ff] --------WP 0010d-003ff
[80400-8dfff]  PDE[201-237] -------UWP
  [80400-8dfff]  PTE[000-3ff] --------WP 00400-0dfff
[fe000-fffff]  PDE[3f8-3ff] -------UWP
  [fe000-fffff]  PTE[000-3ff] --------WP fe000-fffff

可以看到页目录共有3项,因为页目录项通过通用函数 walkpgdir 创建,所以页表的 U 标志位是打开的。而页表项是根据映射关系设置的,所以页的 U 是关闭的。

Reference