Loading

MIT6.S081 ---- Preparation: Read chapter 1

Chapter 1 Operating system interfaces

xv6采用传统的内核形式,由内核为运行的程序提供服务。每个运行的程序称为进程,其内存里包含指令,数据,栈。指令实现程序的计算,数据是计算执行的对象——变量,栈组织程序的过程调用。

进程常常通过system call调用内核服务。系统调用进入内核,内核执行服务并返回。因此进程在用户空间和内核空间交替执行。

内核使用CPU提供的硬件保护机制确保用户态进程只能访问自己的地址空间。这种机制需要硬件特权,内核有特权,用户程序无。当用户程序调用system call时,硬件提升特权级,并在内核中执行预先规定的程序。

System call 描述
int fork() 创建一个进程,返回子进程的PID
int exit(int status) 终止当前进程。status传递给wait()。无返回。
int wait(int *status) 等待子进程结束。exit的参数传给*status。返回子进程PID。
int kill(int pid) 终止进程PID,返回0,错误返回-1。
int getpid() 返回当前进程的PID。
int sleep(int n) 暂停n clock ticks
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处向fd写n个字节。返回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) 删除一个文件

shell读取用户的命令并执行,是一个普通的用户程序,不是内核程序。

进程和内存

xv6进程由用户地址空间(指令,数据,栈)和对内核透明的进程状态组成。当进程处于非执行态时,xv6保存进程的CPU寄存器状态,执行时再恢复这些状态。内核为每个进程绑定一个PID。

fork系统调用:可以创建一个新的进程,和调用进程具有相同的空间内容(包括指令和数据)。调用进程和被调用进程具有不同的返回值,返回新进程PID的为父进程,返回0的为子进程。

exit系统调用:调用进程停止执行,并释放内存和打开文件。参数为0代表成功,1代表失败。

wait系统调用:返回一个(不是所有子进程,只要有一个就可以)被exit或者被kill的子进程的PID,复制子进程的status传递给wait的地址参数。如果调用进程的子进程没有exit,wait将等待。如果调用进程没有子进程,wait立即返回-1。如果父进程不关心子进程的status,则传给wait参数0地址。

初始父子进程具有相同的地址空间内容,但具有不同的地址空间和寄存器。其中一个改变变量不会影响另一个。

exec系统调用:将文件系统上的一个文件导入到一个新的地址空间镜像,替换调用进程的地址空间。这个文件具有特殊的格式,明确指出哪部分是指令,哪部分是数据,指令从哪里开始执行。xv6使用ELF格式。exec执行成功后不返回调用程序。指令从ELF header声明的entry point开始执行。

char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

用程序/bin/echo替换调用程序。多数程序忽略第一个参数,它通常是程序名称。

sh.c:shell进程getcmd输入命令后,调用fork系统调用创建子进程,调用wait系统调用等待子进程执行命令。子进程执行runcmd函数,调用exec系统调用执行echo hello。如果exec执行成功,子进程将执行echo程序且不再返回rumcmd。最终echo调用exit系统调用通知shell进程。

fork和exec分开设计的原因:IO重定向。先fork后exec,采用copy-on-write避免fork复制内存空间内容引起的浪费。

sbrk系统调用:fork依据父进程的内存拷贝非配内存。exec为可执行文件装入分配足够的内存。运行时进程需要更多的内存可以调用sbrk扩大数据地址空间,sbrk返回新地址空间的位置。

I/O和文件描述符

一个文件描述符是一个小整数,是一个内核管理对象,进程可以对它进行读写。进程获得一个文件描述符的方式:打开一个文件、目录、或者设备,创建一个管道,拷贝一个存在的描述符。将文件、管道、设备抽象为文件描述符,使它们看起来像字节流。

xv6进程表中用文件描述符作为对文件的索引,每个进程有一段属于文件描述符的私有空间:描述符0代表标准输入,1代表标准输出,2代表标准错误流。shell利用这种特性实现了I/O重定向和管道。shell确保console始终有3个文件描述符打开。

read系统调用:read(fd, buf, n)最多从文件描述符fd读取n个字节,拷贝到buf,返回读取的字节数。每个指向文件的描述符都有一个偏移,read从当前文件偏移读取数据,偏移推进到读取结束位置,后续的read从之前的偏移开始读取,当没有字节可以读取时,read返回0表示指向文件末尾。

write系统调用:write(fd, buf, n)从buf向文件fd写n个字节,返回写的字节数。只有错误发生返回才小于n个字节。文件偏移原理和read类似.

close系统调用:释放一个文件描述符,可被重新使用,新分配的文件描述符从最低的数字未被使用的描述符开始。

I/O重定向:文件描述符和fork共同实现I/O重定向。fork系统调用拷贝父进程的文件描述符表和它的内存空间,所以父子进程的打开文件相同。exec系统调用替换了父进程的内存空间,但保留了父进程的文件表。实现I/O重定向:fork之后,在子进程修改选择的文件描述符(让已创建的文件描述符指向其他文件,fd的值不变,但是fd指向的文件已经改变,我理解这里的作用是将使用cat功能的shell和cat功能进行分层,这样shell可以通过I/O重定向进行功能的组合从而实现更强大的功能),调用exec运行新的程序(保留修改的文件描述符指向)。

char *argv[2];

argv[0] = "cat";
argv[1] = 0;
if (fork() == 0) {
    clse(0);
    open("input.txt", O_RDONLY);
    exec("cat", argv);
}
/**
关闭文件描述符0后,open会用文件描述符0分配给打开文件(close系统调用的作用,0是最小的可用文件描述符)。
cat执行时标准输入指向的是input.txt文件。
这里只改变了子进程的文件描述符,父进程的没有改变。
**/

I/O重定向解释了fork系统调用和exec系统调用分开的原因:shell可以重定向子进程的I/O而不破坏shell的I/O设置。假设存在forkexec系统调用,那么实现I/O重定向有点不合适。shell要么在调用forkexec前修改I/O配置,要么增加forkexec的I/O配置参数,最起码要在每个程序例如cat等做I/O重定向处理。

尽管fork系统调用复制了文件描述符表,但每个文件偏移仍被父子进程共享。

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

dup系统调用:复制一个存在的文件描述符,返回一个新的文件描述符指向相同的I/O对象(文件、管道、设备)。通过fork系统调用和dup系统调用,两个来源相同的文件描述符共享一个文件偏移。否则不共享,尽管通过open系统调用打开相同的文件。
dup允许shell实现如下命令:ls existing-file non-existing-file > tmp1 2>&12>&1 shell提供给命令文件描述符2,这是文件描述符1的duplicate。使得existing-file的名称和non-existing-file的错误信息都会显示在tmp1。xv6不支持异常文件描述符的I/O重定向。

文件描述符很强大,隐藏了指向细节:一个进程写一个文件描述符1,既可以写文件,又可以写设备如console,还可以写管道。

管道

管道是以一对文件描述符提供给进程的一段内存缓冲区,一端用来读,一端用来写。管道提供给进程通信的方法。

int p[2];
char *argv[2];

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

pipe(p);
if (fork() == 0) {
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv);
} else {
    close(p[0]);
    write(p[1], "hello world\n", 12);
    close(p[1]);
}

这个程序创建了一个新的管道,将读写文件描述符标在数组p里。fork之后,父子进程都有指向管道的文件描述符。子进程调用close系统调用和dup系统调用使文件描述符0指向管道的读端。关闭p中的文件描述符,调用exec系统调用执行wc程序。wc从标准输入读取,这个标准输入指向管道的读端。父进程关闭管道的读端,写管道,然后关闭写端。
如果没有数据可读,读管道将阻塞直到有数据写入或者所有指向写端的文件描述符被close。如果到达一个数据文件的末尾,read将会返回0。对于子进程在执行wc之前关闭管道的写端很重要,因为read可能会因此阻塞:如果wc的一个文件描述符指向了管道的写端,wc可能会一直阻塞。

xv6实现pipelines(;,&&,|| 用分割符将多个命令写在同一行):grep fork sh.c | wc -l子进程创建一个管道链接pipelines的左右两端。分别为左右两端调用fork系统调用和runcmd函数,等待他们结束。右端的pipeline可能是一个包含管道的命令(a | b | c),它又fork两个新的进程(一个对b,一个对c)。因此,shell创建了一个进程树。叶节点是命令,内部节点是等待左右子进程完成的父进程。

管道和临时文件:echo hello wprld | wc 可以不用管道实现 echo hello world >/tmp/xyz; wc </tmp/xyz; 。管道相对于临时文件的四个优势:

  • 管道可以自我清除。文件重定向时,shell必须很小心的移除/tmp/xyz;
  • 管道可以传递任意长度的数据流。文件重定向要求足够的磁盘空间存储所有数据;
  • 管道允许像流水线样并行执行。临时文件实现则需要顺序执行。
  • 如果正在实现IPC,管道的阻塞读和写比临时文件的非阻塞读和写更有效。

文件系统

xv6文件系统提供数据文件(包含连续的字节数组)和目录(包含指向数据文件的有名目录和其他目录)。目录形成了一棵树,以一个特殊的目录/root开始。

chdir系统调用:可以改变当前路径。

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

open("a/b/c", O_RDONL);
//若目录和文件都存在,两种实现效果一样
//第一种实现改变当前路径,第二种实现不改变当前路径。

mkdir系统调用:创建一个新的目录。

open系统调用:带有O_CREATE标志创建一个新的数据文件。

mknod系统调用:创建一个新的设备文件。mknod(devname, major number, minor number)。

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

mknod系统调用创建了一个特殊的文件指向了一个设备。一个设备文件由主设备号和从设备号(两个设备号唯一确定一个设备,主设备号标识某一类设备,从设备号区分一类设备中的不同设备)关联。当一个进程打开了一个设备文件后,内核将read和write系统调用转向内核设备而不再指向文件系统。

一个文件名称标识文件。文件根本的标识是inode,可以有多个文件名,成为links。每个link由一个目录中的entry(快捷方式)组成,entry包含一个文件名称和指向inode的链接。一个inode含有一个文件的metadata(元数据),包含文件类型(文件、目录、设备),文件长度,文件内容在磁盘上的位置,链接这个文件的link数量。

link系统调用:创建指向和一个存在的文件指向相同inode的别名文件。

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

对a和对b读写相同。每个inode对应唯一的inode number。a和b的fstat的关键内容相同:相同的inode number(ino)和相同的nlink数量(nlink = 2)。

unlink系统调用:从文件系统移除一个名称。只有当文件的link数量为0并且没有文件描述符指向文件时,文件的inode和相应内容的硬盘空间才会释放。

Unix从shell中提出了可调用的文件工具作为用户层程序,例如mkdir,ln,rm。这个设计允许任何人通过添加新的用户层程序扩展命令行接口。这个思路很聪明,同时期其他系统将这些命令构建到shell中,并把shell加到内核中。

一个例外是cd,它被构建进了shell。cd一定改变当前shell的工作路径。如果cd作为一个常规的命令,shell将fork一个子进程,子进程执行cd程序改变了子进程的工作路径,但父进程(shell进程)的工作路径并没有改变。

Real world

Unix结合了标准的文件描述符,管道,方便的shell操作语法,对于写出通用目的的、可复用的程序有巨大优势。这个思路引爆了"software tools"(被开发者用来创建、维护、调试、支撑其他应用和程序的一系列计算机程序)的文化,对于Unix的强大和推广至关重要,shell第一次被成为脚本语言。Unix系统调用接口今天仍用于BSD,Linux和macOS。
Unix操作系统通过Portable Operating System Interface(POSIX)标准变得标准化。xv6没有遵循POSIX标准:许多系统调用没有实现(lseek),提供的许多系统调用和标准不同。对于xv6的主要目标是简洁清晰,提供一个简单的类Unix系统调用接口。许多人用更多的系统调用接口和一个简单的可以运行基本Unix程序的C library扩展xv6。然而现代内核提供了远多于xv6的系统调用接口和内核服务。例如,支持networking, windowning systems, 用户级线程和很多设备驱动等。现代内核持续快速发展,提供了很多超越POSIX的特性。
Unix用一系列文件名称和文件描述符接口统一了不同资源类型(文件,目录,设备)的访问。 (Dave Presotto, Rob Pike, Ken Thompson, and Howard Trickey. Plan 9, a distributed system.
In In Proceedings of the Spring 1991 EurOpen Conference, pages 43–50, 1991.)该论文将“资源即文件”思想用到了网络、图形化等。然而许多起源于Unix的系统并没有遵循这个规则。
文件系统和文件描述符成为强大的抽象,但对于OS接口来说也有许多其他模型。Multics是Unix的前身,将文件存储抽象为像内存一样的方式,产生了不同的接口风格。Multics设计复杂对于Unix的设计者有直接影响,从而做出简化。
xv6没有提供用户的概念和用户保护的概念,在Unix模式中,所有的xv6处理均在root下。
任何操作系统一定实现 对底层硬件的多样处理,进程的相互隔离,提供受控的IPC机制。

posted @ 2022-01-06 16:11  seaupnice  阅读(108)  评论(0编辑  收藏  举报