实验代码记录:https://github.com/anda522/LabCode/tree/main/Mit6.S081
课程教材翻译:https://xv6.dgs.zone/ ,对理解感觉很重要
xv6系统为 RISC-V
1 Unix utilities
Knowledge
操作系统结构:
-
用户空间程序:正在运行的程序,他们处于同一个空间
-
Kernel:Kernel程序只有一个,维护数据管理每一个用户空间进程,维护硬件资源
Kernel中存在各种服务:文件系统、进程管理系统、访问控制等等
应用程序访问Kernel通过 系统调用 来完成,Kernel具有对硬件操作的特殊权限。
管道 pipe
特性:
- 管道具有先进先出(FIFO)的特性。数据写入管道的一端,从管道的另一端读出时是按写入顺序读取的。
- 父进程在写入管道后,会通过 read 操作等待子进程的响应。该 read 操作会被阻塞,直到有数据可读。
Lab
/user
目录为用户区代码,所有用户区相关的函数都在此处。
/kernel
目录为内核区代码,所有内核区相关的操作都在此处,如系统调用实现,内存映射,中断处理等。
如要使用 sleep
命令时,需要将 sleep
程序添加进 Makefile
文件的 UPROGS
中,将其纳入编译范围,否则操作系统将不能识别你的命令。后续程序同理。
UPROGS
列表:添加在之中说明用户区可以执行对应的可执行程序,例如在命令行执行对应命令。而其他无需生成可执行程序的系统调用可以不用添加在其中。
参考:
2 System calls
Knowledge
系统调用
// 所有相关的系统调用
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);
部分系统调用解释:
exit(int)
:导致进程停止执行并释放资源。exit
接受一个整数状态参数,通常0表示成功,1表示失败。
fork(void)
:拷贝当前进程的内存,并创建一个新的进程,这里的内存包含了进程的指令和数据。
之后就拥有了一个完全一样内存的进程,在原始进程中 fork()
系统调用返回大于 0
的数,这个数为子类的 PID
, 在子进程中,返回的数为 0
。
exec(char*, char**)
:以新的进程代替原来的进程,该系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了。并没有创建新的进程,还是原来进程的 PID
。
exec
有两个参数:可执行文件的文件名和字符串参数数组。
wait(int*)
:等待之前创建的子进程退出。如果当前进程有任何子进程,并且其中一个已经退出了,那么wait会返回。
wait
系统调用返回当前进程的已退出(或已杀死)子进程的PID,并将子进程的退出状态复制到传递给wait
的地址;如果调用方的子进程都没有退出,那么wait等待一个子进程退出。如果调用者没有子级,wait
立即返回-1。如果父进程不关心子进程的退出状态,它可以传递一个0地址给wait
。
write(fd, buf, n)
:将 buf
中的 n
字节写入文件描述符,并返回写入的字节数。只有发生错误时才会写入小于 n
字节的数据。
read(fd, buf, n)
:从文件描述符读取最多 n
字节,将它们复制到 buf
,并返回读取的字节数。
文件描述符是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象,进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)
系统调用执行过程
系统调用在内核区,执行时需要从用户态过渡到核心态,CPU提供一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V为此提供ecall
指令)。
一个进程可以通过执行RISC-V的ecall
指令进行系统调用,该指令提升硬件特权级别,并将程序计数器(PC)更改为内核定义的入口点,入口点的代码切换到内核栈,执行实现系统调用的内核指令,当系统调用完成时,内核切换回用户栈,并通过调用sret
指令返回用户空间,该指令降低了硬件特权级别,并在系统调用指令刚结束时恢复执行用户指令。
Lab
3 Page tables
Knowledge
Xv6为每个进程维护一个单独的页表,定义了该进程的地址空间。一个进程的虚拟地址空间分布如下。首先是指令,然后是全局变量,然后是栈区,最后是一个堆区域。有许多因素限制了进程地址空间的最大范围: RISC-V上的指针有64位宽;硬件在页表中查找虚拟地址时只使用低39位;xv6只使用这39位中的38位。因此,最大地址是 2^38-1=0x3fffffffff
,即MAXVA
(定义在kernel/riscv.h:348)在地址空间的顶部,xv6为trampoline
(用于在用户和内核之间切换)和映射进程切换到内核的trapframe
分别保留了一个页面。
Lab
argaddr()
和 argint()
函数都是从用户栈空间中提取参数。当用户调用系统调用时,需要将参数传给内核。参数通常会放到用户的栈中。
walk
函数在操作系统中通常用于页表的遍历和访问。在虚拟内存管理中,页表是一种数据结构,用于将虚拟地址映射到物理地址。walk
函数的主要作用是在给定的页表中查找特定虚拟地址对应的页表项。
copyout
函数通常用于从内核空间将数据复制到用户空间。
PTE_A
的默认值是第六位
参考:
[1] MIT 6.S081 2021: Lab page tables
[2] MIT 6.S081 Lab Pgtbl 实验 (非常详细)
4 Traps
Knowledge
Trap机制:用户空间和内核空间的切换,目的是实现操作系统的安全和隔离
用户空间和内核空间的切换发生的场景:
- 程序执行系统调用
- 程序出现页面错误、除以零等错误
- 触发中断导致程序需要响应内核设备驱动
Lab
proc
结构体包含了进程中的所有信息,可以通过 myproc()
获取当前运行的进程的结构体。
5 Copy-on-Write
Knowledge
Lab
- 我们很有可能需要标记当前的 PTE 为 COW 内存页映射,因此我们可以使用 RSW(reserved for software)标识位来实现:
-
如果 COW 下的缺页中断产生了,但是也没有足够的物理内存空间,那么进程应当被杀死。
-
设置引用计数,将物理内容RAM划分成
(PHYSTOP - KERNBASE) / PGSIZE
个页面,对每个页面设置一个引用计数(代表页面被引用的次数,当引用计数变为0时,就要释放对应的空间)。
6 Multithreading
Knowledge
Xv6有两种类型的锁:自旋锁(spinlocks)和睡眠锁(sleep-locks)。
自旋锁和中断的交互引发了潜在的危险。假设sys_sleep
持有tickslock
,并且它的CPU被计时器中断中断。clockintr
会尝试获取tickslock
,意识到它被持有后等待释放。在这种情况下,tickslock
永远不会被释放:只有sys_sleep
可以释放它,但是sys_sleep
直到clockintr
返回前不能继续运行。所以CPU会死锁,任何需要锁的代码也会冻结。
为了避免这种情况,如果一个自旋锁被中断处理程序所使用,那么CPU必须保证在启用中断的情况下永远不能持有该锁。Xv6更保守:当CPU获取任何锁时,xv6总是禁用该CPU上的中断。中断仍然可能发生在其他CPU上,此时中断的acquire
可以等待线程释放自旋锁;由于不在同一CPU上,不会造成死锁。
自旋锁的另一个缺点是,一个进程在持有自旋锁的同时不能让出(yield)CPU,然而我们希望持有锁的进程等待磁盘I/O的时候其他进程可以使用CPU。
Xv6以睡眠锁(sleep-locks)的形式提供了这种锁。acquiresleep
(kernel/sleeplock.c:22) 在等待时让步CPU