内存保护,xv6 采用 x86 的分页机制。分段采用的是平坦模式,只是象征性的使用,所以逻辑地址=线性地址(虚拟地址),然后通过 MMU,将线性地址转换为物理地址。
分页步骤
要使用分页机制,需要做如下事情:
- 设置页目录表和页表(如果是扩展分页,只需要设置页目录表)
- 将页目录表的物理地址存放到
cr3
寄存器上 - 设置
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
,链表的每个节点是空闲页物理内存的起始地址,但是链表存放的是虚拟地址,不是物理地址。
关于链表的操作主要有两个:
-
释放内存
释放内存的函数是
kfree(char *v)
,首先将char *v
开始的页物理内存初始化为1,然后将这空闲页物理内存加到链表头。还有一个辅助函数
freerange(void *vstart, void *vend)
用来释放start->end
之间的内存。首先将start
地址按4K对齐,然后释放物理内存直到end
处,释放后的物理内存加到链表当中。 -
申请内存
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
是关闭的。