1. 从系统调用走起
XV6操作系统的编译及bochs或Qemu运行环境的搭建,MIT的网站上介绍得很详细了,就不再重复。不过,MIT网上下载的dot-bochsrc里对物理内存的只配置了32M,而Xv6源代码Memlayout.h里的物理内存却把宏PHYSTOP定义成了0xE000000,即224M物理内存。所以用make bochs来运行时,会出错。把dot-bochsrc里的megs: 改为256即可.
柿子拣软的捏。让我们从Xv6的用户态应用程序开始吧。不少的操作系统的分析从
CPU复位开始走起,这是一个很好的思路,抓住了最源头,但要从源头走起,需要一开始就对相应CPU体系结构的较好掌握,与硬件的寄存器直接打交道,毕竟属于低级或底层操作,逻辑性未必有应用层那么强,但这一关终究是要走,只是这里我们先放一放,让我们换一个思路,从大部分程序员最熟悉的用户态搞起,先品品XV6操作系统提供的用户态实用工具的源代码,如cat,wc, shell,grep ,ls。毕竟,严格地说,一个完整的操作系统包括了运行在CPU管态的Kernel和运行在CPU目态的实用工具Utilities。如果是微内核的架构,更多的功能都会运行在目态。现代的CPU都支持不同运行级别的,管理者要用更高的权限,被管理者的权限自然要更低。操作系统内核要为所欲为,当然要处在管理的状态,即管态或称核心态;而应用态的用户程序是被管理者,自然要处在目态或称用户态。两者的差别在于,处于目态时,有些CPU的特权指令是不能执行的,显然一个用户程序不用执行“关中断”这样的指令。这个道理,就如网站上,只有管理员同志才能看谁不爽就删掉谁的贴,一般用户没有这样大的权力。
让我们先假设XV6的内核已经写好,要让程序员能在上面做应用层的开发,Kernel就必须提供一些接口让用户态的程序可以调用。站在应用层程序员的角度来看,可以把内核看成一个功能强大的函数库。因为这些服务是由操作系统提供的,所以称为系统调用(System Call)。站在C语言程序员的角度看,这些系统调用与普通的函数调用在使用上没什么区别。但如果到了汇编语言这一级别,两者是完全不同的。系统调用本质上是由软件触发的一次硬件中断,在X86上用的是int指令(或sysenter指令),涉及到由CPU的低权限状态切换成高权限状态, CPU硬件对之的响应与收到定时器的中断信号时的响应并无太大区别,只是系统调用来自于CPU本身,而定时器却来自于CPU之外(如果一定要较真的话,确实有很多32位的单片机的CPU芯片里就包含了大量的定时器,但逻辑上,定时器的中断仍可看成是外部中断)。而应用层的普通函数调用用的却是call指令,未涉及CPU的权限切换。实际上,我们通常只说Windows API,即较少去提Windows的系统调用。Windows提供的API有些是系统调用,有些仅是普通的函数调用。站在C程序员的角度,只在乎函数名、函数的参数、函数的返回值和函数的功能,至于到底是普通函数调用和系统调用,已经“老虎、老鼠,傻傻分不清楚”,从使用的角度来看,确实也没有必要分得那么清楚,人有时难得糊涂。但愿意去读读Kernel的我们,还是要知道这两者的区分。之后,就可以糊里糊涂地使用内核提供的服务了。
让我们先看看Xv6提供了哪些系统调用,打开user.h,映入眼帘的是这么几个函数。
/*3*/ //system calls
/*4*/ intfork(void);
/*5*/ intexit(void) __attribute__((noreturn));
/*6*/ intwait(void);
/*7*/ intpipe(int*);
/*8*/ intwrite(int, void*, int);
/*9*/ intread(int, void*, int);
/*10*/ intclose(int);
/*11*/ intkill(int);
/*12*/ intexec(char*, char**);
/*13*/ intopen(char*, int);
/*14*/ intmknod(char*, short, short);
/*15*/ intunlink(char*);
/*16*/ intfstat(int fd, struct stat*);
/*17*/ intlink(char*, char*);
/*18*/ intmkdir(char*);
/*19*/ intchdir(char*);
/*20*/ intdup(int);
/*21*/ intgetpid(void);
/*22*/ char*sbrk(int);
/*23*/ intsleep(int);
/*24*/ intuptime(void);
没错,这就是Xv6提供的为数不多的几个系统调用,但确实已经相当完备,麻雀虽小,五脏俱全呀。MIT XV6的教学网站提供了一份英文版的book-rev7.pdf,其中Chapter 0. Operating SystemInterface比较详细地这些系统调用的功能,熟悉Unix或Linux的兄弟们对这几个函数应如老友重逢般激动。是的,XV6就是Unix V6在X86平台上的重生。这里班门弄斧地简介这几个系统调用的功能:
/*3*/ //system calls
/*4*/ intfork(void); 创建一个新进程
/*5*/ intexit(void) __attribute__((noreturn)); 进程结束
/*6*/ intwait(void); 等待子进程结束
/*7*/ intpipe(int*); 创建一个管道
/*8*/ intwrite(int, void*, int); 写文件
/*9*/ intread(int, void*, int); 读文件
/*10*/ intclose(int); 关闭文件
/*11*/ intkill(int); 杀死一个进程
/*12*/ intexec(char*, char**); 在当前进程地址空间中加载一个新的可执行程序
/*13*/ intopen(char*, int); 打开一个文件
/*14*/ intmknod(char*, short, short); 创建一个设备文件
/*15*/ intunlink(char*); 断开文件名与对应i节点的链接,当i节点的链接数为0时,要删除相应文件
/*16*/ intfstat(int fd, struct stat*); 获取文件的相关信息
/*17*/ intlink(char*, char*); 硬连接,不同文件名,对应同一个i节点
/*18*/ intmkdir(char*); 创建目录
/*19*/ intchdir(char*); 改变进程的当前目录
/*20*/ intdup(int); 复制文件描述符,用于IO重定向
/*21*/ intgetpid(void); 获取当前进程的进程标志符
/*22*/ char*sbrk(int); 扩充进程的地址空间大小(用于实现malloc)
/*23*/ intsleep(int); 进程睡眠
/*24*/ intuptime(void); 获取开机以来的系统运行时间
这几个系统调用的语义大部分可在Linux平台上调用相似的函数来领会,可找《Unix环境高级编程》这样的重量级牛刀来处理之,就不再累述。这里我们更关注这些系统调用是如何与
X86的int指令关联起来的,系统调用的这个细节平时被Linux的C标准库给遮挡,XV6上没有实现完整的C标准库,简洁的东东,自然看得更清楚。
凭着我们的嗅觉,我们很快就会想到,这东东大概要直接调用汇编指令来实现,或者在C语言里嵌入汇编来实现。在Xv6的目录下找一找,很快我们就会找到usys.S这个文件,
/*4*/ #defineSYSCALL(name) \
/*5*/ .globl name; \
/*6*/ name: \
/*7*/ movl $SYS_ ## name, %eax; \
/*8*/ int $T_SYSCALL; \
/*9*/ ret
/*10*/
/*11*/ SYSCALL(fork)
/*12*/ SYSCALL(exit)
/*13*/ SYSCALL(wait)
/*14*/ SYSCALL(pipe)
/*15*/ SYSCALL(read)
以宏SYSCALL(wait)为例,展开后就是
.globl wait;
wait:
movl $SYS_wait, %eax;
int $T_SYSCALL;
ret
再看syscall.h,我们很快就查到#defineSYS_wait 3
再看traps.h,我们又查到#defineT_SYSCALL 64 // system call
进一步替换后,我们就有
Wait:
Movl$3,%eax //系统调用号 system call number
Int$64 //系统调用中断号
Ret //wait函数返回
此处汇编里的标号wait:可看成一个函数名来用的,就是有一个名为wait的函数。XV6内核显然提供了N个函数来供应用层的程序调用,所以得有个参数来通知内核,我们要调用的是内核中的哪一个函数,这里通过movl $3,%eax把“要调用内核中的哪一个函数”的信息存到了寄存器eax中。接下来的事情就是要用int $64来触发一个硬件中断了,CPU可以收到各种中断,自然就要对不同的中断进行编号,选用哪个中断号来作系统调用,则是由操作系统的开发者指定的。Linux用的是int 0x80;而我们的XV6选用的是int 64. 此时,热爱C语言的我们一定觉得还少了点什么,没错,站在C程序员的角度看,写一个文件的系统调用应如下所示:
intwrite(int fd, void* buf, int n);
那么,参数fd、buf和n放哪里去了?实际上,按照C函数的调用约定,如果我们作如下调用 write(fd, buf, n); C编译器会产生如下代码(我们用伪代码表示吧,领会大意即可):
push n;
push buf;
push fd;
call write;
前面三个push,把三个参数从右向左依次压入用户栈,之后就调用在usys.S汇编代码中实现的函数write。真正的触发系统调用的那条中断指令是在usys.S中实现的。当然,我们也自然会想到,为了提供速度,如果把系统调用的参数都先放到寄存器里,那我们陷入内核后,取参数的动作就会更迅速些,事实上Linux就是这么干的,当然X86平台上的寄存器个数有限,如果系统调用的参数个数太多,Linux 也只好把放不下的东东存到内存栈中了。