xv6使用传统形式内核(一个向其他运行中的程序提供服务的特殊程序)。
每一个正在运行的程序称为进程,进程拥有自己的指令、数据与栈的内存空间。它们的功能如下:
一个计算机通常有许多进程,但只有一个内核。
进程通过调用系统调用来调用内核的服务。
系统调用是操作系统接口中的一个调用。系统调用会进入内核,让内核执行服务后返回。而进程主体运行在用户空间。所以,进程在内核空间和用户空间间交替运行。如下图:
内核使用CPU提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己的内存。
内核具有硬件特权,能够访问上面这些受保护的资源,但进程不行。
当用户程序调用系统调用时,硬件提高特权级别并开始执行内核中预定义的函数。
内核提供的系统调用集合就是用户程序可见的接口。
xv6提供了传统Unix内核所提供的服务和系统调用的一个子集。如下图:
系统调用 | 描述 |
---|---|
int fork() | 创建一个进程,返回子进程的PID。 |
int exit(int status) | 终止当前进程,并将status传递给wait()。不会返回。 |
int wait(int *status) | 等待子进程结束,并将status接收到参数*status中,返回其PID。 |
int kill(int pid) | 终止给定PID的进程,成功返回0,失败返回-1。 |
int getpid() | 返回当前进程的PID。 |
int sleep(int n) | 睡眠n个时钟周期。 |
int exec(char *file, char *argv[]) | 通过给定参数加载并执行一个文件;只在错误时返回。 |
char *sbrk(int n) | 使进程内存增加n字节,返回新内存的起始地址。 |
int open(char *file, int flags) | 打开一个文件,flags表示读或写,返回fd(文件描述符)。 |
int write(int fd, char *buf, int n) | 将buf中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[]) | 创建管道,将读/写文件描述符放置在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) | 将打开的文件的信息放置在*st中。 |
int stat(char *file, struct stat *st) | 将命名文件信息放置在*st中。 |
int link(char *file1, char * file2) | 为文件file1创建一个新的名称file2。 |
int unlink(char *file) | 移除一个文件。 |
上图中的系统调用,若无特殊说明,调用成功返回1,否则返回0。
xv6实现了进程、内存、文件描述符、管道、文件系统和shell。
一个xv6进程由用户空间内存(指令、数据和堆栈)和内核私有的进程状态组成。
xv6可以透明地切换当前CPU正在执行的进程。当一个进程暂时不使用CPU时,xv6保存该进程的CPU环境(具体地讲,就是CPU内部的寄存器),在下次运行该进程时恢复它们。内核为每个进程关联一个独一无二的进程标识符(PID)。
int* pid = fork();
if (pid > 0)
{
printf("parent: child=%d\n", pid);
pid = wait((*int* *)0);
printf("child %d is done\n", pid);
}
else if (pid == 0)
{
printf("child: exiting\n");
exit(0);
}
else
{
printf("fork error\n");
}
上面代码的执行结果有两种可能:
可能1:
parent: child=1234
child: exiting
child 1234 is done
可能2:
child: exiting
parent: child=1234
child 1234 is done
注意:子进程与父进程拥有相同的内存内容,但进程是在不同内存和寄存器中执行,即改变其中一个进程的变量不影响另一个进程。
exec使用案例:
char* *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
上面程序会执行/bin/echo程序,并将argv数组作为参数。
文件描述符是一个小整数,代表一个可由进程读取或写入的内核管理对象。
新分配的文件描述符总是当前进程中最小的未使用描述符。
文件描述符的获取方式:
通常把文件描述符所指向的对象称为文件,文件描述符实现了抽象。因为我们可以由上看见,文件描述符将文件、管道和设备之间的差异抽象化,使它们看起来都像字节流。
O_RDONLY | 读 |
O_WRONLY | 写 |
O_RDWR | 读和写 |
O_CREATE | 如果文件不存在则创建文件 |
O_TRUNC | 将文件长度截断为0 |
xv6内核为每一个进程单独维护一个以文件描述符为索引的表,因此每个进程都有一个从0开始的文件描述符私有空间。按照约定:
怎么实现继续读写呢?
实现继续读写的机制就是在引用文件的文件描述符中设置一个偏移量。
read从当前文件偏移量中读取数据,然后按读取的字节数推进偏移量,随后的read将返回上次读取之后的数据,当没有更多的字节可读时,read返回0,表示文件结束。
write在当前文件偏移量处写入数据,然后按写入的字节数推进偏移量,每次write都从上一次写入的地方开始。
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(1);
}
if (write(1, buf, n) != n)
{
fprintf(2, "write error\n");
exit(1);
}
}
上面程序实现的功能:将数据从其标准输入复制到标准输出,若出错,则它向标准错误写入一条消息。我们可以通过改变fd来使上面的程序从xxx读取,并向xxx写入。
管道是一个小的内核缓冲区,为进程提供了一种通信方式(管道作为一对文件描述符提供给进程,一个用于读,一个用于写,从管道一端写入数据,并从另一端读取数据)。
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if (fork() == 0)
{
close(0); // 释放文件描述符0
dup(p[0]); // 复制一个p[0](管道读端),此时文件描述符0(标准输入)也引用管道读端,故改变了标准输入。
close(p[0]);
close(p[1]);
exec("/bin/wc", argv); // wc 从标准输入读取数据,并写入到参数中的每一个文件
}
else
{
close(p[0]);
write(p[1], "hello world\n", 12);
close(p[1]);
}
程序解析:
程序调用pipe来创建一个新管道。读文件描述符记录在p[0]中,写文件描述符记录在p[1]中。
子进程实现功能:
通过close与dup使文件描述符0引用管道的读端(实现了I/O重定向),释放管道的文件描述符。调用exec运行wc程序时,当wc从其标准输入端读取时,将从管道中读取。
父进程实现功能:
关闭管道读端,并向管道写入,然后关闭写端。
注意:管道的读端会等待数据被写入,或者等待所有指向写端的文件描述符被关闭,在后一种情况下,读将返回0。也就是说,若无数据写入,读会被无限阻塞,直到写端被关闭。
xv6文件系统包含了数据文件(拥有字节数组)和目录(拥有对数据文件和其他目录的命名引用)。这些目录形成了一颗树,从一个被称为根目录的特殊目录开始。
例如/a/b/c,该路径指:根目录/中的a目录中的b目录中的名为c的文件或目录。
不以/开头的路径是相对于调用进程的当前目录进行计算其绝对位置的。
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
mkdir("/dir");
fd = open("/dir/file", O_CREATE | O_WRONLY);
close(fd);
mknod("/console", 1, 1);
文件名称与文件是两码事。底层文件(非磁盘上的文件)被称为inode,一个inode可以有多个名称,称为链接。每个链接由目录中的一个项组成(一个项包含一个文件名和对inode的引用)。
inode中存储着一个文件的元数据,包含它的类型(文件或目录或设备)、长度、文件内容在磁盘上的位置以及文件的链接数量。
#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
};
open("a", O_CREATE | O_WRONLY);
link("a", "b");
每个inode都有一个唯一的inode号来标识。可以通过fstat的结果来确定n个文件名是否指向同一个底层内容,若是,则stat中的ino将相同,nlink将为n。
unlink("a");
创建临时文件的方法:
fd = open("/tmp/xyz", O_CREATE | O_RDWR); unlink("/tmp/xyz");
当进程关闭fd或者退出时会删除文件。
shell,Unix的命令行用户接口,是第一种脚本语言。
shell是一个用户程序,不是内核的一部分。xv6的shell是Unix Bourne shell的简单实现。
shell使用fork、exit和exec在用户空间运行程序。
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; } } //读并运行输入命令 while(getcmd(buf, sizeof(buf)) >= 0){ if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){ // Chdir 只能在父进程中被调用,子进程不行 buf[strlen(buf)-1] = 0; // chop \n if(chdir(buf+3) < 0) fprintf(2, "cannot cd %s\n", buf+3); continue; } if(fork1() == 0) //子进程中运行命令 runcmd(parsecmd(buf)); wait(0); } exit(0); }
主循环用getcmd读取用户的一行输入,然后调用fork创建shell副本。父进程调用wait等待,而子进程则运行命令。
比如,用户向shell输入echo MCQupupup,那么shell调用runcmd,参数为echo MCQupupup。runcmd运行实际的命令。对于echo MCQupupup,它会调用exec,若exec成功,那么子进程将执行echo程序的指令。在某些时候,echo会调用exit来达到返回到父进程的目的。
parsecmd程序:
parsecmd(char *s) { char *es; struct cmd *cmd; es = s + strlen(s); cmd = parseline(&s, es); peek(&s, es, ""); if(s != es){ fprintf(2, "leftovers: %s\n", s); panic("syntax"); } nulterminate(cmd); return cmd; }
runcmd程序:
void runcmd(struct cmd *cmd) { int p[2]; struct backcmd *bcmd; struct execcmd *ecmd; struct listcmd *lcmd; struct pipecmd *pcmd; struct redircmd *rcmd; if(cmd == 0) exit(1); switch(cmd->type){ default: panic("runcmd"); case EXEC: ecmd = (struct execcmd*)cmd; if(ecmd->argv[0] == 0) exit(1); exec(ecmd->argv[0], ecmd->argv); fprintf(2, "exec %s failed\n", ecmd->argv[0]); break; case REDIR: rcmd = (struct redircmd*)cmd; close(rcmd->fd); if(open(rcmd->file, rcmd->mode) < 0){ fprintf(2, "open %s failed\n", rcmd->file); exit(1); } runcmd(rcmd->cmd); break; case LIST: lcmd = (struct listcmd*)cmd; if(fork1() == 0) runcmd(lcmd->left); wait(0); runcmd(lcmd->right); break; case PIPE: pcmd = (struct pipecmd*)cmd; if(pipe(p) < 0) panic("pipe"); if(fork1() == 0){ close(1); dup(p[1]); close(p[0]); close(p[1]); runcmd(pcmd->left); } if(fork1() == 0){ close(0); dup(p[0]); close(p[0]); close(p[1]); runcmd(pcmd->right); } close(p[0]); close(p[1]); wait(0); wait(0); break; case BACK: bcmd = (struct backcmd*)cmd; if(fork1() == 0) runcmd(bcmd->cmd); break; } exit(0); }
exit程序:
// Exit the current process. Does not return. // An exited process remains in the zombie state // until its parent calls wait(). void exit(int status) { struct proc *p = myproc(); if(p == initproc) panic("init exiting"); // Close all open files. for(int fd = 0; fd < NOFILE; fd++){ if(p->ofile[fd]){ struct file *f = p->ofile[fd]; fileclose(f); p->ofile[fd] = 0; } } begin_op(); iput(p->cwd); end_op(); p->cwd = 0; acquire(&wait_lock); // Give any children to init. reparent(p); // Parent might be sleeping in wait(). wakeup(p->parent); acquire(&p->lock); p->xstate = status; p->state = ZOMBIE; release(&wait_lock); // Jump into the scheduler, never to return. sched(); panic("zombie exit"); }
exec程序:
int exec(char *path, char **argv) { char *s, *last; int i, off; uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase; struct elfhdr elf; struct inode *ip; struct proghdr ph; pagetable_t pagetable = 0, oldpagetable; struct proc *p = myproc(); begin_op(); if((ip = namei(path)) == 0){ end_op(); return -1; } ilock(ip); // Check ELF header if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf)) goto bad; if(elf.magic != ELF_MAGIC) goto bad; if((pagetable = proc_pagetable(p)) == 0) goto bad; // Load program into memory. for(i=0, off=elf.phoff; i
sz; // Allocate two pages at the next page boundary. // Make the first inaccessible as a stack guard. // Use the second as the user stack. sz = PGROUNDUP(sz); uint64 sz1; if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE, PTE_W)) == 0) goto bad; sz = sz1; uvmclear(pagetable, sz-2*PGSIZE); sp = sz; stackbase = sp - PGSIZE; // Push argument strings, prepare rest of stack in ustack. for(argc = 0; argv[argc]; argc++) { if(argc >= MAXARG) goto bad; sp -= strlen(argv[argc]) + 1; sp -= sp % 16; // riscv sp must be 16-byte aligned if(sp < stackbase) goto bad; if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0) goto bad; ustack[argc] = sp; } ustack[argc] = 0; // push the array of argv[] pointers. sp -= (argc+1) * sizeof(uint64); sp -= sp % 16; if(sp < stackbase) goto bad; if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0) goto bad; // arguments to user main(argc, argv) // argc is returned via the system call return // value, which goes in a0. p->trapframe->a1 = sp; // Save program name for debugging. for(last=s=path; *s; s++) if(*s == '/') last = s+1; safestrcpy(p->name, last, sizeof(p->name)); // Commit to the user image. oldpagetable = p->pagetable; p->pagetable = pagetable; p->sz = sz; p->trapframe->epc = elf.entry; // initial program counter = main p->trapframe->sp = sp; // initial stack pointer proc_freepagetable(oldpagetable, oldsz); return argc; // this ends up in a0, the first argument to main(argc, argv) bad: if(pagetable) proc_freepagetable(pagetable, sz); if(ip){ iunlockput(ip); end_op(); } return -1; }
xv6隐式分配大部分用户空间内存:
fork复制父进程的内存到子进程,exec分配足够的内存来容纳可执行文件。一个进程在运行时若需要更多的内存(比如该进程里面使用了malloc),该进程可以调用sbrk(n)将其数据内存增长n个字节,sbrk返回新内存的位置。
shell利用文件描述符的约定(比如,文件描述符为0,其源就是标准输入),实现I/O重定向和管道。shell确保自己总是有3个文件描述符打开,这些文件描述符默认是控制台的文件描述符。
文件描述符与fork配合使用,实现I/O重定向:
fork将父进程的文件描述符表和它的内存一起复制,这样会使子进程打开的文件和父进程完全一样。系统调用exec替换调用进程中的内存,但保留文件描述符表。如下代码:
char *argv[2]; argv[0] = "cat"; argv[1] = 0; if (fork() == 0) { close(0); // 释放标准输入的文件描述符 open("input.txt", O_RDONLY); // 这时input.txt的文件描述符为0 // 即标准输入为input.txt exec("cat", argv); // cat从0读取,并输出到1 }
close和open的配合使用,使得文件描述符0会指向input.txt。
上面程序实际上就是shell调用cat
注意:通过上面的程序可以理解为什么xv6将fork与exec分开调用。
将这两个调用分开,shell将可以在这个间隔内重定向子进程的I/O,但不干扰父进程的I/O。
虽然fork复制了文件描述符表,但每个底层文件的偏移量是父子共享的。
if (fork() == 0)
{
write(1, "hello ", 6);
exit(0);
}
else
{
wait(0);
write(1, "world\n", 6);
}
上面程序的结果是:
文件描述符1所引用的文件中包含数据:hello world
这种行为有助于从shell命令的序列中产生有序的输出。比如:
(echo hello; echo world)>output.txt
上面程序的另一种写法是:
fd = dup(1); write(1, "hello ", 6); write(fd, "world\n", 6);
注意:通过fork与dup衍生出来的文件描述符与原文件描述符共享同一个偏移量。 除此之外,其余操作衍生的文件描述符不共享同一个偏移量,即使它们是由同一个文件的打开调用产生的。
shell调用实现错误文件描述符的I/O重定向:
ls existing-file non-existing-file > tmp1 2>&1
2>&1相当于dup(1),即重定向错误信息到标准输出。
上面的命令意为:已存在文件的名称与不存在文件的错误信息都会显示在文件tmp1中。
case PIPE:
pcmd = (struct pipecmd*)cmd;
if(pipe(p) < 0)
panic("pipe");
if(fork1() == 0){
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
}
if(fork1() == 0){
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
close(p[0]);
close(p[1]);
wait(0);
wait(0);
break;
p[0]是管道的读端,p[1]是管道的写端。两个子进程分别创建管道的读端与写端,并分别对应命令的右端与左端。所以我们可以得到如下命令:
echo hello world | wc
将 hello world 写入 wc。
我们也可以不使用管道,而是通过临时文件(文件重定向)去实现这个功能:
echo hello world >/tmp/xyz; wc
但使用管道比使用临时文件更有优势,至少有四个:
这里强调cd命令。
看shell如何实现cd的调用:
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
// Chdir 只能在父进程中被调用,子进程不行
buf[strlen(buf)-1] = 0; // chop \n
if(chdir(buf+3) < 0)
fprintf(2, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
//子进程中运行命令
runcmd(parsecmd(buf));
wait(0);
}
cd的功能是改变shell自身的当前工作目录,如果把cd作为一个普通命令执行,那么shell就会fork一个子进程,子进程中运行的cd只会改变子进程的当前工作目录,父进程(即shell)的当前工作目录则不变。
第一节通过重点讲解xv6的系统调用来讲述xv6操作系统的概貌。
[1] FrankZn/xv6-riscv-book-Chinese (github.com)
[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com)