【MIT CS6.828】Lab 2: Memory Management - Part 2: Virtual Memory
Part 2: Virtual Memory
1. 页式、段式、段页式
虚拟内存管理的意义在于,它可以将计算机可使用的内存范围从物理内存扩展到物理内存+磁盘。某一时刻,一个虚拟内存地址对应的数据可能并不实际存储在物理内存中。当然,CPU能直接访问的只有物理内存,若遇到了上述情况,首先从磁盘中将相关的页调入物理内存,再进行访问。
虚拟内存管理方式有三种:页式管理、段式管理、段页式管理。
-
页式管理:与前文对物理内存的管理方式相同,就是以一个固定大小将内存划分为若干页进行管理。
-
段式管理:在Lab1 Part2的3.2节已经简单介绍了如何由段内偏移量和段选择器得出完整的内存地址。与页式管理直接按固定大小分页不同,段式管理分段的依据是程序中各部分的功能,如代码段、数据段等。
-
段页式管理:对于一个程序,先分段,后分页。如代码段长度有 20KB,可划分为 5 页。地址转换也相应复杂了起来。
JOS 使用分页机制来实现虚拟内存和保护,但在 x86 上无法禁用段转换和基于段的保护,因此需要对段式管理有基本了解。
2. 虚拟地址、线性地址、物理地址
- 虚拟地址:由段选择器和段内偏移量构成的地址
- 线性地址:经过段式地址转换后得到的地址
- 物理地址:线性地址进一步经过页式地址转换后得到的地址,它明确地指向一个实际存在的物理内存单元。
在 JOS 中,切换为保护模式之后,无法直接用线性地址和物理地址访问数据,所有的地址都被视为虚拟地址。
JOS 内核经常需要对地址本身进行操作,为了区分,定义了以下两种类型,它们实际上都是32位整型:
uintptr_t
:虚拟地址physaddr_t
:物理地址
int x = 1;
int *p = &x;
uintptr_t addr = 0xf0112f4c; // 是p的值,复现本代码需根据实际情况修改
cprintf("*p = %d\np = %x\naddr = %x\n", *p, p, addr);
// *p = 1
// p = f0112f4c
// addr = f0112f4c
需要注意的是,addr
只是一个普通的 32 位整数,并非int*
类型,因此无法使用解引用(derefference)运算符*addr
。但这个addr
又确实是一个地址,要想访问这个地址对应的数据,可以将addr
强制转换为int*
类型后再解引用:
cprintf("*addr = %d\n", *(int*)addr);
// *addr = 1
但对于physaddr_t
类型,不能使用类型转换来解引用,实际访问的地址可能与预期不符。
总结:在 Lab 2 中,将会接触和使用的保存地址的类型如下表所示:
C type | 保存的地址类型 |
---|---|
T* (int*, char*, double*, etc.) | 虚拟地址 |
uintptr_t | 虚拟地址 |
physaddr_t | 物理地址 |
Question
-
假设以下内核代码是正确的,
x
应该是什么类型?uintptr_t
orphysaddr_t
?mystery_t x; char* value = return_a_pointer(); *value = 10; x = (mystery_t) value;
x
应该是uintptr_t
。value
为char*
,它保存了对应于char
的一个虚拟地址,要保证正确,只能强制转换为同为保存虚拟地址的uintptr_t
类型。
既然 C 代码中只能用虚拟地址,JOS 内核又如何访问那些只知道物理地址的内存?如PageInfo* pages
的内存分配需要在物理内存上直接进行。
事实上,在 Lab1 Part3 就已经预先做好了这项工作:通过手写的、静态初始化的页目录和页表来完成对前 4MB 的物理地址到虚拟地址的转换,如物理地址0
映射为虚拟地址0xf0000000
。
在代码中两者之间的相互转换可以用以下两个宏定义(练习 1 已经用过了):
vir_addr = KADDR(phy_addr)
phy_addr = PADDR(vir_addr)
3. 引用计数
struct PageInfo
结构中定义了pp_ref
用于引用计数。它用于同时将同一个物理页面映射到多个虚拟地址。
例如,有多个进程需要共用物理页面phy_page
,在它们各自的地址空间里,phy_page
所对应的虚拟地址都是不一样的。此时的pp_ref
就是在记录有多少个进程在使用phy_page
。若pp_ref
为0
,则该物理页所占用的内存可以回收。
严格来说,pp_ref
应该等于该物理页在所有页表中UTOP
以下出现的次数。(UTOP
以上的页表项由内核在启动时设置,且永远不释放,所以没必要纳入计数)
page_alloc
返回值的pp_ref
应为 0。当调用者对页面进行了其他操作,如page_insert
时,pp_ref
才会递增。
4. 页表管理
这里要先知道页表是什么。以下为xv6 - Charpter2.Page Table的原文翻译,有部分修改以增强可读性:
x86 指令(包括内核态和用户态)使用虚拟地址。但计算机的RAM,或者说,物理内存,是用物理地址作为索引的。x86 页表硬件将每个虚拟地址(32 bit)一一映射为物理地址(32 bit)。
一个 x86 页表在逻辑上是一个包含 2^20 个页表项(page table entry, PTE)的数组。每个页表项包含一个 20 bit 长的物理页号(physical page number, PPN)以及一些标志位,如下图所示:
Paging hardware(我习惯的称呼是地址变换机构)会取出一个虚拟地址的前 20 bit 作为索引,在页表中找到对应的页表项,并用该页表项的物理页号代替这 20 bit,余下的 12 bit 原封不动,就得到了对应的物理地址。这也意味着,一页的大小为 2^12 字节。
实际上,页式地址变换一共有 2 步。所有的页表以一棵 2 层的树(二级页表)的形式组织起来存储在物理内存中。
- 树的根结点称为页目录,总大小为 4096 字节,包含 1024 个条目(page directory entry, PDE)。每个 PDE 对应一个页表,每个页表都是一个包含 1024 个页表项的数组,每个页表项 32 bit.
- Paging hardware 取出虚拟地址的前 10 bit 作为索引,在页目录中找到对应页表;再取接下来 10 bit 作为索引,在页表中找到对应页表项。如果找不到页表或者找不到页表项,Paging hardware 会报告出错。
为了动态更新和维护页表,还需要记录各页表项对应页的一些信息,因此每个页表项都有一些标志位。最基本地,需要知道页是否允许读、允许写等。常用标志位如:
- PTE_P:指示该页是否存在(于物理内存中)。若为
0
,则报告出错。 - PTE_W:是否允许写该页。若为
0
,则页是只读的。 - PTE_U:用户程序能否使用该页。若为
0
,只有内核程序可以使用。
练习 4. 现在你将编写一组函数来管理页表:插入和删除线性地址到物理地址的映射,并在需要时创建页表页。在文件kern/pmap.c
中,你必须实现以下函数的代码。
pgdir_walk()
boot_map_region()
page_lookup()
page_remove()
page_insert()
check_page()
会测试你的页表管理函数。确保它报告成功后再进行后面的部分。
pgdir_walk
:线性地址→查页目录→查页表→找到对应页表项,返回页表项的虚拟地址。注意处理页表不存在的情况。
这里需要着重理解一下相关的类型及宏定义:
pde_t* pde;
:声明了一个指针pde
,它保存了一个pde_t
类型的地址。使用时,pde
是地址,*pde
是该地址内存中保存的值,有32位长,是页目录项的内容。
PTE_ADDR(*pde)
:取出该页目录项内容中的物理地址部分,即一个页表的物理地址*pde & PTE_P
:取出该页目录项内容中的PTE_P
位,结果为0
或1
。
pte_t *
pgdir_walk(pde_t *pgdir, const void *va, int create)
{ // 根据虚拟地址va找到对应页表项,返回页表项的虚拟地址
pde_t* pde = &pgdir[PDX(va)];
// PDX(va)取va的前10bit作为下标,找到对应PDE
pte_t* pte;
if(*pde & PTE_P){ // 若该PDE的PTE_P标志位为1,对应页表存在
pte = KADDR(PTE_ADDR(*pde));
// PTE_ADDR(*pde)取出该页表物理地址,并转为虚拟地址
pte = &pte[PTX(va)];
// 将pte视为数组的起始地址,&pte[PTX(va)]即对应页表项的地址
// 上面两行等价于 pte = KADDR(PTE_ADDR(*pde)) + 4 * PTX(va);
return pte; // 返回页表项虚拟地址
}
else if (create){
// 如果连对应的页表都没有,新建并分配物理页
struct PageInfo *pg = page_alloc(ALLOC_ZERO);
if(pg){
pg -> pp_ref ++;
*pde = (page2pa(pg)) | PTE_P | PTE_U | PTE_W;
// 在页目录项中填入内容:页表物理地址 + PTE_P标记页表存在
pte = KADDR(PTE_ADDR(*pde));
pte = &pte[PTX(va)];
return pte; // 新的页表项虚拟地址
}
}
return NULL;
}
注意:PTX(va)
和PDX(va)
都是index
,而非offset
,这是注释里明确指出的。假设页表起始虚拟地址为pte_head
,则在该页表中查找va
的页表项pte
,应为pte = pte_head[PTX(va)]
,而不能写成pte = pte_head + PTX(va)
。因为页表中的每一项长度都为 4B,写成数组下标形式时会自然地以 4B 为单位,而写成偏移量形式时是以 1B 为单位。所以pte = pte_head[PTX(va)]
其实等价于pte = pte_head + 4 * PTX(va)
.(因为没有搞清楚PTX(va)
到底是当下标用还是当偏移量用,我在这里浪费了非常多的时间Debug……)
boot_map_region
:将虚拟地址 va~va+size 映射到物理地址 pa~pa+size。调用者保证 size 是 PGSIZE 的整数倍。遍历所有虚拟地址,调用pgdir_walk逐个生成对应页表项:
static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
pte_t *pte;
int i;
for(i = 0; i < size; i+=PGSIZE){
// size是要映射的总字节数,涉及size/PGSIZE个页表项
pte = pgdir_walk(pgdir, (void*)va, 1);
*pte = pa | perm | PTE_P; // 填入对应物理地址及标志位
va += PGSIZE;
pa += PGSIZE;
// 每建立一个页表项,完成PGSIZE个虚拟地址的映射
}
}
page_lookup
:返回虚拟地址va
对应的物理页物理地址;必要时将页表项地址保存在*pte_store
中返回给调用者:
struct PageInfo *
page_lookup(pde_t *pgdir, void *va, pte_t **pte_store)
{
struct PageInfo* ret = NULL;
pte_t* pte = pgdir_walk(pgdir, va, 0);
if(pte && (*pte & PTE_P)){
if(pte_store){
*pte_store = pte;
}
ret = pa2page(PTE_ADDR(*pte));
}
return ret;
}
page_remove
:删除虚拟地址va
对物理页地址的一个映射:
void
page_remove(pde_t *pgdir, void *va)
{
pte_t* pte = NULL;
struct PageInfo* pgInfo = page_lookup(pgdir, va, &pte);
// 要求返回对应页表项地址,保存在pte中
if(pgInfo != NULL && (*pte & PTE_P)){
// 物理页面有可能不存在,如连续对同一个va调用两次page_remove
page_decref(pgInfo); // 包含了 pgInfo->ref--;
*pte = 0;
tlb_invalidate(pgdir, va);
}
}
page_insert
:建立物理页pp
到虚拟地址va
的一个映射:
int
page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm)
{
// 建立物理页pp到虚拟地址va的映射
// 如果已经有其他物理页映射到va,执行page_remove删除它
// 如果已有pp到va的映射,同上,统一做法
pte_t * pte = pgdir_walk(pgdir, va, 1);
if(pte == NULL){
return -E_NO_MEM; // 对应页表不存在且无法分配内存新建页表
}
pp -> pp_ref ++; //必须在page_remove前执行
if((*pte) & PTE_P){
page_remove(pgdir, va);
// 已有映射,删掉,TLB invalidate已包含在page_remove中
}
*pte = page2pa(pp) | perm | PTE_P; // 填写页表项
return 0;
}
注意:pp->pp_ref++
必须在page_remove
之前。因为page_remove
会调用page_decref(pgInfo);
,后者会在执行pp->pp_ref--
之后立即判断该物理页是否0映射,若为0映射则会立即将页free
掉。如果pp->pp_ref++
在后面执行,会引起错误。
最后运行出现check_page() succeeded!
,练习 4 完成。