lab2-exercise1【重制中】
引用了以下文章中的文字或图片,删除请联系我
https://github.com/Babtsov/jos/blob/master/lab2/README.md
https://zhuanlan.zhihu.com/p/165104094
背景知识
通过实验1,我们知道系统的启动流程:
-
系统启动自动执行BIOS,BIOS初始化设备。
-
BIOS将磁盘的首512个字节(也就是第一个扇区)读取到内存0x7c00处(第一个扇区存放的是boot.S和main.C),并将系统控制权交给boot.S和main.C
-
boot.S将CPU工作模式从实模式切换到保护模式,关闭了系统中断,并且在最后调用main.C中的bootmain函数
-
main.C为了将内核的ELF文件读入内存,首先将磁盘首4096个字节(也就是第一页)读取到内存0x10000处(暂存地址),然后判断ELF文件是否一个合法的ELF文件,然后根据PROGRAM HEADER TABLE将指定的 segments 加载到内存指定的位置中去(也就是加载内核,内核物理地址首部在0x100000,入口在0x10000c),最后跳转0x10000c进入内核。
-
进入内核之后,首先执行的是entry.S
entry.S主要干了这么几件事儿:为了使内核自身能够在页机制下运行,将标号entry_pgdir指向的页目录(entrypgdir.C)的物理地址传送给cr3,并修改cr0启用页机制(在开启页机制前不可直接使用线性地址,因为高址区域还没有被映射);通过一条间接转移(地址存在寄存器中)指令使得EIP指向高址端,从而内核真正实现在高址端运行;设置EBP为0、ESP指向标号bootstacktop处,调用i386_init函数。至此,entry的任务就完成了。
entry.S的核心就是设置CR3和CR0,通过设置CR3来使用手动创建的两级页表,通过设置CR0开启分页模式,使得内核能够在页机制下正常运行。
这个手动创建的二级页表在 entrypgdir.C 中定义。主要功能是将0xf0000000 ~ 0xf0400000的物理内存映射到0x00100000~0x00400000(共4MB)。
在之前,其实已经设置了一次CR0了(打开保护模式),但是并没有打开分页,那么当时bootmain是怎样被正确链接的,C代码为什么可以执行?其实是设置了GDT,把虚拟地址(线性地址)直接变成物理地址(没有任何转化)。在entry快要结束的时候,调用了init.c中的i386_init函数。 -
i386_init
在i386_init中,先将内核ELF文件中的BSS section在内存中清空为0,这是完成加载ELF格式文件的最后一步,且被清空的区域位于内核的末端;接着调用cons_init函数初始化控制设备,主要和显示相关,该函数执行完毕后才可以正常调用cprintf将信息输出到控制台,我们不必知道细节;接下来调用mem_init初始化内存管理;最后是monitor函数,它循环地接受和执行用户输入的命令,即扮演shell的角色,但和xv6不同的是,这里的monitor并非是一个用户进程,此时JOS还没有创建任何进程。
mem_init函数就是我们要在lab2中完善的对象。在entry.S中,JOS创建了一个使得内核能够在页机制下正常运行的两级页表
(这些内存空间足以帮助内核最开始的C语言程序(需要使用虚拟地址)完成真正内核页表的构建,例如kern_pgdir)。但我们的目标远不止于此,我们还要建立更多的映射关系,满足JOS作为一个多任务系统上程序间隔离、保护的需要。在这里,mem_init仅创建内核的地址空间,因为我们还没有任何用户程序。具体地,mem_init需要创建一个页目录,并从该页目录出发建立一些映射关系——而我们要做的,就是实现建立这些映射关系所需要的函数工具,并通过这些工具真正创建好需要的映射。
事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常
Exercise 1
在文件kern/pmap.c中,必须实现以下函数的代码(可能按照给定的顺序)。
boot_alloc()
mem_init() (only up to the call to check_page_free_list(1)) //设置一个两级page table
page_init() //初始化 page structure 和 memory free list
page_alloc() //分配物理页
page_free() //释放物理页
check_page_free_list() and check_page_alloc() test your physical page allocator. You should boot JOS and see whether check_page_alloc() reports success. Fix your code so that it passes. You may find it helpful to add your own assert()s to verify that your assumptions are correct.
按照题目要求我们要补充以上几个中的代码,我们要首先理解这几个函数的的作用。
这个实验,以及所有的6.828实验,都需要你做一些侦探工作来准确地弄清楚你需要做什么。这个作业并没有描述你需要添加到JOS中的所有代码的细节。
官方明确指明了不会每一步都教你怎么做,需要你自己根据已给文件或注释去弄清楚你需要做什么。
首先我们按顺序来看各个函数。
boot_alloc的功能是什么,为什么需要这个功能?
boot_alloc的功能是分配内存。
为什么之前的boot.S,main.C,entry.S不需要考虑分配内存:因为没有数据结构(?)。
为什么现在需要考虑分配内存:因为我们想要使用数据结构,所以需要动态的为这些数据结构分配内存(?)。
在C中,数组的存储位置取决于其声明方式、作用域和分配方式,以数组为例:
栈上:如果数组是在函数内部声明的,并且没有使用动态内存分配函数(如
malloc()或new),那么数组通常会存储在栈上。这种情况下,数组的生命周期与所在的函数调用的生命周期相关联。全局或静态存储区:如果数组是在全局范围内声明的,或者是使用
static关键字在函数内部声明的,那么数组将存储在全局或静态存储区。这意味着数组的生命周期将持续整个程序的执行期间,并且在程序启动时就分配了空间。未初始化的全局或静态数组也可以存储在BSS段中。堆上:如果数组是通过动态内存分配函数(如
malloc()或new)在堆上分配的,那么数组将存储在堆上。在这种情况下,数组的生命周期可以由程序员根据需要进行控制,并且数组的内存将一直保持分配状态,直到程序员显式释放它。
为了使用动态分配的数据结构,我们需要一个用来在堆上动态分配内存的函数,类似于C中的malloc,这就是boot_alloc要做的事情。
实现这个函数之前,我们需要首先考虑几个问题
- 我们没有类似于C中的malloc的东西可以用来“在堆上动态分配内存”。这意味着我们需要创建自己的分配器。
- 我们在entry.S中将0xf0000000 ~ 0xf0400000的物理内存映射到0x00100000~0x00400000(共4MB)。所以目前来讲,只有这4MB的内存可供我们使用。除此之外,我们已经将内核代码放到了这4MB中,这意味着4MB中已经有一些区域是被使用的了,我们需要注意不会意外的覆盖内核代码。
- 我们需要跟踪那些地址是可用的,哪些地址是不可用的(已被使用)。
所有这些问题都由 boot_alloc 函数解决。这将是我们的"临时"内存分配器,我们将使用它来开始动态分配数据结构。它是临时的,因为它只能管理前 4MB 的 RAM,但这没关系,因为这是唯一映射的内存区域(分页模式下,我们不能使用未映射到虚拟地址的 RAM 地址)。
i386_detect_memory
在这个函数中,我们探测出了目前总内存大小(当前系统总内存大小输出为131072K),那么npages = 131072 / (4096 / 1024) = 32768。则总物理内存页数为32798页,我们将npages 称为总页数。这个总页数在后面不会再动。并且为了区分,将从npages里的分配出去的内存页称为已分配内存页,将npages里的未分配出去的内存页称为未分配内存页。
boot_alloc如何分配内存?
首先我们要找到未分配给任何内核代码或全局变量的第一个虚拟地址。设该地址为X。于是我们对X~ 0xf0400000虚拟地址进行管理。
官方已经替我们写了一部分代码,查看boot_alloc()中的官方代码,我们知道这个X就是nextfree指针变量指向的值,而且官方已经帮我们将nextfree设为4096的倍数,方便后续操作:
//如果 nextfree 为空指针,则执行以下操作。
if (!nextfree) {
//声明了一个 end 的外部字符数组
//extern 声明告诉编译器该变量是在其他文件中定义的。
//在C语言中,全局变量 end 通常被链接器设置为程序的末尾地址。
extern char end[];
//ROUNDUP 函数用于将 end 指针对齐到 PGSIZE 的倍数。
//例子1:已知PGSIZE为4096,若end是4095,那么将end舍入成4096,赋值给nextfree
//例子2:已知PGSIZE为4096,若end是6061,那么将end舍入成8192,赋值给nextfree
nextfree = ROUNDUP((char *) end, PGSIZE);
}
根据注释要求,我们要实现的功能:
- 当n>0时,分配一个足够大以容纳'n'字节的块(给某数据结构),然后更新nextfree,确保nextfree保持对PGSIZE的倍数对齐。
- 当n==0时,则返回下一个空闲页面的地址,而不分配任何内容。
什么叫分配,比如我需要一个4000字节大小的块给某数据结构A,那么我需要将nextfree作为A的初始地址,然后将nextfree+4096作为nextfree的地址,。同时我们需要考虑当空间不足时的处理方法。代码实现如下:
// LAB 2: Your code here.
//
// 计算n是4096的多少倍数,向上取整
size_t alloc_page = (size_t)(n + 4095) / 4096;
if (n>0) {
//这里其实应该要加一个判断,判断n是否太大了,内存不够分
//大概思路是根据nextfree获取当前页数A,根据n知道要分配的页数B
//判断A+B是否>npages
//但是alloc_page基本上只用两次,要来分配内存给页目录和PageInfo数组
//内存必然是够的,所以就没写。
// 保存变更前的 nextfree
size_t start_address = nextfree;
// 将nextfree增加到相应的页面数量
nextfree += 4096 * alloc_page;
return start_address;
}
else if(n==0){
return nextfree;
}
else {
// 可分配空间不足,输出提示信息
cprintf("可分配空间不足\n");
}
return NULL;
好了!现在boot_alloc已经完成了。接下来要看的是mem_init()函数
mem_init()
在前面提到:我们还要建立更多的映射关系,满足JOS作为一个多任务系统上程序间隔离、保护的需要。在这里,mem_init仅创建内核的地址空间,因为我们还没有任何用户程序。具体地,mem_init需要创建一个页目录,并从该页目录出发建立一些映射关系——而我们要做的,就是实现建立这些映射关系所需要的函数工具,并通过这些工具真正创建好需要的映射。
分析之mem_init前的几个问题:
-
为什么更多的映射关系可以满足JOS作为一个多任务系统上程序间隔离、保护的需要?
内存保护:通过映射关系,可以实现对内存的不同访问权限设置,比如只读、读写、执行等权限。这样可以防止程序越界访问或者恶意修改其他任务的内存数据。
资源隔离:除了内存之外,其他资源如IO设备也可以通过映射关系进行隔离,确保各个任务之间的资源利用不会互相干扰。
-
为什么创建一个二级目录就可以满足我们的需求?不是三级目录,不是四级目录?
-
为什么mem_init此函数仅设置地址空间的内核部分(即address >= UTOP 的部分),用户部分以后再设置?
不知道。
-
UTOP,ULIM是什么?
官方在 memlayout.h当中给出了操作系统对虚拟内存的规划:memlayout.h中的虚拟内存布局 - Pril - 博客园 (cnblogs.com)。
P.S:只是规划,蓝图,还没实现。
-
操作系统的虚拟内存布局和程序视角的虚拟内存
在操作系统的虚拟内存布局中,操作系统会将物理内存(例如4GB)分割成不同的区域,为每个进程分配虚拟地址空间。这些虚拟地址空间可能不会占用整个物理内存,而是根据需要动态分配。
而在程序视角中,每个程序都会认为自己拥有整个4GB的可用内存,因为操作系统会将程序的虚拟地址空间映射到物理内存。这意味着程序可以使用整个4GB的虚拟地址空间来访问内存,而不必担心物理内存的大小或其他程序的干扰。
因此,从程序的视角来看,它们都认为自己拥有整个4GB的可用内存,虽然实际上这些内存是由操作系统进行管理和分配的。
mem_int分析
页目录初始化
pde_t *kern_pgdir; // Kernel's initial page directory
...
kern_pgdir = (pde_t *) boot_alloc(PGSIZE);
memset(kern_pgdir, 0, PGSIZE);
pde_t是在memlayout.h定义的一个新的类型 ,其实就是一个无符号 32 位整数类型(uint32_t)。
第二行创建了一个初始的页目录,并将其地址赋值给变量 kern_pgdir。(pde_t *) 将分配的内存强制转换为页目录项类型 pde_t 的指针。我们将会得到一个无符号 32 位整数类型作为页目录的起始地址。
第三行使用 memset 函数将刚刚分配的内存块清零,即将页目录中的所有条目都设置为零值。
设置页目录的第一个页目录项
//pmap
// 递归地将页目录插入自身作为一个页表,以形成一个虚拟页表,存储在虚拟地址 UVPT 处。
// (暂时你不需要理解以下行的更大目的。)
// Permissions: kernel R, user R
kern_pgdir[PDX(UVPT)] = PADDR(kern_pgdir) | PTE_U | PTE_P;
PDX(UVPT) 是一个宏,用于获取给定虚拟地址的页目录索引(Page Directory Index),这里用于获取虚拟地址 UVPT 的页目录索引。
-
回顾之前分析的问题5,可以知道UVPT其实是User Virtual Page Table(一个用于用户空间地址映射的数据结构),其虚拟地址是0xef400000,也可以查看代码中的UVPT的定义来验证。
-
试着计算PDX(UVPT)。
//mmu.h //#define PTXSHIFT 12 // offset of PTX in a linear address ... // page directory index #define PDX(la) ((((uintptr_t) (la)) >> PDXSHIFT) & 0x3FF)(0xef400000 >> 22 )& 0x3FF =====> 0xef400000 >> 22 = 0x3BD
=====> 0x3BD & 0x3FF = 0x3BD
-
为什么这样计算出来的就是页目录索引?
因为在x86架构中,虚拟地址的高位用于索引页目录(Page Directory),中间位用于索引页表(Page Table),低位用于页内偏移。因此,我们可以通过对线性地址进行适当的位操作来提取出页目录索引。
具体而言,将线性地址右移22位可以使得页目录索引位于最低10位,因为在x86架构下,页目录索引占据地址的高10位。然后,通过进行位与运算,我们可以清除其他位,仅保留页目录索引的值,这样就得到了页目录索引部分的数值。
PADDR(kern_pgdir) 是一个宏,用于获取 kern_pgdir 这个虚拟地址在物理内存中的地址。
PTE_U 表示用户态(User)可访问。当页表项的 PTE_U 位被设置时,表示该页面可以被用户程序访问,即用户态代码可以读取或写入该页面的内容。
PTE_P 表示页面存在(Present)。当页表项的 PTE_P 位被设置时,表示该页面存在于物理内存中,可以被访问。如果 PTE_P 位未设置,则表示页面不存在,访问时会触发缺页异常。
因此,这行代码的含义是将页目录 kern_pgdir 的一个页目录项设置为指向自身的物理地址,并设置了用户态和存在位标志。也就是讲,将PDX(UVPT)设为kern_pgdir的第一个索引/页目录项。
Your code Here
接下来要求我们自己补充代码,首先看看注释的要求:
//分配一个包含 npages 个 'struct PageInfo' 的数组,并将其存储在 'pages' 中。
// 内核使用这个数组来跟踪物理页面:对于每个物理页面,在这个数组中有一个对应的 struct PageInfo。
// 'npages' 是内存中物理页面的数量。使用 memset 将每个 struct PageInfo 的所有字段初始化为 0。
npages,pages定义在pmap.c中,struct PageInfo定义在 memlayout.h中:
npages,pags:
//size_t其实就是无符号32位整型:typedef uint32_t size_t;
size_t npages; // Amount of physical memory (in pages)
...
npages = totalmem / (PGSIZE / 1024);
...
struct PageInfo *pages; // Physical page state array
struct PageInfo:
/*
* 页面描述符结构体,虚拟地址在 UPAGES 处。
* 内核可读可写,用户程序只读
*
* 每个 struct PageInfo 对应物理页面。
* 您可以使用 kern/pmap.h 中的 page2pa() 函数将 struct PageInfo * 映射到相应的
* 物理地址。
*/
struct PageInfo {
// 空闲列表中的下一个页面
struct PageInfo *pp_link;
// pp_ref 是一个字段,用于记录指向页面的指针计数,通常这些指针存储在页表条目中。这意味着当某个页面被多个页表条目所引用时,pp_ref 记录了这种引用的次数。
// 当使用 page_alloc 函数分配页面时,pp_ref 字段表示对该页面的引用计数。这意味着每次成功分配一个页面时,pp_ref 的引用计数会增加,表示有多个指针指向了该页面。
// 在 pmap.c 中的 boot_alloc 函数用于在操作系统启动时分配页面。在这种情况下,分配的页面不具有有效的引用计数字段,因为它们是在系统启动时直接分配的,而不是通过 page_alloc 函数进行分配的。
uint16_t pp_ref;
};
结合注释可知,pp_ref 字段主要用于跟踪页面的引用计数,以便在页面不再被使用时可以安全地释放它。当pp_ref 的值为1时,讲明该PageInfo对应的物理页面已被使用
pp_link是用来存储空闲列表中的下一个页面的地址的。例如假设有一个空闲页面列表,它由一系列 struct PageInfo 结构组成,每个结构都有一个 pp_link 指针字段指向下一个空闲页面。
接下来,我们来完成代码:
-
分配一个包含
npages个struct PageInfo的数组,并将其存储在pages中。
这意味着我们要分配npages * sizeof(struct PageInfo)大小的内存空间出来,这个内存空间的起始地址是pages。pages = boot_alloc(npages * sizeof(struct PageInfo));
我想这样写,但是显然还需要考虑boot_alloc分配失败的情况,已知boot_alloc分配失败的时候会返回null;所以我们还应该加上类似这样的代码:if(pages == null){...}else{...}考虑到内存必然够,就没加。。。
-
内核使用这个数组来跟踪物理页面:对于每个物理页面,在这个数组中有一个对应的
struct PageInfo。那么如何将struct PageInfo和物理页面对应起来呢?注释中提到我们可以使用 kern/pmap.h 中的 page2pa() 函数将 struct PageInfo * 映射到相应的物理地址。
-
首先要知道当前可用内存页的范围。已知总内存页数为npages,可用内存页第一页的起始地址为
nextfree,我们有一个长度为npages的struct PageInfo的数组,这个数组的起始地址为pages。 -
假设我们有一个
PageInfo *pp,如何通过这个pp知道其对应的物理页号?只需要知道这个pp在数组中的索引是多少即可。 -
假设
(pp - pages)的结果是 2,表示pp指针指向的元素距离数组开头有 2 个元素的距离。然后,左移PGSHIFT位,即将偏移量乘以页面大小。PGSHIFT的值是 12(表示页面大小为 4KB),那么(pp - pages) << PGSHIFT的结果就是 2 * 4096 = 8192,表示页面对应的物理地址偏移了 8192 字节(8kb)。所以pp映射的物理起始地址就是0x0000 1FFF
-
-
npages是内存中物理页面的数量。使用memset将每个struct PageInfo的所有字段初始化为 0。
// Your code goes here:
//分配内存
pages = boot_alloc(npages * sizeof(struct PageInfo));
//使用memset初始化数组
memset(pages, 0, npages * sizeof(struct PageInfo));
完成这部分代码之后,继续往下分析mem_int函数
page初始化及检查
//////////////////////////////////////////////////////////////////////
// 现在我们来设置空闲物理页面的list
//一旦我们这样做了,所有进一步的内存管理都将通过 page_* 函数进行。
// 特别是,我们现在可以使用 boot_map_region 或 page_insert 来映射内存。
page_init();
check_page_free_list(1);
check_page_alloc();
check_page();
函数分析:
page_init
void
page_init(void)
{
// 下面的示例代码将所有物理页面标记为可用。
// 但实际情况并非如此。那么哪些内存是可用的呢?
/*
size_t i;
for (i = 0; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
*/
// 1) 将物理页面0标记为已使用。
// 这样我们可以在保留实模式下的IDT(中断描述符表)和BIOS结构以防日后需要。
// 2) 基本内存的其余部分是空闲的, 也就是说
// [PGSIZE(4096), npages_basemem * PGSIZE)是空闲的
// 3) [IOPHYSMEM(0x0A0000), EXTPHYSMEM(0x100000))这个范围我们称为
// I/O hole在这个范围内的地址空间被保留用于I/O设备或其他系统硬件的操作,因
// 为此不应该被分配给任何内存。
// 4) 在拓展内存 [EXTPHYSMEM, ...)有一些页面是正在被使用的,有一些页面是空闲
// 的。内核在物理内存中的位置是什么?哪些页面已经被用于页表和其他数据结构?
//
// 修改代码以反映上述四点.
// 注意:不要真的去修改物理内存。
}
IOPHYSMEM,EXTPHYSMEM都定义在代码中,他们分别是0x0A0000和0x100000,对应The PC's Physical Address Space - Pril - 博客园 (cnblogs.com)中的VGA Display—— BIOS ROM 。正如lab1所讲,0x000A0000到0x00100000之间存在一个“空洞”,将RAM分成“低”或“常规内存”(前640KB)和“扩展内存”(其他所有内容)。
然后是注释4)的问题,已知我们在前面将内核放到了物理内存0x00100000之后的地址去,结合page_init的注释,我们可以画出物理内存分布的图。

IOPHYSMEM/PGSIZE ,EXTPHYSMEM/PGSIZE都是代表所在处的页数。
请注意,如果已知地址求当前页数,那么计算公式是像上图一样地址/PGSIZE;如果是像i386_detect_memory中,知道字节数(totalmem),求共有多少页,计算公式应该是字节数/PGSIZE/1024。
之后,我们又运行了两次boot_alloc为kern_pgdir和struct PageInfo 数组分配内存:

可以看到,页目录占了一页,struct PageInfo 数组占了(npages * sizeof (struct PageInfo)/PGSIZE页。nextfree指向数组尾地址。
我们要做的是将0~1页,IO Hole,kernel-data,页目录,struct PageInfo 数组占用的几页标记为不可用/已被使用。准确来讲,是将它们对应的PageInfo中的pp_ref的值设为1。
还有一个值得注意的是在示例代码中,出现的page_free_list,其是结构也是PageInfo,用于记录空闲页面。page_free_list是一连串空闲页面的起始地址,所谓的“一连串”是指空闲页面之间通过struct PageInfo *pp_link连起来。所以,当一个页面被设置空闲/不空闲的时候,这个页面也要加到page_free_list的头)或从page_free_list头部当中取下来。
空闲页面加入page_free_list原理:

为什么是这样?因为是从注释中的示例代码推出来的。
完善page_init:
void
page_init(void)
{
/*
size_t i;
for (i = 0; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
*/
// 1) 将物理页面0标记为已使用。
pages[0].pp_ref = 1;
pages[0].pp_link = NULL;
// 2) [PGSIZE(4096), npages_basemem * PGSIZE)是空闲的
// 3) [IOPHYSMEM(0x0A0000), EXTPHYSMEM(0x100000))这个范围我们称为
// I/O hole在这个范围内的地址空间被保留用于I/O设备或其他系统硬件的操作,因
// 为此不应该被分配给任何内存。
// 4) 在拓展内存 [EXTPHYSMEM, ...)有一些页面是正在被使用的,有一些页面是空闲
// 的。内核在物理内存中的位置是什么?哪些页面已经被用于页表和其他数据结构?
//
// 修改代码以反映上述四点.
// 注意:不要真的去修改物理内存。
}

浙公网安备 33010602011771号