XV6操作系统
XV6操作系统接口
系统调用
什么是系统调用?
系统调用就是内核的一组API(比如open write fork
),他们封装了更底层的与硬件交互的逻辑
因此系统调用是应用程序访问内核的一种方式
(即APP如果需要使用内核函数,就必须走内核提供的接口,也就是系统调用)
fork
因为会copy父进程内存所有的数据,因此如果你没用到与父进程内存一样的数据,在某种意义上,这是一种浪费,因此提出了写入时复制(COW ),消除fork
中几乎所有明显的延迟
I/O与文件描述符
要点
- 每个进程都有专属的一套文件描述符(0、1、2、3….):其中0、1、2分别为标准输入、标准输出、标准错误
- 文件描述符这层抽象,屏蔽了文件、管道、或是设备,将他们统一看成IO来处理(这是Linux一切皆文件的原因)
fork
会copy
父进程的fd表;exec
虽然会替换调用者的内存,但是fd表会保留close
关闭fd,会返还fd资源;新分配的fd将是最小的数字
重定向实现的原理
重定向依托于:fork
、exec
、文件描述符
比如这样的一个命令:cat < input.txt
(将input.txt
文件的内容,作为cat
命令的参数执行)
它会这样去执行:
1 | char *argv[2]; |
- 创建一个参数数组:
["cat", 0]
(此时的0代表着标准输入) fork
创建了一个子进程,这个子进程会copy父进程的内存,即子进程也会拥有argv
这个指针以及0、1、2的文件描述符- 当
fork()
函数返回0,代表if
的执行体让子进程去执行 - 关闭了0号文件描述符,将会回收0(标准输入)的资源;之后
open
打开文件,这个文件描述符就是当前最小的一个数字,也就是刚刚回收的0(此时的0代表着input.txt
文件的fd,注意父进程的fd表不会被改变) exec
执行cat
命令,会占用当前的内存,即会替换子进程的内存,去执行cat
命令
如此就完成了重定向,fork
+exec
是一种很常见的执行命令的手段,不会影响原有的进程,且执行完命令,子进程会自动销毁
注意:
exec
会调用其他程序,替换当前的内存(会把自己替换掉),没有返回值,除非出现了一些错误- 因此,
exec
执行完成后就会消失了 - 所以经常使用一个
fork
去执行exec
的程序
- 因此,
文件是共享资源,因此如果有两个进程同时读一个文件,那么有一个进程会阻塞
因为文件是共享资源,所以每个基础文件的偏移量在父级和子级之间共享
1
2
3
4
5
6
7
8
9
10int main(){
if(fork() == 0){
write(1, "hello", 6);
}else{
write(1, " world\n",7);
}
// 因为父子进程共享偏移量,因此不会出现父子进程打印的值被对方覆盖的情况
printf("\n");
return 0;
}dup
系统调用也可以复制一份fd(但是偏移量还是共享的),利用这个系统调用,可以实现这样的命令:1
2
3ls a.out b.out > tmp1 2>&1
2>&1 表示用描述符2复制一份1(即 标准错误将是标准输出的一个复制)
此时a.out 与b.out文件的错误信息,都会输出到tmp1文件中
管道PIPE
管道
管道我的另一篇博客介绍很详细,此处做一些补充
实现原理
管道的实现原理
pipe(int p[2])
:传入一个2个size的数组,调用后会分别存放管道的读端(下标0)与写端(下标1)
1 | int p[2]; |
管道的Highlight
其实就是通过dup + fork
命令,改写文件描述符
使用管道vs使用临时文件:
1
2 echo hello world | wc # 管道
echo hello world > tmp.txt; wc < tmp.txt # 使用临时文件+重定向
- 管道可以自动清理自己;重定向需要自己进行删除临时文件
- 管道可以传输任意长的数据;而重定向需要有足够的磁盘存储临时文件
- 管道可以在
pipeline stage
并行执行;而临时文件重定向,只能等一个执行完再去执行另一个 - 如果要实现进程间通信,管道
read
阻塞要比文件阻塞更有意义
名词解释
pipeline
与stage
:一条指令的执行是被分成多个
stage
的,每个stage
使用一个cycle
,一条指令从第一个stage
依次执行到结束,这个过程叫做pipeline
文件系统
Xv6创建文件或目录的方式
创建文件有这么几种方式:
- 以CREATE的方式
OPEN
文件 mkdir
创建目录mknod
创建设备文件
要点
- 设备文件是一种特殊的文件
mknod(char *file, int 主设备号,int 次设备号)
,在进程打开一个设备文件时,内核会将read
与write
系统调用转移到内核设备实现,而不是递交给文件系统 - 文件名与文件的含义不同。同一个底层文件称为
inode
;可以有多个名称,称为links
- 每个
link
由文件名+inode的引用
组成 inode
包含了文件的元数据(文件类型、长度、磁盘上的位置、link的数目)
- 每个
fstat
系统调用可以从inode
检索信息:
1 |
|
link
系统调用可以给文件一个新名称链接到这个文件上
1 | open("a", O_CREATE|O_WRONLY); |
Question
文件什么时候会被回收?
需要同时满足两个条件:
- 链接数为0
- 没有fd被使用
创建一个没有名称的临时
inode
的方法(该inode
将在进程关闭时自动被清除)
1 | fd = open("/tmp/xyz", O_CREATE|O_RDWR); |
XV6操作系统组成
操作系统需要满足的能力
前一章提供了很多接口函数,为什么我们需要使用操作系统呢?直接让应用程序调用硬件资源不可以吗?
直接让APP调用硬件资源是可以的:在嵌入式系统及一些实时系统很常见
- 优点:高性能(直接利用硬件资源,可以保证很高的性能)
- 缺点:当APP多于硬件资源时,无法维护
比如现在有五个打印机,但是只有一个CPU:
你可能会说,那不能在APP级别实现共享CPU吗(时分复用)?
但是APP可能会存在BUG,及时你能保证一个APP完美无缺,但是很多的APP一同运行,是无法避免BUG的,BUG的出现可能会导致资源被恶意利用。
解决这个问题的一个好的办法就是,APP之间相互隔离,使用内核来控制进程之间的调度,使他们与CPU之间保持透明。
但是某些程序之间需要合作来完成任务,我们就得提供他们交互的能力(管道)
操作系统需要满足的能力:
操作系统必须满足三个要求:
- 多路复用:进程之间共享计算机资源(进程都可以获取到CPU、内存、磁盘资源,得到执行)
- 隔离:进程之间互不影响
- 交互:进程之间可以通信
机器模式、监管模式、用户模式
前面说了,操作系统需要保证进程之间的隔离,这个隔离必须很强(不然会被恶意利用,导致系统不安全):
- 一个进程的运行失败不能影响其他进程
- 一个进程不能访问其他进程的地址
- 进程不能改变操作系统的数据结构
因此操作系统给出以下的方案:
软件方面:(解决1与2)进程虚拟地址(此篇博客详细介绍进程空间)
硬件方面:(解决3)硬件方面解决,比如RSIC-V指令架构给出三种模式:machine mode,supervisor mode, user mode
- 机器模式(machine mode):机器模式拥有全部权限(机器模式会用来装在计算机所需要的硬件,装载完成就会切换到监管模式)
- 监管模式(supervisor mode):可以运行特权指令(常说的内核态)
- 用户模式(user mode):只能运行普通指令(常说的用户态)
如果一个APP需要执行系统调用,必须切换到内核态,CPU提供了特殊的指令,可以切换CPU到监管模式,进而执行内核函数(比如说RSIC-V提供的切换指令为ecall
)
一旦切换到监管模式,就可以检验参数(比如检验地址是否越界),再去判断是否要执行该命令
内核设计
内核设计:哪些操作应该放在监管模式下?
单内核
monolithic kernel
:将整个操作系统全放在内核中
好处:
- 设计简单
- 程序交互方便(可以共享缓存实现交互)
缺点:
- 如果有bug,那么整个OS都会瘫痪(致命的缺点)
微内核
为了降低内核出错的风险,所以设计了微内核micro kernel
(最小化监管模式的代码)
但这样进程之间的交互将会变得麻烦,所以提出了IPC进程间通信,如图shell如果想读写文件,需要给File Server发送消息并且等待
Linux的实现
两种设计理念都存在于Linux中。
linux是一个单内核(但是有些程序是运行在用户级别的,比如窗口程序),但吸取了微内核的优点——模块化