【Linux】-进程的控制(下)

🔑🔑博客主页:阿客不是客

🍓🍓系列专栏:深入代码世界,了解掌握 Linux

欢迎来到泊舟小课堂

😘博客制作不易欢迎各位👍点赞+⭐收藏+➕关注

一、进程替换 

之前我们通过写时拷贝,让子进程和父进程在数据上互相解耦,保证独立性。如果想让子进程和父进程彻底分开,让子进程彻彻底底地执行一个全新的程序,我们就需要 进程的程序替换

为什么要进行程序替换?因为我们想让我们的子进程执行一个全新的程序。

那为什么要让子进程执行新的程序呢?我们一般在服务器设计的时候(Linux 编程)的时候,往往需要子进程干两件种类的事情:

  • 让子进程执行父进程的代码片段(服务器代码…)
  • 想让子进程执行磁盘中一个全新的程序(shell、想让客户端执行对应的程序、通过我们的进程执行其他人写的进程代码、C/C++ 程序调用别人写的 C/C++/Python/Shell/Php/Java...)

1.1 进程替换原理

📃 程序替换的原理:

  • 将磁盘中的内存,加载入内存结构。
  • 重新建立页表映射,设执行程序替换,就重新建立谁的映射(下图为子进程建立)。
  • 效果:让父进程和子进程彻底分离,并让子进程执行一个全新的程序

这个过程有没有创建新的进程呢?没有!根本就没有创建新的进程!

因为子进程的内核数据结构根本没变,只是重新建立了虚拟的物理地址之间的映射关系罢了。内核数据结构没有发生任何变化! 包括子进程的 \textrm{pid}​ 都不变,说明压根没有创建新进程。

1.2 认识进程替换(execl 接口)

我们要调用接口,让操作系统去完成这个工作 —— 系统调用。如何进行程序替换?我们先见见猪跑 —— 从 execl 这个接口讲,看看它怎么跑的。

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

如果我们想执行一个全新的程序,我们需要做几件事情:

(要执行一个全新的程序,以我们目前的认识,程序的本质就是磁盘上的文件)

  • 第一件事情:先找到这个程序在哪里。
  • 第二件事情:程序可能携带选项进行执行(也可以不携带)。

明确告诉 OS,我想怎么执行这个程序?要不要带选项。

\Rightarrow​ 简单来说就是:① 程序在哪?  ② 怎么执行?

所以,execl 这个接口就必须得把这两个功能都体现出来!

  • 它的第一个参数是 path,属于路径。
  • 参数  const char* arg, ... 中的 ... 表示可变参数,命令行怎么写(ls, -l, -a) 这个参数就怎么填。ls, -l, -a 最后必须以 NULL 结尾,表示 "如何执行程序的" 参数传递完毕。

💬 代码演示:exec():

#include<stdio.h>
#include<unistd.h>

int main()
{
    printf("我是一个进程,我的PID是:%d\n", getpid());
    execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
    printf("我执行完毕了,我的PID是:%d\n", getpid());

	return 0;
}

🚩 运行结果如下:

刚才是带选项的,现在我们再来演示一下不带选项的: 

#include<stdio.h>
#include<unistd.h>

int main()
{
    printf("我是一个进程,我的PID是:%d\n", getpid());
    execl("/usr/bin/top", "top" , NULL);
    printf("我执行完毕了,我的PID是:%d\n", getpid());

    return 0;
}

🚩 运行结果如下:

这样我们的程序就直接能执行 top 命令了,除此之外,我们曾经学的大部分命令其实都可以通过 execl 执行起来。这就叫做 程序替换


不知道大家有没有发现问题?代码和输出结果有什么不对劲的地方?

printf("我执行完毕了,我的PID是:%d\n", getpid());

最后一句代码 ——  "我执行完毕了,我的PID是:%d" 似乎没有打印出来啊?

为什么我们最后的代码并没有被打印出来?

因为 一旦替换成功,是会将当前进程的代码和数据全部替换的!

所以自然后面的 printf 代码早就被替换了,这意味着该代码不复存在了,荡然无存!因为在程序替换的时候,就已经把对应进程的代码和数据替换掉了!而第一个 printf 执行了的原因自然是因为程序还没有执行替换。

这里的程序替换函数用不用判断返回值?为什么?

int ret = execl(...);

一旦替换成功,还会执行返回语句吗?返回值有意义吗? 没有意义的!

程序替换不用判断返回值!因为只要成功了,就不会有返回值,也不需要返回值。 而失败的时候,必然会继续向后执行。通过返回值最多能得到是什么原因导致替换失败的。只要执行了后面的代码,看都不用看,一定是替换失败了;只要有返回值,就一定是替换失败了。

我们来模拟一下失败的情况,我们来执行一个不存在的指令

💬 代码演示:

#include<stdio.h>
#include<unistd.h>

int main()
{
	printf("我是一个进程,我的PID是:%d\n", getpid());
    execl("/usr/bin/Atop", "ls" , NULL);
    printf("我执行完毕了,我的PID是:%d, ret: %d\n", getpid(), ret);
    
    return 0;
}

🚩 运行结果如下:

💡 说明:execl 替换失败,就会继续向后执行。但是,一旦 execl 成功后就会跟着新程序的逻辑走,就不会再 return 了,再也不回来了,所以返回值加不加无所谓了。


诶……子进程执行程序替换,会不会影响父进程呢?不会!因为进程具有独立性

为什么?如何做到的?子进程是如何做到代码和数据做分离的呢?让子进程与父进程做相似的代码片段,子进程改了,父进程也不受影响。

我们在前几章,讲过数据层面发生写时拷贝的概念。我们说过:fork 之后父子是共享的,如果要替换新的程序我能理解把新的程序的代码加载到内存里,我的子进程新的代码程序出来之后发生数据的写时拷贝,生成新的数据段。

不是说代码是共享的吗?我们该如何去理解呢?当程序替换的时候,我们可以理解成 —— 代码和数据都发生了写时拷贝,完成了父子分离。

1.3 更多 exec 接口

1. execlp 接口:无需带路径就能直接执行(可变参数列表)

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

execlp,它的作用和 execl 是一样的,它的作用也是执行一个新的程序。

仍然是需要两步:① 找到这个程序   ② 告诉我怎么执行

所以这一块的参数传递,和 execl 是一样的:

  • 第一个参数为字符指针,指向要执行的可执行文件的名称
  • 后续的参数为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。

唯一的区别是比 execl 多了一个 p!我们执行指令的时候,默认的搜索路径在环境变量 \textrm{PATH}​ 中,所以这个 p 的意思是环境变量。

这意味着:执行 execlp 时,会直接在环境变量中找,不用去输路径了,只要程序名即可。而 execlp 可以不带路径,只说出你要执行哪一个程序即可,例如:子进程要执行一个ls命令,并带上 -al 参数:

execlp("ls", "ls", "-a", "-l", "NULL");

值得一提的是:这里出现的两个 ls 含义是不一样的,是不可以省略的。第一个参数是 "供系统去找你是谁的",后面的代表的是 "你想怎么去执行它" 。

💬 代码演示:

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


int main()
{
	pid_t id = fork();
    if (id == 0) {
        // child 
        printf("我是子进程,我的PID是:%d\n", getpid());
        execlp("ls", "ls", "-a", "-l", NULL);
 
        exit(10);  // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
    }

	printf("我是父进程,我的PID是: %d\n", getpid());
	
	int status = 0;
    int ret = waitpid(id, &status, 0);
	// ret 接收的返回值不是exit的,而是子进程的pid
    if (ret == id)
	{
        sleep(2);  
        printf("父进程等待成功!\n");
    }

	return 0;
}

🚩 运行结果如下:

2. execv 接口:以指针数组接收参数的

int execv(const char* path, char* const argv[]);
  • 第一个参数为要执行程序的路径
  • 第二个参数为一个指向字符指针数组的指针,数组中的每个元素都是一个指向字符串的指针,这些字符串就是命令行参数。最后要以NULL结尾。

大家在命令行上 $ ls -a -l ,在 execl 里我们是这么传的: "ls", "-a", "-l", NULL 。所以 execv 和execl 只有传参方式的区别,一个是可变参数列表 (l),一个是指针数组 (v)。值得注意的是,在构建 argv[] 的时,结尾仍然是要加上 NULL!

char* myargv[] = { "ls", "-l", "-a" NULL };
execv("/usr/bin/ls", myargv);

💬 代码演示:

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


int main()
{
	pid_t id = fork();
    if (id == 0) {
        // child 
        printf("我是子进程,我的PID是:%d\n", getpid());

 		char* const argv_[] = {
            (char*)"-ls",
            (char*)"-l",
            (char*)"-a",
            NULL
        };
        execv("usr/bin/ls", argv_);

        exit(10);  // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
    }

	printf("我是父进程,我的PID是: %d\n", getpid());
	
	int status = 0;
    int ret = waitpid(id, &status, 0);
	// ret 接收的返回值不是exit的,而是子进程的pid
    if (ret == id)
	{
        sleep(2);  
        printf("父进程等待成功!\n");
    }

	return 0;
}

🚩 运行结果如下:

 3. execvp 接口:无需带路径(指针数组)

int execvp(const char* file, char* const argv[]);
  • 第一个参数为要执行程序的名称
  • 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。

看到这里,想必大家光看到这个接口的名字,就能猜到它是什么意思了。与 execlp 类似,execvp 也是带 p 的,执行 execvp 时,会直接在环境变量中中搜索可执行文件,只要程序名即可。

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

💬 代码演示:

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


int main()
{
	pid_t id = fork();
    if (id == 0) {
        // child 
        printf("我是子进程,我的PID是:%d\n", getpid());

 		char* const argv_[] = {
            (char*)"-ls",
            (char*)"-l",
            (char*)"-a",
            NULL
        };
        execvp("argv_[0]", argv_);

        exit(10);  // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
    }

	printf("我是父进程,我的PID是: %d\n", getpid());
	
	int status = 0;
    int ret = waitpid(id, &status, 0);
	// ret 接收的返回值不是exit的,而是子进程的pid
    if (ret == id)
	{
        sleep(2);  
        printf("父进程等待成功!\n");
    }

	return 0;
}

🚩 运行结果如下:

4. 超级缝合怪 execvpe 接口

缝合怪罢了,v - 数组,p - 文件名,e - 可自定义环境变量:

int execvpe(const char* file, char* const argv[], char* const envp[]);

 5. execle 接口:添加环境变量给目标进程

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

我们可以使用 execle 接口传递环境变量,相当于自己把环境变量导进去。

  • 第一个参数是要执行程序的路径
  • 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
  • 最后一个参数是你自己设置的环境变量

创建 mycmd 文件,我们加上几句环境变量:

#include <iostream>
#include <stdlib.h>
 
int main()
{
    std::cout << "PATH:" << getenv("PATH") << std::endl;
    std::cout << "--------------------------------------\n";
    std::cout << "MYPATH:" << getenv("MYPATH") << std::endl;
    std::cout << "--------------------------------------\n";
 
    std::cout << "hello c++" << std::endl;
    return 0;
}

其中,\textrm{PATH} 是自带的环境变量,\textrm{MYPATH} 是我们自己的环境变量:

💬 代码演示:

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


int main()
{
	pid_t id = fork();
    if (id == 0) {
        // child 
        printf("我是子进程,我的PID是:%d\n", getpid());

		char* const __env[] =
		{
            (char*)"MYPATH=123456789",
            NULL
        };
        execle("./mycmd", "mycmd", NULL, __env);  // 手动传递环境变量

        exit(10);  // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
    }

	printf("我是父进程,我的PID是: %d\n", getpid());
	
	int status = 0;
    int ret = waitpid(id, &status, 0);
	// ret 接收的返回值不是exit的,而是子进程的pid
    if (ret == id)
	{
        sleep(2);  
        printf("父进程等待成功!\n");
    }

	return 0;
}

🚩 运行结果如下:

为什么没有打印内容啊?

我们自己定义的 \textrm{MYPATH} 环境变量也没出来,我们先把 mycmd 的 \textrm{PATH} 注释掉,然后再运行:

#include <iostream>
#include <stdlib.h>
 
int main()
{
    // std::cout << "PATH:" << getenv("PATH") << std::endl;
    // std::cout << "--------------------------------------\n";
    std::cout << "MYPATH:" << getenv("MYPATH") << std::endl;
    std::cout << "--------------------------------------\n";
 
    std::cout << "hello c++" << std::endl;
    return 0;
}

🚩 运行结果如下:

所以,为什么会出现这种情况?我自己不定义自己的环境变量时,\textrm{PATH} 可以获取得到,一旦我传入了一个自己定义的环境变量时,\textrm{PATH} 就打不出来了?!

execle 接口,这个 e 表示的是 添加环境变量给目标进程,如果是自己的变量,那么是覆盖式的

6. 为什么会有这么多 exec 接口?

为什么搞这么多接口,这些接口好像没有太大的差别啊。

唯一的差别就是传参的方式不一样,有的带路径,有的不带路径,有的是列表传参,有的是数组传参,有的可带环境变量,有的不带环境变量。

因为要适配各种各样的应用场景,使用的场景不一样,有些人就喜欢列表传参,有些人喜欢数组传参。所以就配备了这么多接口,这就好比我们 C++ 函数重载的思想。 

那为什么 execve 是单独的呢?

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

它处于 man 2 号手册,execve 才属于是真正意义上的系统调用接口。而刚才介绍的那些,实际上就是为了适配各种环境而封装的接口:

总结一下它们的命名规律,通过这个来记忆对应接口的功能会好很多:

  • l (list) :表示参数采用列表形式
  • v (vector) :表示参数采用数组形式
  • (path):有 p 自动收缩环境变量 PATH
  • e (env) :表示自己维护环境变量

二、实现简易 shell 脚本

shell就是一个命令解释器,它互动式地解释和执行用户输入的命令;当有命令要执行时,shell创建子进程让子进程去执行命令,而shell只需要等待子进程执行完退出即可。

具体步骤:

  1. 获取终端输入的命令
  2. 解析命令
  3. 创建子进程
  4. 对子进程进行程序替换
  5. 等待子进程执行完后退出

2.1 显示提示符和获取用户输入

 shell 本质就是个死循环,我们不关心获取这些属性的接口我们先从简单的入手,先来实现前两步,显示提示符 和 获取用户输入

我们需要按照终端的格式打印出命令提示符,那么就需要获取用户、主机和当前路径等信息,这就需要用到我们之前学习的获取环境变量的函数 getenv() :

💬 代码演示:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>

// 用户名
const char *GetUserName()
{
	const char *name = getenv("USER");
	return name == NULL ? "None" : name;
}

// 主机名
const char *GetHostName()
{
	const char *hostname = getenv("HOSTNAME");
	return hostname == NULL ? "None" : hostname;
}

// 当前路径
const char *GetPwd()
{
	const char *pwd = getenv("PWD");
	return pwd == NULL ? "None" : pwd;
}

int main()
{
    // 显示提示符
	printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());

	return 0;
}

🚩 运行结果如下:


接下来是获取用户输入的内容:

我们用户输入一行本质上是一行完整的字符串,我们用 scanf 是以空格为分割符,而我们的指令是带空格的,我们利用 fgets 函数从键盘上获取,标准输入 stdin,获取到 C 风格的字符串,注意默认会添加 \0 。

但因为后续还会涉及到很多字符串的操作,我们将文件改成 C++ 的格式,同时也需要修改文件后缀为 .cc ,修改 makefile 中的文件名和 编译方式(gcc -> g++)。但因为一些接口适配性的问题,为了方便入门学习,我们这里不使用 string 类型和 getline 函数,依旧按照 C 语言风格来执行。

💬 代码演示:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>

#define COMMAND_SIZE 1024

// 用户名
const char *GetUserName()
{
	const char *name = getenv("USER");
	return name == NULL ? "None" : name;
}

// 主机名
const char *GetHostName()
{
	const char *hostname = getenv("HOST");
	return hostname == NULL ? GetUserName() : hostname;
}

// 当前路径
const char *GetPwd()
{
	const char *pwd = getenv("PWD");
	return pwd == NULL ? "None" : pwd;
}

int main()
{
	// 显示提示符
	printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
	
	// 获取用户输入
	char commandline[COMMAND_SIZE];
	char *res = fgets(commandline, COMMAND_SIZE, stdin);
	printf("%s\n", commandline);


	return 0;
}

🚩 运行结果如下:

为什么这最后有一行空行?因为我们输入最后按了回车,commandline 里有一个 \n,我们把它替换成 \0 即可:

 commandline[strlen(commandline) - 1] = '\0';  // 消除 '\n'

🚩 运行结果如下:

至此,我们已经完成了提示用户输入,并且也获取到用户的输入了。 

但是目前的代码有些许不美观,我们换成其他的方式:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>

#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"

// 用户名
const char *GetUserName()
{
	const char *name = getenv("USER");
	return name == NULL ? "None" : name;
}

// 主机名
const char *GetHostName()
{
	const char *hostname = getenv("HOST");
	return hostname == NULL ? GetUserName() : hostname;
}

// 当前路径
const char *GetPwd()
{
	const char *pwd = getenv("PWD");
	return pwd == NULL ? "None" : pwd;
}

void MakeCommandLine(char cmd_prompt[], int size)
{
	snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
// 输入命令行提示符
void PrintfCommandPrompt()
{
	char prompt[COMMAND_SIZE];
	// 将格式化内容(提示符行)输入进字符串prompt
	MakeCommandLine(prompt, sizeof(prompt));
	printf("%s", prompt);
	fflush(stdout);
}

//接收指令
bool GetCommandLine(char *out, int size)
{
	// ls -a -l => "ls -a -l\n"
	char *res = fgets(out, size, stdin);
	if(res == NULL)
		return false;
	out[strlen(out) - 1] = '\0';  // 消除 '\0'
	return strlen(out) == 0 ? false : true;
}

int main()
{
	// 1.输出命令行提示符
	// printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
	PrintfCommandPrompt();


	// 2.获取用户输入的命令
	// char commandline[COMMAND_SIZE];
	// // ls -a -l => "ls -a -l\n"
	// char *res = fgets(commandline, COMMAND_SIZE, stdin);
	// commandline[strlen(commandline) - 1] = '\0';  // 消除 '\0'
	// printf("%s\n", commandline);
	char commandline[COMMAND_SIZE];
	if(GetCommandLine(commandline, sizeof(commandline)))
	{
		printf("%s\n", commandline);
	}

	return 0;
}

其中值得注意的是,我们将命令提示符的内容使用 define 方便我们进行修改。

snprintf 是将格式化内容输入进字符串中,并给出字符串的起始地址和长度

🚩 运行结果如下:

2.2 将接收到的字符串拆开

我们系统读取指令肯定不是读取一个完整的字符串,而是拆分成一个个命令行参数,下面我们需要 将接收到的字符串拆开,比如:把 "ls -a -l" 拆成  "ls"  "-a"  "-l" 

我们可以使用 strtok 函数,将一个字符串按照特定的分隔符打散,将子串依次返回:

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

💬 代码演示:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>

#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"

// 下面是shell定义的全局数据
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;

// 用户名
const char *GetUserName()
{
	const char *name = getenv("USER");
	return name == NULL ? "None" : name;
}

// 主机名
const char *GetHostName()
{
	const char *hostname = getenv("HOST");
	return hostname == NULL ? GetUserName() : hostname;
}

// 当前路径
const char *GetPwd()
{
	const char *pwd = getenv("PWD");
	return pwd == NULL ? "None" : pwd;
}

void MakeCommandLine(char cmd_prompt[], int size)
{
	snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}

// 输入命令行提示符
void PrintfCommandPrompt()
{
	char prompt[COMMAND_SIZE];
	// 将格式化内容(提示符行)输入进字符串prompt
	MakeCommandLine(prompt, sizeof(prompt));
	printf("%s", prompt);
	fflush(stdout);
}

//接收指令
bool GetCommandLine(char *out, int size)
{
	// ls -a -l => "ls -a -l\n"
	char *res = fgets(out, size, stdin);
	if(res == NULL)
		return false;
	out[strlen(out) - 1] = '\0';  // 消除 '\0'
	return strlen(out) == 0 ? false : true;
}

// 命令行拆分
bool CommandParse(char *commandline)
{
	g_argc = 0;
	g_argv[g_argc++] = strtok(commandline, " ");
	while(g_argv[g_argc++] = strtok(nullptr, " "));
	return true;
}
void PrintArgv()
{
	for(int i = 0; g_argv[i]; i++)
	{
		printf("argv[%d]: %s\n", i, g_argv[i]);
	}
}

int main()
{
	while(true)
	{
		
		// 1.输出命令行提示符
		// printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
		PrintfCommandPrompt();

		// 2.获取用户输入的命令
		// char commandline[COMMAND_SIZE];
		// // ls -a -l => "ls -a -l\n"
		// char *res = fgets(commandline, COMMAND_SIZE, stdin);
		// commandline[strlen(commandline) - 1] = '\0';  // 消除 '\0'
		// printf("%s\n", commandline);
		char commandline[COMMAND_SIZE];
		if(GetCommandLine(commandline, sizeof(commandline)))
		{
			printf("%s\n", commandline);
		}

		// 3.命令行分析  "ls -a -l" -> "ls" "-a" "-l"
		CommandParse(commandline);
		PrintArgv();
	}

	return 0;
}

🚩 运行结果如下:

2.3 通过创建进程和进程替换执行程序

我们命令存放在 g_argv 之中了,那要怎么执行呢?

我们命令可以看成一个个程序,所以为了不影响主进程,我们创建一个新的进程来执行命令,同时主进程进行进程等待,等待子进程的结束。

我们在子进程中要执行命令,自然需要使用进程替换,来把数组中的内容替换成进程,那么就需要用到带 v 的 exec 接口,因为程序名不带路径,所以需要从环境变量里面去找,要用到 execvp。

💬 代码演示:

// 4.执行命令
pid_t id = fork();
if(id == 0)
{
	// child
	execvp(g_argv[0], g_argv);
	exit(1);
}
// father
pid_t rid = waitpid(id, nullptr, 0);
(void)rid;

🚩 运行结果如下:

2.4 让命令结果高亮

还有很多地方不完美,比如:在系统中使用 ls 命令,可执行程序是高亮的:

那么如何让我们的命令带颜色呢?

💬 代码演示:

// 命令行拆分
bool CommandParse(char *commandline)
{
	g_argc = 0;
	g_argv[g_argc++] = strtok(commandline, " ");
	
	if (strcmp(g_argv[0] , "ls") == 0)
            g_argv[g_argc++] = (char*)"--color=auto";
	
	while(g_argv[g_argc++] = strtok(nullptr, " "));
	g_argc--;
	return true;
}

🚩 运行结果如下:

2.5  内建命令:实现路径切换

目前还有一个问题,我们 cd.. 回退到上级目录时,我们的路径是不发生变化的:

真相:虽然系统中存在 cd 命令,但我们写的 shell 脚本中用的根本就不是这个 cd 命令。

当你在执行 cd 命令时,调用 execvp 执行的实际上是系统特定路径下的 cd,它只影响了子进程,如果我们直接 exec* 执行 cd,那么最多只是让子进程进行路径切换。但是请不要忘了:子进程是一运行就完毕的进程!运行完了你切换它的路径,毫无意义。所以,我们在 shell 中,更希望谁的路径发生变化呢?父进程!(shell 本身)

这部分由 shell 自己执行的命令,我们称之为 内建指令。

下面我们就来解决路径切换的问题,这一步应该需要在第四步之前,顶替执行命令成为第四步:

// 家目录路径
const char *GetHome()
{
	const char *home = getenv("HOME");
	return home == NULL ? "None" : home;
}

// 检测并处理内建命令
bool CheckAndExecBuiltin()
{
	if(strcmp(g_argv[0], "cd") == 0)
	{
        // g_argv 中只有一个参数 cd
		if(g_argc == 1)
		{
			std::string home = GetHome();
			if(home.empty())
				return true;
			chdir(home.c_str());
		}
		else
		{
			std::string where = g_argv[1];
			chdir(where.c_str());
		}
		return true;
	}
	return false;
}

int main()
{
	while(true)
	{
        // ...

		// 4.检测并处理内建命令
		if(CheckAndExecBuiltin())
			continue;

		// ...
	}

	return 0;
}

🚩 运行结果如下:

似乎是成功执行了,但问题是命令行提示符这么为什么没变?

当一个进程的工作路径发生变化的时候,此时系统还存在一个环境变量 pwd。我们在系统中更改工作路径后,shell 会立刻更新环境变量 pwd,二者的值会保持一致,所以系统优化为使用命令 pwd 是查看的是当前工作路径,而不是去环境变量中查找 pwd。

而我们自己创建的 shell 并没有更新环境变量的功能,但是系统执行 pwd 命令还是按照原先查看当前工作路径,所以就能正常显示;而命令行提示符是从环境变量中获取的 pwd,所以依然保持不变。

那我们该怎么处理呢?我们使用一个系统调用:getcwd(),用来获取当前工作路径,并修改环境变量。

💬 代码演示:

char cwd[COMMAND_SIZE];

// 当前路径
const char *GetPwd()
{
	// const char *pwd = getenv("PWD");
	const char *pwd = getcwd(cwd, sizeof(cwd));
	// 修改环境变量为当前路径
	if(pwd != NULL)
	{
		snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
		putenv(cwdenv);
	}
	return pwd == NULL ? "None" : pwd;
}

🚩 运行结果如下:

2.6 echo 命令

echo 同样也是内建命令,我们想打印环境变量,打印退出码等等,都需要 echo。

所以我们在执行程序时获取退出码 status,并将其保存在全局变量 lastcode 中,再在内建命令函数中实现 echo 函数。

// 最近一个程序的退出码
int lastcode = 0;

bool Echo()
{
	if(g_argc == 2)
	{
		// echo "hello"
		// echo $?
		// echo $PATH
		std::string opt = g_argv[1];
		if(opt == "$?")
		{
			std::cout << lastcode << std::endl;
			lastcode = 0;
		}
		else if(opt[0] == '$')
		{
			std::string env_name = opt.substr(1);
			const char *env_value = getenv(env_name.c_str());
			if(env_value)
				std::cout << env_value << std::endl;
		}
		else if(opt[0] == '"')
		{
			std::string new_str(opt.begin() + 1, opt.end() - 1); // 去掉首尾双引号
            std::cout << new_str << std::endl;
		}
	}
	return true;
} 

// 检测并处理内建命令
bool CheckAndExecBuiltin()
{
	if(strcmp(g_argv[0], "cd") == 0)
	{
		Cd();
		return true;
	}
	else if(strcmp(g_argv[0], "echo") == 0)
	{
		Echo();
		return true;
	}
	return false;
}


// 执行程序
int Execute()
{
	pid_t id = fork();
	if(id == 0)
	{
		// child
		execvp(g_argv[0], g_argv);
		exit(1);
	}
	int status = 0;
	// father
	pid_t rid = waitpid(id, &status, 0);
	if(rid > 0)
	{
		lastcode = WEXITSTATUS(status);
	}
	(void)rid;
	return 0;
}

int main()
{
	// ...

	return 0;
}

🚩 运行结果如下: 

2.7 再次理解环境变量

一个真正的 shell 还会存在一张环境变量表,启动的时候正常情况下是从系统配置文件中读取,但我们想模拟实现的时候,暂时做不到从配置文件中读取,所以我们的环境信息,从 父shell 中读取

获取环境变量,直接遍历环境变量列表就行:

// 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;

// 创建环境变量表
void InitEnv()
{
	extern char** environ;
    memset(g_env, 0, sizeof(environ));
	g_envs = 0;

	//1. 获取环境变量
    for (int i = 0; environ[i]; i++)
	{
		// 为每个环境变量分配内存并复制
        g_env[i] = new char[strlen(environ[i]) + 1]; // +1 用于 '\0' 结尾
        strcpy(g_env[i], environ[i]);
		g_envs++;
	}
	g_env[g_envs] = NULL;
}

获取完环境变量表之后,将其导入我的 shell:

//2. 导成环境变量
for(int i = 0; g_env[i]; i++)
{
    putenv(g_env[i]);
} 

为了证明这确实是我自己导入的环境变量表,我们在最后加入一个特殊的环境变量:

// ...

g_env[g_envs++] = "HAHA=for_my_test";
g_env[g_envs] = NULL;

// ...

🚩 运行结果如下: 

📚 环境变量的数据在进程的上下文中:

  1. 环境变量会被子进程继承下去,所以他会有全局属性。
  2. 当我们进行程序替换时, 当前进程的环境变量非但不会替换,而且是继承父进程的!
  • 如果你不传环境变量表,默认子进程全部都会自动继承。
  • 如果你 exel 函数簇带 e,就相当于你选择了自己传,就会覆盖式地把原本的环境变量弄没,然后你自己交给子进程。如果不带 e,那么环境变量就会自己被子进程继承。
  • 如果既不想覆盖系统,也不想新增,所以我们采用 putenv 的方式向父 Shell 获取导入新增一个它自己的环境变量,这样的话原始的环境变量还在,我们能在子进程的 shell 上下文上给它新增环境变量。

所以,如何理解环境变量具有全局属性?因为所有的环境变量会被当前进程之下的所有子进程默认继承下去。

如何在子进程 Shell 内部自己导入新增自己的环境变量?putenv,要注意的是,需要一个独立的空间,放置环境变量的数据被改写。

2.8 完整代码

我们为了美观,对应代码进行了一定程度的排版上的修改,功能无变化。

💬 代码演示:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"

// 下面是shell定义的全局数据
// 1.命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;

// 2.环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;

// 测试使用
char cwd[COMMAND_SIZE];
char cwdenv[COMMAND_SIZE];

// 最近一个程序的退出码
int lastcode = 0;

// 用户名
const char *GetUserName()
{
	const char *name = getenv("USER");
	return name == NULL ? "None" : name;
}

// 主机名
const char *GetHostName()
{
	const char *hostname = getenv("HOST");
	return hostname == NULL ? GetUserName() : hostname;
}

// 当前路径
const char *GetPwd()
{
	// const char *pwd = getenv("PWD");
	const char *pwd = getcwd(cwd, sizeof(cwd));
	// 修改环境变量为当前路径
	if(pwd != NULL)
	{
		snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
		putenv(cwdenv);
	}
	return pwd == NULL ? "None" : pwd;
}

// 家目录路径
const char *GetHome()
{
	const char *home = getenv("HOME");
	return home == NULL ? "None" : home;
}

// 创建环境变量表
void InitEnv()
{
	extern char** environ;
    memset(g_env, 0, sizeof(environ));
	g_envs = 0;

	//1. 获取环境变量
    for (int i = 0; environ[i]; i++)
	{
		// 为每个环境变量分配内存并复制
        g_env[i] = new char[strlen(environ[i]) + 1]; // +1 用于 '\0' 结尾
        strcpy(g_env[i], environ[i]);
		g_envs++;
	}
	g_env[g_envs++] = "HAHA=for_my_test";
	g_env[g_envs] = NULL;

	//2. 导成环境变量
	for(int i = 0; g_env[i]; i++)
	{
		putenv(g_env[i]);
	}
}


bool Cd()
{
	// g_argv 中只有一个参数: cd
	if(g_argc == 1)
	{
		std::string home = GetHome();
		if(home.empty())
			return true;
		chdir(home.c_str());
	}
	else
	{
		std::string where = g_argv[1];
		chdir(where.c_str());
	}
	return true;
}

void Echo()
{
	if(g_argc == 2)
	{
		// echo "hello"
		// echo $?
		// echo $PATH
		std::string opt = g_argv[1];
		if(opt == "$?")
		{
			std::cout << lastcode << std::endl;
			lastcode = 0;
		}
		else if(opt[0] == '$')
		{
			std::string env_name = opt.substr(1);
			const char *env_value = getenv(env_name.c_str());
			if(env_value)
				std::cout << env_value << std::endl;
		}
		else if(opt[0] == '"')
		{
			std::string new_str(opt.begin() + 1, opt.end() - 1); // 去掉首尾双引号
    		std::cout << new_str << std::endl;
		}
	}
}

// 测试:将格式化内容(提示符行)输入进字符串prompt
void MakeCommandLine(char cmd_prompt[], int size)
{
	snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
// 1.输入命令行提示符
void PrintfCommandPrompt()
{
	char prompt[COMMAND_SIZE];
	// 将格式化内容(提示符行)输入进字符串prompt
	MakeCommandLine(prompt, sizeof(prompt));
	printf("%s", prompt);
	fflush(stdout);
}


// 2.接收指令
bool GetCommandLine(char *out, int size)
{
	// ls -a -l => "ls -a -l\n"
	char *res = fgets(out, size, stdin);
	if(res == NULL)
		return false;
	out[strlen(out) - 1] = '\0';  // 消除 '\0'
	return strlen(out) == 0 ? false : true;
}


// 3.命令行拆分(导入命令行参数表)
bool CommandParse(char *commandline)
{
	g_argc = 0;
	g_argv[g_argc++] = strtok(commandline, " ");
	
	if (strcmp(g_argv[0] , "ls") == 0)
            g_argv[g_argc++] = (char*)"--color=auto";
	
	while(g_argv[g_argc++] = strtok(nullptr, " "));
	g_argc--;
	return g_argc > 0 ? true : false;
}


// 测试:打印命令行参数 argv
void PrintArgv()
{
	for(int i = 0; g_argv[i]; i++)
	{
		printf("argv[%d]: %s\n", i, g_argv[i]);
	}
}
// 4.检测并处理内建命令
bool CheckAndExecBuiltin()
{
	if(strcmp(g_argv[0], "cd") == 0)
	{
		Cd();
		return true;
	}
	else if(strcmp(g_argv[0], "echo") == 0)
	{
		Echo();
		return true;
	}
	return false;
}


// 5.执行程序
int Execute()
{
	pid_t id = fork();
	if(id == 0)
	{
		// child
		execvp(g_argv[0], g_argv);
		exit(1);
	}
	int status = 0;
	// father
	pid_t rid = waitpid(id, &status, 0);
	if(rid > 0)
	{
		lastcode = WEXITSTATUS(status);
	}
	(void)rid;
	return 0;
}

int main()
{
	// shell启动的时候,从系统中获取环境变量
	// 我们自己的 shell 环境变量信息应该从父 shell 中获得
	InitEnv();

	while(true)
	{
		// 1.输出命令行提示符
		PrintfCommandPrompt();

		// 2.获取用户输入的命令
		char commandline[COMMAND_SIZE];
		if(!GetCommandLine(commandline, sizeof(commandline)))
			continue;
		//printf("%s\n", commandline);

		// 3.命令行分析  "ls -a -l" -> "ls" "-a" "-l"
		if(!CommandParse(commandline))
			continue;
		// PrintArgv();
		
		// 4.检测并处理内建命令
		if(CheckAndExecBuiltin())
			continue;

		// 5.执行命令
		Execute();
	}

	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值