xv6阅读笔记(0)——操作系统接口

1,124 阅读14分钟

最近在学习MIT 6.828操作系统课程,课程里面采用的主要基于xv6——一个类Unix的教学操作系统,基于该操作系统,从原理上并结合实验实现微内核的操作系统。关于xv6的文档以及源码的学习会记录在此,如有错误还请多指教交流。

  • MIT 6.828 课程地址:https://pdos.csail.mit.edu/6.828/2018/schedule.html

  • xv6文档地址:https://pdos.csail.mit.edu/6.828/2018/xv6/book-rev11.pdf

  • xv6源码地址:https://pdos.csail.mit.edu/6.828/2018/xv6/xv6-rev11.pdf

操作系统接口

操作系统是计算机底层资源和硬件的管理者和服务的提供者,形象点来说就是一个智能管家,这个管家的日常工作就是:

  • 计算机资源方面:将计算机的资源提供给多个程序间共享和使用。
  • 计算机硬件方面:管理并抽象硬件的底层,从而给程序提供相比单独只有硬件更加优秀的服务,使得程序对硬件无需感知。另一方面,使得多个程序可以同时(或近似同时)地共享使用底层硬件。
  • 最终目的:程序可以受控制地进行交互,从而实现程序共享数据与协同工作。

对于程序来说,最重要的是操作系统如何提供服务,以及提供的服务能够做到简单、精准、通用。操作系统通过接口来提供给程序服务,首先来看一张图:

如图0-1所示,xv6使用了传统的内核概念——一个用于向其他运行中的程序提供服务的特殊程序。从图中可以看到内核(kernel)和两个进程(shell、cat),内核位于内核空间而两个进程位于用户空间。并且shell进程通过系统调用(system call)来和内核进行交互。

进程

每一个运行中程序称之为进程,每一个进程拥有包含指令、数据、栈的内存空间。

  • 指令实现的程序的运算。
  • 数据是运算过程的变量。
  • 堆栈管理程序的过程调用。

用户空间和内核空间

指令实现了程序的运算,而CPU里的指令种类繁杂,有些指令如果错用可能会导致系统的崩溃,因此就需要限制指令的使用。于是把指令分成了特权指令非特权指令,特权即特殊权限的意思,只有操作系统可以使用,而非特权指令则提供给用户程序使用。

而操作系统的核心是内核,内核可以访问受保护的内存空间也可以访问底层硬件,是可以执行特权指令的特殊程序。为了保护内核的安全,操作系统便将虚拟地址空间划分为了内核空间和用户空间,内核空间由内核使用,用户空间由用户进程使用。

那么如果进程想要执行特权指令时该怎么实现,这里就有两个概念:内核态和用户态。当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。内核态的进程可以执行所有指令,用户态的进程则会受到限制。

系统调用

系统调用是用户空间进程访问内核的唯一入口。当用户进程执行系统调用后,硬件会提升权限级别并执行一些内核中预定义的功能。

系统调用 描述
fork() 创建进程
exit() 结束当前进程
wait() 等待子进程结束
kill(pid) 结束 pid 所指进程
getpid() 获得当前进程 pid
sleep(n) 睡眠 n 秒
exec(filename, *argv) 加载并执行一个文件
sbrk(n) 为进程内存空间增加 n 字节
open(filename, flags) 打开文件,flags 指定读/写模式
read(fd, buf, n) 从文件中读 n 个字节到 buf
write(fd, buf, n) 从 buf 中写 n 个字节到文件
close(fd) 关闭打开的 fd
dup(fd) 复制 fd
pipe( p) 创建管道, 并把读和写的 fd 返回到p
chdir(dirname) 改变当前目录
mkdir(dirname) 创建新的目录
mknod(name, major, minor) 创建设备文件
fstat(fd) 返回文件信息
link(f1, f2) 给 f1 创建一个新名字(f2)
unlink(filename) 删除文件

进程和内存

一个 xv6 进程由两部分组成,一部分是用户内存空间(指令,数据,栈),另一部分是仅对内核可见的进程状态。每个进程有一个PID(process identifier),用于唯一标识进程。

fork

一个进程通过系统调用fork来创建一个新进程,fork创建的新进程也被称为子进程,fork创建新进程后会先给新进程分配资源,然后把原来进程的所有值都复制到新进程,即子进程的内存同创建它的内存内容同创建它的进程(父进程)一样。

fork函数还有一个奇妙之处,就是fork函数被调用一次但是能够返回两次。在fork函数执行之前只有父进程在执行代码,在fork函数执行之后就变成了父进程和子进程都在执行代码。他有三种不同的返回值:

  • 在父进程中,fork返回新创建子进程的PID
  • 在子进程中,fork返回0
  • 如果出现错误,fork返回一个负值
int pid = fork(); 
if(pid > 0) {
    // 在父进程中,返回子进程的PID
 printf("parent: child=%d\n", pid);
 // wait()会暂时停止目前进程的执行, 直到有信号来到或子进程结束. 
    // 如果在调用wait()时子进程已经结束, 则wait()会立即返回子进程结束状态值. 
    // 子进程的结束状态值会由参数 status 返回, 而子进程的进程识别码也会一并返回. 
    // 如果不在意结束状态值, 则参数 status 可以设成NULL 即 pid = wait(NULL).
    pid = wait();
 printf("child %d is done\n", pid); 
} else if(pid == 0){
    // 在子进程中,返回0
 printf("child: exiting\n");
    // 导致调用它的进程停止运行,并释放资源
    exit(); 
} else {
    // 为负值,出现错误
 printf("fork error\n"); 
}
输出:
parent: child=1234
child: exiting
parent: child 1234 is done

exec

系统调用exec一般是从可执行文件中读取内存镜像,然后用读取到的内存镜像替代调用它的进程的内存空间,即用新的进程代替了原来的进程,进程的PID不变,而原来进程的代码段、数据段、堆栈段被新的进程所替代。

一般执行exec的做法是先fork出一个新进程,由于子进程的内存内容和父进程一样,但是内存空间和寄存器都是分开的。子进程执行exec,那么数据就会被新进程所替代。

exec接受两个两个参数,可执行文件名和一个字符串参数数组。举例来说:

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

在实际中,shell用以下代码执行程序

int main(void)
{  
 static char buf[100];  
    int fd; 
    // Ensure that three file descriptors are open.
    // 保证有三个文件描述符打开
    while((fd = open("console", O_RDWR)) >= 0) {
        if(fd >= 3) { 
            close(fd); 
            break;
        } 
    }
    // Read and run input commands.
    // getcmd读取命令行输入
    while(getcmd(buf, sizeof(buf)) >= 0){
        if(buf[0] == ’c’ && buf[1] == ’d’ && buf[2] == ’ ’){ 
         // Chdir must be called by the parent, not the child. 
            buf[strlen(buf)−1] = 0; // chop \n 
            if(chdir(buf+3) < 0)
                printf(2, "cannot cd %s\n", buf+3);
            continue; 
        }
        // 调用fork生成一个shell的子进程调用runcmd执行用户命令
        if(fork1() == 0)
            runcmd(parsecmd(buf));
        // 父进程调用wait
        wait();
    } exit();
}

I/O和文件描述符

linux有一个重要的设计思想就是一切皆文件,于是所有的资源都有了统一的接口,内核给所有访问的资源分配了文件描述符。文件描述符本质上是一个非负的整数,文件描述符可以指向的资源包括:

  • 文件/目录
  • 输入输出设备
  • 管道
  • 套接字
  • 其他Unix文件类型

进程可以通过多种方式获得一个文件描述符,如打开文件、目录、设备,或者创建一个管道(pipe),或者复制已经存在的文件描述符。每个进程都有一张表,而 xv6 内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符空间。

按照惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标准输出),从文件描述符2输出错误(标准错误输出)

read / write

我们用fd表示文件描述符指向的"文件"。

read(fd, buf, n) 表示从 fd 读最多 n 个字节(fd 可能没有 n 个字节),将它们拷贝到 buf 中,然后返回读出的字节数。每一个指向文件的文件描述符都和一个偏移关联。read 从当前文件偏移处读取数据,然后把偏移增加读出字节数。紧随其后的 read 会从新的起点开始读数据。当没有数据可读时,read 就会返回0,这就表示文件结束了。

write(fd, buf, n) 表示写 buf 中的 n 个字节到 fd 并且返回实际写入的字节数。如果返回值小于 n 那么只可能是发生了错误。同理,write 也从当前文件的偏移处开始写,在写的过程中增加这个偏移。

cat就是将数据从标准输入复制到标准输出实现的,且由于采用了文件描述符,cat不需要考虑输入源或输出目的是文件、控制台、管道或其他的什么,代码如下:

char buf[512];
int n;

for(;;){
    n = read(0, buf, sizeof buf);
    if(n == 0)
        break;
    if(n < 0){
        fprintf(2, "read error\n");
        exit();
    }
    if(write(1, buf, n) != n){
        fprintf(2, "write error\n");
        exit();
    }
}

close

系统调用close会释放一个文件描述符,使得它未来可以被 open, pipe, dup 等调用重用。一个新分配的文件描述符永远都是当前进程的最小的未被使用的文件描述符。

IO重定向

I/O重定向表示可以将标准的输入输出重定向到其他文件上,例如cat < input.txt就是将cat的输入重定向为input.txt,即input.txt作为cat的输入。

实现过程:

  • fork子进程,复制父进程的文件描述符和内存内容
  • 重新打开指定文件的文件描述符
  • exec替换子进程的内存但是保留文件描述符

可以看一下简化版的代码实现 cat < input.txt:

char *argv[2];
argv[0] = "cat";
argv[1] = 0;
// fork子进程
if(fork() == 0) {
    // 关闭文件描述符0
    // 因为0是open执行时的最小可用文件描述符
    // 所以可以保证open会使用0作为新打开的文件input.txt的文件描述符
    close(0);
    open("input.txt", O_RDONLY);
    // exec执行
    exec("cat", argv);
}

需要注意的是,虽然fork子进程复制了父进程的文件描述符,但是父子进程指向的是同一个文件,因此文件的偏移在父子进程间是共享的,在下面这段代码中,父进程的 write 会从子进程 write 结束的地方继续写。

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

dup

dup 复制一个已有的文件描述符,返回一个指向同一个输入/输出对象的新描述符。

管道

管道是一个小的内核缓冲区,它以文件描述符对的形式提供给进程,一个用于写操作(输入管道),一个用于读操作(输出管道),作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道。

首先来看一段代码,运行了wc程序(统计指定文件中的字节数、字数、行数,并将统计结果显示输出),将wc的标准输入绑定到管道的读端口(即管道的输出)上:

int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
// 建立管道,并且将读写描述符记录在数组 p 中
pipe(p);
// fork后,父进程和子进程都有了指向管道的文件描述符
if(fork() == 0) {
    // 子进程
    // 1.关闭文件描述符0
    // 2.dup将管道的读端口拷贝在描述符0上,即绑定标准输入到管道的读接口
    // (因为此时0为关闭,再分配时会分配最小的值即0)
    // 3.关闭 p 中的描述符(防止wc指向了管道的写接口)
    // 4.执行wc
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv);
} else {
    // 父进程
    // 1.父进程向管道的写接口写入
 // 2.然后关闭管道的两个文件描述符
    write(p[1], "hello world\n", 12);
    close(p[0]);
    close(p[1]);
}

比较两个命令:

// 管道实现
echo hello world | wc
// 无管道实现
echo hello world > /tmp/xyz; wc < /tmp/xyz

虽然管道看上去类似于临时文件,但还是有着不同点:

  • 首先,管道会进行自我清扫,如果是 shell 重定向的话,我们必须要在任务完成后删除 /tmp/xyz
  • 第二,管道可以传输任意长度的数据。
  • 第三,管道允许同步:两个进程可以使用一对管道来进行二者之间的信息传递,每一个读操作都阻塞调用进程,直到另一个进程用 write 完成数据的发送。

文件系统

在linux文件系统里包含了文件和目录,文件就是一个简单的字节数组,而目录包含了指向文件和其他目录的引用。

前面说到linux系统里一切都可以看作文件,xv6把目录看成是一种特殊的文件。我们将目录看成是一棵树,它的根节点是一个特殊的目录 root/a/b/c 指向一个在目录 b 中的文件 c,而 b 又是在目录 a 中的,a 又是处在 root 目录下的。从/ 开始的目录为root开始的目录,不从 / 开始的目录则表示相对调用进程当前目录的目录,而调用进程的当前目录可以通过 chdir 这个系统调用进行改变。

chdir(path)表示将当前工作目录改变成参数path所指的目录。

例如下面的两段代码打开的是同一个文件:

// 使用chdir将当前目录切换到 /a/b后打开c
chdir("/a");
chdir("b");
open("c", O_RDONLY);

// 直接打开c
open("/a/b/c", O_RDONLY);

创建新文件或目录(mkdir、open、mknod)

mkdir 表示创建一个新的目录。

open 加上 O_CREATE 标志打开一个新的文件

mknod 创建一个新的设备文件。Linux 中的设备有2种类型:字符设备(无缓冲且只能顺序存取)和块设备(有缓冲且可以随机存取)。有些设备是对实际存在的物理硬件的抽象,而有些设备则是内核自身提供的功能(不依赖于特定的物理硬件,又称为"虚拟设备")。每个设备在 /dev 目录下都有一个对应的文件(节点),称为设备文件。

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

连接(link)

一个文件可能同时有多个名字,这多个名字称为该文件的连接(links)。系统调用link可以给文件创建新的名称而这新的名称和旧的名称都指向同一个文件。下面的代码创建了一个既叫做 a 又叫做 b 的新文件。

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

每一个文件都有一个唯一的inode号,想要知道ab指向是否同一个文件,可以用fstat查看指向的文件的信息比对inode号是否一致。

系统调用unlink则可以从文件移除一个文件名。

总结

综上,linux将文件描述符、管道、文件系统等和shell命令整合在一起,使我们可以写出通用、可重用的程序,而xv6是如何实现这些操作系统接口是我们接下来学习的重点。

本文使用 mdnice 排版