MIT6.S081-第一章操作系统接口

261 阅读14分钟

第一章 操作系统接口

操作系统这个词对大家来说肯定都不陌生,Windows、Linux、macOS以及iOS、Android…这些操作系统其实每天都和我们进行交互,它们在我们每天使用的手机、电脑、平板中。那么究竟什么是操作系统?这些不同名字的操作系统有什么共同之处?希望通过这门课程的学习对操作系统能够有一个比较全面的认识。

总的来说,操作系统能够管理并对底层的硬件进行抽象,然后为使用的用户提供更多的服务。这样,运行在操作系统上的软件不需要考虑自己具体运行的底层硬件架构。操作系统通过管理硬件资源,可以让多个软件同时运行,也可以控制软件之间的交互,可以让它们之间进行数据的共享和传输。

内核,是一个特殊的应用程序,目的是为运行在操作系统上的程序提供服务。在操作系统之上运行的每个程序,都被称之为进程,当一个进程需要调用内核服务的时候,它必须通过系统调用system call来实现。每一个系统调用就是操作系统的接口。通过系统调用可以进入内核,然后在内核提供服务并返回。所以一个进程可以在用户空间user space和内核空间kernel space进行执行命令.

image-20221005195527151

所有系统调用的集合就是一个内核提供给用户运行程序时可使用的接口。xv6是一个简化的,类Unix的操作系统,所·以它提供了Unix内核里部分重要的接口,所有xv6的系统调用在下表中给出。接下来将主要从从进程和内存、文件描述符、管道、文件系统几个部分,借一些代码片段来展示shell是如何通过系统调用作为接口实现自己功能的。

shell这个词的本意是“外壳”,用于比喻内核外面的一层,实际上就是用户跟内核进行交互的界面。所以shell其实也是一个用户程序,这个应用程序运行时,通过接收用户输入的命令,然后将命令交给操作系统执行,最后将结果返回。所以shell也被称为命令行环境(command line interface,简写为 CLI)。

image-20221005195644700

系统调用和函数调用看起来很像,但是他们还是有很多不同点。真正去进行系统调是一个个的进程,进程需要调用内核服务的时候,就需要通过调用系统调用来完成。实际上在调用系统函数时,当前的进程执行就从用户态变成了内核态,进而可以调用操作系统下的硬件资源以及其他在用户态下无法完成的功能。一般情况下,操作系统以上的运行在用户态的进程是无法控制除了自己运行内存之外的内存。但是系统调用相当于转交了优先级,让进程可以完成更多的功能,同时也带来了更多待解决的问题。

1.1 进程和内存

int fork()

内核为每个在运行的进程都分配了一个标志符,被称为PID。fork系统调用用于创建新的进程,在子进程,函数返回值为0,而在父进程,返回子进程的PID。在运行fork系统调用之后,将由父进程创建了子进程,然后两个进程同时运行。

因为fork在不同的进程里有不同的返回值,说明fork函数同时运行在两个进程中,而在fork函数之前的语句只运行在父进程里,之后的语句在两个进程中同时运行。 int exit(int status)

exit系统调用用于告诉当前进程停止运行,并释放所占用的资源,其中包括内存资源以及被打开的文件(这里打开的文件一般是指文件标志符,下面会进行详细的解释)。exit函数将一个整数值作为传入参数,其中0表示运行成功,1表示运行失败。

里面传进去的status参数其实是在当前进程终止后传给父进程看的,例如当在子进程里某个运行分支出现问题时,可以通过运行exit(1),在停止运行子进程的同时,告诉父进程子进程运行出现问题,以便父进程对当前返回的错误状态进行处理。 int wait(int *status)

wait返回当前进程已经exit的子进程的PID,同时把子进程exit传入的status参数传递到某一个地址,该地址即为wait函数的传入参数status。

所以,wait函数一般在父进程里使用,当父进程运行到wait函数时,会在当前一直等待,直到自己的某个子进程exit,并传回子进程exit的状态,然后将该状态传到某一地址status里保存。否则,父进程将会阻塞在wait函数,一直等待某个子进程exit。而如果当前调用wait的进程没有子进程,那么wait函数马上返回-1。如果父进程并不在意子进程exit时的状态,可以将直接将0传入wait函数中。

尽管子进程最初拥有与父进程相同的内存内容,但父进程和子进程是以不同的内存和不同的寄存器执行的:改变一个进程中的变量并不影响另一个进程。例如,当wait的返回值被存储到父进程的pid中时,它并没有改变子进程中的变量pid。子进程中的pid的值仍然是0。 int exec(char *file, char *argv[])

通过调用exec函数,可以将另一个新的进程放到该调用函数当前运行内存空间上来运行。也就是说,让另一个进程进入到当前调用函数的进程的运行内存里,并从当前点开始运行自己的程序。这个程序被放在某个文件里,并保存在的内存的某个地址上。文件有专门的格式,例如ELF格式,其中文件里规定了哪一部分是命令,哪一部分是数据,指令从哪里开始等等。

exec需要传入两个参数,第一个参数为某一个可执行文件的地址,第二个为第一个执行文件执行时所需的字符串参数数组。在Linux系统中,exec是一个函数族,其中包括不同的exec函数有不同的传入参数方式:

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

**对于exec函数族来说,它的作用通俗来说就是使另一个可执行程序替换当前的进程。当我们在执行一个进程的过程中,通过exec函数使得另一个可执行程序A的数据段、代码段和堆栈段取代当前进程B的数据段、代码段和堆栈段,那么当前的进程就开始执行A中的内容,而这一过程中不会创建新的进程,而且PID也没有改变。**也就是说,对系统而言,还是同一个进程,不过此时运行的已经是另一个程序了。

shell其实也是一个程序,程序代码在sh.c里

image-20221005195842222

在sh.c的main函数里,在while循环里,用getcmd函数读取用户的输入,然后用fork创建一个进程来运行该命令,然后作为父进程,wait等待子进程结束运行并返回。此时在子进程里就会运行命令行里输入的命令,例如echo hello,实际上runcmd是调用了exec函数,这样就不会浪费额外的地址空间,让echo函数运行在当下子进程里。

runcmd最后一句话就是exit(0),所以当echo运行在当前进程,然后结束时,就会把成功结束的状态返回给main函数的wait,也就是父进程里。

1.2 I/O 和文件描述符

在Unix操作系统中非常重要的一点,就是把所有东西都视为文件,比如外围设备(键盘和显示器),以及每一个运行的程序其实在系统中都是一个文件。而一个程序如果要和环境产生反应和交互,就必要要有输入输出。在Unix操作系统中,所有的输入和输出都是对文件进行的,即进行所有输入输出时所面向的对象都是文件。

大部分情况下的操作都是对文件进行的,所以说你对文件进行处理时,你可能是读取read内容,也可能是对内容修改write写入。所以最开始你要告知系统你将要对文件进行处理,这一过程就叫opening the file。

此时系统会检查你是否能够对文件进行处理,比如文件是否存在?你是否对文件有读取修改权限?如果可以的话,就返回一个非负整数叫file descriptor,即fd。当需要对文件输入输出时,都需要用文件描述符fd来标识文件。

前面说过,当shell在运行的时候,输出输入是发生在屏幕和键盘之间,所以在shell打开运行时,就有了三个文件被打开,同时用文件描述符0,1,2表示。分别叫做标准输入,标准输出和标准错误。所以在shell的代码里需要保证已经为面板打开了三个文件。

image-20221005200042017

所以此时当一个程序在读取文件0,写入1和2的时候,如果没有打开其他的文件,就会默认将输入输出内容放到上面三个由文件描述符标识的文件里。

对文件进行读写

对文件进行读写需要用到read和write系统调用函数。

第一个参数是文件描述符,表示是对当前open的哪个文件进行操作,第二个参数是字符串数组,是需要写入或者读出的内容地址。第三个参数是传输的字节数。

int n_read = read(int fd, char* buf, int n)
int n_written = write(int fd, char* buf, int n)

返回值为传输内容的字节数,但是在读read操作时,返回的值可能比实际要求传输的字节数int n要少,因为文件本身可能就没有那么多字节。返回值为0,表示读到了文件末尾,返回值为-1时表示读取错误。

当进行write操作时,返回值为最后写进文件的字节数,如果最终写入的数不等于int n,表明写入发生了错误。 int open(char *file, int flags)

前面说了默认的012已经标识了三个文件,所以如果要对其他的文件进行操作的话。可以用open,但是用open去打开一个不存在的文件会发生错误,所以用create可以创建文件或者重写已经存在的文件。

open的第二个参数是标志符,用于标识文件只读,只写,可读写,可创建,具体值在fcntl.h中定义。

image-20221005200128948

宏定义功能说明
O_RDONLY只读
O_WRONLY只写
O_RDWR可读可写
O_CREATE如果文件不存在则创建文件
O_TRUNC将文件截断为零长度

在xv6里,内核为每个进程的文件描述符都有一张索引表。在每个进程里,都有一个独立的空间用于存放文件描述符以及打开文件的索引,都是从0开始。

fork的子进程继承了父进程的文件描述符表,而exec的进程虽然占用了调用进程的内存,但是拥有的还是自己的文件描述符表。 int close(fd)

close(fd)释放一个文件描述符fd,然后等待open创建分配给下一个文件。一般来说,新分配的fd一般是还未使用的最小值,意思就是从0开始寻找没有使用的最小整数作为打开文件的fd。

下面用一段代码进行举例:

char* argv[2];

argv[0] = "cat";
argv[1] = 0;

if(fork() == 0) { 
        close(0); 
    open("input.txt", O_RDONLY); 
    exec("cat", argv); 
}

上面这一段代码的功能其实就是cat < input.txt

因为在子进程里,继承了父进程的fd表,所以需要现释放fd=0,然后open input.txt,因为fd为0的文件被释放,所以此时为input.txt分配的fd为0。前面提到过,尽管子进程最初拥有与父进程相同的内存内容,但父进程和子进程是以不同的内存和不同的寄存器执行的:改变一个进程中的变量并不影响另一个进程。所以只会改变子进程的fd表,而父进程的fd表不会被改变。

然后cat对fd=0的文件,也就是input.txt进行操作。exec第一个参数表明执行cat函数,然后就是cat运行的参数数组,包括命令本身,也就是执行cat 0,即cat input.txt

所以fork和exec的另一个区别的好处除了对资源的利用,还能让程序在调用fork或exec时,可以选择是否继续使用当前的输入输出状态,也就是fd表。

下面另一个例子:

if(fork() == 0) { 
    write(1, "hello ", 6); 
    exit(0); 
    } else {
    wait(0); 
    write(1, "world\n", 6); 
    }

这里在父进程里的wait函数,会一直等待子进程结束后才会继续运行后面的代码,所以说这样保证了hello出现在world的前面。如果没有这一句,可就不一定了。 int dup(int fd)

dup系统调用复制一个当前存在的fd,然后返回一个新的fd指向同一个文件。两个fd指向同一个文件,于是对不同的fd进行读取写入可以得到相同的效果,就类似于两个指针同时指向了同一个内容。

1.3 管道

pipe对进程来说就是一对文件描述符,一个用于读,一个用于写。就像一根管道,在pipe的一端写入数据,在另一端就可以读到。管道是单向的,在内核中通常需要一定的缓冲区来缓冲消息,

pipe用于进程之间的通信,一次只建立一个通信,且是单向的, 一个进程在一端写入,另一个进程在一端读取。 pipe(int fd[])

该函数创建一个pipe,fd[0]表示读取端, fd[1]表示写入端

image-20221005200257721

pipe的端口其实就像是文件

管道在Unix系统中会被当作文件,用pipe建立起一个管道的时候,为fd[0]和fd[1]分配了两个文件描述符fd,一个pipe的两端相当于是两个文件,一个文件用于读取,一个文件用于写入。

因为分配了fd,所以pipe相当于是打开的,只不过特别的fd[0]是读取端,fd[1]是写入端。所以当我们需要向写入端口fd[1](文件fd[1])时可以用write(fd[1], buf[], n),从端口fd[0]读取时read(fd[0], buf[], n)

pipe经由fd返回两个文件描述符,fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。

因为前面说了,当父进程fork的时候,子进程会继承父进程的fd表,pipe是一种特殊类型的文件,所以在fork的时候,子进程也会继承父进程的pipe,那就是说,子进程和父进程都共享了一个pipe,因此可以通过pipe进行通信。虽然在父进程fork的时候,子进程和父进程的fd表是一样的,但是之后的修改不会相互影响。

一次pipe产生一个管道,但是在父进程和子进程里都有fd,所以说是都知道的,所以都可以在pipe上读取和写入内容。但是fork之后,我们要决定数据的流向。管道不会限制谁写入谁读取,所以在父进程里关闭读取端fd[0],子进程关闭写入端fd[1]时,这样就可以在父进程通过fd[1]写入,子进程通过fd[0]读取。反过来,如果在父进程关闭写入端fd[1],子进程关闭读取端fd[0],子进程就可以在fd[1]写入,父进程在fd[0]读出。

下面的程序,因为是要在子进程里运行wc程序,所用到的参数需要父进程传递给子进程。所以需要在父进程里关闭读取端fd[0],子进程关闭写入端fd[1]时,就可以在父进程通过fd[1]写入,子进程通过fd[0]读取。

int p[2]; //大小为2的数组用于存放2个fd
char * argv[2]; //wc的参数
argv[0] = "wc";
argv[1] = 0;

pipe(p); //创建pipe
if(fork() == 0) { //在子进程里
    close(0); //释放fd=0
    dup(p[0]);  //让0指向p[0],因为从最小fd的开始,让fd=0变成读取端,目的是为了关闭p[0]后,还能通过fd=0从pipe读取端读到信息
    close(p[0]); //释放p[0],然后就只剩下fd=0指向pipe读取端
    close(p[1]); //释放p[1]
    exec("/bin/wc", argv); //执行wc 0,也就是wc <读取内容>
    } else { 
    close(p[0]); //关闭读取端
    write(p[1], "hello world\n", 12); //像p[1]写入 
    close(p[1]); //关闭写入端
}

shell里的|其实就是pipe,将左右两边的程序联系起来,可以互相通信交流

如果在pipe端口上read的时候,没有写入,那么read会一直等待,知道有数据写入,或者等到所有fd关闭,如果所有fd都被关闭的话,此时read会返回0,就好像之前的数据已经全部接收到了,并到了文件结束。

事实上,当不可能再有信息写入的时候,read就会自动结束。或者当pipe不可能再有新数据写入的时候,read就会结束。所以在上面的代码里,在子进程里一定要把写入端关闭,如果不关闭,子进程就可能通过fd[1]向pipe写入数据,那么read就永远无法读到文件末尾,程序就会一直等待下去。

1.4 文件系统

在xv6里,chdir也就是cd命令,可以用这个函数调用实现对当前文件目录的切换,参数可以使用相对路径或绝对路径。

chdir("/a"); 
chdir("b"); 
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);

mkdir创建一个新的目录

有很多的系统调用可以创建一个新的文件或者目录:mkdir 创建一个新的目录,open 加上 O_CREATE 标志打开一个新的文件,mknod 创建一个新的设备文件。下面这个例子说明了这3种调用:

mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONGLY);
close(fd);
mknod("/console", 1, 1);

mknod 在文件系统中创建一个文件,但是这个文件没有任何内容。相反,这个文件的元信息标志它是一个设备文件,并且记录主设备号和辅设备号(mknod 的两个参数),这两个设备号唯一确定一个内核设备。当一个进程之后打开这个文件的时候,内核将读、写的系统调用转发到内核设备的实现上,而不是传递给文件系统。

因为前面提到过,在Unix系统里,所有的资源都被抽象成了文件,硬件设备也不例外。以Linux操作系统举例,所有的设备都以文件的形式存放在了/dev目录下,也被称为设备文件。所以可以通过对这些文件打开关闭进行读写操作。为了管理这些设备,系统需要为这些设备进行编号,其中每个设备有一个主设备号和次设备号。主设备号用于区分不同种类的设备,次设备号用于区分同类型的不同设备。

fstat可以通过一个文件的文件描述符得到文件的信息,信息是一个stat结构,在stat.h中定义为

image-20221005200510673

inode本质上是一个关于文件的数据结构,它存储了所有关于文件的信息,除了其名字和文件里的数据。

文件名和这个文件本身是有很大的区别。同一个文件(称为 inode)可能有多个名字,称为连接 (links)。系统调用link创建另一个文件系统的名称,它指向同一个 inode。下面的代码创建了一个既叫做 a 又叫做 b 的新文件,对文件a进行读取和写入等同于对b进行读取和写入。每个inode都由一个唯一的节点号来标识,在ftat里nlink用于记录当前inode被多少文件链接,对于当前代码,nlink应该等于2。

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

系统调用 unlink可以 从文件系统移除一个文件名,但是open一个file并删除链接的话,是创建一个临时 inode 的最佳方式,这个 inode 会在进程关闭 fd 或者退出的时候被清空。

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