进程控制 | 手写shell实现

文章目录

Linux学习专栏

进程创建

fork创建进程

在进程学习章节我们也讲了,fork使用来创建子进程的,如果是子进程返回0,父进程返回子进程的ID。

写实拷贝

当我们父进程使用fork创建子进程的时候,子进程默认其实是拷贝父进程的进程地址空间与页表。所以子进程的数据其实指向的是父进程数据的地址,但是进程之间是相互独立的,当我们子进程想要修改数据的时候会发生写实拷贝。那么操作系统是如何知道子进程要修改数据的呢?

如上图,在父进程创建子进程的时候,其实页表中所有的权限会变为只读,所以子进程拷贝的时候权限都是只读的,当我们子进程进行修改的时候会抛异常,那么此时操作系统就需要判断到底是程序写错了,还是子进程需要修改数据了。如果程序错了就会杀掉进程,如果是子进程想要修改数据则进行写实拷贝。

所以我们父进程创建子进程再到子进程修改数据的流程为:父进程修改权限->子进程拷贝->子进程在自己地址执行修改操作->父进程恢复原来的权限。

那么子进程为什么需要拷贝呢 🌰:子进程对父进程的 v a l val val数据进行 v a l + + val++ val++,所以子进程是要拷贝父进程的数据。

进程终止

在我们c/c++程序中,我们每次main函数都有一个return 0那么这个是干什么用的呢?

我们父进程创建子进程都是带有目的的,我们需要让子进程去帮我们完成某一个任务,那么父进程如何直到子进程是否帮我们完成任务了呢?

退出码: 返回程序运行的结果给父进程。一般而言,我们的退出码0表示运行成功,非零表示运行放生错误。

如上图,我们可以通过echo $?查看最近一个进程 的退出码,至于为什么第二次返回0是因为我们echo也是一个进程,运行成功了就返回的是0。

那么进程有几种终止方式呢?

  1. main函数return返回
  2. exit 终止进程
  3. _exit 终止进程

exit 和 _exit的区别

这里先来看一下exit是什么?

如上图,exit使我们C语言提供一个终止进程的库函数。status表示我们终止进程之后返回的退出码。

如上图,我们exit无论实在main里面还是其他函数里面都是直接终止进程,不会再依次按照栈帧去退出。

如上图,当我们换成_exit的时候发现输出结果一致,那么他们两个的区别是什么呢?

如上图,可以得出我们exit会刷新缓冲区,而_exit不会刷新。

这里有一个问题?我们平常所说的缓冲区在哪?

如上图,我们操作系统提供了一系列的系统调用接口,而上面还有一些包装系统调用的软件层,例如我们c标准库(glibc)。而我们的exit和printf都是我们c标准库提供的,我们知道'\n'会刷新我们的缓冲区,所以我们平常所说的缓冲区都是语言级缓冲区 。那么对于exit来说由于是语言提供的所以实现的时候可能会有fflush(刷新缓冲区)的功能,所以我们会把数据刷新到OS中,然后再有OS到屏幕上,而_exit是系统调用接口,他根本没法去刷新缓冲区,会直接终止进程。

所以exit_exit的区别是:前者 是语言层面提供的,带有刷新语言缓冲区的功能,后者是系统调用接口,只能终止进程,无法去刷新语言缓冲区。

进程等待

在我们进程学习文章提到过僵尸进程,当我们子进程退出的时候,并不会直接退出销毁空间就完事了,还需要让父进程来回收子进程的退出状态,只有这样子进程才可以完全销毁空间,如果我们子进程运行完之后但是父进程没有回收它的退出信息,这样的状态我们就成为僵尸进程(zone)。如下图:

之前我们只谈到了僵尸进程是啥,现在我们来谈一谈如何回收僵尸进程。

我们需要了解两个函数:

两个函数都是用来回收子进程的,先来看一下现象:

如上图,我们子进程被父进程回收了,不过在我们父进程等待子进程被回收的过程当中,我们父进程处于阻塞状态 。不过我们创建子进程是希望子进程完成我给他的任务的,我们wait只能回收子进程,却并不知道他完成的怎么样了。这里我们传入nullptr就是不关系它的退出码,同时无论是wait还是waitpid等待成功了就返回子进程的ID,否则返回-1。如果我们想要知道子进程运行的结果如何就需要使用waitpid()

waitpid()有三个参数:

pid: 如果pid == -1,表示我们父进程等待任意一个子进程都可以,如果我们需要等待指定子进程,只需要传入子进程的pid值即可。

status: 他表示我们子进程的退出信息,他是一个32位bit位的整数,注意他并不单独表示我们进程的退出码。

options:一组标志,用于控制等待行为。

我们先来看一个程序:

如上图,我们发现明明我们子进程的退出码范围的是1,为什么打印出来的是256呢?在这个status32位bit位的整数中,我们的第8位~16位bit位才表示我们子进程的退出码,所以我们可以写出如下代码来获取我们子进程的退出码:

重谈进程退出

进程退出有三种形式:

  1. main函数正常return。
  2. exit终止进程返回退出码
  3. 程序异常退出

上面两种我们已经谈过,接着我们来谈一下第三种程序异常退出。

如上图,我们在子进程中故意写一个访问空指针的错误,那么此时我们子进程就会直接结束,在我们父进程等待子进程的过程中我们的status表示子进程的退出信息,同时低7位(0~6)表示退出信号 ,它表示我们程序是正常退出还是发生异常退出的。访问空指针我们的异常信号为11,也叫做段错误

如上图,所以我们要判断一个子进程的运行结果分为两步:1. 是否正常退出 2. 根据退出码判断结果是否正确。

见一下子进程完成任务

这里我们举个例子见一下子进程要完成父进程交代的任务,如下段代码:

cpp 复制代码
vector<int> data;
const string gsep = " ";
//测试子进程完成父进程交代的任务,每隔10秒中保存数据到不同文件当中
enum                                                                                                     {                                                                                                    
    OK = 0,
    OPEN_FILE_ERROR
};                       
int SaveBegin()
{
    string name(std::to_string(time(NULL)));
    name += ".backup";
    FILE* pf = fopen(name.c_str(),"w");
    if (pf == nullptr) return OPEN_FILE_ERROR;
    string str;       
    //把vector中的数据转化为字符串再以空格分隔
   for (auto& d : data)                
   {                                          
        str += std::to_string(d);
        str += gsep;   
   }
   //把字符串输入到文件当中
   fputs(str.c_str(),pf);
    
   fclose(pf);
                         
    return OK;
}  
void Save()   
{                                                                                                    
    //让子进程去保存
    pid_t id = fork();
    if (id == 0)
    {               
        int code = SaveBegin();
        exit(code);
    }
    int status;
    pid_t w = waitpid(id,&status,0); //option = 0,代表阻塞等待
    if (w > 0)
    {
        int flg = WIFEXITED(status); //如果正常exit退出返回真
        if (flg)
        {
           printf("保存成功,exit code: %d\n", WEXITSTATUS(status)); //打印退出码
        }
        else{
           printf("保存失败,exit code: %d\n", WEXITSTATUS(status)); 
        }
    }
}
int main()
{
    int cnt = 1;
    while(true)
    {
        data.push_back(cnt++);
        sleep(1);
        if (cnt % 10 == 0)
        {
            Save();
        }
    }
    return 0;
}

如上段代码,我们可以看到此时我们的子进程帮助我们对数据进行每隔10秒保存到不同文件,同时文件是以时间戳命名。

阻塞等待与非阻塞等待

接下来我们来谈一下第三个options参数。

这里我们就谈两种:

  1. options = 0

当我们这个参数传入0的时候,就代表着阻塞等待,就是我们父进程此时就一直等着子进程终止直到返回退出信息,啥事也不干。

  1. options = WNOHANG

同时这里我们waitpid()的返回值还不一样,我们以w表示此时waitpid()的返回值:

  • w > 0,表示等待子进程成功,并且子进程已经退出
  • w == 0,表示等待子进程成功,但是子进程还没有退出
  • w < 0,等待失败。

如上图,这里和我们传入0的区别就是:我们在等待子进程的时候还可以去做会自己的事情,但是我们每隔一段时间都会再去等待,直到子进程退出。所以WNOHANG是循环等待。

进程替换

我们能不能在一个进程中去执行一个新的程序呢?

这里我们一共有七个函数,我们依次来见识一下:

  • P: 提供PATH环境变量,所以只要有p我们替换指令的时候就不需要传入详细地址
  • e: 传入父进程的环境变量

单进程替换

如上图,这里再说一下我们可变参数列表最后必须要有一个空指针结尾。那么究竟是如何做到的呢?

当我们在原有进程执行另一个新的程序的时候其实就是把新程序的代码和数据覆盖原有进程的代码和数据。同时需要注意这并不是创建一个新的进程,进程ID还是原有的ID。

验证两个问题:

  1. 是否创建新进程?
  2. excel的返回值?

如上图,我excel并不是创建一个新的进程,而只是用新程序的代码和数据覆盖原有进程的代码和数据。

对于excel的返回值,如果excel运行成功则不返回,运行失败则返回-1。

多进程替换

多进程替换也是我们shell的原理,我们知道shell其实也是一个进程,那么他是如何通过我们用户的输入来执行其他程序呢?

就是创建子进程,然后让子进程execl去执行指定的程序。

认识全部接口

上述我们用了execl

  1. execv

execvexecl的区别就在于,我们前者是把操作放入一个指针数组当中后者则是分开传参。可以这么记忆:v: vector,所以我们需要传入一个数组,l: list,代表我们要把参数分开传递。

  1. execlp

第一个参数我们传入的我们要执行的程序名称,然后再传入我们如何执行,因为我们PATH的原因,所以我们 " l s " "ls" "ls"不需要带路径,但是对于我们当前的other需要加上当前目录( . / ./ ./)。

  1. execvp

后面参数写入一个数组当中,如果我们想要不带路径执行自己的程序则需要把当前路径加入到环境变量当中。

execvpeexecve


对于环境变量而言我们也可以自己创建环境变量并传入。

除了execve上述我们所有的都是C语言库封装的函数,本质都是去调用execve这个系统调用接口。C语言库为我们封装的函数提供了更多种的调用方式。

手写简易版shell

下面模拟的环境是Ubuntu24.04

实现分为以下几个步骤:

  1. 输出命令行提示符 dpj@iZ2zee7b26b1g3ujcquk70Z: ~/linux_code/ShellBlog/shell_blog$
  2. 用户在命令行中输入
  3. 解析命令行形成argv表
  4. 执行程序

1. 输出命令行提示符

可以看到正常情况下,我们是由 用户名 + "@" + 主机名 + ":" + 路径 组成的。那么我们如何得到这些信息呢?

如上图,我们的主机名和用户名都可以在环境变量当中获取(没有就export😊),而路径我们使用getcwd()来获得我们当前的工作路径,接下来就是拼接字符串了。

⚠️: 这里在我的系统当中,当我们在家目录的父目录之上的时候,会显示全路径,而在家目录及其子目录时,前面的路径会变为 ~ 。

处理代码:

这里我把shell封装到一个类当中了,也可以不用封装,第一次写的就不要封装了,直接全部写一个main.cc里面。

cpp 复制代码
void GetCmd()
    {
        std::string user = getenv("USER");
        //我的系统默认是没有HOSTNAME的环境变量的,所以我这样特殊判断一下。
        std::string hostname = getenv("HOSTNAME") == nullptr ? "NONE" : getenv("HOSTNAME"); /
        char buffer[64];
        getcwd(buffer,sizeof(buffer)); //获取当前的工作路径
        std::string cwd = buffer;
        std::string t = "/home/" + user;
        auto pos = cwd.find(t); //我们pos返回的是 "/home/user" 的第一个位置,'/'的位置
        if (pos >= 0 && pos < cwd.size()) //处理
        {
            //    /home/dpj/code -以这个为例子
            std::string tmp = cwd.substr(pos+t.size()); //得到 /code 
            cwd = "~" + tmp;
        }
        //dpj@iZ2zee7b26b1g3ujcquk70Z:~/linux_code/code/ShellBlog$
        _cmdline = user + "@" + hostname + ":" + cwd + "# "; //用"#"和"$"区分
    }    

2. 处理分析指令形成argv表

什么是内键命令:不能通过程序替换达到效果的。

🌰: 我们手写shell执行命令的时候会创建一个子进程,让子进程去执行,这样对我们父进程不产生影响。但是对于cd + 路径这样的命令来说,我们就需要对当前父进程产生影响,所以这种命令只能让我们的父进程自己去执行,需要特殊处理。

cpp 复制代码
    void ParseCmd(std::string& message)
    {
        if (message.size() == 0) return;
        //"ls -a -b -c -d -f"
        size_t pos = 0;
        while(pos < message.size())
        {
            while(message[pos] == ' ' && pos < message.size()) pos++; //跳过空格
            int p1 = message.find(" ",pos+1); //从当前位置的下一个位置往后找
            if (pos >= message.size()) break;
            std::string tmp = message.substr(pos,p1-pos); // ls' ', p1指向' ',pos指向'l' 
            if (tmp == "cd") _IsInnerKey = true;
            argv[argc] = (char*)malloc(tmp.size()+1); //初始化后面进程替换所需要的数组当中
            strcpy(argv[argc],tmp.c_str());
            argv[argc][tmp.size()] = 0; //字符串结尾'\0'
            argc++;
            pos = p1; //跳过已经处理过的命令
        }
        argv[argc] = nullptr; //最后要置空
        _IsInnerKey = _IsInnerKey && (argc==2); //这里内键命令我只处理的cd命令
    }

3. 执行程序

执行程序的时候我们创建一个子进程,让子进程帮我们执行代码

cpp 复制代码
    void Excute()
    {
        pid_t fd = fork();
        if (fd == 0) //子进程
        {
            int ret = ::execvpe(argv[0],argv,env);
            if (ret < 0) 
            {
                std::cout << "command not Found!!" << std::endl;
                exit(1);
            }
            exit(0);
        }
        //等待子进程
        pid_t wid = ::waitpid(fd,nullptr,0); //阻塞等待
        if (wid > 0)
        {
            // std::cout << "等待子进程成功" << std::endl;
        }
    }

⚠️注意:这里我们最好自己写一个环境变量表,现在我是用的是c++写的,在导入char** environ的时候,会报错与C语言的冲突了,所以我在自己代码中定义了自己的环境变量表,同时一开始进行初始化。初始化的时候导入char** environ没问题😓。同时只有我们正确的导入环境变量我们在自己的shell中 ls -al --color才会显示颜色。

代码实现:

cpp 复制代码
char* env[SIZE];
void InitialEnv()
{
    extern char** environ;
    int i = 0;
    while(environ[i] != nullptr)
    {
        int len = strlen(environ[i]);
        env[i] = (char*)malloc(len+1);
        strcpy(env[i],environ[i]);
        env[i][len] = 0;
        i++;
    }
    env[i] = nullptr;
}

效果演示

我实现的还是比较简单的,比如 export(env) 也是内键命令。感兴趣的话可以自己实现😊。

总代码:

cpp 复制代码
//shell.hpp
#pragma once 

#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <cstring>
#include <sys/wait.h>
#define SIZE 1024

class Nocpy
{
public:
    ~Nocpy(){}
    Nocpy(){}
private:
    Nocpy(const Nocpy& n){}
    Nocpy& operator=(const Nocpy& n)
    {return *this;}
};

class Shell : public Nocpy
{
public:
    Shell(){}
    ~Shell(){}
    void Start() 
    {
        InitialEnv(); //初始化环境变量
        while(true)
        {
            GetCmd(); //获取提示符
            std::cout << _cmdline;
            fflush(stdout);
            std::string _cmdmess;
            getline(std::cin,_cmdmess);
            ParseCmd(_cmdmess);
            // for (int i = 0; i < argc; i++) std::cout << argv[i] << std::endl;
            if (argc > 0)
            {
                if (_IsInnerKey) //内建命令
                {
                    HandlerInner();
                }
                else  //普通命令
                {
                    Excute();
                }
            }
            argc = 0;
            _IsInnerKey = false;
        }
    }
private:
    std::string _cmdline = "";
    char* argv[SIZE];
    char* env[SIZE];
    int argc = 0;
    bool _IsInnerKey = false;
    void ParseCmd(std::string& message)
    {
        if (message.size() == 0) return;
        //"ls -a -b -c -d -f"
        size_t pos = 0;
        while(pos < message.size())
        {
            while(message[pos] == ' ' && pos < message.size()) pos++; //取出开头
            int p1 = message.find(" ",pos+1);
            if (pos >= message.size()) break;
            std::string tmp = message.substr(pos,p1-pos);
            if (tmp == "cd") _IsInnerKey = true;
            argv[argc] = (char*)malloc(tmp.size()+1);
            strcpy(argv[argc],tmp.c_str());
            argv[argc][tmp.size()] = 0;
            argc++;
            pos = p1;
        }
        argv[argc] = nullptr;
        _IsInnerKey = _IsInnerKey && (argc==2);
    }
    void InitialEnv()
    {
        extern char** environ;
        int i = 0;
        while(environ[i] != nullptr)
        {
            int len = strlen(environ[i]);
            env[i] = (char*)malloc(len+1);
            strcpy(env[i],environ[i]);
            env[i][len] = 0;
            i++;
        }
        env[i] = nullptr;
    }
    void Excute()
    {
        pid_t fd = fork();
        if (fd == 0) //子进程
        {
            int ret = ::execvpe(argv[0],argv,env);
            if (ret < 0) 
            {
                std::cout << "command not Found!!" << std::endl;
                exit(1);
            }
            exit(0);
        }
        //等待子进程
        pid_t wid = ::waitpid(fd,nullptr,0); //阻塞等待
        if (wid > 0)
        {
            // std::cout << "等待子进程成功" << std::endl;
        }

    }
    void HandlerInner()
    {
        if (argc == 2 && strcmp(argv[0],"cd") == 0)
        {
            int ret = chdir(argv[1]);
            if (ret < 0)
            {
                std::cout << "No such directory!!" << std::endl;
                return;
            }

        }
        else 
        {
            //
        }
    }
    void GetCmd()
    {
        std::string user = getenv("USER");
        std::string hostname = getenv("HOSTNAME") == nullptr ? "NONE" : getenv("HOSTNAME");
        char buffer[64];
        getcwd(buffer,sizeof(buffer));
        std::string cwd = buffer;
        std::string t = "/home/" + user;
        auto pos = cwd.find(t);
        if (pos >= 0 && pos < cwd.size()) //处理
        {
            //    /home/dpj/code
            std::string tmp = cwd.substr(pos+t.size()); //得到 /code
            cwd = "~" + tmp;
        }
        //dpj@iZ2zee7b26b1g3ujcquk70Z:~/linux_code/code/ShellBlog$
        _cmdline = user + "@" + hostname + ":" + cwd + "# "; //用"#"和"$"区分
    }    
};
相关推荐
deeper_wind8 小时前
k8s-容器化部署论坛和商城服务(小白的“升级打怪”成长之路)
linux·运维·容器·kubernetes
勇往直前plus8 小时前
VMware centos磁盘容量扩容教程
linux·运维·centos
政安晨9 小时前
Ubuntu 服务器无法 ping 通网站域名的问题解决备忘 ——通常与网络配置有关(DNS解析)
linux·运维·服务器·ubuntu·ping·esp32编译服务器·dns域名解析
路溪非溪11 小时前
嵌入式Linux驱动开发杂项总结
linux·运维·驱动开发
Neolock11 小时前
Linux应急响应一般思路(三)
linux·web安全·应急响应
被遗忘的旋律.12 小时前
Linux驱动开发笔记(七)——并发与竞争(上)——原子操作
linux·驱动开发·笔记
轻松Ai享生活12 小时前
minidump vs core dump
linux
轻松Ai享生活13 小时前
详细的 Linux 常用文件系统介绍
linux
张童瑶14 小时前
Linux 离线安装lrzsz(rz、sz上传下载小插件)
linux·运维·centos
十五年专注C++开发14 小时前
通信中间件 Fast DDS(二) :详细介绍
linux·c++·windows·中间件·fastdds