云计算百科
云计算领域专业知识百科平台

【Linux】进程程序替换与简陋Shell编写

前言:这篇介绍进程程序替换,认识 exec 函数族,再把进程创建、程序替换和进程等待结合起来,然后再写一个能执行简单命令的 Shell。


1.进程程序替换

前面通过 fork 创建了子进程,但父子进程运行的仍然是同一个程序,只是可以进入不同的代码分支。

如果想让子进程去运行另一个程序,比如执行 ls 查看文件,该怎么做?这就需要用到进程程序替换。

程序替换会把磁盘上的另一个程序加载进来,让当前进程开始执行它。原程序的代码和数据被替换,接下来运行的是新程序。

这里先记住两个结论:

  • fork 创建新进程,exec 替换当前进程运行的程序。
  • 程序替换成功后,不会再返回原来的调用位置;替换失败才返回 -1,并设置 errno。

先看一个直接进行程序替换的例子:

在这里插入图片描述

程序先打印“程序开始执行”,随后通过 execl 执行 ls,终端上显示的就是文件列表。后面的“程序执行结束”没有打印。

这里被替换的是整个原程序,不执行后半段代码。替换成功以后,当前进程就去执行 ls 了,等 ls 结束也不会回来打印“程序执行结束”。

再来看父子进程配合的情况:

在这里插入图片描述

先用 fork 创建子进程,子进程打印自己的 PID,再执行 execl,运行已经编译好的 ./other。

替换前打印的是 11173,新程序打印的也是 11173。这说明执行程序发生了变化,但还是原来的那个子进程,PID 没变。

父进程没有进行程序替换,所以它等待子进程结束之后,仍然可以打印“程序执行结束”。

2.exec 函数族

平时说的 exec 是一组函数的统称。它们做的事情相同,主要区别在于:怎样传参数、怎样找程序、给新程序什么环境变量。

函数名不用一个个死记,先看后面的几个字母:

字母含义怎么理解
l list 把参数一个个列出来
v vector 把参数放在指针数组中传入,这里不是 C++ 的 std::vector
p PATH 文件名不含 / 时,按照环境变量 PATH 查找程序
e environment 自己传入新程序要使用的环境变量表

组合起来,常见的几个接口就是:

函数参数怎么传程序怎么找新程序的环境变量
execl 逐个列出 按给定路径 使用当前环境
execlp 逐个列出 可以按 PATH 查找 使用当前环境
execle 逐个列出 按给定路径 由 envp 指定
execv 指针数组 按给定路径 使用当前环境
execvp 指针数组 可以按 PATH 查找 使用当前环境
execve 指针数组 按给定路径 由 envp 指定

这里的路径既可以是 /usr/bin/ls 这样的绝对路径,也可以是 ./other 这样的相对路径,不是一定要把完整路径写出来。

2.1execl 与 execlp

先看 execl,可以把它的参数理解为:执行谁,给它哪些参数。

在这里插入图片描述

下面用几段 C 代码说明接口用法,每段分别理解即可,不要把它们当作会依次执行的程序。

execl("/bin/ps", "ps", "-ef", NULL);

这几个参数分别表示:

参数作用
"/bin/ps" 要执行的程序路径
"ps" 传给新程序的 argv[0],通常写程序名
"-ef" 传给新程序的选项,对应 argv[1]
NULL 表示参数列表结束

第一个 "/bin/ps" 是用来找程序的,后面的 "ps" 才是新程序看到的第一个命令行参数,二者的作用不同。

图中“命令行怎么写就怎么写”,指的是把程序名和各个选项分别传进去。例如前面的 ls -l -a,要分别传入 "ls"、"-l"、"-a",不能把整条命令当成一个字符串交给 execl 解析。

末尾要传的是空指针。在 execl 这类可变参数函数中,可以用 (char *)NULL 明确它的类型。

execlp 比 execl 多了一个 p,使用起来更接近平时输入命令的习惯:

execlp("ps", "ps", "-ef", NULL);

在这里插入图片描述

这里第一个参数只写 "ps",函数就会按照当前环境变量 PATH 中的目录去查找。

需要注意的是,只有文件名中不含 / 时才这样搜索。如果传入的是 ./other 或 /bin/ps,就按照给定路径执行。

2.2execv 与 execvp

参数数量少时,一个个列出来比较直观;如果参数是运行过程中才拿到的,放进数组会方便很多。

execv 使用的就是以空指针结尾的参数数组:

char *const argv[] = {"ps", "-ef", NULL};
execv("/bin/ps", argv);

数组中,argv[0] 是 "ps",argv[1] 是 "-ef",最后一个元素为 NULL。接口通过这个空指针知道参数已经结束。

如果还想按照 PATH 查找,就沿用这个数组,改用 execvp:

execvp("ps", argv);

execvp 很适合用来编写简陋 Shell。 因为用户这一行输入了几个参数,我们事先并不知道;读取命令后,把它拆成数组,再交给 execvp 就可以了。

这里的数组示例按 C 写法理解,后面的 Shell 则使用 C++ 实现,参数直接指向读入的命令字符串。

2.3环境变量怎么传

没有 e 的接口,会把当前进程的环境变量交给新程序;带 e 的接口,则使用我们指定的环境变量表。

沿用前面的 argv,可以这样为 execve 指定环境变量:

char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execve("/bin/ps", argv, envp);

argv 里放命令行参数,envp 里放环境变量。它们都是指针数组,也都以空指针结尾,只是里面的字符串含义不同。

环境变量按 名字=内容 的形式组织。例如上面的 PATH=/bin:/usr/bin,就是新程序能拿到的一个环境变量。显式传入 envp 时,新程序使用的就是这张表,不会自动把遗漏的原环境变量补进来。

另外还能看到把 v、p、e 合在一起的 execvpe:

在这里插入图片描述

这些接口最终完成的是同一件事。在上面列出的六个接口中,execve 是系统调用,其他几个是在它的基础上提供更方便的传参、查找方式。先根据参数形式选接口,具体声明需要时再查就行。

3.编写一个简陋 Shell

现在把前面的内容连起来。我们输入 ls,终端就能列出文件,是因为 Shell 读到了命令,然后让相应的程序去执行。

对于这里要实现的简单外部命令,流程就是:

打印提示符 → 读取命令 → 拆分参数 → 创建子进程 → 子进程替换程序 → 父进程等待 → 继续读取下一条命令。

为什么不让 Shell 自己直接调用 exec?因为替换成功以后,它自己就去运行 ls 了,原来的读命令循环也没了。所以需要让子进程执行外部程序,父进程继续保留 Shell 的工作。

除此之外,还要提前判断是不是内建命令。这份实现中的 cd 和 echo,会直接在 Shell 进程中处理。

3.1文件划分与主循环

整个实现分为三个文件:

文件内容
myShell.h 头文件、常量、全局变量声明和函数声明
myShell.cpp 提示符、命令读取、参数拆分、内建命令和外部命令执行
main.cpp 把各个步骤组织成循环

先看 myShell.h:

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

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

// 命令行参数表
#define MAXARGC 248
extern char *g_argv[MAXARGC];
extern int g_argc;

// 环境变量表
#define MAXENVS 248
extern char *g_env[MAXENVS];
extern int g_envc;

// 模拟进程工作路径
extern char cwd[1024];
extern char cwdenv[1024];

// 退出码
extern int lastcode;

// 初始化环境变量
void InitEvn();

// 打印命令行
void PrintCommandPrompt();

// 获取用户输入
bool GetCommandLine(char* commandline, int size);

// 处理命令
bool CommandParse(char* commandline);

// 处理内联命令
bool CheckExecBuiltin();

// 执行
int ExecCommand();

g_argv 保存拆分后的命令行参数,g_argc 记录参数个数;g_env 保存环境变量字符串,g_envc 记录数量。lastcode 用来保存后面取得的外部命令退出码。

cwd 和 cwdenv 虽然都与路径有关,用途却不同:前者放当前工作目录,后者放 PWD=路径 这样的环境变量字符串。

再看 main.cpp,先从整体上了解执行顺序:

#include "myShell.h"

int main()
{
InitEvn();
while (1)
{
PrintCommandPrompt();
char commandline[COMMAND_SIZE];
if (!GetCommandLine(commandline, sizeof(commandline)))
continue;
if (!CommandParse(commandline))
continue;
if (CheckExecBuiltin())
continue;

ExecCommand();

}

return 0;
}

启动时先初始化环境,之后不断打印提示符、获取命令、拆分参数。

如果本轮没有读到有效输入,就通过 continue 重新开始;如果命中了内建命令,处理完也直接进入下一轮。剩下的命令才交给 ExecCommand。

下面按功能展开 myShell.cpp,各段按顺序接起来就是这个文件的内容。

3.2环境变量与命令提示符

先定义全局变量,并完成环境变量的初始化:

#include "myShell.h"

char *g_argv[MAXARGC];
int g_argc = 0;

char *g_env[MAXENVS];
int g_envc = 0;

int lastcode = 0;

char cwd[1024];
char cwdenv[1024];

void InitEvn()
{
extern char **environ;
memset(g_env, 0, sizeof(g_env));
g_envc = 0;
for (int i = 0; environ[i]; i++)
{
g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
strcpy(g_env[i], environ[i]);
putenv(g_env[i]);
g_envc += 1;
}

}

environ 指向当前进程的环境变量表,里面保存的是一个个 名字=内容 字符串。

循环取出这些字符串,申请空间、复制内容,再调用 putenv 放到当前进程的环境中。这里的 g_env 保存这些字符串的地址。

putenv 会继续使用传入的那块字符串空间,所以这里用 malloc 分配的空间不能在放进去后马上释放。后面保存 PWD 的 cwdenv 也是全局数组,函数返回后仍然存在。

接下来获取提示符需要的信息:

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()
{
if (getcwd(cwd, sizeof(cwd)) != NULL)
{
snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
putenv(cwdenv); // 同步维护当前进程的 PWD 环境变量
return cwd;
}
return "None";
}

const char* GetHome()
{
const char* home = getenv("HOME");
return home == NULL ? "None" : home;
}

std::string CutPwd(const char* pwd)
{
std::string str(pwd);
if (str == "/")
{
return str;
}
auto pos = str.rfind("/");
if (pos == std::string::npos)
return "BUG";

return str.substr(pos + 1);

}

void PrintCommandPrompt()
{
char commandLine[COMMAND_SIZE];
snprintf(commandLine, sizeof(commandLine), FORMAT, GetUserName(), GetHostName(), CutPwd(GetPwd()).c_str());
printf("%s", commandLine);
fflush(stdout);
}

这一段主要做三件事。

首先,getenv 通过名字读取环境变量。GetUserName 读取 USER,GetHostName 读取 HOSTNAME,GetHome 读取 HOME。如果对应变量不存在,就返回 "None"。

其次,GetPwd 用 getcwd 取得进程当前的工作目录,再把它组织成 PWD=… 放入环境变量。**实际工作目录由进程维护,我这里只是简单的模拟一下。

CutPwd 则取路径最后一段,让提示符短一些。例如完整路径为 /home/hjy/MyShell,提示符里只显示 MyShell;如果当前就在根目录 /,则直接保留 /。

最后,PrintCommandPrompt 按照 FORMAT 拼接并打印提示符。这里没有打印换行,因此紧跟一个 fflush(stdout),让提示符及时显示出来,再等用户输入。

末尾的 # 也是我们自己打印的字符,主要是未来做区分并不是获得了root身份。

3.3读取命令并拆分参数

Shell 读到的首先是一整行字符串。要交给 execvp,还需要把程序名和各个参数拆出来:

bool GetCommandLine(char* commandline, int size)
{
char *tmp = fgets(commandline, size, stdin);
if (tmp == NULL)
return false;

commandline[strlen(commandline) – 1] = 0; // 清理 \\n
if (strlen(commandline) == 0)
return false;

return true;

}

bool CommandParse(char* commandline)
{
#define BLANK " "
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, BLANK);
while ((bool)(g_argv[g_argc++] = strtok(NULL, BLANK)));
g_argc -= 1;
return g_argc > 0 ? true : false;
}

fgets 从标准输入读取一行内容。在正常输入一行并按下回车的情况下,结尾会带有 \\n,所以代码把最后一个字符改成 0,去掉这个换行。

如果只按回车,去掉换行后就是空字符串,当前这轮不执行命令。

接下来用 strtok 按空格拆分。比如输入 ls -l -a,拆完之后就是:

数组位置保存的内容
g_argv[0] 指向 "ls"
g_argv[1] 指向 "-l"
g_argv[2] 指向 "-a"
g_argv[3] 空指针

第一次调用 strtok 时传入 commandline,后面传入 NULL,表示继续拆分刚才那一行。它会修改原字符串,把分隔位置改成字符串结束符,再返回各段的地址。

3.4内建命令 cd 与 echo

普通外部命令可以交给子进程执行,但 cd 要单独处理。

子进程改变自己的工作目录,不会改变父进程的工作目录。 如果把 cd 放到子进程中,等子进程退出,Shell 自己还留在原来的目录,下一条命令也就没有真正“切换过去”。

因此,cd 必须在当前 Shell 进程中执行。这里也把 echo 放在内部处理,方便打印文字、查看环境变量和保存的退出码:

void Cd()
{
if (g_argc == 1) // 回到家目录
{
const char* homedir = GetHome();
chdir(homedir);
}
else
{
std::string where(g_argv[1]);
if (where == "~")
{
chdir(GetHome());
}
else
{
chdir(where.c_str());
}
}

}

void Echo()
{
if (g_argc == 2)
{
std::string opt = g_argv[1];
if (opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
}
else if (opt[0] == '$') // 获取环境变量
{
std::string envName = opt.substr(1);
const char* envValue = getenv(envName.c_str());
if (envValue != NULL)
std::cout << envValue << std::endl;
else
std::cout << std::endl;
}
else
{
std::cout << opt << std::endl;
}
}

}

bool CheckExecBuiltin()
{
std::string command(g_argv[0]);
if (command == "cd")
{
Cd();
return true;
}
else if (command == "echo")
{
Echo();
return true;
}

return false;

}

Cd 的处理比较直接:

  • 只有 cd,没有其他参数:通过 HOME 回到家目录。
  • 参数恰好是 ~:同样回到家目录。
  • 传入其他路径:把这个路径交给 chdir。

chdir 成功后,Shell 进程的工作目录发生变化。下一次打印提示符时,GetPwd 会重新取得路径,并更新 PWD,后面创建的子进程也会继承这个工作目录。

我只实现了一小部分功能,毕竟主要目的还是学习。

Echo 则只处理命令后面恰好有一个参数的情况:

输入形式这里的处理方式
echo hello 打印 hello
echo $HOME 通过 getenv 读取 HOME 并打印
echo $? 打印 lastcode,随后把它设置为 0

这里的 $HOME、$? 是 Echo 函数自己判断并处理的,还没有实现对所有命令通用的变量替换。

CheckExecBuiltin 负责看命令名是不是 cd 或 echo,如果是就执行对应函数,并返回 true。主循环拿到这个结果,直接进入下一轮,不再创建子进程。

3.5执行外部命令并等待

命令读取、参数数组和内建命令都准备好之后,外部命令的执行部分反而不长:

int ExecCommand()
{
pid_t id = fork();
if (id == 0)
{
// child
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode = WEXITSTATUS(status);
}

return 0;
}

先通过 fork 创建子进程。子进程中 id == 0,于是调用:execvp(g_argv[0], g_argv)。

这里的两个参数正好对应前面拆分的结果:

  • g_argv[0]:要执行的程序名,比如 ls。
  • g_argv:完整参数数组,其中包括程序名、选项以及末尾的空指针。

execvp 成功后,子进程就去运行外部程序;只有失败才会执行 exit(1),避免继续进入 Shell 的读命令循环。

父进程走到 waitpid(id, &status, 0),阻塞等待刚才创建的子进程。

等子进程结束,父进程回收它的资源,并从 status 中取得退出码,保存到 lastcode。然后 ExecCommand 返回,主循环再次打印提示符,等待下一条命令。

来看简单外部命令的运行效果:

在这里插入图片描述

进入自制 Shell 后,分别输入 ls、pwd、whoami,能看到文件列表、当前路径和用户名。每执行完一条命令,提示符都会重新出现,说明父进程一直保留着读命令的循环。


完

赞(0)
未经允许不得转载:网硕互联帮助中心 » 【Linux】进程程序替换与简陋Shell编写
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!