进程控制~
一.进程控制
1.进程创建
我们可以通过./cmd来运行我们的程序,而我们运行的程序就是bash进程常见的子进程。当然我们也可以通过fork()系统调用来创建进程。
NAME
fork - create a child processSYNOPSIS
#include <unistd.h>pid_t fork(void);
fork会对子进程和父进程返回不同的返回值,对子进程返回0,对父进程返回子进程的pid,如果返回值小于0,则说明子进程创建失败。
fork引申出来的一系列问题:fork函数有两个返回值,fork函数对父子进程返回值不同,fork的返回值为什么大于0又等于0导致if if-else同时成立......这些问题已经在进程部分解释,这里就不再赘述了。【Linux】进程-CSDN博客
2.进程终止
进程终止的本质就是释放资源,就是释放进程创建时申请的内核数据结构和内存中的代码和数据。
而进程退出有三种场景:
- 代码执行完,结果正确
- 代码执行完,结果错误
- 代码异常终止
而对于子进程来说,它是由父进程创建的,为了实现某种功能的,所以它也有上述三种退出场景。那么我们怎么区分进程的执行情况呢?
对于我们所写的C程序来说,为什么我们在main函数的最后要返回一个0呢?通常这表示程序正常结束,如果代码执行的结果不正确,此时就会返回一个非0值。所以对于一个C程序来说,我们可以通过返回值来判断代码的执行情况。而这个返回值就是错误码。
我们可以通过strerror()函数将错误码转换成错误信息,而errno则会记录最近的一次错误码。
就比如fopen这个函数,如果打开失败,会返回一个空的文件指针,并且设置错误码。我们可以故意让fopen失败,借助sterror(errno)来观察错误信息。
在C语言中,一共有134个错误码,大家可以用循环打印出每一种来看看。
对一个C语言程序来说,它用错误码来标记执行情况,那么对于一个进程来说也一样!!!。一个进程的退出码,表示了该进程的执行情况。
我们可以使用echo $?来查看最近一个进程的退出码
对于上图来说,我们首先调用了ll,该进程正常结束,所以退出码为0,接着我们使用cd命令,跳转到不存在的路径,并且bash表示出现了错误,此时退出码就变成了1,在使用pwd命令,退出码就有变成了1.
在Linux操作系统中,一个进程的退出码如果分为两种,0和非0,0表示成功,非0表示出错
- 1:一般错误(如参数错误)
- 2:误用shell命令(如rm删除只读文件)
- 126:权限不足或命令不可执行
- 127:命令未找到
- 130:被CTRL+c终止
- 139:段错误
而进程的退出码会在该进程推出的时候写入该进程的pcb中
我们可以通过main函数的返回值/exit/_exit来设置进程的退出码。
return语句在main函数处执行,才表示进程结束,如果在其他函数内执行,只表示该函数结束。
对于exit函数来说,不论在代码的那个地方执行到该语句,进程都将结束,并将exit设置的退出码写入到进程的pcb中。
而除了c标准库的的exit函数外,还有一个系统调用_exit函数。这两个函数有什么关系呢?
exit其实是对_exit函数的封装,调用exit函数最终还是会调用_exit。当然两者也是有区别的:
- exit在结束进程之前会进行清理操作,刷新文件缓冲区
- _exit会立刻结束进程,不进行清理操作
有了这个认识,我们就可以猜想,缓冲区的概念是C语言提出的,而不是系统内的缓冲区。
3.进程等待
为什么要进行进程等待呢?
之前说过,当子进程结束之后,父进程没有回收子进程资源时,子进程就会处于僵尸状态,进而导致内存泄漏。
而且一旦进程进入僵尸状态,此时kill -9 也无法无能为力,因为kill -9 不能杀死一个已经死掉的进程。
我们父进程创建子进程是为了帮助我们执行任务的,执行的如何父进程得知道吧!
所以,之所以要进行进程等待,就是为了让父进程回收子进程的资源,避免内存泄漏,并且获取子进程的退出信息。最重要的是回收子进程资源,有时候我们并不关系子进程的结束信息。
说的好,那怎么进行进程等待呢?
3.1wait
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
wait就可以使父进程进行等待子进程,而wait会等待父进程的任意一个子进程,一旦等到子进程,等待就结束了。而它还有一个输出型参数status,我们稍后再说
我们接着下面这个例子,来观察子进程由Z->X的过程
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
// dhild
int cnt = 5;
while (cnt)
{
printf("child pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
// father
sleep(10);
pid_t wid = wait(NULL);
if (wid > 0)
{
printf("success %d\n", wid);
}
else
{
printf("failed\n");
}
sleep(10);
return 0;
}
说明:创建子进程后,让子进程循环5秒后,然后退出,此时父进程还在休眠中,还没有执行等待代码,此时子进程状态为Z,接着过了又过了5秒之后,父进程休眠结束,执行等待,我们现在不用参数,可以传NULL,此时子进程状态转为X。接着我们让父进程继续睡眠10秒。
监控脚本,每隔一秒观察父子进程的状态
while :; do ps ajx | head -1 && ps ajx | grep proc | grep -v grep; echo "-----------------------------------------------------------------"; sleep 1 ; done
接下里我们只需要死盯着STAT即可。
至此,我们成功验证了父进程等待子进程
通过wait()方法,我们可以使进入僵尸状态的子进程,被父进程回收。
3.2waitpid
但是今天的主角并不是wait,因为其功能简单,而是waitpid则是最优先考虑使用的!!!
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
wait就好像是waitpid的一个子功能,waitpid是一个完全版的wait,可以进行多种等待过程。
返回值pid_t:
- > 0 :等待成功
- < 0 :等待失败
参数pid:
- >0:是多少,就表示等待pid为该数的子进程
- -1:表示等待任意一个子进程,类似wait
参数status:输出型参数,与wait的功能一致,获取等待的子进程的退出码。
option:可以控制不同方式的等待过程,默认参数为0,表示阻塞等待......
当我们以waitpid(-1, NULL, 0);的方式调用时,此时waitpid和wait是一样的功能。
上面说了,进程等待主要是为了回收子进程的资源,也可以获取子进程的退出信息,回收子进程资源很好理解,将其的状态从Z->X,操作系统就会回收。那么如何获取子进程的退出信息呢?这就是参数status的事了!!!
0x1参数status:
status作为一个输出型参数,子进程执行结束后,会将自己的退出信息和退出码写到自己的pcb中,父进程等待到子进程后,操作系统会在子进程的pcb中拿出退出信息和退出码写入到status中,最后将信息带回给父进程。
但是退出码是代码执行完毕,结果正确或不正确时的标志,而进程终止还有可能是异常终止,此时子进程也会有退出码么?
首先,进程异常终止肯定收到了信号,比如我们使用kill -9 的方式杀死进程就是给进程传递了信号。另外,一旦进程异常终止了,此时的退出码就无意义了,此时更关心的是退出信号。
那么也就是说,子进程pcb中,除了要维护退出码,还要维护退出信号,但是我们只传了一个整型,如果获取两个内容?
答案就是位图。对于status来说,它的32个比特位被分为了三个部分,高16位,中8位和低8位。高16位没有被使用,中8位记录着退出码,低8位中的最高位是一个core dump标志位,是程序崩溃时生成的内存快照文件,用于调试分析,这部分与信号有关,这里不做解释,最后的7位存储的就是退出信号的编号了。
下面,我们就验证一个status的作用:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt)
{
printf("child pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(66); // 子进程退出码设置为66,方便观察
}
// father
int status = 0;
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
{
printf("success pid:%d exit_code:%d\n", wid, (status >> 8 & 0xFF));
}
else
{
printf("failed\n");
}
return 0;
}
说明:创建子进程后,我们让子进程执行5秒,子进程退出时,设置其退出码为66,接着父进程等待子进程,等待成功后,打印信息,并打印退出码。因为退出码存储在status的次低8未,所以我们首先右移8位,接着&0xFF,就可以拿出次低8位的内容。
上面是正常退出的结果,我们也可以测试进程异常终止时它的退出信号:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
while (1)
{
printf("child pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
exit(66); // 子进程退出码设置为66,方便观察
}
// father
int status = 0;
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
{
printf("success pid:%d exit_code:%d exit_signal:%d\n", wid, (status >> 8 & 0xFF), status & 0x7F);
}
else
{
printf("failed\n");
}
return 0;
说明:我们让子进程死循环,让父进程进行等待,接着使用kill -9 杀死子进程,观察退出码和退出信号。
自此,我们就验证了wait和waitpid的status参数的用途。当进程被信号杀死时,status的次低8位就被置为了0.
但是我们每一次获取子进程退出码都要进行位运算么? 不,操作系统为我们提供了宏接口,我们可以直接使用宏来获取退出码和退出信号。
获取子进程退出码
WEXITSTATUS(status)
如果自己是正常结束的,则返回true,反之返回false
WIFEXITED(status)
获取子进程退出信号
WTERMSIG(status)
通过上述三个宏,我们就可以让我们的等待过程变得更加完整健壮:
// father
pid_t rid = waitpid(id, &status, 0);
if(rid>0) // 等待成功
{
if(WIFEXITED(status)) // 正常退出
{
printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
}
else
{
printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
}
}
了解了status之后,我们在了解下一个参数option,它可以设置等待的行为。
0x2参数option:
wait等待和waitpid的第三个参数为0时都表示阻塞等待,什么是阻塞等待呢?
阻塞等待就像c里面的scanf和c++里面的cin。当父进程执行到等待语句时,父进程就卡住了,除非子进程死亡,否则父进程什么也做不了。
当然,除了阻塞等待,还有非阻塞等待。第三个参数我们可以传WNOHANG,来使父进程不阻塞等待,可以执行自己的事。它表示的是return immediately if no child has exited.即父进程等待子进程时,发现没有一个子进程结束,它立刻返回,接着执行自己的代码。
但是归根结底父进程还是得等待子进程结束,如果非阻塞等待询问一次直接结束,执行自己的代码去了,这不还是会造成僵尸状态,内存泄漏。
所以非阻塞等待主要的使用场景是结合循环来实现非阻塞轮询,再结合waitpid的返回值,就可以实现父进程在等待过程中,执行自己的逻辑:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
while(1)
{
printf("child pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
exit(66);
}
// father
while(1)
{
int status = 0;
pid_t rid = waitpid(id, &status, WNOHANG);
if(rid>0) // 等待成功
{
if(WIFEXITED(status)) // 正常退出
{
printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
}
else// 异常退出
{
printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
}
break;
}
else if(rid == 0)
{
// 询问结束,子进程未退出,执行自己的逻辑
// ......
printf("非阻塞轮询执行逻辑......\n");
}
else
{
printf("等待失败\n");
break;
}
}
return 0;
}
说明:这里采用非阻塞等待的方式,一创建子进程后,父进程即可进入非阻塞轮询状态,首先调用一次,判断子进程是否结束,如果返回值>0表示等待成功,此时退出循环,如果返回值 == 0,表示调用结束,子进程未退出,如果返回值小于0,表示等待失败,也退出循环。在非阻塞轮询期间内,每进行一次调用,如果子进程未退出,此时父进程就可以执行自己的代码逻辑。待下一次继续等待。
4.进程程序替换
fork()之后,父子进程各自执行代码的一部分,那如果子进程想要执行一个全新的程序呢?进程程序替换来实现!!!
4.1程序替换的原理
进程 = 内核数据结构pcb+代码和数据,当我们进行程序替换的时候,操作系统会从磁盘中,将要替换的程序的代码和数据覆盖式的放在原代码和数据的位置上。
那么照上面所说,我们程序替换之后,原代码和数据就被覆盖了,是不是替换上来的程序执行完了,那么整个进程就结束了?
没错!程序替换结束后,替换上来的程序结束了,整个进程就结束了。
看个例子:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
printf("进程开始执行,pid:%d, ppid:%d\n", getpid(), getppid());
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 进行程序替换,执行ls命令
printf("进程结束,pid:%d, ppid:%d\n", getpid(),getppid());
return 0;
}
说明:进程一开始便打印信息,接着我们就进行程序替换,执行ls命令,按照上面所说,最后面的打印语句将不再执行。
总结:在进行程序替换的时候,并不会创建新的进程,而是用待替换的程序的代码和数据覆盖原来程序的代码和数据,并且替换之后,原代码的后半部分就不存在了,替换完的程序执行完毕,进程就结束了。
4.2exec系列接口
0x1exec系列函数返回值
exec系列函数的接口在成功调用时没有返回值,只有失败才有返回值-1,并且设置错误码。
The exec() functions return only if an error has occurred. The return value is -1, and errno is set to indicate the error.
当你程序都已经替换成功了,原来的进程上下文都已经不在了,你要返回值干嘛呢?返回值谁接受呢?
失败返回-1,这是非常明确的,所以我们只需要判断返回值是否为-1即可,如果不是-1就说明成功,失败了,我们在根据错误码来判断错误的原因。
0x2execl
exec系列函数都有相同的前缀,那么不同的后缀,就能体现出不同函数的特点。
就比如execl来说,l可以理解为list,即我们传入的参数就好像在一个一个链表的节点中存储的一样,而链表最后一个节点的next指针为NULL,所以对于execl函数来说,它的参数也要以NULL结尾。
#include <unistd.h>
int execl(const char *path, const char *arg,...);
参数path表示要替换的程序所在的位置即路径+程序名,接下来的参数我们在命令行怎么写,在这就怎么传。
就比如,我们要执行ls命令,写法如下
execl("/usr/bin/ls", "ls", "-l" , "-na", NULL);
我们不仅可以让父进程进行程序替换,也可以创建出来一个子进程,让其执行要替换的程序:
int main()
{
printf("程序开始\n");
if(fork() == 0)
{
sleep(1);
execl("/usr/bin/ls", "ls", "-l", NULL);
exit(22);
}
int status = 0;
waitpid(-1, &status, 0);
printf("%d\n", WEXITSTATUS(status));
printf("程序结束\n");
return 0;
}
首先,子进程进行替换之后,不会执行exit函数,因为后序代码已经被覆盖了,所以我们父进程打印子进程的退出码是不是设置的22,而是0.
其次,为什么子进程进行程序替换没有影响父进程呢?
第一,进程具有独立性;第二,父子进程本来共享代码和数据,子进程进行程序替换,对代码和数据进行了修改,此时会进行写时拷贝。
综上,所以子进程进行程序替换是不会影响父进程的!!!
那么可不可以替换我们自己写的程序呢?
当然也是可以的!!
首先我们写一段C++程序,并编译成可执行文件。
接着便是修改execl的参数
execl("./other", "other", NULL); // 第二个参数可以带./,也可以不带
看结果:
0x2execlp
#include <unistd.h>
int execlp(const char * file, const char *arg ,...);
该函数多了一个后缀p,这个p的意思是环境变量PATH,所以使用这个接口时,第一个参数如果不带路径只是文件名,该函数就会从PATH环境变量里面去搜索该文件。
所以,execlp会自动在PATH环境变量中搜素指定的命令,如果找不到,就会失败返回-1并设置错误码!!!
int main()
{
printf("程序开始\n");
if(fork() == 0)
{
sleep(1);
//execlp("ls", "ls", "-l", NULL); // 成功替换
execlp("other", "other", NULL); // 替换失败 , 并设置错误码为22
exit(22);
}
int status = 0;
waitpid(-1, &status, 0);
printf("%d\n", WEXITSTATUS(status));
printf("程序结束\n");
return 0;
}
说明:对于ls命令来说,他所处的路径/usr/bin/ls,在PATH环境变量中,所以不带路径是可以找到的。但是我们的other是在当前目录下,函数找不到,就会失败!!!
0x3execv
不一样的来了
#include <unistd.h>
int execv(const char *path, char *const argv[]);
第一个参数依旧是可执行文件的路径+文件名,表示我要执行谁;
第二个参数则不一样了,不在像命令行那样传参数了,而是传了一个命令行参数表!!!所以这里后缀的v其实就是vector的意思。
只需要将命令行参数放入放入一个指针数组里面即可,当然也要以NULL结尾。
int main()
{
printf("程序开始\n");
if(fork() == 0)
{
sleep(1);
char* const argv[] = {
(char* const)"ls",
(char* const)"-l",
(char* const)"-a",
NULL
};
execv("/usr/bin/ls", argv);
exit(22);
}
int status = 0;
waitpid(-1, &status, 0);
printf("%d\n", WEXITSTATUS(status));
printf("程序结束\n");
return 0;
}
0x4execvp
#include <unistd.h>
int execvp(const char *file, char *const argv[]);
有了上面的基础,这个就很好理解了,第一个参数会默认在PATH中搜索命令,第二个参数表示将命令行参数以指针数组的方式传过去,并且以NULL结尾。
这个就不做示例了,相信大家没问题
0x5execvpe
#include <unistd.h>
int execvpe(const char *file, char *const argv[],
char *const envp[]);
这个就又不一样了,多了一个后缀e,表示的是环境变量envion。使用该接口除了传递文件名和命令行参数表外,还要传一个环境变量表。传了该环境变量表后就会将原来全局的环境变量表给覆盖掉,子进程只会看到传的环境变量了。
// proc.c
int main()
{
printf("程序开始\n");
if(fork() == 0)
{
sleep(1);
char* const argv[] = {
(char* const)"other",
NULL
};
char* const envp[] = {
(char* const)"MYENV = 112233445566778899",
NULL
};
execvpe("./other", argv, envp);
exit(22);
}
int status = 0;
waitpid(-1, &status, 0);
printf("程序结束\n");
return 0;
}
// other.cc
#include <iostream>
#include <cstdio>
int main()
{
extern char** environ;
for(int i=0; environ[i]; ++i)
{
printf("env[%d] -> %s\n", i, environ[i]);
}
return 0;
}
说明:为了测试execvpe接口对环境变量的影响,我们利用proc替换我们自己的.cc程序。该.cc程序主要工作就是打印环境环境变量。我们让该接口替换我们自己的程序,并且在替换前设置了环境变量envp,按照上面所说,会将替换程序的环境变量给替换掉,所有.cc打印出来的环境变量应该只有我们自己设置的。
我们看结果,确实和我们预料的一样。
对于第三个参数来说,如果envp为空的话,即只有一个NULL,新程序就会继承原来的环境变量,不会改变。
但是我们使用该接口时想给子进程新增环境变量,而不是覆盖原来的环境变量,怎么实现呢?
我们可以借助putenv函数来实现新增环境变量
#include <string.h>
int putenv(char *string);
在每一个进程的进程地址空间上,有一段空间专门用来存储当前进程的环境变量,putenv就是将指定环境变量加载到该进程地址空间上的指定位置。这样,我们再使用该接口时,第三个参数传NULL,新程序就会继承原来的环境变量,当然也包含我们新增的环境变量。
// proc.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>
int main()
{
printf("程序开始\n");
if(fork() == 0)
{
sleep(1);
char* const argv[] = {
(char* const)"other",
NULL
};
// 设置新增环境变量
char* const addNewEnv[] = {
(char* const)"MYENV1=11111111",
(char* const)"MYENV2=22222222",
(char* const)"MYENV3=33333333",
NULL
};
// 加载新增环境变量
for(int i=0; addNewEnv[i]; i++)
{
putenv(addNewEnv[i]);
}
extern char** environ; // 指向环境变量表的全局指针
execvpe("./other", argv, environ);
exit(22);
}
int status = 0;
waitpid(-1, &status, 0);
printf("程序结束\n");
return 0;
}
// other.cc
#include <iostream>
#include <cstdio>
int main()
{
extern char** environ;
for(int i=0; environ[i]; ++i)
{
printf("env[%d] -> %s\n", i, environ[i]);
}
return 0;
}
说明:我们先定义出想要新增的环境变量,然后通过putenv将其加载到子进程的环境变量上,然后我们将全局的environ传给execvpe函数,让其替换程序,此时替换上来的程序就有了我们新增的环境变量。但是这个新增的环境变量只有子进程可以看到,父进程看不到。
0x6execle
经过上面的分析,相比大家已经知道了该接口的用法了
#include <unistd.h>
int execle(const char *path, const char *arg,
..., char * const envp[]);
4.3execve
通过对上面exec家族的了解,我们发现,按照规律应该有一个execve的函数啊,为什么找不到呢?
因为,这个函数是系统调用,而上面的exec家族都是对其在语言层上的封装
#include <unistd.h>
int execve(const char *filename, char *const argv[],
char *const envp[]);
但是我们以及可以用上面了解到的知识来解读它,v表示我们要传一个命令行参数表,e表示我们得传一个环境变量表!!!
上述,便是进程控制的全部内容~~~
本章完~