【Linux】进程控制 (万字详解)—— 进程创建 | 进程退出 | 进程等待 | 程序替换 | 实现简易shell

🌈欢迎来到Linux专栏~~进程控制


  • (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort🎓
  • 🌍博客主页:张小姐的猫~江湖背景
  • 快上车🚘,握好方向盘跟我有一起打天下嘞!
  • 送给自己的一句鸡汤🤔:
  • 🔥真正的大师永远怀着一颗学徒的心
  • 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
  • 🎉🎉欢迎持续关注!
    在这里插入图片描述

请添加图片描述

请添加图片描述

一. 进程创建

🌍回忆fork

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进 程,而原进程为父进程。📌fork不懂的可以去这篇博客fork初始看看

#include <unistd.h>
pid_t fork(void);
//返回值:子进程返回0,父进程返回子进程id;创建失败返回-1

⚡面试题:请你描述一下,fork创建子进程,操作系统都做了什么

  • 1️⃣系统多了一个进程,此进程分配有对应的PCB结构体、地址空间、页表
  • 2️⃣并将自己进程的代码和数据(从父进程中拷贝)加载到内存中,构建映射关系
  • 3️⃣将该进程的PCB放入运行队列里,等待调度
  • 4️⃣一旦开始调度,通过虚拟地址空间➕页表找到相关代码按照顺序语句等执行

在这里插入图片描述
所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定

fork之后,代码共享是after之后的还是全部代码共享?

  • 虽然子进程是从after之后往后,但全部代码都是共享的

⚡那么为什么子进程是从fork之后开始执行,而不是before开始?

  • 因为进程随时可能被中断,下次回来,还必须从之前位置继续运行,就要求CPU必须随时记录下,当前进程执行的位置,所以CPU内有对应的寄存器EIP,用来记录当前进程的执行位置!
  • 寄存器在CPU内,只有一份,寄存器的数据是可以有多份的 —— 上下文数据
  • 🌍虽然父子进程各自调度,各自会修改EIP,但是因为子进程已经认为自己的EIP起始值就是fork之后的代码!

在这里插入图片描述

所以子进程是从after开始跑,但并不代表之前的代码看不到!

创建子进程,给子进程分配对应的内核结构,必须子进程自己独有,因为进程具有独立性!理论上子进程也要有自己的代码和数据!可是一般而言,我们没有加载的过程,也就是说,子进程没有自己的代码和数据!!所以,子进程只能“使用”父进程的代码和数据!

  • 代码:都不可以被写,只能读取,所以父子共享
  • 数据:可能被修改,必须分离!

🌍为什么OS选用写时拷贝 ?

那么数据在创建进程时候就直接拷贝分离吗?

  • 可能拷贝子进程根本用不上的数据,即便用得上也只是读取 ———— 空间浪费

举个例子:

const char *str = "aaa";
const char *str2 = "aaa";

printf("%p\n", str);
printf("%p\n", str2);

打印出来的是同一块地址!编译器在编译程序时候都知道节省空间,你觉得OS不会吗?

OS为何选择了写时拷贝,来将父子进程的数据进行分离?

  1. 一般而言即便是OS,也无法提前知道哪些空间可能会被写入!
  2. 用的时候,再给你分配,是一种延时申请技术,可以提高整机内存的使用率

ps:string,深浅拷贝底层也是写实拷贝实现的

在这里插入图片描述
父/子修改数据时,会发生缺页中断:OS再开辟一段空间,把数据拷贝过来(写时拷贝),重新建立映射关系;父子分开,更改读写权限。这时候再进行写操作。这样保证了父子进程的独立性。

🌍fork的用法 & 调用失败的原因

⚡fork用法

  • 父子进程执行不同代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数(下面详说哦)

⚡fork 调用失败的原因

  • 系统中有太多进程时,资源不足
  • 用户创建的进程数超出了限制,为了防止某些用户恶意创建

二. 进程终止

💦进程退出场景

  • 1️⃣代码运行完毕,结果正确
  • 2️⃣代码运行完毕,结果不正确
  • 3️⃣代码异常终止, 崩溃了

思考:为什么main函数总会return 0,意义何在?

并不是总是0, main函数的return的值就是进程退出码,返回给上一级进程,用来评判该进程执行结果

❗查看最近一次进程退出时的退出码 ——来衡量代码跑完对不对的

echo $?  查看最近一个程序的退出码 
————————————————————————————————————————————————————————————————————————————————————————————————
代码运行完毕,结果正确    - 0:   success
代码运行完毕,结果不正确  - !0:  failed 
代码异常终止			- 程序崩溃 → 退出码没有意义,return都不会跑(可以通过某种方式获得原因,进程等待详谈)

在这里插入图片描述

  1. 代码运行完毕,结果正确:返回0
  2. 代码运行完毕,结果不正确:返回非0

返回非0值,这是因为结果错误有多种可能,通过错误码获得对应错误信息字符串,比如我们可以用strerror来查看 ——

在这里插入图片描述

 #include<stdio.h>
 #include<string.h>
 int main()
 {
   int i=0;
   for(i=0;i< 150;i++)
   {
     printf("%d:%s\n",i,strerror(i));                                                                  
   }                                                                                  
   return 0;                                                                          
  }  

运行结果如下——

在这里插入图片描述以上的退出码是系统给我们提供的,我们可以使用这些退出码,但是如果想自己定义,也可以自己设计一套退出方案!

在这里插入图片描述这个没有错,自定义设为1了

3️⃣程序崩溃
程序运行出错,崩溃 —— 存在野指针

#include<stdio.h>
int main()
{
   printf("hello world\n");
   printf("hello world\n");
   printf("hello world\n");
   int *p =NULL;
   *p=1234;//野指针
   printf("hello world2\n");
   printf("hello world2\n");
   printf("hello world2\n");
   return 0;
}

程序崩溃时退出码是没有意义的,(好比你作弊了,老师还会在意你的分数吗?),一般而言退出码对应的return语句,没有被执行

在这里插入图片描述

💦退出进程方法

🌈return 退出

main函数内的return返回代表进程退出;非main函数return代表函数返回

return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数

🌈exit

📌 exit在任意地方调用,都代表终止进程,参数是退出码。

#include <unistd.h>
void exit(int status);

在这里插入图片描述

🌈_exit

在之前的进度条代码,我们就知道显示器是行刷新的,即\n进行刷新
在这里插入图片描述

🌈exit 和 _exit 区别

我们发现_exit直接终止进程exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程

在这里插入图片描述

🌈进程异常退出

1️⃣向进程发生信号导致进程异常退出:

  • 发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等

2️⃣代码错误导致进程运行时异常退出:

  • 代码当中存在野指针问题使得进程运行时异常退出,或是出现除0的情况使得进程运行时异常退出等。

三. 进程等待

⚡进程等待的必要性

  1. 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏
  2. 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程
  3. 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何

所以父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息

⚡进程等待的方法

➰wait

在这里插入图片描述

#include<sys/types.h>
#include<sys/wait.h>

pid_t wait(int*status);
  • 返回值: 等待成功,返回被等待进程pid;等待失败,返回-1

下面写一段代码来验证:回收僵尸进程的问题

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
	pid_t id = fork();
	if(id < 0)
	{
	  perror("fork");
	  exit(-1);//表示进程运行完毕,结果不正确
	}
	if(id == 0){
		//子进程
		int count = 5;
		while(count--){
			printf("cnt: %d, 我是子进程,pid:%d,ppid:%d\n",cnt, getpid(), getppid());
			sleep(1);
		}
		exit(0);
	}
	//父进程
	sleep(7);
	pid_t ret = wait(NULL);//阻塞式的等待!
    if(ret > 0)
    {
      printf("等待子进程成功,ret:%d\n",ret);
    }
	return 0;
}

我们可以使用以下监控脚本对进程进行实时监控:

while :; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep;sleep 1; echo "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"; done

wait回收了僵尸进程

在这里插入图片描述

➰waitpid
#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

返回值: 等待成功,返回被等待进程pid;等待失败,返回-1

  • pid:待等待子进程的pid,若设置为-1,则等待任意子进程
  • status:输出型参数,获取子进程的退出状态,不关心可设置为NULL
  • options:默认为0,表示阻塞等待
    在这里插入图片描述

⚡通过status获取子进程退出信息

🥑位操作

status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位):

在这里插入图片描述
由此我们可以通过此来对status进行位操作来获取异常信号退出码

exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F;      //退出信号

在这里插入图片描述

在这里插入图片描述

🔸 对于代码异常终止的:

  1. 除0错误异常终止
    在这里插入图片描述

  2. 我们给子进程发送2号信号,把子进程提前干掉,此时可以看到退出码是无效的,退出信号即是我们发送的信号 ——在这里插入图片描述

🥑宏

我们也可以通过一组不用进行位操作的宏来获取退出码、判断有无异常信号

 WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
 WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

在这里插入图片描述

运行结果如下——(正常退出 vs 异常终止)

在这里插入图片描述

在这里插入图片描述

🥑细节小问题

1️⃣为什么要用wait/waitpid函数呢??直接用全局变量不行吗??

  • 进程具有独立性,那么数据就要发生写时拷贝,父进程无法拿到,更何况信号呢?

2️⃣既然进程具有独立性,进程退出码不也是子进程的数据吗?,父进程为什么能拿得到呢??wait/waitpid究竟干了什么

  • 这要从僵尸进程:至少要保留该进程的PCB信息!task_struct里面保留了任何进程退出时的退出结果信息!!所以wait本质就是读取了子进程的task_struct结构
🥑理解waitpid

在这里插入图片描述

⚡options

pid_ t waitpid(pid_t pid, int *status, int options);

waitpid的第三个参数options,用来设置等待方式

  • 0:默认阻塞等待
  • WNOHANG:非阻塞等待

若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID

小故事:快要期末考了,我这个学期没有上过课,我给学霸张三打电话,问他要C语言的考试重点,他说他在楼上有事情让我等30min。我说:等你完全没有问题,电话别挂,你不下来,我就不挂,我就一直等着,这就是阻塞状态,一个月后,我再次找张三要复习资料,这次不同我每隔5mins 给张三打一次电话,询问他好了没有,这样每一次的打电话过程:非阻塞调用——基于非阻塞调用的轮询检测方案

在这里插入图片描述

🔥阻塞状态

阻塞的本质:意味着进程的PCB被放入等待队列中,并将进程状态由R改为S状态
返回的本质:子进程退出,父进程的PCB从等待队列中拿回,继续执行没执行完的代码,可以被CPU调度了

🔥非阻塞状态

我们看到OS或者某些应用,长时间卡住不动,这种情况我们叫做应用或者程序HANG住了。那么,WNOHANG表示设置等待方式为非阻塞

父进程在等待子进程返回结果,情况有如下:

  • 等待成功,子进程退出
  • 等待成功,子进程还未退出
  • 等待失败
 #include<stdio.h>                                                                                       
 #include<stdlib.h>
 #include<unistd.h>
 #include<sys/wait.h>
 
  int main()
  {
    pid_t id =fork();
    if(id == 0)
    {
      //子进程
      int cnt =5;
      while(cnt)
      {
        printf("我是子进程:%d\n",cnt--);
        sleep(1);
      }
      exit(105);//105 仅仅用来测试
    }
    else{
      int quit =0;
      while(!quit)
      {
        int status =0;
        pid_t result = waitpid(-1, &status, WNOHANG);
        if(result > 0)
        {
          //等待成功 && 子进程退出
          printf("等待子进程退出成功,退出码:%d\n",WEXITSTATUS(status));
          break;
        }
        else if(result == 0)
        {
          //等待成功 && 子进程未退出                                                                                                                                                       
          printf("子进程还在运行,暂时退出不了,你待会再来吧\n");
        }
        else
        {
          //等待失败
          printf("wait失败\n");
          break;
        }
      }
}

这就叫做基于非阻塞等待的轮询方案

在这里插入图片描述

四 . 进程替换

众所周知,fork之后,父子各自执行父进程代码的一部分,父子代码共享,数据写时拷贝各自私有一份,如果子进程就想执行一个全新的程序呢?那就要通过进程替换实现

💢概念和原理

程序替换,是通过特定的接口,加载磁盘上的一个权限的程序(代码和数据),加载到调用进程的地址空间中!仅仅替换当前进程的代码和数据的技术,并没有创建新的进程

在这里插入图片描述

程序替换本质就是把程序的代码+数据,加载到特定进程的上下文中。C/C++程序要运行,必须要先加载内存中,如何加载呢?是通过加载器,加载器的底层原理就是一系列的exec*程序替换函数

在这里插入图片描述

上面我们发现,函数替换后,结束语句并没有打印

注:execl是程序替换,调用函数成功之后,会将当前进程的所以代码和数据都进行替换!包括已经执行的和未执行的!(甚至把自己都干掉了,所以没有返回值

execl一旦调用成功,后续所有代码,全部都不会执行!exec*函数成功是不需要进行返回值检测;只要返回了,就一定是因为调用失败了,直接退出程序即可。

💚小细节
在加载新程序之前,父子的数据和代码的关系?代码共享,数据写时拷贝。
当加载新程序的时候,不就是一种“写入吗”?代码为了保证独立性,必须分离,所以会发生写时拷贝,所以父子进程在代码和数据上就彻底分离了

💢替换函数

#include <unistd.h>`

int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);

这些函数名看起来容易混淆,但只要理解其命名含义就很好记忆

替换函数接口
l(list)参数采用列表方式
v(vctor)参数采用数组方式
p(path)自动搜索环境变量path ,无需写全路径
e(env)需要自己定义环境变量

下面我来一一探究:
🌍execl

int execl(const char *path, const char *arg, ...)

在这里插入图片描述

🌍execv
l即参数用列表传递;v即参数用数组传递

int execv(const char *path, char *const argv[]);

在环境变量中我们提到过,main是可以带有参数的。argv是一个指针数组,指针指向命令行参数字符串。我们可以理解为,通过exec函数,把argv喂给了ls程序的main函数。

在这里插入图片描述

🌍execlp && execvp

p:我会自己在环境变量PATH中查找,告诉我程序名即可

  execlp("ls", "ls", "-a", "-l", NULL);                                                                                                             
  char* argv[] = { "ls", "-a", "-l", NULL};   
  execvp("ls", argv);	

ps:Makefile默认只生成第一个目标文件,那么如何在一个Makefile文件中一次形成两个可执行文件呢?
在这里插入图片描述

所有的接口,看起来没有很大差别,只是调用参数的不同。这么多的接口,是为了满足不同的调用场景

操作系统只提供了一个系统调用接口execve(2),其他库函数(3)都是对系统调用的简单封装。

在这里插入图片描述

💢程序替换运行其他语言程序

其中bash是解释器,test.sh是我们写的脚本,作为参数的形式给bash读取到,在bash内部执行的,执行对应的功能

在这里插入图片描述

五. 实现一个简易的shell

💫 写一个shell 命令行解释器,需要循环以下过程

  • 打印提示行
  • 获取和解析命令
  • fork创建子进程;替换子进程
  • 父进程等待

各个阶段都有很多细节要注意:

🔥 1. 打印提示行
由于提示行本就是写死的,对于理解Linux意义不大我们就直接打印:[ljj@localhost myshell]#
另外在之前的进度条我们就知道,显示器的刷新策略就是行刷新,所以不想加\n,可以调用fflush(stdout);

🔥 2. 获取命令行

定义一个缓冲区cmd_line[NUM],并初始化。用fgets函数获取,打印的时候我们发现多换了一次行,这是因为我们把回车也读取到了,需要把\n处置0

cmd_line[strlen(cmd_line)-1] = '\0'; //strlen不包括'\0'

在这里插入图片描述

🔥 3.解析命令行
解析字符串,要分割命令行,用strtok。把一个字符串打散成多个子串吗?

#include<string.h>

char *strtok(char *str, const char *delim);

strtok细节:

  • 第一次调用,要传入原始字符串
  • 第二次调用,如果还要解析原始字符串,传入NULL

🔥 4. fork创建子进程;替换子进程
不能用当前进程直接替换,会把前面的解析代码覆盖掉,因此要创建子进程。同时,父进程需要等待子进程退出,并返回结果

那么选择哪个进程替换函数呢?execvp

bash是一个进程;会获取用户输入、对命令行做解析,帮用户和内核打交道;还会创建子进程帮我们执行命令,就算子进程崩了,也不会影响到父进程(王婆和实习生)

🔥5. 内建命令
在运行我们的shell发现,cd.. cd path等代码路径并没有回退,cd 等命令不能移动myshell的位置是因为子进程会退出,并非是父进程bash。
对于cd,我们以内建命令方式运行(即不创建子进程,让父进程shell自己执行),实际上相当于调用了自己的一个函数。更改当前进程路径,有一个系统调用接口chdir ——

在这里插入图片描述

代码实现——迷你shell

#include<stdio.h>                                                                                                                                                                                                                     
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>

#define NUM 1024
#define SIZE 32
#define SEP " "

//保存打散之后的字符串
char *g_argv[SIZE];
//保存完整的命令行字符串
char cmd_line[NUM];
    
// shell 运行原理 :通过让子进程执行命令,父进程等待&&解析命令
int main()
 {
    //0. 命令行解释器,一定是一个常用内存的进程,也即是不退出
     while(1)
      {
        //1. 打印出提示信息
        //[whb@localhost myshell]#
        printf("[ljj@localhost myshell]# ");
        fflush(stdout);
        sleep(10);
        memset(cmd_line,'\0', sizeof cmd_line);
        //2.获取用户的键盘输入{输入的各种指令和选项,"ls -a -l"}
        if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL)
        {
          continue;
        }
        cmd_line[strlen(cmd_line)-1] = '\0';
        //"ls -a -l\n\0" 这里把最后的\n都输入进去了
        //printf("echo:%s\n", cmd_line);
        //3.解析命令行字符串:"ls -a -l" -> "ls" "-a" "-i"
        g_argv[0] = strtok(cmd_line, SEP); //第一次调用,要传入原始字符串
        int index = 1;
        if(strcmp(g_argv[0], "ls") == 0)
        {
          g_argv[index++] = "--color=auto";
        }
        if(strcmp(g_argv[0], "ll") == 0)
        {
          g_argv[0] = "ls";
          g_argv[index++] = "-l";
          g_argv[index++] = "--color=auto";  
        }
         while(g_argv[index++] = strtok(NULL, SEP)); // 第二次调用,如果还要解析原始字符串,传入NULL
        
        //for :debug
        //for(index =0; g_argv[index]; index++)
        //    printf("g_argv[%d]:%s\n", index, g_argv[index]);
        
        //4.todo:内置命令:让父进程(shell)自己执行的命令,叫做内置命令
        //内建命令本质其实就是shell中的一个函数调用
        if(strcmp(g_argv[0], "cd") == 0) //不想让子进程执行
        {
           if(g_argv[1]!= NULL) chdir(g_argv[1]);  //cd path, cd .. 
    
           continue;
        }
        //5.fork()
        pid_t id = fork();
        if(id == 0) //子进程
        {
           printf("下面功能让子进程执行\n");
           //cd 等命令不能移动myshell的位置,因为子进程会退出
           execvp(g_argv[0],g_argv);// ls -a -l
           exit(1);
        }
        //父进程
        int status =0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)
        {
          printf("退出码:%d\n", WEXITSTATUS(status));                                                                                                                                     
        }
      }
      return 0;
 }

在这里插入图片描述

📢写在最后

  • 能看到这里的都是棒棒哒🙌!
  • 想必进程控制也算是Linux中重要🔥的部分了,如果认真看完以上部分,肯定有所收获。
  • 接下来我还会继续写关于📚《基础IO》等…
  • 💯如有错误可以尽管指出💯
  • 🥇想学吗?我教你啊🥇
  • 🎉🎉觉得博主写的还不错的可以`一键三连撒🎉🎉
  • 在这里插入图片描述
  • 104
    点赞
  • 78
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 203
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 203
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张小姐的猫(考研停更)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值