MIT6.s081/6.828 lectrue1:Introduction and examples 以及 Lab1 心得
这是 MIT 著名的操作系统课程,2019 年之前叫 MIT6.828,之后改名为 6.s081,2021 又改了一波名字,最新的名字是 6.1810
目前课程官网能够查到 2020,2021.2022 秋季的课程表,但是视频都是 2020 年录制的那一版
博客目的:
- 简单复习+回顾下自己的 OS 学习之旅
- 记录下当时自己写 lab 时遇到的难点和思考
参考资料:
官网:https://pdos.csail.mit.edu/6.828/2022/schedule.html
视频翻译:https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/
教材英文版:https://pdos.csail.mit.edu/6.S081/2020/xv6/book-riscv-rev1.pdf
教材中文版:https://th0ar.gitbooks.io/xv6-chinese/content/index.html
我的实现:https://github.com/folook/MIT6.s081-record
课程内容回顾
1.1 课程内容简介
课程目标:
- 理解操作系统的设计与实现,设计是一种高层次的结构,实现是代码的样子;这一点和蒋炎岩的课程理念一致,如何设计?如何实现?会花费大量时间讲解
- 在 XV6 上(一个类 UNIX 操作系统)获得动手经验
关于 OS 的目标:
- 管理底层硬件
- 应用程序的并行
- 应用程序的隔离
- 应用程序的数据共享
- 多用户之间的访问控制(Access Control System)/安全性(Security)/权限系统Permission System
- 帮助应用程序获得高性能(Performance)。
- 能够支持大量不同的用户场景:文本编辑器,正在运行游戏,或许你的操作系统需要支持数据库服务器和云计算
1.2 OS 的结构
在这个架构的最上层,运行各种各样的应用程序,这里程序都运行在同一个空间中,这个空间通常会被称为用户空间(Userspace)
计算机有一些硬件资源,包括了CPU,内存,磁盘,网卡。所以硬件资源在最低一层。
区别于用户空间程序,有一个特殊的程序总是会在运行,它称为Kernel。Kernel是计算机资源的守护者。
当你打开计算机时,Kernel总是第一个被启动。Kernel程序只有一个,它维护数据来管理每一个用户空间进程。Kernel中的最重要的两个服务,其中一个服务是文件系统,另一个就是进程管理系统。(在真实的 os 中,kernel 还有很多其他服务:比如 进程间通信服务,比如TCP/IP协议栈,比如支持声卡的软件,比如支持数百种不同磁盘,不同网卡的驱动)
在进程管理系统中,Kernel会管理内存的分配。不同的进程需要不同数量的内存,Kernel会复用内存、划分内存,并为所有的进程分配内存。
应用程序是如何与Kernel交互:
这里是通过所谓的系统调用(System Call)来完成。系统调用与程序中的函数调用看起来是一样的,但区别是系统调用会实际运行到系统内核中,并执行内核中对于系统调用的实现。
系统调用举例
open-应用程序需要打开一个文件
//现在要打开一个名为“out”的文件,那么会将文件名“out”作为参数传入。同时我们还希望写入数据,那么还会有一个额外的参数,在这里这个参数的值是1,表明我想要写文件。
int fd = open("out", 1);
fd全称就是file descriptor。之后,应用程序可以使用这个文件描述符作为handle,来表示相应打开的文件
write-向文件写入数据
//向write传递一个由open返回的文件描述符作为参数。你还需要向write传递一个指向要写入数据的指针(数据通常是char型序列),在C语言中,可以简单传递一个双引号表示的字符串(下图中的\n表示是换行)。第三个参数是你想要写入字符的数量。
write(fd, "hello\n", 6)
fork-创建新的进程
//更有意思的系统调用是fork。fork是一个这样的系统调用,它创建了一个与调用进程一模一样的新的进程,并返回新进程的process ID/pid。
int pid = fork();
1.3 OS难的原因
- 内核的编程环境比较困难,你实际上在提供一个基础设施让别人来运行他们的程序。在这门课程中,我们会使用一个叫做QEMU的硬件模拟器,来模拟CPU和计算机。这会简单一些,但即使这样,编程环境还是比较恶劣。
- 设计 OS 时一些矛盾:
- 高效和易用的矛盾。高效通常意味着操作系统需要在离硬件近的low-level进行操作,而易用则要求操作系统为应用程序提供抽象的high-level可移植接口。所以,提供一个简单可移植,同时又高效的抽象接口需要一定的技巧。
- 简单和强大的矛盾。我们不想程序员看到数量巨多,复杂且难以理解的的内核接口。因为,如果他们不理解这些接口,他们就会很难使用这些接口。想要提供一个非常强大的操作系统服务,这样操作系统才能分担运行应用程序的负担,所以我们需要强大的操作系统服务。
- 灵活和安全的矛盾。我们希望给程序员完全的自由,但是实际上又不能是真正的完全自由,因为我们不想要程序员能直接访问到硬件,干扰到其他的应用程序,或者干扰操作系统的行为。
趣问:子进程是否能够访问到在fork之前创建的文件描述符fd?
子进程可以访问到在fork之前创建的文件描述符fd。在fork之后,子进程会拥有与父进程相同的文件描述符表,包括文件描述符fd
趣问:高级编程语言和系统调用的关系
高阶编程语言通常会对系统调用进行封装,提供更高级、更易用的接口供开发者使用,以简化和抽象编程过程。某种程度上就与系统调用接口隔离了,但是这些语言要完成一些工作,最终肯定是要执行系统调用的,因为编程语言没有资格去调用硬件资源,肯定要和 os 打交道
1.4 课程结构和资源
- 课程网站
- Piazza
1.5 read,write,exit 系统调用
系统调用是操作系统提供的服务的接口,因为 OS 是服务应用程序的
XV6是一个简化的类似Unix的操作系统,XV6运行在一个RISC-V微处理器上,实际上,XV6运行在QEMU模拟器之上。(QEMU不仅能模拟CPU, 还提供了极为完善的外部硬件模拟,包括处理器、内存、磁盘、网络接口、外设等)
(它足够简单,所以你们极有可能在几周内很直观的读完所有的代码,同时也把相应的书也看完,这样你们就能理解XV6内部发生的一切事情了。)
这个命令会编译XV6,而XV6是用C语言写的。我首先执行一下make clean,这样你们就能看到完整的编译过程。
make qemu
read:读文件
char buf[64];
int n = read(0, buf, sizeof(buf));
- 第一个参数是文件描述符,指向一个之前打开的文件。Shell会确保默认情况下,当一个程序启动时,文件描述符0连接到console的输入,文件描述符1连接到了console的输出。
- read的第二个参数是指向某段内存的指针
- read的第三个参数是代码想读取的最大长度,sizeof(buf)表示,最多读取64字节的数据
read的返回值可能是读到的字节数(包括结束符)
解释:read 函数从文件 fd 中读取size 字节数据,保存到 buf 指针指向的内存中
write:写文件
char buf[64];
int n = write(1, buf, sizeof(buf));
- 第一个参数是文件描述符,指向一个之前打开的文件。Shell会确保默认情况下,当一个程序启动时,文件描述符0连接到console的输入,文件描述符1连接到了console的输出。
- write 的第二个参数是指向某段内存的指针
- write 的第三个参数是要写入的最大长度,sizeof(buf)表示,最多写入64字节的数据
write 的返回值可能是写入的字节数(包括结束符?)
解释:write 函数从内存地址 buf 中读取size 字节数据,然后写到文件 fd 中,
1.6 open
最直接的创建文件描述符的方法是open系统调用
int fd = open("output.txt", O_WRONLY | O_CREATE | O_TRUNC);
write(fd, "xxx\n", 4)
exit(0)
这个叫做open的程序,会创建一个叫做output.txt的新文件,并向它写入数据:"ooo",最后退出。
第二个参数是一些标志位,用来告诉open系统调用在内核中的实现:我们将要创建并写入一个文件。open系统调用会返回一个新分配的文件描述符,这里的文件描述符是一个小的数字,可能是2,3,4或者其他的数字。
O_WRONLY
标志指示文件只能写入。O_CREATE
标志指示如果文件不存在,则创建它。O_TRUNC
标志指示如果文件存在,则截断它,即将其长度设置为 0。
内核为每个进程都维护了一个独立的文件描述符表,这个表描述了这个进程已打开的文件:
-
key是整数值,代表文件描述符;
-
value是指向文件表项(File Table Entry)的指针或索引,它记录了文件的相关信息,如文件状态、位置指针、访问权限等。
1.7 shell
这里的Shell通常也是人们说的命令行接口
当我输入ls时,我是在要求Shell运行位于文件ls内的这些计算机指令。
除了运行程序以外,Shell还会做一些其他的事情,比如,它允许你能重定向IO。比如,我输入 ls > out:我要求Shell运行ls命令,但是将输出重定向到一个叫做out的文件中
趣问:编译器如何处理系统调用?生成的汇编语言是不是会调用一些由操作系统定义的代码段?
OS 定义的代码只能在内核中执行,在RISC-V中,系统调用会被编译为ecall指令,这个特殊的指令将控制权转给内核。之后内核检查进程的内存和寄存器,并确定相应的参数。
1.8 fork:拷贝当前进程的内存
fork会拷贝当前进程的内存,并创建一个新的进程,这里的内存包含了进程的指令和数据。之后,我们就有了两个拥有完全一样内存的进程
在原始的进程(父进程)中,fork系统调用会返回大于0的整数(子进程 ID)。而在新创建的进程(子进程)中,fork系统调用会返回0。
fork创建了一个新的进程。当我们在Shell中运行东西的时候,Shell实际上会创建一个新的进程来运行你输入的每一个指令。所以,当我输入ls时,我们需要Shell通过fork创建一个进程来运行ls,这里需要某种方式来让这个新的进程来运行ls程序中的指令,加载名为ls的文件中的指令(也就是后面的exec系统调用)。
shell 中执行的所有进程都是 shell fork 出来的子进程
1.9 exec:执行指令 wait:等待子进程退出
exec系统调用,这个系统调用会从指定的文件(第一个参数)中读取并加载指令,并替代当前调用进程的指令。
从某种程度上来说,这样相当于丢弃了调用进程的内存,并开始执行新加载的指令。
char *argv[] = { "echo", "this", "is", "echo", 0 };
//操作系统从名为echo的文件中加载指令到当前的进程中,并替换了当前进程的内存,之后开始执行这些新加载的指令
exec("echo", argv);
有关exec系统调用,有一些重要的事情,
-
exec系统调用会保留当前的文件描述符表单。所以任何在exec系统调用之前的文件描述符,例如0,1,2等。它们在新的程序中表示相同的东西。
-
通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。所以 shell 不会直接调用 exec,因为用户还想继续使用 shell,这里的一个好方法是 Shell会执行fork,之后fork出的子进程再调用exec系统调用,这是一个非常常见的Unix程序调用风格。
-
但是如果 exec 命令执行失败,比如 exec 一个不存在的指令,就会返回,在下面的第 6 行中,如果 exec 不存在的指令,会执行第 7、8 行;如果运行一个存在的指令,就不会执行第 7、8 行
Unix提供了一个wait系统调用。这里的父进程是 shell,shell 会执行 wait,等待之前创建的子进程退出。
子进程退出后,父进程的 wait 函数才会返回,wait 函数返回的是子进程的PID(注意,status 是子进程的退出状态,而不是 PID)
int main() {
int pid, status;
pid = fork();
if(pid == 0) {
char argv = { "echo", "THIS", "IS", "ECHO", 0}:
exec("'echo", argv);
printf("exec failed !\n");
exit(1);
} else {
printf("parent waiting\n");
wait (&status);
printf("the child exited with status sdln" , status);
}
exit(0);
}
这里wait的参数status,刚传入时是初始值 0。当子进程退出后,其 exit 值会传递给 status,相当于父进程拿到了子进程的退出状态
Unix中的风格是,如果一个程序成功的退出了,那么exit的参数会是0,如果出现了错误,那么就会像第8行一样,会向exit传递1。
NULL的意思是退出状态不关注。如果要获取退出状态应该写成wait(&status);
优化:
常用的写法:先调用fork,再在子进程中调用exec。由于fork首先拷贝了整个父进程的内存,但是之后exec整个将这个拷贝丢弃了,所以这里的拷贝显得有些浪费。
在这门课程的后面,会实现一些优化,比如说copy-on-write fork,这种方式会消除fork的几乎所有的明显的低效,而只拷贝执行exec所需要的内存
1.10 I/O Redirect
Shell提供了方便的I/O重定向工具。如果我运行下面的指令:
echo hello > out
Shell会将echo的输出送到文件out。之后我们可以运行cat指令,并将out文件作为输入
cat < out
IO 重定向是如何实现的?
#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"
// redirect.c: run a command with output redirected
int
main()
{
int pid;
pid = fork();
if(pid == 0){
//文件描述符1通常是进程用来作为输出的(也就是console的输出文件符),关闭文件描述符
close(1);
//打开 out 文件,为 out 分配一个没有使用的、最小的文件描述符1
open("out", O_WRONLY | O_CREATE | O_TRUNC);
char *argv[] = { "echo", "this", "is", "redirected", "echo", 0 };
//echo 的代码中有:write(1, argv[i], strlen(argv[i]));所以 echo 是写到文件描述符 1 中的
exec("echo", argv);
printf("exec failed!\n");
exit(1);
} else {
wait((int *) 0);
}
exit(0);
}
所以 fork()、close()、open()、echo() 这些系统调用结合起来,实现了I/O重定向。
lab 回顾:lab1 util lab
实现 sleep:
这个很简单,直接调用系统调用 sleep 就可以了,如果想再深究一下,sleep 在 kernel 中是这么实现的:维护一个全局变量 ticks,用来记录 OS 启动后过了多久,调用 sleep 时记录该 ticks 的值为 tick0,然后不断循环判断 ticks - tick0 是否超过阈值,否则就继续 sleep
实现 pingpong
这个函数主要是理解管道,管道就是一个文件、一个缓冲区,可以用来暂存数据
调用 pipe 函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端
半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。之所说管道是半双工,因为父进程关闭读端,子进程关闭写端就可以实现父->子,而反过来就可以实现子->父,所以说是半双工的。
通过pipe在内核中创建一个文件,然后可以实现两个进程通信
管道分匿名管道和有名管道。匿名管道管道只能用于有亲系关系的进程间通信,即只能在父进程与子进程或兄弟进程间通信。而有名管道可以用于任何进程间通信。
管道的一般用法是,进程在使用fork函数创建子进程前先创建一个管道,该管道用于在父子进程间通信,然后创建子进程,
之后父进程关闭管道的读端,子进程关闭管道的写端。父进程负责向管道写数据而子进程负责读数据。当然父进程可以关闭管道的写端而子进程关闭管道的读端。
这样管道就可以用于父子进程间的通信,也可以用于兄弟进程间的通信。
<1>父进程创建管道
父进程调用pipe开辟管道,得到的两个文件描述符指向管道的两端。
<2>父进程fork出子进程
父进程调用fork创建子进程,则子进程也有两个文件描述符指向同一管道。
<3>父进程关闭fd[0],子进程关闭fd[1]
父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是用环形队列实现的,数据从写端流入从读端流出,即实现了进程间通信。
使用pipe创建1个管道。
//pipe()函数创建了管道,并返回了两个描述符:fd[0]用来从管道读数据,fd[1]用来向管道写数据。我们将在父、子进程中使用这两个描述符。
int fd[2];
pipe(int fd[2]);
实现 primes(使用管道)
实现的关键是理解下图的算法:每一个 stage 以当前数集中最小的数字作为素数输出(每个 stage 中数集中最小的数一定是一个素数,因为它没有被任何比它小的数筛掉),并筛掉输入中该素数的所有倍数(必然不是素数),然后将剩下的数传递给下一 stage。
- 进程 1 创建管道 1,fork 出进程 2,使用 write 将 2~11 这些数字输入到管道1中
- 进程 2 输出读到的第一个数(2),然后再创建一个管道2,并 fork 出进程 3,使用 write 将 %2 != 0的数输入到管道 2
- 进程 3 输出读到的第一个数(3),然后再创建一个管道3,并 fork 出进程 4,使用 write 将 %3 != 0的数输入到管道 3
- ...
- 每个进程直到读到-1,exit(0)退出
实现 find
相对来说比较简单,利用 chatGPT 熟读 ls.c的代码,主结构就是递归地遍历所有文件夹,其中需要用到的字符串指针运算需要额外学习一下。
实现xargs
xargs
是一个命令行工具,用于从标准输入或文件中读取数据,并将其作为参数传递给其他命令。它主要用于处理以换行符或空格分隔的数据。
xargs
的基本用法如下:
command | xargs [command]
xargs
将从 command
的输出中读取数据,并将这些数据作为参数传递给 [command]
执行。
这个有些无聊,主要是熟悉一下 c 中的字符串切割和 xargs 的作用