本文主要讲解了mit jos lab(2-4)中的内容,由于前辈们各种博客对题目解答已经非常详细了,我就并不针对题目的解答做文章了,而是整体的对系统执行过程中,内存的情况作出概述,描述各个过程的虚拟地址的分配、使用情况。
其中也参考了各个前辈写的博客,我分享在下面:
Lab2:https://www.cnblogs.com/fatsheep9146/p/5124921.html
和 https://www.jianshu.com/p/3be92c8228b6
Lab3:http://www.cnblogs.com/fatsheep9146/p/5341836.html
和:https://www.jianshu.com/p/f67034d0c3f2
Lab4:https://www.jianshu.com/p/10f822b3deda
接下来就是正文了~
-------------------------------------------------------------------------------------------------------------
从jos lab2开始,就进行内存管理内存管理的配置,由于jos采取了虚拟地址机制,所以逃不开分段和分页,我们就先看看jos的虚拟地址和分段分页具体是怎样实现的:
Jos没有具体实现分段机制,因此我们可以说虚拟地址和线性地址是等价的,在分页时,我们也直接使用虚拟地址进行计算处理。
Jos的分页机制是由一个二级页表构成的,一级页表os将其称为页目录(page directory),二级页表就叫页表(page table)。
对于32位机器,共4G的地址空间来说,jos将每页大小分为4k; 因此用一页地址作为页目录,一个页目录存1024个页目录项; 通过页目录项,便可以找到对应的页表,每个页表有1024个页,因此共4M。
32位虚拟地址,通过前10位,便可定位页目录,10-20位定位页表项,20-32位是页内偏移量。如图:
这是理论,接下来讲一下jos具体的实现,并谈一谈具体由32位虚拟地址如何找到对应的页目录,页表,页面,以及和物理地址的关系,并给出jos计算的具体实现。
首先给出一些预备知识,虽然并不难懂,但是大部分博客都没有讲到,主要是各个值之间的转换关系和计算方法。
1.在jos中,物理地址和虚拟地址只是简单的做了映射关系:
虚拟地址=物理地址+0xf0000000
2.在每一个页目录中,通过虚拟地址前10位寻找页目录项是由PDX实现的:
#define PDX(la) ((((uintptr_t) (la)) >> 22) & 0x3FF)
3.在页表中,通过虚拟地址10-20位寻找页表项是由PTX实现的:
#define PTX(la) ((((uintptr_t) (la)) >> 12) & 0x3FF)
4.下边是页内偏移:
#define PGOFF(la) (((uintptr_t) (la)) & 0xFFF)
5.PADDR:由虚拟地址转换为物理地址
6.KADDR:由物理地址转换为虚拟地址
7.ROUNDUP(n):向上按页取整
例:n=1,返回4k
n=100,返回4k
n=4k+1,返回8k
8.page2pa: PageInfo 转换成物理地址
(page[i]-pages)*4k
I386_init在最开始就调用了mem_init函数,整个jos地址管理也是主要从这部分开始,在这个函数中,奠定了整个地址管理的基础,所以不得不说一下这里边各个函数的功能
避免显得冗余,让文章整体比较流畅,使概括性较强,我写在了另外一篇文章里:http://www.cnblogs.com/Not-a-Coder/articles/8178760.html
以下是整体的逻辑:
首先,通过boot_alloc申请了第一个页,作为内核的页目录(kern_pgdir),在目录的UVPT处,存放的是页目录首地址的物理地址(在后来多个进程,多个目录时也是一样)
然后,申请了npage个pageInfo大小的内存,pageInfo的数量和内存所有物理页面的个数是一样的(经过真实计算查看确实是这样,jos只使用了系统的一部分内存,我的是64M),这点很重要,所以每个pageInfo都对应一个物理页面,也才有后来的根据pageInfo来计算某个页的虚拟地址(page2kva),根据page_free_list分配空闲页。
申请了pages之后,便将其初始化,已经使用的页将其被映射的次数置1,未使用的页将其加入到page_free_list中。之后每次想申请空闲页面时,从page_free_list中获取一个PageInfo,然后经过计算既可以了(具体的计算在上文链接中)。当页面再次处于空闲,再将其放回page_free_list中
接下来说一下权限(perm),是否在内存中(PTE_P)等条件是怎么解决的:
主要是通过boot_map_region实现,这个函数除了映射虚拟地址和物理地址外,还将物理地址最后12位作为一些信息存储位(具体我再函数介绍时说过了),通过传入相应的物理地址和虚拟地址以及一些权限信息,就可以实现。因此通过
boot_map_region(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W);
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W);
就可以将内核的堆栈和整个内核区域都设为只有内核可以读写了(kernbase之上为内核区域,之后会有说明)
对于之后判断是否可以对某块区域访问,是在后面的实验中又补充了一个函数,因为关系密切,我就一并写在这了,就是user_mem_check函数,他可以根据某个进程的信息和虚拟地址,来判断这个进程是不是可以访问此块内存区域。
在lab2完成虚拟地址的管理系统后,主要对外调用的有:
boot_map_region:用来对虚拟地址和物理地址映射,并分配权限等信息
page_alloc:申请新的页面
page_insert:将新申请的页面插入页目录,页表项中
page_free:释放页
user_mem_check:检查某进程是否有权限访问某块地址
至此,地址管理具体方法大致都说完了,在mem_init中,初始化完地址管理的一些东西,便开始初始化进程管理的东西,接下来会开始介绍,当有了进程时,具体的流程和lab2类似,一些具体的函数说明写在了 http://www.cnblogs.com/Not-a-Coder/articles/8178760.html
在men_init中,同样使用boot_alloc申请了NENV个Env结构体(这个结构体的属性存储了进程的状态(正在运行,就绪,阻塞,还有“丧失进程”)、运行过程中的需要的各个寄存器的值等重要信息)。之后立即在kern_pgdir中为其增加了虚拟地址和物理地址的映射(boot_map_region),并设置了用户和内核都可以读取的权限,之后便调用了env_init函数,初始化整个进程的结构体链表,这个过程和pages的初始化类似,也维护了一个env_free_list,就不再多说。
当想要创建并启用一个进程时,调用env_create,它会调用env_alloc,从env_free_list中取出一个env结构体,再通过 env_setup_vm为其初始化,申请新的页目录; 然后执行load_icode,这个函数加载elf文件(二进制文件),它会调用region_alloc为其分配页,并将虚拟地址和物理地址作出映射,load_icon之后分配进程栈,以及,将env->env_tf.tf_eip指向将执行进程函数的入口(等待env_pop_tf的调用)。之后调用env_run启动进程,通过env_pop_tf函数,改变env结构体中运行状态等信息(防止后来的一个进程在多个内核中运行),将env结构体中保存的寄存器中的信息加在到真正的寄存器中,接下来便执行eip所指向的内容,一个进程便可以准备启动了。
但真正启动一个进程还需要在lib/libmain.c 文件中修改一下 libmain() 函数,使它能够初始化全局指针 thisenv ,让它指向当前用户环境的 Env 结构体。libmain() 会调用 umain,即用户进程的main函数。
当进程产生后,很重要的就是处理中断和异常,因此,接下来我们开始讨论中断和异常的处理。
为了保护中断和异常时切入内核是安全且受保护的。jos为了做到这一点,使用了中断描述符表(IDT)和任务状态段(TSS)。
首先来看中断描述符表。处理器将确保从一些内核预先定义的条目才能进入内核,而不是由中断或异常发生时运行的代码决定。不同的中断条目代表中断来源:不同的设备以及错误类型,CPU 利用这些向量作为中断描述符表的索引。从表中恰当的条目,处理器可以获得需要加载到指令指针寄存器(EIP)的值(该值指向内核中处理这类异常的代码),以及代码段寄存器(CS)的值,其中最低两位表示优先级。
而任务状态段处则需要处理器保存中断和异常出现时的自身状态,例如 EIP 和 CS,以便处理完后能返回原函数继续执行。但是存储区域必须禁止用户访问,避免恶意代码或 bug 的破坏。他也定义需要切换的内核栈(数据的压入位置)
为了完成上述功能,我们要为每个处理中断的函数声明并注册,在jos的实现里,在trapentry.S中声明处理函数,在trap.c中为其注册。整个当系统在i386_init时,便调用此初始化程序了,之后中断发生时,系统就可以捕获中断了。
处理器捕捉到中断,然后切换到内核堆栈,将异常参数压入堆栈(按顺序 push SS, ESP, EFLAGS, CS, EIP
),查询中断向量表,找到对应的表项,把eip的值设为处理中断执行代码地址(_alltraps), 后来执行中断处理函数trap,通过trap_dispatch选择相应的执行代码。执行完成后,恢复被中断的进程的上下文,返回用户态,继续运行这个进程(trap_dispatch是根据传入的保存着各个寄存器的结构体信息中的trapno来进行选择执行哪一个函数,最终执行系统调用处理异常/中断),接下来的一些调用的实现(断点异常、系统调用),因为其他博客写的都很清楚,我就不再描述了。
lab3虽然描写了进程的创建以及中断的处理,但是并不是特别的明朗,因为只有一个进程在运行,所以很多东西没有体现出来。到lab4就开始了多核多进程的的处理,应该会使我们能更清晰的了解进程的运行,由于之前的铺垫已经很多了,所以lab4的某些地方会比较简略
多个cpu的初始化我就不多说了(因为我不是特别了解),但是需要注意的是,每个cpu都应该有自己的内核栈,防止中断时压栈导致错误。同时,由于每个进程都有自己的页目录,所以,不同的进程需要切换页目录,这个功能就是由lcr3函数实现的。
接下来我们直接看多个内核竞争内核资源的问题:如果同一时刻,不同cpu同时访问内核区域,便会造成资源的竞争,因此,便要给内核上锁,同时只允许一个cpu访问。jos中是以大内核锁实现的,1.在唤醒其他内核, 2.在用户陷入内核态时,3初始化应用处理器之后获得内核锁,在切换回用户模式前释放内核锁。
接下来是设置不同进程的调度问题:
jos采用的是轮询的方式,就是循环遍历envs数组(根据ENVX寻找每个进程),选择"就绪"的进程,每次运行一个进程。(在lab3中我们已经提到了,每当一个进程运行前,就会将他存储的属性状态设为runing,保证一个进程不会在多个cpu中运行)
这个时候我们需要添加新的系统调用,不然就无法切换进程了(系统调用在lab3中也交代过了,就不再多说)
一个常见的功能就是fork函数,进程拷贝,因为前一种拷贝所有信息的方式没有被采用,所以这里我们直接说 只拷贝页面映射的方式。
为什么可以只拷贝页面映射调用了fork()
之后往往立即就会在子进程中用exec()
,将子进程的内存更换为新的程序。这样,复制父进程的内存这个操作就完全浪费了,因此,让父、子进程共享同一片物理内存,直到某个进程修改了内存。这样可以大大的减少资源的浪费。
jos的具体实现:
主要是由duppage函数实现拷贝父子进程需要的页面
pgdault处理页面错误,页面错误证明了此时父子进程的虚拟页面应该指向不同的物理页面。此时就需要重新分配空间,具体的流程是:先拷贝父进程的物理页面,而后将子进程的虚拟地址映射到新的物理页面,并消除原来的映射。之后再对内容更改。
接着便要实现多进程的抢占式处理,在之前,子进程开启后就陷入死循环,此后 kernel 无法再获得控制权。我们需要让硬件周期性地产生时钟中断,强制将控制权交给 kernel,使得我们能够切换到其他进程,因此我们在trapdistch中添加时钟中断。
之后看一下进程间通信:他有两种方式,一种是只发送一个数字,一种是页面共享。
jos具体的实现是ipc_recv函数和ipc_send函数。他的进程通讯还是比较简陋的,当一个进程调用接受消息的函数后,会阻塞在那,知道某个进程向他发送了消息。发送消息要做多项检测,而且,如果没有收到返回消息,就会一直发送。共享页面也是同样的道理。
到此,lab4的就结束了。
可能会发现,lab3、lab4的介绍简单了许多。这是因为,很多东西在其他博客上已经写得很详细了,没有必要多说(可以去看我开始给出的一些链接)。
此外我只想给出每个实验的概括性思想,而后边的实验很难一步一步连起来,我只能拆分开一个一个的讲,便显得有些散了。由于我并不熟悉汇编,所以题目中有些设计汇编的细节,被我忽略掉了。
最后我还要介绍一下整个4G空间的使用情况,如图:
整个空间中的一些重要的信息都被我标注了一下,方便查看
如果文章有什么错误,欢迎指正~