Linux进程控制
1、进程创建
在Linux中,我们可以通过fork来创建子进程。fork调用失败返回-1,调用成功给父进程返回子进程的pid,给子进程返回0。

下面演示使用fork创建一批子进程:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define N 5
void runChild()
{
int cnt = 10;
while (cnt--)
{
printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
}
int main()
{
int i = 0;
for (i = 0; i < N; i++)
{
pid_t id = fork();
if (id == 0)
{
runChild();
exit(0);
}
}
sleep(3000);
return 0;
}

fork创建子进程后,操作系统要给子进程创建task_struct结构体,以父进程的task_struct为模板初始化子进程,同时拷贝父进程的地址空间和页表给子进程。fork之后,父子进程代码共享,数据写时拷贝。

fork创建子进程后,它们指向的数据和代码是同一份,对于数据来说父子进程在页表的标志位都设置为只读。当某一方尝试对数据进行写入时,比如子进程写入,操作系统会辨别发现这里的数据是只读的,然后触发缺页中断,在内存中开辟新空间,拷贝一份数据,然后修改子进程页表中的物理地址,同时将父子进程对于该数据的权限修改为可读可写。
2、进程终止
进程退出有三种场景:
1、代码运行完毕,结果正确。
2、代码运行完毕,结果不正确。
3、代码异常终止。
为什么我们平时写的代码main函数的返回值是0呢?如果返回1、2…等其他数字呢?
我们就写个main函数返回return 1的代码:
运行后,我们可以通过echo $?查看我们main函数的返回值:

我们平时main函数都是返回0,代表的是success,表示程序执行完毕,结果正确。
那么对于程序执行完毕,结果正确不正确我们是不是应该关心呢?那么谁来关心呢?
当然是该进程的父进程来关心,父进程要关心他所交给子进程的任务子进程完成的如何了。如果代码运行完结果正确,那当然是皆大欢喜。如果代码运行完结果不正确,子进程应该要告诉父进程为什么出错了。
所以通过return返回不同的值,表征不同的出错原因。我们把return的值称为——退出码。
使用echo $?可以查看最近一次进程的退出码。

我们在main函数中return 1,所以运行程序后使用echo $?查看最近进程的退出码就是1。那么再次查看为什么变成了0呢?因为echo命令也要有进程来执行,echo命令正常执行结果正确,所以退出码是0。我们再次查看,看到的就是echo程序的退出码。
在C语言中我们知道有错误码描述,下面介绍一个函数:strerror

sterror返回错误码所对应的字符串描述信息。
那么我们也不知道多少个错误码,我们可以直接循环两百次打印看看:

总共一百多个错误码描述,可以看到每个值对应的错误码描述。0表示的就是success。

我们在使用ls指令查看不存在的文件时,就会显示No such file or directory,这不就是错误码2对应的描述吗。我们执行ls指令,结果不正确就通过退出码来标识,然后打印给用户看。
另外C语言还存在一个全局变量errno ,当我们malloc失败,调用某些库函数失败时errno就会被设置。所以调用库函数失败我们可以通过strerror(errno)打印查看错误码信息。
系统提供的错误码和错误码描述是有对应关系的。
那么我们可不可以自己设计一套退出码体系呢?当然可以,类似下面的方式:

再来看代码异常终止,代码异常终止本质上代码可能没有跑完,那么我们就不需要再关系进程的退出码了,你代码都没跑完我关心你退出码有什么意义。所以我们要关心进程发生了什么异常。
下面我们看C语言中除0错误和指针越界访问异常:


进程出现异常,本质是进程收到了信号。我们可以使用kill -l查看所有信号:

除零错误本质上是CPU状态寄存器出现了溢出错误,给对应进程发送8号信号。
野指针本质上是CPU访问虚拟地址,对应页表没有映射关系,或者建立了映射关系,但是权限设置为只读,所以给进程发送11号信号。
如何验证?我们可以给一个正在运行的进程发送8号和11号信号:


所以对于一个进程退出,我们要先关心该进程是否出现了代码异常,也就是要先看它有没有收到信号。其次再看它的退出码,判断进程运行结果是否正确。
说了这么多,终止进程如何操作呢?

使用exit终止进程,status就是进程的退出码,用status表征进程运行结果是否正确。


可以看到,调用exit函数后,进程直接退出,不会打印后面的内容,并且我们使用echo $?查看进程退出码就是我们传给exit的参数。
exit和return在main函数里面是等价的。
return:在main函数中表示进程退出。在其他函数中表示函数返回,还会继续向后执行。
exit:在任意地方被调用都表示进程退出。
_exit系统调用接口:

下面对比_exit和exit:


把exit换成_exit再次运行:

我们发现第一份代码需要经过一段时间才会将hello linux打印出来。而调用_exit则是连打印都没有了。这是为什么呢?
在进度条我们说过,C语言存在缓冲区,那么hello linux肯定是先存入缓冲区中,然后等待某种条件就绪才会刷新,这里的条件就包括(遇到\n,进程退出)。所以调用exit后进程退出它才刷新出来。
而_exit是系统调用接口,它会直接到系统内核去执行。

从上图可以看到,_exit会直接到系统内核中去执行,而exit会先刷新缓冲区,然后再到内核中去执行。
exit本质上就是先执行清理,刷新缓冲区、关闭流等,然后再调用_exit,所以它们是上下层关系。
那么我们现在就可以判断,这个缓冲区就对不在哪里?——这个缓冲区绝对不可能在内核中。因为如果在内核中_exit肯定会刷新缓冲区,操作系统不会做任何浪费资源的事情。
3、进程等待
为什么要进程等待?
僵尸进程无法被杀死,需要通过进程等待来杀掉它,进而解决内存泄漏的问题。——这是必须解决的
我们通过进程等待可以获取子进程的退出情况——知道我布置给子进程的任务它完成得怎么样了——可选的。
什么是进程等待?
通过系统调用wait/waitpid,来对子进程进行状态检测与回收的功能。

下面我们先看wait的使用,wait有一个int*的参数,我们暂时不关心它,后面会讲,所以我们在下面的代码调用wait传参的时候暂时设为NULL。

wait成功返回子进程的pid,失败则返回-1。
3.1、使用wait等待子进程退出:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork:");
return 1;
}
else if (id == 0)
{
int cnt = 5;
while (cnt)
{
printf("I am child, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
cnt--;
sleep(1);
}
exit(0);
}
sleep(10);
pid_t ret = wait(NULL);
if (ret > 0)
{
printf("parent wait %d success\n", ret);
}
return 0;
}

可以看到父进程先休眠10秒,子进程运行结束后退出,子进程变成僵尸状态,然后父进程休眠结束后通过wait回收子进程资源,右侧就看不到子进程了,然后最后父进程退出。
需要注意的是,如果父进程不休眠10秒,那么就会在wait处一直等待子进程返回,我们称为阻塞式等待。并且wait函数等待的是任意一个进程。
3.2、使用wait等待多个子进程退出:


waitpid的第一个参数可以传对应进程的pid,表示等待对应进程,也可以传-1,表示等待任意一个进程。后面两个参数暂时设置为NULL和0。waitpid等待成功返回子进程pid,等待失败返回-1。这里的等待失败表示的是你传的第一个参数不正确,并不是它的子进程,那当然就会失败。
3.3、使用waitpid等待指定进程:

如果第一个参数给-1就是等待任意子进程,效果是一样的。
3.4、使用waitpid等待多个子进程:


下面来看它们都有的共同的参数status。
那么父进程等待,期望获得子进程的那些信息呢?1、子进程代码是否异常?2、没有异常结果是否正确?通过退出码来标识。
这里的status是一个输出型参数,int类型会被分成几部分使用。int类型有32个比特位,但是这里我们只考虑它的低16位。如下图:

这16位又被分成三个部分,低7位表示终止信号,第8位标识core dump标志这个我们到动静态库那边再讲,高8位标识退出状态。
我们可以使用kill -l命令查看所有信号,发现信号是从1开始的,所以我们可以判断status的低7位是否为零,从而判断进程是否收到了信号,进而判断进程代码是否异常。那么如果进程代码没有异常,我们就可以提取高8位的值,来判断进程的退出码是什么,从而判断进程执行结果是否正确,或者出错的原因。
3.5、提取子进程的退出状态:status&0x7f可以获取低7位的值,(status>>8)&0xff可以获取高8位的值


3.6、我们让子进程除零错误或空指针访问,那么子进程就会被信号所杀,然后我们期望在父进程看到子进程的低7位是不为0的。

我们在子进程中直接除零错误或者空指针访问,父进程成功等待子进程后,提取子进程的退出信息,可以看到低7位不为0,而是11或8,标识收到的几号信号。而退出码我们此时就不关心了。
3.7、通过宏来提取信息:
WIFEXITED(status):若子进程正常退出,则为正。用来判断子进程是否代码异常。
WEXITSTATUS(status):提取子进程的退出码。

waitpid原理:

首先子进程退出对应的PCB绝对不能被释放,而它的代码和数据是可以被释放的,节省操作系统资源。那子进程的退出信息应该保存在哪里?当然是子进程的PCB中,所以调用waitpid就是到子进程的PCB中获取其PCB内的sigcode和exitcode,然后将这两个值通过位运算组合成status,这样上层就可以获取子进程的退出信息了。那么父进程获取后,就可以将子进程PCB释放掉了。

最后来谈waitpid的第三个参数options,我们之前给的都是0,表示阻塞式等待,我们还可以给一个宏:WNOHANG,表示非阻塞等待。那么非阻塞等待就有一个对应的返回值,就是0,表示子进程还未退出,父进程等待的条件还未就绪。
下面讲一个例子加深理解:
假设你们学校老师说过三天要考C语言了,但是你一点都不慌,过了两天,明天已经准备考试了,这时候你才想起来得好好复习一下,但是你们班有个学霸小张,上课笔记也做的特别认真,所以这天早上你赶紧去到小张宿舍楼下,并打电话给他说,小张你在干嘛呢,小张说我在复习呢,你说小张你赶紧跟我去复习时给我复习一下C语言,然后你的笔记给我看一下,小张说好的,不过得等我先复习完。电话挂了,然后你就在楼下等小张了,但是过了一段时间小张还没下来,你又打电话去问,小张说快了。电话挂了,然后你继续等,过了一会小张还没下来,所以你又打电话给小张,小张说马上了。但是过了一会他还是没下来,前前后后你可能打了二十个电话,最终小张终于到楼下了,你们一起去吃饭,吃完饭就去自习室复习了。那么,在这个过程中,小张相当于是操作系统,你相当于是父进程,打电话的本质就是系统调用,那么小张还没好就是检查不成功,电话挂掉就是系统调用返回。所以这个过程中你就是处于非阻塞,且时不时去询问小张,这就是非阻塞+轮询。
C语言考完了,你考了61分,刚好过线,很高兴。但是这天老师又说过两天要考数据结构了,你还是心不在焉。考试前一天,你又到小张宿舍楼下,并打电话给小张说,小张赶紧跟我去自习室,帮我复习一下数据结构,小张说好的,不过我现在在复习,你得等一会。这时候你说,那这样,电话也别挂了,等下你好了跟我说一声。小张想竟然还有这种需求,好吧,那我就把电话放在旁边,等下复习好了直接跟他说。所以你就一直等一直等,等了好久终于小张复习完了,然后人下了宿舍楼跟你说他到了,你也看到了小张了,这时候才把电话挂了。然后你们就去吃饭,然后愉快的去自习室了。这时候你就相当于是处于阻塞状态,一直等到小张复习完。
很幸运,你数据结构59分,但是老师给了你机会,让你60分过了。那么又来了,这天老师说过两天要考操作系统,那么这时候你就很慌,操作系统这么难,没考过可咋办,所以你又去小张宿舍楼下,不过这时候你还带了本操作系统的书,然后打电话给小张说,小张老样子,小张说好的,得等我复习完。有了前两次的经验,你也知道小张一时半会下不来,所以就带了本书,然后打完电话你就在那看了会书。然后又继续打电话问小张什么时候下来,小张说快了。挂了电话后你又继续看了会书,就这样直到小张下来,你们就一同去自习室了。这时候你不断打电话去问小张,小张还没复习完,你就做自己的事情。这就是非阻塞+轮询+自己的事情。
3.8、非阻塞轮询等待子进程:


父进程每隔一秒对子进程进行轮询,同时做自己的事情:

4、进程程序替换
man查看3号手册:man 3 execl

存在以上这六个函数,我们先挑一个来演示看看现象:
int execl(const char *path, const char *arg, ...);
这六个函数可以进行程序替换。execl的第一个参数path表示可执行程序的路径,而指令本身就是可执行程序,所以我们就以/usr/bin下的可执行程序来演示。然后arg表示选项,…是可变参数,所以后面跟你需要加的选项。那么最后可变参数需要跟NULL。用法如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("before: I am a process, pid:%d, ppid:%d\n", getpid(), getppid());
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
printf("after: I am a process, pid:%d, ppid:%d\n", getpid(), getppid());
return 0;
}

运行程序后,我们发现打印了before,然后执行了ls指令,但是after并没有执行。这就是最简单的程序替换。
进程程序替换的原理:
CPU调度进程,操作系统给进程创建了PCB、地址空间、页表,程序的代码和数据也加载了一部分到内存中,虚拟地址通过页表映射物理地址。这时候程序替换执行了execl,那么进程就要去执行磁盘下的ls可执行程序。本质上就是把ls程序的数据和代码替换掉原来进程的数据和代码。那么该进程就去执行ls的代码了。这就是程序替换的基本原理。
下面再来看多进程的程序替换:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
printf("before: I am child, pid:%d, ppid:%d\n", getpid(), getppid());
sleep(3);
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("after: I am child, pid:%d, ppid:%d\n", getpid(), getppid());
exit(0);
}
pid_t ret = waitpid(id, NULL, 0);
if (ret == id)
{
printf("father wait %d success\n", ret);
}
sleep(3);
return 0;
}

父进程创建子进程,操作系统要给子进程创建task_struct、mm_struct、页表,由于父子进程数据和代码共享,所以它们映射的物理内存是一样的。而子进程进行程序替换,需要将ls程序的代码和数据替换到物理内存中,那么就是要写入,所以发生写时拷贝,重新开辟新空间,然后将ls程序的数据和代码加载到内存中,修改子进程页表中的物理地址,从而保证了进程间的独立性。所以在这里我们可以看到,不仅数据发生了写时拷贝,代码也是。这样才不会影响到父进程。
那么程序替换有没有创建新进程??——不创建新进程,只进行进程的程序代码和数据替换工作。
并且我们注意到,子进程程序替换成功之后,并不会执行后面的打印after,所以程序替换成功,不会执行exec*后续的代码。只有替换失败才可能执行后面的代码。所以exec*系列函数没有成功返回值,只有失败返回值,失败返回-1。
小知识:Linux中可执行程序是有格式的——ELF,可执行程序是有表头的,表头存储了可执行程序的入口地址。所以程序替换之后可以找到替换的程序的入口地址。

以上是对execl函数的分析,这个l就是list,表示传参以上面这种方式,就类似链表一个一个节点一样,然后最后给NULL。
下面看其他函数:
int execlp(const char *file, const char *arg, ...);
execlp("ls", "ls", "-a", "-l", NULL);
execlp:p表示的就是环境变量的PATH,也就是说第一个参数我们可以直接传指令,execlp会直接帮我们在系统环境变量PATH中的路径进行搜索。
int execv(const char *path, char *const argv[]);
//execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
//execlp("ls", "ls", "-a", "-l", NULL);
char* const myargv[] = {
"ls",
"-a",
"-l",
NULL
};
execv("/usr/bin/ls", myargv);
execv:v表示vector,所以第二个参数argv是以指针数组的形式去传参的。
int execvp(const char *file, char *const argv[]);
//execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
//execlp("ls", "ls", "-a", "-l", NULL);
char* const myargv[] = {
"ls",
"-a",
"-l",
NULL
};
//execv("/usr/bin/ls", myargv);
execvp("ls", myargv);
execvp:以字符指针的传参方式,并且默认会在环境变量PATH中进行搜索。
思考:进行程序替换成ls可执行程序的时候,我们传了myargv参数,那么是不是会把myargv作为命令行参数传给ls程序中的main函数呢。
下面我们可以进行验证,程序替换并不是只能指令,也可以替换成我们写的可执行程序。
新建otherExe.cc,我们以C++程序为例,演示C程序替换C++程序运行的效果。我们在otherExe.cc中只执行简单的打印语句。
C++的源文件可以以.cxx、.cpp、.cc结尾。
下面介绍如何使用make/makefile形成多个可执行文件:

默认执行make只会编译第一个依赖关系的依赖方法,所以需要添加伪目标修饰all,all的依赖关系就是对应的两个可执行程序,但是没有依赖方法。

然后我们在mycommand.c中程序替换成我们自己写的可执行程序otherExe:
execl("/home/zzy/test/otherExe", "otherExe", NULL);

下面我们使用execl传点命令行参数,然后我们在otherExe.cc中获取命令行参数并打印出来,并且我们知道子进程会继承父进程的环境变量,因此我们把环境变量也打出来看看:
// mycommand.c
execl("/home/zzy/test/otherExe", "otherExe", "-a", "-b", "-c",NULL);
// otherExe.cc
#include <iostream>
using namespace std;
int main(int argc, char* argv[], char* env[])
{
cout << "hello c++ linux" << endl;
printf("这是命令行参数:\n");
for (int i = 0; i < argc; i++)
{
cout << i << ":" << argv[i] << endl;
}
printf("这是环境变量:\n");
for (int i = 0; env[i]; i++)
{
cout << i << ":" << env[i] << endl;
}
return 0;
}

可以看到,我们传给exec*函数的第二个参数会传给可执行程序的命令行参数,并且子进程继承了父进程的环境变量,因此环境变量具有全局属性。所以程序替换中,环境变量信息不会被替换。
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
这两个函数的e表示的就是环境变量,le表示的传参方式是以list,并且可以传递环境变量。vpe传参方式是以字符指针数组的形式,并且会在环境变量PATH路径中搜索,可以传递环境变量。
如果我想要给子进程传递环境变量,该如何传递?
1、子进程会自动继承父进程的环境变量,所以子进程使用main函数的第三个参数,或者导入第三方环境变量extern char** environ。
2、父进程新增环境变量,子进程继承后可以获取父进程新增的环境变量。
对于方式2,我们需要介绍一个函数:putenv

putenv的作用就是导入一个环境变量。
现在我们在父进程中使用putenv导入MY_VAL=1234,然后创建子进程,子进程经过程序替换执行otherExe,我们期望在otherExe通过第三方变量environ获取到父进程导入的环境变量。


运行后我们确实发现,子进程的环境变量中确实有MY_VAL=1234。
下面使用execle程序替换,并传递环境变量,我们看看子进程程序替换后打印的环境变量都有什么?这里的传参是追加还是覆盖?


我们发现,这里并不是追加,而是直接覆盖了。所以execle和execvpe是直接将myenv传给子进程,彻底替换掉子进程的环境变量。
3、使用execle/execvpe彻底替换。
最后,再来看个系统调用:execve

2号手册,参数fliename就是可执行程序路径,argv就是命令行参数,env就是环境变量。
而我们上面讲的那六个函数都是库函数,上面六个库函数都是调用了这个execve系统调用,所以他们是上下层调用和被调用关系。
所以exec*执行的是加载器的功能,将可执行程序的代码和数据替换到内存中。
5、实现myshell
shell/bash也是一个进程,执行指令的时候,本质是通过创建子进程来执行的。那么shell的环境变量从哪来的呢?用户家目录下的.bash_profile里面保存了导入环境变量的方式。

具体实现步骤:
1、获取命令行。
2、解析命令行。
3、创建子进程。
4、子进程进行程序替换。
5、父进程等待子进程退出。
对于一些内建命令直接由父进程完成。
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define LEFT "["
#define RIGHT "]"
#define LABLE "$"
#define DELIM " \t"
int lastcode = 0;
int quit = 0;
char commandline[LINE_SIZE];
char* argv[ARGC_SIZE];
char pwd[LINE_SIZE];
char hostname[LINE_SIZE];
// 环境变量
char myenv[LINE_SIZE];
char* GetHostName()
{
//return getenv("HOSTNAME");
gethostname(hostname, sizeof(hostname));
return hostname;
}
char* GetUserName()
{
return getenv("USER");
}
void GetPwd()
{
getcwd(pwd, sizeof(pwd));
}
void SplitPwd()
{
int j = 0;
int len = strlen(pwd);
for (int i = len - 1; i >= 0; i--)
{
if (pwd[i] == '/')
{
j = i;
break;
}
}
if (!j) {
pwd[0] = '/';
pwd[1] = '\0';
return;
}
int l = 0;
for (int i = j + 1; i < len; i++)
{
pwd[l++] = pwd[i];
}
pwd[l] = 0;
}
void Interact(char* cline, size_t size)
{
GetPwd();
SplitPwd();
printf(LEFT"%s@%s %s"RIGHT""LABLE" ", GetUserName(), GetHostName(), pwd);
char* s = fgets(cline, size, stdin);
assert(s);
(void)s;
cline[strlen(cline)-1] = '\0';
}
int SplitString(char* _argv[], char* cline)
{
int i = 0;
_argv[i++] = strtok(cline, DELIM);
while (_argv[i++] = strtok(NULL, DELIM));
return i - 1;
}
void NormalExcute(char* argv[])
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return;
}
else if (id == 0)
{
execvp(argv[0], argv);
exit(0);
}
else
{
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret == id)
{
lastcode = WEXITSTATUS(status);
}
}
}
int BuildExcute(int _argc, char* _argv[])
{
if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
chdir(_argv[1]);
GetPwd();
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if (strcmp(_argv[1], "$?") == 0)
{
printf("%d\n", lastcode);
lastcode = 0;
}
if (*_argv[1] == '$')
{
char* val = getenv(_argv[1] + 1);
if (val) printf("%s\n", val);
}
else
{
printf("%s\n", _argv[1]);
}
return 1;
}
// 单独处理ls
if (strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
int main()
{
while (!quit)
{
Interact(commandline, sizeof(commandline));
int argc = SplitString(argv, commandline);
if (argc == 0) continue;
int n = BuildExcute(argc, argv);
if (!n)
NormalExcute(argv);
}
return 0;
}
演示效果:

1560

被折叠的 条评论
为什么被折叠?



