diff --git a/lab2.md b/lab2.md new file mode 100644 index 0000000..d2074e1 --- /dev/null +++ b/lab2.md @@ -0,0 +1,978 @@ +# 实验二:物理内存管理 + +## 1 实验目的 + +* 理解基于段页式内存地址的转换机制 +* 理解页表的建立和使用方法 +* 理解物理内存的管理方法 + +## 2 实验内容 + +实验一过后大家做出来了一个可以启动的系统,实验二主要涉及操作系统的物理内存管理。操作系统为了使用内存,还需高效地管理内存资源。在实验二中大家会了解并且自己动手完成一个简单的物理内存管理系统。 + +本次实验包含三个部分。首先了解如何发现系统中的物理内存;然后了解如何建立对物理内存的初步管理,即了解连续物理内存管理;最后了解页表相关的操作,即如何建立页表来实现虚拟内存到物理内存之间的映射,对段页式内存管理机制有一个比较全面的了解。本实验里面实现的内存管理还是非常基本的,并没有涉及到对实际机器的优化,比如 +针对 cache 的优化等。实际操作系统(如Linux等)中的内存管理是相当复杂的。 +如果大家有余力,尝试完成扩展练习。 + +### 2.1 练习 + +**练习0:填写已有实验** + +本实验依赖实验1。请把你做的实验1的代码填入本实验中代码中有“LAB1”的注释相应部分。提示:可采用merge工具,比如kdiff3,eclipse中的diff/merge工具,understand中的diff/merge工具等。 + +**练习1:实现 first-fit 连续物理内存分配算法(需要编程)** + +在实现first fit +内存分配算法的回收函数时,要考虑地址连续的空闲块之间的合并操作。提示:在建立空闲页块链表时,需要按照空闲页块起始地址来排序,形成一个有序的链表。可能会修改default\_pmm.c中的default\_init,default\_init\_memmap,default\_alloc\_pages, +default\_free\_pages等相关函数。请仔细查看和理解default\_pmm.c中的注释。 + +**练习2:实现寻找虚拟地址对应的页表项(需要编程)** + +通过设置页表和对应的页表项,可建立虚拟内存地址和物理内存地址的对应关系。其中的get\_pte函数是设置页表项环节中的一个重要步骤。此函数找到一个虚地址对应的二级页表项的内核虚地址,如果此二级页表项不存在,则分配一个包含此项的二级页表。本练习需要补全get\_pte函数 +in +kern/mm/pmm.c,实现其功能。请仔细查看和理解get\_pte函数中的注释。get\_pte函数的调用关系图如下所示: + +![image](lab2.files/image001.png) +图1 get\_pte函数的调用关系图 + +**练习3:释放某虚地址所在的页并取消对应二级页表项的映射(需要编程)** + +当释放一个包含某虚地址的物理内存页时,需要让对应此物理内存页的管理数据结构Page做相关的清除处理,使得此物理内存页成为空闲;另外还需把表示虚地址与物理地址对应关系的二级页表项清除。请仔细查看和理解page\_remove\_pte函数中的注释。为此,需要补全在 +kern/mm/pmm.c中的page\_remove\_pte函数。page\_remove\_pte函数的调用关系图如下所示: + +![image](lab2.files/image002.png) +图2 page\_remove\_pte函数的调用关系图 + +**扩展练习Challenge:任意大小的内存单元slub分配算法(需要编程)** + +如果觉得上诉练习难度不够,可考虑完成此扩展练习。实现两层架构的高效内存单元分配,第一层是基于页大小的内存分配,第二层是在第一层基础上实现基于任意大小的内存分配。比如,如果连续分配8个16字节的内存块,当分配完毕后,实际只消耗了一个空闲物理页。要求时空都高效,可参考slub算法来实现,可简化实现,能够体现其主体思想即可。要求有设计文档。slub相关网页在[http://www.ibm.com/developerworks/cn/linux/l-cn-slub/](http://www.ibm.com/developerworks/cn/linux/l-cn-slub/) +。完成challenge的同学可单独提交challenge。完成得好的同学可获得最终考试成绩的加分。 + +### 2.2项目组成 + +表1: 实验二文件列表 + +|-- boot +| |-- asm.h +| |-- bootasm.S +| \`-- bootmain.c +|-- kern +| |-- init +| | |-- entry.S +| | \`-- init.c +| |-- mm +| | |-- default\_pmm.c +| | |-- default\_pmm.h +| | |-- memlayout.h +| | |-- mmu.h +| | |-- pmm.c +| | \`-- pmm.h +| |-- sync +| | \`-- sync.h +| \`-- trap +| |-- trap.c +| |-- trapentry.S +| |-- trap.h +| \`-- vectors.S +|-- libs +| |-- atomic.h +| |-- list.h +\`-- tools +|-- kernel.ld + +相对与实验一,实验二主要增加和修改的文件如上表红色部分所示。主要改动如下: + +* boot/bootasm.S:增加了对计算机系统中物理内存布局的探测功能; +* kern/init/entry.S:根据临时段表重新暂时建立好新的段空间,为进行分页做好准备。 +* kern/mm/default\_pmm.[ch]:提供基本的基于链表方法的物理内存管理(分配单位为页,即4096字节); +* kern/mm/pmm.[ch]:pmm.h定义物理内存管理类框架struct +pmm\_manager,基于此通用框架可以实现不同的物理内存管理策略和算法(default\_pmm.[ch] +实现了一个基于此框架的简单物理内存管理策略); +pmm.c包含了对此物理内存管理类框架的访问,以及与建立、修改、访问页表相关的各种函数实现。 +* kern/sync/sync.h:为确保内存管理修改相关数据时不被中断打断,提供两个功能,一个是保存eflag寄存器中的中断屏蔽位信息并屏蔽中断的功能,另一个是根据保存的中断屏蔽位信息来使能中断的功能;(可不用细看) +* libs/list.h:定义了通用双向链表结构以及相关的查找、插入等基本操作,这是建立基于链表方法的物理内存管理(以及其他内核功能)的基础。其他有类似双向链表需求的内核功能模块可直接使用list.h中定义的函数。 +* libs/atomic.h:定义了对一个变量进行读写的原子操作,确保相关操作不被中断打断。(可不用细看) +* tools/kernel.ld:ld形成执行文件的地址所用到的链接脚本。修改了ucore的起始入口和代码段的起始地址。相关细节可参看附录C。 + +**编译方法** + +编译并运行代码的命令如下: +``` +make + +make qemu +``` +则可以得到如下显示界面(仅供参考) +``` +chenyu$ make qemu +(THU.CST) os is loading ... + +Special kernel symbols: + entry 0xc010002c (phys) + etext 0xc010537f (phys) + edata 0xc01169b8 (phys) + end 0xc01178dc (phys) +Kernel executable memory footprint: 95KB +memory managment: default_pmm_manager +e820map: + memory: 0009f400, [00000000, 0009f3ff], type = 1. + memory: 00000c00, [0009f400, 0009ffff], type = 2. + memory: 00010000, [000f0000, 000fffff], type = 2. + memory: 07efd000, [00100000, 07ffcfff], type = 1. + memory: 00003000, [07ffd000, 07ffffff], type = 2. + memory: 00040000, [fffc0000, ffffffff], type = 2. +check_alloc_page() succeeded! +check_pgdir() succeeded! +check_boot_pgdir() succeeded! +-------------------- BEGIN -------------------- +PDE(0e0) c0000000-f8000000 38000000 urw + |-- PTE(38000) c0000000-f8000000 38000000 -rw +PDE(001) fac00000-fb000000 00400000 -rw + |-- PTE(000e0) faf00000-fafe0000 000e0000 urw + |-- PTE(00001) fafeb000-fafec000 00001000 -rw +--------------------- END --------------------- +++ setup timer interrupts +100 ticks +100 ticks +…… +``` +通过上图,我们可以看到ucore在显示其entry(入口地址)、etext(代码段截止处地址)、edata(数据段截止处地址)、和end(ucore截止处地址)的值后,探测出计算机系统中的物理内存的布局(e820map下的显示内容)。接下来ucore会以页为最小分配单位实现一个简单的内存分配管理,完成二级页表的建立,进入分页模式,执行各种我们设置的检查,最后显示ucore建立好的二级页表内容,并在分页模式下响应时钟中断。 + +## 3 物理内存管理 + +### 3.1 实验执行流程概述 + +本次实验主要完成ucore内核对物理内存的管理工作。参考ucore总控函数kern\_init的代码,可以清楚地看到在调用完成物理内存初始化的pmm\_init函数之前和之后,是已有lab1实验的工作,好像没啥修改。其实不然,ucore有两个方面的扩展。首先,bootloader的工作有增加,在bootloader中,完成了对物理内存资源的探测工作(可进一步参阅附录A和附录B),让ucore +kernel在后续执行中能够基于bootloader探测出的物理内存情况进行物理内存管理初始化工作。其次,bootloader不像lab1那样,直接调用kern\_init函数,而是先调用位于lab2/kern/init/entry.S中的kern\_entry函数。kern\_entry函数的主要任务是为执行kern\_init建立一个良好的C语言运行环境(设置堆栈),而且临时建立了一个段映射关系,为之后建立分页机制的过程做一个准备(细节在3.5小节有进一步阐述)。完成这些工作后,才调用kern\_init函数。 + +kern\_init函数在完成一些输出并对lab1实验结果的检查后,将进入物理内存管理初始化的工作,即调用pmm\_init函数完成物理内存的管理,这也是我们lab2的内容。接着是执行中断和异常相关的初始化工作,即调用pic\_init函数和idt\_init函数等,这些工作与lab1的中断异常初始化工作的内容是相同的。 + +为了完成物理内存管理,这里首先需要探测可用的物理内存资源;了解到物理内存位于什么地方,有多大之后,就以固定页面大小来划分整个物理内存空间,并准备以此为最小内存分配单位来管理整个物理内存,管理在内核运行过程中每页内存,设定其可用状态(free的,used的,还是reserved的),这其实就对应了我们在课本上讲到的连续内存分配概念和原理的具体实现;接着ucore +kernel就要建立页表, +启动分页机制,让CPU的MMU把预先建立好的页表中的页表项读入到TLB中,根据页表项描述的虚拟页(Page)与物理页帧(Page +Frame)的对应关系完成CPU对内存的读、写和执行操作。这一部分其实就对应了我们在课本上讲到内存映射、页表、多级页表等概念和原理的具体实现。 + +在代码分析上,建议根据执行流程来直接看源代码,并可采用GDB源码调试的手段来动态地分析ucore的执行过程。内存管理相关的总体控制函数是pmm\_init函数,它完成的主要工作包括: + +1. 初始化物理内存页管理器框架pmm\_manager; +2. 建立空闲的page链表,这样就可以分配以页(4KB)为单位的空闲内存了; +3. 检查物理内存页分配算法; +4. 为确保切换到分页机制后,代码能够正常执行,先建立一个临时二级页表; +5. 建立一一映射关系的二级页表; +6. 使能分页机制; +7. 从新设置全局段描述符表; +8. 取消临时二级页表; +9. 检查页表建立是否正确; +10. 通过自映射机制完成页表的打印输出(这部分是扩展知识) + +另外,主要注意的相关代码内容包括: + +* boot/bootasm.S中探测内存部分(从probe\_memory到finish\_probe的代码); +* 管理每个物理页的Page数据结构(在mm/memlayout.h中),这个数据结构也是实现连续物理内存分配算法的关键数据结构,可通过此数据结构来完成空闲块的链接和信息存储,而基于这个数据结构的管理物理页数组起始地址就是全局变量pages,具体初始化此数组的函数位于page\_init函数中; +* 用于实现连续物理内存分配算法的物理内存页管理器框架pmm\_manager,这个数据结构定义了实现内存分配算法的关键函数指针,而同学需要完成这些函数的具体实现; +* 设定二级页表和建立页表项以完成虚实地址映射关系,这与硬件相关,且用到不少内联函数,源代码相对难懂一些。具体完成页表和页表项建立的重要函数是boot\_map\_segment函数,而get\_pte函数是完成虚实映射关键的关键。 + +### 3.2 探测系统物理内存布局 + +当 ucore +被启动之后,最重要的事情就是知道还有多少内存可用,一般来说,获取内存大小的方法由 +BIOS 中断调用和直接探测两种。但BIOS +中断调用方法是一般只能在实模式下完成,而直接探测方法必须在保护模式下完成。通过 +BIOS 中断获取内存布局有三种方式,都是基于INT 15h中断,分别为88h e801h +e820h。但是 并非在所有情况下这三种方式都能工作。在 Linux kernel +里,采用的方法是依次尝试这三 +种方法。而在本实验中,我们通过e820h中断获取内存信息。因为e820h中断必须在实模式下使用,所以我们在 +bootloader 进入保护模式之前调用这个 BIOS 中断,并且把 e820 映 +射结构保存在物理地址0x8000处。具体实现详见boot/bootasm.S。有关探测系统物理内存方法和具体实现的 +信息参见[附录A和附录B。](http://wiki.osdev.org/How_Do_I_Determine_The_Amount_Of_RAM) + +### 3.3 以页为单位管理物理内存 + +在获得可用物理内存范围后,系统需要建立相应的数据结构来管理以物理页(按4KB对齐,且大小为4KB的物理内存单元)为最小单位的整个物理内存,以配合后续涉及的分页管理机制。每个物理页可以用一个 +Page数据结构来表示。由于一个物理页需要占用一个Page结构的空间,Page结构在设计时须尽可能小,以减少对内存的占用。Page的定义在kern/mm/memlayout.h中。以页为单位的物理内存分配管理的实现在kern/default\_pmm.[ch]。 + +为了与以后的分页机制配合,我们首先需要建立对整个计算机的每一个物理页的属性用结构Page来表示,它包含了映射此物理页的虚拟页个数,描述物理页属性的flags和双向链接各个Page结构的page\_link双向链表。 +``` +struct Page { + int ref; // page frame's reference counter + uint32_t flags; // array of flags that describe the status of the page frame + unsigned int property;// the num of free block, used in first fit pm manager + list_entry_t page_link;// free list link +}; +``` +这里看看Page数据结构的各个成员变量有何具体含义。ref表示这样页被页表的引用记数(在“实现分页机制”一节会讲到)。如果这个页被页表引用了,即在某页表中有一个页表项设置了一个虚拟页到这个Page管理的物理页的映射关系,就会把Page的ref加一;反之,若页表项取消,即映射关系解除,就会把Page的ref减一。flags表示此物理页的状态标记,进一步查看kern/mm/memlayout.h中的定义,可以看到: +``` +/* Flags describing the status of a page frame */ +#define PG_reserved 0 // the page descriptor is reserved for kernel or unusable +#define PG_property 1 // the member 'property' is valid +``` +这表示flags目前用到了两个bit表示页目前具有的两种属性,bit +0表示此页是否被保留(reserved),如果是被保留的页,则bit +0会设置为1,且不能放到空闲页链表中,即这样的页不是空闲页,不能动态分配与释放。比如目前内核代码占用的空间就属于这样“被保留”的页。在本实验中,bit +1表示此页是否是free的,如果设置为1,表示这页是free的,可以被分配;如果设置为0,表示这页已经被分配出去了,不能被再二次分配。另外,本实验这里取的名字PG\_property比较不直观 +,主要是我们可以设计不同的页分配算法(best fit, buddy +system等),那么这个PG\_property就有不同的含义了。 + +在本实验中,Page数据结构的成员变量property用来记录某连续内存空闲块的大小(即地址连续的空闲页的个数)。这里需要注意的是用到此成员变量的这个Page比较特殊,是这个连续内存空闲块地址最小的一页(即头一页, +Head +Page)。连续内存空闲块利用这个页的成员变量property来记录在此块内的空闲页的个数。这里去的名字property也不是很直观,原因与上面类似,在不同的页分配算法中,property有不同的含义。 + +Page数据结构的成员变量page\_link是便于把多个连续内存空闲块链接在一起的双向链表指针(可回顾在lab0实验指导书中有关双向链表数据结构的介绍)。这里需要注意的是用到此成员变量的这个Page比较特殊,是这个连续内存空闲块地址最小的一页(即头一页, +Head +Page)。连续内存空闲块利用这个页的成员变量page\_link来链接比它地址小和大的其他连续内存空闲块。 + +在初始情况下,也许这个物理内存的空闲物理页都是连续的,这样就形成了一个大的连续内存空闲块。但随着物理页的分配与释放,这个大的连续内存空闲块会分裂为一系列地址不连续的多个小连续内存空闲块,且每个连续内存空闲块内部的物理页是连续的。那么为了有效地管理这些小连续内存空闲块。所有的连续内存空闲块可用一个双向链表管理起来,便于分配和释放,为此定义了一个free\_area\_t数据结构,包含了一个list\_entry结构的双向链表指针和记录当前空闲页的个数的无符号整型变量nr\_free。其中的链表指针指向了空闲的物理页。 +``` +/* free_area_t - maintains a doubly linked list to record free (unused) pages */ +typedef struct { + list_entry_t free_list; // the list header + unsigned int nr_free; // # of free pages in this free list +} free_area_t; +``` +有了这两个数据结构,ucore就可以管理起来整个以页为单位的物理内存空间。接下来需要解决两个问题: + +• 管理页级物理内存空间所需的Page结构的内存空间从哪里开始,占多大空间? +• 空闲内存空间的起始地址在哪里? + +对于这两个问题,我们首先根据bootloader给出的内存布局信息找出最大的物理内存地址maxpa(定义在page\_init函数中的局部变量),由于x86的起始物理内存地址为0,所以可以得知需要管理的物理页个数为 +``` +npage = maxpa / PGSIZE +``` +这样,我们就可以预估出管理页级物理内存空间所需的Page结构的内存空间所需的内存大小为: +``` +sizeof(struct Page) * npage) +``` +由于bootloader加载ucore的结束地址(用全局指针变量end记录)以上的空间没有被使用,所以我们可以把end按页大小为边界去整后,作为管理页级物理内存空间所需的Page结构的内存空间,记为: +``` +pages = (struct Page *)ROUNDUP((void *)end, PGSIZE); +``` +为了简化起见,从地址0到地址pages+ sizeof(struct Page) \* +npage)结束的物理内存空间设定为已占用物理内存空间(起始0\~640KB的空间是空闲的),地址pages+ +sizeof(struct Page) \* +npage)以上的空间为空闲物理内存空间,这时的空闲空间起始地址为 +``` +uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage); +``` +为此我们需要把这两部分空间给标识出来。首先,对于所有物理空间,通过如下语句即可实现占用标记: +``` +for (i = 0; i < npage; i ++) { +SetPageReserved(pages + i); +} +```` +然后,根据探测到的空闲物理空间,通过如下语句即可实现空闲标记: +``` +//获得空闲空间的起始地址begin和结束地址end +…… +init_memmap(pa2page(begin), (end - begin) / PGSIZE); +``` +其实SetPageReserved只需把物理地址对应的Page结构中的flags标志设置为PG\_reserved +,表示这些页已经被使用了,将来不能被用于分配。而init\_memmap函数则是把空闲物理页对应的Page结构中的flags和引用计数ref清零,并加到free\_area.free\_list指向的双向列表中,为将来的空闲页管理做好初始化准备工作。 + +关于内存分配的操作系统原理方面的知识有很多,但在本实验中只实现了最简单的内存页分配算法。相应的实现在default\_pmm.c中的default\_alloc\_pages函数和default\_free\_pages函数,相关实现很简单,这里就不具体分析了,直接看源码,应该很好理解。 + +其实实验二在内存分配和释放方面最主要的作用是建立了一个物理内存页管理器框架,这实际上是一个函数指针列表,定义如下: +``` +struct pmm_manager { + const char *name; //物理内存页管理器的名字 + void (*init)(void); //初始化内存管理器 + void (*init_memmap)(struct Page *base, size_t n); //初始化管理空闲内存页的数据结构 + struct Page *(*alloc_pages)(size_t n); //分配n个物理内存页 + void (*free_pages)(struct Page *base, size_t n); //释放n个物理内存页 + size_t (*nr_free_pages)(void); //返回当前剩余的空闲页数 + void (*check)(void); //用于检测分配/释放实现是否正确的辅助函数 +}; +``` +重点是实现init\_memmap/ alloc\_pages/ +free\_pages这三个函数。当完成物理内存页管理初始化工作后,计算机系统的内存布局如下图所示: + +![image](lab2.files/image003.png) +图3 计算机系统的内存布局 + +### 3.4 物理内存页分配算法实现 + +如果要在ucore中实现连续物理内存分配算法,则需要考虑的事情比较多,相对课本上的物理内存分配算法描述要复杂不少。下面介绍一下如果要实现一个FirstFit内存分配算法的大致流程。 + +lab2的第一部分是完成first\_fit的分配算法。原理FirstFit内存分配算法上很简单,但要在ucore中实现,需要充分了解和利用ucore已有的数据结构和相关操作、关键的一些全局变量等。 + +**关键数据结构和变量** + +first\_fit分配算法需要维护一个查找有序(地址按从小到大排列)空闲块(以页为最小单位的连续地址空间)的数据结构,而双向链表是一个很好的选择。 + +libs/list.h定义了可挂接任意元素的通用双向链表结构和对应的操作,所以需要了解如何使用这个文件提供的各种函数,从而可以完成对双向链表的初始化/插入/删除等。 + +kern/mm/memlayout.h中定义了一个 free\_area\_t 数据结构,包含成员结构 +``` + list_entry_t free_list; // the list header 空闲块双向链表的头 + unsigned int nr_free; // # of free pages in this free list 空闲块的总数(以页为单位) +``` +显然,我们可以通过此数据结构来完成对空闲块的管理。而default\_pmm.c中定义的free\_area变量就是干这个事情的。 + +kern/mm/pmm.h中定义了一个通用的分配算法的函数列表,用pmm\_manager +表示。其中init函数就是用来初始化free\_area变量的, +first\_fit分配算法可直接重用default\_init函数的实现。init\_memmap函数需要根据现有的内存情况构建空闲块列表的初始状态。何时应该执行这个函数呢? + +通过分析代码,可以知道: +``` +kern_init --> pmm_init-->page_init-->init_memmap--> pmm_manager->init_memmap +``` +所以,default\_init\_memmap需要根据page\_init函数中传递过来的参数(某个连续地址的空闲块的起始页,页个数)来建立一个连续内存空闲块的双向链表。这里有一个假定page\_init函数是按地址从小到大的顺序传来的连续内存空闲块的。链表头是free\_area.free\_list,链表项是Page数据结构的base-\>page\_link。这样我们就依靠Page数据结构中的成员变量page\_link形成了连续内存空闲块列表。 + +**设计实现** + +default\_init\_memmap函数讲根据每个物理页帧的情况来建立空闲页链表,且空闲页块应该是根据地址高低形成一个有序链表。根据上述变量的定义,default\_init\_memmap可大致实现如下: +``` +default_init_memmap(struct Page *base, size_t n) { + struct Page *p = base; + for (; p != base + n; p ++) { + p->flags = p->property = 0; + set_page_ref(p, 0); + } + base->property = n; + SetPageProperty(base); + nr_free += n; + list_add(&free_list, &(base->page_link)); +} +``` +如果要分配一个页,那要考虑哪些呢?这里就需要考虑实现default\_alloc\_pages函数,注意参数n表示要分配n个页。另外,需要注意实现时尽量多考虑一些边界情况,这样确保软件的鲁棒性。比如 +``` +if (n > nr_free) { +return NULL; +} +``` +这样可以确保分配不会超出范围。也可加一些 +assert函数,在有错误出现时,能够迅速发现。比如 n应该大于0,我们就可以加上 +``` +assert(n \> 0); +``` +这样在n<=0的情况下,ucore会迅速报错。firstfit需要从空闲链表头开始查找最小的地址,通过list\_next找到下一个空闲块元素,通过le2page宏可以更加链表元素获得对应的Page指针p。通过p-\>property可以了解此空闲块的大小。如果\>=n,这就找到了!如果 nr_free) { + return NULL; + } + struct Page *page = NULL; + list_entry_t *le = &free_list; + while ((le = list_next(le)) != &free_list) { + struct Page *p = le2page(le, page_link); + if (p->property >= n) { + page = p; + break; + } + } + if (page != NULL) { + list_del(&(page->page_link)); + if (page->property > n) { + struct Page *p = page + n; + p->property = page->property - n; + list_add(&free_list, &(p->page_link)); + } + nr_free -= n; + ClearPageProperty(page); + } + return page; +} +``` +default\_free\_pages函数的实现其实是default\_alloc\_pages的逆过程,不过需要考虑空闲块的合并问题。这里就不再细讲了。注意,上诉代码只是参考设计,不是完整的正确设计。更详细的说明位于lab2/kernel/mm/default\_pmm.c的注释中。希望同学能够顺利完成本实验的第一部分。 + +### 3.5 实现分页机制 + +#### 3.5.1 段页式管理基本概念 + +如图4在保护模式中,x86 +体系结构将内存地址分成三种:逻辑地址(也称虚地址)、线性地址和物理地址。逻辑地址即是程序指令中使用的地址,物理地址是实际访问内存的地址。逻 +辑地址通过段式管理的地址映射可以得到线性地址,线性地址通过页式管理的地址映射得到物理地址。 + + ![image](lab2.files/image004.png) + 图 4 段页式管理总体框架图 + +段式管理前一个实验已经讨论过。在 ucore +中段式管理只起到了一个过渡作用,它将逻辑地址不加转换直接映射成线性地址,所以我们在下面的讨论中可以对这两个地址不加区分(目前的 +OS 实现也是不加区分的)。对段式管理有兴趣的同学可以参照《Intel® 64 and +IA-32Architectures Software Developer ’s Manual – Volume 3A》3.2 节。 + +![image](lab2.files/image005.png)如图5所示,页式管理将线性地址分成三部分(图中的 +Linear Address 的 Directory 部分、 Table 部分和 Offset 部分)。ucore +的页式管理通过一个二级的页表实现。一级页表的起始物理地址存放在 cr3 +寄存器中,这个地址必须是一个页对齐的地址,也就是低 12 位必须为 +0。目前,ucore 用boot\_cr3(mm/pmm.c)记录这个值。 + +![image](lab2.files/image006.png) +图 5 分页机制管理 + +### 3.5.2建立段页式管理中需要考虑的关键问题 + +为了实现分页机制,需要建立好虚拟内存和物理内存的页映射关系,即正确建立二级页表。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题: + +* 如何在建立页表的过程中维护全局段描述符表(GDT)和页表的关系,确保ucore能够在各个时间段上都能正常寻址? +* 对于哪些物理内存空间需要建立页映射关系? +* 具体的页映射关系是什么? +* 页目录表的起始地址设置在哪里? +* 页表的起始地址设置在哪里,需要多大空间? +* 如何设置页目录表项的内容? +* 如何设置页表项的内容? + +### 3.5.3建立虚拟页和物理页帧的地址映射关系 + +**从链接脚本分析 ucore 执行时的地址** + +首先观察一下tools/kernel.ld文件在lab1和lab2中的区别。在lab1中: +``` +ENTRY(kern_init) + +SECTIONS { + /* Load the kernel at this address: "." means the current address */ + . = 0x100000; + + .text : { + *(.text .stub .text.* .gnu.linkonce.t.*) + } +``` +这意味着在lab1中通过ld工具形成的ucore的起始虚拟地址从0x100000开始,注意:这个地址是虚拟地址。但由于lab1中建立的段地址映射关系为对等关系,所以ucore的物理地址也是0x100000。而入口函数为kern\_init函数。在lab2中: +``` +ENTRY(kern_entry) + +SECTIONS { + /* Load the kernel at this address: "." means the current address */ + . = 0xC0100000; + + .text : { + *(.text .stub .text.* .gnu.linkonce.t.*) + } +``` +这意味着lab2中通过ld工具形成的ucore的起始虚拟地址从0xC0100000开始,注意:这个地址也是虚拟地址。入口函数为kern\_entry函数。这与lab1有很大差别。但其实在lab1和lab2中,bootloader把ucore都放在了起始物理地址为0x100000的物理内存空间。这实际上说明了ucore在lab1和lab2中采用的地址映射不同: + +lab1: virtual addr = linear addr = phy addr +lab2: virtual addr = linear addr = phy addr + 0xC0000000 + +lab1只采用了段映射机制,但在lab2中,启动好分页管理机制后,形成的是段页式映射机制,从而使得虚拟地址空间和物理地址空间之间存在如下的映射关系: + +Virtual Address=LinearAddress=0xC0000000+Physical Address + +另外,ucore的入口地址也改为了kern\_entry函数,这个函数位于init/entry.S中,分析代码可以看出,entry.S重新建立了段映射关系,从以前的 + +Virtual Address= Linear Address + +改为 + +Virtual Address=Linear Address-0xC0000000 + +由于gcc编译出的虚拟起始地址从0xC0100000开始,ucore被bootloader放置在从物理地址0x100000处开始的物理内存中。所以当kern\_entry函数完成新的段映射关系后,且ucore在没有建立好页映射机制前,CPU按照ucore中的虚拟地址执行,能够被分段机制映射到正确的物理地址上,确保ucore运行正确。 + +由于物理内存页管理器管理了从0到实际可用物理内存大小的物理内存空间,所以对于这些物理内存空间都需要建立好页映射关系。由于目前ucore只运行在内核空间,所以可以建立一个一一映射关系。假定内核虚拟地址空间的起始地址为0xC0000000,则虚拟内存和物理内存的具体页映射关系为: + +Virtual Address=Physical Address+0xC0000000 + +**建立二级页表** + +由于我们已经具有了一个物理内存页管理器default\_pmm\_manager,我们就可以用它来获得所需的空闲物理页。在二级页表结构中,页目录表占4KB空间,ucore就可通过default\_pmm\_manager的default\_alloc\_pages函数获得一个空闲物理页,这个页的起始物理地址就是页目录表的起始地址。同理,ucore也通过这种方式获得各个页表所需的空间。页表的空间大小取决与页表要管理的物理页数n,一个页表项(32位,即4字节)可管理一个物理页,页表需要占n/256个物理页空间。这样页目录表和页表所占的总大小为4096+1024\*n字节。 + +为把0\~KERNSIZE(明确ucore设定实际物理内存不能超过KERNSIZE值,即0x38000000字节,896MB,3670016个物理页)的物理地址一一映射到页目录表项和页表项的内容,其大致流程如下: + +1. 先通过default\_pmm\_manager获得一个空闲物理页,用于页目录表; +2. 调用boot\_map\_segment函数建立一一映射关系,具体处理过程以页为单位进行设置,即 + +Virtual Address=Physical Address+0xC0000000 + +Ø 设一个逻辑地址la(按页对齐,故低12位为零)对应的物理地址pa(按页对齐,故低12位为零),如果在页目录表项(la的高10位为索引值)中的存在位(PTE\_P)为0,表示缺少对应的页表空间,则可通过default\_pmm\_manager获得一个空闲物理页给页表,页表起始物理地址是按4096字节对齐的,这样填写页目录表项的内容为 + +***页目录表项内容 = 页表起始物理地址 | PTE_U | PTE_W | PTE\_P*** + +进一步对于页表中对应页表项(la的中10位为索引值)的内容为 + +***页表项内容 = pa | PTE\_P | PTE\_W*** + +其中: + +* PTE\_U:位3,表示用户态的软件可以读取对应地址的物理内存页内容 +* PTE\_W:位2,表示物理内存页内容可写 +* PTE\_P:位1,表示物理内存页存在 + +ucore +的内存管理经常需要查找页表:给定一个虚拟地址,找出这个虚拟地址在二级页表中对应的项。通过更改此项的值可以方便地将虚拟地址映射到另外的页上。可完成此功能的这个函数是get\_pte函数。它的原型为 +``` +pte_t *get_pte (pde_t *pgdir, uintptr_t la, bool create) +``` +下面的调用关系图可以比较好地看出get\_pte在实现上诉流程中的位置: + +![image](lab2.files/image007.png) +图6 get\_pte调用关系图 + +这里涉及到三个类型pte t、pde t和uintptr +t。通过参见mm/mmlayout.h和libs/types.h,可知它们其实都是unsigned +int类型。在此做区分,是为了分清概念。 + +pde\_t全称为 page directory +entry,也就是一级页表的表项(注意:pgdir实际不是表 +项,而是一级页表本身。实际上应该新定义一个类型pgd\_t来表示一级页表本身)。pte +t全 称为 page table entry,表示二级页表的表项。uintptr +t表示为线性地址,由于段式管理只做直接映射,所以它也是逻辑地址。 + +pgdir给出页表起始地址。通过查找这个页表,我们需要给出二级页表中对应项的地址。 +虽然目前我们只有boot\_pgdir一个页表,但是引入进程的概念之后每个进程都会有自己的页 +表。 + +有可能根本就没有对应的二级页表的情况,所以二级页表不必要一开始就分配,而是等到需要的时候再添加对应的二级页表。如果在查找二级页表项时,发现对应的二级页表不存在,则需要根据create参数的值来处理是否创建新的二级页表。如果create参数为0,则get\_pte返回NULL;如果create参数不为0,则get\_pte需要申请一个新的物理页(通过alloc\_page来实现,可在mm/pmm.h中找到它的定义),再在一级页表中添加页目录表项指向表示二级页表的新物理页。注意,新申请的页必须全部设定为零,因为这个页所代表的虚拟地址都没有被映射。 + +当建立从一级页表到二级页表的映射时,需要注意设置控制位。这里应该设置同时设置 +上PTE\_U、PTE\_W和PTE\_P(定义可在mm/mmu.h)。如果原来就有二级页表,或者新建立了页表,则只需返回对应项的地址即可。 + +虚拟地址只有映射上了物理页才可以正常的读写。在完成映射物理页的过程中,除了要象上面那样在页表的对应表项上填上相应的物理地址外,还要设置正确的控制位。有关 +x86 中页表控制位的详细信息,请参照《Intel® 64 and IA-32 Architectures +Software Developer ’s Manual – Volume 3A》4.11 节。 + +只有当一级二级页表的项都设置了用户写权限后,用户才能对对应的物理地址进行读写。 +所以我们可以在一级页表先给用户写权限,再在二级页表上面根据需要限制用户的权限,对物理页进行保护。由于一个物理页可能被映射到不同的虚拟地址上去(譬如一块内存在不同进程 +间共享),当这个页需要在一个地址上解除映射时,操作系统不能直接把这个页回收,而是要先看看它还有没有映射到别的虚拟地址上。这是通过查找管理该物理页的Page数据结构的成员变量ref(用来表示虚拟页到物理页的映射关系的个数)来实现的,如果ref为0了,表示没有虚拟页到物理页的映射关系了,就可以把这个物理页给回收了,从而这个物理页是free的了,可以再被分配。page\_insert函数将物理页映射在了页表上。可参看page\_insert函数的实现来了解ucore内核是如何维护这个变量的。当不需要再访问这块虚拟地址时,可以把这块物理页回收并在将来用在其他地方。取消映射由page\_remove来做,这其实是page +insert的逆操作。 + +建立好一一映射的二级页表结构后,接下来就要使能分页机制了,这主要是通过enable\_paging函数实现的,这个函数主要做了两件事: + +1. 通过lcr3指令把页目录表的起始地址存入CR3寄存器中; + +2. 通过lcr0指令把cr0中的CR0\_PG标志位设置上。 + +执行完enable\_paging函数后,计算机系统进入了分页模式!但到这一步还不够,还记得ucore在最开始通过kern\_entry函数设置了临时的新段映射机制吗?这个临时的新段映射机制不是最简单的对等映射,导致虚拟地址和线性地址不相等。而刚才建立的页映射关系是建立在简单的段对等映射,即虚拟地址=线性地址的假设基础之上的。所以我们需要进一步调整段映射关系,即重新设置新的GDT,建立对等段映射。 + +这里需要注意:在进入分页模式到重新设置新GDT的过程是一个过渡过程。在这个过渡过程中,已经建立了页表机制,所以通过现在的段机制和页机制实现的地址映射关系为: +``` +Virtual Address=Linear Address + 0xC0000000 = Physical Address ++0xC0000000+0xC0000000 +``` +在这个特殊的阶段,如果不把段映射关系改为Virtual Address = Linear +Address,则通过段页式两次地址转换后,无法得到正确的物理地址。为此我们需要进一步调用gdt\_init函数,根据新的gdt全局段描述符表内容(gdt定义位于pmm.c中),恢复以前的段映射关系,即使得Virtual +Address = Linear +Address。这样在执行完gdt\_init后,通过的段机制和页机制实现的地址映射关系为: +``` +Virtual Address=Linear Address = Physical Address +0xC0000000 +``` +这里存在的一个问题是,在调用enable\_page函数使能分页机制后到执行完毕gdt\_init函数重新建立好段页式映射机制的过程中,内核使用的还是旧的段表映射,也就是说,enable +paging 之后,内核使用的是页表的低地址 entry。 +如何保证此时内核依然能够正常工作呢?其实只需让低地址目录表项的内容等于以KERNBASE开始的高地址目录表项的内容即可。目前内核大小不超过 +4M (实际上是3M,因为内核从 0x100000 +开始编址),这样就只需要让页表在0\~4MB的线性地址与KERNBASE \~ +KERNBASE+4MB的线性地址获得相同的映射即可,都映射到 0\~4MB +的物理地址空间,具体实现在pmm.c中pmm\_init函数的语句: +``` +boot_pgdir[0] = boot_pgdir[PDX(KERNBASE)]; +``` +实际上这种映射也限制了内核的大小。当内核大小超过预期的3MB +就可能导致打开分页之后内核 +crash,在后面的试验中,也的确出现了这种情况。解决方法同样简单,就是拷贝更多的高地址项到低地址。 + +当执行完毕gdt\_init函数后,新的段页式映射已经建立好了,上面的0\~4MB的线性地址与0\~4MB的物理地址一一映射关系已经没有用了。所以可以通过如下语句解除这个老的映射关系。 +``` +boot_pgdir[0] = 0; +``` +在page\_init函数建立完实现物理内存一一映射和页目录表自映射的页目录表和页表后,一旦使能分页机制,则ucore看到的内核虚拟地址空间如下图所示: + +![说明: proj5-vm-map](lab2.files/image008.png) +图7 使能分页机制后的虚拟地址空间图 + +### 3.5.4不同运行阶段的地址映射关系 + +在大多数课本中,描述了基于段的映射关系,基于页的映射关系以及基于段页式的映射关系和CPU访存时对应的地址转换过程。但很少涉及操作系统如何一步一步建立这个映射关系的。其实,在lab1和lab2中都会涉及如何建立映射关系的操作。在lab1中,我们已经碰到到了简单的段映射,即对等映射关系,保证了物理地址和虚拟地址相等,也就是通过建立全局段描述符表,让每个段的基址为0,从而确定了对等映射关系。 + +在lab2中,由于在段地址映射的基础上进一步引入了页地址映射,形成了组合式的段页式地址映射。这种方式虽然更加灵活了,但实现的复杂性也增加了。在lab2中,ucore从计算机加电,启动段式管理机制,启动段页式管理机制,在段页式管理机制下运行这整个过程中,虚地址到物理地址的映射产生了多次变化,接下来我们会逐一进行说明。 + +1. +首先是bootloader地址映射阶段,bootloader完成了与lab1一样的工作,即建立了基于段的对等映射(请查看lab2/boot/bootasm.S中的finish\_probe地址处); +2. +接着进入了ucore启动页机制前的地址映射阶段,ucore建立了一个一一段映射关系,其中虚拟地址 += 物理地址 + +0xC0000000(请查看lab2/kern/init/entry.S中的kern\_entry函数); +3. +再接下来是建立并使能页表的临时段页式地址映射阶段,页表要表示的是线性地址与物理地址的对应关系为:线性地址 += 物理地址 + +0xC0000000;然后这里有一个小技巧,让在0\~4MB的线性地址区域空间的 线性地址 +(0\~4MB)对应的物理地址 = 线性地址 (0xC0000000\~0xC0000000 + +4MB)对应的物理地址,这是通过lab2/kern/mm/pmm.c中第321行的代码实现的: + +boot\_pgdir**[**0**]** **=** boot\_pgdir**[**PDX**(**KERNBASE**)];** + +注意此时CPU在寻址时还是只采用了分段机制。最后后并使能分页映射机制(请查看lab2/kern/mm/pmm.c中的enable\_paging函数),一旦执行完enable\_paging函数中的加载cr0指令(即让CPU使能分页机制),则接下来的访问是基于段页式的映射关系了。对于(0xC0000000\~0xC0000000 ++ 4MB)这块虚拟地址空间,最终会映射到哪些物理地址空间中呢? + +由于段映射关系没有改变,使得经过段映射机制,虚拟地址范围(0xC0000000\~0xC0000000 ++ 4MB)对应的线性地址 = +(0\~4MB)。而根据页表建立过程的描述,我们可知道线性地址空间(0\~4MB)与线性地址空间 +(0xC0000000\~0xC0000000 + +4MB)对应同样的物理地址,而线性地址空间(0xC0000000\~0xC0000000 + +4MB)对应的物理地址空间为(0,4MB)。这样对于(0xC0000000\~0xC0000000 + +4MB)这块虚拟地址空间,段页式的地址映射关系为 虚拟地址 = 线性地址 + +0xC0000000 =物理地址 + 0xC0000000。 + +注意,这只是针对(0xC0000000\~0xC0000000 + +4MB)这块虚拟地址空间。如果是(0xD0000000\~0xD0000000 + +4MB)这块虚拟地址空间,则段页式的地址映射关系为虚拟地址 = 线性地址 + +0xC0000000 =物理地址 + 0xC0000000 + +0xC0000000。这不是我们需要的映射关系,所以0xC0000000 + +4MB以上的虚拟地址访问会出页错误异常。 +4. +最后一步完成收尾工作的正常段页式地址映射阶段,即首先调整段映射关系,这是通过加载新的全局段描述符表(pmm\_init函数调用gdt\_init函数来完成)实现,这时的段映射关系为:虚拟地址 += 线性地址。然后通过执行语句“boot\_pgdir**[**0**]** **=** +0**;**”把boot\_pgdir[0]的第一个页目录表项(0\~4MB)清零来取消临时的页映射关系。至此,新的段页式的地址映射关系为虚拟地址 += 线性地址 =物理地址 + +0xC0000000。这也形成了ucore操作系统的内核虚拟地址空间的段页式映射关系,即虚拟地址空间(KERNBASE, +KERNBASE + KMEMSIZE)= 线性地址空间(KERNBASE, KERNBASE + KMEMSIZE) = +物理地址空间(0,KMEMSIZE)。 + +### 3.6 自映射机制 + +这是扩展知识。 +上一小节讲述了通过boot\_map\_segment函数建立了基于一一映射关系的页目录表项和页表项,这里的映射关系为: + +virtual addr (KERNBASE\~KERNBASE+KMEMSIZE) = physical\_addr +(0\~KMEMSIZE) + +这样只要给出一个虚地址和一个物理地址,就可以设置相应PDE和PTE,就可完成正确的映射关系。 + +如果我们这时需要按虚拟地址的地址顺序显示整个页目录表和页表的内容,则要查找页目录表的页目录表项内容,根据页目录表项内容找到页表的物理地址,再转换成对应的虚地址,然后访问页表的虚地址,搜索整个页表的每个页目录项。这样过程比较繁琐。 + +我们需要有一个简洁的方法来实现这个查找。ucore做了一个很巧妙的地址自映射设计,把页目录表和页表放在一个连续的4MB虚拟地址空间中,并设置页目录表自身的虚地址<--\>物理地址映射关系。这样在已知页目录表起始虚地址的情况下,通过连续扫描这特定的4MB虚拟地址空间,就很容易访问每个页目录表项和页表项内容。 + +具体而言,ucore是这样设计的,首先设置了一个常量(memlayout.h): + +VPT=0xFAC00000, 这个地址的二进制表示为: + +1111 1010 1100 0000 0000 0000 0000 0000 + +高10位为1111 1010 +11,即10进制的1003,中间10位为0,低12位也为0。在pmm.c中有两个全局初始化变量 + +pte\_t \* const vpt = (pte\_t \*)VPT; + +pde\_t \* const vpd = (pde\_t \*)PGADDR(PDX(VPT), PDX(VPT), 0); + +并在pmm\_init函数执行了如下语句: + +boot\_pgdir[PDX(VPT)] = PADDR(boot\_pgdir) | PTE\_P | PTE\_W; + +这些变量和语句有何特殊含义呢?其实vpd变量的值就是页目录表的起始虚地址0xFAFEB000,且它的高10位和中10位是相等的,都是10进制的1003。当执行了上述语句,就确保了vpd变量的值就是页目录表的起始虚地址,且vpt是页目录表中第一个目录表项指向的页表的起始虚地址。此时描述内核虚拟空间的页目录表的虚地址为0xFAFEB000,大小为4KB。页表的理论连续虚拟地址空间0xFAC00000\~0xFB000000,大小为4MB。因为这个连续地址空间的大小为4MB,可有1M个PTE,即可映射4GB的地址空间。 + +但ucore实际上不会用完这么多项,在memlayout.h中定义了常量 + +\#define KMEMSIZE 0x38000000 + +表示ucore只支持896MB的物理内存空间,这个896MB只是一个设定,可以根据情况改变。则最大的内核虚地址为常量 + +\#define KERNTOP (KERNBASE + KMEMSIZE)=0xF8000000 + +所以最大内核虚地址KERNTOP的页目录项虚地址为 + +vpd+0xF8000000/0x400000=0xFAFEB000+0x3E0=0xFAFEB3E0 + +最大内核虚地址KERNTOP的页表项虚地址为: + +vpt+0xF8000000/0x1000=0xFAC00000+0xF8000=0xFACF8000 + +在pmm.c中的函数print\_pgdir就是基于ucore的页表自映射方式完成了对整个页目录表和页表的内容扫描和打印。注意,这里不会出现某个页表的虚地址与页目录表虚地址相同的情况。 + +![image](lab2.files/image009.png)![image](lab2.files/image010.png)![image](lab2.files/image011.png)print +pgdir函数使得 ucore 具备和 qemu 的info pg相同的功能,即print pgdir能 +够从内存中,将当前页表内有效数据(PTE\_P)印出来。拷贝出的格式如下所示: +``` +PDE(0e0) c0000000-f8000000 38000000 urw +|-- PTE(38000) c0000000-f8000000 38000000 -rw +PDE(001) fac00000-fb000000 00400000 -rw +|-- PTE(000e0) faf00000-fafe0000 000e0000 urw +|-- PTE(00001) fafeb000-fafec000 00001000 -rw +``` +上面中的数字包括括号里的,都是十六进制。 + +主要的功能是从页表中将具备相同权限的 PDE 和 PTE +项目组织起来。比如上表中: +``` +PDE(0e0) c0000000-f8000000 38000000 urw +``` +• PDE(0e0):0e0表示 PDE 表中相邻的 224 项具有相同的权限; +• c0000000-f8000000:表示 PDE 表中,这相邻的两项所映射的线性地址的范围; +• 38000000:同样表示范围,即f8000000减去c0000000的结果; +• urw:PDE 表中所给出的权限位,u表示用户可读,即PTE\_U,r表示PTE\_P,w表示用 +户可写,即PTE\_W。 +``` +PDE(001) fac00000-fb000000 00400000 -rw +``` +表示仅 1 条连续的 PDE 表项具备相同的属性。相应的,在这条表项中遍历找到 2 +组 PTE 表项,输出如下: +``` +|-- PTE(000e0) faf00000-fafe0000 000e0000 urw +|-- PTE(00001) fafeb000-fafec000 00001000 -rw +``` +注意: +1. PTE 中输出的权限是 PTE 表中的数据给出的,并没有和 PDE +表中权限做与运算。 +2. +![image](lab2.files/image012.png)整个print\_pgdir函数强调两点:第一是相同权限,第二是连续。 +3. +![image](lab2.files/image013.png)print\_pgdir中用到了vpt和vpd两个变量。可以参 +考VPT和PGADDR两个宏。 + +自映射机制还可方便用户态程序访问页表。因为页表是内核维护的,用户程序很难知道自己页表的映射结构。VPT +实际上在内核地址空间的,我们可以用同样的方式实现一个用户地址空间的映射(比如 +pgdir[UVPT] = PADDR(pgdir) | PTE\_P | PTE\_U,注意,这里不能给写权限,并且 +pgdir 是每个进程的 page table,不是 +boot\_pgdir),这样,用户程序就可以用和内核一样的 print\_pgdir +函数遍历自己的页表结构了。 + +\ + +\ + +## 4 实验报告要求 + +从网站上下载lab2.zip后,解压得到本文档和代码目录 +lab2,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab2-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有***“LAB2”***的注释,代码中所有需要完成的地方(challenge除外)都有***“LAB2”***和***“YOUR CODE”***的注释,请在提交时特别注意保持注释,并将***“YOUR CODE”***替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。 + +\ + +## 附录 + +**A. 探测物理内存分布和大小的方法** + +操作系统需要知道了解整个计算机系统中的物理内存如何分布的,哪些被可用,哪些不可用。其基本方法是通过BIOS中断调用来帮助完成的。其中BIOS中断调用必须在实模式下进行,所以在bootloader进入保护模式前完成这部分工作相对比较合适。这些部分由boot/bootasm.S中从probe\_memory处到finish\_probe处的代码部分完成完成。通过BIOS中断获取内存可调用参数为e820h的INT +15h BIOS中断。BIOS通过系统内存映射地址描述符(Address Range +Descriptor)格式来表示系统物理内存布局,其具体表示如下: +``` +Offset Size Description +00h 8字节 base address #系统内存块基地址 +08h 8字节 length in bytes #系统内存大小 +10h 4字节 type of address range #内存类型 +``` +看下面的(Values for System Memory Map address type) +``` +Values for System Memory Map address type: +01h memory, available to OS +02h reserved, not available (e.g. system ROM, memory-mapped device) +03h ACPI Reclaim Memory (usable by OS after reading ACPI tables) +04h ACPI NVS Memory (OS is required to save this memory between NVS sessions) +other not defined yet -- treat as Reserved +``` +INT15h BIOS中断的详细调用参数: +``` +eax:e820h:INT 15的中断调用参数; +edx:534D4150h (即4个ASCII字符“SMAP”) ,这只是一个签名而已; +ebx:如果是第一次调用或内存区域扫描完毕,则为0。 如果不是,则存放上次调用之后的计数值; +ecx:保存地址范围描述符的内存大小,应该大于等于20字节; +es:di:指向保存地址范围描述符结构的缓冲区,BIOS把信息写入这个结构的起始地址。 +``` +此中断的返回值为: +``` +cflags的CF位:若INT 15中断执行成功,则不置位,否则置位; + +eax:534D4150h ('SMAP') ; + +es:di:指向保存地址范围描述符的缓冲区,此时缓冲区内的数据已由BIOS填写完毕 + +ebx:下一个地址范围描述符的计数地址 + +ecx :返回BIOS往ES:DI处写的地址范围描述符的字节大小 + +ah:失败时保存出错代码 +``` +这样,我们通过调用INT 15h +BIOS中断,递增di的值(20的倍数),让BIOS帮我们查找出一个一个的内存布局entry,并放入到一个保存地址范围描述符结构的缓冲区中,供后续的ucore进一步进行物理内存管理。这个缓冲区结构定义在memlayout.h中: +``` +struct e820map { + int nr_map; + struct { + long long addr; + long long size; + long type; + } map[E820MAX]; +}; +``` +**** + +**B. 实现物理内存探测** + +物理内存探测是在bootasm.S中实现的,相关代码很短,如下所示: +``` +probe_memory: +//对0x8000处的32位单元清零,即给位于0x8000处的 +//struct e820map的成员变量nr_map清零 + movl $0, 0x8000 + xorl %ebx, %ebx +//表示设置调用INT 15h BIOS中断后,BIOS返回的映射地址描述符的起始地址 + movw $0x8004, %di +start_probe: + movl $0xE820, %eax // INT 15的中断调用参数 +//设置地址范围描述符的大小为20字节,其大小等于struct e820map的成员变量map的大小 + movl $20, %ecx +//设置edx为534D4150h (即4个ASCII字符“SMAP”),这是一个约定 + movl $SMAP, %edx +//调用int 0x15中断,要求BIOS返回一个用地址范围描述符表示的内存段信息 + int $0x15 +//如果eflags的CF位为0,则表示还有内存段需要探测 + jnc cont +//探测有问题,结束探测 + movw $12345, 0x8000 + jmp finish_probe +cont: +//设置下一个BIOS返回的映射地址描述符的起始地址 + addw $20, %di +//递增struct e820map的成员变量nr_map + incl 0x8000 +//如果INT0x15返回的ebx为零,表示探测结束,否则继续探测 + cmpl $0, %ebx + jnz start_probe +finish_probe: +``` +上述代码正常执行完毕后,在0x8000地址处保存了从BIOS中获得的内存分布信息,此信息按照struct +e820map的设置来进行填充。这部分信息将在bootloader启动ucore后,由ucore的page\_init函数来根据struct +e820map的memmap(定义了起始地址为0x8000)来完成对整个机器中的物理内存的总体管理。 + +**C.链接地址/虚地址/物理地址/加载地址以及edata/end/text的含义** + +**链接脚本简介** + +ucore +kernel各个部分由组成kernel的各个.o或.a文件构成,且各个部分在内存中地址位置由ld工具根据kernel.ld链接脚本(linker +script)来设定。ld工具使用命令-T指定链接脚本。链接脚本主要用于规定如何把输入文件(各个.o或.a文件)内的section放入输出文件(lab2/bin/kernel,即ELF格式的ucore内核)内, +并控制输出文件内各部分在程序地址空间内的布局。下面简单分析一下/lab2/tools/kernel.ld,来了解一下ucore内核的地址布局情况。kernel.ld的内容如下所示: +``` +/* Simple linker script for the ucore kernel. + See the GNU ld 'info' manual ("info ld") to learn the syntax. */ + +OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386") +OUTPUT_ARCH(i386) +ENTRY(kern_entry) + +SECTIONS { + /* Load the kernel at this address: "." means the current address */ + . = 0xC0100000; + + .text : { + *(.text .stub .text.* .gnu.linkonce.t.*) + } + + PROVIDE(etext = .); /* Define the 'etext' symbol to this value */ + + .rodata : { + *(.rodata .rodata.* .gnu.linkonce.r.*) + } + + /* Include debugging information in kernel memory */ + .stab : { + PROVIDE(__STAB_BEGIN__ = .); + *(.stab); + PROVIDE(__STAB_END__ = .); + BYTE(0) /* Force the linker to allocate space + for this section */ + } + + .stabstr : { + PROVIDE(__STABSTR_BEGIN__ = .); + *(.stabstr); + PROVIDE(__STABSTR_END__ = .); + BYTE(0) /* Force the linker to allocate space + for this section */ + } + + /* Adjust the address for the data segment to the next page */ + . = ALIGN(0x1000); + + /* The data segment */ + .data : { + *(.data) + } + + PROVIDE(edata = .); + + .bss : { + *(.bss) + } + + PROVIDE(end = .); + + /DISCARD/ : { + *(.eh_frame .note.GNU-stack) + } +} +``` +其实从链接脚本的内容,可以大致猜出它指定告诉链接器的各种信息: + +* 内核加载地址:0xC0100000 +* 入口(起始代码)地址: ENTRY(kern\_entry) +* cpu机器类型:i386 + +其最主要的信息是告诉链接器各输入文件的各section应该怎么组合:应该从哪个地址开始放,各个section以什么顺序放,分别怎么对齐等等,最终组成输出文件的各section。除此之外,linker +script还可以定义各种符号(如.text、.data、.bss等),形成最终生成的一堆符号的列表(符号表),每个符号包含了符号名字,符号所引用的内存地址,以及其他一些属性信息。符号实际上就是一个地址的符号表示,其本身不占用的程序运行的内存空间。 + +**链接地址/加载地址/虚地址/物理地址** + +ucore 设定了ucore运行中的虚地址空间,具体设置可看 +lab2/kern/mm/memlayout.h 中描述的"Virtual memory map +"图,可以了解虚地址和物理地址的对应关系。lab2/tools/kernel.ld描述的是执行代码的链接地址(link\_addr),比如内核起始地址是0xC0100000,这是一个虚地址。所以我们可以认为链接地址等于虚地址。在ucore建立内核页表时,设定了物理地址和虚地址的虚实映射关系是: + +phy addr + 0xC0000000 = virtual addr + +即虚地址和物理地址之间有一个偏移。但boot loader把ucore +kernel加载到内存时,采用的是加载地址(load +addr),这是由于ucore还没有运行,即还没有启动页表映射,导致这时采用的寻址方式是段寻址方式,用的是boot +loader在初始化阶段设置的段映射关系,其映射关系(可参看bootasm.S的末尾处有关段描述符表的内容)是: + +linear addr = phy addr = virtual addr + +查看 bootloader的实现代码 bootmain::bootmain.c + +readseg(ph-\>p\_va & 0xFFFFFF, ph-\>p\_memsz, ph-\>p\_offset); + +这里的ph-\>p\_va=0xC0XXXXXX,就是ld工具根据kernel.ld设置的链接地址,且链接地址等于虚地址。考虑到ph-\>p\_va +& 0xFFFFFF == 0x0XXXXXX,所以bootloader加载ucore +kernel的加载地址是0x0XXXXXX, 这实际上是ucore内核所在的物理地址。简言之: +OS的链接地址(link addr) 在tools/kernel.ld中设置好了,是一个虚地址(virtual +addr);而ucore kernel的加载地址(load addr)在boot +loader中的bootmain函数中指定,是一个物理地址。 + +小结一下,ucore内核的链接地址==ucore内核的虚拟地址;boot +loader加载ucore内核用到的加载地址==ucore内核的物理地址。 + +**edata/end/text的含义** + +在基于ELF执行文件格式的代码中,存在一些对代码和数据的表述,基本概念如下: + +* BSS段(bss +segment):指用来存放程序中未初始化的全局变量的内存区域。BSS是英文Block +Started by Symbol的简称。BSS段属于静态内存分配。 +* 数据段(data +segment):指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。 +* 代码段(code segment/text +segment):指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, +某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。 + +在lab2/kern/init/init.c的kern\_init函数中,声明了外部全局变量: +``` +extern char edata[], end[]; +``` +但搜寻所有源码文件\*.[ch],没有发现有这两个变量的定义。那这两个变量从哪里来的呢?其实在lab2/tools/kernel.ld中,可以看到如下内容: +``` +… +.text : { + *(.text .stub .text.* .gnu.linkonce.t.*) +} +… + .data : { + *(.data) +} +… +PROVIDE(edata = .); +… + .bss : { + *(.bss) +} +… +PROVIDE(end = .); +… +``` +这里的“.”表示当前地址,“.text”表示代码段起始地址,“.data”也是一个地址,可以看出,它即代表了代码段的结束地址,也是数据段的起始地址。类推下去,“edata”表示数据段的结束地址,“.bss”表示数据段的结束地址和BSS段的起始地址,而“end”表示BSS段的结束地址。 + +这样回头看kerne\_init中的外部全局变量,可知edata[]和 +end[]这些变量是ld根据kernel.ld链接脚本生成的全局变量,表示相应段的起始地址或结束地址等,它们不在任何一个.S、.c或.h文件中定义。 + + diff --git a/lab3.md b/lab3.md new file mode 100644 index 0000000..45dcecf --- /dev/null +++ b/lab3.md @@ -0,0 +1,588 @@ +# 实验三:虚拟内存管理 + +## 1. 实验目的 + +* 了解虚拟内存的Page Fault异常处理实现 + +* 了解页替换算法在操作系统中的实现 + +## 2. 实验内容 + +做完实验二后,大家可以了解并掌握物理内存管理中的连续空间分配算法的具体实现以及如何建立二级页表。本次实验是在实验二的基础上,借助于页表机制和实验一中涉及的中断异常处理机制,完成Page +Fault异常处理和FIFO页替换算法的实现,结合磁盘提供的缓存空间,从而能够支持虚存管理,提供一个比实际物理内存空间“更大”的虚拟内存空间给系统使用。实际操作系统系统中的虚拟内存管理设计与实现是相当复杂的,涉及到与进程管理系统、文件系统等的交叉访问。如果大家有余力,可以尝试完成扩展练习,实现extended +clock页替换算法。 + +### 2.1 练习 + +#### 练习0:填写已有实验 + +本实验依赖实验1/2。请把你做的实验1/2的代码填入本实验中代码中有“LAB1”,“LAB2”的注释相应部分。 + +#### 练习1:给未被映射的地址映射上物理页(需要编程) + +完成do\_pgfault(mm/vmm.c)函数,给未被映射的地址映射上物理页。设置访问权限 +的时候需要参考页面所在 VMA +的权限,同时需要注意映射物理页时需要操作内存控制 +结构所指定的页表,而不是内核的页表。注意:在LAB2 EXERCISE +1处填写代码。执行“make +qemu”后,如果通过check\_pgfault函数的测试后,会有“check\_pgfault() +succeeded!”的输出,表示练习1基本正确。 + +#### 练习2:补充完成基于FIFO的页面替换算法(需要编程) + +完成vmm.c中的do\_pgfault函数,并且在实现FIFO算法的swap\_fifo.c中完成map\_swappable和swap\_out\_vistim函数。通过对swap的测试。注意:在LAB2 +EXERCISE 2处填写代码。执行“make +qemu”后,如果通过check\_swap函数的测试后,会有“check\_swap() +succeeded!”的输出,表示练习2基本正确。 + +#### 扩展练习 Challenge:实现识别dirty bit的 extended clock页替换算法(需要编程) + +challenge部分不是必做部分,不过在正确最后会酌情加分。需写出有详细的设计、分析和测试的实验报告。完成出色的可获得适当加分。(基本实验完成后一周内完成,单独提交)。 + +### 2.2 项目组成 + +表1:实验三文件列表 + +|-- boot +|-- kern +| |-- driver +| | |-- … +| | |-- ide.c +| | \`-- ide.h +| |-- fs +| | |-- fs.h +| | |-- swapfs.c +| | \`-- swapfs.h +| |-- init +| | |-- … +| | \`-- init.c +| |-- mm +| | |-- default\_pmm.c +| | |-- default\_pmm.h +| | |-- memlayout.h +| | |-- mmu.h +| | |-- pmm.c +| | |-- pmm.h +| | |-- swap.c +| | |-- swap.h +| | |-- swap\_fifo.c +| | |-- swap\_fifo.h +| | |-- vmm.c +| | \`-- vmm.h +| |-- sync +| \`-- trap +| |-- trap.c +| \`-- … +|-- libs +| |-- list.h +| \`-- … +\`-- tools + +相对与实验二,实验三主要增加的文件如上表红色部分所示,主要修改的文件如上表紫色部分所示,其他需要用到的重要文件用黑色表示。主要改动如下: + +* kern/mm/default\_pmm.[ch]:实现基于struct +pmm\_manager类框架的Fist-Fit物理内存分配参考实现(分配最小单位为页,即4096字节),相关分配页和释放页等实现会间接被kmalloc/kfree等函数使用。 + +* kern/mm/pmm.[ch]:pmm.h定义物理内存分配类框架struct pmm\_manager。 +pmm.c包含了对此物理内存分配类框架的访问,以及与建立、修改、访问页表相关的各种函数实现。在本实验中会用到kmalloc/kfree等函数。 + +* libs/list.h:定义了通用双向链表结构以及相关的查找、插入等基本操作,这是建立基于链表方法的物理内存管理(以及其他内核功能)的基础。在lab0文档中有相关描述。其他有类似双向链表需求的内核功能模块可直接使用list.h中定义的函数。在本实验中会多次用到插入,删除等操作函数。 + +* kern/driver/ide.[ch]:定义和实现了内存页swap机制所需的磁盘扇区的读写操作支持;在本实验中会涉及通过swapfs\_\*函数间接使用文件中的函数。故了解即可。 + +* kern/fs/\*:定义和实现了内存页swap机制所需从磁盘读数据到内存页和写内存数据到磁盘上去的函数 +swapfs\_read/swapfs\_write。在本实验中会涉及使用这两个函数。 + +* kern/mm/memlayout.h:修改了struct +Page,增加了两项pra\_\*成员结构,其中pra\_page\_link可以用来建立描述各个页访问情况(比如根据访问先后)的链表。在本实验中会涉及使用这两个成员结构,以及le2page等宏。 + +* kern/mm/vmm.[ch]:vmm.h描述了mm\_struct,vma\_struct等表述可访问的虚存地址访问的一些信息,下面会进一步详细讲解。vmm.c涉及mm,vma结构数据的创建/销毁/查找/插入等函数,这些函数在check\_vma、check\_vmm等中被使用,理解即可。而page +fault处理相关的do\_pgfault函数是本次实验需要涉及完成的。 + +* kern/mm/swap.[ch]:定义了实现页替换算法的类框架struct +swap\_manager。swap.c包含了对此页替换算法类框架的初始化、页换入/换出等各种函数实现。重点是要理解何时调用swap\_out和swap\_in函数。和如何在此框架下连接具体的页替换算法实现。check\_swap函数以及被此函数调用的\_fifo\_check\_swap函数完成了对本次实验中的练习2:FIFO页替换算法基本正确性的检查,可了解,便于知道为何产生错误。 + +* kern/mm/swap\_fifo.[ch]:FIFO页替换算法的基于类框架struct +swap\_manager的简化实现,主要被swap.c的相关函数调用。重点是\_fifo\_map\_swappable函数(可用于建立页访问属性和关系,比如访问时间的先后顺序)和\_fifo\_swap\_out\_victim函数(可用于实现挑选出要换出的页),当然换出哪个页需要借助于fifo\_map\_swappable函数建立的某种属性关系,已选出合适的页。 + +* kern/mm/mmu.h:其中定义额也页表项的各种属性位,比如PTE\_P\\PET\_D\\PET\_A等,对于实现扩展实验的clock算法会有帮助。 + +本次实验的主要练习集中在vmm.c中的do\_pgfault函数和swap\_fifo.c中的\_fifo\_map\_swappable函数、\_fifo\_swap\_out\_victim函数。 + +#### 编译执行 + +编译并运行代码的命令如下: +``` + make + make qemu +``` +则可以得到如附录所示的显示内容(仅供参考,不是标准答案输出) + +## 3.虚拟内存管理 + +### 3.1 基本原理概述 + +什么是虚拟内存?简单地说,是指程序员或CPU +“需要”和直接“看到”的内存,这其实暗示了两点:1、虚拟内存单元不一定有实际的物理内存单元对应,即实际的物理内存单元可能不存在;2、如果虚拟内存单元对应有实际的物理内存单元,那二者的地址一般不是相等的。通过操作系统的某种内存管理和映射技术可建立虚拟内存与实际的物理内存的对应关系,使得程序员或CPU访问的虚拟内存地址会转换为另外一个物理内存地址。 + +那么这个“虚拟”的作用或意义在哪里体现呢?在操作系统中,虚拟内存其实包含多个虚拟层次,在不同的层次体现了不同的作用。首先,在有了分页机制后,程序员或CPU直接“看到”的地址已经不是实际的物理地址了,这已经有一层虚拟化,我们可简称为内存地址虚拟化。有了内存地址虚拟化,我们就可以通过设置页表项来限定软件运行时的访问空间,确保软件运行不越界,完成内存访问保护的功能。 + +通过内存地址虚拟化,可以使得软件在没有访问某虚拟内存地址时不分配具体的物理内存,而只有在实际访问某虚拟内存地址时,操作系统再动态地分配物理内存,建立虚拟内存到物理内存的页映射关系,这种技术属于lazy +load技术,简称按需分页(demand +paging)。把不经常访问的数据所占的内存空间临时写到硬盘上,这样可以腾出更多的空闲内存空间给经常访问的数据;当CPU访问到不经常访问的数据时,再把这些数据从硬盘读入到内存中,这种技术称为页换入换出(page +swap +in/out)。这种内存管理技术给了程序员更大的内存“空间”,我们称为内存空间虚拟化。 + +### 3.2 实验执行流程概述 + +本次实验主要完成ucore内核对虚拟内存的管理工作。其总体设计思路还是比较简单,即首先完成初始化虚拟内存管理机制,即需要设置好哪些页需要放在物理内存中,哪些页不需要放在物理内存中,而是可被换出到硬盘上,并涉及完善建立页表映射、页错误异常处理操作等函数实现。然后就执行一组访存测试,看看我们建立的页表项是否能够正确完成虚实地址映射,是否正确描述了虚拟内存页在物理内存中还是在硬盘上,是否能够正确把虚拟内存页在物理内存和硬盘之间进行传递,是否正确实现了页面替换算法等。lab3的总体执行流程如下。 + +首先是初始化过程。参考ucore总控函数init的代码,可以看到在调用完成虚拟内存初始化的vmm\_init函数之前,需要首先调用pmm\_init函数完成物理内存的管理,这也是我们lab2已经完成的内容。接着是执行中断和异常相关的初始化工作,即调用pic\_init函数和idt\_init函数等,这些工作与lab1的中断异常初始化工作的内容是相同的。 + +在调用完idt\_init函数之后,将进一步调用三个lab3中才有的新函数vmm\_init、ide\_init和swap\_init。这三个函数设计了本次实验中的两个练习。第一个函数vmm\_init是检查我们的练习1是否正确实现了。为了表述不在物理内存中的“合法”虚拟页,需要有数据结构来描述这样的页,为此ucore建立了mm\_struct和vma\_struct数据结构(在3.3小节中有进一步详细描述),假定我们已经描述好了这样的“合法”虚拟页,当ucore访问这些“合法”虚拟页时,会由于没有虚实地址映射而产生页错误异常。如果我们正确实现了练习1,则do\_pgfault函数会申请一个空闲物理页,并建立好虚实映射关系,从而使得这样的“合法”虚拟页有实际的物理页帧对应。这样练习1就算完成了。 + +ide\_init和swap\_init是为练习2准备的。由于页面置换算法的实现存在对硬盘数据块的读写,所以ide\_init就是完成对用于页换入换出的硬盘(简称swap硬盘)的初始化工作。完成ide\_init函数后,ucore就可以对这个swap硬盘进行读写操作了。swap\_init函数首先建立swap\_manager,swap\_manager是完成页面替换过程的主要功能模块,其中包含了页面置换算法的实现(具体内容可参考5小节)。然后会进一步调用执行check\_swap函数在内核中分配一些页,模拟对这些页的访问,这会产生页错误异常。如果我们正确实现了练习2,就可通过do\_pgfault来调用swap\_map\_swappable函数来查询这些页的访问情况并间接调用实现页面置换算法的相关函数,把“不常用”的页换出到磁盘上。 + +ucore在实现上述技术时,需要解决三个关键问题: + +1、 当程序运行中访问内存产生page +fault异常时,如何判定这个引起异常的虚拟地址内存访问是越界、写只读页的“非法地址”访问还是由于数据被临时换出到磁盘上或还没有分配内存的“合法地址”访问? + +2、 何时进行请求调页/页换入换出处理? + +3、 如何在现有ucore的基础上实现页替换算法? + +接下来将进一步分析完成lab3主要注意的关键问题和涉及的关键数据结构。 + +### 3.3 关键数据结构和相关函数分析 + +对于第一个问题的出现,在于实验二中有关内存的数据结构和相关操作都是直接针对实际存在的资源--物理内存空间的管理,没有从一般应用程序对内存的“需求”考虑,即需要有相关的数据结构和操作来体现一般应用程序对虚拟内存的“需求”。一般应用程序的对虚拟内存的“需求”与物理内存空间的“供给”没有直接的对应关系,ucore是通过page +fault异常处理来间接完成这二者之间的衔接。 + +page\_fault函数不知道哪些是“合法”的虚拟页,原因是ucore还缺少一定的数据结构来描述这种不在物理内存中的“合法”虚拟页。为此ucore通过建立mm\_struct和vma\_struct数据结构,描述了ucore模拟应用程序运行所需的合法内存空间。当访问内存产生page +fault异常时,可获得访问的内存的方式(读或写)以及具体的虚拟内存地址,这样ucore就可以查询此地址,看是否属于vma\_struct数据结构中描述的合法地址范围中,如果在,则可根据具体情况进行请求调页/页换入换出处理(这就是练习2涉及的部分);如果不在,则报错。mm\_struct和vma\_struct数据结构结合页表表示虚拟地址空间和物理地址空间的示意图如下所示: + +图 虚拟地址空间和物理地址空间的示意图 +![image](lab3.files/image001.png) + +在ucore中描述应用程序对虚拟内存“需求”的数据结构是vma\_struct(定义在vmm.h中),以及针对vma\_struct的函数操作。这里把一个vma\_struct结构的变量简称为vma变量。vma\_struct的定义如下: +``` +struct vma_struct { + +// the set of vma using the same PDT + +struct mm_struct *vm_mm; + +uintptr_t vm_start; // start addr of vma + +uintptr_t vm_end; // end addr of vma + +uint32_t vm_flags; // flags of vma + +//linear list link which sorted by start addr of vma + +list_entry_t list_link; + +}; +``` +vm\_start和vm\_end描述了一个连续地址的虚拟内存空间的起始位置和结束位置,这两个值都应该是PGSIZE 对齐的,而且描述的是一个合理的地址空间范围(即严格确保 vm\_start < vm\_end的关系);list\_link是一个双向链表,按照从小到大的顺序把一系列用vma\_struct表示的虚拟内存空间链接起来,并且还要求这些链起来的vma\_struct应该是不相交的,即vma之间的地址空间无交集;vm\_flags表示了这个虚拟内存空间的属性,目前的属性包括: +``` +#define VM_READ 0x00000001 //只读 + +#define VM_WRITE 0x00000002 //可读写 + +#define VM_EXEC 0x00000004 //可执行 +``` + +vm\_mm是一个指针,指向一个比vma\_struct更高的抽象层次的数据结构mm\_struct,这里把一个mm\_struct结构的变量简称为mm变量。这个数据结构表示了包含所有虚拟内存空间的共同属性,具体定义如下 +``` +struct mm_struct { + +// linear list link which sorted by start addr of vma + +list_entry_t mmap_list; + +// current accessed vma, used for speed purpose + +struct vma_struct *mmap_cache; + +pde_t *pgdir; // the PDT of these vma + +int map_count; // the count of these vma + +void *sm_priv; // the private data for swap manager + +}; +``` +mmap_list是双向链表头,链接了所有属于同一页目录表的虚拟内存空间,mmap\_cache是指向当前正在使用的虚拟内存空间,由于操作系统执行的“局部性”原理,当前正在用到的虚拟内存空间在接下来的操作中可能还会用到,这时就不需要查链表,而是直接使用此指针就可找到下一次要用到的虚拟内存空间。由于 +mmap_cache 的引入,可使得 mm_struct 数据结构的查询加速 30% 以上。pgdir +所指向的就是 mm_struct +数据结构所维护的页表。通过访问pgdir可以查找某虚拟地址对应的页表项是否存在以及页表项的属性等。map_count记录 +mmap_list 里面链接的 vma_struct +的个数。sm_priv指向用来链接记录页访问情况的链表头,这建立了mm_struct和后续要讲到的swap_manager之间的联系。 + +涉及vma_struct的操作函数也比较简单,主要包括三个: + +* vma_create--创建vma + +* insert_vma_struct--插入一个vma + +* find_vma--查询vma。 + +vma\_create函数根据输入参数vm\_start、vm\_end、vm\_flags来创建并初始化描述一个虚拟内存空间的vma\_struct结构变量。insert\_vma\_struct函数完成把一个vma变量按照其空间位置[vma-\>vm\_start,vma-\>vm\_end]从小到大的顺序插入到所属的mm变量中的mmap\_list双向链表中。find\_vma根据输入参数addr和mm变量,查找在mm变量中的mmap\_list双向链表中某个vma包含此addr,即vma-\>vm\_start<= +addr end。这三个函数与后续讲到的page fault异常处理有紧密联系。 + +涉及mm\_struct的操作函数比较简单,只有mm\_create和mm\_destroy两个函数,从字面意思就可以看出是是完成mm\_struct结构的变量创建和删除。在mm\_create中用kmalloc分配了一块空间,所以在mm\_destroy中也要对应进行释放。在ucore运行过程中,会产生描述虚拟内存空间的vma\_struct结构,所以在mm\_destroy中也要进对这些mmap\_list中的vma进行释放。 + +## 4.Page Fault异常处理 + +对于第三节提到的第二个关键问题,解决的关键是page +fault异常处理过程中主要涉及的函数 -- +do\_pgfault。在程序的执行过程中由于某种原因(页框不存在/写只读页等)而使 +CPU +无法最终访问到相应的物理内存单元,即无法完成从虚拟地址到物理地址映射时,CPU +会产生一次页错误异常,从而需要进行相应的页错误异常服务例程。这个页错误异常处理的时机就是求调页/页换入换出/处理的执行时机。当相关处理完成后,页错误异常服务例程会返回到产生异常的指令处重新执行,使得软件可以继续正常运行下去。 + +具体而言,当启动分页机制以后,如果一条指令或数据的虚拟地址所对应的物理页框不在内存中或者访问的类型有错误(比如写一个只读页或用户态程序访问内核态的数据等),就会发生页错误异常。产生页面异常的原因主要有: + +* 目标页面不存在(页表项全为0,即该线性地址与物理地址尚未建立映射或者已经撤销); + +* 相应的物理页面不在内存中(页表项非空,但Present标志位=0,比如在swap分区或磁盘文件上),这将在下面介绍换页机制实现时进一步讲解如何处理; + +* 访问权限不符合(此时页表项P标志=1,比如企图写只读页面). + +当出现上面情况之一,那么就会产生页面page +fault(\#PF)异常。产生异常的线性地址存储在CR2中,并且将是page +fault的产生类型保存在 error code 中,比如 bit 0 表示是否 PTE\_P为0,bit 1 +表示是否 write 操作。 + +产生页错误异常后,CPU硬件和软件都会做一些事情来应对此事。首先页错误异常也是一种异常,所以针对一般异常的硬件处理操作是必须要做的,即CPU在当前内核栈保存当前被打断的程序现场,即依次压入当前被打断程序使用的eflags,cs,eip,errorCode;由于页错误异常的中断号是0xE, +CPU把异常中断号0xE对应的中断异常服务例程的地址(vectors.S中的标号vector14处)加载到cs和eip寄存器中,开始执行中断服务例程。这时ucore开始处理异常中断,首先需要保存硬件没有保存的寄存器。在vectors.S中的标号vector14处先把中断号压入内核栈,然后再在trapentry.S中的标号\_\_alltraps处把ds、es和其他通用寄存器都压栈。自此,被打断的程序现场被保存在内核栈中。接下来,在trap.c的trap函数开始了中断服务例程的处理流程,大致调用关系为: + +trap--\> trap\_dispatch--\>pgfault\_handler--\>do\_pgfault + +下面需要具体分析一下do\_pgfault函数。do\_pgfault的调用关系如下图所示: + +图 do\_pgfault的调用关系图 + +![image](lab3.files/image002.png) + +产生页错误异常后,CPU把引起页错误异常的虚拟地址装到寄存器CR2中,并给出了出错码(tf-\>tf\_err),指示引起页错误异常的存储器访问的类型。而中断服务例程会调用页错误异常处理函数do\_pgfault进行具体处理。页错误异常处理是实现按需分页、swap +in/out的关键之处。 + +ucore中do\_pgfault函数是完成页错误异常处理的主要函数,它根据从CPU的控制寄存器CR2中获取的页错误异常的虚拟地址以及根据 +error +code的错误类型来查找此虚拟地址是否在某个VMA的地址范围内以及是否满足正确的读写权限,如果在此范围内并且权限也正确,这认为这是一次合法访问,但没有建立虚实对应关系。所以需要分配一个空闲的内存页,并修改页表完成虚地址到物理地址的映射,刷新TLB,然后调用iret中断,返回到产生页错误异常的指令处重新执行此指令。如果该虚地址不再某VMA范围内,这认为是一次非法访问。 + +## 5.页面置换机制的实现 + +### 5.1 页替换算法 + +操作系统为何要进行页面置换呢?这是由于操作系统给用户态的应用程序提供了一个虚拟的“大容量”内存空间,而实际的物理内存空间又没有那么大。所以操作系统就就“瞒着”应用程序,只把应用程序中“常用”的数据和代码放在物理内存中,而不常用的数据和代码放在了硬盘这样的存储介质上。如果应用程序访问的是“常用”的数据和代码,那么操作系统已经放置在内存中了,不会出现什么问题。但当应用程序访问它认为应该在内存中的的数据或代码时,如果这些数据或代码不在内存中,则根据上一小节的介绍,会产生页错误异常。这时,操作系统必须能够应对这种页错误异常,即尽快把应用程序当前需要的数据或代码放到内存中来,然后重新执行应用程序产生异常的访存指令。如果在把硬盘中对应的数据或代码调入内存前,操作系统发现物理内存已经没有空闲空间了,这时操作系统必须把它认为“不常用”的页换出到磁盘上去,以腾出内存空闲空间给应用程序所需的数据或代码。 + +操作系统迟早会碰到没有内存空闲空间而必须要置换出内存中某个“不常用”的页的情况。如何判断内存中哪些是“常用”的页,哪些是“不常用”的页,把“常用”的页保持在内存中,在物理内存空闲空间不够的情况下,把“不常用”的页置换到硬盘上就是页替换算法着重考虑的问题。容易理解,一个好的页替换算法会导致页错误异常次数少,也就意味着访问硬盘的次数也少,从而使得应用程序执行的效率就高。本次实验涉及的页替换算法(包括扩展练习): + +* 先进先出(First In First Out, +FIFO)页替换算法:该算法总是淘汰最先进入内存的页,即选择在内存中驻留时间最久的页予以淘汰。只需把一个应用程序在执行过程中已调入内存的页按先后次序链接成一个队列,队列头指向内存中驻留时间最久的页,队列尾指向最近被调入内存的页。这样需要淘汰页时,从队列头很容易查找到需要淘汰的页。FIFO算法只是在应用程序按线性顺序访问地址空间时效果才好,否则效率不高。因为那些常被访问的页,往往在内存中也停留得最久,结果它们因变“老”而不得不被置换出去。FIFO算法的另一个缺点是,它有一种异常现象(Belady现象),即在增加放置页的页帧的情况下,反而使页错误异常次数增多。 + +* 时钟(Clock)页替换算法:也称最近未使用 (Not Used Recently, NUR) +页替换算法。虽然二次机会算法是一个较合理的算法,但它经常需要在链表中移动页面,这样做既降低了效率,又是不必要的。一个更好的办法是把各个页面组织成环形链表的形式,类似于一个钟的表面。然后把一个指针指向最古老的那个页面,或者说,最先进来的那个页面。时钟算法和第二次机会算法的功能是完全一样的,只是在具体实现上有所不同。时钟算法需要在页表项(PTE)中设置了一位访问位来表示此页表项对应的页当前是否被访问过。当该页被访问时,CPU中的MMU硬件将把访问位置“1”。然后将内存中所有的页都通过指针链接起来并形成一个循环队列。初始时,设置一个当前指针指向某页(比如最古老的那个页面)。操作系统需要淘汰页时,对当前指针指向的页所对应的页表项进行查询,如果访问位为“0”,则淘汰该页,把它换出到硬盘上;如果访问位为“1”,这将该页表项的此位置“0”,继续访问下一个页。该算法近似地体现了LRU的思想,且易于实现,开销少。但该算法需要硬件支持来设置访问位,且该算法在本质上与FIFO算法是类似的,惟一不同的是在clock算法中跳过了访问位为1的页。 + +* 改进的时钟(Enhanced +Clock)页替换算法:在时钟置换算法中,淘汰一个页面时只考虑了页面是否被访问过,但在实际情况中,还应考虑被淘汰的页面是否被修改过。因为淘汰修改过的页面还需要写回硬盘,使得其置换代价大于未修改过的页面。改进的时钟置换算法除了考虑页面的访问情况,还需考虑页面的修改情况。即该算法不但希望淘汰的页面是最近未使用的页,而且还希望被淘汰的页是在主存驻留期间其页面内容未被修改过的。这需要为每一页的对应页表项内容中增加一位引用位和一位修改位。当该页被访问时,CPU中的MMU硬件将把访问位置“1”。当该页被“写”时,CPU中的MMU硬件将把修改位置“1”。这样这两位就存在四种可能的组合情况:(0,0)表示最近未被引用也未被修改,首先选择此页淘汰;(0,1)最近未被使用,但被修改,其次选择;(1,0)最近使用而未修改,再次选择;(1,1)最近使用且修改,最后选择。该算法与时钟算法相比,可进一步减少磁盘的I/O操作次数,但为了查找到一个尽可能适合淘汰的页面,可能需要经过多次扫描,增加了算法本身的执行开销。 + +### 5.2 页面置换机制 + +如果要实现页面置换机制,只考虑页替换算法的设计与实现是远远不够的,还需考虑其他问题: + +* 哪些页可以被换出? + +* 一个虚拟的页如何与硬盘上的扇区建立对应关系? + +* 何时进行换入和换出操作? + +* 如何设计数据结构已支持页替换算法? + +* 如何完成页的换入换出操作? + +这些问题在下面会逐一进行分析。注意,在实验三中仅实现了简单的页面置换机制,但现在还没有涉及实验四和实验五才实现的内核线程和用户进程,所以还无法通过内核线程机制实现一个完整意义上的虚拟内存页面置换功能。 + +#### 1. 可以被换出的页 + +在操作系统的设计中,一个基本的原则是:并非所有的物理页都可以交换出去的,只有映射到用户空间且被用户程序直接访问的页面才能被交换,而被内核直接使用的内核空间的页面不能被换出。这里面的原因是什么呢?操作系统是执行的关键代码,需要保证运行的高效性和实时性,如果在操作系统执行过程中,发生了缺页现象,则操作系统不得不等很长时间(硬盘的访问速度比内存的访问速度慢2\~3个数量级),这将导致整个系统运行低效。而且,不难想象,处理缺页过程所用到的内核代码或者数据如果被换出,整个内核都面临崩溃的危险。 + +但在实验三实现的ucore中,我们只是实现了换入换出机制,还没有设计用户态执行的程序,所以我们在实验三中仅仅通过执行check\_swap函数在内核中分配一些页,模拟对这些页的访问,然后通过do\_pgfault来调用swap\_map\_swappable函数来查询这些页的访问情况并间接调用相关函数,换出“不常用”的页到磁盘上。 + +#### 2. 虚存中的页与硬盘上的扇区之间的映射关系 + +如果一个页被置换到了硬盘上,那操作系统如何能简捷来表示这种情况呢?在ucore的设计上,充分利用了页表中的PTE来表示这种情况:当一个 +PTE +用来描述一般意义上的物理页时,显然它应该维护各种权限和映射关系,以及应该有 +PTE\_P +标记;但当它用来描述一个被置换出去的物理页时,它被用来维护该物理页与 swap +磁盘上扇区的映射关系,并且该 PTE 不应该由 MMU 将它解释成物理页映射(即没有 +PTE\_P 标记),与此同时对应的权限则交由 mm\_struct +来维护,当对位于该页的内存地址进行访问的时候,必然导致 page +fault,然后ucore能够根据 PTE 描述的 swap +项将相应的物理页重新建立起来,并根据虚存所描述的权限重新设置好 PTE +使得内存访问能够继续正常进行。 + +如果一个页(4KB/页)被置换到了硬盘某8个扇区(0.5KB/扇区),该 +PTE的最低位--present位应该为0 (即 PTE\_P +标记为空,表示虚实地址映射关系不存在),接下来的7位暂时保留,可以用作各种扩展;而原来用来表示页帧号的高24位地址,恰好可以用来表示此页在硬盘上的起始扇区的位置(其从第几个扇区开始)。为了在页表项中区别 +0 和 swap 分区的映射,将 swap 分区的一个 page +空出来不用,也就是说一个高24位不为0,而最低位为0的PTE表示了一个放在硬盘上的页的起始扇区号(见swap.h中对swap\_entry\_t的描述): +``` +swap_entry_t +-------------- +| offset | reserved | 0 | +--------------- +24 bits 7 bits 1 bit +``` +考虑到硬盘的最小访问单位是一个扇区,而一个扇区的大小为512(2\^8)字节,所以需要8个连续扇区才能放置一个4KB的页。在ucore中,用了第二个IDE硬盘来保存被换出的扇区,根据实验三的输出信息 + +“ide 1: 262144(sectors), 'QEMU HARDDISK'.” + +我们可以知道实验三可以保存262144/8=32768个页,即128MB的内存空间。swap +分区的大小是 swapfs\_init 里面根据磁盘驱动的接口计算出来的,目前 ucore +里面要求 swap 磁盘至少包含 1000 个 page,并且至多能使用 1<<24 个page。 + +#### 3. 执行换入换出的时机 + +在实验三中, check\_mm\_struct变量这个数据结构表示了目前 +ucore认为合法的所有虚拟内存空间集合,而mm中的每个vma表示了一段地址连续的合法虚拟空间。当ucore或应用程序访问地址所在的页不在内存时,就会产生 +page +fault异常,引起调用do\_pgfault函数,此函数会判断产生访问异常的地址属于check\_mm\_struct某个vma表示的合法虚拟地址空间,且保存在硬盘swap文件中(即对应的PTE的高24位不为0,而最低位为0),则是执行页换入的时机,将调用swap\_in函数完成页面换入。 + +换出页面的时机相对复杂一些,针对不同的策略有不同的时机。ucore目前大致有两种策略,即积极换出策略和消极换出策略。积极换出策略是指操作系统周期性地(或在系统不忙的时候)主动把某些认为“不常用”的页换出到硬盘上,从而确保系统中总有一定数量的空闲页存在,这样当需要空闲页时,基本上能够及时满足需求;消极换出策略是指,只是当试图得到空闲页时,发现当前没有空闲的物理页可供分配,这时才开始查找“不常用”页面,并把一个或多个这样的页换出到硬盘上。 + +在实验三中的基本练习中,支持上述第二种情况。对于第一种积极换出策略,即每隔1秒执行一次的实现积极的换出策略,可考虑在扩展练习中实现。对于第二种消极的换出策略,则是在ucore调用alloc\_pages函数获取空闲页时,此函数如果发现无法从物理内存页分配器(比如first +fit)获得空闲页,就会进一步调用swap\_out函数换出某页,实现一种消极的换出策略。 + +#### 4. 页替换算法的数据结构设计 + +到实验二为止,我们知道目前表示内存中物理页使用情况的变量是基于数据结构Page的全局变量pages数组,pages的每一项表示了计算机系统中一个物理页的使用情况。为了表示物理页可被换出或已被换出的情况,可对Page数据结构进行扩展: +``` +struct Page { +…… +list\_entry\_t pra\_page\_link; +uintptr\_t pra\_vaddr; +}; +``` +pra\_page\_link可用来构造按页的第一次访问时间进行排序的一个链表,这个链表的开始表示第一次访问时间最近的页,链表结尾表示第一次访问时间最远的页。当然链表头可以就可设置为pra\_list\_head(定义在swap\_fifo.c中),构造的时机实在page +fault发生后,进行do\_pgfault函数时。pra\_vaddr可以用来记录此物理页对应的虚拟页起始地址。 + +当一个物理页 (struct Page) 需要被 swap +出去的时候,首先需要确保它已经分配了一个位于磁盘上的swap +page(由连续的8个扇区组成)。这里为了简化设计,在swap\_check函数中建立了每个虚拟页唯一对应的swap +page,其对应关系设定为:虚拟页对应的PTE的索引值 = swap +page的扇区起始位置\*8。 + +为了实现各种页替换算法,我们设计了一个页替换算法的类框架swap\_manager: + + struct swap_manager + { + const char *name; + /* Global initialization for the swap manager */ + int (*init) (void); + /* Initialize the priv data inside mm_struct */ + int (*init_mm) (struct mm_struct *mm); + /* Called when tick interrupt occured */ + int (*tick_event) (struct mm_struct *mm); + /* Called when map a swappable page into the mm_struct */ + int (*map_swappable) (struct mm_struct *mm, uintptr_t addr, struct Page *page, int swap_in); + /* When a page is marked as shared, this routine is called to delete the addr entry from the swap manager */ + int (*set_unswappable) (struct mm_struct *mm, uintptr_t addr); + /* Try to swap out a page, return then victim */ + int (*swap_out_victim) (struct mm_struct *mm, struct Page *ptr_page, int in_tick); + /* check the page relpacement algorithm */ + int (*check\_swap)(void); + }; + +这里关键的两个函数指针是map\_swappable和swap\_out\_vistim,前一个函数用于记录页访问情况相关属性,后一个函数用于挑选需要换出的页。显然第二个函数依赖与第一个函数记录的页访问情况。tick\_event函数指针也很重要,结合定时产生的中断,可以实现一种积极的换页策略。 + +#### 5. swap_check的检查实现 + +下面具体讲述一下实验三中实现置换算法的页面置换的检查执行逻辑,便于大家实现练习2。实验三页面置换的检查过程在函数swap\_check +(kern/mm/swap.c中)中,其大致流程如下。 + +1. 调用mm\_create建立mm变量,并调用vma\_create创建vma变量,设置合法的访问范围为4KB\~24KB; + +2. 调用free\_page等操作,模拟形成一个只有4个空闲 physical +page;并设置了从4KB\~24KB的连续5个虚拟页的访问操作; + +3. 设置记录缺页次数的变量pgfault\_num=0,执行check\_content\_set函数,使得起始地址分别对起始地址为0x1000, +0x2000, 0x3000, +0x4000的虚拟页按时间顺序先后写操作访问,由于之前没有建立页表,所以会产生page +fault异常,如果完成练习1,则这些从4KB\~20KB的4虚拟页会与ucore保存的4个物理页帧建立映射关系; + +4. 然后对虚页对应的新产生的页表项进行合法性检查; + +5. 然后进入测试页替换算法的主体,执行函数check\_content\_access,并进一步调用到\_fifo\_check\_swap函数,如果通过了所有的assert。这进一步表示FIFO页替换算法基本正确实现; + +6. 最后恢复ucore环境。 + +## 6.实验报告要求 + +从网站上下载lab3.zip后,解压得到本文档和代码目录 +lab3,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab3-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB3”的注释,代码中所有需要完成的地方(challenge除外)都有“LAB3”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。所有扩展实验的加分总和不超过10分。 + +附录:正确输出的参考: +``` +yuchen@yuchen-PAI4:~/oscourse/2012spring/lab3/lab3-code-2012$ make qemu + +(THU.CST) os is loading ... + +Special kernel symbols: + +entry 0xc010002c (phys) + +etext 0xc010962b (phys) + +edata 0xc0122ac8 (phys) + +end 0xc0123c10 (phys) + +Kernel executable memory footprint: 143KB + +memory management: default_pmm_manager + +e820map: + +memory: 0009f400, [00000000, 0009f3ff], type = 1. + +memory: 00000c00, [0009f400, 0009ffff], type = 2. + +memory: 00010000, [000f0000, 000fffff], type = 2. + +memory: 07efd000, [00100000, 07ffcfff], type = 1. + +memory: 00003000, [07ffd000, 07ffffff], type = 2. + +memory: 00040000, [fffc0000, ffffffff], type = 2. + +check_alloc_page() succeeded! + +check_pgdir() succeeded! + +check_boot_pgdir() succeeded! + +-------------------- BEGIN -------------------- + +PDE(0e0) c0000000-f8000000 38000000 urw + +|-- PTE(38000) c0000000-f8000000 38000000 -rw + +PDE(001) fac00000-fb000000 00400000 -rw + +|-- PTE(000e0) faf00000-fafe0000 000e0000 urw + +|-- PTE(00001) fafeb000-fafec000 00001000 -rw + +--------------------- END --------------------- + +check_vma_struct() succeeded! + +page fault at 0x00000100: K/W [no page found]. + +check_pgfault() succeeded! + +check_vmm() succeeded. + +ide 0: 10000(sectors), 'QEMU HARDDISK'. + +ide 1: 262144(sectors), 'QEMU HARDDISK'. + +SWAP: manager = fifo swap manager + +BEGIN check_swap: count 1, total 31992 + +mm->sm_priv c0123c04 in fifo_init_mm + +setup Page Table for vaddr 0X1000, so alloc a page + +setup Page Table vaddr 0~4MB OVER! + +set up init env for check_swap begin! + +page fault at 0x00001000: K/W [no page found]. + +page fault at 0x00002000: K/W [no page found]. + +page fault at 0x00003000: K/W [no page found]. + +page fault at 0x00004000: K/W [no page found]. + +set up init env for check_swap over! + +write Virt Page c in fifo_check_swap + +write Virt Page a in fifo_check_swap + +write Virt Page d in fifo_check_swap + +write Virt Page b in fifo_check_swap + +write Virt Page e in fifo_check_swap + +page fault at 0x00005000: K/W [no page found]. + +swap_out: i 0, store page in vaddr 0x1000 to disk swap entry 2 + +write Virt Page b in fifo_check_swap + +write Virt Page a in fifo_check_swap + +page fault at 0x00001000: K/W [no page found]. + +swap_out: i 0, store page in vaddr 0x2000 to disk swap entry 3 + +swap_in: load disk swap entry 2 with swap_page in vadr 0x1000 + +write Virt Page b in fifo_check_swap + +page fault at 0x00002000: K/W [no page found]. + +swap_out: i 0, store page in vaddr 0x3000 to disk swap entry 4 + +swap_in: load disk swap entry 3 with swap_page in vadr 0x2000 + +write Virt Page c in fifo_check_swap + +page fault at 0x00003000: K/W [no page found]. + +swap_out: i 0, store page in vaddr 0x4000 to disk swap entry 5 + +swap_in: load disk swap entry 4 with swap_page in vadr 0x3000 + +write Virt Page d in fifo_check_swap + +page fault at 0x00004000: K/W [no page found]. + +swap_out: i 0, store page in vaddr 0x5000 to disk swap entry 6 + +swap_in: load disk swap entry 5 with swap_page in vadr 0x4000 + +check_swap() succeeded! + +++ setup timer interrupts + +100 ticks + +End of Test. + +kernel panic at kern/trap/trap.c:20: + +EOT: kernel seems ok. + +Welcome to the kernel debug monitor!! + +Type 'help' for a list of commands. +``` + diff --git a/lab4.md b/lab4.md new file mode 100644 index 0000000..762abe2 --- /dev/null +++ b/lab4.md @@ -0,0 +1,653 @@ +# 实验四:内核线程管理 + +## 1. 实验目的 + +* 了解内核线程创建/执行的管理过程 + +* 了解内核线程的切换和基本调度过程 + +## 2. 实验内容 + +实验2/3完成了物理和虚拟内存管理,这给创建内核线程(内核线程是一种特殊的进程)打下了提供内存管理的基础。当一个程序加载到内存中运行时,首先通过ucore的内存管理分配合适的空间,然后就需要考虑如何使用CPU来“并发”执行多个程序。 + +本次实验将首先接触的是内核线程的管理。内核线程是一种特殊的进程,内核线程与用户进程的区别有两个:内核线程只运行在内核态而用户进程会在在用户态和内核态交替运行;所有内核线程直接使用共同的ucore内核内存空间,不需为每个内核线程维护单独的内存空间而用户进程需要维护各自的用户内存空间。相关原理介绍可看附录B:【原理】进程/线程的属性与特征解析。 + +### 2.1 练习 + +#### 练习0:填写已有实验 + +本实验依赖实验1/2/3。请把你做的实验1/2/3的代码填入本实验中代码中有“LAB1”,“LAB2”, +“LAB3”的注释相应部分。 + +#### 练习1:分配并初始化一个进程控制块(需要编码) + +alloc\_proc函数(位于kern/process/proc.c中)负责分配并返回一个新的struct +proc\_struct结构,用于存储新建立的内核线程的管理信息。ucore需要对这个结构进行最基本的初始化,你需要完成这个初始化过程。【提示】在alloc\_proc函数的实现中,需要初始化的proc\_struct结构中的成员变量至少包括:state/pid/runs/kstack/need\_resched/parent/mm/context/tf/cr3/flags/name。 + +#### 练习2:为新创建的内核线程分配资源(需要编码) + +创建一个内核线程需要分配和设置好很多资源。kernel\_thread函数通过调用**do\_fork**函数完成具体内核线程的创建工作。do\_kernel函数会调用alloc\_proc函数来分配并初始化一个进程控制块,但alloc\_proc只是找到了一小块内存用以记录进程的必要信息,并没有实际分配这些资源。ucore一般通过do\_fork实际创建新的内核线程。do\_fork的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。你需要完成在kern/process/proc.c中的do\_fork函数中的处理过程。它的大致执行步骤包括: + +* 调用alloc\_proc,首先获得一块用户信息块。 + +* 为进程分配一个内核栈。 + +* 复制原进程的内存管理信息到新进程(但内核线程不必做此事) + +* 复制原进程上下文到新进程 + +* 将新进程添加到进程列表 + +* 唤醒新进程 + +* 返回新进程号 + +#### 练习3:阅读代码,理解 proc\_run 和它调用的函数如何完成进程切换的。(无编码工作) + +完成代码编写后,编译并运行代码:make qemu + +如果可以得到如 附录A所示的显示内容(仅供参考,不是标准答案输出),则基本正确。 + +#### 扩展练习Challenge:实现支持任意大小的内存分配算法 + +这不是本实验的内容,其实是上一次实验内存的扩展,但考虑到现在的slab算法比较复杂,有必要实现一个比较简单的任意大小内存分配算法。可参考本实验中的slab如何调用基于页的内存分配算法(注意,不是要你关注slab的具体实现)来实现first-fit/best-fit/worst-fit/buddy等支持任意大小的内存分配算法。。 + +【注意】下面是相关的Linux实现文档,供参考 + +SLOB + +[http://en.wikipedia.org/wiki/SLOB](http://en.wikipedia.org/wiki/SLOB) +[http://lwn.net/Articles/157944/](http://lwn.net/Articles/157944/) + +SLAB + +[https://www.ibm.com/developerworks/cn/linux/l-linux-slab-allocator/](https://www.ibm.com/developerworks/cn/linux/l-linux-slab-allocator/) + +### 2.2 项目组成 + +├── boot +├── kern +│ ├── debug +│ ├── driver +│ ├── fs +│ ├── init +│ │ ├── init.c +│ │ └── ... +│ ├── libs +│ │ ├── rb\_tree.c +│ │ ├── rb\_tree.h +│ │ └── ... +│ ├── mm +│ │ ├── kmalloc.c +│ │ ├── kmalloc.h +│ │ ├── memlayout.h +│ │ ├── pmm.c +│ │ ├── pmm.h +│ │ ├── swap.c +│ │ ├── vmm.c +│ │ └── ... +│ ├── process +│ │ ├── entry.S +│ │ ├── proc.c +│ │ ├── proc.h +│ │ └── switch.S +│ ├── schedule +│ │ ├── sched.c +│ │ └── sched.h +│ ├── sync +│ │ └── sync.h +│ └── trap +│ ├── trapentry.S +│ └── ... +├── libs +│ ├── hash.c +│ ├── stdlib.h +│ ├── unistd.h +│ └── ... +├── Makefile +└── tools + +相对与实验三,实验四主要增加的文件如上表红色部分所示,主要修改的文件如上表紫色部分所示。主要改动如下: + +● kern/process/ (新增进程管理相关文件) + +proc.[ch]:新增:实现进程、线程相关功能,包括:创建进程/线程,初始化进程/线程,处理进程/线程退出等功能 + +entry.S:新增:内核线程入口函数kernel\_thread\_entry的实现 + +switch.S:新增:上下文切换,利用堆栈保存、恢复进程上下文 + +● kern/init/ + +init.c:修改:完成进程系统初始化,并在内核初始化后切入idle进程 + +● kern/mm/ +(基本上与本次实验没有太直接的联系,了解kmalloc和kfree如何使用即可) + +kmalloc.[ch]:新增:定义和实现了新的kmalloc/kfree函数。具体实现是基于slab分配的简化算法 +(只要求会调用这两个函数即可) + +memlayout.h:增加slab物理内存分配相关的定义与宏 (可不用理会)。 + +pmm.[ch]:修改:在pmm.c中添加了调用kmalloc\_init函数,取消了老的kmalloc/kfree的实现;在pmm.h中取消了老的kmalloc/kfree的定义 + +swap.c:修改:取消了用于check的Line 185的执行 + +vmm.c:修改:调用新的kmalloc/kfree + +● kern/trap/ + +trapentry.S:增加了汇编写的函数forkrets,用于do\_fork调用的返回处理。 + +● kern/schedule/ + +sched.[ch]:新增:实现FIFO策略的进程调度 + +● kern/libs + +rb\_tree.[ch]:新增:实现红黑树,被slab分配的简化算法使用(可不用理会) + +**编译执行** + +编译并运行代码的命令如下: +``` +make + +make qemu +``` +则可以得到如附录A所示的显示内容(仅供参考,不是标准答案输出) + +## 3. 内核线程管理 + +### 3.1 实验执行流程概述 + +lab2和lab3完成了对内存的虚拟化,但整个控制流还是一条线串行执行。lab4将在此基础上进行CPU的虚拟化,即让ucore实现分时共享CPU,实现多条控制流能够并发执行。从某种程度上,我们可以把控制流看作是一个内核线程。本次实验将首先接触的是内核线程的管理。内核线程是一种特殊的进程,内核线程与用户进程的区别有两个:内核线程只运行在内核态而用户进程会在在用户态和内核态交替运行;所有内核线程直接使用共同的ucore内核内存空间,不需为每个内核线程维护单独的内存空间而用户进程需要维护各自的用户内存空间。从内存空间占用情况这个角度上看,我们可以把线程看作是一种共享内存空间的轻量级进程。 + +为了实现内核线程,需要设计管理线程的数据结构,即进程控制块(在这里也可叫做线程控制块)。如果要让内核线程运行,我们首先要创建内核线程对应的进程控制块,还需把这些进程控制块通过链表连在一起,便于随时进行插入,删除和查找操作等进程管理事务。这个链表就是进程控制块链表。然后在通过调度器(scheduler)来让不同的内核线程在不同的时间段占用CPU执行,实现对CPU的分时共享。那lab4中是如何一步一步实现这个过程的呢? + +我们还是从lab4/kern/init/init.c中的kern\_init函数入手分析。在kern\_init函数中,当完成虚拟内存的初始化工作后,就调用了proc\_init函数,这个函数完成了idleproc内核线程和initproc内核线程的创建或复制工作,这也是本次实验要完成的练习。idleproc内核线程的工作就是不停地查询,看是否有其他内核线程可以执行了,如果有,马上让调度器选择那个内核线程执行(请参考cpu\_idle函数的实现)。所以idleproc内核线程是在ucore操作系统没有其他内核线程可执行的情况下才会被调用。接着就是调用kernel\_thread函数来创建initproc内核线程。initproc内核线程的工作就是显示“Hello +World”,表明自己存在且能正常工作了。 + +调度器会在特定的调度点上执行调度,完成进程切换。在lab4中,这个调度点就一处,即在cpu\_idle函数中,此函数如果发现当前进程(也就是idleproc)的need\_resched置为1(在初始化idleproc的进程控制块时就置为1了),则调用schedule函数,完成进程调度和进程切换。进程调度的过程其实比较简单,就是在进程控制块链表中查找到一个“合适”的内核线程,所谓“合适”就是指内核线程处于“PROC\_RUNNABLE”状态。在接下来的switch\_to函数(在后续有详细分析,有一定难度,需深入了解一下)完成具体的进程切换过程。一旦切换成功,那么initproc内核线程就可以通过显示字符串来表明本次实验成功。 + +接下来将主要介绍了进程创建所需的重要数据结构--进程控制块 +proc\_struct,以及ucore创建并执行内核线程idleproc和initproc的两种不同方式,特别是创建initproc的方式将被延续到实验五中,扩展为创建用户进程的主要方式。另外,还初步涉及了进程调度(实验六涉及并会扩展)和进程切换内容。 + +### 3.2 设计关键数据结构 -- 进程控制块 + +在实验四中,进程管理信息用struct +proc\_struct表示,在*kern/process/proc.h*中定义如下: +``` +struct proc_struct { + +enum proc_state state; // Process state + +int pid; // Process ID + +int runs; // the running times of Proces + +uintptr_t kstack; // Process kernel stack + +volatile bool need_resched; // need to be rescheduled to release CPU? + +struct proc_struct *parent; // the parent process + +struct mm_struct *mm; // Process's memory management field + +struct context context; // Switch here to run process + +struct trapframe *tf; // Trap frame for current interrupt + +uintptr_t cr3; // the base addr of Page Directroy Table(PDT) + +uint32_t flags; // Process flag + +char name[PROC_NAME_LEN + 1]; // Process name + +list_entry_t list_link; // Process link list + +list_entry_t hash_link; // Process hash list + +}; +``` +下面重点解释一下几个比较重要的成员变量: + +● mm +:内存管理的信息,包括内存映射列表、页表指针等。mm成员变量在lab3中用于虚存管理。但在实际OS中,内核线程常驻内存,不需要考虑swap +page问题,在lab5中涉及到了用户进程,才考虑进程用户内存空间的swap +page问题,mm才会发挥作用。所以在lab4中mm对于内核线程就没有用了,这样内核线程的proc\_struct的成员变量\*mm=0是合理的。mm里有个很重要的项pgdir,记录的是该进程使用的一级页表的物理地址。由于\*mm=NULL,所以在proc\_struct数据结构中需要有一个代替pgdir项来记录页表起始地址,这就是proc\_struct数据结构中的cr3成员变量。 + +● state:进程所处的状态。 + +● parent +:用户进程的父进程(创建它的进程)。在所有进程中,只有一个进程没有父进程,就是内核创建的第一个内核线程idleproc。内核根据这个父子关系建立一个树形结构,用于维护一些特殊的操作,例如确定某个进程是否可以对另外一个进程进行某种操作等等。 + +● context:进程的上下文,用于进程切换(参见switch.S)。在 ucore +中,所有的进程在内核中也是相对独立的(例如独立的内核堆栈以及上下文等等)。使用 +context +保存寄存器的目的就在于在内核态中能够进行上下文之间的切换。实际利用context进行上下文切换的函数是在*kern/process/switch.S*中定义switch\_to。 + +● +tf:中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,ucore +内核允许嵌套中断。因此为了保证嵌套中断发生时tf 总是能够指向当前的 +trapframe,ucore 在内核栈上维护了 tf 的链,可以参考 +trap.c::trap函数做进一步的了解。 + +l cr3: cr3 保存页表的物理地址,目的就是进程切换的时候方便直接使用 lcr3 +实现页表切换,避免每次都根据 mm 来计算 cr3。mm +数据结构是用来实现用户空间的虚存管理的,但是内核线程没有用户空间,它执行的只是内核中的一小段代码(通常是一小段函数),所以它没有 +mm 结构,也就是NULL。当某个进程是一个普通用户态进程的时候,PCB 中的 cr3 +就是 mm 中页表(pgdir)的物理地址;而当它是内核线程的时候,cr3 等于 +boot\_cr3。 +而boot\_cr3指向了ucore启动时建立好的饿内核虚拟空间的页目录表首地址。 + +● kstack: +每个线程都有一个内核栈,并且位于内核地址空间的不同位置。对于内核线程,该栈就是运行时的程序使用的栈;而对于普通进程,该栈是发生特权级改变的时候使保存被打断的硬件信息用的栈。Ucore在创建进程时分配了 +2 个连续的物理页(参见 +memlayout.h中KSTACKSIZE的定义)作为内核栈的空间。这个栈很小,所以内核中的代码应该尽可能的紧凑,并且避免在栈上分配大的数据结构,以免栈溢出,导致系统崩溃。kstack记录了分配给该进程/线程的内核栈的位置。主要作用有以下几点。首先,当内核准备从一个进程切换到另一个的时候,需要根据 +kstack 的值正确的设置好 tss (可以回顾一下在实验一中讲述的 tss +在中断处理过程中的作用),以便在进程切换以后再发生中断时能够使用正确的栈。其次,内核栈位于内核地址空间,并且是不共享的(每个线程都拥有自己的内核栈),因此不受到 +mm 的管理,当进程退出的时候,内核能够根据 kstack +的值快速定位栈的位置并进行回收。ucore 的这种内核栈的设计借鉴的是 linux +的方法(但由于内存管理实现的差异,它实现的远不如 linux +的灵活),它使得每个线程的内核栈在不同的位置,这样从某种程度上方便调试,但同时也使得内核对栈溢出变得十分不敏感,因为一旦发生溢出,它极可能污染内核中其它的数据使得内核崩溃。如果能够通过页表,将所有进程的内核栈映射到固定的地址上去,能够避免这种问题,但又会使得进程切换过程中对栈的修改变得相当繁琐。感兴趣的同学可以参考 +linux kernel 的代码对此进行尝试。 + +为了管理系统中所有的进程控制块,ucore维护了如下全局变量(位于*kern/process/proc.c*): + +● static struct proc +\*current:当前占用CPU且处于“运行”状态进程控制块指针。通常这个变量是只读的,只有在进程切换的时候才进行修改,并且整个切换和修改过程需要保证操作的原子性,目前至少需要屏蔽中断。可以参考 +switch\_to 的实现。 + +● static struct proc +\*initproc:本实验中,指向一个内核线程。本实验以后,此指针将指向第一个用户态进程。 + +● static list\_entry\_t +hash\_list[HASH\_LIST\_SIZE]:所有进程控制块的哈希表,proc\_struct中的成员变量hash\_link将基于pid链接入这个哈希表中。 + +● list\_entry\_t +proc\_list:所有进程控制块的双向线性列表,proc\_struct中的成员变量list\_link将链接入这个链表中。 + +### 3.3 创建并执行内核线程 + +建立进程控制块(proc.c中的alloc\_proc函数)后,现在就可以通过进程控制块来创建具体的进程了。首先,考虑最简单的内核线程,它通常只是内核中的一小段代码或者函数,没有用户空间。而由于在操作系统启动后,已经对整个核心内存空间进行了管理,通过设置页表建立了核心虚拟空间(即boot\_cr3指向的二级页表描述的空间)。所以内核中的所有线程都不需要再建立各自的页表,只需共享这个核心虚拟空间就可以访问整个物理内存了。 + +#### 1. 创建第 0 个内核线程 idleproc + +在init.c::kern\_init函数调用了proc.c::proc\_init函数。proc\_init函数启动了创建内核线程的步骤。首先当前的执行上下文(从kern\_init +启动至今)就可以看成是ucore内核(也可看做是内核进程)中的一个内核线程的上下文。为此, +ucore +通过给当前执行的上下文分配一个进程控制块以及对它进行相应初始化,将其打造成第0个内核线程 +-- idleproc。具体步骤如下: + +首先调用alloc\_proc函数来通过kmalloc函数获得proc\_struct结构的一块内存—proc,这就是第0个进程控制块了,并把proc进行初步初始化(即把proc\_struct中的各个成员变量清零)。但有些成员变量设置了特殊的值: +``` +练习1 //设置进程为“初始”态 + +练习1 //进程的pid还没设置好 + +练习1 //进程在内核中使用的内核页表的起始地址 +``` +上述三条语句中,第一条设置了进程的状态为“初始”态,这表示进程已经 +“出生”了,正在获取资源茁壮成长中;第二条语句设置了进程的pid为-1,这表示进程的“身份证号”还没有办好;第三条语句表明由于该内核线程在内核中运行,故采用为ucore内核已经建立的页表,即设置为在ucore内核页表的起始地址boot\_cr3。后续实验中可进一步看出所有进程的内核虚地址空间(也包括物理地址空间)是相同的。既然内核线程共用一个映射内核空间的页表,这表示所有这些内核空间对所有内核线程都是“可见”的,所以更精确地说,这些内核线程都应该是从属于同一个唯一的内核进程—ucore内核。 + +接下来,proc\_init函数对idleproc内核线程进行进一步初始化: +``` +idleproc->pid = 0; + +idleproc->state = PROC_RUNNABLE; + +idleproc->kstack = (uintptr_t)bootstack; + +idleproc->need_resched = 1; + +set_proc_name(idleproc, "idle"); +``` +需要注意前4条语句。第一条语句给了idleproc合法的身份证号--0,这名正言顺地表明了idleproc是第0个内核线程。通常可以通过pid的赋值来表示线程的创建和身份确定。“0”是第一个的表示方法是计算机领域所特有的,比如C语言定义的第一个数组元素的小标也是“0”。第二条语句改变了idleproc的状态,使得它从“出生”转到了“准备工作”,就差ucore调度它执行了。第三条语句设置了idleproc所使用的内核栈的起始地址。需要注意以后的其他线程的内核栈都需要通过分配获得,因为ucore启动时设置的内核栈直接分配给idleproc使用了。第四条很重要,因为ucore希望当前CPU应该做更有用的工作,而不是运行idleproc这个“无所事事”的内核线程,所以把idleproc-\>need\_resched设置为“1”,结合idleproc的执行主体--cpu\_idle函数的实现,可以清楚看出如果当前idleproc在执行,则只要此标志为1,马上就调用schedule函数要求调度器切换其他进程执行。 + +#### 2. 创建第 1 个内核线程 initproc + +第0个内核线程主要工作是完成内核中各个子系统的初始化,然后就通过执行cpu\_idle函数开始过退休生活了。所以ucore接下来还需创建其他进程来完成各种工作,但idleproc内核子线程自己不想做,于是就通过调用kernel\_thread函数创建了一个内核线程init\_main。在实验四中,这个子内核线程的工作就是输出一些字符串,然后就返回了(参看init\_main函数)。但在后续的实验中,init\_main的工作就是创建特定的其他内核线程或用户进程(实验五涉及)。下面我们来分析一下创建内核线程的函数kernel\_thread: +``` +kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) +{ + +struct trapframe tf; + +memset(&tf, 0, sizeof(struct trapframe)); + +tf.tf_cs = KERNEL_CS; + +tf.tf_ds = tf_struct.tf_es = tf_struct.tf_ss = KERNEL_DS; + +tf.tf_regs.reg_ebx = (uint32_t)fn; + +tf.tf_regs.reg_edx = (uint32_t)arg; + +tf.tf_eip = (uint32_t)kernel_thread_entry; + +return do_fork(clone_flags | CLONE_VM, 0, &tf); + +} +``` +注意,kernel\_thread函数采用了局部变量tf来放置保存内核线程的临时中断帧,并把中断帧的指针传递给do\_fork函数,而do\_fork函数会调用copy\_thread函数来在新创建的进程内核栈上专门给进程的中断帧分配一块空间。 + +给中断帧分配完空间后,就需要构造新进程的中断帧,具体过程是:首先给tf进行清零初始化,并设置中断帧的代码段(tf.tf\_cs)和数据段(tf.tf\_ds/tf\_es/tf\_ss)为内核空间的段(KERNEL\_CS/ +KERNEL\_DS),这实际上也说明了initproc内核线程在内核空间中执行。而initproc内核线程从哪里开始执行呢?tf.tf\_eip的指出了是kernel\_thread\_entry(位于kern/process/entry.S中),kernel\_thread\_entry是entry.S中实现的汇编函数,它做的事情很简单: +``` +kernel_thread_entry: # void kernel_thread(void) + +pushl %edx # push arg + +call *%ebx # call fn + +pushl %eax # save the return value of fn(arg) + +call do_exit # call do_exit to terminate current thread +``` +从上可以看出,kernel\_thread\_entry函数主要为内核线程的主体fn函数做了一个准备开始和结束运行的“壳”,并把函数fn的参数arg(保存在edx寄存器中)压栈,然后调用fn函数,把函数返回值eax寄存器内容压栈,调用do\_exit函数退出线程执行。 + +do\_fork是创建线程的主要函数。kernel\_thread函数通过调用do\_fork函数最终完成了内核线程的创建工作。下面我们来分析一下do\_fork函数的实现(练习2)。do\_fork函数主要做了以下6件事情: + +1. 分配并初始化进程控制块(alloc\_proc函数); + +2. 分配并初始化内核栈(setup\_stack函数); + +3. 根据clone\_flag标志复制或共享进程内存管理结构(copy\_mm函数); + +4. +设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy\_thread函数); + +5. 把设置好的进程控制块放入hash\_list和proc\_list两个全局进程链表中; + +6. 自此,进程已经准备好执行了,把进程状态设置为“就绪”态; + +7. 设置返回码为子进程的id号。 + +这里需要注意的是,如果上述前3步执行没有成功,则需要做对应的出错处理,把相关已经占有的内存释放掉。copy\_mm函数目前只是把current-\>mm设置为NULL,这是由于目前在实验四中只能创建内核线程,proc-\>mm描述的是进程用户态空间的情况,所以目前mm还用不上。copy\_thread函数做的事情比较多,代码如下: +``` +static void + +copy_thread(struct proc_struct *proc, uintptr_t esp, struct +trapframe *tf) { + +//在内核堆栈的顶部设置中断帧大小的一块栈空间 + +proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1; + +*(proc->tf) = *tf; //拷贝在kernel_thread函数建立的临时中断帧的初始值 + +proc->tf->tf_regs.reg_eax = 0; +//设置子进程/线程执行完do_fork后的返回值 + +proc->tf->tf_esp = esp; //设置中断帧中的栈指针esp + +proc->tf->tf_eflags |= FL_IF; //使能中断 + +proc->context.eip = (uintptr_t)forkret; + +proc->context.esp = (uintptr_t)(proc->tf); + +} +``` +此函数首先在内核堆栈的顶部设置中断帧大小的一块栈空间,并在此空间中拷贝在kernel\_thread函数建立的临时中断帧的初始值,并进一步设置中断帧中的栈指针esp和标志寄存器eflags,特别是eflags设置了FL\_IF标志,这表示此内核线程在执行过程中,能响应中断,打断当前的执行。执行到这步后,此进程的中断帧就建立好了,对于initproc而言,它的中断帧如下所示: +``` +//所在地址位置 + +initproc->tf= (proc->kstack+KSTACKSIZE) – sizeof (struct trapframe); + +//具体内容 + +initproc->tf.tf_cs = KERNEL_CS; + +initproc->tf.tf_ds = initproc->tf.tf_es = initproc->tf.tf_ss = +KERNEL_DS; + +initproc->tf.tf_regs.reg_ebx = (uint32_t)init_main; + +initproc->tf.tf_regs.reg_edx = (uint32_t) ADDRESS of "Hello +world!!"; + +initproc->tf.tf_eip = (uint32_t)kernel_thread_entry; + +initproc->tf.tf_regs.reg_eax = 0; + +initproc->tf.tf_esp = esp; + +initproc->tf.tf_eflags |= FL_IF; +``` +设置好中断帧后,最后就是设置initproc的进程上下文,(process +context,也称执行现场)了。只有设置好执行现场后,一旦ucore调度器选择了initproc执行,就需要根据initproc-\>context中保存的执行现场来恢复initproc的执行。这里设置了initproc的执行现场中主要的两个信息:上次停止执行时的下一条指令地址context.eip和上次停止执行时的堆栈地址context.esp。其实initproc还没有执行过,所以这其实就是initproc实际执行的第一条指令地址和堆栈指针。可以看出,由于initproc的中断帧占用了实际给initproc分配的栈空间的顶部,所以initproc就只能把栈顶指针context.esp设置在initproc的中断帧的起始位置。根据context.eip的赋值,可以知道initproc实际开始执行的地方在forkret函数(主要完成do\_fork函数返回的处理工作)处。至此,initproc内核线程已经做好准备执行了。 + +#### 3. 调度并执行内核线程 initproc + +在ucore执行完proc\_init函数后,就创建好了两个内核线程:idleproc和initproc,这时ucore当前的执行现场就是idleproc,等到执行到init函数的最后一个函数cpu\_idle之前,ucore的所有初始化工作就结束了,idleproc将通过执行cpu\_idle函数让出CPU,给其它内核线程执行,具体过程如下: +``` +void + cpu_idle(void) { + while (1) { + if (current->need_resched) { + schedule(); + …… +``` +首先,判断当前内核线程idleproc的need\_resched是否不为0,回顾前面“创建第一个内核线程idleproc”中的描述,proc\_init函数在初始化idleproc中,就把idleproc-\>need\_resched置为1了,所以会马上调用schedule函数找其他处于“就绪”态的进程执行。 + +ucore在实验四中只实现了一个最简单的FIFO调度器,其核心就是schedule函数。它的执行逻辑很简单: + +1. 设置当前内核线程current-\>need\_resched为0; + +2. 在proc\_list队列中查找下一个处于“就绪”态的线程或进程next; + +3. +找到这样的进程后,就调用proc\_run函数,保存当前进程current的执行现场(进程上下文),恢复新进程的执行现场,完成进程切换。 + +至此,新的进程next就开始执行了。由于在proc10中只有两个内核线程,且idleproc要让出CPU给initproc执行,我们可以看到schedule函数通过查找proc\_list进程队列,只能找到一个处于“就绪”态的initproc内核线程。并通过proc\_run和进一步的switch\_to函数完成两个执行现场的切换,具体流程如下: + +1. 让current指向next内核线程initproc; + +2. +设置任务状态段ts中特权态0下的栈顶指针esp0为next内核线程initproc的内核栈的栈顶,即next-\>kstack + KSTACKSIZE ; + +3. +设置CR3寄存器的值为next内核线程initproc的页目录表起始地址next-\>cr3,这实际上是完成进程间的页表切换; + +4. +由switch\_to函数完成具体的两个线程的执行现场切换,即切换各个寄存器,当switch\_to函数执行完“ret”指令后,就切换到initproc执行了。 + +注意,在第二步设置任务状态段ts中特权态0下的栈顶指针esp0的目的是建立好内核线程或将来用户线程在执行特权态切换(从特权态0<--\>特权态3,或从特权态3<--\>特权态3)时能够正确定位处于特权态0时进程的内核栈的栈顶,而这个栈顶其实放了一个trapframe结构的内存空间。如果是在特权态3发生了中断/异常/系统调用,则CPU会从特权态3--\>特权态0,且CPU从此栈顶(当前被打断进程的内核栈顶)开始压栈来保存被中断/异常/系统调用打断的用户态执行现场;如果是在特权态0发生了中断/异常/系统调用,则CPU会从从当前内核栈指针esp所指的位置开始压栈保存被中断/异常/系统调用打断的内核态执行现场。反之,当执行完对中断/异常/系统调用打断的处理后,最后会执行一个“iret”指令。在执行此指令之前,CPU的当前栈指针esp一定指向上次产生中断/异常/系统调用时CPU保存的被打断的指令地址CS和EIP,“iret”指令会根据ESP所指的保存的址CS和EIP恢复到上次被打断的地方继续执行。 + +在页表设置方面,由于idleproc和initproc都是共用一个内核页表boot\_cr3,所以此时第三步其实没用,但考虑到以后的进程有各自的页表,其起始地址各不相同,只有完成页表切换,才能确保新的进程能够正常执行。 + +第四步proc\_run函数调用switch\_to函数,参数是前一个进程和后一个进程的执行现场:process +context。在上一节“设计进程控制块”中,描述了context结构包含的要保存和恢复的寄存器。我们再看看switch.S中的switch\_to函数的执行流程: +``` +.globl switch_to + +switch_to: # switch_to(from, to) + +# save from's registers + +movl 4(%esp), %eax # eax points to from + +popl 0(%eax) # esp--> return address, so save return addr in FROM’s +context + +movl %esp, 4(%eax) + +…… + +movl %ebp, 28(%eax) + +# restore to's registers + +movl 4(%esp), %eax # not 8(%esp): popped return address already + +# eax now points to to + +movl 28(%eax), %ebp + +…… + +movl 4(%eax), %esp + +pushl 0(%eax) # push TO’s context’s eip, so return addr = TO’s eip + +ret # after ret, eip= TO’s eip +``` +首先,保存前一个进程的执行现场,前两条汇编指令(如下所示)保存了进程在返回switch\_to函数后的指令地址到context.eip中 +``` +movl 4(%esp), %eax # eax points to from + +popl 0(%eax) # esp--> return address, so save return addr in FROM’s +context +``` +在接下来的7条汇编指令完成了保存前一个进程的其他7个寄存器到context中的相应成员变量中。至此前一个进程的执行现场保存完毕。再往后是恢复向一个进程的执行现场,这其实就是上述保存过程的逆执行过程,即从context的高地址的成员变量ebp开始,逐一把相关成员变量的值赋值给对应的寄存器,倒数第二条汇编指令“pushl +0(%eax)”其实把context中保存的下一个进程要执行的指令地址context.eip放到了堆栈顶,这样接下来执行最后一条指令“ret”时,会把栈顶的内容赋值给EIP寄存器,这样就切换到下一个进程执行了,即当前进程已经是下一个进程了。 +ucore会执行进程切换,让initproc执行。在对initproc进行初始化时,设置了initproc-\>context.eip = (uintptr\_t)forkret,这样,当执行switch\_to函数并返回后,initproc将执行其实际上的执行入口地址forkret。而forkret会调用位于kern/trap/trapentry.S中的forkrets函数执行,具体代码如下: +``` +.globl __trapret + __trapret: + # restore registers from stack + popal + # restore %ds and %es + popl %es + popl %ds + # get rid of the trap number and error code + addl $0x8, %esp + iret + .globl forkrets + forkrets: + # set stack to this new process's trapframe + movl 4(%esp), %esp //把esp指向当前进程的中断帧 + jmp __trapret +``` +可以看出,forkrets函数首先把esp指向当前进程的中断帧,从\_trapret开始执行到iret前,esp指向了current-\>tf.tf\_eip,而如果此时执行的是initproc,则current-\>tf.tf\_eip= +kernel\_thread\_entry,initproc-\>tf.tf\_cs = +KERNEL\_CS,所以当执行完iret后,就开始在内核中执行kernel\_thread\_entry函数了,而initproc-\>tf.tf\_regs.reg\_ebx += init\_main,所以在kernl\_thread\_entry中执行“call +%ebx”后,就开始执行initproc的主体了。Initprocde的主体函数很简单就是输出一段字符串,然后就返回到kernel\_tread\_entry函数,并进一步调用do\_exit执行退出操作了。本来do\_exit应该完成一些资源回收工作等,但这些不是实验四涉及的,而是由后续的实验来完成。至此,实验四中的主要工作描述完毕。 + +## 4. 实验报告要求 + +从网站上下载lab4.zip后,解压得到本文档和代码目录 +lab4,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab4-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB4”的注释,代码中所有需要完成的地方(challenge除外)都有“LAB4”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。 + +## 附录A:实验四的参考输出如下: +``` +make qemu + (THU.CST) os is loading ... + + Special kernel symbols: + entry 0xc010002c (phys) + etext 0xc010d0f7 (phys) + edata 0xc012dad0 (phys) + end 0xc0130e78 (phys) + Kernel executable memory footprint: 196KB + memory management: default_pmm_manager + e820map: + memory: 0009f400, [00000000, 0009f3ff], type = 1. + memory: 00000c00, [0009f400, 0009ffff], type = 2. + memory: 00010000, [000f0000, 000fffff], type = 2. + memory: 07efd000, [00100000, 07ffcfff], type = 1. + memory: 00003000, [07ffd000, 07ffffff], type = 2. + memory: 00040000, [fffc0000, ffffffff], type = 2. + check_alloc_page() succeeded! + check_pgdir() succeeded! + check_boot_pgdir() succeeded! + -------------------- BEGIN -------------------- + PDE(0e0) c0000000-f8000000 38000000 urw + |-- PTE(38000) c0000000-f8000000 38000000 -rw + PDE(001) fac00000-fb000000 00400000 -rw + |-- PTE(000e0) faf00000-fafe0000 000e0000 urw + |-- PTE(00001) fafeb000-fafec000 00001000 -rw + --------------------- END --------------------- + check_slab() succeeded! + kmalloc_init() succeeded! + check_vma_struct() succeeded! + page fault at 0x00000100: K/W [no page found]. + check_pgfault() succeeded! + check_vmm() succeeded. + ide 0: 10000(sectors), 'QEMU HARDDISK'. + ide 1: 262144(sectors), 'QEMU HARDDISK'. + SWAP: manager = fifo swap manager + BEGIN check_swap: count 1, total 31944 + mm->sm_priv c0130e64 in fifo_init_mm + setup Page Table for vaddr 0X1000, so alloc a page + setup Page Table vaddr 0~4MB OVER! + set up init env for check_swap begin! + page fault at 0x00001000: K/W [no page found]. + page fault at 0x00002000: K/W [no page found]. + page fault at 0x00003000: K/W [no page found]. + page fault at 0x00004000: K/W [no page found]. + set up init env for check_swap over! + write Virt Page c in fifo_check_swap + write Virt Page a in fifo_check_swap + write Virt Page d in fifo_check_swap + write Virt Page b in fifo_check_swap + write Virt Page e in fifo_check_swap + page fault at 0x00005000: K/W [no page found]. + swap_out: i 0, store page in vaddr 0x1000 to disk swap entry 2 + write Virt Page b in fifo_check_swap + write Virt Page a in fifo_check_swap + page fault at 0x00001000: K/W [no page found]. + swap_out: i 0, store page in vaddr 0x2000 to disk swap entry 3 + swap_in: load disk swap entry 2 with swap_page in vadr 0x1000 + write Virt Page b in fifo_check_swap + page fault at 0x00002000: K/W [no page found]. + swap_out: i 0, store page in vaddr 0x3000 to disk swap entry 4 + swap_in: load disk swap entry 3 with swap_page in vadr 0x2000 + write Virt Page c in fifo_check_swap + page fault at 0x00003000: K/W [no page found]. + swap_out: i 0, store page in vaddr 0x4000 to disk swap entry 5 + swap_in: load disk swap entry 4 with swap_page in vadr 0x3000 + write Virt Page d in fifo_check_swap + page fault at 0x00004000: K/W [no page found]. + swap_out: i 0, store page in vaddr 0x5000 to disk swap entry 6 + swap_in: load disk swap entry 5 with swap_page in vadr 0x4000 + check_swap() succeeded! + ++ setup timer interrupts + this initproc, pid = 1, name = "init" + To U: "Hello world!!". + To U: "en.., Bye, Bye. :)" + kernel panic at kern/process/proc.c:316: + process exit!!. + + Welcome to the kernel debug monitor!! + Type 'help' for a list of commands. + K> +``` +## 附录B:【原理】进程的属性与特征解析 + +操作系统负责进程管理,即从程序加载到运行结束的全过程,这个程序运行过程将经历从“出生”到“死亡”的完整“生命”历程。所谓“进程”就是指这个程序运行的整个执行过程。为了记录、描述和管理程序执行的动态变化过程,需要有一个数据结构,这就是进程控制块。进程与进程控制块是一一对应的。为此,ucore需要建立合适的进程控制块数据结构,并基于进程控制块来完成对进程的管理。 + +为了让多个程序能够使用CPU执行任务,需要设计用于进程管理的内核数据结构“进程控制块”。但到底如何设计进程控制块,如何管理进程?如果对进程的属性和特征了解不够,则无法有效地设计进程控制块和实现进程管理。 + +再一次回到进程的定义:一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程。这里有四个关键词:程序、数据集合、执行和动态执行过程。从CPU的角度来看,所谓程序就是一段特定的指令机器码序列而已。CPU会一条一条地取出在内存中程序的指令并按照指令的含义执行各种功能;所谓数据集合就是使用的内存;所谓执行就是让CPU工作。这个数据集合和执行其实体现了进程对资源的占用。动态执行过程体现了程序执行的不同“生命”阶段:诞生、工作、休息/等待、死亡。如果这一段指令执行完毕,也就意味着进程结束了。从开始执行到执行结束是一个进程的全过程。那么操作系统需要管理进程的什么?如果计算机系统中只有一个进程,那操作系统的工作就简单了。进程管理就是管理进程执行的指令,进程占用的资源,进程执行的状态。这可归结为对一个进程内的管理工作。但实际上在计算机系统的内存中,可以放很多程序,这也就意味着操作系统需要管理多个进程,那么,为了协调各进程对系统资源的使用,进程管理还需要做一些与进程协调有关的其他管理工作,包括进程调度、进程间的数据共享、进程间执行的同步互斥关系(后续相关实验涉及)等。下面逐一进行解析。 + +#### 1. 资源管理 + +在计算机系统中,进程会占用内存和CPU,这都是有限的资源,如果不进行合理的管理,资源会耗尽或无法高效公平地使用,从而会导致计算机系统中的多个进程执行效率很低,甚至由于资源不够而无法正常执行。 + +对于用户进程而言,操作系统是它的“上帝”,操作系统给了用户进程可以运行所需的资源,最基本的资源就是内存和CPU。在实验二/三中涉及的内存管理方法和机制可直接应用到进程的内存资源管理中来。在有多个进程存在的情况下,对于CPU这种资源,则需要通过进程调度来合理选择一个进程,并进一步通过进程分派和进程切换让不同的进程分时复用CPU,执行各自的工作。对于无法剥夺的共享资源,如果资源管理不当,多个进程会出现死锁或饥饿现象。 + +#### 2. 进程状态管理 + +用户进程有不同的状态(可理解为“生命”的不同阶段),当操作系统把程序的放到内存中后,这个进程就“诞生”了,不过还没有开始执行,但已经消耗了内存资源,处于“创建”状态;当进程准备好各种资源,就等能够使用CPU时,进程处于“就绪”状态;当进程终于占用CPU,程序的指令被CPU一条一条执行的时候,这个进程就进入了“运行”状态,这时除了继续占用内存资源外,还占用了CPU资源;当进程由于等待某个资源而无法继续执行时,进程可放弃CPU使用,即释放CPU资源,进入“等待”状态;当程序指令执行完毕,由操作系统回收进程所占用的资源时,进程进入了“死亡”状态。 + +这些进程状态的转换时机需要操作系统管理起来,而且进程的创建和清除等服务必须由操作系统提供,而且在“运行”与“就绪”/“等待”状态之间的转换,涉及到保存和恢复进程的“执行现场”,也就是进程上下文,这是确保进程即使“断断续续”地执行,也能正确完成工作的必要保证。 + +#### 3. 进程与线程 + +一个进程拥有一个存放程序和数据的的虚拟地址空间以及其他资源。一个进程基于程序的指令流执行,其执行过程可能与其它进程的执行过程交替进行。因此,一个具有执行状态(运行态、就绪态等)的进程是一个被操作系统分配资源(比如分配内存)并调度(比如分时使用CPU)的单位。在大多数操作系统中,这两个特点是进程的主要本质特征。但这两个特征相对独立,操作系统可以把这两个特征分别进行管理。 + +这样可以把拥有资源所有权的单位通常仍称作进程,对资源的管理成为进程管理;把指令执行流的单位称为线程,对线程的管理就是线程调度和线程分派。对属于同一进程的所有线程而言,这些线程共享进程的虚拟地址空间和其他资源,但每个线程都有一个独立的栈,还有独立的线程运行上下文,用于包含表示线程执行现场的寄存器值等信息。 + +在多线程环境中,进程被定义成资源分配与保护的单位,与进程相关联的信息主要有存放进程映像的虚拟地址空间等。在一个进程中,可能有一个或多个线程,每个线程有线程执行状态(运行、就绪、等待等),保存上次运行时的线程上下文、线程的执行栈等。考虑到CPU有不同的特权模式,参照进程的分类,线程又可进一步细化为用户线程和内核线程。 + +到目前为止,我们就可以明确用户进程、内核进程(可把ucore看成一个内核进程)、用户线程、内核线程的区别了。从本质上看,线程就是一个特殊的不用拥有资源的轻量级进程,在ucore的调度和执行管理中,并没有区分线程和进程。且由于ucore内核中的所有内核线程共享一个内核地址空间和其他资源,所以这些内核线程从属于同一个唯一的内核进程,即ucore内核本身。理解了进程或线程的上述属性和特征,就可以进行进程/线程管理的设计与实现了。但是为了叙述上的简便,以下用户态的进程/线程统称为用户进程。 diff --git a/lab5.md b/lab5.md new file mode 100644 index 0000000..fdaab9e --- /dev/null +++ b/lab5.md @@ -0,0 +1,563 @@ +# 实验五:用户进程管理 + +## 1. 实验目的 + +* 了解第一个用户进程创建过程 + +* 了解系统调用框架的实现机制 + +* 了解ucore如何实现系统调用sys\_fork/sys\_exec/sys\_exit/sys\_wait来进行进程管理 + +## 2. 实验内容 + +实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys\_fork/sys\_exec/sys\_exit/sys\_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。相关原理介绍可看附录B。 + +### 2.1 练习 + +#### 练习0:填写已有实验 + +本实验依赖实验1/2/3/4。请把你做的实验1/2/3/4的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”的注释相应部分。注意:为了能够正确执行lab5的测试应用程序,可能需对已完成的实验1/2/3/4的代码进行进一步改进。 + +#### 练习1: 加载应用程序并执行(需要编码) + +**do\_execv**函数调用load\_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc\_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。 + +#### 练习2: 父进程复制自己的内存空间给子进程(需要编码) + +创建子进程的函数do\_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy\_range函数(位于kern/mm/pmm.c中)实现的,请补充copy\_range的实现,确保能够正确执行。 + +#### 练习3: 阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现(不需要编码) + +执行:make grade +。如果所显示的应用程序检测都输出ok,则基本正确。(使用的是qemu-1.0.1) + +#### 扩展练习 Challenge :实现 Copy on Write 机制 + +这个扩展练习涉及到本实验和上一个实验“虚拟内存管理”。Copy-on-write(简称COW)的基本概念是指如果有多个使用者对一个资源A(比如内存块)进行读操作,则每个使用者只需获得一个指向同一个资源A的指针,就可以该资源了。若某使用者需要对这个资源A进行写操作,系统会对该资源进行拷贝操作,从而使得该“写操作”使用者获得一个该资源A的“私有”拷贝—资源B,可对资源B进行写操作。该“写操作”使用者对资源B的改变对于其他的使用者而言是不可见的,因为其他使用者看到的还是资源A。 + +在ucore操作系统中,当一个用户父进程创建自己的子进程时,父进程会把其申请的用户空间设置为只读,子进程可共享父进程占用的用户内存空间中的页面(这就是一个共享的资源)。当其中任何一个进程修改此用户内存空间中的某页面时,ucore会通过page +fault异常获知该操作,并完成拷贝内存页面,使得两个进程都有各自的内存页面。这样一个进程所做的修改不会被另外一个进程可见了。请在ucore中实现这样的COW机制。 + +### 2.2 项目组成 + +├── boot +├── kern +│ ├── debug +│ │ ├── kdebug.c +│ │ └── …… +│ ├── mm +│ │ ├── memlayout.h +│ │ ├── pmm.c +│ │ ├── pmm.h +│ │ ├── ...... +│ │ ├── vmm.c +│ │ └── vmm.h +│ ├── process +│ │ ├── proc.c +│ │ ├── proc.h +│ │ └── ...... +│ ├── schedule +│ │ ├── sched.c +│ │ └── ...... +│ ├── sync +│ │ └── sync.h +│ ├── syscall +│ │ ├── syscall.c +│ │ └── syscall.h +│ └── trap +│ ├── trap.c +│ ├── trapentry.S +│ ├── trap.h +│ └── vectors.S +├── libs +│ ├── elf.h +│ ├── error.h +│ ├── printfmt.c +│ ├── unistd.h +│ └── ...... +├── tools +│ ├── user.ld +│ └── ...... +└── user +├── hello.c +├── libs +│ ├── initcode.S +│ ├── syscall.c +│ ├── syscall.h +│ └── ...... +└── ...... + +相对与实验四,实验五主要增加的文件如上表红色部分所示,主要修改的文件如上表紫色部分所示。主要改动如下: + +◆ kern/debug/ + +kdebug.c:修改:解析用户进程的符号信息表示(可不用理会) + +◆ kern/mm/ (与本次实验有较大关系) + +memlayout.h:修改:增加了用户虚存地址空间的图形表示和宏定义 (需仔细理解)。 + +pmm.[ch]:修改:添加了用于进程退出(do\_exit)的内存资源回收的page\_remove\_pte、unmap\_range、exit\_range函数和用于创建子进程(do\_fork)中拷贝父进程内存空间的copy\_range函数,修改了pgdir\_alloc\_page函数 + +vmm.[ch]:修改:扩展了mm\_struct数据结构,增加了一系列函数 + +* mm\_map/dup\_mmap/exit\_mmap:设定/取消/复制/删除用户进程的合法内存空间 + +* copy\_from\_user/copy\_to\_user:用户内存空间内容与内核内存空间内容的相互拷贝的实现 + +* user\_mem\_check:搜索vma链表,检查是否是一个合法的用户空间范围 + +◆ kern/process/ (与本次实验有较大关系) + +proc.[ch]:修改:扩展了proc\_struct数据结构。增加或修改了一系列函数 + +* setup\_pgdir/put\_pgdir:创建并设置/释放页目录表 + +* copy\_mm:复制用户进程的内存空间和设置相关内存管理(如页表等)信息 + +* do\_exit:释放进程自身所占内存空间和相关内存管理(如页表等)信息所占空间,唤醒父进程,好让父进程收了自己,让调度器切换到其他进程 + +* load\_icode:被do\_execve调用,完成加载放在内存中的执行程序到进程空间,这涉及到对页表等的修改,分配用户栈 + +* do\_execve:先回收自身所占用户空间,然后调用load\_icode,用新的程序覆盖内存空间,形成一个执行新程序的新进程 + +* do\_yield:让调度器执行一次选择新进程的过程 + +* do\_wait:父进程等待子进程,并在得到子进程的退出消息后,彻底回收子进程所占的资源(比如子进程的内核栈和进程控制块) + +* do\_kill:给一个进程设置PF\_EXITING标志(“kill”信息,即要它死掉),这样在trap函数中,将根据此标志,让进程退出 + +* KERNEL\_EXECVE/\_\_KERNEL\_EXECVE/\_\_KERNEL\_EXECVE2:被user\_main调用,执行一用户进程 + +◆ kern/trap/ + +trap.c:修改:在idt\_init函数中,对IDT初始化时,设置好了用于系统调用的中断门(idt[T\_SYSCALL])信息。这主要与syscall的实现相关 + +◆ user/\* + +新增的用户程序和用户库 + +## 3 用户进程管理 + +### 3.1 实验执行流程概述 + +到实验四为止,ucore还一直在核心态“打转”,没有到用户态执行。提供各种操作系统功能的内核线程只能在CPU核心态运行是操作系统自身的要求,操作系统就要呆在核心态,才能管理整个计算机系统。但应用程序员也需要编写各种应用软件,且要在计算机系统上运行。如果把这些应用软件都作为内核线程来执行,那系统的安全性就无法得到保证了。所以,ucore要提供用户态进程的创建和执行机制,给应用程序执行提供一个用户态运行环境。接下来我们就简要分析本实验的执行过程,以及分析用户进程的整个生命周期来阐述用户进程管理的设计与实现。 + +显然,由于进程的执行空间扩展到了用户态空间,且出现了创建子进程执行应用程序等与lab4有较大不同的地方,所以具体实现的不同主要集中在进程管理和内存管理部分。首先,我们从ucore的初始化部分来看,会发现初始化的总控函数kern\_init没有任何变化。但这并不意味着lab4与lab5差别不大。其实kern\_init调用的物理内存初始化,进程管理初始化等都有一定的变化。 + +在内存管理部分,与lab4最大的区别就是增加用户态虚拟内存的管理。为了管理用户态的虚拟内存,需要对页表的内容进行扩展,能够把部分物理内存映射为用户态虚拟内存。如果某进程执行过程中,CPU在用户态下执行(在CS段寄存器最低两位包含有一个2位的优先级域,如果为0,表示CPU运行在特权态;如果为3,表示CPU运行在用户态。),则可以访问本进程页表描述的用户态虚拟内存,但由于权限不够,不能访问内核态虚拟内存。另一方面,不同的进程有各自的页表,所以即使不同进程的用户态虚拟地址相同,但由于页表把虚拟页映射到了不同的物理页帧,所以不同进程的虚拟内存空间是被隔离开的,相互之间无法直接访问。在用户态内存空间和内核态内核空间之间需要拷贝数据,让CPU处在内核态才能完成对用户空间的读或写,为此需要设计专门的拷贝函数(copy\_from\_user和copy\_to\_user)完成。但反之则会导致违反CPU的权限管理,导致内存访问异常。 + +在进程管理方面,主要涉及到的是进程控制块中与内存管理相关的部分,包括建立进程的页表和维护进程可访问空间(可能还没有建立虚实映射关系)的信息;加载一个ELF格式的程序到进程控制块管理的内存中的方法;在进程复制(fork)过程中,把父进程的内存空间拷贝到子进程内存空间的技术。另外一部分与用户态进程生命周期管理相关,包括让进程放弃CPU而睡眠等待某事件;让父进程等待子进程结束;一个进程杀死另一个进程;给进程发消息;建立进程的血缘关系链表。 + +当实现了上述内存管理和进程管理的需求后,接下来ucore的用户进程管理工作就比较简单了。首先,“硬”构造出第一个进程(lab4中已有描述),它是后续所有进程的祖先;然后,在proc\_init函数中,通过alloc把当前ucore的执行环境转变成idle内核线程的执行现场;然后调用kernl\_thread来创建第二个内核线程init\_main,而init\_main内核线程有创建了user\_main内核线程.。到此,内核线程创建完毕,应该开始用户进程的创建过程,这第一步实际上是通过user\_main函数调用kernel\_tread创建子进程,通过kernel\_execve调用来把某一具体程序的执行内容放入内存。具体的放置方式是根据ld在此文件上的地址分配为基本原则,把程序的不同部分放到某进程的用户空间中,从而通过此进程来完成程序描述的任务。一旦执行了这一程序对应的进程,就会从内核态切换到用户态继续执行。以此类推,CPU在用户空间执行的用户进程,其地址空间不会被其他用户的进程影响,但由于系统调用(用户进程直接获得操作系统服务的唯一通道)、外设中断和异常中断的会随时产生,从而间接推动了用户进程实现用户态到到内核态的切换工作。ucore对CPU内核态与用户态的切换过程需要比较仔细地分析(这其实是实验一的扩展练习)。当进程执行结束后,需回收进程占用和没消耗完毕的设备整个过程,且为新的创建进程请求提供服务。在本实验中,当系统中存在多个进程或内核线程时, +ucore采用了一种FIFO的很简单的调度方法来管理每个进程占用CPU的时间和频度等。在ucore运行过程中,由于调度、时间中断、系统调用等原因,使得进程会进行切换、创建、睡眠、等待、发消息等各种不同的操作,周而复始,生生不息。 + +### 3.2 创建用户进程 + +在实验四中,我们已经完成了对内核线程的创建,但与用户进程的创建过程相比,创建内核线程的过程还远远不够。而这两个创建过程的差异本质上就是用户进程和内核线程的差异决定的。 + +#### 1. 应用程序的组成和编译 + +我们首先来看一个应用程序,这里我们假定是hello应用程序,在user/hello.c中实现,代码如下: +``` + #include + #include + + int main(void) { + cprintf("Hello world!!.\n"); + cprintf("I am process %d.\n", getpid()); + cprintf("hello pass.\n"); + return 0; +} +``` +hello应用程序只是输出一些字符串,并通过系统调用sys\_getpid(在getpid函数中调用)输出代表hello应用程序执行的用户进程的进程标识--pid。 + +首先,我们需要了解ucore操作系统如何能够找到hello应用程序。这需要分析ucore和hello是如何编译的。修改Makefile,把第六行注释掉。然后在本实验源码目录下执行make,可得到如下输出: +``` +…… ++ cc user/hello.c + +gcc -Iuser/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Iuser/include/ -Iuser/libs/ -c user/hello.c -o obj/user/hello.o + +ld -m elf_i386 -nostdlib -T tools/user.ld -o obj/__user_hello.out obj/user/libs/initcode.o obj/user/libs/panic.o obj/user/libs/stdio.o obj/user/libs/syscall.o obj/user/libs/ulib.o obj/user/libs/umain.o obj/libs/hash.o obj/libs/printfmt.o obj/libs/rand.o obj/libs/string.o obj/user/hello.o +…… +ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/entry.o obj/kern/init/init.o …… -b binary …… obj/__user_hello.out +…… +``` +从中可以看出,hello应用程序不仅仅是hello.c,还包含了支持hello应用程序的用户态库: + +* user/libs/initcode.S:所有应用程序的起始用户态执行地址“\_start”,调整了EBP和ESP后,调用umain函数。 + +* user/libs/umain.c:实现了umain函数,这是所有应用程序执行的第一个C函数,它将调用应用程序的main函数,并在main函数结束后调用exit函数,而exit函数最终将调用sys\_exit系统调用,让操作系统回收进程资源。 + +* user/libs/ulib.[ch]:实现了最小的C函数库,除了一些与系统调用无关的函数,其他函数是对访问系统调用的包装。 + +* user/libs/syscall.[ch]:用户层发出系统调用的具体实现。 + +* user/libs/stdio.c:实现cprintf函数,通过系统调用sys\_putc来完成字符输出。 + +* user/libs/panic.c:实现\_\_panic/\_\_warn函数,通过系统调用sys\_exit完成用户进程退出。 + +除了这些用户态库函数实现外,还有一些libs/\*.[ch]是操作系统内核和应用程序共用的函数实现。这些用户库函数其实在本质上与UNIX系统中的标准libc没有区别,只是实现得很简单,但hello应用程序的正确执行离不开这些库函数。 + +【注意】libs/\*.[ch]、user/libs/\*.[ch]、user/\*.[ch]的源码中没有任何特权指令。 + +在make的最后一步执行了一个ld命令,把hello应用程序的执行码obj/\_\_user\_hello.out连接在了ucore +kernel的末尾。且ld命令会在kernel中会把\_\_user\_hello.out的位置和大小记录在全局变量\_binary\_obj\_\_\_user\_hello\_out\_start和\_binary\_obj\_\_\_user\_hello\_out\_size中,这样这个hello用户程序就能够和ucore内核一起被 +bootloader +加载到内存里中,并且通过这两个全局变量定位hello用户程序执行码的起始位置和大小。而到了与文件系统相关的实验后,ucore会提供一个简单的文件系统,那时所有的用户程序就都不再用这种方法进行加载了,而可以用大家熟悉的文件方式进行加载了。 + +#### 2. 用户进程的虚拟地址空间 + +在tools/user.ld描述了用户程序的用户虚拟空间的执行入口虚拟地址: +``` +SECTIONS { + /* Load programs at this address: "." means the current address */ + . = 0x800020; +``` +在tools/kernel.ld描述了操作系统的内核虚拟空间的起始入口虚拟地址: +``` +SECTIONS { + /* Load the kernel at this address: "." means the current address */ + . = 0xC0100000; +``` +这样ucore把用户进程的虚拟地址空间分了两块,一块与内核线程一样,是所有用户进程都共享的内核虚拟地址空间,映射到同样的物理内存空间中,这样在物理内存中只需放置一份内核代码,使得用户进程从用户态进入核心态时,内核代码可以统一应对不同的内核程序;另外一块是用户虚拟地址空间,虽然虚拟地址范围一样,但映射到不同且没有交集的物理内存空间中。这样当ucore把用户进程的执行代码(即应用程序的执行代码)和数据(即应用程序的全局变量等)放到用户虚拟地址空间中时,确保了各个进程不会“非法”访问到其他进程的物理内存空间。 + +这样ucore给一个用户进程具体设定的虚拟内存空间(kern/mm/memlayout.h)如下所示: + +![image](lab5.files/image001.png) + +#### 3. 创建并执行用户进程 + +在确定了用户进程的执行代码和数据,以及用户进程的虚拟空间布局后,我们可以来创建用户进程了。在本实验中第一个用户进程是由第二个内核线程initproc通过把hello应用程序执行码覆盖到initproc的用户虚拟内存空间来创建的,相关代码如下所示: +``` + // kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread + static int + kernel_execve(const char *name, unsigned char *binary, size_t size) { + int ret, len = strlen(name); + asm volatile ( + "int %1;" + : "=a" (ret) + : "i" (T_SYSCALL), "0" (SYS_exec), "d" (name), "c" (len), "b" (binary), "D" (size) + : "memory"); + return ret; + } + + #define __KERNEL_EXECVE(name, binary, size) ({ \ + cprintf("kernel_execve: pid = %d, name = \"%s\".\n", \ + current->pid, name); \ + kernel_execve(name, binary, (size_t)(size)); \ + }) + + #define KERNEL_EXECVE(x) ({ \ + extern unsigned char _binary_obj___user_##x##_out_start[], \ + _binary_obj___user_##x##_out_size[]; \ + __KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start, \ + _binary_obj___user_##x##_out_size); \ + }) +…… +// init_main - the second kernel thread used to create kswapd_main & user_main kernel threads +static int +init_main(void *arg) { + #ifdef TEST + KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE); + #else + KERNEL_EXECVE(hello); + #endif + panic("kernel_execve failed.\n"); + return 0; +} +``` +对于上述代码,我们需要从后向前按照函数/宏的实现一个一个来分析。Initproc的执行主体是init\_main函数,这个函数在缺省情况下是执行宏KERNEL\_EXECVE(hello),而这个宏最终是调用kernel\_execve函数来调用SYS\_exec系统调用,由于ld在链接hello应用程序执行码时定义了两全局变量: + +* \_binary\_obj\_\_\_user\_hello\_out\_start:hello执行码的起始位置 +* \_binary\_obj\_\_\_user\_hello\_out\_size中:hello执行码的大小 + +kernel\_execve把这两个变量作为SYS\_exec系统调用的参数,让ucore来创建此用户进程。当ucore收到此系统调用后,将依次调用如下函数 +``` +vector128(vectors.S)--\> +\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)-- +--\>syscall(syscall.c)--\>sys\_exec(syscall.c)--\>do\_execve(proc.c) +``` +最终通过do\_execve函数来完成用户进程的创建工作。此函数的主要工作流程如下: + +* 首先为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。由于此处的initproc是内核线程,所以mm为NULL,整个处理都不会做。 + +* 接下来的一步是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及到读ELF格式的文件,申请内存空间,建立用户态虚存空间,加载应用程序执行码等。load\_icode函数完成了整个复杂的工作。 + +load\_icode函数的主要工作就是给用户进程建立一个能够让用户进程正常运行的用户环境。此函数有一百多行,完成了如下重要工作: + + 1. +调用mm\_create函数来申请进程的内存管理数据结构mm所需内存空间,并对mm进行初始化; + + 2. +调用setup\_pgdir来申请一个页目录表所需的一个页大小的内存空间,并把描述ucore内核虚空间映射的内核页表(boot\_pgdir所指)的内容拷贝到此新目录表中,最后让mm-\>pgdir指向此页目录表,这就是进程新的页目录表了,且能够正确映射内核虚空间; + + 3. +根据应用程序执行码的起始位置来解析此ELF格式的执行程序,并调用mm\_map函数根据ELF格式的执行程序说明的各个段(代码段、数据段、BSS段等)的起始位置和大小建立对应的vma结构,并把vma插入到mm结构中,从而表明了用户进程的合法用户态虚拟地址空间; + + 4. +调用根据执行程序各个段的大小分配物理内存空间,并根据执行程序各个段的起始位置确定虚拟地址,并在页表中建立好物理地址和虚拟地址的映射关系,然后把执行程序各个段的内容拷贝到相应的内核虚拟地址中,至此应用程序执行码和数据已经根据编译时设定地址放置到虚拟内存中了; + + 5. +需要给用户进程设置用户栈,为此调用mm\_mmap函数建立用户栈的vma结构,明确用户栈的位置在用户虚空间的顶端,大小为256个页,即1MB,并分配一定数量的物理内存且建立好栈的虚地址<--\>物理地址映射关系; + + 6. +至此,进程内的内存管理vma和mm数据结构已经建立完成,于是把mm-\>pgdir赋值到cr3寄存器中,即更新了用户进程的虚拟内存空间,此时的initproc已经被hello的代码和数据覆盖,成为了第一个用户进程,但此时这个用户进程的执行现场还没建立好; + + 7. +先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令“iret”后,能够让CPU转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断; + +至此,用户进程的用户环境已经搭建完毕。此时initproc将按产生系统调用的函数调用路径原路返回,执行中断返回指令“iret”(位于trapentry.S的最后一句)后,将切换到用户进程hello的第一条语句位置\_start处(位于user/libs/initcode.S的第三句)开始执行。 + +### 3.3 进程退出和等待进程 + +当进程执行完它的工作后,就需要执行退出操作,释放进程占用的资源。ucore分了两步来完成这个工作,首先由进程本身完成大部分资源的占用内存回收工作,然后由此进程的父进程完成剩余资源占用内存的回收工作。为何不让进程本身完成所有的资源回收工作呢?这是因为进程要执行回收操作,就表明此进程还存在,还在执行指令,这就需要内核栈的空间不能释放,且表示进程存在的进程控制块不能释放。所以需要父进程来帮忙释放子进程无法完成的这两个资源回收工作。 + +为此在用户态的函数库中提供了exit函数,此函数最终访问sys\_exit系统调用接口让操作系统来帮助当前进程执行退出过程中的部分资源回收。我们来看看ucore是如何做进程退出工作的。 + +首先,exit函数会把一个退出码error\_code传递给ucore,ucore通过执行内核函数do\_exit来完成对当前进程的退出处理,主要工作简单地说就是回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作,具体流程如下: + +**1.** 如果current-\>mm != +NULL,表示是用户进程,则开始回收此用户进程所占用的用户态虚拟内存空间; + +a) +首先执行“lcr3(boot\_cr3)”,切换到内核态的页表上,这样当前用户进程目前只能在内核虚拟地址空间执行了,这是为了确保后续释放用户态内存和进程页表的工作能够正常执行; + +b) +如果当前进程控制块的成员变量mm的成员变量mm\_count减1后为0(表明这个mm没有再被其他进程共享,可以彻底释放进程所占的用户虚拟空间了。),则开始回收用户进程所占的内存资源: + +i. +调用exit\_mmap函数释放current-\>mm-\>vma链表中每个vma描述的进程合法空间中实际分配的内存,然后把对应的页表项内容清空,最后还把页表所占用的空间释放并把对应的页目录表项清空; + +ii. 调用put\_pgdir函数释放当前进程的页目录所占的内存; + +iii. 调用mm\_destroy函数释放mm中的vma所占内存,最后释放mm所占内存; + +c) +此时设置current-\>mm为NULL,表示与当前进程相关的用户虚拟内存空间和对应的内存管理成员变量所占的内核虚拟内存空间已经回收完毕; + +**2.** +这时,设置当前进程的执行状态current-\>state=PROC\_ZOMBIE,当前进程的退出码current-\>exit\_code=error\_code。此时当前进程已经不能被调度了,需要此进程的父进程来做最后的回收工作(即回收描述此进程的内核栈和进程控制块); + +**3.** 如果当前进程的父进程current-\>parent处于等待子进程状态: + +current-\>parent-\>wait\_state==WT\_CHILD, + +则唤醒父进程(即执行“wakup\_proc(current-\>parent)”),让父进程帮助自己完成最后的资源回收; + +**4.** +如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程initproc,且各个子进程指针需要插入到initproc的子进程链表中。如果某个子进程的执行状态是PROC\_ZOMBIE,则需要唤醒initproc来完成对此子进程的最后回收工作。 + +**5.** 执行schedule()函数,选择新的进程执行。 + +那么父进程如何完成对子进程的最后回收工作呢?这要求父进程要执行wait用户函数或wait\_pid用户函数,这两个函数的区别是,wait函数等待任意子进程的结束通知,而wait\_pid函数等待进程id号为pid的子进程结束通知。这两个函数最终访问sys\_wait系统调用接口让ucore来完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间,具体流程如下: + +**1.** +如果pid!=0,表示只找一个进程id号为pid的退出状态的子进程,否则找任意一个处于退出状态的子进程; + +**2.** +如果此子进程的执行状态不为PROC\_ZOMBIE,表明此子进程还没有退出,则当前进程只好设置自己的执行状态为PROC\_SLEEPING,睡眠原因为WT\_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤1处执行; + +**3.** +如果此子进程的执行状态为PROC\_ZOMBIE,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc\_list和hash\_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,消除了它所占用的所有资源。 + +### 3.4 系统调用实现 + +系统调用的英文名字是System +Call。操作系统为什么需要实现系统调用呢?其实这是实现了用户进程后,自然引申出来需要实现的操作系统功能。用户进程只能在操作系统给它圈定好的“用户环境”中执行,但“用户环境”限制了用户进程能够执行的指令,即用户进程只能执行一般的指令,无法执行特权指令。如果用户进程想执行一些需要特权指令的任务,比如通过网卡发网络包等,只能让操作系统来代劳了。于是就需要一种机制来确保用户进程不能执行特权指令,但能够请操作系统“帮忙”完成需要特权指令的任务,这种机制就是系统调用。 + +采用系统调用机制为用户进程提供一个获得操作系统服务的统一接口层,这样一来可简化用户进程的实现,把一些共性的、繁琐的、与硬件相关、与特权指令相关的任务放到操作系统层来实现,但提供一个简洁的接口给用户进程调用;二来这层接口事先可规定好,且严格检查用户进程传递进来的参数和操作系统要返回的数据,使得让操作系统给用户进程服务的同时,保护操作系统不会被用户进程破坏。 + +从硬件层面上看,需要硬件能够支持在用户态的用户进程通过某种机制切换到内核态。试验一讲述中断硬件支持和软件处理过程其实就可以用来完成系统调用所需的软硬件支持。下面我们来看看如何在ucore中实现系统调用。 + +#### 1. 初始化系统调用对应的中断描述符 + +在ucore初始化函数kern\_init中调用了idt\_init函数来初始化中断描述符表,并设置一个特定中断号的中断门,专门用于用户进程访问系统调用。此事由ide\_init函数完成: +``` +void +idt_init(void) { + extern uintptr_t __vectors[]; + int i; + for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) { + SETGATE(idt[i], 1, GD_KTEXT, __vectors[i], DPL_KERNEL); + } + SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); + lidt(&idt_pd); +} +``` +在上述代码中,可以看到在执行加载中断描述符表lidt指令前,专门设置了一个特殊的中断描述符idt[T\_SYSCALL],它的特权级设置为DPL\_USER,中断向量处理地址在\_\_vectors[T\_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行 +“INT +T\_SYSCALL”后,由于此中断允许用户态进程产生(注意它的特权级设置为DPL\_USER),所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到\_\_vectors[T\_SYSCALL]处开始执行,形成如下执行路径: +``` +vector128(vectors.S)--\> +\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)----\>syscall(syscall.c)- +``` +在syscall中,根据系统调用号来完成不同的系统调用服务。 + +#### 2. 建立系统调用的用户库准备 + +在操作系统中初始化好系统调用相关的中断描述符、中断处理起始地址等后,还需在用户态的应用程序中初始化好相关工作,简化应用程序访问系统调用的复杂性。为此在用户态建立了一个中间层,即简化的libc实现,在user/libs/ulib.[ch]和user/libs/syscall.[ch]中完成了对访问系统调用的封装。用户态最终的访问系统调用函数是syscall,实现如下: +``` +static inline int +syscall(int num, ...) { + va_list ap; + va_start(ap, num); + uint32_t a[MAX_ARGS]; + int i, ret; + for (i = 0; i < MAX_ARGS; i ++) { + a[i] = va_arg(ap, uint32_t); + } + va_end(ap); + + asm volatile ( + "int %1;" + : "=a" (ret) + : "i" (T_SYSCALL), + "a" (num), + "d" (a[0]), + "c" (a[1]), + "b" (a[2]), + "D" (a[3]), + "S" (a[4]) + : "cc", "memory"); + return ret; +} +``` +从中可以看出,应用程序调用的exit/fork/wait/getpid等库函数最终都会调用syscall函数,只是调用的参数不同而已,如果看最终的汇编代码会更清楚: +``` +…… + 34: 8b 55 d4 mov -0x2c(%ebp),%edx + 37: 8b 4d d8 mov -0x28(%ebp),%ecx + 3a: 8b 5d dc mov -0x24(%ebp),%ebx + 3d: 8b 7d e0 mov -0x20(%ebp),%edi + 40: 8b 75 e4 mov -0x1c(%ebp),%esi + 43: 8b 45 08 mov 0x8(%ebp),%eax + 46: cd 80 int $0x80 +48: 89 45 f0 mov %eax,-0x10(%ebp) +…… +``` + +可以看到其实是把系统调用号放到EAX,其他5个参数a[0]\~a[4]分别保存到EDX/ECX/EBX/EDI/ESI五个寄存器中,及最多用6个寄存器来传递系统调用的参数,且系统调用的返回结果是EAX。比如对于getpid库函数而言,系统调用号(SYS\_getpid=18)是保存在EAX中,返回值(调用此库函数的的当前进程号pid)也在EAX中。 + +#### 3. 与用户进程相关的系统调用 + +在本实验中,与进程相关的各个系统调用属性如下所示: + + + + + + + + + + +
系统调用名含义具体完成服务的函数
SYS_exitprocess exitdo_exit
SYS_forkcreate child process, dup mm do_fork-->wakeup_proc
SYS_waitwait child processdo_wait
SYS_execafter fork, process execute a new programload a program and refresh the mm
SYS_yieldprocess flag itself need resechedulingproc->need_sched=1, then scheduler will rescheule this process
SYS_killkill processdo_kill-->proc->flags |= PF_EXITING, -->wakeup_proc-->do_wait-->do_exit
SYS_getpidget the process's pid
+ +通过这些系统调用,可方便地完成从进程/线程创建到退出的整个运行过程。 + +#### 4. 系统调用的执行过程 + +与用户态的函数库调用执行过程相比,系统调用执行过程的有四点主要的不同: + +* 不是通过“CALL”指令而是通过“INT”指令发起调用; + +* 不是通过“RET”指令,而是通过“IRET”指令完成调用返回; + +* 当到达内核态后,操作系统需要严格检查系统调用传递的参数,确保不破坏整个系统的安全性; + +* 执行系统调用可导致进程等待某事件发生,从而可引起进程切换; + +下面我们以getpid系统调用的执行过程大致看看操作系统是如何完成整个执行过程的。当用户进程调用getpid函数,最终执行到“INT +T\_SYSCALL”指令后,CPU根据操作系统建立的系统调用中断描述符,转入内核态,并跳转到vector128处(kern/trap/vectors.S),开始了操作系统的系统调用执行过程,函数调用和返回操作的关系如下所示: +``` +vector128(vectors.S)--\> +\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)-- +--\>syscall(syscall.c)--\>sys\_getpid(syscall.c)--\>……--\>\_\_trapret(trapentry.S) +``` +在执行trap函数前,软件还需进一步保存执行系统调用前的执行现场,即把与用户进程继续执行所需的相关寄存器等当前内容保存到当前进程的中断帧trapframe中(注意,在创建进程是,把进程的trapframe放在给进程的内核栈分配的空间的顶部)。软件做的工作在vector128和\_\_alltraps的起始部分: +``` +vectors.S::vector128起始处: + pushl $0 + pushl $128 +...... +trapentry.S::__alltraps起始处: +pushl %ds + pushl %es + pushal +…… +``` +自此,用于保存用户态的用户进程执行现场的trapframe的内容填写完毕,操作系统可开始完成具体的系统调用服务。在sys\_getpid函数中,简单地把当前进程的pid成员变量做为函数返回值就是一个具体的系统调用服务。完成服务后,操作系统按调用关系的路径原路返回到\_\_alltraps中。然后操作系统开始根据当前进程的中断帧内容做恢复执行现场操作。其实就是把trapframe的一部分内容保存到寄存器内容。恢复寄存器内容结束后,调整内核堆栈指针到中断帧的tf\_eip处,这是内核栈的结构如下: +``` +/* below here defined by x86 hardware */ + uintptr_t tf_eip; + uint16_t tf_cs; + uint16_t tf_padding3; + uint32_t tf_eflags; +/* below here only when crossing rings */ + uintptr_t tf_esp; + uint16_t tf_ss; + uint16_t tf_padding4; +``` +这时执行“IRET”指令后,CPU根据内核栈的情况回复到用户态,并把EIP指向tf\_eip的值,即“INT +T\_SYSCALL”后的那条指令。这样整个系统调用就执行完毕了。 + +至此,实验五中的主要工作描述完毕。 + +## 4. 实验报告要求 + +从网站上下载lab5.zip后,解压得到本文档和代码目录 +lab5,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab5-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB5”的注释,代码中所有需要完成的地方(challenge除外)都有“LAB5”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。 + +## 附录 A:【原理】用户进程的特征 + +### 从内核线程到用户进程 + +在实验四中设计实现了进程控制块,并实现了内核线程的创建和简单的调度执行。但实验四中没有在用户态执行用户进程的管理机制,既无法体现用户进程的地址空间,以及用户进程间地址空间隔离的保护机制,不支持进程执行过程的用户态和核心态之间的切换,且没有用户进程的完整状态变化的生命周期。其实没有实现的原因是内核线程不需要这些功能。那内核线程相对于用户态线程有何特点呢? + +但其实我们已经在实验四中看到了内核线程,内核线程的管理实现相对是简单的,其特点是直接使用操作系统(比如ucore)在初始化中建立的内核虚拟内存地址空间,不同的内核线程之间可以通过调度器实现线程间的切换,达到分时使用CPU的目的。由于内核虚拟内存空间是一一映射计算机系统的物理空间的,这使得可用空间的大小不会超过物理空间大小,所以操作系统程序员编写内核线程时,需要考虑到有限的地址空间,需要保证各个内核线程在执行过程中不会破坏操作系统的正常运行。这样在实现内核线程管理时,不必考虑涉及与进程相关的虚拟内存管理中的缺页处理、按需分页、写时复制、页换入换出等功能。如果在内核线程执行过程中出现了访存错误异常或内存不够的情况,就认为操作系统出现错误了,操作系统将直接宕机。在ucore中,就是调用panic函数,进入内核调试监控器kernel\_debug\_monitor。 + +内核线程管理思想相对简单,但编写内核线程对程序员的要求很高。从理论上讲(理想情况),如果程序员都是能够编写操作系统级别的“高手”,能够勤俭和高效地使用计算机系统中的资源,且这些“高手”都为他人着想,具有奉献精神,在别的应用需要计算机资源的时候,能够从大局出发,从整个系统的执行效率出发,让出自己占用的资源,那这些“高手”编写出来的程序直接作为内核线程运行即可,也就没有用户进程存在的必要了。 + +但现实与理论的差距是巨大的,能编写操作系统的程序员是极少数的,与当前的应用程序员相比,估计大约差了3\~4个数量级。如果还要求编写操作系统的程序员考虑其他未知程序员的未知需求,那这样的程序员估计可以成为是编程界的“上帝”了。 + +从应用程序编写和运行的角度看,既然程序员都不是“上帝”,操作系统程序员就需要给应用程序员编写的程序提供一个既“宽松”又“严格”的执行环境,让对内存大小和CPU使用时间等资源的限制没有仔细考虑的应用程序都能在操作系统中正常运行,且即使程序太可靠,也只能破坏自己,而不能破坏其他运行程序和整个系统。“严格”就是安全性保证,即应用程序执行不会破坏在内存中存在的其他应用程序和操作系统的内存空间等独占的资源;“宽松”就算是方便性支持,即提供给应用程序尽量丰富的服务功能和一个远大于物理内存空间的虚拟地址空间,使得应用程序在执行过程中不必考虑很多繁琐的细节(比如如何初始化PCI总线和外设等,如果管理物理内存等)。 + +### 让用户进程正常运行的用户环境 + +在操作系统原理的介绍中,一般提到进程的概念其实主要是指用户进程。从操作系统的设计和实现的角度看,其实用户进程是指一个应用程序在操作系统提供的一个用户环境中的一次执行过程。这里的重点是用户环境。用户环境有啥功能?用户环境指的是什么? + +从功能上看,操作系统提供的这个用户环境有两方面的特点。一方面与存储空间相关,即限制用户进程可以访问的物理地址空间,且让各个用户进程之间的物理内存空间访问不重叠,这样可以保证不同用户进程之间不能相互破坏各自的内存空间,利用虚拟内存的功能(页换入换出)。给用户进程提供了远大于实际物理内存空间的虚拟内存空间。 + +另一方面与执行指令相关,即限制用户进程可执行的指令,不能让用户进程执行特权指令(比如修改页表起始地址),从而保证用户进程无法破坏系统。但如果不能执行特权指令,则很多功能(比如访问磁盘等)无法实现,所以需要提供某种机制,让操作系统完成需要特权指令才能做的各种服务功能,给用户进程一个“服务窗口”,用户进程可以通过这个“窗口”向操作系统提出服务请求,由操作系统来帮助用户进程完成需要特权指令才能做的各种服务。另外,还要有一个“中断窗口”,让用户进程不主动放弃使用CPU时,操作系统能够通过这个“中断窗口”强制让用户进程放弃使用CPU,从而让其他用户进程有机会执行。 + +基于功能分析,我们就可以把这个用户环境定义为如下组成部分: + +* 建立用户虚拟空间的页表和支持页换入换出机制的用户内存访存错误异常服务例程:提供地址隔离和超过物理空间大小的虚存空间。 + +* 应用程序执行的用户态CPU特权级:在用户态CPU特权级,应用程序只能执行一般指令,如果特权指令,结果不是无效就是产生“执行非法指令”异常; + +* 系统调用机制:给用户进程提供“服务窗口”; + +* 中断响应机制:给用户进程设置“中断窗口”,这样产生中断后,当前执行的用户进程将被强制打断,CPU控制权将被操作系统的中断服务例程使用。 + +### 用户态进程的执行过程分析 + +在这个环境下运行的进程就是用户进程。那如果用户进程由于某种原因下面进入内核态后,那在内核态执行的是什么呢?还是用户进程吗?首先分析一下用户进程这样会进入内核态呢?回顾一下lab1,就可以知道当产生外设中断、CPU执行异常(比如访存错误)、陷入(系统调用),用户进程就会切换到内核中的操作系统中来。表面上看,到内核态后,操作系统取得了CPU控制权,所以现在执行的应该是操作系统代码,由于此时CPU处于核心态特权级,所以操作系统的执行过程就就应该是内核进程了。这样理解忽略了操作系统的具体实现。如果考虑操作系统的具体实现,应该如果来理解进程呢? + +从进程控制块的角度看,如果执行了进程执行现场(上下文)的切换,就认为到另外一个进程执行了,及进程的分界点设定在执行进程切换的前后。到底切换了什么呢?其实只是切换了进程的页表和相关硬件寄存器,这些信息都保存在进程控制块中的相关域中。所以,我们可以把执行应用程序的代码一直到执行操作系统中的进程切换处为止都认为是一个应用程序的执行过程(其中有操作系统的部分代码执行过过程)即进程。因为在这个过程中,没有更换到另外一个进程控制块的进程的页表和相关硬件寄存器。 + +从指令执行的角度看,如果再仔细分析一下操作系统这个软件的特点并细化一下进入内核原因,就可以看出进一步进行划分。操作系统的主要功能是给上层应用提供服务,管理整个计算机系统中的资源。所以操作系统虽然是一个软件,但其实是一个基于事件的软件,这里操作系统需要响应的事件包括三类:外设中断、CPU执行异常(比如访存错误)、陷入(系统调用)。如果用户进程通过系统调用要求操作系统提供服务,那么用户进程的角度看,操作系统就是一个特殊的软件库(比如相对于用户态的libc库,操作系统可看作是内核态的libc库),完成用户进程的需求,从执行逻辑上看,是用户进程“主观”执行的一部分,即用户进程“知道”操作系统要做的事情。那么在这种情况下,进程的代码空间包括用户态的执行程序和内核态响应用户进程通过系统调用而在核心特权态执行服务请求的操作系统代码,为此这种情况下的进程的内存虚拟空间也包括两部分:用户态的虚地址空间和核心态的虚地址空间。但如果此时发生的事件是外设中断和CPU执行异常,虽然CPU控制权也转入到操作系统中的中断服务例程,但这些内核执行代码执行过程是用户进程“不知道”的,是另外一段执行逻辑。那么在这种情况下,实际上是执行了两段目标不同的执行程序,一个是代表应用程序的用户进程,一个是代表中断服务例程处理外设中断和CPU执行异常的内核线程。这个用户进程和内核线程在产生中断或异常的时候,CPU硬件就完成了它们之间的指令流切换。 + +### 用户进程的运行状态分析 + +用户进程在其执行过程中会存在很多种不同的执行状态,根据操作系统原理,一个用户进程一般的运行状态有五种:创建(new)态、就绪(ready)态、运行(running)态、等待(blocked)态、退出(exit)态。各个状态之间会由于发生了某事件而进行状态转换。 + +但在用户进程的执行过程中,具体在哪个时间段处于上述状态的呢?上述状态是如何转变的呢?首先,我们看创建(new)态,操作系统完成进程的创建工作,而体现进程存在的就是进程控制块,所以一旦操作系统创建了进程控制块,则可以认为此时进程就已经存在了,但由于进程能够运行的各种资源还没准备好,所以此时的进程处于创建(new)态。创建了进程控制块后,进程并不能就执行了,还需准备好各种资源,如果把进程执行所需要的虚拟内存空间,执行代码,要处理的数据等都准备好了,则此时进程已经可以执行了,但还没有被操作系统调度,需要等待操作系统选择这个进程执行,于是把这个做好“执行准备”的进程放入到一个队列中,并可以认为此时进程处于就绪(ready)态。当操作系统的调度器从就绪进程队列中选择了一个就绪进程后,通过执行进程切换,就让这个被选上的就绪进程执行了,此时进程就处于运行(running)态了。到了运行态后,会出现三种事件。如果进程需要等待某个事件(比如主动睡眠10秒钟,或进程访问某个内存空间,但此内存空间被换出到硬盘swap分区中了,进程不得不等待操作系统把缓慢的硬盘上的数据重新读回到内存中),那么操作系统会把CPU给其他进程执行,并把进程状态从运行(running)态转换为等待(blocked)态。如果用户进程的应用程序逻辑流程执行结束了,那么操作系统会把CPU给其他进程执行,并把进程状态从运行(running)态转换为退出(exit)态,并准备回收用户进程占用的各种资源,当把表示整个进程存在的进程控制块也回收了,这进程就不存在了。在这整个回收过程中,进程都处于退出(exit)态。2考虑到在内存中存在多个处于就绪态的用户进程,但只有一个CPU,所以为了公平起见,每个就绪态进程都只有有限的时间片段,当一个运行态的进程用完了它的时间片段后,操作系统会剥夺此进程的CPU使用权,并把此进程状态从运行(running)态转换为就绪(ready)态,最后把CPU给其他进程执行。如果某个处于等待(blocked)态的进程所等待的事件产生了(比如睡眠时间到,或需要访问的数据已经从硬盘换入到内存中),则操作系统会通过把等待此事件的进程状态从等待(blocked)态转到就绪(ready)态。这样进程的整个状态转换形成了一个有限状态自动机。 diff --git a/lab6.md b/lab6.md new file mode 100644 index 0000000..cebeabf --- /dev/null +++ b/lab6.md @@ -0,0 +1,557 @@ +# 实验六: 调度器 + +## 1 实验目的 + +* 理解操作系统的调度管理机制 + +* 熟悉 ucore 的系统调度器框架,以及缺省的Round-Robin 调度算法 + +* 基于调度器框架实现一个(Stride Scheduling)调度算法来替换缺省的调度算法 + +## 2 实验内容 + +实验五完成了用户进程的管理,可在用户态运行多个进程。但到目前为止,采用的调度策略是很简单的FIFO调度策略。本次实验,主要是熟悉ucore的系统调度器框架,以及基于此框架的 +Round-Robin(RR) 调度算法。然后参考RR调度算法的实现,完成Stride +Scheduling调度算法。 + +### 2.1 练习 + +#### 练习0:填写已有实验 + +本实验依赖实验1/2/3/4/5。请把你做的实验2/3/4/5的代码填入本实验中代码中有“LAB1”/ +“LAB2”/“LAB3”/“LAB4”/“LAB5”的注释相应部分。并确保编译通过。注意:为了能够正确执行lab6的测试应用程序,可能需对已完成的实验1/2/3/4/5的代码进行进一步改进。 + +#### 练习1: 使用 Round Robin 调度算法(不需要编码) + +完成练习0后,建议大家比较一下(可用kdiff3等文件比较软件)个人完成的lab5和练习0完成后的刚修改的lab6之间的区别,分析了解lab6采用RR调度算法后的执行过程。执行make +grade,大部分测试用例应该通过。但执行priority.c应该过不去。 + +#### 练习2: 实现 Stride Scheduling 调度算法(需要编码) + +首先需要换掉RR调度器的实现,即用default\_sched\_stride\_c覆盖default\_sched.c。然后根据此文件和后续文档对Stride度器的相关描述,完成Stride调度算法的实现。 + +后面的实验文档部分给出了Stride调度算法的大体描述。这里给出Stride调度算法的一些相关的资料(目前网上中文的资料比较欠缺)。 + +* [http://wwwagss.informatik.uni-kl.de/Projekte/Squirrel/stride/node3.html](http://wwwagss.informatik.uni-kl.de/Projekte/Squirrel/stride/node3.html) +* [http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.138.3502&rank=1](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.138.3502&rank=1) +* 也可GOOGLE “Stride Scheduling” 来查找相关资料 + +执行:make grade +。如果所显示的应用程序检测都输出ok,则基本正确。如果只是priority.c过不去,可执行 +make run-priority +命令来单独调试它。大致执行结果可看附录。( 使用的是 qemu-1.0.1 )。 + +#### 扩展练习 Challenge :实现 Linux 的 CFS 调度算法 + +在ucore的调度器框架下实现下Linux的CFS调度算法。可阅读相关Linux内核书籍或查询网上资料,可了解CFS的细节,然后大致实现在ucore中。 + +### 2.2 项目组成 + +├── boot +├── kern +│ ├── debug +│ ├── driver +│ ├── fs +│ ├── init +│ ├── libs +│ ├── mm +│ ├── process +│ │ ├── ..... +│ │ ├── proc.c +│ │ ├── proc.h +│ │ └── switch.S +│ ├── schedule +│ │ ├── default\_sched.c +│ │ ├── default\_sched.h +│ │ ├── default\_sched\_stride\_c +│ │ ├── sched.c +│ │ └── sched.h +│ ├── syscall +│ │ ├── syscall.c +│ │ └── syscall.h +… + +相对与实验五,实验六主要增加的文件如上表红色部分所示,主要修改的文件如上表紫色部分所示。主要改动如下: +简单说明如下: +* libs/skew\_heap.h: +提供了基本的优先队列数据结构,为本次实验提供了抽象数据结构方面的支持。 +* kern/process/proc.[ch]:proc.h中扩展了proc\_struct的成员变量,用于RR和stride调度算法。proc.c中实现了lab6\_set\_priority,用于设置进程的优先级。 +* kern/schedule/{sched.h,sched.c}: 定义了 ucore +的调度器框架,其中包括相关的数 +据结构(包括调度器的接口和运行队列的结构),和具体的运行时机制。 +* kern/schedule/{default\_sched.h,default\_sched.c}: 具体的 round-robin +算法,在本次实验中你需要了解其实现。 +* kern/schedule/default\_sched\_stride\_c: Stride +Scheduling调度器的基本框架,在此次 +实验中你需要填充其中的空白部分以实现一个完整的 Stride 调度器。 +* kern/syscall/syscall.[ch]: +增加了sys\_gettime系统调用,便于用户进程获取当前时钟值;增加了sys\_lab6\_set\_priority系统调用,便于用户进程设置进程优先级(给priority.c用) +* user/{matrix.c,priority.c,. . . }: +相关的一些测试用户程序,测试调度算法的正确性,user +目录下包含但不限于这些程序。在完成实验过程中,建议阅读这些测试程序,以了解这些程序的行为,便于进行调试。 + +## 3 调度框架和调度算法设计与实现 + +### 3.1 实验执行流程概述 + +在实验五,创建了用户进程,并让它们正确运行。这中间也实现了FIFO调度策略。可通过阅读实验五下的 +kern/schedule/sched.c 的 schedule +函数的实现来了解其FIFO调度策略。与实验五相比,实验六专门需要针对处理器调度框架和各种算法进行设计与实现,为此对ucore的调度部分进行了适当的修改,使得kern/schedule/sched.c +只实现调度器框架,而不再涉及具体的调度算法实现。而调度算法在单独的文件(default\_sched.[ch])中实现。 + +除此之外,实验中还涉及了idle进程的概念。当cpu没有进程可以执行的时候,系统应该如何工作?在实验五的scheduler实现中,ucore内核不断的遍历进程池,直到找到第一个runnable状态的 +process,调用并执行它。也就是说,当系统没有进程可以执行的时候,它会把所有 +cpu 时间用在搜索进程池,以实现 +idle的目的。但是这样的设计不被大多数操作系统所采用,原因在于它将进程调度和 +idle 进程两种不同的概念混在了一起,而且,当调度器比较复杂时,schedule +函数本身也会比较复杂,这样的设计结构很不清晰而且难免会出现错误。所以在此次实验中,ucore +建立了一个单独的进程(kern/process/proc.c 中的 idleproc)作为 cpu 空闲时的 +idle 进程,这个程序是通常一个死循环。你需要了解这个程序的实现。 + +接下来可看看实验六的大致执行过程,在init.c中的kern\_init函数增加了对sched\_init函数的调用。sched\_init函数主要完成了对实现特定调度算法的调度类(sched\_class)的绑定,使得ucore在后续的执行中,能够通过调度框架找到实现特定调度算法的调度类并完成进程调度相关工作。为了更好地理解实验六整个运行过程,这里需要关注的重点问题包括: + + 1. 何时或何事件发生后需要调度? + 2. 何时或何事件发生后需要调整实现调度算法所涉及的参数? + 3. 如果基于调度框架设计具体的调度算法? + 4. 如果灵活应用链表等数据结构管理进程调度? + +大家可带着这些问题进一步阅读后续的内容。 + +### 3.2 计时器的原理和实现 + +在传统的操作系统中,计时器是其中一个基础而重要的功能.它提供了基于时间事件的调度机制。在 +ucore 中,timer 中断(irq0) 给操作系统提供了有一定间隔的时间事件, +操作系统将其作为基本的调度和计时单位(我们记两次时间中断之间的时间间隔为一个 +时间片,timer splice)。 + +基于此时间单位,操作系统得以向上提供基于时间点的事件,并实现基于时间长度的等待和唤醒机制。在每个时钟中断发生时,操作系统产生对应的时间事件。应用程序或者操作系统的其他组件可以以此来构建更复杂和高级的调度。 + +* sched.h, sched.c 定义了有关timer的各种相关接口来使用 timer +服务,其中主要包括: +* typedef struct {……} timer\_t: 定义了 timer\_t 的基本结构,其可以用 +sched.h 中的timer\_init函数对其进行初始化。 +* void timer\_init(timer t \*timer, struct proc\_struct \*proc, int +expires): 对某计时器 进行初始化,让它在 expires 时间片之后唤醒 proc +进程。 +* void add\_timer(timer t \*timer): 向系统添加某个初始化过的 +timer\_t,该计时器在 指定时间后被激活,并将对应的进程唤醒至 +runnable(如果当前进程处在等待状态)。 +* void del\_timer(timer\_t \*time): +向系统删除(或者说取消)某一个计时器。该计时 +器在取消后不会被系统激活并唤醒进程。 +* void run\_timer\_list(void): +更新当前系统时间点,遍历当前所有处在系统管理内的 +计时器,找出所有应该激活的计数器,并激活它们。该过程在且只在每次计时器中断时被调用。在 +ucore 中,其还会调用调度器事件处理程序。 + +一个 timer\_t 在系统中的存活周期可以被描述如下: + +1. timer\_t 在某个位置被创建和初始化,并通过 +add\_timer加入系统管理列表中 +2. 系统时间被不断累加,直到 run\_timer\_list 发现该 timer\_t到期。 +3. run\_timer\_list更改对应的进程状态,并从系统管理列表中移除该timer\_t。 + +尽管本次实验并不需要填充计时器相关的代码,但是作为系统重要的组件(同时计时器也是调度器的一个部分),你应该了解其相关机制和在ucore中的实现方法。接下来的实验描述将会在一定程度上忽略计时器对调度带来的影响,即不考虑基于固定时间点的调度。 + +### 3.3 进程状态 + +在此次实验中,进程的状态之间的转换需要有一个更为清晰的表述,在 ucore +中,runnable的进程会被放在运行队列中。值得注意的是,在具体实现中,ucore定义的进程控制块struct +proc\_struct包含了成员变量state,用于描述进程的运行状态,而running和runnable共享同一个状态(state)值(PROC\_RUNNABLE)。不同之处在于处于running态的进程不会放在运行队列中。进程的正常生命周期如下: + +* 进程首先在 cpu 初始化或者 sys\_fork +的时候被创建,当为该进程分配了一个进程描 述符之后,该进程进入 uninit态(在 +proc.c 中 alloc\_proc)。 +* 当进程完全完成初始化之后,该进程转为runnable态。 +* 当到达调度点时,由调度器 sched\_class +根据运行队列rq的内容来判断一个进程是否应该被运行,即把处于runnable态的进程转换成 +running状态,从而占用CPU执行。 +* running态的进程通过wait等系统调用被阻塞,进入sleeping态。 +* sleeping态的进程被wakeup变成runnable态的进程。 +* running态的进程主动 exit 变成 +zombie态,然后由其父进程完成对其资源的最后释放,子进程的进程控制块成为unused。 +* 所有从runnable态变成其他状态的进程都要出运行队列,反之,被放入某个运行队列中。 + +### 3.4 进程调度实现 + +#### 3.4.1 内核抢占点 + +调度本质上体现了对CPU资源的抢占。对于用户进程而言,由于有中断的产生,可以随时打断用户进程的执行,转到操作系统内部,从而给了操作系统以调度控制权,让操作系统可以根据具体情况(比如用户进程时间片已经用完了)选择其他用户进程执行。这体现了用户进程的可抢占性(preemptive)。但如果把ucore操作系统也看成是一个特殊的内核进程或多个内核线程的集合,那ucore是否也是可抢占的呢?其实ucore +内核执行是不可抢占的(non-preemptive),即在执行“任意”内核代码时,CPU控制权可被强制剥夺。这里需要注意,不是在所有情况下ucore内核执行都是不可抢占的,有以下几种“固定”情况是例外: + +1. 进行同步互斥操作,比如争抢一个信号量、锁(lab7中会详细分析); +2.进行磁盘读写等耗时的异步操作,由于等待完成的耗时太长,ucore会调用shcedule让其他就绪进程执行。 + +这几种情况其实都是由于当前进程所需的某个资源(也可称为事件)无法得到满足,无法继续执行下去,从而不得不主动放弃对CPU的控制权。如果参照用户进程任何位置都可被内核打断并放弃CPU控制权的情况,这些在内核中放弃CPU控制权的执行地点是“固定”而不是“任意”的,不能体现内核任意位置都可抢占性的特点。我们搜寻一下实验五的代码,可发现在如下几处地方调用了shedule函数: + +表一:调用进程调度函数schedule的位置和原因 + + + + + + + + + +
编号位置原因
1proc.c::do_exit用户线程执行结束,主动放弃CPU控制权。
2proc.c::do_wait用户线程等待子进程结束,主动放弃CPU控制权。
3proc.c::init_main1. initproc内核线程等待所有用户进程结束,如果没有结束,就主动放弃CPU控制权; +2. initproc内核线程在所有用户进程结束后,让kswapd内核线程执行10次,用于回收空闲内存资源
4proc.c::cpu_idleidleproc内核线程的工作就是等待有处于就绪态的进程或线程,如果有就调用schedule函数
5sync.h::lock在获取锁的过程中,如果无法得到锁,则主动放弃CPU控制权
6trap.c::trap如果在当前进程在用户态被打断去,且当前进程控制块的成员变量need_resched设置为1,则当前线程会放弃CPU控制权
+ +仔细分析上述位置,第1、2、5处的执行位置体现了由于获取某种资源一时等不到满足、进程要退出、进程要睡眠等原因而不得不主动放弃CPU。第3、4处的执行位置比较特殊,initproc内核线程等待用户进程结束而执行schedule函数;idle内核线程在没有进程处于就绪态时才执行,一旦有了就绪态的进程,它将执行schedule函数完成进程调度。这里只有第6处的位置比较特殊: +``` + if (!in_kernel) { + …… + + if (current->need_resched) { + schedule(); + } + } +``` +这里表明了只有当进程在用户态执行到“任意”某处用户代码位置时发生了中断,且当前进程控制块成员变量need\_resched为1(表示需要调度了)时,才会执行shedule函数。这实际上体现了对用户进程的可抢占性。如果没有第一行的if语句,那么就可以体现对内核代码的可抢占性。但如果要把这一行if语句去掉,我们就不得不实现对ucore中的所有全局变量的互斥访问操作,以防止所谓的race +condition现象,这样ucore的实现复杂度会增加不少。 + +#### 3.4.2 进程切换过程 + +进程调度函数schedule选择了下一个将占用CPU执行的进程后,将调用进程切换,从而让新的进程得以执行。通过实验四和实验五的理解,应该已经对进程调度和上下文切换有了初步的认识。在实验五中,结合调度器框架的设计,可对ucore中的进程切换以及堆栈的维护和使用等有更加深刻的认识。假定有两个用户进程,在二者进行进程切换的过程中,具体的步骤如下: + +首先在执行某进程A的用户代码时,出现了一个 trap +(例如是一个外设产生的中断),这 +个时候就会从进程A的用户态切换到内核态(过程(1)),并且保存好进程A的trapframe; +当内核态处理中断时发现需要进行进程切换时,ucore要通过schedule函数选择下一个将占用CPU执行的进程(即进程B),然后会调用proc\_run函数,proc\_run函数进一步调用switch\_to函数,切换到进程B的内核态(过程(2)),继续进程B上一次在内核态的操作,并通过iret指令,最终将执行权转交给进程B的用户空间(过程(3))。 + +当进程B由于某种原因发生中断之后(过程(4)),会从进程B的用户态切换到内核态,并且保存好进程B的trapframe;当内核态处理中断时发现需要进行进程切换时,即需要切换到进程A,ucore再次切换到进程A(过程(5)),会执行进程A上一次在内核调用 +schedule (具体还要跟踪到 switch\_to 函数) +函数返回后的下一行代码,这行代码当然还是在进程A的上一次中断处理流程中。最后当进程 +A的中断处理完毕的时候,执行权又会反交给进程A的用户代码(过程(6))。这就是在只有两个进程的情况下,进程切换间的大体流程。 + +几点需要强调的是: + +**a)** +需要透彻理解在进程切换以后,程序是从哪里开始执行的?需要注意到虽然指令还是同一个cpu上执行,但是此时已经是另外一个进程在执行了,且使用的资源已经完全不同了。 + +**b)** +内核在第一个程序运行的时候,需要进行哪些操作?有了实验四和实验五的经验,可以确定,内核启动第一个用户进程的过程,实际上是从进程启动时的内核状态切换到该用户进程的内核状态的过程,而且该用户进程在用户态的起始入口应该是forkret。 + +### 3.5 调度框架和调度算法 + +#### 3.5.1 设计思路 + +实行一个进程调度策略,到底需要实现哪些基本功能对应的数据结构?首先考虑到一个无论哪种调度算法都需要选择一个就绪进程来占用CPU运行。为此我们可把就绪进程组织起来,可用队列(双向链表)、二叉树、红黑树、数组…等不同的组织方式。 + +在操作方面,如果需要选择一个就绪进程,就可以从基于某种组织方式的就绪进程集合中选择出一个进程执行。需要注意,这里“选择”和“出”是两个操作,选择是在集合中挑选一个“合适”的进程,“出”意味着离开就绪进程集合。另外考虑到一个处于运行态的进程还会由于某种原因(比如时间片用完了)回到就绪态而不能继续占用CPU执行,这就会重新进入到就绪进程集合中。这两种情况就形成了调度器相关的三个基本操作:在就绪进程集合中选择、进入就绪进程集合和离开就绪进程集合。这三个操作属于调度器的基本操作。 + +在进程的执行过程中,就绪进程的等待时间和执行进程的执行时间是影响调度选择的重要因素,这两个因素随着时间的流逝和各种事件的发生在不停地变化,比如处于就绪态的进程等待调度的时间在增长,处于运行态的进程所消耗的时间片在减少等。这些进程状态变化的情况需要及时让进程调度器知道,便于选择更合适的进程执行。所以这种进程变化的情况就形成了调度器相关的一个变化感知操作: +timer时间事件感知操作。这样在进程运行或等待的过程中,调度器可以调整进程控制块中与进程调度相关的属性值(比如消耗的时间片、进程优先级等),并可能导致对进程组织形式的调整(比如以时间片大小的顺序来重排双向链表等),并最终可能导致调选择新的进程占用CPU运行。这个操作属于调度器的进程调度属性调整操作。 + +#### 3.5.2 数据结构 + +在理解框架之前,需要先了解一下调度器框架所需要的数据结构。 + +* 通常的操作系统中,进程池是很大的(虽然在 ucore 中,MAX\_PROCESS 很小)。 +在 ucore 中,调度器引入 run-queue(简称rq, +即运行队列)的概念,通过链表结构管理进程。 +* 由于目前 ucore 设计运行在单 +CPU上,其内部只有一个全局的运行队列,用来管理系统内全部的进程。 +* 运行队列通过链表的形式进行组织。链表的每一个节点是一个list\_entry\_t, +每个list\_entry\_t 又对应到了 struct proc\_struct \*, +这其间的转换是通过宏 le2proc 来完成 的。具体来说,我们知道在 struct +proc\_struct 中有一个叫 run\_link 的 list\_entry\_t, +因此可以通过偏移量逆向找到对因某个 run\_list的 struct proc\_struct。即 +进程结构指针 proc = le2proc(链表节点指针, run\_link)。 +* 为了保证调度器接口的通用性,ucore +调度框架定义了如下接口,该接口中,几乎全部成员变量均为函数指针。具体的功能会在后面的框架说明中介绍。 +``` +1 struct sched_class { +2 // 调度器的名字 +3 const char *name; +4 // 初始化运行队列 +5 void (*init) (struct run_queue *rq); +6 // 将进程 p 插入队列 rq +7 void (*enqueue) (struct run_queue *rq, struct proc_struct *p); +8 // 将进程 p 从队列 rq 中删除 +9 void (*dequeue) (struct run_queue *rq, struct proc_struct *p); +10 // 返回 运行队列 中下一个可执行的进程 +11 struct proc_struct* (*pick_next) (struct run_queue *rq); +12 // timetick 处理函数 +13 void (*proc_tick)(struct run_queue* rq, struct proc_struct* p); +14 }; +``` + +• 此外,proc.h 中的 struct proc\_struct 中也记录了一些调度相关的信息: +``` +1 struct proc_struct { +2 // . . . +3 // 该进程是否需要调度,只对当前进程有效 +4 volatile bool need_resched; +5 // 该进程的调度链表结构,该结构内部的连接组成了 运行队列 列表 +6 list_entry_t run_link; +7 // 该进程剩余的时间片,只对当前进程有效 +8 int time_slice; +9 // round-robin 调度器并不会用到以下成员 +10 // 该进程在优先队列中的节点,仅在 LAB6 使用 +11 skew_heap_entry_t lab6_run_pool; +12 // 该进程的调度优先级,仅在 LAB6 使用 +13 uint32_t lab6_priority; +14 // 该进程的调度步进值,仅在 LAB6 使用 +15 uint32_t lab6_stride; +16 }; +``` +在此次实验中,你需要了解 default\_sched.c +中的实现RR调度算法的函数。在该文件中,你可以看到ucore 已经为 RR +调度算法创建好了一个名为 RR\_sched\_class 的调度策略类。 + +通过数据结构 struct run\_queue 来描述完整的 +run\_queue(运行队列)。它的主要结构如下: +``` +1 struct run_queue { +2 //其运行队列的哨兵结构,可以看作是队列头和尾 +3 list_entry_t run_list; +4 //优先队列形式的进程容器,只在 LAB6 中使用 +5 skew_heap_entry_t *lab6_run_pool; +6 //表示其内部的进程总数 +7 unsigned int proc_num; +8 //每个进程一轮占用的最多时间片 +9 int max_time_slice; +10 }; +``` +在 ucore 框架中,运行队列存储的是当前可以调度的进程,所以,只有状态为 +runnable的进程才能够进入运行队列。当前正在运行的进程并不会在运行队列中,这一点需要注意。 + +#### 3.5.3 调度点的相关关键函数 + +虽然进程各种状态变化的原因和导致的调度处理各异,但其实仔细观察各个流程的共性部分,会发现其中只涉及了三个关键调度相关函数:wakup\_proc、shedule、run\_timer\_list。如果我们能够让这三个调度相关函数的实现与具体调度算法无关,那么就可以认为ucore实现了一个与调度算法无关的调度框架。 + +wakeup\_proc函数其实完成了把一个就绪进程放入到就绪进程队列中的工作,为此还调用了一个调度类接口函数sched\_class\_enqueue,这使得wakeup\_proc的实现与具体调度算法无关。schedule函数完成了与调度框架和调度算法相关三件事情:把当前继续占用CPU执行的运行进程放放入到就绪进程队列中,从就绪进程队列中选择一个“合适”就绪进程,把这个“合适”的就绪进程从就绪进程队列中摘除。通过调用三个调度类接口函数sched\_class\_enqueue、sched\_class\_pick\_next、sched\_class\_enqueue来使得完成这三件事情与具体的调度算法无关。run\_timer\_list函数在每次timer中断处理过程中被调用,从而可用来调用调度算法所需的timer时间事件感知操作,调整相关进程的进程调度相关的属性值。通过调用调度类接口函数sched\_class\_proc\_tick使得此操作与具体调度算法无关。 + +这里涉及了一系列调度类接口函数: + +* sched_class_enqueue +* sched_class_dequeue +* sched_class_pick_next +* sched_class_proc_tick + +这4个函数的实现其实就是调用某基于sched\_class数据结构的特定调度算法实现的4个指针函数。采用这样的调度类框架后,如果我们需要实现一个新的调度算法,则我们需要定义一个针对此算法的调度类的实例,一个就绪进程队列的组织结构描述就行了,其他的事情都可交给调度类框架来完成。 + +#### 3.5.4 RR 调度算法实现 + +RR调度算法的调度思想 是让所有 +runnable态的进程分时轮流使用CPU时间。RR调度器维护当前runnable进程的有序运行队列。当前进程的时间片用完之后,调度器将当前进程放置到运行队列的尾部,再从其头部取出进程进行调度。RR调度算法的就绪队列在组织结构上也是一个双向链表,只是增加了一个成员变量,表明在此就绪进程队列中的最大执行时间片。而且在进程控制块proc\_struct中增加了一个成员变量time\_slice,用来记录进程当前的可运行时间片段。这是由于RR调度算法需要考虑执行进程的运行时间不能太长。在每个timer到时的时候,操作系统会递减当前执行进程的time\_slice,当time\_slice为0时,就意味着这个进程运行了一段时间(这个时间片段称为进程的时间片),需要把CPU让给其他进程执行,于是操作系统就需要让此进程重新回到rq的队列尾,且重置此进程的时间片为就绪队列的成员变量最大时间片max\_time\_slice值,然后再从rq的队列头取出一个新的进程执行。下面来分析一下其调度算法的实现。 + +RR\_enqueue的函数实现如下表所示。即把某进程的进程控制块指针放入到rq队列末尾,且如果进程控制块的时间片为0,则需要把它重置为rq成员变量max\_time\_slice。这表示如果进程在当前的执行时间片已经用完,需要等到下一次有机会运行时,才能再执行一段时间。 +``` +static void +RR_enqueue(struct run_queue *rq, struct proc_struct *proc) { + assert(list_empty(&(proc->run_link))); + list_add_before(&(rq->run_list), &(proc->run_link)); + if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) { + proc->time_slice = rq->max_time_slice; + } + proc->rq = rq; + rq->proc_num ++; +} +``` +RR\_pick\_next的函数实现如下表所示。即选取就绪进程队列rq中的队头队列元素,并把队列元素转换成进程控制块指针。 +``` +static struct proc_struct * +FCFS_pick_next(struct run_queue *rq) { + list_entry_t *le = list_next(&(rq->run_list)); + if (le != &(rq->run_list)) { + return le2proc(le, run_link); + } + return NULL; +} +``` +RR\_dequeue的函数实现如下表所示。即把就绪进程队列rq的进程控制块指针的队列元素删除,并把表示就绪进程个数的proc\_num减一。 +``` +static void +FCFS_dequeue(struct run_queue *rq, struct proc_struct *proc) { + assert(!list_empty(&(proc->run_link)) && proc->rq == rq); + list_del_init(&(proc->run_link)); + rq->proc_num --; +} +``` +RR\_proc\_tick的函数实现如下表所示。即每次timer到时后,trap函数将会间接调用此函数来把当前执行进程的时间片time\_slice减一。如果time\_slice降到零,则设置此进程成员变量need\_resched标识为1,这样在下一次中断来后执行trap函数时,会由于当前进程程成员变量need\_resched标识为1而执行schedule函数,从而把当前执行进程放回就绪队列末尾,而从就绪队列头取出在就绪队列上等待时间最久的那个就绪进程执行。 +``` +static void +RR_proc_tick(struct run_queue *rq, struct proc_struct *proc) { + if (proc->time_slice > 0) { + proc->time_slice --; + } + if (proc->time_slice == 0) { + proc->need_resched = 1; + } +} +``` + +### 3.6 Stride Scheduling + +#### 3.6.1 基本思路 + +**【提示】请先看练习2中提到的论文, 理解后在看下面的内容。** + +考察 round-robin 调度器,在假设所有进程都充分使用了其拥有的 CPU +时间资源的情况下,所有进程得到的 CPU +时间应该是相等的。但是有时候我们希望调度器能够更智能地为每个进程分配合理的 +CPU +资源。假设我们为不同的进程分配不同的优先级,则我们有可能希望每个进程得到的时间资源与他们的优先级成正比关系。Stride调度是基于这种想法的一个较为典型和简单的算法。除了简单易于实现以外,它还有如下的特点: + +* 可控性:如我们之前所希望的,可以证明 Stride Scheduling对进程的调度次数正比于其优先级。 +* 确定性:在不考虑计时器事件的情况下,整个调度机制都是可预知和重现的。该算法的基本思想可以考虑如下: + 1. 为每个runnable的进程设置一个当前状态stride,表示该进程当前的调度权。另外定义其对应的pass值,表示对应进程在调度后,stride 需要进行的累加值。 + 2. 每次需要调度时,从当前 runnable 态的进程中选择 stride最小的进程调度。 + 3. 对于获得调度的进程P,将对应的stride加上其对应的步长pass(只与进程的优先权有关系)。 + 4. 在一段固定的时间之后,回到 2.步骤,重新调度当前stride最小的进程。 +可以证明,如果令 P.pass =BigStride / P.priority +其中 P.priority 表示进程的优先权(大于 1),而 BigStride +表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。将该调度器应用到 +ucore 的调度器框架中来,则需要将调度器接口实现如下: + +* init: +– 初始化调度器类的信息(如果有的话)。 +–初始化当前的运行队列为一个空的容器结构。(比如和RR调度算法一样,初始化为一个有序列表) + +* enqueue +– 初始化刚进入运行队列的进程 proc的stride属性。 +– 将 proc插入放入运行队列中去(注意:这里并不要求放置在队列头部)。 + +* dequeue +– 从运行队列中删除相应的元素。 + +* pick next +– 扫描整个运行队列,返回其中stride值最小的对应进程。 +– 更新对应进程的stride值,即pass = BIG\_STRIDE / P-\>priority; P-\>stride += pass。 + +* proc tick: +– 检测当前进程是否已用完分配的时间片。如果时间片用完,应该正确设置进程结构的相关标记来引起进程切换。 +– 一个 process 最多可以连续运行 rq.max\_time\_slice个时间片。 + +在具体实现时,有一个需要注意的地方:stride属性的溢出问题,在之前的实现里面 +我们并没有考虑 stride 的数值范围,而这个值在理论上是不断增加的,在 +stride溢出以后,基于 +stride的比较可能会出现错误。比如假设当前存在两个进程A和B,stride属性采用16位无符号整数进行存储。当前队列中元素如下(假设当前运行的进程已经被重新放置进运行队列中): + +![image](lab6.files/image001.png) + +此时应该选择 A 作为调度的进程,而在一轮调度后,队列将如下: + +![image](lab6.files/image002.png) + +可以看到由于溢出的出现,进程间stride的理论比较和实际比较结果出现了偏差。我们首先在理论上分析这个问题:令PASS\_MAX为当前所有进程里最大的步进值。则我们可以证明如下结论:对每次 +Stride +调度器的调度步骤中,有其最大的步进值STRIDE\_MAX和最小的步进值STRIDE\_MIN +之差: +STRIDE\_MAX – STRIDE\_MIN <= PASS\_MAX +提问 1:如何证明该结论? 有了该结论,在加上之前对优先级有Priority \> 1 +限制,我们有 +STRIDE\_MAX – STRIDE\_MIN <= BIG\_STRIDE +于是我们只要将BigStride取在某个范围之内,即可保证对于任意两个 Stride +之差都会在机器整数表示的范围之内。而我们可以通过其与0的比较结构,来得到两个 +Stride的大小关系。在上例中,虽然在直接的数值表示上 98 < 65535,但是 98 - +65535 的结果用带符号的 16位整数表示的结果为99, +与理论值之差相等。所以在这个意义下 98 \> 65535。 +基于这种特殊考虑的比较方法,即便 +Stride有可能溢出,我们仍能够得到理论上的当前最小Stride,并做出正确的调度决定。 + +提问 2:在 ucore 中,目前 +Stride是采用无符号的32位整数表示。则BigStride应该取多少,才能保证比较的正确性? + +#### 3.6.2 使用优先队列实现 Stride Scheduling + +在上述的实现描述中,对于每一次pick\_next函数,我们都需要完整地扫描来获得当前最小的stride及其进程。这在进程非常多的时候是非常耗时和低效的,有兴趣的同学可以在实 +现了基于列表扫描的Stride调度器之后比较一下priority程序在Round-Robin及Stride调度器下各自的运行时间。考虑到其调度选择于优先队列的抽象逻辑一致,我们考虑使用优化的优先队列数据结构实现该调度。 + +优先队列是这样一种数据结构:使用者可以快速的插入和删除队列中的元素,并且在预先指定的顺序下快速取得当前在队列中的最小(或者最大)值及其对应元素。可以看到,这样的数据结构非常符合 +Stride 调度器的实现。 + +本次实验提供了libs/skew\_heap.h +作为优先队列的一个实现,该实现定义相关的结构和接口,其中主要包括: +``` +1 // 优先队列节点的结构 +2 typedef struct skew_heap_entry skew_heap_entry_t; +3 // 初始化一个队列节点 +4 void skew_heap_init(skew_heap_entry_t *a); +5 // 将节点 b 插入至以节点 a 为队列头的队列中去,返回插入后的队列 +6 skew_heap_entry_t *skew_heap_insert(skew_heap_entry_t *a, +7 skew_heap_entry_t *b, +8 compare_f comp); +9 // 将节点 b 插入从以节点 a 为队列头的队列中去,返回删除后的队列 +10 skew_heap_entry_t *skew_heap_remove(skew_heap_entry_t *a, +11 skew_heap_entry_t *b, +12 compare_f comp); +``` + +其中优先队列的顺序是由比较函数comp决定的,sched\_stride.c中提供了proc\_stride\_comp\_f比较器用来比较两个stride的大小,你可以直接使用它。当使用优先队列作为Stride调度器的实现方式之后,运行队列结构也需要作相关改变,其中包括: + +* struct +run\_queue中的lab6\_run\_pool指针,在使用优先队列的实现中表示当前优先队列 +的头元素,如果优先队列为空,则其指向空指针(NULL)。 + +* struct +proc\_struct中的lab6\_run\_pool结构,表示当前进程对应的优先队列节点。 +本次实验已经修改了系统相关部分的代码,使得其能够很好地适应LAB6 +新加入的数据结构和接口。而在实验中我们需要做的是用优先队列实现一个正确和高效的Stride调度器,如果用较简略的伪代码描述,则有: + +* init(rq): +– Initialize rq-\>run\_list +– Set rq-\>lab6\_run\_pool to NULL +– Set rq-\>proc\_num to 0 + +* enqueue(rq, proc) +– Initialize proc-\>time\_slice +– Insert proc-\>lab6\_run\_pool into rq-\>lab6\_run\_pool +– rq-\>proc\_num ++ + +* dequeue(rq, proc) +– Remove proc-\>lab6\_run\_pool from rq-\>lab6\_run\_pool +– rq-\>proc\_num -- + +* pick\_next(rq) +– If rq-\>lab6\_run\_pool == NULL, return NULL +– Find the proc corresponding to the pointer rq-\>lab6\_run\_pool +– proc-\>lab6\_stride += BIG\_STRIDE / proc-\>lab6\_priority +– Return proc + +* proc\_tick(rq, proc): +– If proc-\>time\_slice \> 0, proc-\>time\_slice -- +– If proc-\>time\_slice == 0, set the flag proc-\>need\_resched + +## 4 实验报告要求 + +从网站上下载lab6.zip后,解压得到本文档和代码目录 +lab6,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab6-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB6”的注释,主要是修改default\_sched\_swide\_c中的内容。代码中所有需要完成的地方(challenge除外)都有“LAB6”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。 + +## 附录:执行 priority大致的显示输出 +``` +$ make run-priority +...... +check_swap() succeeded! +++ setup timer interrupts +kernel_execve: pid = 2, name = "priority". +main: fork ok,now need to wait pids. +child pid 7, acc 2492000, time 2001 +child pid 6, acc 1944000, time 2001 +child pid 4, acc 960000, time 2002 +child pid 5, acc 1488000, time 2003 +child pid 3, acc 540000, time 2004 +main: pid 3, acc 540000, time 2004 +main: pid 4, acc 960000, time 2004 +main: pid 5, acc 1488000, time 2004 +main: pid 6, acc 1944000, time 2004 +main: pid 7, acc 2492000, time 2004 +main: wait pids over +stride sched correct result: 1 2 3 4 5 +all user-mode processes have quit. +init check memory pass. +kernel panic at kern/process/proc.c:426: + initproc exit. + +Welcome to the kernel debug monitor!! +Type 'help' for a list of commands. +K> +``` diff --git a/lab7.md b/lab7.md new file mode 100644 index 0000000..11274d3 --- /dev/null +++ b/lab7.md @@ -0,0 +1,457 @@ +# 实验七:同步互斥 + +## 1. 实验目的 + +* 熟悉ucore中的进程同步机制,了解操作系统为进程同步提供的底层支持; +* 在ucore中理解信号量(semaphore)机制的具体实现; +* 理解管程机制,在ucore内核中增加基于管程(monitor)的条件变量(condition +variable)的支持; +* 了解经典进程同步问题,并能使用同步机制解决进程同步问题。 + +## 2 实验内容 + +实验六完成了用户进程的调度框架和具体的调度算法,可调度运行多个进程。如果多个进程需要协同操作或访问共享资源,则存在如何同步和有序竞争的问题。本次实验,主要是熟悉ucore的进程同步机制—信号量(semaphore)机制,以及基于信号量的哲学家就餐问题解决方案。然后掌握管程的概念和原理,并参考信号量机制,实现基于管程的条件变量机制和基于条件变量来解决哲学家就餐问题。 + +在本次实验中,在kern/sync/check\_sync.c中提供了一个基于信号量的哲学家就餐问题解法。同时还需完成练习,即实现基于管程(主要是灵活运用条件变量和互斥信号量)的哲学家就餐问题解法。哲学家就餐问题描述如下:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。 + +### 2.1 练习 + +#### 练习0:填写已有实验 + +本实验依赖实验1/2/3/4/5/6。请把你做的实验1/2/3/4/5/6的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”/“LAB5”/“LAB6”的注释相应部分。并确保编译通过。注意:为了能够正确执行lab7的测试应用程序,可能需对已完成的实验1/2/3/4/5/6的代码进行进一步改进。 + +#### 练习1: 理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题(不需要编码) + +完成练习0后,建议大家比较一下(可用kdiff3等文件比较软件)个人完成的lab6和练习0完成后的刚修改的lab7之间的区别,分析了解lab7采用信号量的执行过程。执行make +grade,大部分测试用例应该通过。 + +#### 练习2: 完成内核级条件变量和基于内核级条件变量的哲学家就餐问题(需要编码) + +首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。 + +执行:make grade +。如果所显示的应用程序检测都输出ok,则基本正确。如果只是某程序过不去,比如matrix.c,则可执行 +make run-matrix +命令来单独调试它。大致执行结果可看附录。(**使用的是****qemu-1.0.1**)。 + +#### 扩展练习 Challenge :实现 Linux 的 RCU + +在ucore +下实现下Linux的RCU同步互斥机制。可阅读相关Linux内核书籍或查询网上资料,可了解RCU的细节,然后大致实现在ucore中。下面是一些参考资料: + +* [http://www.ibm.com/developerworks/cn/linux/l-rcu/](http://www.ibm.com/developerworks/cn/linux/l-rcu/) +* [http://www.diybl.com/course/6\_system/linux/Linuxjs/20081117/151814.html](http://www.diybl.com/course/6_system/linux/Linuxjs/20081117/151814.html) + +### 2.2 项目组成 + +此次实验中,主要有如下一些需要关注的文件: +. +├── boot +├── kern +│ ├── driver +│ ├── fs +│ ├── init +│ ├── libs +│ ├── mm +│ │ ├── ...... +│ │ ├── vmm.c +│ │ └── vmm.h +│ ├── process +│ │ ├── proc.c +│ │ ├── proc.h +│ │ └──...... +│ ├── schedule +│ ├── sync +│ │ ├── check\_sync.c +│ │ ├── monitor.c +│ │ ├── monitor.h +│ │ ├── sem.c +│ │ ├── sem.h +│ │ ├── sync.h +│ │ ├── wait.c +│ │ └── wait.h +│ ├── syscall +│ │ ├── syscall.c +│ │ └──...... +│ └── trap +├── libs +└── user +├── forktree.c +├── libs +│ ├── syscall.c +│ ├── syscall.h +│ ├── ulib.c +│ ├── ulib.h +│ └── ...... +├── priority.c +├── sleep.c +├── sleepkill.c +├── softint.c +├── spin.c +└── ...... + +简单说明如下: + +* kern/sync/sync.h: 去除了lock实现(这对于不抢占内核没用)。 +* kern/sync/wait.[ch]: +定了为wait结构和waitqueue结构以及在此之上的函数,这是ucore中的信号量semophore机制和条件变量机制的基础,在本次实验中你需要了解其实现。 +* kern/sync/sem.[ch]:定义并实现了ucore中内核级信号量相关的数据结构和函数,本次试验中你需要了解其中的实现,并基于此完成内核级条件变量的设计与实现。 +* user/ libs/ {syscall.[ch],ulib.[ch] +}与kern/sync/syscall.c:实现了进程sleep相关的系统调用的参数传递和调用关系。 +* user/{ sleep.c,sleepkill.c}: 进程睡眠相关的一些测试用户程序。 +* kern/sync/monitor.[ch]:基于管程的条件变量的实现程序,在本次实验中是练习的一部分,要求完成。 +* kern/sync/check\_sync.c:实现了基于管程的哲学家就餐问题,在本次实验中是练习的一部分,要求完成基于管程的哲学家就餐问题。 +* kern/mm/vmm.[ch]:用信号量mm\_sem取代mm\_struct中原有的mm\_lock。(本次实验不用管) + +## 3 同步互斥的设计与实现 + +### 3.1 实验执行流程概述 + +互斥是指某一资源同时只允许一个进程对其进行访问,具有唯一性和排它性,但互斥不用限制进程对资源的访问顺序,即访问可以是无序的。同步是指在进程间的执行必须严格按照规定的某种先后次序来运行,即访问是有序的,这种先后次序取决于要系统完成的任务需求。在进程写资源情况下,进程间要求满足互斥条件。在进程读资源情况下,可允许多个进程同时访问资源。 + +实验七提供了多种同步互斥手段,包括中断控制、等待队列、信号量、管程机制(包含条件变量设计)等,并基于信号量实现了哲学家问题的执行过程。而练习是要求用管程机制实现哲学家问题的执行过程。在实现信号量机制和管程机制时,需要让无法进入临界区的进程睡眠,为此在ucore中设计了等待队列。当进程无法进入临界区(即无法获得信号量)时,可让进程进入等待队列,这时的进程处于等待状态(也可称为阻塞状态),从而会让实验六中的调度器选择一个处于就绪状态(即RUNNABLE +STATE)的进程,进行进程切换,让新进程有机会占用CPU执行,从而让整个系统的运行更加高效。 + +在实验七中的ucore初始化过程,开始的执行流程都与实验六相同,直到执行到创建第二个内核线程init\_main时,修改了init\_main的具体执行内容,即增加了check\_sync函数的调用,而位于lab7/kern/sync/check\_sync.c中的check\_sync函数可以理解为是实验七的起始执行点,是实验七的总控函数。进一步分析此函数,可以看到这个函数主要分为了两个部分,第一部分是实现基于信号量的哲学家问题,第二部分是实现基于管程的哲学家问题。 + +对于check\_sync函数的第一部分,首先实现初始化了一个互斥信号量,然后创建了对应5个哲学家行为的5个信号量,并创建5个内核线程代表5个哲学家,每个内核线程完成了基于信号量的哲学家吃饭睡觉思考行为实现。这部分是给学生作为练习参考用的。学生可以看看信号量是如何实现的,已经如何利用信号量完成哲学家问题。 + +对于check\_sync函数的第二部分,首先初始化了管程,然后又创建了5个内核线程代表5个哲学家,每个内核线程要完成基于管程的哲学家吃饭睡觉思考行为实现。这部分需要学生来具体完成。学生需要掌握如何用信号量来实现条件变量,以及包含条件变量的管程如何能够确保哲学家能够正常思考和吃饭。 + +### 3.2 同步互斥的底层支撑 + +**开关中断** + +根据操作系统原理的知识,我们知道如果没有在硬件级保证读内存-修改值-写回内存的原子性,我们只能通过复杂的软件来实现同步互斥操作。但由于有开关中断和test\_and\_set\_bit等原子操作机器指令的存在,使得我们在实现同步互斥原语上可以大大简化。在atomic.c文件中实现的test\_and\_set\_bit等原子操作。 + +在ucore中提供的底层机制包括中断开关控制和test\_and\_set相关原子操作机器指令。kern/sync.c中实现的开关中断的控制函数local\_intr\_save(x)和local\_intr\_restore(x),它们是基于kern/driver文件下的intr\_enable()、intr\_disable()函数实现的。具体调用关系为: +``` +关中断:local_intr_save --> __intr_save --> intr_disable --> cli +开中断:local_intr_restore--> __intr_restore --> intr_enable --> sti +``` +最终的cli和sti是x86的机器指令,最终实现了关中断和开中断,即设置了eflags寄存器中与中断相关的位。通过关闭中断,可以防止对当前执行的控制流被其他中断事件处理所打断。既然不能中断,那也就意味着在内核运行的当前进程无法被打断或被从新调度,即实现了对临界区的互斥操作。所以在单处理器情况下,可以通过开关中断实现对临界区的互斥保护,需要互斥的临界区代码的一般写法为: +``` +local_intr_save(intr_flag); +{ + 临界区代码 +} +local_intr_restore(intr_flag); +…… +``` +由于目前ucore只实现了对单处理器的支持,所以通过这种方式,就可简单地支撑互斥操作了。在多处理器情况下,这种方法是无法实现互斥的,因为屏蔽了一个CPU的中断,只能阻止本CPU上的进程不会被中断或调度,并不意味着其他CPU上执行的进程不能执行临界区的代码。所以,开关中断只对单处理器下的互斥操作起作用。在本实验中,开关中断机制是实现信号量等高层同步互斥原语的底层支撑基础之一。 + +**等待队列** + +到目前为止,我们的实验中,用户进程或内核线程还没有睡眠的支持机制。在课程中提到用户进程或内核线程可以转入休眠状态以等待某个特定事件,当该事件发生时这些进程能够被再次唤醒。内核实现这一功能的一个底层支撑机制就是等待队列(wait +queue),等待队列和每一个事件(睡眠结束、时钟到达、任务完成、资源可用等)联系起来。需要等待事件的进程在转入休眠状态后插入到等待队列中。当事件发生之后,内核遍历相应等待队列,唤醒休眠的用户进程或内核线程,并设置其状态为就绪状态(runnable +state),并将该进程从等待队列中清除。ucore在kern/sync/{ wait.h, wait.c +}中实现了wait结构和wait +queue结构以及相关函数),这是实现ucore中的信号量机制和条件变量机制的基础,进入wait +queue的进程会被设为睡眠状态,直到他们被唤醒。 +``` +typedef struct { + struct proc_struct *proc; //等待进程的指针 + uint32_t wakeup_flags; //进程被放入等待队列的原因标记 + wait_queue_t *wait_queue; //指向此wait结构所属于的wait_queue + list_entry_t wait_link; //用来组织wait_queue中wait节点的连接 +} wait_t; +typedef struct { + list_entry_t wait_head; //wait_queue的队头 +} wait_queue_t; +le2wait(le, member) //实现wait_t中成员的指针向wait_t 指针的转化 +``` +与wait和wait queue相关的函数主要分为两层,底层函数是对wait queue的初始化、插入、删除和查找操作,相关函数如下: +``` +void wait_init(wait_t *wait, struct proc_struct *proc); //初始化wait结构 +bool wait_in_queue(wait_t *wait); //wait是否在wait queue中 +void wait_queue_init(wait_queue_t *queue); //初始化wait_queue结构 +void wait_queue_add(wait_queue_t *queue, wait_t *wait); //把wait前插到wait queue中 +void wait_queue_del(wait_queue_t *queue, wait_t *wait); //从wait queue中删除wait +wait_t *wait_queue_next(wait_queue_t *queue, wait_t *wait);//取得wait的后一个链接指针 +wait_t *wait_queue_prev(wait_queue_t *queue, wait_t *wait);//取得wait的前一个链接指针 +wait_t *wait_queue_first(wait_queue_t *queue); //取得wait queue的第一个wait +wait_t *wait_queue_last(wait_queue_t *queue); //取得wait queue的最后一个wait +bool wait_queue_empty(wait_queue_t *queue); //wait queue是否为空 +``` +高层函数基于底层函数实现了让进程进入等待队列,以及从等待队列中唤醒进程,相关函数如下: +``` +//让wait与进程关联,且让当前进程关联的wait进入等待队列queue,当前进程睡眠 +void wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state); +//把与当前进程关联的wait从等待队列queue中删除 +wait_current_del(queue, wait); +//唤醒与wait关联的进程 +void wakeup_wait(wait_queue_t *queue, wait_t *wait, uint32_t wakeup_flags, bool del); +//唤醒等待队列上挂着的第一个wait所关联的进程 +void wakeup_first(wait_queue_t *queue, uint32_t wakeup_flags, bool del); +//唤醒等待队列上所有的等待的进程 +void wakeup_queue(wait_queue_t *queue, uint32_t wakeup_flags, bool del); +``` + +### 3.3 信号量 + +信号量是一种同步互斥机制的实现,普遍存在于现在的各种操作系统内核里。相对于spinlock +的应用对象,信号量的应用对象是在临界区中运行的时间较长的进程。等待信号量的进程需要睡眠来减少占用 +CPU 的开销。参考教科书“Operating Systems Internals and Design +Principles”第五章“同步互斥”中对信号量实现的原理性描述: +``` +struct semaphore { +int count; +queueType queue; +}; +void semWait(semaphore s) +{ +s.count--; +if (s.count < 0) { +/* place this process in s.queue */; +/* block this process */; +} +} +void semSignal(semaphore s) +{ +s.count++; +if (s.count<= 0) { +/* remove a process P from s.queue */; +/* place process P on ready list */; +} +} +``` +基于上诉信号量实现可以认为,当多个(\>1)进程可以进行互斥或同步合作时,一个进程会由于无法满足信号量设置的某条件而在某一位置停止,直到它接收到一个特定的信号(表明条件满足了)。为了发信号,需要使用一个称作信号量的特殊变量。为通过信号量s传送信号,信号量的V操作采用进程可执行原语semSignal(s) +;为通过信号量s接收信号,信号量的P操作采用进程可执行原语semWait(s ) +;如果相应的信号仍然没有发送,则进程被阻塞或睡眠,直到发送完为止。 + +ucore中信号量参照上述原理描述,建立在开关中断机制和wait +queue的基础上进行了具体实现。信号量的数据结构定义如下: +``` +typedef struct { + int value; //信号量的当前值 + wait_queue_t wait_queue; //信号量对应的等待队列 +} semaphore_t; +``` +semaphore\_t是最基本的记录型信号量(record +semaphore)结构,包含了用于计数的整数值value,和一个进程等待队列wait\_queue,一个等待的进程会挂在此等待队列上。 + +在ucore中最重要的信号量操作是P操作函数down(semaphore\_t +\*sem)和V操作函数 up(semaphore\_t +\*sem)。但这两个函数的具体实现是\_\_down(semaphore\_t \*sem, uint32\_t +wait\_state) 函数和\_\_up(semaphore\_t \*sem, uint32\_t +wait\_state)函数,二者的具体实现描述如下: + +● \_\_down(semaphore\_t \*sem, uint32\_t wait\_state, timer\_t +\*timer):具体实现信号量的P操作,首先关掉中断,然后判断当前信号量的value是否大于0。如果是\>0,则表明可以获得信号量,故让value减一,并打开中断返回即可;如果不是\>0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait从等待队列中删除(此过程需要先关中断,完成后开中断)。具体实现如下所示: +``` +static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) { + bool intr_flag; + local_intr_save(intr_flag); + if (sem->value > 0) { + sem->value --; + local_intr_restore(intr_flag); + return 0; + } + wait_t __wait, *wait = &__wait; + wait_current_set(&(sem->wait_queue), wait, wait_state); + local_intr_restore(intr_flag); + + schedule(); + + local_intr_save(intr_flag); + wait_current_del(&(sem->wait_queue), wait); + local_intr_restore(intr_flag); + + if (wait->wakeup_flags != wait_state) { + return wait->wakeup_flags; + } + return 0; +} + +● +``` + +● \_\_up(semaphore\_t \*sem, uint32\_t +wait\_state):具体实现信号量的V操作,首先关中断,如果信号量对应的wait +queue中没有进程在等待,直接把信号量的value加一,然后开中断返回;如果有进程在等待且进程等待的原因是semophore设置的,则调用wakeup\_wait函数将waitqueue中等待的第一个wait删除,且把此wait关联的进程唤醒,最后开中断返回。具体实现如下所示: +``` +static __noinline void __up(semaphore_t *sem, uint32_t wait_state) { + bool intr_flag; + local_intr_save(intr_flag); + { + wait_t *wait; + if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) { + sem->value ++; + } + else { + wakeup_wait(&(sem->wait_queue), wait, wait_state, 1); + } + } + local_intr_restore(intr_flag); +} +``` +对照信号量的原理性描述和具体实现,可以发现二者在流程上基本一致,只是具体实现采用了关中断的方式保证了对共享资源的互斥访问,通过等待队列让无法获得信号量的进程睡眠等待。另外,我们可以看出信号量的计数器value具有有如下性质: + +* value\>0,表示共享资源的空闲数 +* vlaue<0,表示该信号量的等待队列里的进程数 +* value=0,表示等待队列为空 + +### 3.4 管程和条件变量 + +引入了管程是为了将对共享资源的所有访问及其所需要的同步操作集中并封装起来。Hansan为管程所下的定义:“一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据”。有上述定义可知,管程由四部分组成: + +* 管程内部的共享变量; +* 管程内部的条件变量; +* 管程内部并发执行的进程; +* 对局部于管程内部的共享数据设置初始值的语句。 + +局限在管程中的数据结构,只能被局限在管程的操作过程所访问,任何管程之外的操作过程都不能访问它;另一方面,局限在管程中的操作过程也主要访问管程内的数据结构。由此可见,管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。 + +但在管程中仅仅有互斥操作是不够用的。进程可能需要等待某个条件C为真才能继续执行。如果采用[忙等](http://zh.wikipedia.org/w/index.php?title=%E5%BF%99%E7%AD%89%E5%BE%85&action=edit&redlink=1 "忙等待(页面不存在)")(busy +waiting)方式: +``` +while not( C ) do {} +``` +在单处理器情况下,将会导致所有其它进程都无法进入[临界区](http://zh.wikipedia.org/wiki/%E4%B8%B4%E7%95%8C%E5%8C%BA "临界区")使得该条件C为真,该管程的执行将会发生[死锁](http://zh.wikipedia.org/wiki/%E6%AD%BB%E9%94%81 "死锁")。为此,可引入条件变量(Condition +Variables,简称CV)。一个条件变量CV可理解为一个进程的等待队列,队列中的进程正等待某个条件C变为真。每个条件变量关联着一个[断言](http://zh.wikipedia.org/wiki/%E6%96%B7%E8%A8%80_(%E7%A8%8B%E5%BC%8F) "断言 (程序)")Pc。当一个进程等待一个条件变量,该进程不算作占用了该管程,因而其它进程可以进入该管程执行,改变管程的状态,通知条件变量CV其关联的断言Pc在当前状态下为真。因此对条件变量CV有两种主要操作: + +* wait\_cv: 被一个进程调用,以等待断言Pc被满足后该进程可恢复执行. +进程挂在该条件变量上等待时,不被认为是占用了管程。 +* signal\_cv:被一个进程调用,以指出断言Pc现在为真,从而可以唤醒等待断言Pc被满足的进程继续执行。 + +有了互斥和信号量支持的管程就可用用了解决各种同步互斥问题。比如参考《OS +Concept》一书中的6.7.2小节“用管程解决哲学家就餐问题”就给出了这样的事例: + + ![image](lab7.files/image001.png) + +虽然大部分教科书上说明管程适合在语言级实现比如java等高级语言,没有提及在采用C语言的OS中如何实现。下面我们将要尝试在ucore中用C语言实现采用基于互斥和条件变量机制的管程基本原理。 + +ucore中的管程机制是基于信号量和条件变量来实现的。ucore中的管程的数据结构monitor\_t定义如下: +``` +typedef struct monitor{ + semaphore_t mutex; // the mutex lock for going into the routines in monitor, should be initialized to 1 + semaphore_t next; // the next semaphore is used to down the signaling proc itself, and the other OR wakeuped + //waiting proc should wake up the sleeped signaling proc. + int next_count; // the number of of sleeped signaling proc + condvar_t *cv; // the condvars in monitor +} monitor_t; +``` +管程中的成员变量mutex是一个二值信号量,是实现每次只允许一个进程进入管程的关键元素,确保了[互斥](http://zh.wikipedia.org/wiki/%E4%BA%92%E6%96%A5 "互斥")访问性质。管程中的条件变量cv通过执行wait\_cv,会使得等待某个条件C为真的进程能够离开管程并睡眠,且让其他进程进入管程继续执行;而进入管程的某进程设置条件C为真并执行signal\_cv时,能够让等待某个条件C为真的睡眠进程被唤醒,从而继续进入管程中执行。管程中的成员变量信号量next和整形变量next\_count是配合进程对条件变量cv的操作而设置的,这是由于发出signal\_cv的进程A会唤醒睡眠进程B,进程B执行会导致进程A睡眠,直到进程B离开管程,进程A才能继续执行,这个同步过程是通过信号量next完成的;而next\_count表示了由于发出singal\_cv而睡眠的进程个数。 + +管程中的条件变量的数据结构condvar\_t定义如下: +``` +typedef struct condvar{ + semaphore_t sem; // the sem semaphore is used to down the waiting proc, and the signaling proc should up the waiting proc + int count; // the number of waiters on condvar + monitor_t * owner; // the owner(monitor) of this condvar +} condvar_t; +``` +条件变量的定义中也包含了一系列的成员变量,信号量sem用于让发出wait\_cv操作的等待某个条件C为真的进程睡眠,而让发出signal\_cv操作的进程通过这个sem来唤醒睡眠的进程。count表示等在这个条件变量上的睡眠进程的个数。owner表示此条件变量的宿主是哪个管程。 + +理解了数据结构的含义后,我们就可以开始管程的实现了。ucore设计实现了条件变量wait\_cv操作和signal\_cv操作对应的具体函数,即cond\_wait函数和cond\_signal函数,此外还有cond\_init初始化函数(可直接看源码)。函数cond\_wait(condvar\_t +\*cvp, semaphore\_t \*mp)和cond\_signal (condvar\_t +\*cvp)的实现原理可参考《OS +Concept》一书中的6.7.3小节“用信号量实现管程”的内容: + +** cond_wait的原理描述 ** +``` +cv.count++; +if(monitor.next_count > 0) + sem_signal(monitor.next); +else + sem_signal(monitor.mutex); +sem_wait(cv.sem); +cv.count -- ; +``` + + +** ond_signal的原理描述 ** +``` +if( cv.count > 0) { + monitor.next_count ++; + sem_signal(cv.sem); + sem_wait(monitor.next); + monitor.next_count -- ; +} +``` +简单分析一下cond\_wait函数的实现。可以看出如果进程A执行了cond\_wait函数,表示此进程等待某个条件C不为真,需要睡眠。因此表示等待此条件的睡眠进程个数cv.count要加一。接下来会出现两种情况。 + +情况一:如果monitor.next\_count如果大于0,表示有大于等于1个进程执行cond\_signal函数且睡着了,就睡在了monitor.next信号量上。假定这些进程形成S进程链表。因此需要唤醒S进程链表中的一个进程B。然后进程A睡在cv.sem上,如果睡醒了,则让cv.count减一,表示等待此条件的睡眠进程个数少了一个,可继续执行了!这里隐含这一个现象,即某进程A在时间顺序上先执行了signal\_cv,而另一个进程B后执行了wait\_cv,这会导致进程A没有起到唤醒进程B的作用。这里还隐藏这一个问题,在cond\_wait有sem\_signal(mutex),但没有看到哪里有sem\_wait(mutex),这好像没有成对出现,是否是错误的?其实在管程中的每一个函数的入口处会有wait(mutex),这样二者就配好对了。 + +情况二:如果monitor.next\_count如果小于等于0,表示目前没有进程执行cond\_signal函数且睡着了,那需要唤醒的是由于互斥条件限制而无法进入管程的进程,所以要唤醒睡在monitor.mutex上的进程。然后进程A睡在cv.sem上,如果睡醒了,则让cv.count减一,表示等待此条件的睡眠进程个数少了一个,可继续执行了! + +对照着再来看cond\_signal的实现。首先进程B判断cv.count,如果不大于0,则表示当前没有执行cond\_wait而睡眠的进程,因此就没有被唤醒的对象了,直接函数返回即可;如果大于0,这表示当前有执行cond\_wait而睡眠的进程A,因此需要唤醒等待在cv.sem上睡眠的进程A。由于只允许一个进程在管程中执行,所以一旦进程B唤醒了别人(进程A),那么自己就需要睡眠。故让monitor.next\_count加一,且让自己(进程B)睡在信号量monitor.next上。如果睡醒了,这让monitor.next\_count减一。 + +为了让整个管程正常运行,还需在管程中的每个函数的入口和出口增加相关操作,即: +``` +function (…) +{ +sem.wait(monitor.mutex); + the real body of function; + if(monitor.next_count > 0) + sem_signal(monitor.next); + else + sem_signal(monitor.mutex); +} +``` +这样带来的作用有两个,(1)只有一个进程在执行管程中的函数。(2)避免由于执行了cond\_signal函数而睡眠的进程无法被唤醒。对于第二点,如果进程A由于执行了cond\_signal函数而睡眠(这会让monitor.next\_count大于0,且执行sem\_wait(monitor.next)),则其他进程在执行管程中的函数的出口,会判断monitor.next\_count是否大于0,如果大于0,则执行sem\_signal(monitor.next),从而执行了cond\_signal函数而睡眠的进程被唤醒。上诉措施将使得管程正常执行。 + +需要注意的是,上述只是原理描述,与具体描述相比,还有一定的差距。需要大家在完成练习时仔细设计和实现。 + +## 4 实验报告要求 + +从网站上下载lab7.zip后,解压得到本文档和代码目录 +lab7,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab7-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB7”的注释,主要是修改condvar.c和check\_sync.c中的内容。代码中所有需要完成的地方challenge除外)都有“LAB7”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。 + +## 附录:执行 ”make run-matrix”的大致的显示输出 +``` +(THU.CST) os is loading ... +…… +check_alloc_page() succeeded! +…… +check_swap() succeeded! +++ setup timer interrupts +I am No.4 philosopher_condvar +Iter 1, No.4 philosopher_condvar is thinking +I am No.3 philosopher_condvar +…… +I am No.1 philosopher_sema +Iter 1, No.1 philosopher_sema is thinking +I am No.0 philosopher_sema +Iter 1, No.0 philosopher_sema is thinking +kernel_execve: pid = 2, name = “matrix”. +pid 14 is running (1000 times)!. +pid 13 is running (1000 times)!. +phi_test_condvar: state_condvar[4] will eating +phi_test_condvar: signal self_cv[4] +Iter 1, No.4 philosopher_condvar is eating +phi_take_forks_condvar: 3 didn’t get fork and will wait +phi_test_condvar: state_condvar[2] will eating +phi_test_condvar: signal self_cv[2] +Iter 1, No.2 philosopher_condvar is eating +phi_take_forks_condvar: 1 didn’t get fork and will wait +phi_take_forks_condvar: 0 didn’t get fork and will wait +pid 14 done!. +pid 13 done!. +Iter 1, No.4 philosopher_sema is eating +Iter 1, No.2 philosopher_sema is eating +…… +pid 18 done!. +pid 23 done!. +pid 22 done!. +pid 33 done!. +pid 27 done!. +pid 25 done!. +pid 32 done!. +pid 29 done!. +pid 20 done!. +matrix pass. +all user-mode processes have quit. +init check memory pass. +kernel panic at kern/process/proc.c:426: + initproc exit. +Welcome to the kernel debug monitor!! +Type 'help' for a list of commands. +K> qemu: terminating on signal 2 +``` \ No newline at end of file diff --git a/lab8.md b/lab8.md new file mode 100644 index 0000000..4c6c921 --- /dev/null +++ b/lab8.md @@ -0,0 +1,733 @@ +# 实验八:文件系统 + +## 1. 实验目的 +通过完成本次实验,希望能达到以下目标 + +* 了解基本的文件系统系统调用的实现方法; +* 了解一个基于索引节点组织方式的Simple FS文件系统的设计与实现; +* 了解文件系统抽象层-VFS的设计与实现; + +## 2. 实验内容 + +实验七完成了在内核中的同步互斥实验。本次实验涉及的是文件系统,通过分析了解ucore文件系统的总体架构设计,完善读写文件操作,从新实现基于文件系统的执行程序机制(即改写do\_execve),从而可以完成执行存储在磁盘上的文件和实现文件读写等功能。 + +### 2.1 练习 + +**练习0:填写已有实验** + +本实验依赖实验1/2/3/4/5/6/7。请把你做的实验1/2/3/4/5/6/7的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”/“LAB5”/“LAB6” +/“LAB7”的注释相应部分。并确保编译通过。注意:为了能够正确执行lab8的测试应用程序,可能需对已完成的实验1/2/3/4/5/6/7的代码进行进一步改进。 + +**练习1: 完成读文件操作的实现(需要编码)** + +首先了解打开文件的处理流程,然后参考本实验后续的文件读写操作的过程分析,编写在sfs\_inode.c中sfs\_io\_nolock读文件中数据的实现代码。 + +**练习2: 完成基于文件系统的执行程序机制的实现(需要编码)** + +改写proc.c中的load\_icode函数和其他相关函数,实现基于文件系统的执行程序机制。执行:make +qemu。如果能看看到sh用户程序的执行界面,则基本成功了。如果在sh用户界面上可以执行”ls”,”hello”等其他放置在sfs文件系统中的其他执行程序,则可以认为本实验基本成功。(**使用的是qemu-1.0.1**)。 + +### 2.2 项目组成 + +. +├── boot +├── kern +│ ├── debug +│ ├── driver +│ │ ├── clock.c +│ │ ├── clock.h +│ │ └── …… +│ ├── fs +│ │ ├── devs +│ │ │ ├── dev.c +│ │ │ ├── dev\_disk0.c +│ │ │ ├── dev.h +│ │ │ ├── dev\_stdin.c +│ │ │ └── dev\_stdout.c +│ │ ├── file.c +│ │ ├── file.h +│ │ ├── fs.c +│ │ ├── fs.h +│ │ ├── iobuf.c +│ │ ├── iobuf.h +│ │ ├── sfs +│ │ │ ├── bitmap.c +│ │ │ ├── bitmap.h +│ │ │ ├── sfs.c +│ │ │ ├── sfs\_fs.c +│ │ │ ├── sfs.h +│ │ │ ├── sfs\_inode.c +│ │ │ ├── sfs\_io.c +│ │ │ └── sfs\_lock.c +│ │ ├── swap +│ │ │ ├── swapfs.c +│ │ │ └── swapfs.h +│ │ ├── sysfile.c +│ │ ├── sysfile.h +│ │ └── vfs +│ │ ├── inode.c +│ │ ├── inode.h +│ │ ├── vfs.c +│ │ ├── vfsdev.c +│ │ ├── vfsfile.c +│ │ ├── vfs.h +│ │ ├── vfslookup.c +│ │ └── vfspath.c +│ ├── init +│ ├── libs +│ │ ├── stdio.c +│ │ ├── string.c +│ │ └── …… +│ ├── mm +│ │ ├── vmm.c +│ │ └── vmm.h +│ ├── process +│ │ ├── proc.c +│ │ ├── proc.h +│ │ └── …… +│ ├── schedule +│ ├── sync +│ ├── syscall +│ │ ├── syscall.c +│ │ └── …… +│ └── trap +│ ├── trap.c +│ └── …… +├── libs +├── tools +│ ├── mksfs.c +│ └── …… +└── user +├── badarg.c +├── badsegment.c +├── divzero.c +├── exit.c +├── faultread.c +├── faultreadkernel.c +├── forktest.c +├── forktree.c +├── hello.c +├── libs +│ ├── dir.c +│ ├── dir.h +│ ├── file.c +│ ├── file.h +│ ├── initcode.S +│ ├── lock.h +│ ├── stdio.c +│ ├── syscall.c +│ ├── syscall.h +│ ├── ulib.c +│ ├── ulib.h +│ └── umain.c +├── ls.c +├── sh.c +└── …… + +本次实验主要是理解kern/fs目录中的部分文件,并可用user/\*.c测试所实现的Simple +FS文件系统是否能够正常工作。本次实验涉及到的代码包括: + +* 文件系统测试用例: user/\*.c:对文件系统的实现进行测试的测试用例; + +* 通用文件系统接口 +n user/libs/file.[ch]|dir.[ch]|syscall.c:与文件系统操作相关的用户库实行; +n kern/syscall.[ch]:文件中包含文件系统相关的内核态系统调用接口 +n kern/fs/sysfile.[ch]|file.[ch]:通用文件系统接口和实行 + +* 文件系统抽象层-VFS +n kern/fs/vfs/\*.[ch]:虚拟文件系统接口与实现 + +* Simple FS文件系统 +n kern/fs/sfs/\*.[ch]:SimpleFS文件系统实现 + +* 文件系统的硬盘IO接口 +n kern/fs/devs/dev.[ch]|dev\_disk0.c:disk0硬盘设备提供给文件系统的I/O访问接口和实现 + +* 辅助工具 +n tools/mksfs.c:创建一个Simple FS文件系统格式的硬盘镜像。(理解此文件的实现细节对理解SFS文件系统很有帮助) + +* 对内核其它模块的扩充 +n kern/process/proc.[ch]:增加成员变量 struct fs\_struct +\*fs\_struct,用于支持进程对文件的访问;重写了do\_execve +load\_icode等函数以支持执行文件系统中的文件。 +n kern/init/init.c:增加调用初始化文件系统的函数fs\_init。 + +## 3. 文件系统设计与实现 + +### 3.1 ucore 文件系统总体介绍 + +操作系统中负责管理和存储可长期保存数据的软件功能模块称为文件系统。在本次试验中,主要侧重文件系统的设计实现和对文件系统执行流程的分析与理解。 + +ucore的文件系统模型源于Havard的OS161的文件系统和Linux文件系统。但其实这二者都是源于传统的UNIX文件系统设计。UNIX提出了四个文件系统抽象概念:文件(file)、目录项(dentry)、索引节点(inode)和安装点(mount +point)。 + +* 文件:UNIX文件中的内容可理解为是一有序字节buffer,文件都有一个方便应用程序识别的文件名称(也称文件路径名)。典型的文件操作有读、写、创建和删除等。 +* 目录项:目录项不是目录,而是目录的组成部分。在UNIX中目录被看作一种特定的文件,而目录项是文件路径中的一部分。如一个文件路径名是“/test/testfile”,则包含的目录项为:根目录“/”,目录“test”和文件“testfile”,这三个都是目录项。一般而言,目录项包含目录项的名字(文件名或目录名)和目录项的索引节点(见下面的描述)位置。 +* 索引节点:UNIX将文件的相关元数据信息(如访问控制权限、大小、拥有者、创建时间、数据内容等等信息)存储在一个单独的数据结构中,该结构被称为索引节点。 +* 安装点:在UNIX中,文件系统被安装在一个特定的文件路径位置,这个位置就是安装点。所有的已安装文件系统都作为根文件系统树中的叶子出现在系统中。 + +上述抽象概念形成了UNIX文件系统的逻辑数据结构,并需要通过一个具体文件系统的架构设计与实现把上述信息映射并储存到磁盘介质上。一个具体的文件系统需要在磁盘布局也实现上述抽象概念。比如文件元数据信息存储在磁盘块中的索引节点上。当文件被载如内存时,内核需要使用磁盘块中的索引点来构造内存中的索引节点。 + +ucore模仿了UNIX的文件系统设计,ucore的文件系统架构主要由四部分组成: + +* 通用文件系统访问接口层:该层提供了一个从用户空间到文件系统的标准访问接口。这一层访问接口让应用程序能够通过一个简单的接口获得ucore内核的文件系统服务。 + +* 文件系统抽象层:向上提供一个一致的接口给内核其他部分(文件系统相关的系统调用实现模块和其他内核功能模块)访问。向下提供一个同样的抽象函数指针列表和数据结构屏蔽不同文件系统的实现细节。 + +* Simple +FS文件系统层:一个基于索引方式的简单文件系统实例。向上通过各种具体函数实现以对应文件系统抽象层提出的抽象函数。向下访问外设接口 + +* 外设接口层:向上提供device访问接口屏蔽不同硬件细节。向下实现访问各种具体设备驱动的接口,比如disk设备接口/串口设备接口/键盘设备接口等。 + +对照上面的层次我们再大致介绍一下文件系统的访问处理过程,加深对文件系统的总体理解。假如应用程序操作文件(打开/创建/删除/读写),首先需要通过文件系统的通用文件系统访问接口层给用户空间提供的访问接口进入文件系统内部,接着由文件系统抽象层把访问请求转发给某一具体文件系统(比如SFS文件系统),具体文件系统(Simple +FS文件系统层)把应用程序的访问请求转化为对磁盘上的block的处理请求,并通过外设接口层交给磁盘驱动例程来完成具体的磁盘操作。结合用户态写文件函数write的整个执行过程,我们可以比较清楚地看出ucore文件系统架构的层次和依赖关系。 + +![image](lab8.files/image001.png) +**ucore文件系统总体结构** + +从ucore操作系统不同的角度来看,ucore中的文件系统架构包含四类主要的数据结构, +它们分别是: + +* 超级块(SuperBlock),它主要从文件系统的全局角度描述特定文件系统的全局信息。它的作用范围是整个OS空间。 +* 索引节点(inode):它主要从文件系统的单个文件的角度它描述了文件的各种属性和数据所在位置。它的作用范围是整个OS空间。 +* 目录项(dentry):它主要从文件系统的文件路径的角度描述了文件路径中的特定目录。它的作用范围是整个OS空间。 +* 文件(file),它主要从进程的角度描述了一个进程在访问文件时需要了解的文件标识,文件读写的位置,文件引用情况等信息。它的作用范围是某一具体进程。 + +如果一个用户进程打开了一个文件,那么在ucore中涉及的相关数据结构(其中相关数据结构将在下面各个小节中展开叙述)和关系如下图所示: + +![image](lab8.files/image002.png) +**ucore中文件相关关键数据结构及其关系** + +### 3.2 通用文件系统访问接口 + +**文件和目录相关用户库函数** + +Lab8中部分用户库函数与文件系统有关,我们先讨论对单个文件进行操作的系统调用,然后讨论对目录和文件系统进行操作的系统调用。 + +在文件操作方面,最基本的相关函数是open、close、read、write。在读写一个文件之前,首先要用open系统调用将其打开。open的第一个参数指定文件的路径名,可使用绝对路径名;第二个参数指定打开的方式,可设置为O\_RDONLY、O\_WRONLY、O\_RDWR,分别表示只读、只写、可读可写。在打开一个文件后,就可以使用它返回的文件描述符fd对文件进行相关操作。在使用完一个文件后,还要用close系统调用把它关闭,其参数就是文件描述符fd。这样它的文件描述符就可以空出来,给别的文件使用。 + +读写文件内容的系统调用是read和write。read系统调用有三个参数:一个指定所操作的文件描述符,一个指定读取数据的存放地址,最后一个指定读多少个字节。在C程序中调用该系统调用的方法如下: +``` +count = read(filehandle, buffer, nbytes); +``` +该系统调用会把实际读到的字节数返回给count变量。在正常情形下这个值与nbytes相等,但有时可能会小一些。例如,在读文件时碰上了文件结束符,从而提前结束此次读操作。 + +如果由于参数无效或磁盘访问错误等原因,使得此次系统调用无法完成,则count被置为-1。而write函数的参数与之完全相同。 + +对于目录而言,最常用的操作是跳转到某个目录,这里对应的用户库函数是chdir。然后就需要读目录的内容了,即列出目录中的文件或目录名,这在处理上与读文件类似,即需要通过opendir函数打开目录,通过readdir来获取目录中的文件信息,读完后还需通过closedir函数来关闭目录。由于在ucore中把目录看成是一个特殊的文件,所以opendir和closedir实际上就是调用与文件相关的open和close函数。只有readdir需要调用获取目录内容的特殊系统调用sys\_getdirentry。而且这里没有写目录这一操作。在目录中增加内容其实就是在此目录中创建文件,需要用到创建文件的函数。 + +**文件和目录访问相关系统调用** + +与文件相关的open、close、read、write用户库函数对应的是sys\_open、sys\_close、sys\_read、sys\_write四个系统调用接口。与目录相关的readdir用户库函数对应的是sys\_getdirentry系统调用。这些系统调用函数接口将通过syscall函数来获得ucore的内核服务。当到了ucore内核后,在调用文件系统抽象层的file接口和dir接口。 + +### 3.3 Simple FS 文件系统 + +这里我们没有按照从上到下先讲文件系统抽象层,再讲具体的文件系统。这是由于如果能够理解Simple +FS(简称SFS)文件系统,就可更好地分析文件系统抽象层的设计。即从具体走向抽象。ucore内核把所有文件都看作是字节流,任何内部逻辑结构都是专用的,由应用程序负责解释。但是ucore区分文件的物理结构。ucore目前支持如下几种类型的文件: + +* 常规文件:文件中包括的内容信息是由应用程序输入。SFS文件系统在普通文件上不强加任何内部结构,把其文件内容信息看作为字节。 +* 目录:包含一系列的entry,每个entry包含文件名和指向与之相关联的索引节点(index +node)的指针。目录是按层次结构组织的。 +* 链接文件:实际上一个链接文件是一个已经存在的文件的另一个可选择的文件名。 +* 设备文件:不包含数据,但是提供了一个映射物理设备(如串口、键盘等)到一个文件名的机制。可通过设备文件访问外围设备。 +* 管道:管道是进程间通讯的一个基础设施。管道缓存了其输入端所接受的数据,以便在管道输出端读的进程能一个先进先出的方式来接受数据。 + +在lab8中关注的主要是SFS支持的常规文件、目录和链接中的 hardlink +的设计实现。SFS文件系统中目录和常规文件具有共同的属性,而这些属性保存在索引节点中。SFS通过索引节点来管理目录和常规文件,索引节点包含操作系统所需要的关于某个文件的关键信息,比如文件的属性、访问许可权以及其它控制信息都保存在索引节点中。可以有多个文件名可指向一个索引节点。 + +#### 3.3.1 文件系统的布局 + +文件系统通常保存在磁盘上。在本实验中,第三个磁盘(即disk0,前两个磁盘分别是 +ucore.img 和 swap.img)用于存放一个SFS文件系统(Simple +Filesystem)。通常文件系统中,磁盘的使用是以扇区(Sector)为单位的,但是为了实现简便,SFS +中以 block (4K,与内存 page 大小相等)为基本单位。 + +SFS文件系统的布局如下图所示。 + +![image](lab8.files/image003.png) + +第0个块(4K)是超级块(superblock),它包含了关于文件系统的所有关键参数,当计算机被启动或文件系统被首次接触时,超级块的内容就会被装入内存。其定义如下: +``` +struct sfs_super { + uint32_t magic; /* magic number, should be SFS_MAGIC */ + uint32_t blocks; /* # of blocks in fs */ + uint32_t unused_blocks; /* # of unused blocks in fs */ + char info[SFS_MAX_INFO_LEN + 1]; /* infomation for sfs */ +}; +``` +可以看到,包含一个成员变量魔数magic,其值为0x2f8dbe2a,内核通过它来检查磁盘镜像是否是合法的 +SFS img;成员变量blocks记录了SFS中所有block的数量,即 img +的大小;成员变量unused\_block记录了SFS中还没有被使用的block的数量;成员变量info包含了字符串"simple +file system"。 + +第1个块放了一个root-dir的inode,用来记录根目录的相关信息。有关inode还将在后续部分介绍。这里只要理解root-dir是SFS文件系统的根结点,通过这个root-dir的inode信息就可以定位并查找到根目录下的所有文件信息。 + +从第2个块开始,根据SFS中所有块的数量,用1个bit来表示一个块的占用和未被占用的情况。这个区域称为SFS的freemap区域,这将占用若干个块空间。为了更好地记录和管理freemap区域,专门提供了两个文件kern/fs/sfs/bitmap.[ch]来完成根据一个块号查找或设置对应的bit位的值。 + +最后在剩余的磁盘空间中,存放了所有其他目录和文件的inode信息和内容数据信息。需要注意的是虽然inode的大小小于一个块的大小(4096B),但为了实现简单,每个 +inode 都占用一个完整的 block。 + +在sfs\_fs.c文件中的sfs\_do\_mount函数中,完成了加载位于硬盘上的SFS文件系统的超级块superblock和freemap的工作。这样,在内存中就有了SFS文件系统的全局信息。 + +#### 3.3.2 索引节点 + +**磁盘索引节点** + +SFS中的磁盘索引节点代表了一个实际位于磁盘上的文件。首先我们看看在硬盘上的索引节点的内容: +``` +struct sfs_disk_inode { + uint32_t size; 如果inode表示常规文件,则size是文件大小 + uint16_t type; inode的文件类型 + uint16_t nlinks; 此inode的硬链接数 + uint32_t blocks; 此inode的数据块数的个数 + uint32_t direct[SFS_NDIRECT]; 此inode的直接数据块索引值(有SFS_NDIRECT个) + uint32_t indirect; 此inode的一级间接数据块索引值 +}; +``` +通过上表可以看出,如果inode表示的是文件,则成员变量direct[]直接指向了保存文件内容数据的数据块索引值。indirect间接指向了保存文件内容数据的数据块,indirect指向的是间接数据块(indirect +block),此数据块实际存放的全部是数据块索引,这些数据块索引指向的数据块才被用来存放文件内容数据。 + +默认的,ucore 里 SFS\_NDIRECT 是 12,即直接索引的数据页大小为 12 \* 4k = +48k;当使用一级间接数据块索引时,ucore 支持最大的文件大小为 12 \* 4k + +1024 \* 4k = 48k + 4m。数据索引表内,0 表示一个无效的索引,inode 里 blocks +表示该文件或者目录占用的磁盘的 block 的个数。indiret 为 0 +时,表示不使用一级索引块。(因为 block 0 用来保存 super +block,它不可能被其他任何文件或目录使用,所以这么设计也是合理的)。 + +对于普通文件,索引值指向的 block +中保存的是文件中的数据。而对于目录,索引值指向的数据保存的是目录下所有的文件名以及对应的索引节点所在的索引块(磁盘块)所形成的数组。数据结构如下: +``` +/* file entry (on disk) */ +struct sfs_disk_entry { + uint32_t ino; 索引节点所占数据块索引值 + char name[SFS_MAX_FNAME_LEN + 1]; 文件名 +}; +``` +操作系统中,每个文件系统下的 inode 都应该分配唯一的 inode 编号。SFS +下,为了实现的简便(偷懒),每个 inode 直接用他所在的磁盘 block 的编号作为 +inode 编号。比如,root block 的 inode 编号为 1;每个 sfs\_disk\_entry +数据结构中,name 表示目录下文件或文件夹的名称,ino 表示磁盘 block +编号,通过读取该 block 的数据,能够得到相应的文件或文件夹的 inode。ino 为0 +时,表示一个无效的 entry。 + +此外,和 inode 相似,每个 sfs\_dirent\_entry 也占用一个 block。 + +**内存中的索引节点** +``` +/* inode for sfs */ +struct sfs_inode { + struct sfs_disk_inode *din; /* on-disk inode */ + uint32_t ino; /* inode number */ + uint32_t flags; /* inode flags */ + bool dirty; /* true if inode modified */ + int reclaim_count; /* kill inode if it hits zero */ + semaphore_t sem; /* semaphore for din */ + list_entry_t inode_link; /* entry for linked-list in sfs_fs */ + list_entry_t hash_link; /* entry for hash linked-list in sfs_fs */ +}; +``` +可以看到SFS中的内存inode包含了SFS的硬盘inode信息,而且还增加了其他一些信息,这属于是便于进行是判断否改写、互斥操作、回收和快速地定位等作用。需要注意,一个内存inode是在打开一个文件后才创建的,如果关机则相关信息都会消失。而硬盘inode的内容是保存在硬盘中的,只是在进程需要时才被读入到内存中,用于访问文件或目录的具体内容数据 + +为了方便实现上面提到的多级数据的访问以及目录中 entry 的操作,对 inode +SFS实现了一些辅助的函数: + +1. sfs\_bmap\_load\_nolock:将对应 sfs\_inode 的第 index 个索引指向的 +block 的索引值取出存到相应的指针指向的单元(ino\_store)。该函数只接受 +index <= inode-\>blocks 的参数。当 index == inode-\>blocks +时,该函数理解为需要为 inode 增长一个 block。并标记 inode 为 dirty(所有对 +inode 数据的修改都要做这样的操作,这样,当 inode 不再使用的时候,sfs +能够保证 inode 数据能够被写回到磁盘)。sfs\_bmap\_load\_nolock 调用的 +sfs\_bmap\_get\_nolock 来完成相应的操作,阅读 +sfs\_bmap\_get\_nolock,了解他是如何工作的。(sfs\_bmap\_get\_nolock 只由 +sfs\_bmap\_load\_nolock 调用) +2. sfs\_bmap\_truncate\_nolock:将多级数据索引表的最后一个 entry +释放掉。他可以认为是 sfs\_bmap\_load\_nolock 中,index == inode-\>blocks +的逆操作。当一个文件或目录被删除时,sfs 会循环调用该函数直到 +inode-\>blocks 减为 0,释放所有的数据页。函数通过 sfs\_bmap\_free\_nolock +来实现,他应该是 sfs\_bmap\_get\_nolock 的逆操作。和 +sfs\_bmap\_get\_nolock 一样,调用 sfs\_bmap\_free\_nolock 也要格外小心。 +3. sfs\_dirent\_read\_nolock:将目录的第 slot 个 entry +读取到指定的内存空间。他通过上面提到的函数来完成。 +4. sfs\_dirent\_write\_nolock:用指定的 entry 来替换某个目录下的第 slot +个entry。他通过调用 sfs\_bmap\_load\_nolock保证,当第 slot 个entry +不存在时(slot == inode-\>blocks),SFS 会分配一个新的 +entry,即在目录尾添加了一个 entry。 +5. sfs\_dirent\_search\_nolock:是常用的查找函数。他在目录下查找 +name,并且返回相应的搜索结果(文件或文件夹)的 inode +的编号(也是磁盘编号),和相应的 entry 在该目录的 index +编号以及目录下的数据页是否有空闲的 entry。(SFS +实现里文件的数据页是连续的,不存在任何空洞;而对于目录,数据页不是连续的,当某个 +entry 删除的时候,SFS 通过设置 entry-\>ino 为0将该 entry 所在的 block +标记为 free,在需要添加新 entry 的时候,SFS 优先使用这些 free 的 +entry,其次才会去在数据页尾追加新的 entry。 + +注意,这些后缀为 nolock 的函数,只能在已经获得相应 inode 的semaphore +才能调用。 + +**Inode的文件操作函数** +``` +static const struct inode_ops sfs_node_fileops = { + .vop_magic = VOP_MAGIC, + .vop_open = sfs_openfile, + .vop_close = sfs_close, + .vop_read = sfs_read, + .vop_write = sfs_write, + …… +}; +``` +上述sfs\_openfile、sfs\_close、sfs\_read和sfs\_write分别对应用户进程发出的open、close、read、write操作。其中sfs\_openfile不用做什么事;sfs\_close需要把对文件的修改内容写回到硬盘上,这样确保硬盘上的文件内容数据是最新的;sfs\_read和sfs\_write函数都调用了一个函数sfs\_io,并最终通过访问硬盘驱动来完成对文件内容数据的读写。 + +**Inode的目录操作函数** +``` +static const struct inode_ops sfs_node_dirops = { + .vop_magic = VOP_MAGIC, + .vop_open = sfs_opendir, + .vop_close = sfs_close, + .vop_getdirentry = sfs_getdirentry, +.vop_lookup = sfs_lookup, + …… +}; +``` +对于目录操作而言,由于目录也是一种文件,所以sfs\_opendir、sys\_close对应户进程发出的open、close函数。相对于sfs\_open,sfs\_opendir只是完成一些open函数传递的参数判断,没做其他更多的事情。目录的close操作与文件的close操作完全一致。由于目录的内容数据与文件的内容数据不同,所以读出目录的内容数据的函数是sfs\_getdirentry,其主要工作是获取目录下的文件inode信息。 + +### 3.4 文件系统抽象层 -VFS + +文件系统抽象层是把不同文件系统的对外共性接口提取出来,形成一个函数指针数组,这样,通用文件系统访问接口层只需访问文件系统抽象层,而不需关心具体文件系统的实现细节和接口。 + +#### 3.4.1 file&dir接口 + +file&dir接口层定义了进程在内核中直接访问的文件相关信息,这定义在file数据结构中,具体描述如下: +``` +struct file { + enum { + FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED, + } status; //访问文件的执行状态 + bool readable; //文件是否可读 + bool writable; //文件是否可写 + int fd; //文件在filemap中的索引值 + off_t pos; //访问文件的当前位置 + struct inode *node; //该文件对应的内存inode指针 + atomic_t open_count; //打开此文件的次数 +}; +``` +而在kern/process/proc.h中的proc\_struct结构中描述了进程访问文件的数据接口 +fs\_struct,其数据结构定义如下: +``` +struct fs_struct { + struct inode *pwd; //进程当前执行目录的内存inode指针 + struct file *filemap; //进程打开文件的数组 + atomic_t fs_count; //访问此文件的线程个数?? + semaphore_t fs_sem; //确保对进程控制块中fs_struct的互斥访问 +}; +``` +当创建一个进程后,该进程的fs\_struct将会被初始化或复制父进程的fs\_struct。当用户进程打开一个文件时,将从filemap数组中取得一个空闲file项,然后会把此file的成员变量node指针指向一个代表此文件的inode的起始地址。 + +#### 3.4.2 inode 接口 + +index +node是位于内存的索引节点,它是VFS结构中的重要数据结构,因为它实际负责把不同文件系统的特定索引节点信息(甚至不能算是一个索引节点)统一封装起来,避免了进程直接访问具体文件系统。其定义如下: +``` +struct inode { + union { //包含不同文件系统特定inode信息的union成员变量 + struct device __device_info; //设备文件系统内存inode信息 + struct sfs_inode __sfs_inode_info; //SFS文件系统内存inode信息 + } in_info; + enum { + inode_type_device_info = 0x1234, + inode_type_sfs_inode_info, + } in_type; //此inode所属文件系统类型 + atomic_t ref_count; //此inode的引用计数 + atomic_t open_count; //打开此inode对应文件的个数 + struct fs *in_fs; //抽象的文件系统,包含访问文件系统的函数指针 + const struct inode_ops *in_ops; //抽象的inode操作,包含访问inode的函数指针 +}; +``` +在inode中,有一成员变量为in\_ops,这是对此inode的操作函数指针列表,其数据结构定义如下: +``` +struct inode_ops { + unsigned long vop_magic; + int (*vop_open)(struct inode *node, uint32_t open_flags); + int (*vop_close)(struct inode *node); + int (*vop_read)(struct inode *node, struct iobuf *iob); + int (*vop_write)(struct inode *node, struct iobuf *iob); + int (*vop_getdirentry)(struct inode *node, struct iobuf *iob); + int (*vop_create)(struct inode *node, const char *name, bool excl, struct inode **node_store); +int (*vop_lookup)(struct inode *node, char *path, struct inode **node_store); +…… + }; +``` +参照上面对SFS中的索引节点操作函数的说明,可以看出inode\_ops是对常规文件、目录、设备文件所有操作的一个抽象函数表示。对于某一具体的文件系统中的文件或目录,只需实现相关的函数,就可以被用户进程访问具体的文件了,且用户进程无需了解具体文件系统的实现细节。 + +### 3.5 设备层文件 IO 层 + +在本实验中,为了统一地访问设备,我们可以把一个设备看成一个文件,通过访问文件的接口来访问设备。目前实现了stdin设备文件文件、stdout设备文件、disk0设备。stdin设备就是键盘,stdout设备就是CONSOLE(串口、并口和文本显示器),而disk0设备是承载SFS文件系统的磁盘设备。下面我们逐一分析ucore是如何让用户把设备看成文件来访问。 + +#### 3.5.1 关键数据结构 + +为了表示一个设备,需要有对应的数据结构,ucore为此定义了struct +device,其描述如下: +``` +struct device { + size_t d_blocks; //设备占用的数据块个数 + size_t d_blocksize; //数据块的大小 + int (*d_open)(struct device *dev, uint32_t open_flags); //打开设备的函数指针 + int (*d_close)(struct device *dev); //关闭设备的函数指针 + int (*d_io)(struct device *dev, struct iobuf *iob, bool write); //读写设备的函数指针 + int (*d_ioctl)(struct device *dev, int op, void *data); //用ioctl方式控制设备的函数指针 +}; +``` +这个数据结构能够支持对块设备(比如磁盘)、字符设备(比如键盘、串口)的表示,完成对设备的基本操作。ucore虚拟文件系统为了把这些设备链接在一起,还定义了一个设备链表,即双向链表vdev\_list,这样通过访问此链表,可以找到ucore能够访问的所有设备文件。 + +但这个设备描述没有与文件系统以及表示一个文件的inode数据结构建立关系,为此,还需要另外一个数据结构把device和inode联通起来,这就是vfs\_dev\_t数据结构: +``` +// device info entry in vdev_list +typedef struct { + const char *devname; + struct inode *devnode; + struct fs *fs; + bool mountable; + list_entry_t vdev_link; +} vfs_dev_t; +``` +利用vfs\_dev\_t数据结构,就可以让文件系统通过一个链接vfs\_dev\_t结构的双向链表找到device对应的inode数据结构,一个inode节点的成员变量in\_type的值是0x1234,则此 +inode的成员变量in\_info将成为一个device结构。这样inode就和一个设备建立了联系,这个inode就是一个设备文件。 + +#### 3.5.2 stdout设备文件 + +**初始化** + +既然stdout设备是设备文件系统的文件,自然有自己的inode结构。在系统初始化时,即只需如下处理过程 +``` +kern_init-->fs_init-->dev_init-->dev_init_stdout --> dev_create_inode + --> stdout_device_init + --> vfs_add_dev +``` +在dev\_init\_stdout中完成了对stdout设备文件的初始化。即首先创建了一个inode,然后通过stdout\_device\_init完成对inode中的成员变量inode-\>\_\_device\_info进行初始: + +这里的stdout设备文件实际上就是指的console外设(它其实是串口、并口和CGA的组合型外设)。这个设备文件是一个只写设备,如果读这个设备,就会出错。接下来我们看看stdout设备的相关处理过程。 + +**初始化** + +stdout设备文件的初始化过程主要由stdout\_device\_init完成,其具体实现如下: +``` +static void +stdout_device_init(struct device *dev) { + dev->d_blocks = 0; + dev->d_blocksize = 1; + dev->d_open = stdout_open; + dev->d_close = stdout_close; + dev->d_io = stdout_io; + dev->d_ioctl = stdout_ioctl; +} +``` +可以看到,stdout\_open函数完成设备文件打开工作,如果发现用户进程调用open函数的参数flags不是只写(O\_WRONLY),则会报错。 + +**访问操作实现** + +stdout\_io函数完成设备的写操作工作,具体实现如下: +``` +static int +stdout_io(struct device *dev, struct iobuf *iob, bool write) { + if (write) { + char *data = iob->io_base; + for (; iob->io_resid != 0; iob->io_resid --) { + cputchar(*data ++); + } + return 0; + } + return -E_INVAL; +} +``` +可以看到,要写的数据放在iob-\>io\_base所指的内存区域,一直写到iob-\>io\_resid的值为0为止。每次写操作都是通过cputchar来完成的,此函数最终将通过console外设驱动来完成把数据输出到串口、并口和CGA显示器上过程。另外,也可以注意到,如果用户想执行读操作,则stdout\_io函数直接返回错误值**-**E\_INVAL。 + +#### 3.5.3 stdin 设备文件 + +这里的stdin设备文件实际上就是指的键盘。这个设备文件是一个只读设备,如果写这个设备,就会出错。接下来我们看看stdin设备的相关处理过程。 + +**初始化** + +stdin设备文件的初始化过程主要由stdin\_device\_init完成了主要的初始化工作,具体实现如下: +``` +static void +stdin_device_init(struct device *dev) { + dev->d_blocks = 0; + dev->d_blocksize = 1; + dev->d_open = stdin_open; + dev->d_close = stdin_close; + dev->d_io = stdin_io; + dev->d_ioctl = stdin_ioctl; + + p_rpos = p_wpos = 0; + wait_queue_init(wait_queue); +} +``` +相对于stdout的初始化过程,stdin的初始化相对复杂一些,多了一个stdin\_buffer缓冲区,描述缓冲区读写位置的变量p\_rpos、p\_wpos以及用于等待缓冲区的等待队列wait\_queue。在stdin\_device\_init函数的初始化中,也完成了对p\_rpos、p\_wpos和wait\_queue的初始化。 + +**访问操作实现** + +stdin\_io函数负责完成设备的读操作工作,具体实现如下: +``` +static int +stdin_io(struct device *dev, struct iobuf *iob, bool write) { + if (!write) { + int ret; + if ((ret = dev_stdin_read(iob->io_base, iob->io_resid)) > 0) { + iob->io_resid -= ret; + } + return ret; + } + return -E_INVAL; +} +``` + +可以看到,如果是写操作,则stdin\_io函数直接报错返回。所以这也进一步说明了此设备文件是只读文件。如果此读操作,则此函数进一步调用dev\_stdin\_read函数完成对键盘设备的读入操作。dev\_stdin\_read函数的实现相对复杂一些,主要的流程如下: +``` +static int +dev_stdin_read(char *buf, size_t len) { + int ret = 0; + bool intr_flag; + local_intr_save(intr_flag); + { + for (; ret < len; ret ++, p_rpos ++) { + try_again: + if (p_rpos < p_wpos) { + *buf ++ = stdin_buffer[p_rpos % stdin_BUFSIZE]; + } + else { + wait_t __wait, *wait = &__wait; + wait_current_set(wait_queue, wait, WT_KBD); + local_intr_restore(intr_flag); + + schedule(); + + local_intr_save(intr_flag); + wait_current_del(wait_queue, wait); + if (wait->wakeup_flags == WT_KBD) { + goto try_again; + } + break; + } + } + } + local_intr_restore(intr_flag); + return ret; +} +``` + +在上述函数中可以看出,如果p\_rpos < +p\_wpos,则表示有键盘输入的新字符在stdin\_buffer中,于是就从stdin\_buffer中取出新字符放到iobuf指向的缓冲区中;如果p\_rpos +\>=p\_wpos,则表明没有新字符,这样调用read用户态库函数的用户进程就需要采用等待队列的睡眠操作进入睡眠状态,等待键盘输入字符的产生。 + +键盘输入字符后,如何唤醒等待键盘输入的用户进程呢?回顾lab1中的外设中断处理,可以了解到,当用户敲击键盘时,会产生键盘中断,在trap\_dispatch函数中,当识别出中断是键盘中断(中断号为IRQ\_OFFSET + IRQ\_KBD)时,会调用dev\_stdin\_write函数,来把字符写入到stdin\_buffer中,且会通过等待队列的唤醒操作唤醒正在等待键盘输入的用户进程。 + +### 3.6 实验执行流程概述 + +与实验七相比,实验八增加了文件系统,并因此实现了通过文件系统来加载可执行文件到内存中运行的功能,导致对进程管理相关的实现比较大的调整。我们来简单看看文件系统是如何初始化并能在ucore的管理下正常工作的。 + +首先看看kern\_init函数,可以发现与lab7相比增加了对fs\_init函数的调用。fs\_init函数就是文件系统初始化的总控函数,它进一步调用了虚拟文件系统初始化函数vfs\_init,与文件相关的设备初始化函数dev\_init和Simple +FS文件系统的初始化函数sfs\_init。这三个初始化函数联合在一起,协同完成了整个虚拟文件系统、SFS文件系统和文件系统对应的设备(键盘、串口、磁盘)的初始化工作。其函数调用关系图如下所示: + +![image](lab8.files/image004.png) + +文件系统初始化调用关系图 + +参考上图,并结合源码分析,可大致了解到文件系统的整个初始化流程。vfs\_init主要建立了一个device +list双向链表vdev\_list,为后续具体设备(键盘、串口、磁盘)以文件的形式呈现建立查找访问通道。dev\_init函数通过进一步调用disk0/stdin/stdout\_device\_init完成对具体设备的初始化,把它们抽象成一个设备文件,并建立对应的inode数据结构,最后把它们链入到vdev\_list中。这样通过虚拟文件系统就可以方便地以文件的形式访问这些设备了。sfs\_init是完成对Simple +FS的初始化工作,并把此实例文件系统挂在虚拟文件系统中,从而让ucore的其他部分能够通过访问虚拟文件系统的接口来进一步访问到SFS实例文件系统。 + +### 3.7 文件操作实现 + +#### 3.7.1 打开文件 + +有了上述分析后,我们可以看看如果一个用户进程打开文件会做哪些事情?首先假定用户进程需要打开的文件已经存在在硬盘上。以user/sfs\_filetest1.c为例,首先用户进程会调用在main函数中的如下语句: +``` +int fd1 = safe_open("/test/testfile", O_RDWR | O_TRUNC); +``` +从字面上可以看出,如果ucore能够正常查找到这个文件,就会返回一个代表文件的文件描述符fd1,这样在接下来的读写文件过程中,就直接用这样fd1来代表就可以了。那这个打开文件的过程是如何一步一步实现的呢? + +**通用文件访问接口层的处理流程** + +首先进入通用文件访问接口层的处理流程,即进一步调用如下用户态函数: +open-\>sys\_open-\>syscall, +从而引起系统调用进入到内核态。到了内核态后,通过中断处理例程,会调用到sys\_open内核函数,并进一步调用sysfile\_open内核函数。到了这里,需要把位于用户空间的字符串"/test/testfile"拷贝到内核空间中的字符串path中,并进入到文件系统抽象层的处理流程完成进一步的打开文件操作中。 + +**文件系统抽象层的处理流程** + +1. 分配一个空闲的file数据结构变量file +在文件系统抽象层的处理中,首先调用的是file\_open函数,它要给这个即将打开的文件分配一个file数据结构的变量,这个变量其实是当前进程的打开文件数组current-\>fs\_struct-\>filemap[]中的一个空闲元素(即还没用于一个打开的文件),而这个元素的索引值就是最终要返回到用户进程并赋值给变量fd1。到了这一步还仅仅是给当前用户进程分配了一个file数据结构的变量,还没有找到对应的文件索引节点。 + +为此需要进一步调用vfs\_open函数来找到path指出的文件所对应的基于inode数据结构的VFS索引节点node。vfs\_open函数需要完成两件事情:通过vfs\_lookup找到path对应文件的inode;调用vop\_open函数打开文件。 + +2. 找到文件设备的根目录“/”的索引节点 +需要注意,这里的vfs\_lookup函数是一个针对目录的操作函数,它会调用vop\_lookup函数来找到SFS文件系统中的“/test”目录下的“testfile”文件。为此,vfs\_lookup函数首先调用get\_device函数,并进一步调用vfs\_get\_bootfs函数(其实调用了)来找到根目录“/”对应的inode。这个inode就是位于vfs.c中的inode变量bootfs\_node。这个变量在init\_main函数(位于kern/process/proc.c)执行时获得了赋值。 + +3. 找到根目录“/”下的“test”子目录对应的索引节点 +在找到根目录对应的inode后,通过调用vop\_lookup函数来查找“/”和“test”这两层目录下的文件“testfile”所对应的索引节点,如果找到就返回此索引节点。 + +4. 把file和node建立联系 +完成第3步后,将返回到file\_open函数中,通过执行语句“file-\>node=node;”,就把当前进程的current-\>fs\_struct-\>filemap[fd](即file所指变量)的成员变量node指针指向了代表“/test/testfile”文件的索引节点node。这时返回fd。经过重重回退,通过系统调用返回,用户态的syscall-\>sys\_open-\>open-\>safe\_open等用户函数的层层函数返回,最终把把fd赋值给fd1。自此完成了打开文件操作。但这里我们还没有分析第2和第3步是如何进一步调用SFS文件系统提供的函数找位于SFS文件系统上的“/test/testfile”所对应的sfs磁盘inode的过程。下面需要进一步对此进行分析。 + +**SFS文件系统层的处理流程** + +这里需要分析文件系统抽象层中没有彻底分析的vop\_lookup函数到底做了啥。下面我们来看看。在sfs\_inode.c中的sfs\_node\_dirops变量定义了“.vop\_lookup += sfs\_lookup”,所以我们重点分析sfs\_lookup的实现。 + +sfs\_lookup有三个参数:node,path,node\_store。其中node是根目录“/”所对应的inode节点;path是文件“testfile”的绝对路径“/test/testfile”,而node\_store是经过查找获得的“testfile”所对应的inode节点。 + +Sfs\_lookup函数以“/”为分割符,从左至右逐一分解path获得各个子目录和最终文件对应的inode节点。在本例中是分解出“test”子目录,并调用sfs\_lookup\_once函数获得“test”子目录对应的inode节点subnode,然后循环进一步调用sfs\_lookup\_once查找以“test”子目录下的文件“testfile1”所对应的inode节点。当无法分解path后,就意味着找到了testfile1对应的inode节点,就可顺利返回了。 + +当然这里讲得还比较简单,sfs\_lookup\_once将调用sfs\_dirent\_search\_nolock函数来查找与路径名匹配的目录项,如果找到目录项,则根据目录项中记录的inode所处的数据块索引值找到路径名对应的SFS磁盘inode,并读入SFS磁盘inode对的内容,创建SFS内存inode。 + +#### 3.7.2 读文件 + +读文件其实就是读出目录中的目录项,首先假定文件在磁盘上且已经打开。用户进程有如下语句: +``` +read(fd, data, len); +``` +即读取fd对应文件,读取长度为len,存入data中。下面来分析一下读文件的实现。 + +**通用文件访问接口层的处理流程** + +先进入通用文件访问接口层的处理流程,即进一步调用如下用户态函数:read-\>sys\_read-\>syscall,从而引起系统调用进入到内核态。到了内核态以后,通过中断处理例程,会调用到sys\_read内核函数,并进一步调用sysfile\_read内核函数,进入到文件系统抽象层处理流程完成进一步读文件的操作。 + +**文件系统抽象层的处理流程** + +1) 检查错误,即检查读取长度是否为0和文件是否可读。 + +2) 分配buffer空间,即调用kmalloc函数分配4096字节的buffer空间。 + +3) 读文件过程 + +[1] 实际读文件 +循环读取文件,每次读取buffer大小。每次循环中,先检查剩余部分大小,若其小于4096字节,则只读取剩余部分的大小。然后调用file\_read函数(详细分析见后)将文件内容读取到buffer中,alen为实际大小。调用copy\_to\_user函数将读到的内容拷贝到用户的内存空间中,调整各变量以进行下一次循环读取,直至指定长度读取完成。最后函数调用层层返回至用户程序,用户程序收到了读到的文件内容。 + +[2] file\_read函数 +这个函数是读文件的核心函数。函数有4个参数,fd是文件描述符,base是缓存的基地址,len是要读取的长度,copied\_store存放实际读取的长度。函数首先调用fd2file函数找到对应的file结构,并检查是否可读。调用filemap\_acquire函数使打开这个文件的计数加1。调用vop\_read函数将文件内容读到iob中(详细分析见后)。调整文件指针偏移量pos的值,使其向后移动实际读到的字节数iobuf\_used(iob)。最后调用filemap\_release函数使打开这个文件的计数减1,若打开计数为0,则释放file。 + +**SFS文件系统层的处理流程** + +vop\_read函数实际上是对sfs\_read的包装。在sfs\_inode.c中sfs\_node\_fileops变量定义了.vop\_read += sfs\_read,所以下面来分析sfs\_read函数的实现。 + +sfs\_read函数调用sfs\_io函数。它有三个参数,node是对应文件的inode,iob是缓存,write表示是读还是写的布尔值(0表示读,1表示写),这里是0。函数先找到inode对应sfs和sin,然后调用sfs\_io\_nolock函数进行读取文件操作,最后调用iobuf\_skip函数调整iobuf的指针。 + +在sfs\_io\_nolock函数中,先计算一些辅助变量,并处理一些特殊情况(比如越界),然后有sfs\_buf\_op += sfs\_rbuf,sfs\_block\_op = +sfs\_rblock,设置读取的函数操作。接着进行实际操作,先处理起始的没有对齐到块的部分,再以块为单位循环处理中间的部分,最后处理末尾剩余的部分。每部分中都调用sfs\_bmap\_load\_nolock函数得到blkno对应的inode编号,并调用sfs\_rbuf或sfs\_rblock函数读取数据(中间部分调用sfs\_rblock,起始和末尾部分调用sfs\_rbuf),调整相关变量。完成后如果offset ++ alen \> +din-\>fileinfo.size(写文件时会出现这种情况,读文件时不会出现这种情况,alen为实际读写的长度),则调整文件大小为offset ++ alen并设置dirty变量。 + +sfs\_bmap\_load\_nolock函数将对应sfs\_inode的第index个索引指向的block的索引值取出存到相应的指针指向的单元(ino\_store)。它调用sfs\_bmap\_get\_nolock来完成相应的操作。sfs\_rbuf和sfs\_rblock函数最终都调用sfs\_rwblock\_nolock函数完成操作,而sfs\_rwblock\_nolock函数调用dop\_io-\>disk0\_io-\>disk0\_read\_blks\_nolock-\>ide\_read\_secs完成对磁盘的操作。 + +## 4. 实验报告要求 + + +从网站上下载lab8.zip后,解压得到本文档和代码目录 +lab8,完成实验中的各个练习。完成代码编写并检查无误后,在对应目录下执行 +make handin 任务,即会自动生成 +lab8-handin.tar.gz。最后请一定提前或按时提交到网络学堂上。 + +注意有“LAB8”的注释,这是需要主要修改的内容。代码中所有需要完成的地方challenge除外)都有“LAB8”和“YOUR +CODE”的注释,请在提交时特别注意保持注释,并将“YOUR +CODE”替换为自己的学号,并且将所有标有对应注释的部分填上正确的代码。