💘作者:泠沫
💘博客主页:泠沫的博客
💘专栏:Linux系统编程,文件认识与理解,Linux进程学习…
💘觉得博主写的不错的话,希望大家三连(✌关注,✌点赞,✌评论),多多支持一下!!
目录
🏠 进程创建
🚀 fork函数初识
fork函数的作用是创建一个子进程,创建失败返回-1。创建成功的话,对于父进程返回子进程的pid,对于子进程则返回0。所以我们可以通过fork函数的返回值来区分父子进程,从而让父子进程分别执行不同的代码,满足不同的场景需求。
🚀 fork函数返回值
关于fork函数的返回值问题,可能许多人就会产生疑惑,为什么一个函数能有两个返回值。这里做一下简要解释。首先,我们要有一个共识,那就是子进程一旦被创建出来,后续的代码就是和父进程共享。
我们回忆一下,当我们自己在创建一个返回值不为空的函数的时候,如果我们即将执行return语句传递返回值,那这个函数的主要部分是不是就已经执行完了?所以我们可以理解成在fork函数即将执行return语句的时候,子进程已经被创建出来了。那么,整个代码就从原来的一个执行流变成了两个执行流,也就是后续代码会被父子进程所共享,那么fork函数可以根据父子进程两个执行流分别执行return语句,从而达到fork函数是针对父进程返回子进程的pid,针对子进程返回0。
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
🚀 fork调用失败
fork创建子进程失败原因:
-
操作系统资源不足,已经达到了最大进程数或者没有足够的内存分配给新的进程。
-
用户权限不足,无法创建新的进程。
-
计算机病毒或恶意软件感染导致操作系统异常,无法创建新的进程。
🏠 进程终止
🚀 进程退出方式
正常终止:
-
从main返回
-
调用C语言函数 exit
-
系统调用接口 _exit
异常退出:
- ctrl + c,信号终止
- …
只有上面的三种方式才能使进程正常退出,使用信号让进程退出是异常退出!
接下来我来简单介绍一下 exit 和 _exit的区别:
-
_exit()
_exit()是操作系统给我们提供的一个系统调用接口:
该系统调用接口的作用是让进程正常退出,且可以设置退出码。 该函数没有返回值,参数status是设置该进程退出后的退出码。但是这个整型参数只有低8可以被父进程所用。 -
exit()
exit()是C语言提供的一个函数,该函数的作用也是让进程正常退出,且可以设置退出码。但是除了让进程退出,该函数也会关闭所有打开的流以及刷新缓冲区。所以,很显然该函数的底层实现是封装了系统调用接口_exit()。
🚀 进程退出码
进程退出码本质上就是一个数字,只不过每一个数字都代表不同的含义。我们可以通过strerror函数来将错误码转换成对应的错误信息。
#include<iostream>
#include<cstdio>
#include<cstring>
int main()
{
for(int i = 0; i < 134; i++)
{
printf("%d:%s\n",i,strerror(i));
}
return 0;
}
通过实验我们知道,错误码一共有134个(这里没有截全),每一个错误码都有唯一的错误原因。
🏠 进程等待
🚀 进程等待必要性
通过前面的介绍,我们之前提到了在Linux中的进程有一种特殊的状态叫做僵尸状态,僵尸状态其实是一种问题,之前我只对该状态进行了讲解介绍,但并没有说如何解决。在这里,笔者将告诉大家如何解决僵尸进程的问题。
对于一个子进程,通常情况下被创建出来都是去完成父进程交派的任务。子进程完成任务后进程终止,但是进程终止并不代表会立即进入死亡状态,而是变成僵尸状态。那是因为子进程在退出的时候,它的进程控制块并没有被立即释放,因为在进程控制块中存放了两个变量,专门用来保存进程退出时收到的信号和进程退出码。
那么,子进程为什么要保存自己的退出信号和进程退出码呢?
答案当然是为了让父进程能够获取到子进程的退出状态。为了让父进程能更好的获取到子进程的退出状态,操作系统提供了两个系统调用接口,分别是wait/waitpid。
🚀 进程等待的方法
下面是关于wait和waitpid的相关介绍:
wait和waitpid的作用都是等待子进程,如果waitpid没有设置WNOHANG选项,那么wait和waitpid都是满足如果子进程没有退出,则等待失败,返回-1。若子进程已经退出,则等待成功,返回子进程的pid。如果waitpid设置了WNOHANG选项,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
🚀 获取子进程staus
statu本质上就是一个整数,整形数据是4个字节,32个比特位。我们只关心它的低8位。我们可以通过以下计算来分别获得进程退出码和进程退出信号。
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
int a = 1;
a = a/0;
while(1)
{
printf("我是子进程,pid:%d, ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
int status = 0;
pid_t pid = waitpid(id, &status, NULL);
printf("exit code:%d exit signal: %d\n",(status>>8) & 0xFF, status & 0x7F);
return 0;
}
进程退出码:(status>>8)& 0xFF, 进程退出信号: status & 0x7F。
其实也可以通过WIFEXITED(status)和WEXITSTATUS(status)来分别判断是否正常退出以及获取其退出码。如果子进程被信号终止,那么可以通过WIFSIGNALED(status)和WTERMSIG(status)来分别判断是否被信号终止以及获取终止信号的编号。
如果子进程正常退出,父进程获得的进程退出码应该是如同我们给子进程所设置的一样,这个时候我们不关心子进程的终止信号。
如果子进程异常退出,我们不再关心进程的退出码,而是只关心进程的退出信号。
如上述代码就发生了经典的除0错误,导致子进程异常退出。最后父进程等待子进程拿到退出信号就是8号信号,浮点数错误。
当然,我们也可以通过给子进程发送信号的方式来终止子进程从而验证父进程获取到的子进程终止信号是否准。
在知道了父进程可以通过wait/waitpid的方式来等待子进程,下面我们来演示子进程如何避免成为僵尸进程。
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt =0;
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, cnt:%d\n",getpid(),getppid(),cnt++);
sleep(1);
if(cnt == 5)
{
exit(123);
}
}
}
sleep(8);
int status = 0;
pid_t ret = waitpid(id, &status, 0);
printf("exit code:%d exit signal: %d\n",(status>>8) & 0xFF, status & 0x7F);
return 0;
}
通过实验我们发现,我们让父进程在执行代码前先进行等待8s再回收子进程,一旦子进程退出且父进程没有及时回收子进程,那么子进程就会变成僵尸状态。直到父进程调用waitpid等待回收子进程,子进程才会释放自己的所有资源。
🚀 阻塞等待和非阻塞等待
-
阻塞等待是指当父进程尝试执行一个需要等待某些条件成立的系统调用时(waitpid),该进程会被挂起并等待条件成立后再继续执行。换句话说,父进程会阻塞等待条件成立,直到条件满足后也就是子进程退出之后才能继续向下执行。我们前面所介绍的都是阻塞等待。
-
非阻塞等待是指当进程尝试执行一个需要等待某些条件成立的系统调用时(waitpid),如果条件不满足,系统调用会立即返回,告诉进程当前条件不满足。进程可以根据需要继续执行其他任务或重新尝试该系统调用,而不必一直等待条件成立。这种方式通常需要程序员自己轮询状态,以确认条件是否已经满足。
-
如果想要让父进程进行非阻塞等待的话,我们需要给waitpid传入第三个参数WNOHANG。
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt =0;
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, cnt:%d\n",getpid(),getppid(),cnt++);
sleep(1);
if(cnt == 5)
{
exit(123);
}
}
}
int status = 0;
pid_t ret = waitpid(id, &status, WNOHANG);
while(ret == 0)
{
printf("parent process runing ......\n");
sleep(1);
ret = waitpid(id, &status, WNOHANG);
if(ret == id)
break;
}
printf("exit code:%d exit signal: %d\n",(status>>8) & 0xFF, status & 0x7F);
return 0;
}
从输出结果上我们可以看出,父进程在子进程执行过程中轮询的判断子进程是否退出,如果没有退出,那么waitpid返回0,此时父进程就可以执行自己的代码,一旦子进程退出,父进程调用waitpid判断时返回子进程的pid,此时父进程再去获取子进程的退出信息。通过这种轮询判断子进程是否终止的方式可以适当提高效率。
🏠 进程替换
🚀 替换原理
-
首先,操作系统会加载新进程的可执行文件,并将其从磁盘上加载到内存。
-
接下来,操作系统会创建进程的虚拟地址空间,然后将可执行文件中的代码和数据映射到该进程的地址空间中。
-
替换发生时,操作系统会使用新进程的代码和数据来覆盖当前进程的对应内容,即更新进程的虚拟地址空间。
-
最后,操作系统会开始执行新进程的代码,从而完成进程替换。
需要注意的是,在进程替换的过程中,当前进程的 PID、环境变量等信息都会被保留下来,只有代码和数据发生了变化。这种机制使得进程替换成为一种常见的进程管理方式。
🚀 替换函数
上面6个都是C语言提供的函数,至于execve则是操作系统对外提供的系统接口,所以我们很容易想到,上面6个C语言函数的底层肯定都是封装了execve。接下来分别对execl,execlp,execv,execvp,execvpe进行讲解。
- execl
#include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int main()
{
printf("mycmd begin ............\n");
execl("/usr/bin/ls", "ls","-a","-l","--color=auto", NULL);
printf("mycmd end ............\n");
return 0;
}
execl的第一个参数表示需要替换的进程的具体地址,第二个参数是一个可变参数,也就是可以传入多个参数,我们可以参考我们平时输入 ls 指令的方式进行参数传递,把命令名称,选项都当成一个一个的参数,最后再以NULL结尾。
通过实验现象我们发现,程序运行只执行了第一个打印语句,并没有执行最后一个打印语句,这是因为当程序正常运行时,执行完第一个打印语句,紧接着调用execl函数,执行完该语句之后,我们所写的进程已经被替换成了 “ls” 这个进程,所以后面的打印语句并不会执行。而且所有的进程替换函数或者系统接口都只有在滴哦用失败的时候才有返回值,调用成功没有返回值,因为进程已被替换,返回值没有任何意义。
- execlp
#include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int main()
{
printf("mycmd begin ............\n");
//execl("/usr/bin/ls", "ls","-a","-l","--color=auto", NULL);
execlp("ls", "ls","-a","-l","--color=auto", NULL);
printf("mycmd end ............\n");
return 0;
}
通过对比我们可以发现,其实两次实验的实验现象一致。execl在传入第一个参数时需要指明需要替换的进程的具体地址,而execlp则只需要指明需要替换的进程的名称,然后execlp函数会在环境变量中自动搜索该进程。后面的参数传递和execl一致。
- execv
include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int main()
{
printf("mycmd begin ............\n");
//execl("/usr/bin/ls", "ls","-a","-l","--color=auto", NULL);
//execlp("ls", "ls","-a","-l","--color=auto", NULL);
char *argv_[]={"ls","-a","-l","--color=auto",NULL};
execv("/usr/bin/ls",argv_);
printf("mycmd end ............\n");
return 0;
}
这里的实验现象和前面两个是一样的,就不再展示了。
通过观察我们发现,execv的第一个参数和execl一样,都是传递需要替换的进程的具体地址,但是execv的第二个参数则不再像之前execl和execlp一样需要一个一个传递,execv支持我们提前把参数存放在一个数组里面,然后以数组的方式进行传递。
- execvp
通过前面的学习,我们很容易就能猜出来execvp的第一个参数是传递需要替换的进程的名字,第二个参数就是命令行数组。但是,如果我们结合上命令行参数,那么这个程序将会变得很神奇。
#include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int main(int argc, char* argv[])
{
printf("mycmd begin ............\n");
//execl("/usr/bin/ls", "ls","-a","-l","--color=auto", NULL);
//execlp("ls", "ls","-a","-l","--color=auto", NULL);
//char *argv_[]={"ls","-a","-l","--color=auto",NULL};
//execv("/usr/bin/ls",argv_);
execvp(argv[1],&argv[1]);
printf("mycmd end ............\n");
return 0;
}
我们通过命令行参数的传递,从main函数那里获取到了execvp的两个参数,这样我们就能通过命令行的方式来修改需要替换的进程了。
至于最后一个函数,我就不在这里介绍了,相信读者在看完接下来的函数命名解释之后应该很容易就能知晓execvpe的功能和参数传递。
🚀 命名解释
所有的程序替换函数都是exec作为前缀,后面在加上不用的字母,每一个字母都有独特的含义。
-
l(list) : 表示参数采用列表
-
v(vector) : 参数用数组
-
p(path) : 有p自动搜索环境变量PATH
-
e(env) : 表示自己维护环境变量
下面是一个简易版的代码,针对C语言提供的6个函数进行解释说明:
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL);
// 带p的,可以使用环境变量PATH,无需写全路径
execlp("ps", "ps", "-ef", NULL);
// 带e的,需要自己组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
execv("/bin/ps", argv);
// 带p的,可以使用环境变量PATH,无需写全路径
execvp("ps", argv);
// 带e的,需要自己组装环境变量
execve("/bin/ps", argv, envp);
exit(0);
}
🏠 简易版shell的制作
在学习了进程替换之后,我们就可以尝试做一个简易版的shell。在自己实现shell之前,我们来详细谈谈shell的工作原理。
Shell是一种命令行解释器,可以在计算机上运行各种命令和脚本。它的工作原理如下:
- 用户输入命令或脚本;
- Shell接收并解释这些命令或脚本;
- Shell进行语法分析,并根据命令执行相应的操作;
- 如果命令需要读取数据或文件,Shell会打开文件或连接到输入源(如键盘)来获取数据;
- 命令执行完成后,Shell将最终结果返回给用户。
- Shell的工作流程相当于一个转译过程,将用户输入的命令或脚本翻译成计算机所能理解的指令,并将其运行起来。
而我们平时输入的指令在本质上其实就是用C语言提前写好的可执行程序,被放在了特定的目录下/usr/bin,而一旦我们输入指令执行,该可执行程序加载到内存中就成了bash的一个子进程。然后该子进程执行自己的功能。
我们实现简易版shell的核心思路就是,自己写一个可执行程序作为bash,然后再用fork创建子进程,让子进程进行程序替换,完成对应的功能。
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。
所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
#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);
}
}