????作者:一只大喵咪1201
????专栏:《Linux学习》
????格言:你只管努力,剩下的交给时间!
在前面,我们学习了进程的相关概念,在这里本喵会给大家介绍如何控制进程。
进程控制
一、进程创建
- 函数:pid_t fork(void);
- 返回值:
-1:子进程没有创建成功。
0:该值传给子进程。
pid值:该值传给父进程。- 作用:创建一个子进程,如果创建成功,返回两个值。
之前我们都是直接在用这个函数,它的原理到底是什么样我们并不清楚,现在本喵来回答三个问题。
如何理解fork有两个返回值?
这个问题是不是一直都在困扰着你?一个函数居然有两个返回值,这是怎么做到的呢?在学习了进程地址空间以后就可以回答这个问题了。
在一段代码中,执行完fork函数以后就会创建出子进程,此时就同时存在两个进程。
fork函数是一个系统调用,但是它仍然是一个函数,只是执行者是操作系统而已,既然有返回值,所以该函数的最后一条语句肯定是return。
fork函数中做的事情大概如上图,这里仅是一个感性的介绍,可以看到,在return之前,该函数的核心逻辑已经做完了,就和我们平时写的函数一样,return的时候,核心逻辑肯定是执行完的。
换句话说,在执行return的时候,子进程已经被创建成功了。
此时父进程和子进程都会执行return语句,都会返回pid值,所以表现出来的就是fork有两个返回值。
如何理解fork之后,给父进程返回子进程的pid,给子进程返回0?
现在已经知道,父子进程都会执行return语句,所以会有两个返回的pid值,但又是怎么做到给父进程和子进程返回的值是不同的呢?
在这里本喵正式介绍一下写时拷贝。
写时拷贝
上图描述了子进程创建的过程。
在父进程创建子进程之前,它有自己的PCB,还有自己的进程地址空间,并且通过页表的映射,在物理内存中也有相应的内存空间。
当执行完fork以后,子进程诞生了,它同样具有自己的PCB以及进程地址空间和页表。
但是此时,子进程的数据段和代码段等内容经过页表的映射,也指向了父进程在物理内存中的空间。
- 子进程会继承父进程的资源。
也就是说,子进程在刚创建的时候,它和父进程共用同一块物理内存。
当子进程尝试这对数据段中的内容做修改的时候,就会发生写时拷贝。
所谓写时拷贝,就是在物理内存中,将被修改空间中的内容先拷贝到另一个物理空间中,并且修改对应的页表映射关系,再修改拷贝后新空间中的内容。
pid_t id这个用来接受返回值的变量,在子进程刚创建的时候,在物理内存中是只有一个的,但是在返回的时候,父子进程return的值不一样,也就是物理内存中的id值被修改了,为了保证进程的独立性,所以就会发生写时拷贝。
- 父进程和子进程谁先返回谁就发生写时拷贝,并且使用新物理空间,至于它俩是谁先返回的,这个是不一定的。
正是由于写时拷贝的存在,所以父进程和子进程能够接收到不同的返回值。
如何理解,父子进程让if和else if同时执行?
根据上面的介绍,我们知道,由于发生了写时拷贝,所以父进程和子进程都有一个id值,并且是不同的。
fork之后的代码,父子进程是共享的。
也就是说,在fork之后的代码,父子进程是共同执行的,并且父子进程使用的是同一块物理空间中的代码。
但是各自的id值是不同的,所以会父子进程会进入不同的条件判断中,并且执行不同的代码。
二、进程终止
进程终止指的就是程序执行结束了,进程终止退出的场景有三种:
- 代码运行完毕且结果正确
- 代码运行完毕但结果不正确
- 代码异常终止
程序执行完毕
我们在写代码的时候,main函数最后总会有一个return 0,其中0就是进程终止的退出码。
退出码:用来标识程序的运行结果。
在C语言的库中,官方提供了很多的退出码:
将官方提供的退出码全部打印出来,可以看到,一共有134个退出码,其中退出码为0表示成功,退出码是其他的时候,都代表着不同的意义。
- 0只有一个,所以用它来标识成功,其他数字有无数个,所以用非0数字表述程序执行的其他情况。
我们可以根据程序的退出码来判断程序执行的情况,在shell中也有专门的变量来查看程序的退出码:
写一个打印hello world的程序,return的值是0,也就是程序的退出码是0。在shell中使用本地变量?来查看退出码。
- ?:这是一个本地变量,用了存放最近一个进程的退出码
上面的程序中,退出码都是0,标识的是成功,同样我们可以根据不同的情况返回不同的退出码。
上面程序是从1加到100,如果结果正确返回0,结果不正确返回1.
可以看到,退出码是1,表示结果不正确,此时我们是自己定义的退出码是1标识结果不正确。并没有使用官方库中提供的退出码。
return 后面的数字是进程的退出码,用echo查看的退出码是main函数中的return值,为什么不是加法函数中的return值呢?
只有main函数中的return值才能作为进程的退出码,其他自己实现的函数中的return值不能够作为退出码。
如果就我们就想在子函数中退出,并且有相应的退出码呢?
在加法函数中,如果结果正确,退出码是0,如果结果不正确,退出码是10.
可以看到,此时的退出码是10。
- exit(int num)函数的作用是终止进程,并且进程的退出码就是它的参数。
exit()和_exit()的区别
此时使用_exit函数来终止进程。
可以看到,退出码仍然是10,和使用exit()的结果是一样的。
那么exit和_exit到底有什么区别呢?
使用exit()来终止进程。
可以看到,字符串正常打印了出来。
使用_exit()来终止进程。
运行结果中,并没有字符串的打印,这是什么原因呢?
- 程序是属于用户层的,exit和_exit等函数都是在程序中的。
- 调用exit()之后,还会再执行一些程序,比如刷新缓存区等操作,然后再调用_exit()终止进程,exit的_exit的封装。
- 调用_exit()之后,会直接终止进程,不会执行刷新缓存区等操作。
区别:也就是说,_exit()是系统调用,而exit()是库函数。
所以在使用_exit的时候,缓冲区还没有来得及刷新,进程就被终止了,所以字符串就没被打印出来。
结论:程序执行完毕的情况下,只有main函数中的return和exit或者_exit的才能终止进程,并且返回退出码。
程序异常终止
异常终止就是程序执行到一半不再执行了,因为发生了异常。
上图中的程序,发生了除0操作,所以肯定是异常,如果不异常的话,会打印一个语句再终止进程。
可以看到,打印的语句并不是DIV执行成功,而是打印了异常信息,告诉我们浮点数除0了。
此时程序就没有执行完毕,但是异常终止了。
三、进程等待
进程等待主要使用上面的俩个系统调用,等待成功返回被等待进程的pid值。
进程等待的作用:
- 回收子进程资源
- 获取子进程退出信息
回收子进程资源
先来看看现象:
子进程在执行了10秒钟之后终止了,退出码是10.
此时子进程成了僵尸进程,如上图中红色框中所示的Z+。
此时就是连kill -9 都无法杀死这个僵尸进程,因为已经挂了的进程是无法再杀死的,所以它仍然是僵尸进程。
在子进程终止的10秒钟后,使用waitpid函数来等待子进程。
在子进程终止后的10秒钟内,子进程的状态是僵尸状态。
在进程等待成功以后,处于僵尸状态的子进程(pid值为26718)的进程就没有了,如上图中绿色框所示,此时子进程的资源也被回收了。
进程等待可以回收子进程资源。
获取退出信息
再看进程等待的系统调用:
- pid_t waitpid(pid_t pid, int* status, int options);
该函数的三个参数中,第二参数status是一个输出型参数,进程退出的信息就放在这个变量里。
这个变量是一个32为的int类型的变量,在存放进程退出状态的时候只用到了16位。
- [15:8]:这8位放的是进程的退出码。
- [6:0]:这7位放的是进程异常退出时的终止信号。
*[7]:这一位放的是一个标志位,以后本喵再详细介绍这一位。
通过移位操作,获取到status的次低8位和低7位,也就是进程退出码和终止信号信息并且打印出来。
可以看到,在子进程终止以后,父进程进行等待,等待成功以后打印出了子进程的退出码和终止信息。
- 我们在子进程中使用exit(10)来终止进程。
此时我们是使用kill -9来终止的子进程,所以打印出来的终止进行就是kill -9。进程异常终止的情况下,程序的退出码就没有任何意义了。
不仅可以使用kill -9来终止进程,还可以使用其他信号来终止进程。
此时是使用kill -4终止的进程,所以终止信号就是4.
一个进程退出以后会变成僵尸,并且会把自己的退出信息写入自己的PCB中的task_struct中,如下图所示的变量中。
wait和waitpid是系统调用,所以操作系统是有资格去读取子进程的task_struct的,读取到以后会放在status变量中。
阻塞和非阻塞等待
waitpid的第三个参数就是用了决定进程等待的方式的。
我们上面演示的就是阻塞式等待,此时第三个参数是0。所谓阻塞等待就是父进程什么也不干,就在等子进程终止,终止以后父进程继续执行,来回收子进程资源并且获取它的退出信息。
非阻塞等待是和阻塞等待相反的,在等待子进程终止期间,父进程还可以干它自己的事情,此时第三个参数是WNOHANG。
- waitpid的返回值:
-1:等待失败,例如给它传入一个不存在的pid值,就会等待失败
pid_t:等待成功以后,会返回被等待进程的pid值
0:在非阻塞等待中,如果子进程没有终止,那么返回0,如果终止且等待成功,返回子进程的pid值。
将等待方式设定为非阻塞等待。
可以看到,虽然使用了进程等待,但是子进程和父进在同时执行。
这里有一个问题,是进行了非阻塞等待,但是子进程终止了,父进程也没有进行资源回收和退出信息获取啊,也就是对子进程只询问了一次,发现子进程没有结束父进程就干自己的事了,之后也没有再询问。
这样的逻辑执行一次就被叫做非阻塞等待。
而将这部分代码放在一个while循环中,就会进行多次非阻塞等待,也会对子进程进行多次询问,此时叫做轮询。
此时在子进程执行的过程中,父进程在干自己的事之前都会等待一次子进程,当子进程终止以后,父进程在干完自己手头的事情发现子进程终止了,然后获取到了子进程的退出信息。
非阻塞等待的好处就是不会占用父进程的所有精力,父进程可以在轮询期间干自己的事情。
四、进程程序替换
首先我们要知道,创建子进程的目的是什么?无非就是两种目的:
- 想让子进程执行父进程代码的一部分。
- 想让子进程执行一个全新的代码。
我们之前所写的程序,子进程都是在执行父进程代码的一部分,而要想让子进程执行全新的代码,就需要进行进程程序替换。
这是程序替换用到的系统调用,本喵会挨个给大家介绍。
先来看看进程程序替换是什么:
在子进程中,用ls -al来替换程序,并且执行。
此时,使用我们自己的程序同样可以实现ls -al的功能,因为子进程执行的就是ls -a -l程序。因为程序替换成功了,所以返回ls程序的退出码,如果替换失败,就会执行exit(1)。
程序替换原理
在子进程刚创建的时候,子进程和父进程通过页表映射到物理内存中空间是同一块空间,父子进程的代码段,数据段,堆,栈等区域都同一个。
当子进程中执行exec*()函数的时候,会发生写时拷贝,将原本物理内存中的数据段和代码段拷贝一份,放在新的物理内存中。
将磁盘中要替换的可执行程序覆盖到新的物理内存中,并且改变子进程原本的页表映射关系。
仅程序发生了替换(数据段和代码段),子进程的PCB中的task_struct仍然不变。
而且写时拷贝不仅在数据段发生,在代码段也可以发生,写时拷贝的目的同样是为了保证进程的独立性。程序替换之后,子进程执行的代码也不再是原本父进程中的代码,而是全新的代码,比如上诉例子中的ls程序。
替换函数
- int execl(const char* path, const char* arg,…);
- 第一个参数是要替换程序所在路径,比如/usr/bin/ls,就是可执行程序ls所在的路径。
- 除了第一个参数以外,后面的参数是可变参数,也就是参数的个数是可以变的。
- 返回值:如果替换失败了就返回-1,替换成功了什么都不返回,因为程序已经被替换了,有没有返回值也没有意义。
可变参数我们在C语言中见过很多,比如printf:
这里的可变参数其实就是main函数的命令行参数中的char* argv,argv[0]是程序名,argv[1]及后面的是程序的选项,最后要有NULL结尾。
上面演示就是使用的execl()函数,本喵就不再演示了。
- int execlp(const char* file,const char* arg,…);
- 第一个参数是要替换程序的程序名字,操作系统会自动去环境变量PATH中寻找该程序名。
- 后面的参数和返回值是和execl一样的。
使用execlp的时候,只写了要替换的程序名ls,后面的可变参数也只写了ls。
由于替换ls的时候,没有加任何选项,所以现实的内容如上图中的红色框所示。
- int execle(const char* path,const char* arg,…,char* const envp[]);
- 第一个参数和execl一样,也是要替换程序的路径。
- 第二个开始直到倒数第一个之前都是可变参数,和execl还有execlp一样。
- 最后一个参数是环境变量。
替换的程序也可以是我们自己写的程序。
自己写一个获取环境变量的程序,并且打印出来。
- 自定义环境变量
原本,子进程和父进程都是使用的系统的环境变量,但是在子进程进行程序替换的时候,使用execle将自定义的环境变量传了过去,此时子进程在进行程序替换的同时,将原本系统的环境变量也进行了替换,替换成了自定义的环境变量。
可以看到,子进程打印出来的环境变量只有自己定义的3个环境变量。
- 系统环境变量
如果想将自己定义的环境变量也加到系统环境变量中呢?
使用putenv()系统调用将自定义的环境变量增加到系统的环境变量中,在给execle传参的时候,第三个参数传environ。
可以看到,子进程打印出来的环境变量,不仅有系统的,还有我们自己定义的。
对于系统的环境变量,即使不传参,子进程也是能获取到的,因为子进程会继承父进程的一切。
- int execv(const char* path, char* const argv[]);
- 第一个参数同样是需要替换程序的路径,和execl一样。
- 第二个参数不再是可变参数,而是将原本的可变参数放在一个指针数组中。
第二个参数的指针数组,和mian命令函数中的char* argv[]一样,argv[0]是程序名,argv[1]等之后的是选项,最后一个是NULL。
将ls -a -l 并且带颜色高亮的程序加选项以字符串的形式放在指针数组中,传给execv()函数。
结果和execl()一样。
- int execvp(const char* file, char* const argv[]);
- 第一个参数是需要替换的程序名,同样不需要路径,系统回去环境变量PATH中寻找,和execlp一样。
- 第二个参数和execv一样,不再讲解。
- int execvpe(const char* file, char* const argv[], char* const envp[]);
参数本喵都已经讲解过了,这里无非就是做了一些组合。
7. int execve(const char* path, char* const argv[], char* const envp[]);
参数类型本喵就不在这里继续讲解了,相信大家可以自己进行组合。
- 该函数是在手册2中的,其他6个程序替换函数是在手册3中的。
前面的六个程序替换函数,都是execve()函数的封装。
封装了六个不同函数的目的就是方便我们用户根据不同的使用情况去调用。
替换不同类型的程序
使用上诉7个程序替换函数,并不是父进程是C程序,替换的程序也必须是C程序,而是任何类型的程序都可以,因为任何类型的程序最后都是二进制机器码。
在while循环中,将cpp,python,shell三种不同类型的可执行程序,分别替换一次并且执行。
可以看到,这三种类型的可执行程序都可以去替换。
进程程序替换可以替换任何类型的可执行程序。
五、shell的简单实现
现在我们就可以更加清楚shell的运行机制了,bash是一个父进程,每输入一个指令就会创建一个子进程,并且进行相应的程序替换。
现在我们就来简单的模拟实现一下:
使用fgets函数获取输入的一行字符串,并且将这一行字符串进行切割,比如“ls -a -l”切割为“ls”,“-a”,“-l”,并且将切割后的字符串放入myargv指针数组中。
通过打印可以看到,分割成功了。
字符串分割成功以后,就要创建子进程,在子进程中进行进程程序替换,因为替换的程序都是指令池中的,所以使用execvp()即可。
为了能够让我们自己的shell一直运行,这部分逻辑全部放到while(1)循环中。
在我们对shell运行起来以后,输入ls指令可以成功显示文件,但是没有发生文件类型的高亮。
在将字符串中的程序名分割出来后,判断一下程序名是不是ls,如果是对话给它加高亮选项。
此时我们自己的shell中,使用ls指令以后,显示的文件名同样有颜色高亮。
但是又出现了问题,更换了路径,但是使用pwd后的现实的路径却没有发生改变。
内建/内置命令
首先需要明白,当前目录是什么?
- /proc中放的当前内存中存在的进程,176178是我们的shell的pid值,查看myshell进程文件的情况。
- 最重要的信息有两条:
橘黄色线:cwd就是表示当前路径,该路径就是当前进程的可执行程序所在的目录。
红色线:当前进程的路径。
所以说,当前路径是相对于进程来说的,不同进程对应的当前路径也是不同的。
我们的shell中,使用cd改变了路径,但是使用pwd以后,现实的路径没有发生改变,原因是:
- cd是子进程替换的程序,所以子进程执行的时候,确实改变了路径,但是当它终止后,它就退出了,所对应的cwd(当前路径也没有了)。
- 再使用pwd指令,查看的是myshell的cwd(当前路径),而myshell的当前路径是不会发生改变的。
找到原因再找办法解决,有一个系统调用chdir可以改变当前路径:
更改成功返回0,更改失败返回-1。
在将字符串分割完以后,在创建子进程之前,需要判断一下是否是cd指令,如果是cd指令的话,需要更改当前目录,这个操作父进程就可以完成,不需要创建子进程。
此时更换路径就不再存在问题了。
同样的,echo也是一个内建命令,只需要父进程就可以完成,不需要创建子进程。
在我们对shell程序中,使用echo打印退出码时,结果并不是预期那样。
当指令echo的时候,单独处理打印的内容,并且不需要创建子进程,父进程就可以完成。
出来内建指令,其他子进程终止后,父进程获取它们的退出信息。
此时echo指令就可以正常使用了。
内建/内置指令的本质就是不用创建子进程去完成任务,父进程就足够了。
myshell.c的全部代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
#define NUM 1024
#define OPT_NUM 64
char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int lastCode = 0;
int lastSig = 0;
int main()
{
while(1)
{
// 输出提示符
printf("[用户名@主机名 当前路径]# ");
fflush(stdout);
// 获取用户输入, 输入的时候,输入\n
char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);
assert(s != NULL);
(void)s;
// 清除最后一个\n , abcd\n
lineCommand[strlen(lineCommand)-1] = 0; // ?
//printf("test : %s\n", lineCommand);
// "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n
// 字符串切割
myargv[0] = strtok(lineCommand, " ");
int i = 1;
if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0)
{
myargv[i++] = (char*)"--color=auto";
}
// 如果没有子串了,strtok->NULL, myargv[end] = NULL
while(myargv[i++] = strtok(NULL, " "));
// 如果是cd命令,不需要创建子进程,让shell自己执行对应的命令,本质就是执行系统接口
// 像这种不需要让我们的子进程来执行,而是让shell自己执行的命令 --- 内建/内置命令
if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
{
if(myargv[1] != NULL) chdir(myargv[1]);
continue;
}
if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0)
{
if(strcmp(myargv[1], "$?") == 0)
{
printf("%d, %d\n", lastCode, lastSig);
}
else
{
printf("%s\n", myargv[1]);
}
continue;
}
// 测试是否成功, 条件编译
#ifdef DEBUG
for(int i = 0 ; myargv[i]; i++)
{
printf("myargv[%d]: %s\n", i, myargv[i]);
}
#endif
// 内建命令 --> echo
// 执行命令
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
execvp(myargv[0], myargv);
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
assert(ret > 0);
(void)ret;
lastCode = ((status>>8) & 0xFF);
lastSig = (status & 0x7F);
}
}
代码如上,全部都是本喵讲解到的知识点,本喵不再做详细解释。
可以看到,我们自己实现的shell,同样可以实现系统shell的部分功能,比如ls,cd,pwd等指令,因为在子进程中都是使用的指令池中的程序来替换的。
总结
进程控制内容是对前面进程学习内容的一种检验,很重要,尤其是进程程序替换,此时我们心中曾经的疑惑能够解开不少。希望对各位有所帮助。