在 Linux 中,文件分为属性和数据两部分,每个文件有三种时间,分别用于记录与文件属性和文件数据相关的时间,这三个时间分别是 atime、 mtime 、 ctime 。
- atime,即 access time,和示访问文件数据部分时间,每次读取文件数据部分时就会更新剧me,强调下,是读取文件数据(内容)时改变 atime,比如 cat 或 less 命令查看文件就可以更新 atime,而 ls 命令则不会。
- ctime,即 change time,表示文件属性或数据的改变时间,每当文件的属性或数据被修改时,就会更新 ctime,也就是说 ctime 同时跟踪文件属性和文件数据变化的时间。
- mtime,即 modify time,表示文件数据部分的修改时间,每次文件的数据被修改时就会更新 mtime 。在上面说过啦, ctime 也跟踪数据变化时间,所以,当文件数据被修改时, mtime 和 ctime 一同更新。
1:2 # 目标文件:依赖文件
echo hello # 表示若2文件的mtime比1文件的mtime新,就执行这个命令
可直接指定目标进行执行:make 1
# 伪目标
all: # 不存在真实目标文件all且未给出依赖文件,此时all是伪目标名
echo hello
# 为了避免伪目标和真实目标文件同名的情况
.PHONY:clean
clean:
...
test2.o:test2.c
gcc -c -o test2.o test2.c
test1.o:test1.c
gcc -e -o test1.o test1.c
test.bin:test1.o test2.o
gcc -o test.bin testl.o test2.o
all:test.bin
@echo "compile done"
此 makefile 还是蛮简单的,第 1~4 行都是在准备.o 目标文件,第 5~6 行是将.o 文件生成二进制文件test.bin。第 7 行的目标 all 是为了编译 test.bin,咱们要执府的命令是 make all,借此分析下 make 的执行流程。
- make 未找到文件 GNUmakefile,便继续找文件 makefile,找到后,根据命令的参数 all,从文件中找到 all 所在的规则
- make 发现 all 的依赖文件 test.bin 不存在,于是就去找以 test.bin 为目标文件的规则
- 在第 5 行终于找到了 test.bin 的规则,但 make 发现, test.bin 的依赖文件 test1.o 和 test2.o 都不存在,于是先去找以 test1.o 为目标文件的规则
- 同样经过千辛万苦,在第 3 行找到了生成 test1.o 的规则,但它的依赖文件是 test1.c ,由于 test1.o 本身不存在,所以不用再查看 test1.c 的 mtime,直接执行此规则的命令,即第 4 行的 gcc -c -o test1.o test1.c,用 test1.c 来编译 test1.o
- 生成 test1.o 后,执行流程返回到 test.bin 所在的规则,即第 5 行,此时 make 发现 test2.o 也不存在,于是继续递归查找目标 test2.o
- 同样,在第 1 行发现 test2.o 所在的规则,由于 test2.o 本身不存在,也不再检查其所依赖文件 test2.c的 mtime,直接执行规则中的编译命令 gcc -c -o test2.o test2.c 生成 test2.o
- 生成 test2.o。后,此时执行流程又回到了第 5 行, make 发现两个依赖文件 test1.o 和 test2.o 都准备齐了,于是执行本规则的命令,即第 6 行的 gcc-o test.bin test1.o test2.o,将这两个目标文件生成可执行文件test.bin
- test.bin 终于生成了,此时回到了第 2 步目标 all 所在的规则,于是执行所在规则中的命令
@echo”compile done”
,打印宇符串表示编译完成。提醒一下,虽然 all 被当作了真实目标文件来处理,但我们给出的命令并不是为了生成它,所以它同伪目标的作用类似,大伙儿不要感到奇怪。因为在前面我们己经解释过啦, make+makefile 并不是为了编译或生成文件,它们只是为了执行规则中的命令,无所谓命令是什么
makefile 变量定义的格式:变量名=值(字符串),多个值之间用空格分开。make 程序在处理时会用空格将值打散,然后遍历每一个值。另外,值仅支持字符串类型,即使是数字也被当作字符串来处理 。
变量引用的格式:$(变量名)。这样,每次引用变量时,变量名就会被其值(宇符串)替换。
注意,虽然变量的值会被当作字符串类型处理,但不能将其用双引号或单引号括起来,否则双引号或单引号也会被当作变量值的一部分。比如 var =’file.c’ 的值并不是 file.c ,而是 ‘file.c’ 。当引用变量$(var)做依赖文件时, make 会去找名为 ‘file.c’ 的目标,而不是 file.c 。
obj files = testl.o test2.o
test.bin:$(objfiles)
gcc -o test.bin $(objfiles)
make 还支持一种自动化变量,此变量代表一组文件名,无论是目标文件名,还是依赖文件名,此变量值的范围属于这组文件名集合,也就是说,自动化变量相当于对文件名集合循环遍历一遍 。 对于不同的文件名集合,有不同的自动化变量名,下面列举一些。
- @,表示规则中的目标文件名集合,如果存在多个目标文件, @,表示规则中的目标文件名集合,如果存在多个目标文件, @,表示规则中的目标文件名集合,如果存在多个目标文件,@则表示其中每一个文件名。助记,’@’很像是 at, aim at,表示瞄准目标 。
- $<,表示规则中依赖文件中的第 1 个文件。助记,‘<’ 很像是集合的最左边,也就是第 1 个。
- $^,表示规则中所有依赖文件的集合,如果集合中有重复的文件, $^ 会自动去重。助记, ‘^’ 很像从上往下罩的动作,能罩住很大的范围,所以称为集合。
- $?,表示规则中,所有比目标文件 mtime 更新的依赖文件集合。助记,'?'表示疑问, make 最大的疑问就是依赖文件的 mtime 是否比目标文件的 mtime 要新。
实现内核使用的 ASSERT 断言
在断言打印信息前,应该把中断关掉
/* 定义中断的两种状态:
* INTR_OFF值为0,表示关中断,
* INTR_ON值为1,表示开中断 */
enum intr_status { // 中断状态
INTR_OFF, // 中断关闭
INTR_ON // 中断打开
};
#define EFLAGS_IF 0x00000200 // eflags寄存器中的if位为1
#define GET_EFLAGS(EFLAG_VAR) asm volatile("pushfl; popl %0" : "=g" (EFLAG_VAR))
/* 开中断并返回开中断前的状态*/
enum intr_status intr_enable() {
enum intr_status old_status;
if (INTR_ON == intr_get_status()) {
old_status = INTR_ON;
return old_status;
} else {
old_status = INTR_OFF;
asm volatile("sti"); // 开中断,sti指令将IF位置1
return old_status;
}
}
/* 关中断,并且返回关中断前的状态 */
enum intr_status intr_disable() {
enum intr_status old_status;
if (INTR_ON == intr_get_status()) {
old_status = INTR_ON;
asm volatile("cli" : : : "memory"); // 关中断,cli指令将IF位置0
return old_status;
} else {
old_status = INTR_OFF;
return old_status;
}
}
/* 将中断状态设置为status */
enum intr_status intr_set_status(enum intr_status status) {
return status & INTR_ON ? intr_enable() : intr_disable();
}
/* 获取当前中断状态 */
enum intr_status intr_get_status() {
uint32_t eflags = 0;
GET_EFLAGS(eflags);
return (EFLAGS_IF & eflags) ? INTR_ON : INTR_OFF;
}
实现ASSERT
#ifndef __KERNEL_DEBUG_H
#define __KERNEL_DEBUG_H
void panic_spin(char* filename, int line, const char* func, const char* condition);
/*************************** __VA_ARGS__ *******************************
* __VA_ARGS__ 是预处理器所支持的专用标识符。
* 代表所有与省略号相对应的参数.
* "..."表示定义的宏其参数可变.*/
#define PANIC(...) panic_spin (__FILE__, __LINE__, __func__, __VA_ARGS__)
/***********************************************************************/
#ifdef NDEBUG
#define ASSERT(CONDITION) ((void)0) // 让ASSERT等于0,即删除ASSERT
#else
#define ASSERT(CONDITION) \
if (CONDITION) {} else { \
/* 符号#让编译器将宏的参数转化为字符串字面量 */ \
PANIC(#CONDITION); \
}
#endif /*__NDEBUG */
#endif /*__KERNEL_DEBUG_H*/
我们传给 panic_spin 的其中一个参数是 __VA_ARGS_
。同样作为参数的还有__FILE__
__LINE__
__func__
,这三个是预定义的宏,分别表示被编译的文件名、被编译文件中的行号、被编译的函数名。
调用 PANIC 的形式为 PANIC(#CONDITION)
,即形参CONDITION,其中字符#的作用是让预处理器把 CONDITION 转换成字符串常量。比如 CONDITION 若为 var!= 0,#CONDITION的效果是变成了字符串 “var != 0”。
于是,传给 panic_spin 函数的第 4 个参数__VA_ARGS__
实际类型为字符串指针。
/* 打印文件名,行号,函数名,条件并使程序悬停 */
void panic_spin(char* filename, \
int line, \
const char* func, \
const char* condition) \
{
intr_disable(); // 因为有时候会单独调用panic_spin,所以在此处关中断。
put_str("\n\n\n!!!!! error !!!!!\n");
put_str("filename:");put_str(filename);put_str("\n");
put_str("line:0x");put_int(line);put_str("\n");
put_str("function:");put_str((char*)func);put_str("\n");
put_str("condition:");put_str((char*)condition);put_str("\n");
while(1);
}
内存地址池的概念是将可用的内存地址集中放到一个“池子”中,需要的时候直接从里面取出,用完后再放回去。由于在分页机制下有了虚拟地址和物理地址,为了有效地管理它们,我们需要创建虚拟内存地址池和物理内存地址池。
如何规划物理内存池。
操作系统为了能够正常运行,需要自己预留一部分给自己使用,所以划分两个内存池分别给内核和用户进程使用
内存池中的内存也得按单位大小来获取,这个单位大小是 4kb,称为页,故,内存池中管理的是一个个大小为 4kb 的内存块,从内存池中获取的内存大小至少为 4kb 或者为 4KB 的倍数
虚拟内存地址池。
对于所有任务(包括用户进程、内核〉来说,他们都有各自 4GB 的虚拟地址空间,因此需要为所有任务都维护它们自己的虚拟地址池,即一个任务一个 。
/* 虚拟地址池,用于虚拟地址管理 */
struct virtual_addr {
struct bitmap vaddr_bitmap; // 虚拟地址用到的位图结构
uint32_t vaddr_start; // 虚拟地址起始地址
};
#define PG_SIZE 4096 // 页的尺寸
/*************** 位图地址 ********************
* 因为0xc009f000是内核主线程栈顶,0xc009e000是内核主线程的pcb.
* 一个页框大小的位图可表示128M内存, 一页大小为 0x1000,再减去4页,位图位置安排在地址0xc009a000,
* 这样本系统最大支持4个页框的位图,即512M */
#define MEM_BITMAP_BASE 0xc009a000
/*************************************/
/* 0xc0000000是内核从虚拟地址3G起. 0x100000意指跨过低端1M内存,使虚拟地址在逻辑上连续 */
#define K_HEAP_START 0xc0100000
/* 物理内存池结构,生成两个实例用于管理内核内存池和用户内存池 */
struct pool {
struct bitmap pool_bitmap; // 本内存池用到的位图结构,用于管理物理内存
uint32_t phy_addr_start; // 本内存池所管理物理内存的起始地址
uint32_t pool_size; // 本内存池字节容量
};
struct pool kernel_pool, user_pool; // 生成内核内存池和用户内存池
struct virtual_addr kernel_vaddr; // 此结构是用来给内核分配虚拟地址
/* 初始化内存池 */
static void mem_pool_init(uint32_t all_mem) {
put_str(" mem_pool_init start\n");
uint32_t page_table_size = PG_SIZE * 256; // 页表大小= 页的尺寸*页的数量
// 第0和第768个页目录项指向同一个页表,第769~1022个页目录项共指向254个页表,共256个页框
uint32_t used_mem = page_table_size + 0x100000; // 0x100000为低端1M内存
uint32_t free_mem = all_mem - used_mem;
uint16_t all_free_pages = free_mem / PG_SIZE; // 1页为4k,不管总内存是不是4k的倍数,
// 对于以页为单位的内存分配策略,不足1页的内存不用考虑了。
uint16_t kernel_free_pages = all_free_pages / 2;
uint16_t user_free_pages = all_free_pages - kernel_free_pages;
/* 为简化位图操作,余数不处理,坏处是这样做会丢内存。
好处是不用做内存的越界检查,因为位图表示的内存少于实际物理内存*/
uint32_t kbm_length = kernel_free_pages / 8; // Kernel BitMap的长度,位图中的一位表示一页,以字节为单位
uint32_t ubm_length = user_free_pages / 8; // User BitMap的长度.
uint32_t kp_start = used_mem; // Kernel Pool start,内核内存池的起始地址
uint32_t up_start = kp_start + kernel_free_pages * PG_SIZE; // User Pool start,用户内存池的起始地址
kernel_pool.phy_addr_start = kp_start;
user_pool.phy_addr_start = up_start;
kernel_pool.pool_size = kernel_free_pages * PG_SIZE;
user_pool.pool_size = user_free_pages * PG_SIZE;
kernel_pool.pool_bitmap.btmp_bytes_len = kbm_length;
user_pool.pool_bitmap.btmp_bytes_len = ubm_length;
/********* 内核内存池和用户内存池位图 ***********
* 位图是全局的数据,长度不固定。
* 全局或静态的数组需要在编译时知道其长度,
* 而我们需要根据总内存大小算出需要多少字节。
* 所以改为指定一块内存来生成位图.
* ************************************************/
// 内核使用的最高地址是0xc009f000,这是主线程的栈地址.(内核的大小预计为70K左右)
// 32M内存占用的位图是2k.内核内存池的位图先定在MEM_BITMAP_BASE(0xc009a000)处.
kernel_pool.pool_bitmap.bits = (void*)MEM_BITMAP_BASE;
/* 用户内存池的位图紧跟在内核内存池位图之后 */
user_pool.pool_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length);
/******************** 输出内存池信息 **********************/
put_str(" kernel_pool_bitmap_start:");put_int((int)kernel_pool.pool_bitmap.bits);
put_str(" kernel_pool_phy_addr_start:");put_int(kernel_pool.phy_addr_start);
put_str("\n");
put_str(" user_pool_bitmap_start:");put_int((int)user_pool.pool_bitmap.bits);
put_str(" user_pool_phy_addr_start:");put_int(user_pool.phy_addr_start);
put_str("\n");
/* 将位图置0*/
bitmap_init(&kernel_pool.pool_bitmap);
bitmap_init(&user_pool.pool_bitmap);
/* 下面初始化内核虚拟地址的位图,按实际物理内存大小生成数组。*/
kernel_vaddr.vaddr_bitmap.btmp_bytes_len = kbm_length; // 用于维护内核堆的虚拟地址,所以要和内核内存池大小一致
/* 位图的数组指向一块未使用的内存,目前定位在内核内存池和用户内存池之外*/
kernel_vaddr.vaddr_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length + ubm_length);
kernel_vaddr.vaddr_start = K_HEAP_START; // 虚拟内存池起始地址
bitmap_init(&kernel_vaddr.vaddr_bitmap); // 虚拟内存池位图置0
put_str(" mem_pool_init done\n");
}
/* 内存管理部分初始化入口 */
void mem_init() {
put_str("mem_init start\n");
// 三种BIOS方法获得内存容量,存入汇编变量total_mem_bytes中,物理地址为0xb00
uint32_t mem_bytes_total = (*(uint32_t*)(0xb00)); // mem_bytes_total用于存储机器上安装的物理内存总量
mem_pool_init(mem_bytes_total); // 初始化内存池
put_str("mem_init done\n");
}
分配页内存
/* 内存池标记,用于判断用哪个内存池 */
enum pool_flags {
PF_KERNEL = 1, // 内核内存池
PF_USER = 2 // 用户内存池
};
// 页表项或页目录项的属性
#define PG_P_1 1 // 页表项或页目录项存在属性位
#define PG_P_0 0 // 页表项或页目录项存在属性位
#define PG_RW_R 0 // R/W 属性位值, 读/执行
#define PG_RW_W 2 // R/W 属性位值, 读/写/执行
#define PG_US_S 0 // U/S 属性位值, 系统级
#define PG_US_U 4 // U/S 属性位值, 用户级
#define PDE_IDX(addr) ((addr & 0xffc00000) >> 22) // 返回虚拟地址的高10位,即pde索引部分,此部分用于在页目录表中定位pde
#define PTE_IDX(addr) ((addr & 0x003ff000) >> 12) // 返回虚拟地址的中间10位,即pte索引部分,此部分用于在页表中定位pte
/* 在内核/用户虚拟内存池中连续申请pg_cnt个虚拟页,
* 成功则返回虚拟页的起始地址, 失败则返回NULL */
static void* vaddr_get(enum pool_flags pf, uint32_t pg_cnt) {
int vaddr_start = 0, bit_idx_start = -1;
uint32_t cnt = 0;
if (pf == PF_KERNEL) {
bit_idx_start = bitmap_scan(&kernel_vaddr.vaddr_bitmap, pg_cnt);// 扫描内核虚拟地址池位图
if (bit_idx_start == -1) {
return NULL;
}
while(cnt < pg_cnt) {
bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1);// 将相应位置置1
}
vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE;
} else {
// 用户内存池,将来实现用户进程再补充
}
return (void*)vaddr_start;
}
/* 得到虚拟地址vaddr对应的pte指针,实际返回的是能够访问vaddr所在pte的虚拟地址。*/
uint32_t* pte_ptr(uint32_t vaddr) {
/* 先访问到页表自己 + \
* 再用页目录项pde(页目录内页表的索引)做为pte的索引访问到页表 + \
* 再用pte的索引做为页内偏移*/
uint32_t* pte = (uint32_t*)(0xffc00000 + \ //高10位指向最后一个页目录项,让处理器自动在最后一个pde中取出页目录表物理地址
((vaddr & 0xffc00000) >> 10) + \ //获取vaddr的页目录表索引,将参数vaddr的高10位(pde索引〉取出来,做新地址new_vaddr的中间10位(pte索引)
//处理器以为获得的是pte中的普通物理页地址,但其实是vaddr中高10位的pde索引所对应的pde里保存的页表的物理地址
PTE_IDX(vaddr) * 4); //处理器需要页内偏移地址(低12位),但其实是vaddr中的页表索引(页表偏移地址,中间10位),页表索引乘4补上
return pte;
}
/* 得到虚拟地址vaddr对应的pde的指针 */
uint32_t* pde_ptr(uint32_t vaddr) {
/* 0xfffff是用来访问到页表本身所在的地址 */
uint32_t* pde = (uint32_t*)((0xfffff000) + PDE_IDX(vaddr) * 4);
return pde;
}
/* 在m_pool指向的物理内存池中分配1个物理页,
* 成功则返回页框的物理地址,失败则返回NULL */
static void* palloc(struct pool* m_pool) {
/* 扫描或设置位图要保证原子操作 */
int bit_idx = bitmap_scan(&m_pool->pool_bitmap, 1); // 找一个物理页面
if (bit_idx == -1 ) {
return NULL;
}
bitmap_set(&m_pool->pool_bitmap, bit_idx, 1); // 将此位bit_idx置1
uint32_t page_phyaddr = (m_pool->phy_addr_start + (bit_idx * PG_SIZE));//物理页地址 = 物理内存池的起始地址+物理页在内存池中的偏移地址
return (void*)page_phyaddr;
}
/* 页表中添加虚拟地址_vaddr与物理地址_page_phyaddr的映射 */
static void page_table_add(void* _vaddr, void* _page_phyaddr) {
uint32_t vaddr = (uint32_t)_vaddr, page_phyaddr = (uint32_t)_page_phyaddr;
uint32_t* pde = pde_ptr(vaddr); //获取虚拟地址所在的页目录项的虚拟地址
uint32_t* pte = pte_ptr(vaddr); //获取虚拟地址所在的页表项的虚拟地址
/************************ 注意 *************************
* 执行*pte,会访问到空的pde。所以确保pde创建完成后才能执行*pte,
* 否则会引发page_fault。因此在*pde为0时,*pte只能出现在下面else语句块中的*pde后面。
* *********************************************************/
/* 先在页目录内判断目录项的P位,若为1,则表示该表已存在 */
if (*pde & 0x00000001) { // 页目录项和页表项的第0位为P,此处判断目录项是否存在
ASSERT(!(*pte & 0x00000001));
if (!(*pte & 0x00000001)) { // 只要是创建页表,pte就应该不存在,多判断一下放心
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
} else { //应该不会执行到这,因为上面的ASSERT会先执行。
PANIC("pte repeat");
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
}
} else { // 页目录项不存在,所以要先创建页目录再创建页表项.
/* 页表中用到的页框一律从内核空间分配 */
uint32_t pde_phyaddr = (uint32_t)palloc(&kernel_pool);
*pde = (pde_phyaddr | PG_US_U | PG_RW_W | PG_P_1);
/* 分配到的物理页地址pde_phyaddr对应的物理内存清0,
* 避免里面的陈旧数据变成了页表项,从而让页表混乱.
* 访问到pde对应的物理地址,用pte取高20位便可.
* 因为pte是基于该pde对应的物理地址内再寻址,
* 把低12位置0便是该pde对应的物理页的起始*/
memset((void*)((int)pte & 0xfffff000), 0, PG_SIZE);
ASSERT(!(*pte & 0x00000001));
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
}
}
/* 分配pg_cnt个页空间,成功则返回起始虚拟地址,失败时返回NULL */
void* malloc_page(enum pool_flags pf, uint32_t pg_cnt) {
ASSERT(pg_cnt > 0 && pg_cnt < 3840);//监督申请的内存页数是否超过了物理内存池的容量
/*********** malloc_page的原理是三个动作的合成: ***********
1通过vaddr_get在虚拟内存池中申请虚拟地址
2通过palloc在物理内存池中申请物理页
3通过page_table_add将以上得到的虚拟地址和物理地址在页表中完成映射
***************************************************************/
void* vaddr_start = vaddr_get(pf, pg_cnt);//因为虚拟地址是连续的,一次性申请虚拟地址
if (vaddr_start == NULL) {
return NULL;
}
uint32_t vaddr = (uint32_t)vaddr_start, cnt = pg_cnt;
struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;//判断内存池是内核还是用户
/* 为虚拟页分配物理页并在页表中建立映射,因为虚拟地址是连续的,但物理地址可以是不连续的,所以逐个做映射*/
while (cnt-- > 0) {
void* page_phyaddr = palloc(mem_pool);
if (page_phyaddr == NULL) { // 失败时要将曾经已申请的虚拟地址和物理页全部回滚,在将来完成内存回收时再补充
return NULL;
}
page_table_add((void*)vaddr, page_phyaddr); // 在页表中做映射
vaddr += PG_SIZE; // 下一个虚拟页
}
return vaddr_start;
}
/* 从内核物理内存池中申请pg_cnt页内存,成功则返回其虚拟地址,失败则返回NULL */
void* get_kernel_pages(uint32_t pg_cnt) {
void* vaddr = malloc_page(PF_KERNEL, pg_cnt);
if (vaddr != NULL) { // 若分配的地址不为空,将页框清0后返回
memset(vaddr, 0, pg_cnt * PG_SIZE);//虚拟地址连续,所以直接用pg_cnt乘以PG_SIZE表示需置零的字节数
}
return vaddr;
}
int main(void) {
put_str("I am kernel\n");
init_all();
void* addr = get_kernel_pages(3);
put_str("\n get_kernel_page start vaddr is ");
put_int((uint32_t)addr);
put_str("\n");
while(1);
return 0;
}
一般的内存管理系统所管理的是那些空闲的内存,即己被使用的内存是不在内存池中的,"己使用的内存"当然包括内存管理相关数据结构所占的内存,位图就是用于管理内存的数据结构,这也是位图地址选为 0xc009a000 的原因,此地址位于低端 1MB 之内,这里面的内存几乎都被占用了,因此我们就不用考虑它占用的内存了
页表的作用是将虚拟地址转换成物理地址,其转换过程中涉及访问的页目录表、页目录项及页表项,都是通过真实物理地址访问的,否则若用虚拟地址访问它们的话,会陷入转换的死循环中不可自拔。
虚拟地址和物理地址的映射关系是在页表中完成的,本质上是在页表中添加此虚拟地址对应的页表项 pte,并把物理页的物理地址写入此页表项 pte 中。