目录
1. 进程终止
1.1 main函数返回值
在之前的C/C++代码中,我们会给mian函数返回0,可是该返回值究竟是返回给谁的?main函数的返回值是返回给父进程或者是操作系统。命令行中会有一个bash进程,它是所有在命名行上启动的进程的父进程面写一个打印helloworld的程序,返回值是2。
#include <iostream>
int main()
{
std::cout << "hello world!" << std::endl;
return 2;
}
执行完该程序后,我们使用echo指令加上$?,可以打印出命令行中最近一个程序退出时的退出码。
那这个退出码的作用是什么?启动进程是为了让进程完成某项任务,既然是做任务,我们就要知道进程完成任务的情况。因此,退出码表示任务错误信息,通常用0表示任务执行成功,非0表示有错误。并且非0的数字有很多,可以使用不同的数字约定或表明程序出错的原因。
C/C++中提供了一批错误码,其中errno会记录最近一次的错误码,strerror函数可以将传入的错误码转换成约定的错误信息字符串,perror函数可以直接打印系统错误消息,参数是可以传递有关错误位置字符串。
我们故意打开一个当前路径下不存在的txt文件,fopen函数执行时一定是失败的。
#include <string.h>
#include <cstdio>
#include <errno.h>
int main()
{
printf("before: errno: %d, errstring: %s\n", errno, strerror(errno));
FILE *fp = fopen("./log.txt", "r");
if(fp == nullptr)
{
printf("after: errno: %d, errstring: %s\n", errno, strerror(errno));
return errno;
}
return 5;
}
运行结果如下,没调用fopen函数之前,错误码为0,对应的信息是执行成功。调用fopen函数后,错误码被系统自动设置为2,对应的错误信息是没有这样的文件或目录。
那Linux系统给我们提供多少个有效的错误码呢?我们可以通过for循环打印一下。
#include <string.h>
#include <cstdio>
#include <errno.h>
int main()
{
for (int i = 0; i < 200; i++)
{
printf("code: %d, errstring: %s\n", i, strerror(i));
}
return 0;
}
Linux系统提供了134个错误码。
不过这些是系统提供的错误码,如果所写代码跟系统有关,可以使用系统提供的错误码。如果所写代码内容本身跟这些关系不大,可以自己约定相关错误对应的错误信息。
1.2 exit函数
exit函数可以引起一个正常进程的终止。我们可以拿return跟exit作比较。
#include <iostream>
#include <stdlib.h>
void func()
{
std::cout << "hello world" <<std::endl;
exit(50);
}
int main()
{
func();
std::cout << "进程正常退出" << std::endl;
return 0;
}
#include <iostream>
#include <stdlib.h>
int func()
{
std::cout << "hello world" <<std::endl;
return 50;
}
int main()
{
func();
std::cout << "进程正常退出" << std::endl;
return 0;
}
通过运行结果,我们可以知道return表示函数终止,exit函数表示进程终止,可以在代码的任何位置。
1.3 _exit函数
_exit函数也是终止调用它的进程。
#include <iostream>
#include <unistd.h>
void func()
{
std::cout << "hello world" <<std::endl;
_exit(50);
}
int main()
{
func();
std::cout << "进程正常退出" << std::endl;
return 0;
}
运行结果跟exit函数相同。但是_exit函数是系统调用函数。
运行下面两段代码,观察运行结果。
#include <cstdio>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("hello world");
sleep(2);
exit(30);
return 0;
}
#include <cstdio>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("hello world");
sleep(2);
_exit(30);
return 0;
}
两段代码中使用printf输出"hello world",没有加上"\n",所以printf函数里的内容还在缓冲区中。当第一个进程调用exit函数退出后,打印出了helloworld。但是第二个进程调用_exit函数退出时,没有输出任何语句。说明exit函数会把语言级缓冲区的数据刷新出来,而_exit函数不会。因为_exit是系统调用函数,它在系统层面。
2. 进程等待
2.1 子进程僵尸状态
#include <cstdio>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 10;
while(cnt)
{
printf("子进程运行中, pid: %d\n", getpid());
cnt--;
sleep(1);
}
exit(0);
}
else
{
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
上面的代码中线fork创建一个子进程,子进程运行0秒后,调用exit函数退出,成为僵尸状态。而父进程一直在死循环中,所以子进程一直是僵尸状态。
使用while指令,每隔一秒打印一次有关process进程的相关信息。
子进程状态变为Z+,就是zmobie僵尸状态。此时子进程的task_struct对象还存在,等待父进程回收。
2.2 wait函数
一般而言,父进程需要对创建的子进程进行回收,系统提供了wait和waitpid函数,父进程可以使用这两个函数回收子进程。在子进程运行期间,子进程不会退出,父进程在等待子进程的过程中就会阻塞在wait函数内部。
wait函数的参数暂时不讲解,在waitpid函数中一起介绍。wait函数成功时返回等待的子进程pid值,失败的话就会返回-1。
下面是使用wait函数回收子进程代码实例。
#include <cstdio>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程运行, pid: %d\n", getpid());
cnt--;
sleep(1);
}
exit(0);
}
else
{
pid_t rid = wait(nullptr);
if (rid > 0)
{
printf("等待子进程成功, rid: %d\n", rid);
}
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
运行结果如下,一般来说回收子进程不会失败,在使用ps指令展示process进程的相关信息中,当子进程运行5秒结束后,子进程会立马被父进程回收,只剩下父进程的相关信息,没有显示子进程的僵尸状态。
2.3 waitpid函数
2.3.1 pid参数
父进程创建子进程,本质是让子进程完成任务的。而进程完成任务一般会有退出码返回给父进程,那么父进程怎么获得子进程的退出码呢?我们可以使用waitpid函数,waitpid相比于wait更常用。
waitpid函数有三个参数。第一个参数明显是进程的pid值。当传入pid值大于0,相当于等待指定的子进程。当pid等于-1,父进程会等待任意一个子进程
waitpid函数成功时返回等待的子进程pid值,失败的话就会返回-1。还有其他情况涉及第三个参数,下面会介绍。
如果waitpid函数像下面一样传参,就跟调用wait函数一样。
#include <cstdio>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程运行, pid: %d\n", getpid());
cnt--;
sleep(1);
}
exit(0);
}
else
{
//pid_t rid = wait(nullptr);
//pid_t rid = waitpid(id, nullptr, 0); //指定等待的进程
pid_t rid = waitpid(-1, nullptr, 0); //等同于wait函数
if (rid > 0)
{
printf("等待子进程成功, rid: %d\n", rid);
}
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
2.3.2 status退出信息
waitpid的第二个参数wstatus是一个指针变量,用来获取子进程的退出信息。参数一般设置为指针变量是一个输出型参数。即在外部创建一个整型变量,将该变量地址传进去,操作系统可以获取子进程task_struct结构体的属性,从而将子进程退出码写到传入的指针指向的空间中。
#include <cstdio>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程运行, pid: %d\n", getpid());
cnt--;
sleep(1);
}
exit(2);
}
else
{
int status = 0;
//pid_t rid = wait(nullptr);
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("等待子进程成功, rid: %d, status: %d\n", rid, status);
}
else
{
perror("waitpid");
}
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
运行上面的代码,传入一个整型变量地址,打印一下该变量,会发现变量值位512,而我们的退出码是2,完全对应不上。
我们要重谈进程退出的情况,进程退出有三种情况。第一和第二种情况都是进程代码跑完后,才会退出,根据退出后的错误码,判断有没有错误。第三种情况就是进程代码没跑完,碰到野指针或者除零等错误,直接异常退出。
而进程异常退出的情况实际上是操作系统通过给进程发信号终止进程。
int main()
{
int *p = nullptr;
*p = 10;
return 0;
}
int main()
{
int a = 1/0;
return 0;
}
上面两份代码分别有野指针和除零的错误,运行之后,命令行直接返回错误信息。如果代码中有野指针错误,操作系统会发下面的11号信号,如果是除零错误,会给进程发8号信号。
因此,进程不一定是正常退出,可能会被信号终止,所以不仅要记录退出码,还有记录收到的信号信息。而status变量实际上一个32位的位图,前七位记录终止信号信息,次第八位记录进程退出码。
并且你会发现信号编号是从1开始的,所以当进程正常退出时,不会收到相关信号信息,所以前八位为0。而当进程异常退出,进程执行不到返回退出码的代码位置,所以次第八位的退出码是无效的,前七位写入终止信号的信息,至于第八位暂时不用管。
下面的代码中,我们使用位移和按位与操作,可以获取次第八位和前七位的信息。
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程运行, pid: %d\n", getpid());
cnt--;
sleep(1);
}
exit(2);
}
else
{
int status = 0;
//pid_t rid = wait(nullptr);
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("等待子进程成功, rid: %d, exit code: %d, exit signal: %d\n",
rid, (status>>8)&0xff, status&0x7f);
}
else
{
perror("waitpid");
}
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
子进程正常退出,所以退出信号值为0,退出码为2,跟打印出来的相对应,操作正确。
但是使用位运算获取退出码和退出信号不太好,系统提供了一些宏。WIFEXITED会判断进程是不是正常退出,WEXITSTATUS会返回退出状态,WIFSIGNALED会判断该进程是否为异常退出,WTERMSIG会返回进程被终止的信号信息。
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("errno: %d, errstring: %s\n", errno, strerror(errno));
}
else if (id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程运行, pid: %d\n", getpid());
cnt--;
sleep(1);
}
//子进程死循环,使用信号终止
//while(cnt)
//{
// printf("子进程运行, pid: %d\n", getpid());
// sleep(1);
//}
exit(100);
}
else
{
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
if (WIFEXITED(status))
{
printf("等待子进程成功, rid: %d, exit code: %d\n", rid, WEXITSTATUS(status));
}
else
{
printf("进程异常退出, exit signal: %d\n", WTERMSIG(status));
}
}
else
{
perror("waitpid");
}
while(true)
{
printf("我是父进程, pid: %d\n", getpid());
sleep(1);
}
}
}
子进程运行五秒后,父进程等待成功,退出码是100。
当子进程死循环运行时,使用kill指令发送11号信号,即与野指针错误相同的信号,进程异常退出,获取到的退出信号也是11号。
2.3.3 非阻塞状态
waitpid函数中的第三个参数是父进程等待子进程的方式,一般传0进去表示父进程阻塞等待子进程。既然有阻塞状态,那父进程就有非阻塞状态等待子进程。系统定义了一个非阻塞状态的宏WNOHANG,wait no hang即指父进程没有被挂起。
那该怎么理解呢?阻塞状态相当于父进程会一直等待子进程,直到子进程退出,在此期间不能做其他事情。而非阻塞状态,父进程只是检测子进程是否退出,不会进行等待,所以需要循环调用非阻塞接口,完成轮询检测。如果父进程不轮巡检测,就可以完成其他任务。
而非阻塞状态下,返回值大于0,说明等待子进程成功,会获得目标进程pid值。如果返回值等于0,说明等待子进程成功,但是子进程没有退出。如果返回值小于0,说明等待失败。
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
while(true)
{
printf("我是子进程, pid: %d\n", getpid());
sleep(1);
}
exit(0);
}
while (true)
{
sleep(1);
pid_t rid = waitpid(id, nullptr, WNOHANG);
if (rid > 0)
{
printf("等待子进程%d成功\n", rid);
break;
}
else if (rid < 0)
{
printf("等待子进程失败\n");
break;
}
else
{
printf("子进程尚未退出,父进程做自己的事情\n");
//做自己的事情
}
}
}
子进程一直在死循环中,父进程检测完子进程不退出状态,可以在内部做自己的事情,可以执行其他函数。
3. 进程替换
3.1 初步认识
当我们的进程使用fork函数创建子进程时,子进程都是执行父进程的代码。如果子进程执行全新的程序,不是执行父进程的代码,这就是程序替换。下面我们展示程序替换的代码。
execl函数是第一个参数是字符串类型变量,该变量是某个可执行程序的路径,后面的参数是执行该指令的字符串,“...”三个点是可变参数列表,表示可以传多个字符串指针变量。
下面是直接在进程中使用execl函数执行ls指令,大部分指令一般放在/bin目录下,假设要使用 ls -l -a指令,在路径参数后按空格分割传入字符串,最后必须以nullptr空指针结尾。
#include <cstdio>
#include <unistd.h>
int main()
{
execl("/bin/ls", "ls", "-l", "-a", nullptr);
printf("hello world\n");
return 0;
}
执行该程序,我们通过execl函数执行系统中的ls程序,并且没有执行后面的printf函数
myexec程序加载到物理内存中,会创建一个task_struct结构体,还有一个虚拟地址空间和页表,通过页表建立虚拟地址空间到物理内存的映射。当执行execl函数,会将ls程序中的代码段覆盖原来进程物理内存中的代码段,数据区也是如此。程序替换不是创建新进程,因为进程的数据结构没有变。
我们创建一个other程序,代码如下,就是打印一句话,带上pid值。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("我是other进程, pid: %d\n", getpid());
return 0;
}
myexec程序代码如下,先打印一段话,带上进程的pid值,再使用execl函数替换other程序,而other程序也是打印一段话,里面也会打印进程pid值,看看打印的pid值是否相同。
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main()
{
printf("我是myexec进程, pid:%d\n", getpid());
execl("./other", "other", nullptr);
return 0;
}
运行结果如下,你会发现前后打印的pid值相同,说明程序替换本质上不是创建新进程。
那execl函数的返回值是什么?运行下面两段代码。
int main()
{
int n = execl("/bin/ls", "ls", "-l", "-a", nullptr);
printf("return value:%d\n", n);
return 0;
}
当execl函数替换程序成功,源程序代码被覆盖。也就是说,execl函数执行成功没有返回值。
int main()
{
int n = execl("/bin/lss", "lsss", "-l", "-a", nullptr);
printf("return value:%d\n", n);
return 0;
}
调用execl函数时,故意传错参数,返回值为-1。总的来说,只要execl函数有返回值,就是执行失败。
3.2 多进程
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//child
execl("/usr/bin/ls", "ls", "--color", "-l", "-a", nullptr);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
在上面的代码中,父进程创建子进程,将子进程的程序替换成ls指令程序。多进程的进程替换也是比较简单的。
而多进程的进程替换原理,跟单进程类似,子进程创建出来后,拥有自己的task_struct结构体、进程地址空间和页表,但在物理内存和父进程共享一块空间。当子进程进行程序替换时,会进行写时拷贝,在物理内存中开辟新的空间,加载excel函数中指定程序的数据和代码。
exec类程序替换函数有这一下七种,会重点介绍其中的五种。
3.3 execl vs execv
execv函数只有传递两个参数,第一个参数还是可执行程序的路径,第二个参数是字符指针数组。如果还是要使用该函数执行ls指令,要将ls指令的完成选项填入到指针数组中,然后调用execv函数即可。
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//child
char *const argv[] = {
(char*)"ls",
(char*)"--color",
(char*)"-a",
(char*)"-l",
nullptr
};
execv("/usr/bin/ls", argv);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
运行结果如下,成功使用子进程替换成ls指令程序。
那么execl和execv函数有什么区别呢?execl中的“l”指的是list,即链表的意思,传参就像链表的结点一样传进去,并且最后一节点是空指针。而execv函数中的“v”指的是vector,就是数组的意思,将在命令行输出的指令按空格分割好填到数组中,再传入函数中。
3.4 execlp vs execvp
execlp函数参数看起来和execl类似,唯一不同的是第一个参数execl需要传某个可执行程序的路径,而execlp函数只需要传该可执行程序的文件名即可。
int main()
{
pid_t id = fork();
if (id == 0)
{
execlp("ls", "ls", "--color", "-l", "-a", nullptr);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
运行结果如下,那么execlp函数中的第一个参数和第二个参数会不会重复了,答案是两个参数虽然内容相同,但是代表的含义不同,第一个是文件名,第二个是命令行的参数。那为什么可以不传路径,就能执行ls指令呢?
因为execlp函数中的“p”指的是环境变量PATH,不要忘了子进程会继承父进程的环境变量表,而环境变量PATH会带有指令的可执行文件路径,该函数会在PATH变量提供的路径中寻找可执行程序。
那么execvp函数也是类似,第一个参数传可执行文件名,第二个参数传递字符指针数组,运行结果就不展示了。
int main()
{
pid_t id = fork();
if (id == 0)
{
char *const argv[] = {
(char*)"ls",
(char*)"--color",
(char*)"-a",
(char*)"-l",
nullptr
};
execv("ls", argv);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
3.5 execvpe函数
execvpe函数在execvp函数的基础上,需要多传入一个环境变量表。下面我们创建一个other.c文件,声明environ环境变量表,然后使用for循环打印,会有二十多个环境变量。因为环境变量表可以被子进程继承,所以具有全局属性。
extern char **environ;
int main()
{
for(int i = 0; environ[i]; i++)
{
printf("env[%d]: %s\n", i, environ[i]);
}
return 0;
}
之后,我们自己写一张环境变量表,传递给execvpe函数。
int main()
{
pid_t id = fork();
if (id == 0)
{
char *const argv[] = {
(char*)"other",
nullptr
};
char *const env[] = {
(char*)"HELLO1=xxxxxxxxxx",
(char*)"HELLO2=xxxxxxxxxx",
(char*)"HELLO3=xxxxxxxxxx",
(char*)"HELLO4=xxxxxxxxxx"
};
execvpe("./other", argv, env);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
运行结果如下,我们传递的环境变量表覆盖了原来的环境变量表。
也就是说,默认情况下,子进程进行程序替换时,会继承父进程的环境变量表。如果想要传递全新的环境变量表,需要自己定义,自己传递。
如果我们要在原来的环境变量表上新增一些环境变量呢?可以使用C标准库提供的putenv函数,可以新增环境变量。
extern关键字用来声明外部变量或者函数,其中environ就是环境变量表。使用putenv函数在原有的环境比变量表上新增变量,execvpe函数最后一个参数传递声明的environ。
extern char**environ;
const char* string = "HELLO=XXXXXXXXXXX";
int main()
{
putenv((char*)string);
pid_t id = fork();
if (id == 0)
{
char *const argv[] = {
(char*)"other",
nullptr
};
execvpe("./other", argv, environ);
exit(1);
}
pid_t rid = waitpid(id, nullptr, 0);
if (rid > 0)
{
printf("等待子进程成功\n");
}
return 0;
}
运行结果如下:
像execle和execve的使用类似于上面的函数,就不演示了。其中execve是系统调用函数,而其它函数是c标准库封装execve函数实现的。
创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!