add markdown

This commit is contained in:
bqh1989 2014-10-08 09:21:17 +08:00
parent 98b4bb25da
commit af97b7e96b
7 changed files with 4529 additions and 0 deletions

978
lab2.md Normal file
View File

@ -0,0 +1,978 @@
# 实验二:物理内存管理
## 1 实验目的
* 理解基于段页式内存地址的转换机制
* 理解页表的建立和使用方法
* 理解物理内存的管理方法
## 2 实验内容
实验一过后大家做出来了一个可以启动的系统,实验二主要涉及操作系统的物理内存管理。操作系统为了使用内存,还需高效地管理内存资源。在实验二中大家会了解并且自己动手完成一个简单的物理内存管理系统。
本次实验包含三个部分。首先了解如何发现系统中的物理内存;然后了解如何建立对物理内存的初步管理,即了解连续物理内存管理;最后了解页表相关的操作,即如何建立页表来实现虚拟内存到物理内存之间的映射,对段页式内存管理机制有一个比较全面的了解。本实验里面实现的内存管理还是非常基本的,并没有涉及到对实际机器的优化,比如
针对 cache 的优化等。实际操作系统如Linux等中的内存管理是相当复杂的。
如果大家有余力,尝试完成扩展练习。
### 2.1 练习
**练习0填写已有实验**
本实验依赖实验1。请把你做的实验1的代码填入本实验中代码中有“LAB1”的注释相应部分。提示可采用merge工具比如kdiff3eclipse中的diff/merge工具understand中的diff/merge工具等。
**练习1实现 first-fit 连续物理内存分配算法(需要编程)**
在实现first fit
内存分配算法的回收函数时,要考虑地址连续的空闲块之间的合并操作。提示:在建立空闲页块链表时需要按照空闲页块起始地址来排序形成一个有序的链表。可能会修改default\_pmm.c中的default\_initdefault\_init\_memmapdefault\_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.ldld形成执行文件的地址所用到的链接脚本。修改了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数据段截止处地址、和enducore截止处地址的值后探测出计算机系统中的物理内存的布局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这就找到了如果<n则list\_next继续查找直到list\_next==
&free\_list这表示找完了一遍了。找到后就要从新组织空闲块然后把找到的page返回。所以default\_alloc\_pages可大致实现如下
```
static struct Page *
default_alloc_pages(size_t n) {
if (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\_cr3mm/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字节896MB3670016个物理页的物理地址一一映射到页目录表项和页表项的内容其大致流程如下
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对应的物理地址空间为04MB。这样对于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 =
物理地址空间0KMEMSIZE
### 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的结果
• urwPDE 表中所给出的权限位u表示用户可读即PTE\_Ur表示PTE\_Pw表示用
户可写即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中断的详细调用参数:
```
eaxe820hINT 15的中断调用参数
edx534D4150h (即4个ASCII字符“SMAP”) ,这只是一个签名而已;
ebx如果是第一次调用或内存区域扫描完毕则为0。 如果不是,则存放上次调用之后的计数值;
ecx保存地址范围描述符的内存大小,应该大于等于20字节
es:di指向保存地址范围描述符结构的缓冲区BIOS把信息写入这个结构的起始地址。
```
此中断的返回值为:
```
cflags的CF位若INT 15中断执行成功则不置位否则置位
eax534D4150h ('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文件中定义。

588
lab3.md Normal file
View File

@ -0,0 +1,588 @@
# 实验三:虚拟内存管理
## 1. 实验目的
* 了解虚拟内存的Page Fault异常处理实现
* 了解页替换算法在操作系统中的实现
## 2. 实验内容
做完实验二后大家可以了解并掌握物理内存管理中的连续空间分配算法的具体实现以及如何建立二级页表。本次实验是在实验二的基础上借助于页表机制和实验一中涉及的中断异常处理机制完成Page
Fault异常处理和FIFO页替换算法的实现结合磁盘提供的缓存空间从而能够支持虚存管理提供一个比实际物理内存空间“更大”的虚拟内存空间给系统使用。实际操作系统系统中的虚拟内存管理设计与实现是相当复杂的涉及到与进程管理系统、文件系统等的交叉访问。如果大家有余力可以尝试完成扩展练习实现extended
clock页替换算法。
### 2.1 练习
#### 练习0填写已有实验
本实验依赖实验1/2。请把你做的实验1/2的代码填入本实验中代码中有“LAB1”,“LAB2”的注释相应部分。
#### 练习1给未被映射的地址映射上物理页需要编程
完成do\_pgfaultmm/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\_structvma\_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函数完成了对本次实验中的练习2FIFO页替换算法基本正确性的检查可了解便于知道为何产生错误。
* 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\_managerswap\_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 <vma-\>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为0bit 1
表示是否 write 操作。
产生页错误异常后CPU硬件和软件都会做一些事情来应对此事。首先页错误异常也是一种异常所以针对一般异常的硬件处理操作是必须要做的即CPU在当前内核栈保存当前被打断的程序现场即依次压入当前被打断程序使用的eflagscseiperrorCode由于页错误异常的中断号是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”。这样这两位就存在四种可能的组合情况00表示最近未被引用也未被修改首先选择此页淘汰01最近未被使用但被修改其次选择10最近使用而未修改再次选择11最近使用且修改最后选择。该算法与时钟算法相比可进一步减少磁盘的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
```
考虑到硬盘的最小访问单位是一个扇区而一个扇区的大小为5122\^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.
```

653
lab4.md Normal file
View File

@ -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 总是能够指向当前的
trapframeucore 在内核栈上维护了 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 FROMs
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 TOs contexts eip, so return addr = TOs eip
ret # after ret, eip= TOs eip
```
首先保存前一个进程的执行现场前两条汇编指令如下所示保存了进程在返回switch\_to函数后的指令地址到context.eip中
```
movl 4(%esp), %eax # eax points to from
popl 0(%eax) # esp--> return address, so save return addr in FROMs
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\_entryinitproc-\>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内核本身。理解了进程或线程的上述属性和特征就可以进行进程/线程管理的设计与实现了。但是为了叙述上的简便,以下用户态的进程/线程统称为用户进程。

563
lab5.md Normal file
View File

@ -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 <stdio.h>
#include <ulib.h>
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\_starthello执行码的起始位置
* \_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\_execsyscall.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传递给ucoreucore通过执行内核函数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. 与用户进程相关的系统调用
在本实验中,与进程相关的各个系统调用属性如下所示:
<table>
<tr><td>系统调用名</td><td>含义</td><td>具体完成服务的函数</td></tr>
<tr><td>SYS_exit</td><td>process exit</td><td>do_exit</td></tr>
<tr><td>SYS_fork</td><td>create child process, dup mm </td><td>do_fork-->wakeup_proc</td></tr>
<tr><td>SYS_wait</td><td>wait child process</td><td>do_wait</td></tr>
<tr><td>SYS_exec</td><td>after fork, process execute a new program</td><td>load a program and refresh the mm</td></tr>
<tr><td>SYS_yield</td><td>process flag itself need resecheduling</td><td>proc->need_sched=1, then scheduler will rescheule this process</td></tr>
<tr><td>SYS_kill</td><td>kill process</td><td>do_kill-->proc->flags |= PF_EXITING, -->wakeup_proc-->do_wait-->do_exit</td></tr>
<tr><td>SYS_getpid</td><td>get the process's pid</td><td> </td></tr>
</table>
通过这些系统调用,可方便地完成从进程/线程创建到退出的整个运行过程。
#### 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态。这样进程的整个状态转换形成了一个有限状态自动机。

557
lab6.md Normal file
View File

@ -0,0 +1,557 @@
# 实验六: 调度器
## 1 实验目的
* 理解操作系统的调度管理机制
* 熟悉 ucore 的系统调度器框架以及缺省的Round-Robin 调度算法
* 基于调度器框架实现一个(Stride Scheduling)调度算法来替换缺省的调度算法
## 2 实验内容
实验五完成了用户进程的管理可在用户态运行多个进程。但到目前为止采用的调度策略是很简单的FIFO调度策略。本次实验主要是熟悉ucore的系统调度器框架以及基于此框架的
Round-RobinRR 调度算法。然后参考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的位置和原因
<table>
<tr><td>编号</td><td>位置</td><td>原因</td></tr>
<tr><td>1</td><td>proc.c::do_exit</td><td>用户线程执行结束主动放弃CPU控制权。</td></tr>
<tr><td>2</td><td>proc.c::do_wait</td><td>用户线程等待子进程结束主动放弃CPU控制权。</td></tr>
<tr><td>3</td><td>proc.c::init_main</td><td>1. initproc内核线程等待所有用户进程结束如果没有结束就主动放弃CPU控制权;
2. initproc内核线程在所有用户进程结束后让kswapd内核线程执行10次用于回收空闲内存资源</td></tr>
<tr><td>4</td><td>proc.c::cpu_idle</td><td>idleproc内核线程的工作就是等待有处于就绪态的进程或线程如果有就调用schedule函数</td></tr>
<tr><td>5</td><td>sync.h::lock</td><td>在获取锁的过程中如果无法得到锁则主动放弃CPU控制权</td></tr>
<tr><td>6</td><td>trap.c::trap</td><td>如果在当前进程在用户态被打断去且当前进程控制块的成员变量need_resched设置为1则当前线程会放弃CPU控制权</td></tr>
</table>
仔细分析上述位置第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当内核态处理中断时发现需要进行进程切换时即需要切换到进程Aucore再次切换到进程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和Bstride属性采用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>
```

457
lab7.md Normal file
View File

@ -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 didnt 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 didnt get fork and will wait
phi_take_forks_condvar: 0 didnt 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
```

733
lab8.md Normal file
View File

@ -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.cdisk0硬盘设备提供给文件系统的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-\>blocksSFS 会分配一个新的
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\_opensfs\_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有三个参数nodepathnode\_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是对应文件的inodeiob是缓存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”替换为自己的学号并且将所有标有对应注释的部分填上正确的代码。