本文将介绍进程控制;最后自己实现简陋的shell,对于bash会有更深的理解。

前置文章:进程虚拟地址空间;环境变量。

反爬链接

正文开始

1. 进程创建

众所周知,可以通过./或调用fork来创建进程。

1.1 回忆fork

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

现在我们知道,创建一个进程,内核会为它分配新的内存块加载代码和数据,创建各种内核数据结构包括进程控制块PCB、地址空间、页表、构建映射关系;将父进程部分数据结构内容拷贝至子进程;添加子进程到系统进程列表(运行队列)当中;fork返回,开始调度器调度。

1.2 站在地址空间角度理解写时拷贝

默认情况下,子进程会继承父进程的代码和数据 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LBaHoTLy-1649598752612)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220406162231908.png)]

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

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

💜 fork的用法

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

💜 fork调用失败的原因

  • 众所周知,创建进程成本很高(时间+空间),系统中有太多进程时,资源不足
  • 用户创建的进程数超出了限制,为了防止某些用户恶意创建。

2. 进程退出

进程退出,在OS层面做了什么呢?系统层面,进程退出,意味着少了一个进程:free PCB;free mm_struct;free页表和各种映射关系

2.1 进程退出的三种场景

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

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

  • 代码异常终止

思考:为什么main函数总会return 0,意义何在?main函数的return的值就是进程退出码

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

echo $?  查看退出码
————————————————————————————————————————————————————————————————————————————————————————————————
1.代码运行完毕,结果正确    - 0:  success
2.代码运行完毕,结果不正确  - !0: failed → 为什么不正确?有多种可能,错误码对应字符串(strerror)
3.代码异常终止			- 程序崩溃 → 退出码没有意义,return都不会跑(可以通过某种方式获得原因,进程等待详谈)


(bash是命令行启动的所有进程的父进程,bash一定是通过wait方式得到子进程的退出结果,所以echo $?能查到子进程的退出码)

🍓 1. 代码运行完毕,结果正确(0)

🍓 2. 代码运行完毕,结果不正确(!0)

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

运行结果 ——
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OfUa5vAJ-1649598752614)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220406195830857.png)]

🍓 3. 代码异常终止

这是运行时错误,即程序崩溃。除0错误 ——

程序崩溃时,退出码是没有意义的,return根本就没有执行 (可以通过某些方式获取原因,进程等待详谈)。

2.2 进程退出方法

2.2.1 从main返回

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

2.2.2 exit

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

#include <unistd.h>
void exit(int status);
//status: 退出码 EXIT_SUCCESS and EXIT_FAILURE(我们_exit详谈)

2.2.3 _exit

在进度条代码时我们就观察过,显示器的刷新策略是行刷新,即\n即进行刷新 ——

exit或main return除了进程退出外,本身都就会要求系统进行缓冲区刷新 ——

对比第三种进程退出方案 ——

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

💜 强制终止进程,不会进行进程的收尾工作,比如刷新缓冲区

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bQZqurp0-1649598752615)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220408103545175.png)]

3. 进程等待

3.1 what & why?

💜 进程等待

fork后,父子进程谁先退出不确定

  • 子进程:为了帮助父进程成某种任务
  • 父进程:父进程就需要通过某种方式知道子进程任务完成的怎么样

因此,父进程fork后,就需要通过wait/waitpid等待子进程退出,来获取退出信息。

💜 为什么要进行进程等待

  • 通过获取子进程退出的信息,能够得知子进程的执行结果
  • 保证时序问题:保证子进程先退出,父进程后退出
  • 进程退出时,会先进入僵尸状态,若父进程不等待,会造成内存泄漏。需要通过父进程wait,释放子进程占用的资源(解决僵尸进程问题方法之一)。

3.2 进程等待的方法

3.2.1 wait

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

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

我们写一段代码来验证。fork后父进程先sleep上6s,在前3秒,子进程正常跑;后3s,子进程退出,进入僵尸状态。父进程睡醒后,开始等待,可以观察到僵尸进程会消失。

再复制SSH渠道,写一个监控脚本 ——

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

运行结果,证明wait可以回收僵尸进程 ——

3.2.2 waitpid

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

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

  • 第一个参数 pid

    • -1:等待任意一个子进程,等价于wait

    • 0:等待指定一个进程

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

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

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

wait和waitpid都有status参数,它是一个输出型参数 - 最终让父进程通过status得到子进程执行的结果

打印status ——

打印结果,父进程等到什么结果,一定和子进程如何退出强相关 ——

3.3.1 位操作

status对于进程退出的三种情况 ——


由此我们可以通过对status进行位操作来获取异常信号和退出码。

🔸 对于代码能运行完的情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FE4yQOJE-1649598752616)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220407172937707.png)]

🔸 对于代码异常终止的:

  • 例如,我们给子进程发送2号信号,把子进程提前干掉,此时可以看到退出码是无效的,退出信号即是我们发送的信号 ——

  • 例如,除0错误异常终止

3.3.2 宏

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

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

只需要对上面代码做小小修改 ——

运行结果 ——

3.3.3 理解waitpid

3.4 options

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

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

  • 0:默认阻塞等待
  • WNOHANG:设置为非阻塞等待

阻塞和非阻塞,比如啊,我啊,边通书,这学期马原课就没听过,学分绩课,同学们把最后一排门边儿的位置都留给我了,我啊,电脑一开,小风儿一吹;转到线上后我就把自己挂往那儿一挂,甚至常常都忘了挂(大家不要学我,我觉得我思辨能力在倒退,当然上课和这个也没有直接关联啦)。but要期末考试了,我就苦哈哈的追着大学霸同学划重点(现实中我根本就不会这么干),于是我来到A01楼下给他打电话,他说他在楼上看书让我等30min。fine! 等就等,电话别挂,你不完成,我就不返回,这就是阻塞状态;但是我这个istj怎么可能闲着呢,于是我在楼下打开电脑写5min代码就再给他打个电话,轮巡检测他的状态,不因为对方没准备好就卡在那里,这就是非阻塞状态

阻塞等待和非阻塞等待都是等待的一种方式。对应到操作系统,谁等?父进程在等;等谁?子进程;等什么?子进程退出。

3.4.1 阻塞等待

阻塞的本质:意味着进程的PCB被放入等待队列中,并将进程状态由R改为S状态。

返回的本质:子进程退出时,父进程的PCB从等待队列拿回R队列,从而被CPU调度。

3.4.2 非阻塞等待

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

父进程在非阻塞等待子进程时,返回值有以下几种情况 ——

  • 子进程根本就没退出
  • 子进程退出,waitpid成功或失败(等待是有可能失败的,比如你等错了进程)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FSAMoR6c-1649598752622)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220408213242634.png)]

这就叫做基于非阻塞等待的轮询方案,运行结果 ——


注:关于waitpid的返回值补充

若等待成功时候waitpid返回收集到的子进程的进程ID;若等待出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。

如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0

4. 进程程序替换

众所周知,创建子进程,可以让子进程子承父业,执行父进程代码的一部分(代码共享),那么如何让子进程执行一个“全新”的程序呢?那就要通过程序替换

4.1 what & why?

进程不变,仅仅替换当前进程代码和数据的技术,叫做进程程序替换。并没有创建新的进程

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hKlpwTir-1649598752630)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220409110310942.png)]

如果我们fork子进程,让子进程进行程序替换 ——

可以看到子进程程序替换,父进程并没有受到它的影响,进程是具有独立性的。那么父子代码是共享的吗?事实上,进程程序替换会更改代码区的代码,也要发生写时拷贝。这样,就可以让子进程执行全新的程序。

关于exec*函数的返回值 ——

只要进程的程序替换成功,就不会执行后续代码,因此,exec*函数成功是不需要进行返回值检测;只要返回了,就一定是因为调用失败了,直接退出程序即可。

4.2 六个替换函数 & 它们之间的关系

有6个以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(vector)参数采用数组方式
p(path)自动搜索环境变量PATH
e(env)自己维护环境变量,或者说自定义环境变量

我们依次来看,就能摸到其中的规律 ——

💜 execl

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3SmaY7lo-1649598752630)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220409183907078.png)]

💜 execv

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

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

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

💜 execlp & execvp

p,表示会自动环境变量PATH中搜索,只需要知道程序名即可。

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

💜 execle & execve

e,表示自己维护环境变量,也就是我不想用你给我传的默认环境变量。(execve同理,只不过用数组传递命令行参数)


exec*也可以调用我们自己的程序。

:Makefile默认只生成第一个目标文件,那么如何在一个Makefile文件中一次形成两个可执行文件呢?

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

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

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

5. 实现一个简陋的shell

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

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

来谈一谈各个阶段的注意点 ——

🍓 1. 打印提示行

[用户名@主机名 路径]提示符,这些都可以通过系统调用获取到,但是对于理解Linux意义不大,就直接写死了。

另外,写进度条时候就知道,显示器的刷新策略就是行刷新,不想换行还要刷新,可以调用fflush(stdout);

🍓 2. 获取命令行

注意,输入一整行,要用fgets。再这个字符串打印出来,发现多换了一次行,这是因为我们把回车也读入进command串儿中,需要把\n处置0。

🍓 3. 解析命令行

解析字符串,要分割串,用strtok。传参给要解析的字符串、分隔符串儿,返回子串;第二次提取时,把还想提取的老串儿给NULL——

我们把子串儿都提取到char*的指针数组argv中。

🍓 4. fork创建子进程;替换子进程

不能用当前进程直接替换,会把前面的解析代码覆盖掉,因此要创建子进程。同时,父进程需要等待子进程退出。这也就解释了为什么在bash上执行出错了,echo $? 就能拿到退出码,这是因为子进程的退出结果是可以wait拿到的,如何拿到?回看3.3小节。

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

我们之前在命令行解释器一文中铺垫的:bash是一个进程;会获取用户输入、对命令行做解析,帮用户和内核打交道;还会创建子进程帮我们执行命令,就算子进程崩了也不会影响bash(王婆和实习生),曾经抽象的理论,现在就无比清晰了。

🍓 5. 内建命令

wait我们发现,对于|管道和>重定向是无法处理的,因为当前代码没有组合设置,我们实现的其实是一个相当简陋的shell。然而震惊的是,我们 cd.. 路径没有回退?!这是因为执行回退的是子进程,并非是父进程bash.

fork要执行的命令是第三方命令,对于cd,现在我们不想再执行第三方命令,以内建命令方式运行(即不创建子进程,让父进程shell自己执行),实际上相当于调用了自己的一个函数。更改当前进程路径,有一个系统调用接口chdir ——

于是我们在添加一个检测是否要执行内建命令,我们这里只做的简单的匹配,且执行了内建命令,直接continue继续解析.

💜 shell完整代码

附:mini_shell.c

#include<stdio.h>
  #include<stdlib.h>
  #include<string.h>
  #include<sys/wait.h>
  #include<unistd.h>
  
  #define NUM 128                                                                                                                                             
  #define CMD_NUM 64
  
  int main()
  {
    char command[NUM];
    char* argv[CMD_NUM] = {NULL};//未设置就是NULL值
    for(;;)
    {
      command[0]=0;//这种方式可以做到,时间复杂度O(1),清空字符串
      //1.打印提示符
      printf("[you-know-who@myhostname mydir]$ ");
      fflush(stdout); 
      
      //2.获取命令行
      fgets(command, NUM, stdin);
      //printf("echo %s\n", command);// ls -a -l\n\0
      command[strlen(command)-1] = 0;
      
      //3.解析命令行字符串,argv[] - strtok
      const char* sep = " ";
      argv[0] = strtok(command, sep);
      int i = 1;
      while(argv[i] = strtok(NULL, sep))
      {
        i++;
      }
        
   	  if(strcmp(argv[0], "cd")==0)
      {
        if(argv[1])
          chdir(argv[1]);
        continue; //
      }
  
      //5.执行第三方命令
      if(fork()==0)
      {
        //child
        execvp(argv[0], argv);
        exit(1);
      }
  
      waitpid(-1, NULL, 0);
    }
    return 0;
  }

持续更新~@边通书

Logo

更多推荐