Linux(五) 进程控制

本文详细探讨了进程创建中的fork机制,以及写时拷贝如何确保数据独立性。进一步讲解了进程终止的常见方式、判定结果、操作系统处理以及代码示例。涉及进程等待的wait/waitpid函数和其内部实现,以及进程程序替换的exec*函数和minishell的实现。
摘要由CSDN通过智能技术生成

目录

一、进程创建

1.fork

2.写时拷贝

二、进程终止

1.进程终止的常见方式

1.1怎么判断结果正确

1.2 结果错误时返回什么

1.3 程序崩溃

2.进程终止时,操作系统做了什么

3.用代码如何终止掉一个进程

3.1 方法

3.2 exit和_exit

三、进程等待

1.是什么

2.为什么

3.怎么办

3.1 wait/waitpid

3.1.1 status 

3.1.2 option(阻塞等待和非阻塞等待)

3.1.3 waitpid 内部实现 

4.几个宏定义

四、进程程序替换

1.是什么

2.为什么

3.怎么办

3.1 exec*函数

3.2 使用接口函数

3.2.1 execl

3.2.2 execv

3.2.3 execlp 

3.2.4 execvp

3.2.5 execle

3.2.6 execvpe

3.3 系统调用

五、minishell

5.1 了解一下shell

5.2 minishell的实现


一、进程创建

1.fork

进程 = 内核数据结构 + 进程代码和数据 

fork之后,进程进入内核态,执行fork的代码,创建子进程,那么OS内核是怎么创建子进程的呢?

首先,需要给子进程分配对应的内核数据结构(为了保证进程间的独立型,必须每个进程独有一份)

第二,将父进程部分内核数据结构的内容拷贝到子进程的内核数据结构。

然后,将子进程添加到系统进程列表中

最后,开始调度器调度(注意,父子进程谁先执行由调度器决定

理论上,子进程也要有自己的代码和数据,但是创建子进程并没有加载的过程,所以子进程只能“使用”父进程的代码和数据,子进程的代码和数据与父进程相同。

代码段:一般只有读取权限没有修改权限,所以可以共享

数据段:可能会被修改,所以必须分离。

内核数据结构拷贝了一份,映射到相同的物理内存

当子进程要修改数据时,此时系统会发生写时拷贝,把要修改的数据在物理内存重新开辟空间并通过页表映射到虚拟地址空间。

子进程会把拷贝父进程内核数据结构的什么内容? 

  1. 内存映像(Memory Image):子进程会复制父进程的内存映像,包括代码段、数据段和堆栈。这样,子进程就可以在其自己的地址空间中执行,而不会影响父进程或其他进程。

  2. 文件描述符表(File Descriptor Table):子进程会复制父进程的文件描述符表。这样,子进程就可以继承父进程打开的文件描述符,可以继续读写这些文件或套接字,而无需重新打开。

  3. 进程上下文(Process Context):子进程会复制父进程的进程上下文,包括进程标识符(PID)、信号处理器、进程组和会话 ID 等信息。这样,子进程就可以在独立的进程上下文中执行。

  4. 资源限制和权限(Resource Limits and Permissions):子进程会继承父进程的资源限制和权限设置,例如打开文件的最大数量、CPU 时间限制等。

fork之后,父子进程代码共享,是所有的还是after之后的?

 是所有的都共享。

那子进程从哪里开始执行?

从fork之后,因为CPU内有一种寄存器用来存放当前指令在内存中的地址(EIP寄存器,pc寄存器与EIP功能一样,只是在不同体系结构中的不同叫法)。我们的进程可能随时被中断,下次回来时必须从之前的位置继续运行,就需要CPU必须随时记录下当前进程执行的位置,所以CPU内有对应的寄存器来记录当前执行的位置。CPU的寄存器只有一份,但是寄存器数据可以有很多份。 寄存器的数据可以称作为进程的上下文数据。进程的上下文数据是需要拷贝一份给子进程的,此时子进程认为自己的EIP起始值就是fork之后的代码。

进程的上下文数据都包括什么?

  1. 程序计数器(Program Counter,PC):指向下一条要执行的指令地址。

  2. 寄存器的内容:包括通用寄存器(如 x86 架构中的 EAX、EBX、ECX、EDX 等)、栈指针(Stack Pointer,SP)、基址指针(Base Pointer,BP)等。

  3. 内存管理信息:包括页表、段表等,用于描述进程的内存布局和内存访问权限。

  4. 打开文件描述符表:描述了进程当前打开的文件或者网络连接。

  5. 信号处理器状态:包括当前信号掩码、待处理信号队列等。

  6. 进程 ID 和父进程 ID:用于唯一标识进程及其父进程。

  7. 进程状态:描述了进程当前的状态,如运行、就绪、阻塞等。

  8. 调度信息:包括进程的优先级、时间片大小等调度相关的信息。

  9. 栈和堆的状态:描述了进程的栈空间和堆空间的分配情况。

  10. 环境变量和命令行参数:描述了进程的运行环境和启动参数。

2.写时拷贝

写时拷贝是一种延迟复制技术,在这种技术下,当多个进程或线程共享同一份数据时,只有在其中一个进程或线程尝试修改数据时,才会真正复制数据。在修改之前,所有进程或线程都共享相同的数据。这样可以节省内存空间,并且减少了不必要的数据复制。

为什么使用写时拷贝?

1.用的时候再给你分配,是高效使用内存的表现

2.OS不知道哪份数据会被修改 

因为有写时拷贝的存在,父子进程的以彻底分离,保证了进程的独立性。

二、进程终止

1.进程终止的常见方式

a.代码跑完结果正确

b.代码跑完结果错误

c.代码跑完程序崩溃

1.1怎么判断结果正确

main函数的返回值的意义是什么?

返回给上一级进程,用来评判执行结果。

return 0; 的含义是什么?为什么总是0?

0是退出码,返回0代表告诉上一级代码跑完结果正确。如果不是0,就代表结果错误,不同的非0值代表不同的错误。

1.2 结果错误时返回什么

通过 echo $? 可以获取最近一个退出进程的退出码。

下面的代码可以获取系统设置的退出码

#include <iostream>
#include <string.h>
#include <cstdio>
using namespace std;

int main()
{
    for(int i = 0;i < 150;i++)
    {
        printf("error[%d]:%s\n",i,strerror(i));
    }

    return 0;
}

我们可以自己设置一套退出机制,退出码 

1.3 程序崩溃

程序崩溃时退出码没有意义,因为执行不到return语句。

2.进程终止时,操作系统做了什么

释放进程申请的相关数据结构和对应的数据和代码。本质就是释放系统资源。

3.用代码如何终止掉一个进程

3.1 方法

在main函数内可以使用return语句退出整个程序,但在其余函数只能用于在该函数返回一个值,并将控制权交还给调用它的函数。

而exit语句在任何地方都可以用来退出整个程序。

_exit为系统接口。

3.2 exit和_exit

通过下列代码的结果

int main()
{
    printf("代码开始了\n");
    printf("aaaaaaaaaa");
    sleep(3);
    printf("代码结束了");

    _exit(1);
 
}

 

int main()
{
    printf("代码开始了\n");
    printf("aaaaaaaaaa");
    sleep(3);
    printf("代码结束了");

    exit(1);
 
}

 

为什么使用exit和_exit会有不同的结果呢?

我们知道printf的数据是会先保存在“缓冲区”中的,那么有几个问题,这个“缓冲区”在哪里?是由谁维护的?

首先一定不在操作系统内部,如果是在操作系统内部,_exit一定也能刷新出来。

所以就只能是C标准库给我们维护的。

三、进程等待

1.是什么

通过系统接口(wait/waitpid),让用户等待子进程的一种方案

2.为什么

获知子进程运行结果和回收子进程资源

3.怎么办

3.1 wait/waitpid

 先介绍waitpid的几个参数

pid:

 pid > 0 : 等待进程ID与pid相等的子进程

 pid = -1:等待任意一个子进程,与wait等效

3.1.1 status 

status:输出型参数,返回等待的子进程的执行状态信息 

statu并不是按照整数整体使用,而是按bit位进行使用,我们只介绍低16位

通过上述位操作代码,可以获取次低八位和低七位的值,也可获得退出码和信号值

其实core dump为是否发生核心转储的标志,核心转储的文件core.xxx可以用于gdb调试

程序异常退出或者崩溃本质是操作系统杀掉了你的进程,是操作系统通过想进程写入信号的方式杀掉的进程,当进程被信号杀掉时,我们只关心是几号信号杀掉的,不关心退出码,因为此时根本没有运行到exit和return。

3.1.2 option(阻塞等待和非阻塞等待)

option默认为0,代表阻塞等待,为1代表非阻塞等待。

这里的0或者1我们称为魔法数字,因为我们无法通过这个数字获取有效的信息。

我们使用宏定义 #define WNOHANG 1 定义1的含义。

HANG(夯住了)即软件在运行时卡住不动了,在系统层面,这个进程没有被CPU调度,要么是在阻塞队列中,要么是在排队等待被调度(没有被CPU调度的原因很多)

WNOHANG就是WAIT NO HANG 不卡住等待

即我们使用waitpid等待子进程返回,如果子进程此时并没有结束,waitpid立马返回,不继续阻塞等待,这就叫非阻塞等待。

3.1.3 waitpid 内部实现 

 阻塞等待是在操作系统内核中等待,也就是在系统调用函数的内部,阻塞等待伴随着进程被切换,即CPU执行别的进程,此时进程好像卡住了(HANG住了)。

scanf和cin里也必定有系统调用,我么在没有输入时系统好像卡住了。

4.几个宏定义

WIFEXITED(status) 检查子进程是否退出,返回一个非零值表示进程正常退出,否则返回0

#define WIFEXITED(status) (((status) & 0xff) == 0)

这个宏会检查 status 中的低 8 位,如果为 0 则表示子进程正常退出。使用这个宏可以方便地判断子进程的退出状态,而无需直接操作 status

 WEXITSTATUS(status)用于获取子进程的退出状态码

#define WEXITSTATUS(status) (((status) >> 8) & 0xff)

 WIFSIGNALED(status)用于检查子进程是否因为信号终止的宏

#define WIFSIGNALED(status) (((status) & 0x7f) && !WIFSTOPPED(status))

这个宏会检查 status 中的低 7 位是否非零,并且同时检查是否子进程不是被停止(stopped,SIGSTOP)而是被信号终止。如果满足这两个条件,则返回真,表示子进程因为信号而终止。

WTERMSIG(status):获取导致子进程终止的信号编号。如果子进程因为信号终止,则返回导致终止的信号编号。

WIFSTOPPED(status):检测子进程是否处于停止状态。如果子进程处于停止状态,则返回非零值。

WSTOPSIG(status):获取导致子进程停止的信号编号。如果子进程处于停止状态,则返回导致停止的信号编号。

四、进程程序替换

1.是什么

进程程序替换就是要子进程执行一个全新的程序,子进程拥有一个全新的代码。

子进程执行一个新的程序无论出现任何事情都不会影响父进程,父进程只会进行结果的回收与分析

程序替换,是通过特定的接口,加载磁盘上一个全新的程序(代码和数据),加载到调用进程的地址空间中。

所谓的exec*函数,本质就是如何加载程序的函数

OS内核部分几乎不发生变化,只是把磁盘中的可执行程序加载到物理内存,并且修改页表中的映射,然后再初始化新进程的堆栈。

当子进程加载新程序时不就是一种“写入”吗,此时代码要写时拷贝,父子代码分离

2.为什么

和应用场景有关,我们有时候必须让子进程执行新的程序

3.怎么办

3.1 exec*函数

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

这些函数在调用失败时会返回-1,调用成功无需返回。 

3.2 使用接口函数

3.2.1 execl

 

3.2.2 execv

 

 

3.2.3 execlp 

3.2.4 execvp

类似

3.2.5 execle

环境变量具有全局属性,父进程的环境变量可以从子进程继承。

可以设置环境变量,如果设置环境变量,那么你提供的环境变量数组将称为替换后程序的初始环境,使用execle替换进程后,你就完全控制了被替换后程序的环境变量,而不是从父进程继承了。

如果环境变量数组设置为nullptr,那么被替换后的进程还是会继承父进程的环境变量。

3.2.6 execvpe

类似

3.3 系统调用

int execve(const char *filename, char *const argv[],
                  char *const envp[]);

此为系统调用,上述exec*的六个函数底层都调用这个函数。 

五、minishell

5.1 了解一下shell

shell执行的命令通常有两种

1.第三方提供的对应的在磁盘上的有具体二进制的可执行程序(由子进程执行)(./test,ls,pwd)

2.shell内部,自己实现的方法,由自己(父进程)执行 cd,export

export只有由父进程执行,才能给所有子进程,如果只导给单一的一个子进程,那么环境变量也就不是全局的了。

shell代表的是用户

shell的环境变量是从哪来的呢?

前面文章已经说过,简单说一下,环境变量是写在配置文件中的,shell启动的时候,通过配置文件获得起始环境变量。

5.2 minishell的实现

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

#define NUM 1024
#define SIZE 100
#define SPA " "

// 保存完整的命令行字符串
char all_order[NUM];
// 保存拆分之后的命令行字符串
char* single_order[SIZE];
// 写一个环境变量的buffer用来测试
// 因为环境变量在把你的环境变量添加到系统环境变量里的时候
// 不是把你的长字符串拷贝到你对应的指针数组空间里
// 而是把这个字符串的地址添加到指针数组当中
// 我们的字符串是储存在all_order里的
// 在下一次循环(即输入下一条命令时)all_order被memset清空了
// 导致地址没变但里面的内容没有了
char g_env[100];
// shell 运行原理 :通过让子进程执行命令,父进程等待&&解析命令
int main()
{
    extern char** environ;
    // 0.命令行解释器,一定是一个常驻内存的进程,不退出
    while(1)
    {
        //1. 打印出提示信息 [dgz@localhost myshell]# 
        printf("[dgz@localhost myshell]# ");
        fflush(stdout);
        memset(all_order,'\0',sizeof all_order);
        //2. 获取用户的键盘输入[输入的是各种指令和选项: "ls -a -l -i"]
        if(fgets(all_order,sizeof all_order,stdin) == NULL)
        {
            continue;
        }
        all_order[strlen(all_order) - 1] = '\0';
        // 如果没有这一步all_order结尾会多带一个'\n',会导致execvp传入vector时多出一个'\n'指令
        // 而这个指令是无效的会造成错误。
        // printf("ehco:%s\n",all_order);
        //3. 将获取的命令分割成单个选项, 命令行字符串解析:"ls -a -l -i" -> "ls" "-a" "-i"
        single_order[0] = strtok(all_order,SPA);// 第一次调用传入原始字符串
        int index = 1;
        if(strcmp(single_order[0],"ls") == 0)
        {
            single_order[index++] = "--color=auto";
        }
        if(strcmp(single_order[0],"ll") == 0)
        {
            single_order[0] = "ls";
            single_order[1] = "-l";
            single_order[2] = "--color=auto";
        }
        while(single_order[index++] = strtok(NULL,SPA));// 第二次,如果还要解析原字符串,传入NULL
        if(strcmp(single_order[0],"export") == 0 && single_order[1] != NULL)
        {
            strcpy(g_env,single_order[1]);
            int ret =  putenv(g_env);
            if(ret == 0) printf("%s export success\n",single_order[1]);
            int i = 0;
           // for(i = 0;environ[i];i++)
           // {
           //     printf("%d:%s\n",i,environ[i]);
           // }
            continue;
        }
        // for debug
        //for(index = 0;single_order[index];index++)
        //{
        //    printf("single_order[%d]:%s\n",index,single_order[index]);
        //}
        //4. TODO,内置命令,让父进程(shell)自己执行的命令,我们叫做内置命令,内建命令
        //内建命令的本质其实就是shell中的一个函数调用
        if(strcmp(single_order[0],"cd") == 0)// 不是子进程执行,是父进程执行
        {
            if(single_order[0] != NULL)
            {
                chdir(single_order[1]);// cd path cd ..
            }
            continue;
        }
        
        //5.fork
        pid_t id = fork();
        if(id == 0)
        {
            printf("下面为子进程运行的\n");
            // 不是说好的程序替换会替换代码和数据吗?
            // 环境变量相关的数据会被替换吗?
            // 没有
            execvp(single_order[0],single_order);
            exit(1);
        }
        int status = 0;
        pid_t res = waitpid(-1,&status,0);
        if(res > 0)
        {
            printf("exit code: %d\n",WEXITSTATUS(status));
        }
        
    }

    return 0;
}



  • 53
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值