URL
type
status
date
slug
summary
tags
category
icon
password

MIT 操作系统课程笔记
👨🏻💻更新至第一章:实验
Lecture1:简介与操作系统接口
第一堂课是课程整体介绍
一、课程介绍
课程目标:
- 了解操作系统的设计与实现
- 研究课程提供的小型操作系统 XV6
- 动手拓展操作系统
操作系统的作用:
- 为了更方便使用,并提高可移植性,对硬件进行抽象
- 在多个应用中实现硬件的多路复用
- 隔离应用程序,互不干扰
- 多个应用共享资源
- 保证共享的安全
- 保证应用的高性能
- 支持各种不同的应用
操作系统的运作过程:

操作系统会把电脑分为用户空间和内核空间,我们在用户空间打开一系列的应用,然后应用进程调用操作系统提供的接口就能进入内核空间,内核空间里提供各种服务,操作计算机的硬件资源,操作系统内核把应用的需求通过接口返回到用户空间。
从这里我们可以看出,操作系统就像一层壳把整个计算机的硬件资源罩住,然后壳上提供一些接口给应用程序使用。所以应用程序只能运行在操作系统至上,调用各种操作系统提供的系统调用。
系统调用:
操作系统提供的这些系统调用很像 c
语言的函数调用,但实际上两者并不一样,系统调用会进入操作系统内核执行操作。
系统调用举例:
二、UNIX 系统调用简介
因为所有的应用需要使用计算机的资源,都需要通过操作系统提供的接口,也就是系统调用,所以课程后半段介绍了下
unix 的系统调用。
课程研究的小型操作系统 XV6 和 unix 类似,选择 unix
是因为它代码开源,使用广泛,手册优良,设计干净。
课程用几个代码实例来展示了系统调用的作用,以及各种不同系统调用的协同工作,主要介绍了如下系统调用:
int open(char *file, int flags)
打开一个文件;flags 表示 read/write;返回一个 fd(文件描述符)
int fork()
复制当前进程的数据和命令,创建一个新进程即子进程,返回进程号 pid。子进程的 pid 为 0,以此区别父子进程。
int exec(char *file, char *argv[])
加载一个文件并使用参数执行它; 只有在出错时才返回。这个新执行的程序进程会替换掉当前进程,丢弃指令和数据。
int exit(int status)
终止当前进程,并将状态报告给 wait()函数。无返回
int wait(int *status)
等待一个子进程退出; 将退出状态存入*status; 返回子进程 PID。
int close(int fd)
释放打开的文件 fd 文件描述符
int pipe(int p[])
创建一个管道,把 read/write 文件描述符放在 p[0]和 p[1]中
文件描述符:open 打开一个文件后,会给文件一个描述符,通常是一个数字。一般 0 指向标准输入,1 指向标准输出,2 指向错误输出
代码还展示了这些系统调用如何合作工作,比如用 fork 创建一个新进程,用
close 释放文件描述符 1,再 open
打开文件,此时会给这个文件匹配一个最小的未占用的描述符,也就是刚释放的
1,完成重定向。
这些代码具体可以看 ,课程介绍
下面展示书上 XV6 的完整系统调用表
系统调用 | 描述 |
int fork() | 创建一个进程,返回子进程的 PID |
int exit(int status) | 终止当前进程,并将状态报告给 wait()函数。无返回 |
int wait(int *status) | 等待一个子进程退出; 将退出状态存入*status; 返回子进程 PID。 |
int kill(int pid) | 终止对应 PID 的进程,返回 0,或返回-1 表示错误 |
int getpid() | 返回当前进程的 PID |
int sleep(int n) | 暂停 n 个时钟节拍 |
int exec(char *file, char *argv[]) | 加载一个文件并使用参数执行它; 只有在出错时才返回 |
char *sbrk(int n) | 按 n 字节增长进程的内存。返回新内存的开始 |
int open(char *file, int flags) | 打开一个文件;flags 表示 read/write;返回一个 fd(文件描述符) |
int write(int fd, char *buf, int n) | 从 buf 写 n 个字节到文件描述符 fd; 返回 n |
int read(int fd, char *buf, int n) | 将 n 个字节读入 buf;返回读取的字节数;如果文件结束,返回 0 |
int close(int fd) | 释放打开的文件 fd |
int dup(int fd) | 返回一个新的文件描述符,指向与 fd 相同的文件 |
int pipe(int p[]) | 创建一个管道,把 read/write 文件描述符放在 p[0]和 p[1]中 |
int chdir(char *dir) | 改变当前的工作目录 |
int mkdir(char *dir) | 创建一个新目录 |
int mknod(char *file, int, int) | 创建一个设备文件 |
int fstat(int fd, struct stat *st) | 将打开文件 fd 的信息放入*st |
int stat(char *file, struct stat *st) | 将指定名称的文件信息放入*st |
int link(char *file1, char *file2) | 为文件 file1 创建另一个名称(file2) |
int unlink(char *file) | 删除一个文件 |
一般的,返回值0表示成功,-1表示出错
三、实验环境搭建
官方指导 https://pdos.csail.mit.edu/6.828/2021/tools.html
这里以Windows上配置为例,实验环境照着2021课程搭建,实验工程用的2020的(因为2020的视频课有精校中文字幕)
A. 安装Ubuntu20.04
xv6操作系统,运行在一个模拟的计算机硬件系统之上,而这个模拟的硬件系统又需要运行在linux中。这里官方推荐的是Ubuntu20.04版本,因为许多工具都是自带的,比较便捷。注意一定不要使用Ubuntu18.04
WSL
在windows中安装,可以使用虚拟机,但是比较笨重。Windows10
2004以上版本或者win11支持WSL(Windows Subsystem for
Linux)。可以直接在Microsoft商店安装一个linux子系统,用powershell来作为终端。我们的系统盘会直接挂载到这个子系统的/mnt目录下。
- 下载Ubuntu20.04 打开Microsoft商店搜索Ubuntu20.04点击安装即可

- 激活Windows的WSL
安装WSL,以管理员身份在powershell中输入
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
,然后打开安装的Ubuntu即可 中途可能有各种报错,可能是没有启用服务,可能是没开虚拟化,csdn都有
- 更新apt包管理
输入指令
sudo apt-get update && sudo apt-get upgrade
B. 安装工具链
课程需要安装如下工具
RISC-V版本的:
- QEMU 5.1+:一个能模拟各种架构的虚拟机,19年xv6系统的指令集改为了RISC-V
- GDB 8.3+:debug工具
- GCC:一个开源c语言编译器
- Binutils:一个gnu工具集
输入指令
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
一次性安装C. 下载课程git仓库
git clone git://g.csail.mit.edu/xv6-labs-2020
然后进入文件夹,用命令
git checkout util
切换到util分支,你会发现目录下有个Makefile文件夹
然后执行命令make qume
就可以进入xv6系统了 退出先按
【ctrl+a】,然后按x进入系统后键入
ls
即可查看目录,可以确定跑起来了
image-20230130150641363
Makefile文件,因为早期linux环境下并没有win平台上完备的c/c++的IDE,不能一键编译项目并跑起来。因此有了Makefile文件,相当于一种构建c项目的脚本,执行make命令,会自动寻找当前目录下的makefile文件并编译运行c项目。
D. 与vscode联用
wsl会把Linux系统的目录也挂在到Windows上
点开此电脑,在左侧你会发现自己的Linux目录

用vscode打开这个文件夹就可以快乐编辑调试了
四、正式学习xv6系统接口
官方书籍中文版:xv6-book
写这个笔记只是为了梳理思路加深印象,并不是教程,所以需要结合书本看。
1.理解fork
官方实例程序如下
理解:

主进程执行到
,就会复制出一个子进程,这俩同时向下运行代码,唯一的区别就是主进程的pid变量获取的是子进程的进程号,而子进程的pid变量为0。因此接下来的代码,主进程执行
红2
代码,子进程执行
蓝2
代码,
两个进程同时进行,因此输出先后顺序不一定。
接下来
红2
执行到
红3
,
系统调用是等待子进程退出并返回子进程的进程号,并把子进程的退出状态传到参数中的地址(如果不关心退出状态就如图传入个零地址)。
蓝2
执行到
蓝3
,
系统调用就是停止执行并释放资源,然后子进程结束,主进程的
拿到了子进程的pid并继续执行到
红4
注意:父子进程虽然内容相同,但是运行在完全不同的内存空间和寄存器,因此各自的变量赋值都不会互相影响。
2.理解exec
int exec(char *file, char *argv[])
执行指定程序,并且可以传入参数(默认argv[0]为程序名,argv[1]开始才是参数)。exec加载程序进当前进程的内存空间,并替换掉当前进程的内存内容
,因此书中示例程序最后的printf不会执行了。
3.I/O与文件描述符
文件、目录、设备,或创建一个管道都被抽象为文件,读取文件实际上就是读取字节流。
文件描述符是一个小的整数,可以看做文件的id。一个进程可以打开许许多多的文件,因此我们需要用文件描述符来区分这些文件,每个进程都有一个文件描述符表,记录这文件描述符和文件的对应关系。
众多linux
shell习惯上规定:进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)。
read(fd,buf,n)
fd是文件描述符,read调用将文件描述符fd读取最多n个字节到buf中去。write(fd,buf,n)
fd是文件描述符,write调用从buf中写入最多n个字节到文件描述符fd中去。read和write调用都返回读写的字节数,如果读写完毕则返回0。
每个文件描述符都有个关联的偏移量,read和write都是从这个偏移量的位置开始读写,读写完成后偏移量自动移动读写的长度,以便下一次读写从上一次的末尾继续。
close(fd)
释放文件描述符,这个文件描述符就可以在未来被重新分配int open(char *file, int flags)
打开指定文件,返回一个fd,默认分配最小可用的fd,flag说明读写方式,具体如下:宏定义 | 功能说明 |
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 可读可写 |
O_CREATE | 如果文件不存在则创建文件 |
O_TRUNC | 将文件截断为零长度 |
I/O重定向的例子
值得注意的是,在调用fork之后,文件描述符被复制了,子进程修改不影响父进程,但是父子文件的偏移量是共享的
网上找到个不错的图:

fd=dup(1)
复制文件描述符1,fd就成了一个新的文件描述符,两个文件描述符共享一个偏移量,类似fork注意:只有dup和fork出来的文件描述符才共享同一个偏移量,否则就算是对同一个文件的打开,都不共享
4.管道
pipe(p)
创建一个管道,p[0]为读端的文件描述符,p[1]为写端的文件描述符管道类似一个临时文件,把数据读进读端,输出到写端,但是更加快捷方便和强大
如果没有可用数据,管道的read操作会一直阻塞,直到新数据写入或者所有指向写入端的文件描述符都被关闭
利用I/O重定向,管道可用实现进程间通信
xv6shell或者linux中将管道抽象成了符号
|
比如linux命令ls -l | grep "^-" | wc -l 目录名
就是利用了管道。5.文件系统
文件本身和文件的名字是不同的,同一个底层文件可能有不同的名称
底层文件本身的源数据就是inode(索引节点),而这些名字就是link(链接)
Inode保存有关文件的元数据,包括其类型(文件/目录/设备)、长度、文件内容在磁盘上的位置以及指向文件的链接数。
fstat(int fd, struct stat *st)
系统调用用于检索文件fd的inode信息,将信息装进stat *st 结构体中:link(char *file1, char *file2)
系统调用用于给file1文件创建另一个名字file2unlink(char *file)
删除一个文件的链接,当一个文件的链接为0且没有文件描述符引用,会释放掉inode经过查阅资料,inode也不算文件本身,这里参考阮一峰大神的文章:
点击阅读===👉:理解inode
五、感悟
通过这些接口的功能可以发现,像文件系统,I/O相关的接口,都是对一些复杂的具体程序或者过程进行抽象,包括文件描述符也是对各种字节流的抽象。这样的设计可以大大提高接口对用户的友好度,并且更加直观方便。
Lab1.1:Sleep
要求:写一个程序,shell命令行输入sleep n执行程序,用户提供整数n,可以让进程暂停到用户指定的计时数n
首先阅读其他写好的用户程序,如echo,ls等,学习如何传参和头文件
本人的程序如下(因为不熟悉工具链,所以第一个实验直接抄了下b站星麟技术大佬的研究下)
在user文件夹中创建sleep.c文件
将sleep程序加入makefile文件的UPROGS中去 📁/Makefile : 135

重新make qemu,然后就可以执行sleep了。
Lab1.2:pingpong
要求:编写一个使用UNIX系统调用的程序来在两个进程之间“ping-pong”一个字节,请使用两个管道,每个方向一个。父进程应该向子进程发送一个字节;子进程应该打印“<pid>: received ping”,其中<pid>是进程ID,并在管道中写入字节发送给父进程,然后退出;父级应该从读取从子进程而来的字节,打印“<pid>: received pong”,然后退出。您的解决方案应该在文件*user/pingpong.c*中。
程序如下:
理解如下

Lab1.3:Primes素数筛选
使用pipe和fork来设置管道。第一个进程将数字2到35输入管道。对于每个素数,您将安排创建一个进程,该进程通过一个管道从其左邻居读取数据,并通过另一个管道向其右邻居写入数据。由于xv6的文件描述符和进程数量有限,因此第一个进程可以在35处停止。
这是一个比较有挑战性的实验,原理如下。

如图打印出2-11的所有素数,第一个进程打印2并过滤掉所有2的倍数,第二个进程打印3并过滤掉所有3的倍数,第三个进程打印5并过滤掉所有5的倍数……
单看每个进程:不停的获取前一个进程的数字,打印第一个数,循环检测后来的数,如果不是第一个数的倍数,就交给下一个进程。
可以看出这是一个递归过程。
我对c语言不熟悉,这玩意对我还是太难,学习了下阿苏EEer大佬的:MIT6.S081操作系统实验.
lab1-primes. 用pipe和fork实现素数筛法
他是创建了一个长度35的数组,用数组的下标来表示0-35的数字,数组的内容表示划掉还是保留,这样第一个进程将2的倍数划掉,第二个进程将3的倍数划掉……,每个进程一开始就输出第一个没有划掉的数。
ps:看了两个大佬的写法,都是将整个数组传递到管道中,进程是一个接着一个线性执行的,前一个执行完了后一个才能拿到数组执行。因为书上介绍说这是一个筛选素数的并发版本,我还以为是一个数一个数进管道,然后每个进程都一个数一个数的判断,可能第一个进程在判断4,而3已经通过管道进入第二个进程进行判断了。不知道能不能实现。
倒腾了一下午实现了
经过多次改进,终于可以做到最大化发挥xv6的性能,将xv6操作系统的64个线程全部跑满,经测试可以筛选出270以内的所有素数。

踩过的坑:
- 整个结构的设计,每个进程都循环的从前一个管道fd读取数据,经判断后发送给下一个管道p_next,一个数一个数的读取,整体来看,每个线程都同时在不停的读取输出。因此要先fork了再在父进程循环的读写。
- 要利用wait,等待子进程关闭再关闭父进程,不然父进程提前关闭会导致程序无法结束。
- 前一个进程发送完所有数据即时释放这个进程上连接的所有管道,一方面后面的进程不会阻塞,一方面xv6的文件标识符有限,前面的释放了给后面的用。
Lab1.4:find
写一个简化版本的UNIX的find程序:查找目录树中具有特定名称的所有文件,你的解决方案应该放在*user/find.c*
直接学习user/ls.c的实现,然后再上面改就行了,要递归的打印目录
Lab1.5:xargs
Lecture2:操作系统架构
一. 需求
操作系统需要满足三个要求:
- 多路复用
- 隔离
- 交互
本教程以宏内核为中心设计
xv6用基于“LP64”的c语言编写,long和指针都是64位的,但是int是32位的。
二. 隔离
设想一个没有操作系统的电脑,上面要运行各种各样的程序,每个程序需要自己实现调用硬件资源的部分。
假设只有一个cpu可以使用,那么多个程序就必须自行暂停自己的进程,将资源空出来供其他程序使用,这就需要各个程序相互协作。但是免不了有恶意程序它不配合大伙,如下图:有三个正在运行的程序,echo、shell、bad,echo和shell都很自觉的把资源空出来给别人用,但偏偏有个坏家伙bad它占着茅坑不拉屎还不让位,这样一颗老鼠屎坏了一锅粥,整个计算机可能都会停摆。再说说内存,因为大家都可以自由使用内存,所有很有可能shell的内存用着用着就用到echo的内存去了,这样程序就崩溃了。

因此我们需要一个操作系统,将硬件资源管理起来,将程序隔离开来,并统一调度协调。
要实现强隔离,操作系统将接管硬件资源,并把硬件资源抽象成一个个接口,也就是系统调用供程序使用。操作系统管理磁盘,程序通过文件系统读写磁盘。每个进程都单独为其分配资源,各个进程互不干扰。既实现操作系统和程序的隔离,也实现程序与程序的隔离。
操作系统在进程之间透明的切换硬件处理器,应用程序就不必意识到分时共享的存在,实现复用
三. 硬件对隔离的支持
用户态与和内核态
用户态和内核态由硬件控制,寄存器中有个flag位,必须用特权指令对其进行切换
用户态:只能执行非特权指令,比如add
内核态:可以执行特权指令和非特权指令
这种设计就实现了程序和操作系统的隔离,程序运行在用户态,想要运行特权指令只能向操作系统请求。
虚拟内存
pagetable将虚拟内存地址与物理内存做对应,每个进程都有自己独立的pagetable,只能访问自己pagetable中对应的物理地址,这就形成了程序之间的隔离性
四. 用户态与内核态的切换
运用系统调用
ecall()
传入一个数字,这个数字代表程序想要调用的系统调用编号,然后整个程序通过一个接入点(后面课程学),
进入内核态,内核态中有一个syscall函数,它会检查ecall的参数并调用对应的系统调用。经过查阅资料,linux用户和内核态的转变过程如下
- 保留用户态现场(上下文、寄存器、用户栈等)
- 复制用户态参数,用户栈切到内核栈,进入内核态
- 额外的检查(因为内核代码对用户不信任)
- 执行内核态代码
- 复制内核态代码执行结果,回到用户态
- 恢复用户态现场(上下文、寄存器、用户栈等)
可以看出是需要一定的性能开销的
五. 内核架构
内核被称为可信计算空间TCB,它必须没有bug运行稳定
因此设计内核时一定要将所有的程序当做恶意程序,保证安全性。
- 宏内核:整个操作系统都运行在内核空间
- 优点:性能高
- 缺点:内核代码量太大,容易出bug
- 微内核:尽可能少的代码在内核中运行,其他一些诸如文件系统的程序都在用户态,然后与内核进行一种CS模式的交互
- 优点:内核代码少,易维护
- 缺点:频繁切换用户态和内核态,性能降低
xv6是一个宏内核的架构而Windows似乎混合了两者