进程控制
目录
进程创建
1、子进程继承
2、写时拷贝
进程退出
echo $?
退出码
进程异常退出的情况模拟:
退出进程的方式
退出码的意义:
进程退出,在系统中发生了什么?
进程等待
为什么要有进程等待呢?
wait函数与waitpid函数
option的两个取值
进程替换
为什么需要进程替换呢?
进程替换是什么呢?
如何进行进程替换呢?
myshell
1、初出茅庐的shell
2、"shell,狼来了!"
3、练就钢铁之躯
进程创建
1、子进程继承
默认情况下,与父进程共有同一段代码,即各自的页表的代码区映射关系指向的物理地址是一样的,数据在默认情况下也是继承父进程的,但是子进程与父进程做同样的事,未免有点画蛇添足,因此实际情况中,往往是干着不一样的事。
2、写时拷贝
当父/子进程要进行数据写入的时候,就会发生写时拷贝,先将数据复制一份,然后将页表的映射指向新数据,再对新数据进行修改。在这个复制过程期间,对父子进程均透明,子进程在此期间是被中断的。
eg:return的时候,子进程会返回一个退出码,给父进程返回pid;当发生进程替换的时候,会发生代码的写时拷贝,进程替换在后面会详细讲解。
// 正常的创建进程 子进程在执行了5秒之后,在此期间父进程也在执行,子进程执行完毕后转为僵尸状态,然后父进程再等待3秒,进程结束int x = 50;if(id == 0) {// childint count = 5;while (count) {if (count == 3)x = 100;printf("I am child count = %d pid : %d x = %d\n", count, getpid(), x);sleep(1);count--;}}sleep(8);printf("i am parent x = %d\n", x);
进程退出
进程退出的三种情况
1、运行完毕,结果正确
2、运行完毕,结果不正确
3、代码异常终止
echo $?
如何观察退出是否正常呢?
通过echo $? 就可以观察到最近一次的退出码,退出码从何而来呢?
是通过exit, return, _exit这三个函数所得到的
当把return 0 改成 return 12;就可以发现echo $? 的值是12了
退出码
退出码有许多个,每一个退出码都具有不同的意思,可以通过strerror()函数来查看每个错误码的解释
printf("i am parent x = %d\n", x);
int i = 0;
for (i = 0; i < 140; i++) {printf("strerror[%d] : %s\n", i, strerror(i));
}
在当前的Linux环境下,一共有134个退出码,除0以外,分别代表一种错误情况。平时遇到程序错误,就可以通过错误码来查看错误原因。
进程异常退出的情况模拟:
/* * 测试pid_t wait(int status);函数* 子进程创建成功,并exit(10);进行了进程终止,此时ret以特殊的格式来接收到该值*/if(id == 0) { // child // 测试异常终止 int x = 10;x /= 0; // 除0错误 int count = 3; while (count) { printf("I am child count = %d pid : %d\n", count, getpid());sleep(1);count--; } exit(10); } pid_t ret = wait(NULL); if(ret == 0) { printf("child success ret : %d\n", ret); } else {printf("child failed ret : %d\n", ret);}/* */
大家想一下,在遇到进程异常终止的时候,他所谓的退出码还有意义吗?退出码是用来做什么的?是用来告诉父进程,我是你的子进程,我已经执行完毕了啊,等着父进程来终结(释放)他,那么此刻我们再想想一个进程如果异常终止了,那么再传回一个退出码还有意义吗?你都异常终止了,你穿回来的退出码还有什么意义呢?对吧程序都没有执行完全,这个结果无论结果是否正确都不可取。所以我们要说的是什么呢?进程如果是异常终止,实际上是由一个信号中断,就比如这里的除0异常终止,在上面的一张strerror的截图里面正好有12号错误原因,感兴趣的大家可以在Linux里面将其余的错误原因全部打印出来看一看。
退出进程的方式
1、return
2、exit()
3、_exit()
我们知道,printf的机制是先将文本存储在缓冲区中,当缓冲区满了的时候才会刷新在屏幕上,又或者是在结尾加\n 或者 \r 进行强制刷新缓冲区,那么下面这段代码,结尾没有加\n 或者\r进行刷新,那么是怎么将结果刷新出来的呢?
原因是:在return或者exit退出程序之前,会强制刷新缓冲区,因此就可以将文本刷新在屏幕上。exit(EXIT_SUCCESS),就相当于exit(0);如果将return或者exit替换成_exit(EXIT_SUCCESS)会发生什么呢?
可以看到屏幕没有刷新文本,因此,_exit()与exit 和 return 的区别就是,前者不刷新,后两者会强制刷新。
此时可能就会有人要问了:既然不刷新,那么缓冲区里面的数据被占用着,会造成内存泄漏吗?答案是不会的,因为这只是不将文本刷新在屏幕上,但不意味着不对内部的空间进行释放。
退出码的意义:
1、main函数返回代表进程终止,非main函数返回,是函数返回
2、exit(x); 代表进程退出,参数是退出码,在任意地方都可调用
进程退出,在系统中发生了什么?
创建一个进程的时候,会给其创建描述进程的PCB,进程地址空间mm_struct,虚拟地址到物理地址的映射关系的页表,代码,数据,那么在退出的时候,这些信息就不会在使用了,因此在退出的时候就会释放(free)创建时所创建的所有内容。
进程等待
为什么要有进程等待呢?
1、通过子进程退出的信息,得知子进程的执行结果
2、保证时序问题,要让子进程先退出
3、子进程提前退出会进入僵尸状态,如果不释放,会造成内存泄漏,就需要通过父进程wait来释放子进程所占用的资源
wait函数与waitpid函数
函数原型
#include <sys/wait.h>
pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options); // pid 有两个值:0代表等待具体的一个子进程;-1代表等待任意一个子进程// status就是之前所说的退出码,该参数是个输出型参数,占4字节// options 有两个值,0:阻塞等待;WNOHANG:非阻塞等待
if (id == 0) {// child
int cnt = 3;while (cnt) {printf ("i am child cnt = %d pid = %d\n", cnt, getpid());--cnt;sleep(1);}exit(1);
}
// parent
int status = 0;
int ret = wait(&status);
while(1) {printf ("i am parent ret = %d status = %d\n", ret, status);sleep(1);
}
可以看到wait函数的返回值是子进程的pid,但是status为什么不是返回值1呢?这个此时先留着,在等会看完waitpid(),然后再来总结。
if(id == 0) {// child// 测试异常终止//int x = 10;//x /= 0;
int count = 3;while (count) {printf("I am child count = %d pid : %d\n", count, getpid());sleep(1);count--;}exit(0);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
printf("ret = %d status = %d exit_code = %d exit_singal = %d\n",ret, status, (status >> 8) & 0xff, status & 0x7f);
可以看到的status又是退出码了,这是怎么回事呢?
原来啊,这个输出型参数是大有讲究的,不是单纯的返回退出码,而是一系列信息都集合于他身上,可以将这个整形理解成是一个位段,在这32个字节里面,不同区间范围就具有着不同意义。
今天我们先来看一下这32个字节的低16位表示的意义吧。
因此,就可以解释为什么之前的wait(&status)传回来的值不是1了,原因就是因为传回来的1实际上是在第8位u,整理出来就是
status = 0x00 00 01 00,第九位刚好是2^8 = 256
有两个宏是可以专门获取对应的退出码和终止信号:
exit_code的宏命令:WIFEXITED(status) exit_singal的宏命令:WEXITSTATUS(status)
这里我们应该想到一种常用语句了,echo $?,为什么这条语句可以获取到上一次的退出码呢?
bash是命令行启动的所有进程的父进程,bash也一定是通过wait的方式去获取子进程的退出码的,因此echo $? 才能够获取到退出码
option的两个取值
0:默认行为,阻塞等待
通俗一点理解:你在车站等车,阻塞等待下的你,就只能做等车这一件事,其他的任何事都被暂停着,换做进程来说就是,父进程一直等待子进程的执行完毕,直到子进程执行完毕返回退出码等相关信息,父进程才会继续执行
WNOHANG:非阻塞等待
还是以上面这个例子为例,在你等待车到来之前,你可以做其他的事,期间每隔一个固定的时间去车站轮询监测以下车到来没有,如果到来了就坐车。换做进程来说就是在一个while循环里面,在子进程到来之前不断地做着自己的事,当子进程执行完毕,就根据子进程的结果在不同场景下继续执行直至结束。
阻塞本质:进程的PCB从R队列转换至S队列,并将状态设置为S
子进程返回退出码:父进程的PCB从S队列被放到R队列,并将状态重新设置为R,再次被CPU调度。
进程替换
为什么需要进程替换呢?
当我们需要在一个进程里面运行其他的进程的时候,该怎么做呢?比如在当前目录下有两个可执行文件A1, A2,现在想要在A1程序里面的子进程fork1里面执行A2,那么应该怎么办呢?这个时候,进程替换就体现出价值了。
进程替换是什么呢?
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,也就是将新代码和数据加载进数据区,代码区,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。进程替换的过程中,进程不会发生改变,改变的是数据和代码,也就是说,在进程替换这个过程中,进程会将自己的PCB,mm_struct等等进行改变,将目标进程的代码和数据等等与待替换进程进行替换。
程序替换并没有创建新的进程,而仅仅是将数据与代码进行了替换。由于在默认情况下,父子进程是公用代码的,那么在子进程将代码更改的时候,父进程的代码也会更改吗?由于进程的独立性特征,因此并不会更改父进程的代码,在更改的时候会发生写时拷贝,给子进程重新开辟一段空间用来存储需要的代码
程序替换的本质是将指定进程的代码和数据加载进特定上下文中
如何进行进程替换呢?
函数原型:
#include <unistd.h>
extern char **environ;
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[]);int execve(const char *path, char *const argv[], char *const envp[]);
if (fork() == 0) {// child
execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);
printf("hello world\n");printf("hello world\n");printf("hello world\n"); printf("hello world\n"); printf("hello world\n"); printf("hello world\n");
}
// parent
int cnt = 3;
while (cnt) { printf ("i am parent \n");sleep(1);--cnt;
} waitpid(-1, NULL, 0); printf("wait success\n");
可以看到应该执行的printf("hello world\n");没有执行,而是执行了execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);
原因就是,这条语句会将子进程的代码,数据进行替换,而不再执行原先的程序转去执行新的被替换的进程的代码和数据,需要注意的是,在此期间,没有创建新进程,而是将原进程的代码、数据进行了替换,这里之所以再提一遍,是因为这相当的重要。
接下来我们就验证一下其余的语句时如何使用的
int main()
{if (fork() == 0) {// execl 函数的使用 //execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);char * argv[] = {"ls", "-a", "-l", "-n", NULL};char* envp[] = { "hahaha", "hehehe", "wawawa", "gugugu", NULL };// execv 函数的使用//execv("/usr/bin/ls", argv);// execlp 函数的使用//execlp("ls", "ls", "-a", "-l", "-n", NULL);// execvp 函数的使用//execvp("ls", argv);
// execle 函数的使用//execle("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL, envp);execve("/usr/bin/ls", argv, envp);
printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");exit(0);}waitpid(-1, NULL, 0);printf("wait success!\n");
return 0;
}
运行截图就不依次放了,因为结果除最后两个函数,其余都一样。
想必大家都注意到了这些函数的一些特点,比如:
1、一定是以NULL结尾;
2、函数是以exec开头的,在其后面添加不同的字母就会以不同的格式来调用对应函数;
3、以及两个指针数组*argv[], *envp[];
4、如果大家有去动手实验就会发现这几组代码都是呈现出同样的结果,不过最后两个execve,execle会呈现出不同的结果。
指针数组就是将之前的可变参数列表换成了数组形式,换汤不换药。
execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);
char * argv[] = {"ls", "-a", "-l", "-n", NULL};
既然可以执行一些命令,而那些命令的本质其实也是文件,那么可以执行我们自己的程序吗?当然可以
这里我们创建一个myexe.c文件,然后编译链接生成myexe可执行文件
#include <stdio.h>
int main()
{printf("cann you see me?\n");return 0;
}
然后在之前的文件里面加上以下这条语句
execl("./myexe", "./myexe", NULL)
就可以发现在子进程里面成功运行了myexe这个我们自己的进程。
myshell
1、初出茅庐的shell
有了以上的基础,我们再来试着做一个简陋版本的myshell
可以先想一下,一个shell首先会打印出一些识别信息,并且是不断地循环,因此将其放在while(1)这个死循环中
然后我们的myshell程序先将这一串字符打印出来
while(1) {printf("[HB@VM mini_shell] #");
}
但是如果大家去这样试了,就会发现不会立即将字符串打印出来,而是等待很长的时间才能打印出来,而且还是一长串,很明显这是错误的,原因就是printf里面字符串的结尾如果没有/n 或者/r就不会立即刷新,但是如果加上/n就会多一个换行,那这还和我们使用的shell一样吗?不一样了,看着也别捏。那么应该如何才能刷新呢?这里就需要引出一个函数
这个函数可以立即将文本刷新在某个文件流内,因此源代码再次发生改变
while(1) {printf("[HB@VM mini_shell] #");fflush(stdout); //将内容刷新至stdout内,也就是标准输出流,即我们使用的显示器sleep(5);
}
因此我们的myshell初有成型了,接下来我们实现一些命令功能,比如ls -a -n -l ……
2、"shell,狼来了!"
shell既然要进行文本的输入,而这些文本可能是不连续的字符串,因此我们可以用一个指针数组来存储这些内容
接下来我们来实现以下接收这些字符串。大家想一想我们可以用scanf()来接收这些字符吗?比如“ls -a -l -n -i"这种格式的字符能一次性接收吗?是不可以的,因为scanf接收字符串的结束符是以\n 空格等等为标志的,然而以上这些字符串里面是包含有空格的,因此这种方式是不可以的,那么大家可以想到哪些接收多个字符串的函数了呢?在这里我们使用fgets,当然还有许多其他同功能的函数接口比如gets(),大家也可以使用一下
char command[NUM]; // 接收字符串
while(1) {// 1、打印提示符char* argv[COM_NUM] = {NULL};command[0] = 0; // 用这种方式可以做到O(1)的时间复杂度,情况字符串printf("[HB@VM mini_shell] #");fflush(stdout);//将内容刷新至stdout内,也就是标准输出流,即我们使用的显示器
// 2、输入字符串字符进command[]fgets(command, COM_NUM, stdin); // 将字符串输入到command中,argv来提取其中的有效字符 command[strlen(command) - 1] = '\0'; // fgets 会获取最后的回车符或者其他无效终止字符,要将该字符设置成\0sleep(5);
}
这样一来我们就可以正确获取字符串了,接下来我们就可以直接使用这个字符串了吗?不可以,为什么?先看一下这个格式与上述格式匹配吗?就以 int execvp(const char *file, char *const argv[]);为例,参数是明显不匹配的,虽然得到的字符串五脏俱全,但是没有放到正确的位置,那他就不是一个好的字符串,或者说此时的字符串不是一个合格的字符串,我们需要对他的信息进行提取重组,让他变的有意义,聪明的大家注意到“ls -a -l -n -i"这个字符串的特点了吗?首先ls 是const char *file的内容,其次"ls -a -l -n -i"是, char *const argv[]需要的内容,还有个很明显的特点,这些分离开的字符串都是以空格分割的,因此我们可以使用strtok诸如这类的字符串分割函数来分割处有意义的字符串,咱们废话不多说直接上干货:
该函数需要先提前调用一次,然后再继续调用,以上这张图片的描述内容说人话就是:每次调用了函数之后,会将第一次在delim字符串里面出现的字符编程\0,然后指针指向\0这个位置,之后再从该位置继续往后偏移分割。人话说完,我们来通过这次的实践来验证一下
int main()
{char command[NUM];while(1) {// 1、打印提示符char* argv[COM_NUM] = {NULL};command[0] = 0; // 用这种方式可以做到O(1)的时间复杂度,情况字符串printf("[HB@VM mini_shell] #");fflush(stdout);
// 2、输入字符串字符进command[]fgets(command, COM_NUM, stdin); // 将字符串输入到command中,argv来提取其中的有效字符 command[strlen(command) - 1] = '\0'; // fgets 会获取最后的回车符或者其他无效终止字符,要将该字符设置成\0
// 3、分割出有效字符串到argv中int index = 0;argv[index] = strtok(command, " ");++index;while (argv[index] = strtok(NULL, " "))++index;sleep(1); // 将argv的数据打印出来验证// int i = 0;// for (i = 0; i <= index; i++)// printf( " argv[%d] = %s\n", i, argv[i] );}return 0;
}
3、练就钢铁之躯
到这个时候,我们就可以看到可以获取命令的字符串了,接下来只需要我们去执行命令就可以了,那么有了之前的进程替换的经验之后,再来进行进程替换可谓是手到擒来,而我们需要做的就只是从中选择一个更好的格式来执行命令,观察字符串可以看到,这是一个文件,后续的则是指针数组,因此我们选用int execvp(const char *file, char *const argv[]);
int main()
{char command[NUM];while(1) {// 1、打印提示符char* argv[COM_NUM] = {NULL};command[0] = 0; // 用这种方式可以做到O(1)的时间复杂度,情况字符串printf("[HB@VM mini_shell] #");fflush(stdout);
// 2、输入字符串字符进command[]fgets(command, COM_NUM, stdin); // 将字符串输入到command中,argv来提取其中的有效字符 command[strlen(command) - 1] = '\0'; // fgets 会获取最后的回车符或者其他无效终止字符,要将该字符设置成\0
// 3、分割出有效字符串到argv中int index = 0;argv[index] = strtok(command, " ");++index;while (argv[index] = strtok(NULL, " "))++index;execvp(argv[0], argv);sleep(1); // 将argv的数据打印出来验证// int i = 0;// for (i = 0; i <= index; i++)// printf( " argv[%d] = %s\n", i, argv[i] );}return 0;
}
这样一来我们的shell就可以执行命令了,但是大家看截图会发现,诶,我才执行一次命令,myshell你怎么就退出了呢?这样的程序实在是做不到百毒不侵,金刚护体,那么我们该怎么办呢?
我们先来分析以下没有一直运行的原因是什么,可以看到execvp(argv[0], argv);这个语句是在进程里面进行进程替换的,那么回顾之前的进程替换原理,此刻进行了替换之后,执行的还会是之前的父进程吗?父进程还存在吗?答案显而易见,父进程还是之前的父进程,父进程还存在着并没有因为进程替换而创建了新的进程,但是此时的父进程的血液已经被人换了,而且换得很彻底,虽然是原先的躯壳却不再是原先的灵魂了,因为代码和数据在发生进程替换的时候已经完完全全被新来的替换了,所以原先的代码数据也就不再是有效的,所以只执行了一次被加载到代码段和数据段的新进程,然后这个进程如果没有循环语句,就会瞬间die。
那么我们应该如何避免这种危险行为的发生呢?在很久以前,我们讲过媒婆和王婆的故事,那么我们的myshell就可以看作媒婆这个职业,我们需要去制造一个王婆,说人话就是创建一个子进程,让子进程来执行这条语句,接下来看代码:
int main()
{char command[NUM];while(1) {// 1、打印提示符char* argv[COM_NUM] = {NULL};command[0] = 0; // 用这种方式可以做到O(1)的时间复杂度,情况字符串printf("[HB@VM mini_shell] #");fflush(stdout);
// 2、输入字符串字符进command[]fgets(command, COM_NUM, stdin); // 将字符串输入到command中,argv来提取其中的有效字符 command[strlen(command) - 1] = '\0'; // fgets 会获取最后的回车符或者其他无效终止字符,要将该字符设置成\0
// 3、分割出有效字符串到argv中int index = 0;argv[index] = strtok(command, " ");++index;while (argv[index] = strtok(NULL, " "))++index;// 5、 执行第三方命令 if (fork() == 0) {execvp(argv[0], argv);exit(0);}}return 0;
}
大家仔细看这张截图会发现,本该切换路径的cd .. 却没有变换路径,大家可能会说:这王婆是不是在偷懒啊,居然没有执行媒婆下达的命令,但媒婆突然站出来说:大兄弟,你让王婆去到这个这件事下有什么用啊?有什么用啊?我没有去做这件事啊,王婆她做完这件可是就要去做其他的事了,而且你也没有跟她说做完之后跟我汇报啊,所以这件事你赖不着王婆啊,这得赖你啊!!!
好了,到这里,大家应该多少有点眉头了,现在将上面这句话说人话就是,子进程执行完之后瞬间被释放掉,但是父进程并不会切换路径,因为正在切换路径的是这个子进程而并不是父进程,所以要将父进程进行路径切换,而遇到这类情况必须要由父进程来处理的时候,我们 称之为内建命令。我们此时来接触一个新函数
这个函数就可以让父进程执行cd命令,废话咱们也不多说,直接上代码
int main()
{char command[NUM];while(1) {// 1、打印提示符char* argv[COM_NUM] = {NULL};command[0] = 0; // 用这种方式可以做到O(1)的时间复杂度,情况字符串printf("[HB@VM mini_shell] #");fflush(stdout);
// 2、输入字符串字符进command[]fgets(command, COM_NUM, stdin); // 将字符串输入到command中,argv来提取其中的有效字符 command[strlen(command) - 1] = '\0'; // fgets 会获取最后的回车符或者其他无效终止字符,要将该字符设置成\0
// 3、分割出有效字符串到argv中int index = 0;argv[index] = strtok(command, " ");++index;while (argv[index] = strtok(NULL, " "))++index;// 4、 检测命令是否需要让父进程shell来执行,而不是让子进程执行,内建命令if (strcmp(argv[0], "cd") == 0 ) {if (argv[1] != NULL) chdir(argv[1]);continue;}// 5、 执行第三方命令if (fork() == 0) {execvp(argv[0], argv);exit(0);}}return 0;
}
这样一来,当遇到cd这类命令的时候,就会去让父进程来执行,当然将步骤放在第四也是有原因的,因为要先让父进程进行切换路径,不然就会遇到上述情况。
本次的学习分享也要接近尾声了,接下来就让我们以myshell的执行情况来作为结束吧!!!
感谢大家的观看!!!若您们有任何疑问,私信我,看到必回!!