0
点赞
收藏
分享

微信扫一扫

Linux下的进程控制

艾米吖 2022-07-04 阅读 83

文章目录

🌲进程地址空间复习和补充

  • 进程地址空间本质上是进程看待内存的方式,是抽象出来的一个概念,在内核中就是一个结构体mm_struct(内存描述符)
  • 区域划分本质:将线性地址空间划分成一个又一个的area[start,end];start和end之间的地址就是线性地址
  • PCB和进程地址空间可以通过指针进行联系(比如task_struct里面存一个指向mm_struct的指针)
  • 页表:完成虚拟地址到物理地址之间的映射(需要配合硬件)

虚拟地址可以完全一样,映射到的物理内存可能不同。

  • 写时拷贝,父子进程共享代码和数据,如果子进程修改数据就要开一块新的空间来存放新的数据。
  • 虚拟地址的必要性:如果进程直接访问物理内存,那我们看到的地址就是物理内存的地址,就可能会导致越界,同时无法保证进程的独立性。
  • OS的主要功能有进程管理,内存管理,设备管理,文件管理,作业管理,其中内存管理只需要知道哪些区域是有效的,哪些区域是无效的(比如释放一块空间时将这块空间置为无效的即可,把这块空间全部置为0是一种效率不高的方式)
  • 4GB能不能运行16GB的游戏?

可以,需要一段就加载一段。(并不需要一次性全部加载完毕,但可能有点卡…)

  • image-20220629143341992

小知识:可执行程序,本身就已经划分成为了一个个的区域,这样做利于链接,且划分大小都是4KB的整数倍,利于系统操作。

  • 进程和程序的区别

进程是动态的,程序是静态的,且进程有生命周期,而程序没有。

一个程序可以有多个进程,而一个进程只能对应一个程序。(比如一个程序可以创建父子进程,但父子进程都对应同一个程序)

组成上,进程包括了程序,而程序是指令的集合

🌲进程控制

🌴进程的创建–fork()

进程的创建有两种方式,分别是命令行启动命令和通过程序fork创建子进程。

fork()是操作系统给我们创建进程的接口

让我们先问问linux里的man

image-20220629150550228

🌵原理

调用fork后,内核会分配新的内存块和内核数据给子进程,将父进程中的部分数据结构(比如PCB)拷贝给子进程。子进程假如到系统的进程列表中,fork返回,调度器开始调度。

🌵返回值

fork之后父子进程都有自己的pid,因为父子进程的返回值不同会发生写时拷贝,但因为虚拟地址没变,导致看起来像有两个返回值

image-20220629150948722

我们可以看到fork有“两个”返回值“,并不是return了两次,而是值的写入引起了写时拷贝 (实际上页表映射的物理内存不是同一块)

📄理解

观察下面的if和else if同时执行的现象

 #include<unistd.h>
 #include<stdio.h>
 int main()
 {
   int ret=fork();
   while(1)
   {
     if(ret==0)
     {
       //child
       printf("i am child ,ret=%d ,addr=%p\n",ret,&ret);
       sleep(1);
     }
     else if(ret>0)
     {
       //father
       printf("i am father,ret=%d ,addr=%p\n",ret,&ret);                                                                                     
       sleep(1);
     }
     else
     {
       //fork err
       perror("fork");
     }
   } 
 }

image-20220629161806565

解释我们看到的地址是虚拟地址,实际上的物理地址是不同的,这是因为发生了写时拷贝。

fork()创建子进程后子进程返回了一个值,返回一个值等同于子进程修改了数据,要发生写时拷贝,所以看起来就有了一个变量存储了两个值(fork返回两个值)的现象。

🌵子进程

  • 子进程会以父进程为模板拷贝代码和数据,子进程更改数据就会发生写时拷贝。(写时拷贝维护了父子进程的独立性,利于运行多进程)

  • 创建子进程本质是系统多了一个进程(进了调度列表),也就是多了一套进程相关的数据结构(如PCB)

  • 在不写入的情况下,用户的代码和数据是父子共享的

🌵写时拷贝

写这里指修改

image-20220629154245577

image-20220629154510039

  • 为什么要进行写时拷贝,我在创建子进程时直接将数据和代码全部拷贝一份不就行了?

写时拷贝可以保证进程的独立性。子进程如果没用到父进程所有的资源却拷贝了父进程所有的资源,这就是浪费,如果这么做了还会导致fork()效率的降低,提高fork失败的概率(比以前需要申请更多的资源,自然更容易失败)

  • 上面的写时拷贝是针对数据的,那有没有可能发生代码的写时拷贝呢?

有可能,比如后面会提到的进程替换。

🌵fork失败的原因

  • 进程数超出限制
  • 内存不足

🌴进程的终止

🌵结束场景

  • 代码运行完,结果正确。

  • 代码运行完,结果不正确。

  • 代码没运行完就结束。(异常终止,如野指针导致的越界问题等)

前两种属于进程正常终止,第三种属于非正常终止。

🌵进程的退出码

系统通过接口调用main函数,main函数执行完后要给系统一个执行完成的信息,main函数的return 0就充当了这个功能,return 0告诉系统正常结束且结果正确,这里的0就是退出码。(非0一般表示结果不正确)

退出码可以人为定义,也可以用系统或者库里提供的错误码列表。

比如C语言提供的strerror(在vim界面下怎么查询函数)

image-20220629203105619

🌵退出码在进程里的作用

我们为什么要创建子进程?因为我们需要子进程完成我们给它的任务。

父进程想知道子进程有没有完成任务,就得借助退出码了。

🌵进程退出的操作

  • main函数return

  • 任何函数exit。

举个栗子

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
void show()
{
  printf("i am show\n");
  exit(10);
}
int main()
{
  show();
  printf("i am main\n");
  return 0;
}

image-20220629204417203

🌵exit和_exit

两者的区别在于,exit会在进程结束做一些收尾工作(比如刷新缓冲区),而_exit不会,看下面的对比。

exit函数

image-20220629204839008

_exit函数

image-20220629205036587

🌴进程的等待

为什么要等待?

  • 回收僵尸进程,解决内存泄漏(僵尸进程是杀不死了,kill -9不能干掉僵尸进程)

  • 获取子进程的运行结束状态(交给子进程的工作做的怎么样了)

  • 父进程晚于子进程退出,规范化的进行资源回收。

🌵等待函数

wait和waitpid函数

image-20220629205630505

头文件是sys/types.h和sys/wait.h

wait的返回值:等待成功返回子进程的id,等待失败返回-1

waitpid的返回值:等待成功返回子进程的id,如果指定了WNOHANG选项,且子进程正在运行(没有已退出的子进程可收集)则返回0,等待失败返回-1

image-20220629214228247

🌵例子

例子1:wait的简单使用

pid_t wait(int*status); wait参数列表里有一个参数status,这个参数的作用和waitpid参数列表中的status作用相同

wait里的参数设为NULL,表示等待任意一个子进程。如果不设置为空可以带出子进程的状态,比如是否是正常退出(后面会举例)

#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");
  }
  else if(id==0)
  {
    //child
    int cnt=5;
    while(cnt--)
    {
      printf("child is running,pid:%d\n",getpid());
      sleep(1);
    }
    printf("child finished...\n");
    exit(0);
  }
  else 
  {
    //father
    printf("father is waiting\n");
    pid_t ret=wait(NULL);
    printf("father is wait done,ret:%d\n",ret);
  }
  return 0;
}

image-20220629215817574

例子2:waitpid的简单使用

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

waitpid有三个参数,第一个是等待的子进程id,第二个是子进程返回的状态,第三个是等待的方式。其中第二个参数status也被称为输出型参数。

第三个参数option一般是一些宏,比如image-20220630000043919

不关心子进程的返回状态则用NULL,option如果不想用提供的宏就设为0

#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");
  }
  else if(id==0)
  {
    //child
    int cnt=5;
    while(cnt--)
    {
      printf("child is running,pid:%d\n",getpid());
      sleep(1);
    }
    printf("child finished...\n");
    exit(0);
  }
  else 
  {
    //father
    printf("father is waiting\n");
    pid_t ret=waitpid(id,NULL,0);//waitpid
    printf("father is wait done,ret:%d\n",ret);
  }
  return 0;
}

image-20220629221736842

例子3:waitpid参数中status的使用

status的组成

image-20220629225009388

#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");
  }
  else if(id==0)
  {
    //child
    int cnt=5;
    while(cnt--)
    {
      printf("child is running,pid:%d\n",getpid());
      sleep(1);
    }
    printf("child finished...\n");
    exit(10);
  }
  else 
  {
    //father
    int status=0;
    printf("father is waiting\n");
    pid_t ret=waitpid(id,&status,0);
    if(ret>0&&(status&0x7f)==0)
    {
      printf("正常退出\n");
    }
    else 
    {
      printf("被信号所杀\n");
    }
    printf("father is wait done,ret:%d,\n",ret);
  }
  return 0;
}

正常运行

image-20220629230302806

运行中杀掉进程

image-20220629230043794

🌵小问题

为什么要通过waitpid拿到子进程的结果,能不能用全局变量?

不可以,每个进程都有自己独立的进程地址空间,如果用了全局变量会导致虚拟地址看起来一样但是实际指向的物理内存不同(因为全局变量更改时发生了写时拷贝)

那waitpid里的status是从哪里拿到的呢?这是个系统接口,自然是从task_struct里面

🌵阻塞与非阻塞

阻塞等待,注意等待的主体是父进程,是父进程等子进程。阻塞等待就是父进程一直在那等啥事也不干,等到子进程干完了自己的事父进程再去干自己的事,比如我们将wait和waitpid的选项设置为0就是阻塞等待。

非阻塞等待:就是父进程在等待的同时也在做自己的事,在子进程退出后再去读取子进程的退出信息。

实现非阻塞等待,把waitpid里的第三个参数设置为WNOHANG即可

image-20220630002816893

image-20220630003117308

非阻塞轮询方案:采用多次检测,过一段时间就检测一次子进程的状态,直到检测到子进程完成自己的任务。

//非阻塞轮询方案
#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");
  }
  else if(id==0)
  {
    //child
    int cnt=5;
    while(cnt--)
    {
      printf("child is running,pid:%d\n",getpid());
      sleep(2);
    }
    printf("child finished...\n");
    exit(10);
  }
  else 
  {
    //father
    while(1)//一直检测
    {
      int status=0;
      printf("father is waiting\n");
      pid_t ret=waitpid(id,&status,WNOHANG);//根据返回值来确定是否等待成功
      if(ret==0)
      {
        printf("father does other things\n");
        sleep(1);
      }
      else if (ret>0)
      {
        printf("father wait success\n");
        printf("正常结束\n");
        return 0;
      }
      else 
      {
        printf("wait err\n");
        break;
      }
    }
  }
  return 0;
}

image-20220630002325979

🌴进程替换

为什么需要进程替换?

我们创建子进程肯定让子进程来执行我们的任务,这个任务在没讲进程替换之前都是父进程给它的,那如果现在我们需要执行别的程序呢?此时就需要进程替换了。

🌵原理

下面简单将程序理解为代码和数据。

进程替换并不是创建了新的进程,而是一种替换。以磁盘上的一个程序去替代子进程为例,是将磁盘上程序的代码和数据去替代子进程的代码和数据,此时就会有写时拷贝。

image-20220630223257446

🌵操作

实现进程替换需要通过exec系列的库函数。

让我们问问linux的man。

这几个函数都是对execve的封装,因为系统真正调用的execve函数,execve函数与这几个函数是同一个头文件

image-20220630122610731

因为进程替换的原因,我们一般不用考虑这几个函数的返回值,因为替换后直接从替换的代码开始执行,现有的这个进程就被替换掉了,一旦返回就说明替换失败,函数出错了,此时会返回-1。

🌵例子

execl

int execl(const char *path, const char *arg, …);

path表示替换程序的路径,arg表示你要如何执行这个程序,以ls -a -l为例

#include <stdio.h>
#include <unistd.h>
int main()
{
  execl("/usr/bin/ls","ls","-a","-l",NULL);
  printf("hello world\n");
  return 0;
}

image-20220630222000583

execlp

int execlp(const char *file, const char *arg, …);

file表示要执行的程序,arg表示要如何执行,与execl的不同点在于这个函数会在PATH环境变量下自动寻找程序。

image-20220630223715147

execle

int execle(const char *path, const char *arg, …, char *const envp[]);

与execl相比多了最后一个参数,envp这个字符指针数组表示我们自己定义的环境变量。

作用是传入默认或自定义的环境变量给目标可执行程序


下面的例子用mycmd程序替代当前进程,并且传入环境变量。

mycmd.c

#include <stdio.h>
#include <stdlib.h>
int main()
{
  for(int i=0;i<5;i++)
  {
    printf("cmd:%d\n",i);
  }
  printf("获取自己定义的环境变量:%s\n",getenv("MYENV"));
  return 0;
}

getenv是一个库函数,获取这个环境变量的值,MYENV是我们自己定义的环境变量,系统里找不到,此时要用到我们自己定义的环境变量就用execle,换句话说execle可以传递环境变量

replace.c

#include <stdio.h>
#include <unistd.h>
int main()
{
  char* const envp[]={"MYENV=11",NULL};
  execle("./mycmd","mycmd",NULL,envp);
  printf("hello world\n");
  return 0;
}

运行结果

image-20220630230905425

execv

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

与execl相比,execl的arg表示要怎么直线这个程序,execv则是通过数组实现相同的功能,在字符指针数组里以字符串来表示要执行的选项,也是以NULL结尾。

#include <stdio.h>
#include <unistd.h>
int main()
{
  char* const argv[]={"ls","-a","-l",NULL};
  execv("/usr/bin/ls",argv);
  printf("hello world\n");
  return 0;
}

运行结果

image-20220630231212493

execve和execvp

这两个函数与execv相比,一个多了p(在PATH下自动找程序),一个多了e(传递需要的环境变量信息),他们的区别就是execl和execle,execlp的区别。这里就不举例了

🌴简易Shell的实现

image-20220630232551158

这个Shell的功能

  • 获取命令行
  • 解析命令行
  • 建立子进程
  • 替换子进程
  • 父进程等待子进程退出

🌵代码实现

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

#define NUM 128
#define SIZE 32

char command_line[NUM];//命令行
char* command_parse[SIZE];//切分命令行
int main()
{
  while(1)
  {
    memset(command_line,'\0',sizeof(command_line));
    printf("[我的Shell]$ ");
    fflush(stdout);
    if(fgets(command_line,NUM-1,stdin))
    {
      command_line[strlen(command_line)-1]='\0';//处理\n
      int index=0;
      command_parse[index]=strtok(command_line," ");
      while(1)
      {
        index++;
        command_parse[index]=strtok(NULL," ");
        if(command_parse[index]==NULL)
        {
          break;
        }
      }
      if(fork()==0)
      {
        execvp(command_parse[0],command_parse);
        exit(1);//进程替换失败  成功这句代码会被替换掉     
      }
      int status=0;
      pid_t ret=waitpid(-1,&status,0);
      if(ret>0 && WIFEXITED(status))
      {
        printf("EXIT Code:%d\n",WEXITSTATUS(status));
      }
    }
  }
  return 0;
}

image-20220630235518436

bug:如cd …的功能为什么不能在子进程中实现?cd …的主体是父进程,父子进程是独立的,子进程去更改并不能去影响父进程,需要在子进程里影响到父进程肯定不是我们能做的,我们需要系统提供给我们的一些接口才能完成,即我们需要调用系统接口来实现cd…。echo $PATH也是一个道理
此外这个Shell不能输入特殊字符,比如backspace

调试可以结合/proc目录,/proc目录下有着进程的相关信息。

举报

相关推荐

0 条评论