CS 162 23Fall Lab 总结
PintOS是一个x86架构的教育用操作系统,它支持多线程,加载并运行用户程序,以及文件系统。骨架代码只提供了很简单的实现,本Lab需要丰富并增强这些模块的功能。本实验提供Bochs2和QEMU3模拟器模拟x86 CPU和相应外设来运行并调试PintOS.
PintOS源码结构:
threads/: PintOS的基础内核相关源码,包括bootloader,内核入口, 基础中断处 理,内存分配,CPU调度等。
userprog/: 用户程序支持相关源码,包括页表管理,系统调用处理,缺页处理, 各种traps,以及程序加载器。
filesys/: 文件系统相关源码,可以使用初始的接口进行文件操作,后续会进行扩 展以及支持树形目录等。
devices/: I/O设备接口相关源码,包括键盘,磁盘,时钟等等。
lib/: 部分C标准库相关源码,有kernel子目录和user子目录,分别被链接进内 核程序和用户程序。
PintOS启动过程:
1. BIOS将Pintos引导加载程序(threads/loader.S)从磁盘的第一个扇区读取到地址0x7c00的内存中。
2. 引导加载程序将内核代码从磁盘读取到地址0x20000的内存中,然后跳转到内核入口点(threads/start.s)。
3. 内核入口点的代码切换到32位保护模式1,然后调用main(threads/init.c)。我们称运行init.c的线程为OS的主线程。为了使其能运行用户程序,需要给它分配一个pcb来管理子进程,即使它不是一个进程。
4. OS主线程通过初始化线程调度器、内存子系统、中断向量、硬件设备和文件系统来启动Pintos,并根据命令行输入(argv)来加载用户程序或运行测试,运行完毕后shutdown.
PintOS内存模型:
虚拟地址一共4GB,其中0-PHYS_BASE(3GB)是用户空间,PHYS_BASE-4GB是内核空间。
内核空间是所有进程共享的,并且直接映射到物理地址,pa = va – PHYS_BASE. 即虚拟地址PHYS_BASE的物理地址是0.
用户空间每个进程不同,通过二级页表映射,在进程切换时由pagedir_activate()通过修改CR3寄存器(页目录基址寄存器)进行切换。
内核中分配内存以页为单位,通过内存池管理连续空闲物理帧,并且分为用户池和内核池,前者分给用户空间,后者分给内核空间。
CS162没有实现完整的虚拟内存,page_fault异常处理未实现。
Proj0: Introduction
程序启动时有一个段错误,是某一行代码的问题,调试一下找出来改了就好了,该项目只是让我们熟悉一下PintOS的运行和调试。
Proj1: User Programs
实现用户程序支持和浮点数操作。该proj涉及tcb,pcb,syscall,FPU管理等,此外还有task-state segment (tss) ,主要用于中断时栈的切换(用户态到内核态需要切换栈帧,而内核态到内核态不用,且CPU是先根据tss切换到内核栈再保存断点的)。使用tss而不用tcb保存栈信息的原因是tss是x86硬件支持的,而tcb不是。
Task1. Argument Passing
实现用户程序加载时的命令行参数传递,即argc和argv[]. 进程创建过程首先是 由当前进程执行process execute调用thread_create创建一个对应的内核线程并由该 线程调用start_process,然后在start_process中进行pcb、中断栈帧等的初始化,在 load可执行程序,在加载成功后通过移动中断栈帧中的esp并进行相应赋值将参数存 到栈中,最后通过中断返回指令将控制权转交给用户程序即可。
以下是参数在栈中的放置方式,注意x86由于call的时候esp必须与16字节对 齐,因此最后将argc传入后需要进行对齐。
Task2. Process Control Syscalls
实现进程控制相关系统调用。要注意系统调用和函数调用一样通过栈传递参数, 系统调用处理时要先检查参数是否合法,如是否空指针,是否访问了内核空间,是否 访问了为映射空间。系统调用的第一个参数是系统调用类型,根据该类型调用对应的 处理函数,并将返回值保存在中断栈帧的eax中。
exit()
进程退出系统调用。释放进程的各类资源,如页表,打开文件,子进程链表 等,此外如果父进程还在,需要signal对应的信号量,以便父进程进行wait,并 设置退出状态供父进程获取。
exec()
进程执行系统调用。功能相当于UNIX中的fork()+exec(),即创建新进程并执 行新的可执行程序。为了支持并发加载,设置一个全局的load_list,将待加载的 进程放入链表,并使用信号量对每个节点进行同步。当父进程创建了线程去加载 子进程时,父进程必须阻塞等待看是否加载成功。子进程加载完毕后唤醒父进 程,然后等待父进程将自己加入到其子进程链表中,才可以开始运行。
wait()
进程等待系统调用。等待子进程退出并获取其退出状态。通过子进程链表中 的信号量进行wait,然后获取退出状态,再将其从链表中移除即可。要注意每个 进程只能wait他的子进程,且子进程不存在继承关系,即不能wait孙子进程,否 则直接返回-1. 此外如果某个进程意外退出(kill),也要直接返回-1,因此在还要修 改userprog/exception.c中的kill()函数,将退出状态设为-1.
Task3. File Operation Syscalls
实现文件操作系统调用。骨架代码已经实现了基本的文件系统,因此这里直接调 用相应接口就好。注意这些接口不是线程安全的,因此要进行同步,这里直接加一把 全局文件操作锁。
create(), remove()
创建一个文件和删除一个文件,调用相应文件系统接口即可。要注意这里的 删除文件指的是将其从目录中删除,若该文件已被某进程打开,该进程还可继续 对文件进行读写,并且由于其已从目录中删除,其他进程无法再打开该文件。当 所有对应的文件描述符都关闭时该文件才真正消失。这一规则与UNIX一致。
open()
打开一个文件并返回一个文件描述符。文件描述符(fd)是一个非负整数,是下 图fd Table中元素的下标,每个元素都是一个struct file* 指针,指向一个struct file结构体,该结构体除了存储读写权限,偏移量外,还有成员struct Inode* 指 针指向对应的struct Inode,Inode对应唯一的文件,存储着该文件在磁盘中的位 置等信息。每次open一个文件,不管是不是同一进程,都会构造新的struct file,并返回新的fd. 并且在PintOS中子进程不会继承父进程的文件描述符表。要 注意文件描述符0 (STDIN_FILENO)和1 (STDOUT_FILENO)分别表示标准输入和标 准输出,不能被open()返回。
read(), write()
对fd对应的文件进行读写操作。若fd == STDIN_FILENO则从键盘读入,调 用devices/input.c中的input_getc();若fd == STDOUT_FILENO则输出到控制 台,调用lib/kernel/console.c中的putbuf().
seek(), tell()
设置或获取当前fd对应file的读写位置。要注意seek如果超出EOF需要对 文件进行扩展,这将在proj 3中实现。
close()
关闭fd对应的文件。调用file_close()释放struct file,file_close会调用 inode_close减少引用次数,当系统中该文件inode的引用次数减为0时才真正关 闭一个文件。
Task4. Floating Point Operations
实现支持浮点数操作。x86中浮点数没有专用寄存器来存储,而是使用栈来存 储。由于CPU中只有一个floating-point-unit (FPU),线程要共享它必须存储相关状态 FPU save state,共108字节,在线程切换或中断时进行状态切换,在线程切换栈帧和 中断栈帧中添加FPU save state并在switch.S和intr-subs.S中添加相应指令即可,这 和寄存器值等其他上下文类似。此外FPU save state还需要在线程/进程创建时进行初 始化,需要修改start process()中初始化中断栈帧的代码,这是寄存器切换不需要的。
Proj2: Threads
实现线程优先级调度和用户进程的多线程支持。PintOS每个线程在内核中都有一页内存用于存储它的TCB (struct thread)和内核栈,如下图所示。线程切换时会调用thread.c中的schedule(),根据调度算法获取下一个线程的tcb,并调用switch.S中的switch_thread()来进行上下文切换,这是一个汇编函数,在切换完成后当前线程也切换了,也就是说每次线程重新获得CPU都是从switch_thread()中返回,之后再调用thread.c中的thread_switch_tail()进行状态设置、页表切换等收尾操作。
另外PintOS是分时系统,在计时器中断处理函数中会查看时间片是否用完,用完了会在中断结束时让出CPU (intr_yield_on_return).
Task1. Efficient Alarm Clock
实现更高效的timer_sleep()函数。原来的实现是忙等(busy wait)直到时间到,可以 通过thread_block()将其阻塞并将其加入到sleep_list中,在每次时钟处理函数中检查 是否到时间,到了将其thread_unblock()即可。
Task2. Strict Priority Scheduler
实现线程的优先级调度。除了schedule()外还需要修改3个同步原语(互斥锁,信 号量,条件变量)的相关调度代码。
另外,为了避免优先级逆转(priority inversion)现象,即高优先级的线程A由于同 步机制等待低优先级的线程B,而B由于中优先级的线程C得不到CPU,我们需要实 现优先级捐赠(priority donation)机制,即将B的优先级暂时提高到和A相同。只需实 现lock的优先级捐赠。要考虑嵌套捐赠,即A等C,C等B的情况(将C和B都提高 到A). 我的实现方法是在tcb中添加一个waiting_lock成员,表示正在等待的锁,锁中 有holder成员,表示持有锁的线程,这样就可以形成一个等待链表,每次lock()时遍 历等待链表对链表中的线程进行donation.
而unlock()时捐赠情况可能会变,如H(high)和M(mid)分别在等L(low)的lock1和 lock2,则L的优先级是high,当lock1释放时,H不再捐赠L,而变成M在捐赠L, 即L的优先级要变为mid. 这可以通过在tcb中添加一个lock_list链表,表示所有持有 的锁,当释放一把锁时遍历其他锁获得最高的优先级来捐赠。
实现优先级调度的数据结构选择有多种方法:
(1)链表:这是最简单的方法,插入O(1),查找O(n),修改O(n).
(2)堆:用堆来维护就绪队列,插入O(logn),查找O(1),但缺点是不方便修 改优先级,需要遍历堆来找到目标修改后再上滤或下滤,复杂度O(n).
(3)位图+多级队列:这是我想到的相对好实现的最好的方法,每个队列对应 一个优先级,插入是O(1);如果没有位图则查找需要遍历所有队列O(n),而使 用位图表示某一队列是否为空,查找优化为O(1);修改在最坏情况下仍是O(n).
(4)红黑树:复杂度稳定但难实现,插入,查找,修改都是O(logn).
Task3. User threads
实现部分pthread线程库和线程同步syscall以支持用户多线程。PintOS是一对一 的线程模型,也就是说每个用户线程对应一个内核线程。在实现用户多线程后还需要 修改之前User Program的部分代码,主要是exit(),分以下情况:1.主线程调用 pthread_exit(), 等待其他线程结束然后以状态0退出;2.任何线程调用exit(n),整个进 程应该以状态n退出;3.任何线程意外终止,整个进程应该以状态-1退出。上述中的 “整个进程退出”指该进程其他线程也必须马上终止。当同时发生时三种情况的优先级 是3>2>1.
pthread_create()
创建一个用户线程。与process_execute()类似,创建一个新的内核线程让其 运行start_pthread()进行初始化,创建栈,设置参数,并设置中断栈帧中的eip, esp等以在返回时运行用户线程,此外还要将该线程加入到pcb的thread_list 中。注意多用户线程栈结构如下。
pthread_exit()
终止当前用户线程,如果是主线程,则join所有其他线程然后退出。其他线 程:先signal join()等待的信号量,然后调用thread_exit()将其从调度队列中去掉 并释放其内核栈即可。主线程:为了防止其他线程join主线程,提前signal对应 信号量,然后再join其他所有线程,最后调用process_exit().
pthread_join()
等待指定线程结束。只能join同一进程的线程,且每个线程只能被join一 次。设置信号量进行同步即可。
lock_init(), lock_acquire(), lock_release()
互斥锁系统调用。在tcb中用一个链表管理该进程所有互斥锁然后使用其接 口进行相应操作即可。
sema_init(), sema_down(), sema_up()
信号量系统调用。在tcb中用一个链表管理该进程所有信号量然后使用其接 口进行相应操作即可。
Proj3: File Systems
丰富扩展文件系统的功能,如实现内存缓冲区,支持可扩展文件,树形目录结构等。
Task1. Buffer cache
实现文件读写的内存缓冲区,替换算法自行选择,我选择的是enhanced-clock算 法,即考虑used位和dirty位,扫描缓冲区,按00,01,10,11的优先顺序进行淘 汰。要注意保证对缓冲区访问的线程安全,可以对每个block都加一把读写锁。此外 如果只在淘汰或关机时进行写回会使系统在崩溃时更脆弱,因此可以定期写回,可通 过时钟处理函数实现。
Task2. Extensible files
实现可扩展文件,使得文件大小可变。这里使用的是类似UNIX-FFS的方法,前 12个block使用直接索引,往后128个block使用一级索引,再往后128 * 128个 block使用二级索引,每个block 512B,也就是说最大可支持8MB的文件大小。主要 修改inode_create()改变分配block的方式,注意要一块一块地分配,因为索引不需要 连续块,还要修改inode_write_at()以支持动态文件扩展。
Task3. Subdirectories
实现树形目录结构和目录操作syscall并且支持相对路径,还需要修改p1实现的 部分文件操作syscall
树形目录结构通过路径解析并递归读取目录文件即可实现,路径解析时开头如果 是 ’/’ 表示绝对路径,否则是相对路径,相对路径则直接从cwd开始解析,且要注意. 和..这两个特殊的文件名,表示本目录和上级目录。注意创建和删除文件时还要在其目 录文件中增加或删除目录项,若某目录是某进程的cwd或已被某进程打开,则不允许 删除(另一种做法是允许删除但不允许再打开该目录下的文件)。
系统调用部分,目录文件也是个特殊的文件,和普通文件一样使用文件描述符来 操作,因此在fd Table中还需要区分其指向的是目录文件还是普通文件,我在pcb中 增加了一个位图来描述,此外在操作时也要判断类型区分接口的使用。
chdir()
更改当前进程的工作目录。更改pcb中的cwd指向的struct dir即可。
mkdir()
创建新的目录。注意在父目录中添加目录项。
readdir()
读取目录中下一个目录项的文件名。struct dir中有pos成员来记录当前读取 的目录项的位置。