前言
最近在学习MIT经典的操作系统课程——xv6操作系统,之前在本科生期间同样实现过一个简单的操作系统内核,所以代码阅读起来不是特别困难。在这里简单记录一下写实验期间自己的学习笔记,自己实现的代码不一定准确,也希望大佬们多多指正。
一、进程与内存
一个xv6进程在内存中包含两部分。第一部分是用户态的内存,包含有进程的指令、数据以及堆栈;第二部分是进程在操作系统内核中保存的进程表项(在内核态才能够访问),操作系统内核为每一个进程都分配了一个PID进行标识。由于操作系统的第一个实验都是在调用xv6已经写好的几个系统调用函数,在这里结合UNIX常见的系统调用函数,来对一些常用的系统的调用进行功能上的分析(暂时不做实现上的分析,这一部分留到后面的实验具体分析)。 上图是xv6内核中已经实现的系统调用函数,在此做简要的讲解。
1.1 fork()
头文件
#include<unistd.h>
#include<sys/types.h>
函数原型
pid_t fork(void)
fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork() 系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。 子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。 UNIX将复制父进程的地址空间内容给子进程,因此,子进程有了独立的地址空间。在不同的UNIX系统下,无法确定fork之后是子进程先运行还是父进程先运行,这依赖于系统的实现。由于在复制时复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待返回。因此fork函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。 fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
- 在父进程中,fork返回新创建子进程的进程ID;
- 在子进程中,fork返回0;
- 如果出现错误,fork返回一个负值。
fork的另一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程中相同编号的文件描述符在内核中指向同一个file结构体,也就是说,file结构体的引用计数要增加。
代码示例
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
int main(){
int pid = fork();
if (pid > 0)
{
printf("parent:child=%d\n",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");
}
return 0;
}
编译运行结果:
parent:child=1049
child:exiting!
child 1049 is done!
1.2 wait()
头文件
#include <sys/types.h>
#include <sys/wait.h>
函数原型
pid_t wait(int *__stat_loc)
wait() 会暂时停止目前进程的执行, 直到有信号来到或子进程结束。 如果在调用wait()时子进程已经结束, 则wait()会立即返回子进程结束状态值.。子进程的结束状态值会由参数__stat_loc返回, 而子进程的进程识别码也会一起返回、如果不在意结束状态值, 则参数 __stat_loc 可以设成NULL。 子进程的结束状态值参考如下:
WNOHANG:如果没有任何已经结束的子进程则马上返回, 不予以等待.
WUNTRACED:如果子进程进入暂停执行情况则马上返回, 但结束状态不予以理会. 子进程的结束状态返回后存于status, 底下有几个宏可判别结束情况
WIFEXITED(status):如果子进程正常结束则为非0 值.
WEXITSTATUS(status):取得子进程exit()返回的结束代码, 一般会先用WIFEXITED 来判断是否正常结束才能使用此宏.
WIFSIGNALED(status):如果子进程是因为信号而结束则此宏值为真
WTERMSIG(status):取得子进程因信号而中止的信号代码, 一般会先用WIFSIGNALED 来判断后才使用此宏.
WIFSTOPPED(status):如果子进程处于暂停执行情况则此宏值为真. 一般只有使用WUNTRACED时才会有此情况.
WSTOPSIG(status):取得引发子进程暂停的信号代码, 一般会先用WIFSTOPPED 来判断后才使用此宏.
具体的代码示例见1.1节,当父进程调用wait()函数时,就会阻塞当前进程,一直等待子进程运行完毕(打印child:exiting!),之后父进程才会继续执行,打印child 1049 is done!。
补充函数waitpid(): 头文件
#include <sys/types.h>
#include <sys/wait.h>
函数原型
pid_t waitpid(pid_t __pid, int *__stat_loc, int __options)
**waitpid()**会暂时停止目前进程的执行, 直到有信号来到或子进程结束。如果在调用wait()时子进程已经结束,则wait()会立即返回子进程结束状态值.。子进程的结束状态值会由参数__stat_loc返回,而子进程的进程识别码也会一块返回. 如果不在意结束状态值, 则参数status 可以设成NULL. 参数__pid为欲等待的子进程识别码, 其他数值意义如下:
- pid<-1 等待进程组识别码为pid 绝对值的任何子进程。
- pid=-1 等待任何子进程, 相当于
wait() 。 - pid=0 等待进程组识别码与目前进程相同的任何子进程。
- pid>0 等待任何子进程识别码为pid 的子进程。
参数options可以为0或上面状态值。
1.3 exit()
头文件
#include <stdlib.h>
函数原型
void exit(int __status)
exit()通常是用在子程序中用来终结程序用的,使用后程序自动结束,跳回操作系统,并把参数status 返回给父进程,,而进程所有的缓冲区数据会自动写回并关闭未关闭的文件。exit(0) 表示程序正常退出,exit⑴/exit(-1)表示程序异常退出。
1.4 exec()
在xv6内核中单独实现了一个exec()函数用于创建新进程,但是在实际的Linux系统当中。exec是一个函数族,可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。在执行完后,原调用进程的内容除了进程号外,其它全部被新程序的内容替换了。另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。 exec函数族的使用场景主要分为以下两种情况:
- 当进程认为自己不能再为系统和用户做出任何贡献时,就可以调用任何exec函数族让自己重生;
- 如果一个进程想执行另外一个程序,那么它就可以调用fork函数新建一个进程,然后调用任何一个exec函数使子进程重生;
主要区别 这6个函数在函数名和使用语法的规则上都有细微的区别,下面就可执行文件查找方式、参数传递方式及环境变量这几个方面进行比较说明。
- 查找方式:上表中前4个函数的查找方式都是完整的文件目录路径(即绝对路径),而最后两个函数(也就是以p结尾的两个函数)可以只给出文件名,系统就会自动从环境变量“$PATH”所指出的路径中进行查找。
- 参数传递方式:有两种方式,一种是逐个列举的方式,另一种是将所有参数整体构造成一个指针数组进行传递。(在这里,字母“l”表示逐个列举的方式,字母“v”表示将所有参数整体构造成指针数组进行传递,然后将该数组的首地址当做参数传递给它,数组中的最后一个指针要求时NULL)
- 环境变量:exec函数族使用了系统默认的环境变量,也可以传入指定的环境变量。这里以“e”结尾的两个函数就可以在envp[]中指定当前进程所使用的环境变量替换掉该进程继承的所有环境变量。
环境变量 在Linux中,Shell进程是所有执行码的父进程。当一个执行码执行时,Shell进程会fork子进程然后调用exec函数去执行执行码。Sehll进程堆栈中存放着该用户下的所有环境变量,使用不带“e”的4个函数使执行码重生时,Shell进程会将所有环境变量复制给生成的新进程;而使用带“e”的两个函数时新进程不继承任何Shell进程的环境变量,而由envp[]数组自行设置环境变量。
事实上,这6个函数中真正的系统调用只有execve,其他5个都是库函数,它们最终都会调用execve这个系统调用,调用关系如下图所示: 代码示例
char *const ps_argv[] = {"ps", "-o", "pid, ppid, session, tpgid, comm, NULL"};
char *const ps_envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execv("/bin/ps", ps_argv);
execle("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL, ps_envp);
execve("/bin/ps", ps_argv, ps_envp);
execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execvp("ps", ps_argv);
1 #ifdef HAVE_CONFIG_H
2 #include <config.h>
3 #endif
4
5 #include <stdio.h>
6 #include <stdlib.h>
7 #include <unistd.h>
8 #include <string.h>
9 #include <errno.h>
10
11 int main(int argc, char *argv[])
12 {
13
14 char *arg[] = {"ls", "-a", NULL};
15
16
20 if( fork() == 0 )
21 {
22
23 printf( "1------------execl------------\n" );
24 if( execl( "/bin/ls", "ls","-a", NULL ) == -1 )
25 {
26 perror( "execl error " );
27 exit(1);
28 }
29 }
30
31
35 if( fork() == 0 )
36 {
37
38 printf("2------------execv------------\n");
39 if( execv( "/bin/ls",arg) < 0)
40 {
41 perror("execv error ");
42 exit(1);
43 }
44 }
45
46
52 if( fork() == 0 )
53 {
54
55 printf("3------------execlp------------\n");
56 if( execlp( "ls", "ls", "-a", NULL ) < 0 )
57 {
58 perror( "execlp error " );
59 exit(1);
60 }
61 }
62
63
68 if( fork() == 0 )
69 {
70 printf("4------------execvp------------\n");
71 if( execvp( "ls", arg ) < 0 )
72 {
73 perror( "execvp error " );
74 exit( 1 );
75 }
76 }
77
78
83 if( fork() == 0 )
84 {
85 printf("5------------execle------------\n");
86 if( execle("/bin/ls", "ls", "-a", NULL, NULL) == -1 )
87 {
88 perror("execle error ");
89 exit(1);
90 }
91 }
92
93
98 if( fork() == 0 )
99 {
100 printf("6------------execve-----------\n");
101 if( execve( "/bin/ls", arg, NULL ) == 0)
102 {
103 perror("execve error ");
104 exit(1);
105 }
106 }
107 return EXIT_SUCCESS;
108 }
在xv6内核中,只实现了一个简单的exec()系统调用,比较简单,在此不做过多赘述,代码示例如下:
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
xv6使用上述的系统调用来模拟用户执行程序。shell进程是在xv6内核启动的时候就已经开始执行的用户进程,在user/sh.c的shell实现main函数(159行)中,有如下的while循环不断通过getcmd() 函数获取用户输入的字符串,当用户完成输入时,父进程将通过fork1() 函数创建用于执行用户想要执行的程序的子进程。父进程(shell进程)在wait(0) 处被阻塞,等待子进程的结束。子进程首先通过parsecmd() 函数将用户输入进行分割,传入runcmd() 函数。runcmd() 函数发现用户命令是创建新进程并执行时,通过exec() 函数将子进程的代码、数据以及堆栈进行替换,如果替换成功,将不会返回runcmd() 函数。当用户进程执行完毕后,shell进程的wait() 函数收到信号并返回,shell进程继续执行while循环,等待输入用户的下一条命令。 代码分析
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
buf[strlen(buf)-1] = 0;
if(chdir(buf+3) < 0)
fprintf(2, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
runcmd(parsecmd(buf));
wait(0);
}
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;
...
}
fork()函数在分配进程内存时与exec()函数有所不同。fork函数分配足够拷贝父进程内存空间的内存,exec()函数则要分配足够的内存空间供新进程使用。
|