XV6操作系统

引言:MIT-S081课程——XV6操作系统(更新ing);包括管道、重定向的实现原理;文件描述符的作用

XV6操作系统接口

系统调用

什么是系统调用?

系统调用就是内核的一组API(比如open write fork),他们封装了更底层的与硬件交互的逻辑

因此系统调用是应用程序访问内核的一种方式

(即APP如果需要使用内核函数,就必须走内核提供的接口,也就是系统调用

fork因为会copy父进程内存所有的数据,因此如果你没用到与父进程内存一样的数据,在某种意义上,这是一种浪费,因此提出了写入时复制(COW ),消除fork 中几乎所有明显的延迟

I/O与文件描述符

要点

  1. 每个进程都有专属的一套文件描述符(0、1、2、3….):其中0、1、2分别为标准输入、标准输出、标准错误
  2. 文件描述符这层抽象,屏蔽了文件、管道、或是设备,将他们统一看成IO来处理(这是Linux一切皆文件的原因)
  3. forkcopy父进程的fd表;exec虽然会替换调用者的内存,但是fd表会保留
  4. close关闭fd,会返还fd资源;新分配的fd将是最小的数字

重定向实现的原理

重定向依托于:forkexec、文件描述符

比如这样的一个命令:cat < input.txt(将input.txt文件的内容,作为cat命令的参数执行)

它会这样去执行:

1
2
3
4
5
6
7
8
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
close(0);
open("input.txt", O_RDONLY);
exec("cat", argv);
}
  1. 创建一个参数数组:["cat", 0](此时的0代表着标准输入)
  2. fork创建了一个子进程,这个子进程会copy父进程的内存,即子进程也会拥有argv这个指针以及0、1、2的文件描述符
  3. fork()函数返回0,代表if的执行体让子进程去执行
  4. 关闭了0号文件描述符,将会回收0(标准输入)的资源;之后open打开文件,这个文件描述符就是当前最小的一个数字,也就是刚刚回收的0(此时的0代表着input.txt文件的fd,注意父进程的fd表不会被改变)
  5. exec执行cat命令,会占用当前的内存,即会替换子进程的内存,去执行cat命令

如此就完成了重定向,fork+exec是一种很常见的执行命令的手段,不会影响原有的进程,且执行完命令,子进程会自动销毁

注意:

  • exec会调用其他程序,替换当前的内存(会把自己替换掉),没有返回值,除非出现了一些错误

    • 因此,exec执行完成后就会消失了
    • 所以经常使用一个fork去执行exec的程序
  • 文件是共享资源,因此如果有两个进程同时读一个文件,那么有一个进程会阻塞

  • 因为文件是共享资源,所以每个基础文件的偏移量在父级和子级之间共享

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int main(){
    if(fork() == 0){
    write(1, "hello", 6);
    }else{
    write(1, " world\n",7);
    }
    // 因为父子进程共享偏移量,因此不会出现父子进程打印的值被对方覆盖的情况
    printf("\n");
    return 0;
    }
  • dup系统调用也可以复制一份fd(但是偏移量还是共享的),利用这个系统调用,可以实现这样的命令:

    1
    2
    3
    ls 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int p[2];
char*argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
// 此时的fd:0、1、2、3、4
if(fork() == 0) {
// 子进程此时的fd:0、1、2、3、4
close(0);// 1、2、3、4
dup(p[0]);// 0、1、2、3、4 (此后的0表示管道的读端)
close(p[0]);//0、1、2、4
close(p[1]);//0、1、2
exec("/bin/wc", argv);
} else {
// 父进程此时的fd:0、1、2、3、4
close(p[0]); // 0、1、2、4
write(p[1], "hello world\n", 12); // 由写端写入数据 hello world
close(p[1]); // 0、1、2
}

管道的Highlight

其实就是通过dup + fork命令,改写文件描述符

使用管道vs使用临时文件:

1
2
echo hello world | wc # 管道
echo hello world > tmp.txt; wc < tmp.txt # 使用临时文件+重定向
  1. 管道可以自动清理自己;重定向需要自己进行删除临时文件
  2. 管道可以传输任意长的数据;而重定向需要有足够的磁盘存储临时文件
  3. 管道可以在pipeline stage并行执行;而临时文件重定向,只能等一个执行完再去执行另一个
  4. 如果要实现进程间通信,管道read阻塞要比文件阻塞更有意义

名词解释 pipelinestage

一条指令的执行是被分成多个stage的,每个stage使用一个cycle,一条指令从第一个stage依次执行到结束,这个过程叫做pipeline

文件系统

Xv6创建文件或目录的方式

创建文件有这么几种方式:

  • 以CREATE的方式OPEN文件
  • mkdir创建目录
  • mknod创建设备文件

要点

  1. 设备文件是一种特殊的文件mknod(char *file, int 主设备号,int 次设备号),在进程打开一个设备文件时,内核会将readwrite系统调用转移到内核设备实现,而不是递交给文件系统
  2. 文件名与文件的含义不同。同一个底层文件称为inode;可以有多个名称,称为links
    • 每个link文件名+inode的引用组成
    • inode包含了文件的元数据(文件类型、长度、磁盘上的位置、link的数目)

fstat系统调用可以从inode检索信息:

1
2
3
4
5
6
7
8
9
10
11
#define T_DIR     1   // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device

struct stat {
int dev; // File system's disk device
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file
uint64 size; // Size of file in bytes
};

link系统调用可以给文件一个新名称链接到这个文件上

1
2
open("a", O_CREATE|O_WRONLY);
link("a", "b");

Question

文件什么时候会被回收?

需要同时满足两个条件:

  1. 链接数为0
  2. 没有fd被使用

创建一个没有名称的临时inode的方法(该inode将在进程关闭时自动被清除)

1
2
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");

XV6操作系统组成

操作系统需要满足的能力

前一章提供了很多接口函数,为什么我们需要使用操作系统呢?直接让应用程序调用硬件资源不可以吗?

直接让APP调用硬件资源是可以的:在嵌入式系统及一些实时系统很常见

  • 优点:高性能(直接利用硬件资源,可以保证很高的性能)
  • 缺点:当APP多于硬件资源时,无法维护

比如现在有五个打印机,但是只有一个CPU:

​ 你可能会说,那不能在APP级别实现共享CPU吗(时分复用)?

​ 但是APP可能会存在BUG,及时你能保证一个APP完美无缺,但是很多的APP一同运行,是无法避免BUG的,BUG的出现可能会导致资源被恶意利用。

​ 解决这个问题的一个好的办法就是,APP之间相互隔离,使用内核来控制进程之间的调度,使他们与CPU之间保持透明。

​ 但是某些程序之间需要合作来完成任务,我们就得提供他们交互的能力(管道)

操作系统需要满足的能力:

操作系统必须满足三个要求:

  • 多路复用:进程之间共享计算机资源(进程都可以获取到CPU、内存、磁盘资源,得到执行)
  • 隔离:进程之间互不影响
  • 交互:进程之间可以通信

机器模式、监管模式、用户模式

前面说了,操作系统需要保证进程之间的隔离,这个隔离必须很强(不然会被恶意利用,导致系统不安全):

  1. 一个进程的运行失败不能影响其他进程
  2. 一个进程不能访问其他进程的地址
  3. 进程不能改变操作系统的数据结构

因此操作系统给出以下的方案:

软件方面:(解决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是一个单内核(但是有些程序是运行在用户级别的,比如窗口程序),但吸取了微内核的优点——模块化