实现自己的Shell(计蒜客996第三周实验)
目录进程、地址空间与线程
进程和地址空间
在引言中我们已经提到,由于计算机所拥有的物理资源有限,当多个程序在计算机上同时运行时,它们不可能连续地在处理器上运行或占用全部内存,因此我们需要进程的抽象在用户的一侧隐藏上下文切换等细节。
进程作为运行的程序的抽象,包含了一个程序的运行状态和这个程序所用的抽象内存及其中存储的数据。
我们将后一部分,即一个进程可以使用的全部内存的地址和它们存储的数据,称为这个进程的 地址空间(address space)。
我们可以将进程笼统地看做是一个或多个线程与一个地址空间的结合。
上面提到的都是进程在用户一侧的抽象,但我们还没有考虑过在系统一侧我们需要什么数据结构来实现这一抽象。这一节中我们就将会探索进程在系统中的实现。
在讲解进程在系统中的实现方式以前,让我们先明确这一实现方式需要达到的目的:
-
一方面,进程的结构必须能够囊括一个进程的运行状态,在由于中断、异常或系统调度进入内核态时我们应该能够将进程的运行状态放入内存中的某一位置,使我们可以在某一个时间点根据进程的优先级接着这次的进度再次运行该进程;
-
另一方面,由于进程在用户一侧对内存进行了抽象,我们需要在系统一侧包含从抽象内存向实际内存和外存(磁盘等存储设备)的映射,以便通过抽象内存的地址获得物理地址和其中存储的数据。
为了实现上述的第一个目的,即在进程中存储其运行状态,我们将在进程中存储 处理器状态寄存器、指令计数器、栈指针、通用寄存器等 代表了进程目前的运行状态的值。
由于现代计算机中往往有多个核或多个处理器,我们的程序可以达到 并行(parallelism,即在物理时间上同时运行,区别于多个进程在处理器上的并发(concurrency)),因此同一个进程可能同时包含有多个不同的运行状态。
线程
为了更好地区分进程在一个处理器上的运行状态和进程本身的运行状态,我们在此引入 线程(thread) 的概念。
每个线程拥有一个 线程控制块(thread control block, TCB),用来存储我们上述提到的处理器状态寄存器、指令计数器、栈指针、通用寄存器等数值,但同一个进程中的所有线程都共享同一个地址空间。
在前文我们已经提到,一个用户进程所看到的内存空间是抽象的,区别于实际的物理内存,因此为了支持进程从抽象内存获取物理内存中存储的信息,我们需要在进程中包含从抽象内存向物理内存的映射,即该系统所用的地址转换方法所需要的信息。
地址转换有很多种不同方法,包括分页存储、分段存储等,它们都需要不同的信息来实现地址转换;我们将在下一章中详细介绍这些方法,这里我们将以最为简单的 Base and Bound 作为例子来解释这一概念。
在 Base and Bound 方法中,系统将一段连续的物理内存分配给一个进程,Base 代表基地址,即系统分配给一个进程的可用内存的起始地址,Bound 代表这个进程可用的内存的最高地址。
为了读写物理内存,我们将基地址与抽象内存地址相加,获得实际地址;只要实际地址不大于 Bound,内核就会允许进程进行该操作。
因此在这个方法中,为了实现进程地址空间的抽象,进程在系统一侧的数据结构中必须包括这段可用的内存的基地址、可用长度。
进程控制块
为了能在一个进程开始运行的时候方便地载入上面提到的两方面的数据,我们需要一个数据结构来包含这些数据。
这个数据结构就是 进程控制块(Process Control Block, PCB)。
对于内核来说,它是进程存在的唯一标识。
进程控制块中存储的信息除了上面提到的实现地址转换的信息和表示运行状态的线程信息以外,还包括了很多系统在调度进程时需要的信息,比如进程号、进程所处状态(我们将在下一节中更多地解释进程可能所处的状态与不同状态间的切换)、进程的优先级等等。
需要注意的是,我们不会将整个地址空间中包含的地址及其数据都存储在进程控制块里;我们只需要可以帮助我们从抽象内存地址获得实际物理地址的方法,而实际数据仍然存储在内存的对应位置中。
由于进程控制块中的信息包含了优先级、基地址、可用地址范围等用户进程不应该有权限修改的信息,用户进程不应该有权限修改进程控制块。
因此我们将进程控制块存储在内核空间中。
进程状态与用户态和内核态之间的切换
进程状态
在上一节中我们提到,进程控制块中包含了进程的状态;那么,什么是进程的状态呢?
我们已经提到过,一个进程可能在运行一段时间后被切换出去,一段时间后继续运行。
显然,进程至少需要两个状态——运行态(running) 与 就绪态(ready)。
进程在运行态中运行一段时间后被切换出去,排入就绪队列,等待继续运行。
三态模型
在三态模型中,进程还有一个状态,即 等待态(wait)。
在这个状态中,进程在等待某一事件完成,不会被排入就绪队列,直到该事件发生后,进程才会被排入就绪队列,可能被选为下一个运行的进程。
这一等待事件可能包括等待系统调用完成,等待获得某一个锁或收到某一信号量的信号等等,我们在接下来的章节中会详细讲解这些内容。
现在你只需要知道:
- 处于就绪态的进程不会因为遇到任何事件而无法进入等待态;
- 处于等待状态的进程在事件发生后考虑到优先级等问题,必须先回到就绪状态而不能直接运行。
五态模型
除三态模型外,五态模型也是一种常见的进程状态转换模型。
相比三态模型,五态模型多引入了 新建态(new) 和 终止态(exit)。
新建态对应着进程被创建时尚未加入就绪队列的状态。
为了建立一个新的进程,内核需要给进程分配资源(如:建立进程控制块)、建立必要的管理信息;在这个过程中,进程就处于新建态。
相对的,终止态对应的是进程已经结束但尚未被系统撤销的状态。
处于终止态的进程虽然已经结束,但其获得的资源还未被系统回收,因此系统仍然可以获得该进程结束时的信息。
定义这两种状态可以帮助我们避免一些我们不想看到的情况。
比如,如果我们允许一个进程在获得全部资源以前就进入就绪队列,那么一个新进程可能在未获得全部资源前就开始运行,而产生错误。(这是因为我们是在另一个进程里通过系统调用创造了这个新的进程,我们会在本章的后几个章节详细阐明这个问题)
同样的,如果一个进程 A 创建了另一个进程 B 而想要等待进程 B 运行完毕,从中获得一些信息,那么终止态的缺失可能导致进程 B 率先运行完毕后被系统撤销,使得进程 A 无法从进程 B 处获得任何信息。
UNIX 系统中,如果发生一个进程创建另一个进程的情况,那么在老进程从新进程获取信息前,新进程会一直停留在终止态;如果新进程终止后,仍在运行的老进程没有从新进程获取信息,那么新进程就会成为我们所说的“僵尸进程”,停留在系统内存中,导致系统变慢。
终止态
^
|
进程内部结束或因非法操作结束
|
运行态<---被选为下一个-------------|
|
等待事件(I/O、使用共享资源等)
|
\/
等待态
|
等待事件已发生
|
\/
就绪态<---处理器时间消耗-----------|
^
|
完成分配资源,建立管理信息
|
新建态
子进程与父进程
fork()
在本章第二节中介绍终止态时我们已经提到过,一个进程可以创建另一个进程,并等待其运行完毕。
现在就让我们来看一看这个过程具体如何实现吧!
为了产生新的进程,我们必须使用一个新的系统调用,fork()
。
pid_t fork(void);
fork()
被包含在unistd.h
中。
这个函数不需要任何参数;它会直接创建一个新的进程,将现在运行的进程的处理器状态、地址空间直接拷贝到新进程中,因此新进程也会从fork()
对应的指令开始运行。
我们称这个新进程为调用fork()
的进程的子进程,称创建子进程的进程为父进程。
fork()
执行完毕后会返回一个进程标识 ID(pid = process ID),可以被用来区分父进程与子进程。
在父进程中返回的进程标识 ID 是新创建的子进程的进程标识 ID,而在子进程中返回的进程标识 ID 为 $0$。
我们可以利用if
语句判断进程标识 ID 是否为 $0$,从而使子进程与父进程执行不同的命令。
我们将以鸣人的螺旋手里剑为例子来带你使用这些新学习的函数。现在请先用
fork()
给鸣人制造一个影分身吧!不要忘了,鸣人和影分身负责的是制造螺旋丸的不同部分,所以你要把fork()
返回的影分身的标识 ID 存在变量中。
exit()
一个进程在运行结束或产生重大错误(如:企图越权使用内核的特权指令)后会被系统终止。
但我们在由fork()
产生的子程序中也可以用exit()
系统调用主动结束这个程序。
void exit(int status);
exit()
函数在stdlib.h
头文件中;它的参数status表示这个进程退出时的结束状态。
当我们用这个函数结束一个子进程时,status
会作为子进程的结束状态被提供给wait()
的第一个参数。
wait()
pid_t wait(int* stat_loc);
wait()
被包含在sys/wait.h
中。它的参数stat_loc
,是一个可以被用来储存等待的进程的 结束状态值(exit code,一个代表子进程是否成功退出的整数;如成功则为 $0$,否则为对应的错误数字) 的指针。
但我们也可以把这个设为NULL
,函数仍可以正常运行。
wait()
函数会使父进程的状态变为等待态,直到父进程的所有子进程中的 一个 成功退出并返回结束状态值,父进程才可以进入就绪状态,准备继续运行;如果在wait()
被调用时已经有可用的结束状态值,那么wait()
会立即返回。
wait()
返回的值是返回结束状态值的子进程的进程标识 ID;如果在等待过程中出现了错误(如已经没有可等待的子进程),那么wait()
将返回 $−1$。注意,父进程直接不能等待子进程的子进程!
现在让我们开始制造螺旋手里剑吧!鸣人的本体将负责性质变化,鸣人的影分身将再一次使用
fork()
分出一个影分身,之后新的影分身将负责提供查克拉,老的影分身负责查克拉的形态变化。请你用让三个鸣人分别说:“我负责产生查克拉!\n”,“我负责形态变化!\n”,“我负责性质变化!\n”。两个影分身说完就会正常exit()。
上一步讲到wait()
的第一个参数可以得到exit()
的参数,也就是退出的子进程的结束状态值,我们可以通过wait()
的返回值获得那个退出的子进程的进程识别 ID。
pid_t wait(int* stat_loc);
但wait()
并不能帮我们直接从子进程中获得任何数值,那么我们为什么要使用这个函数呢?
一种情况是,我们可能希望在子进程中执行的某些指令在我们继续执行父进程以前完成,此时我们可以用wait
等待这个子进程执行完毕(注意,如果我们不使用wait
,子进程也 可能 比父进程先完成,这取决于内核对不同进程的调度,但我们希望子进程 一定 先完成,所以使用wait
);
另一种情况是,父进程即将退出,为了避免僵尸进程的出现,父进程必须等待直至所有子进程结束。
回到螺旋手里剑的问题,我们知道如果鸣人的第二个影分身没有产生查克拉,那么鸣人和第一个影分身也不能对查克拉使用形态变化和性质变化,因此我们希望第二个影分身先完成;请利用
wait()
让三个鸣人按顺序说出这三句话:“我负责产生查克拉!\n”,“我负责形态变化!\n”,“我负责性质变化!\n”。另外,如果任何一个影分身失败了,请让鸣人本体说:“可恶,又失败了,再来一次!\n”,并在主函数中返回 $−1$。
恭喜你学会了创建新的进程!
创建新进程在实际编程中有很多应用,比如在今后学习网络编程时就会看到,每当服务器接收到请求,主进程就会用fork()
产生出一个进程,处理那个请求;父进程仍然会继续在网络上等待其它请求的到来。
waitpid()
在本节的最后我们想讲一讲wait
的一个变体,waitpid()
:
pid_t waitpid(pid_t pid, int* stat_loc, int options);
waitpid()
与wait()
不同的一点在于你可以通过在pid
参数里指定一个进程标识 ID 来等待一个有特定的进程标识 ID 的进程——
当pid
为一个大于零的数的时候waitpid
等待的就是进程标识 ID 等于pid
的子进程;
当pid
为 $−1$ 时,我们等待所有子进程中的任何一个;
当pid
为 $0$ 时,我们等待所有进程组标识 ID 与父进程相同的子进程;
当pid
小于 $−1$ 时,我们任何进程组标识 ID 为 pid 的绝对值的进程。
stat_loc
与 wait()
中的 stat_loc
有同样的作用;options
将一些针对这个函数的特殊的控制选项(如:我们可以选择在没有子进程退出时立即返回,而不做等待)用或运算连接在一起,我们在这里不对所有选项一一进行介绍,如果你有兴趣了解可以在 Linux man page 或 百度百科 上查看。当 options
被设为 $0$ 时, waitpid
没有任何特殊选项。
因此,我们可以知道,当pid
为 $−1$、options
为 $0$ 时,waitpid()
与 wait()
效果相同。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
printf("我是鸣人!\n"); // 1
int pid1 = fork();
if (pid1 != 0) {
int status1;
int result1 = wait(&status1);
if (result1 == -1 || status1 != 0) {
printf("可恶,又失败了,再来一次\n");
return -1;
} else {
printf("我负责性质变化!\n"); // 4
}
} else {
int pid2 = fork();
if (pid2 != 0) {
int status2;
int result2 = wait(&status2);
if (result2 == -1 || status2 != 0) {
exit(-1);
} else {
printf("我负责形态变化!\n"); // 3
exit(0);
}
} else {
printf("我负责产生查克拉!\n"); // 2
exit(0);
}
}
return 0;
}
exec()
上一节中我们已经提到,利用fork()
返回的进程标识 ID,我们可以在子进程中执行和父进程中不同的指令;但如果我们想要运行完全不同的程序,这种办法可能就不是最好的。
因此我们在此向你介绍一个新的系统调用,execve()
。
在 Linux 系统中,我们实际比起系统调用本身更经常使用 exec()
组包含的六个函数,但此处我们为了让你了解系统调用的实际过程将先给你介绍系统调用,然后再介绍这六个建立在系统调用基础上的函数。
int execve(
const char* filename,
char* const argv[],
char* const envp[]);
这个函数将在 现在运行的 进程中以argv
中存储的字符串为参数运行由filename
指向的可执行程序,并将envp
中存储的“变量=数值”对作为环境变量代入这个程序。
根据传统,argv
中的第一个参数必须与filename
一致,真正的参数从第二个开始。
execve
会以 filename
的程序覆盖现在进程中正在运行的程序,因此所有在 execve
函数之后的行都只会在 execve
失败之后被执行,我们可以通过在后面加入打印语句等方法标识 execve
运行失败。
基于execve()
的还有六个函数;他们都具有在现有的进程中打开某一可执行文件、覆盖现有程序执行的功能。
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg,
..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execvpe(const char* file, char* const argv[], char* const envp[]);
我们可以将这些函数表示为:“exec + l/v + p/e/pe”,所以这六个函数主要有两处不同。
第一处不同(l/v)在参数中表现为
- “execl”类函数将所有参数作为一整个字符串代入,
- 而“execv”类函数将参数分别放入一个字符串的数组中,将数组作为参数代入函数运行。
第二处不同(p/e/pe)表现为
- 包含 p(代表 path)的函数可以模仿计算机中Shell的功能,在“file”中不含“/”时应用环境变量 PATH 中的目录来寻找可执行文件,
- 而包含 e(代表 environment)的函数可以像
execve
一样将“变量=数值”对作为环境变量代入程序。
所有上述函数的常见用法是先由当前运行的进程调用 fork()
产生新进程,然后在新进程中用 exec()
类函数运行新程序。
你可能会认为这样应用fork()
复制了全部父进程的地址空间后又马上弃置不用、浪费了很多处理器时间,但实际上 Linux 系统采用了 copy_on_write 技术,也就是说只有在某一个数据被修改时那个数据才会被实际复制到子进程的地址空间,所以当fork()
之后直接运行exec()
时,父进程的地址空间不会被复制过来。
fork()
,wait()
,exit()
,和exec()
是对于进程来讲最重要的几个函数。
下面我们就在一个实例中验证一下自己对这些函数的理解吧!
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
int count = 0;
int main() {
int pid = fork();
if (pid == 0) {
count++;
int pid2 = fork();
if (pid2 == 0) {
char **argv = (char **)malloc(3 * sizeof(char*));
argv[0] = "echo";
argv[1] = "Hello!";
argv[2] = NULL;
int pid3 = fork();
count++;
if (pid3 == 0) {
count++;
execvp("echo", argv);
printf("Count %d", count);
free(argv);
exit(0);
} else {
free(argv);
wait(NULL);
printf("%d,", count);
exit(0);
}
} else {
wait(NULL);
printf("%d,", count);
exit(0);
}
} else {
wait(NULL);
printf("%d.", count);
return 0;
}
}
Result:
Hello!
2,1,0.
实验:设计自己的shell
利用之前的知识,这个实验中我们要自己实现一个简单的 shell ——跟我们之前使用到的 bash shell 类似,它的功能是 显示当前所在的目录,然后对输入的命令进行解释。在目录下有一个文件main_sol
,它是一个已经编译好的标程,会用来生成测试用的文件。你可以在目录下执行make
后直接运行这个程序,查看它的效果——你的 shell 必须跟它的显示效果保持一致。
第一部分:log_t
在实现shell
之前,你需要实现一个数据结构log_t
——结构体的布局可以参见log.h
头文件,它相当于一个用链表实现的栈,作用是保存输入的每一条命令以便查找。
在log.h
中定义了这样几个函数,你需要全部实现它们:
-
void log_init(log_t *l);
初始化一个log_t
——熟悉面向对象程序设计的同学应该能看出,这相当于是log_t
的构造函数。你需要将l
的head
设置为NULL
。 -
void log_destroy(log_t* l);
销毁一个log_t
,相当于log_t
的析构函数——你需要回收一个log_t
所占用的所有内存,并将l
的head
设置为NULL
,如果head
已经为NULL
则直接返回。 -
void log_push(log_t* l, const char *item);
把一个字符串item
插入到一个log_t
的末尾。如果l
的head
为NULL
的话,你需要在这个函数中为log_t
建立第一个结点并分配对应的内存空间。 -
char *log_search(log_t* l, const char *prefix);
遍历整个log_t
的链表,搜索是否有包含前缀prefix
存在的字符串,如果有的话则返回这个字符串(只返回第一个),没有的话直接返回NULL
。
有 C++ 或者其他面向对象语言基础的同学可以看出,这一部分我们其实是在用 C 语言来进行面向对象编程——对于系统编程来说,很多情况下确实需要这种看起来有些“蹩脚”的编程方式,因此这里需要大家对此熟练掌握。
第二部分:shell
接下来,我们需要来实现 shell 本身——一个 shell 相当于一个无限循环的程序,它在循环中应该执行这些操作:
- 向标准输出打印一个命令提示符
- 从标准输入读取一个命令
- 判断要执行哪些命令
在这一步,你需要修改 shell.c 里的prefix
函数和execute
函数。接下来我们将依次介绍进一步的细节:
- 命令提示符
你需要在prefix
函数里输出以下格式的命令提示符:
/path/to/cwd$
这里可以直接用printf("%s$ ", cwd)
来实现——cwd
部分为当前所在目录。你可以自行查阅文档,阅读关于getcwd()
函数的相关信息。
- 从标准输入读取命令
你的程序要每次从标准输入读取一行,获取输入的命令——你可以查阅文档学习getline()
函数的用法,这里要 注意回收内存,不然你将无法获得内存泄露检测的分数。这部分功能已经在 main.c 文件中实现了,你可以阅读这部分代码,但无法对其进行修改,最终测试时会将你所做的任何修改覆盖掉。读入后,会将命令作为参数传递给execute
函数。
- 对内置命令进行解析
你需要在execute
函数里对内置命令进行解析,实现如下几个命令:
1)cd
cd xxx
进入某个目录(上述代码会进入xxx目录)——你可以查阅chdir()
函数的用法。如果目录不存在,则要打印xxx: No such file or directory
并换行,其中xxx
表示输入的目录名称。
2)exit
exit
退出 shell 程序,在execute
函数中直接返回 $0$。在其他情况下,execute
返回一个非零值即可。
3)显示历史命令
!#
所有输入的命令都保存在一个log_t
中,这个命令的作用是显示所有输入过的命令,每个占一行。从栈底元素开始输出,log_t
变量名为Log
,定义在 shell.h 文件中,请勿修改。
注意,以!开头的所有命令都不会被放入命令栈中。
4)根据前缀查找命令
!prefix
查找是否曾经输入过包含前缀prefix
的命令,如果找到(如果有多个,只找最靠近栈顶的一个),则执行这条命令,如果没有,则返回No Match
换行。新执行的命令也会被放入栈顶。
5)ls
ls
跟 bash shell 的ls
命令一样,列举当年目录下所有子目录和文件——你可以直接用system()
函数执行这个命令。
执行外部命令
你需要使用fork()
和exec()
等函数来执行一个外部命令——如果执行失败,则输出%s: no such command
换行。注意,这里输入的命令采用的是 DOS 格式,而不是 Unix 格式——运行当前目录下可执行文件 args
应该直接输入 args
而非 ./args
,因此你 不能使用 system()
函数来执行该命令,否则将通不过判题测试。
文件夹下有一个
args.cpp
文件,你可以调用g++
(注意不是gcc
,因为是 C++ 代码)编译它,然后试着用你的 shell 来执行这个程序。这个程序会输出一行Hello world!
然后依次输出它接收的每一个参数。
如果外部命令无法执行,则输出XXX: no such command
并换行,其中XXX
表示输入的完整外部命令。注意,你必须确保无论执行成功还是失败,都 不要有额外的子进程留下。
即使外部命令未正确执行,也依然将这条命令放入命令栈中。
本地判分
在/include
文件夹内包含了 头文件(header file) ,头文件声明了函数和类;
在/src
文件夹内含有具体的 .c,其中包含了对相关函数的实现——你需要实现的函数在shell.c
和log.c
中。
主函数保存在main.c
中。不要擅自修改主函数代码! 否则可能会导致你的代码不能通过判题。
在自测时你可以在终端中输入make
来编译执行本地判分器,输入make clean
可以清空之前编译出的文件。其它的make
指令可以在Makefile
中查看。
编译出的可执行文件名为main
,在运行make
之后,你可以直接执行./main
来直接运行你的 shell。
提交与在线判分
正如前面提到的,这次作业由在线判分器进行自动判分。
在这次任务中,你只能修改 shell.c 和 log.c 文件,对其他文件进行的修改将会被覆盖。 你不能通过standard output
输出任何额外的内容(这意味着你 不可以 使用printf
或类似的函数输出多余的信息),因为这会导致在线判分器判分失误从而影响你的成绩。在你提交作业后,自动判分器将针对你提交的内容进行正确性测试与内存测试——本地判题将只进行正确性测试,一共占 $45%$ 的成绩。
在你已经通过正确性测试的前提条件下,你的程序接下来将会接受内存测试——如果没有内存泄露的话你将获得额外 $20%$ 成绩。至此,你可以获得基础的 $65%$ 的成绩。
在文件夹下有一个空的fbomb.txt
,输入任意内容让该文件不为空,就可以进行fork bomb
测试——通过测试后,你将获得剩余的 $35%$ 的成绩。
注意,你必须确保你的fork
实现是正确的——如果产生了额外的进程,无法终止的话判题器将不能正常结束,被系统杀死,然后你将 无法得到任何分数。
实验提示
测试数据下载:https://jsk-res.pek3b.qingstor.com/file/shell_testcase.zip
如何切分字符串
使用 C 语言的 strtok 函数,参考用法:
#include <string.h>
#include <stdio.h>
int main () {
char str[80] = "This is - www.tutorialspoint.com - website";
const char s[2] = "-";
char *token;
/* get the first token */
token = strtok(str, s);
/* walk through other tokens */
while( token != NULL ) {
printf( " %s\n", token );
token = strtok(NULL, s);
}
return(0);
}
如何执行 cd
命令及获取当前路径
可以使用 C 语言的 chdir
和 getcwd
函数。
#include<stdio.h>
// chdir function is declared
// inside this header
#include<unistd.h>
int main()
{
char s[100];
// printing current working directory
printf("%s\n", getcwd(s, 100));
// using the command
chdir("..");
// printing current working directory
printf("%s\n", getcwd(s, 100));
if (chdir("temp") < 0) {
printf("change directory error!\n");
}
// after chdir is executed
return 0;
}
如何执行 ls
命令
可以使用 C 语言中的 system
函数,或者使用 exec
系列函数。
system()
函数实际上执行了三步操作:
- fork一个子进程;
- 在子进程中调用 exec 函数去执行 command;
- 在父进程中调用 wait 去等待子进程结束。
对于 fork 失败,system()
函数返回 -1。
如果 exec 执行成功,也即 command 顺利执行完毕,则返回 command 通过 exit 或 return 返回的值。
- 注意,command 顺利执行完毕不代表执行成功,比如 command:"rm a.txt",不管文件存不存在,该 command 都顺利执行了
如果 exec 执行失败,也即 command 没有顺利执行,比如被信号中断,或者 command 命令根本不存在,system()
函数返回 127。
如果 command 为 NULL,则 system()
函数返回非 0 值,一般为1。
#include <unistd.h>
#include <stdio.h>
int main() {
system("ls");
return 0;
}
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "-l", NULL);
exit(0);
} else {
int status;
int ret = wait(&status);
}
return 0;
}
当我们用 fork
产生一个新的子进程后,如果不对他进行 wait
,而父进程还在继续执行的话,此时这个子进程将会处于 defunct
状态(僵尸进程)。当父进程结束后,仍然会被收回。
所以,wait
是很有必要的,除了 wait
以外还有 waitpid
函数,可以帮助我们等待指定 pid
的子进程结束。
想一想:如果生成了多个子进程,该如何避免僵尸进程的出现?
除了“僵尸进程”以外,还有“孤儿进程”,感兴趣的同学可以自行搜索了解。
fork
与exec
实践
程序1
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int pid = fork();
printf("pid=%d\n", pid);
for (int i = 0; i < 10; i++) {
printf("line %d\n", i);
sleep(1);
}
return 0;
}
程序2
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int pid = fork();
int pid2 = fork();
printf("pid=%d\n", pid);
for (int i = 0; i < 10; i++) {
printf("line %d\n", i);
sleep(1);
}
return 0;
}
程序3
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
execlp("ls", "-l", NULL);
printf("omg\n");
return 0;
}
标签:fork,Shell,函数,实现,开始,int,进程,include,wait
From: https://www.cnblogs.com/dinosauria/p/18459101