目录
前言
书接上文【Linux】进程控制(一)----进程创建、进程终止、进程等待详情请点击,今天继续介绍【Linux】进程控制(二)----进程程序替换、编写自主Shell命令行解释器(简易版)
一、进程程序替换
- fork() 之后,父子各自执行父进程代码的一部分,但是如果子进程想执行一个全新的程序(子进程有自己的代码、自己的数据)呢?进程的程序替换来完成这个功能!
- 程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中
替换函数

execl函数
cpp
int execl(const char *path, const char *arg, ...);
const char *path: 程序也是文件,通过路径+文件名查找,该参数是进程执行的代码const char *arg, ...:可变参数,const char *arg:我们需要知悉程序的程序名;...:给程序传递的命令行的选项(可不使用),NULL结尾
可变参数理解
double sum(int count, ...):必须要有一个实际的参数
cpp#include <stdio.h> #include <stdarg.h> // 可变参数相关头文件 // 函数:累加任意个数 double sum(int count, ...) { double total = 0.0; va_list args; // 1. 声明可变参数列表 va_start(args, count); // 2. 初始化可变参数列表 // 3. 遍历并累加所有参数 for (int i = 0; i < count; i++) { double num = va_arg(args, double); // 获取下一个double参数 total += num; } va_end(args); // 4. 清理可变参数列表 return total; } int main() { double result1 = sum(3, 1.1, 2.2, 3.3); printf("sum_double(3, 1.1, 2.2, 3.3) = %.2f\n", result1); double result2 = sum(5, 0.5, 1.5, 2.5, 3.5, 4.5); printf("sum_double(5, 0.5, 1.5, 2.5, 3.5, 4.5) = %.2f\n", result2); double result3 = sum(1, 99.9); printf("sum_double(1, 99.9) = %.2f\n", result3); double result4 = sum(0); printf("sum_double(0) = %.2f\n", result4); double result5 = sum(4, 1, 2.5, 3, 4.7); printf("sum_double(4, 1, 2.5, 3, 4.7) = %.2f\n", result5); return 0; }
- execl程序替换演示
1. 单个进程程序替换

- 我们从结果可以看到,在没有使用execl程序替换时,进程运行printf打印进程pid,再调用execl进程程序替换,执行
"/usr/bin/ls"路径下的ls -a -l -n程序- 因为程序替换了,进程已经执行另一个程序代码了,所以后续我们自己写的代码没有了 ,不会再执行我们自己写的代码
如果程序替换失败返回-1,进程成功不会也不需要有返回值
- 因为程序替换我们不需要判断是否替换失败,直接在exec系列函数后面打印程序替换失败即可,因为程序替换成功之后后续代码根本不会执行,只要往后执行了后续代码,则程序替换失败

2. 子进程程序替换


execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);中,"/usr/bin/ls":表示进程需要执行哪个文件,后面的"ls", "-a", "-l", "-n",:表示执行方法,这个代码中的ls并不冲突
execv函数
int execv(const char *path, char *const argv[]);:和execl第一个参数都是传入文件路径,只不过execv函数后面使将执行方法放到数组中(以NULL结尾)
1. execv函数演示


2. execv函数实现加载器作用
- 通过main函数的argv数组参数,我们获得命令行参数,将参数传入到execv函数中,加载不同指令执行,实现将不同程序加载进内存中,形成进程执行

实现在命令行输入参数,将根据参数实现相关进程
execlp/execvp函数
execlp函数
int execlp(const char *file, const char *arg, ...);:execlp函数第一个参数只需要传入文件名称即可,不需要文件路径- 执行指定命令,需要让execlp在环境变量PATH中寻找指定程序


execvp函数 int execvp(const char *file, char *const argv[]);:对比execlp,只是传入参数保存到数组中


execle/execvpe函数
- 这两个函数分别比上面的execl和execvp函数多了一个参数
char * const envp[]:环境变量
cpp
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
-
上面我们子进程进程替换的都是系统的命令,我们可以替换我们自己的程序来执行吗?必然是可以的
-
再创建一个.cc文件

-
execl函数,传入当前目录下mycmd程序的路径,mycmd执行方式


execvpe函数演示 -
修改mycmd.cc,打印argv数组内容和env数组内容(命令行参数和环境变量)


-
修改myexec.c文件

- 所以我们main函数的默认参数
int main(int argc, char* argv[], char* env[])是通过程序替换由对应进程通过exec**e的方式将命令行参数表和环境变量传递给目标程序的
- 除了能将我们自定义的环境变量传入,也可以传入系统的环境变量
- 上面传入的环境变量要不传入系统的,要不就是我们自己的,那我们要在系统的环境变量中添加几个自定义环境变量,再传入给目标程序,如何做呢?



execve函数(系统调用)
- execve是系统调用函数,上面的exec*函数(库函数)底层都是调用的execve函数

命名理解
- l(list) :表示参数采用列表
- v(vector) :参数用数组
- p(path) :有 p 自动搜索环境变量 PATH
- e(env) :表示自己维护环境变量
替换原理
- 进程替换,是将子进程从父进程继承的数据和代码替换成自己的数据和代码,程序替换并没有创建新的进程

- 但是子进程和父进程不是代码共享,数据写时拷贝吗?子进程替换了代码,父进程的代码不是也会替换吗?
- 这里可以理解成代码如果被替换,代码也要进行写时拷贝
- 在我们shell命令行执行
ls -a -l命令时,其实就是bash fork()创建子进程,exec*子进程程序替换,父进程waitpid等待子进程完成任务 - 我们的程序要变为进程,是先创建PCB数据结构,再加载代码和数据的,但是我们的程序是如何加载到内存中的呢?我们的exec就类似于一种"加载器",让我们进程执行exec后的代码
二、自主Shell命令行解释器
- 创建三个文件,main.cc负责上层调用,myshell.cc命令行相关实现代码,myshell.h函数声明


myshell命令行提示符实现
- shell是一个进程,实现shell,首先我们得实现出自己的命令行提示符(字符串)来,在提示符后面等待用户输入命令

- 命令行提示符包括[]、用户名(gy)、主机名(@VM-16-12-centos)和当前所在的工作路径,再包括一个$符号
cpp
#include "myshell.h"
#include <iostream>
#include <stdlib.h>
#include <string.h>
static std::string GetUserName()
{}
static std::string GetHostName()
{}
static std::string GetPwd()
{}
void PrintCommandPrompt()
{
std::string user = GetUserName();
std::string hostname = GetHostName();
std::string pwd = GetPwd();
printf("[%s@%s %s]# ", user.c_str(), hostname.c_str(), pwd.c_str());
}
- 在myshell.cc文件中,对于GetUserName这些函数,我们一般只允许在该文件内部调用,上层main.cc文件中,我们只需要调用PrintCommandPrompt函数来实现命令行参数提示符,并不需要将GetUserName函数接口暴露给上层调用,所以使用static修饰函数
- 获取环境变量,使用getenv函数,获取成功返回指向该环境变量字符串的指针;失败返回NULL


cpp
static std::string GetUserName()
{
std::string username = getenv("USER");
if(username.empty())
return "None";
return username;
}
static std::string GetHostName()
{
std::string hostname = getenv("HOSTNAME");
if(hostname.empty())
return "None";
return hostname;
}
static std::string GetPwd()
{
std::string pwd= getenv("PWD");
if(pwd.empty())
return "None";
return pwd;
}
- main函数中调用
PrintCommandPrompt()函数,编译运行代码
cpp
#include "myshell.h"
int main()
{
//1. 输出命令行提示符
PrintCommandPrompt();
return 0;
}
- 我们发现确实打印出来了自己的命令行提示符,但是后面又接着打印了系统的提示符
- 因为main函数调用
PrintCommandPrompt()函数后该进程直接就结束了- 命令行解释器是一个死循环 的软件
cpp
#include "myshell.h"
int main()
{
while(true)
{
//1. 输出命令行提示符
PrintCommandPrompt();
}
return 0;
}
获取用户命令
- 上面代码实现了打印命令行提示符,但是在一直打印提示符,我们要实现打印命令行提示符后,等待用户输入命令

- 获取键盘用户输入的命令,首先对于命令(
ls -a -l -n),操作系统内部是将其看作一个整体字符串,而不是ls一个字符串,-a一个字符串...因此我们使用fgets函数直接获得整个长字符串,将从键盘(stdin)获得的字符串整体写入到char数组中

cpp
//myshell.cc
//获取用户的键盘输入
bool GetCommandString(char cmd_str_buff[], int len)
{
if(cmd_str_buff == NULL || len <= 0)
return false;
char* ret = fgets(cmd_str_buff, len, stdin);
if(ret == NULL)
return false;
return true;
}
//main.cc
#define SIZE 1024
int main()
{
char commandstr[SIZE];
while(true)
{
//1. 输出命令行提示符
PrintCommandPrompt();
//2. 获取用户输入命令
GetCommandString(commandstr, SIZE);
printf("echo %s", commandstr);
}
return 0;
}
- 运行代码,我们发现,我们的shell输出命令行提示符之后就停在这里,不再是反复打印了,停在这里就是等用户键盘输入命令行
- 输入命令行之后,打印出来了我们从键盘获得的命令,成功获取
- 但是有一个问题,我们打印的时候明明没有换行符(
\n),但是打印还是换行了,这是为什么呢?因为我们在输入ls -a -l -n之后,我们在结尾还输入了enter回车键,所以我们获取用户输入时最后一个\n我们需要将其替换成\0- 那么我们fgets函数有可能获得字符串为0吗?即cmd_str_buff长度为0?答案是不可能,我们在使用fgets获取键盘字符串时,即使没有输入任何字符串,但是我们最后一定会敲enter回车键,因此不可能为0,所以
strlen(cmd_str_buff) - 1不会越界
cpp
//myshell.cc
//获取用户的键盘输入
bool GetCommandString(char cmd_str_buff[], int len)
{
if(cmd_str_buff == NULL || len <= 0)
return false;
char* ret = fgets(cmd_str_buff, len, stdin);
if(ret == NULL)
return false;
// ls -a -l\n -> ls -a -l\0
cmd_str_buff[strlen(cmd_str_buff) - 1] = 0;
return strlen(cmd_str_buff) == 0 ? false : true;
}

对命令行字符串进行解析(切割)
- 实现命令,我们需要将我们上面获得的字符串进行解析,解析为命令行参数表,因为命令行参数表我们是需要继承给子进程的,所以我们定义成全局的char数组
cpp
//myshell.h
#ifndef __MYSHELL_H__
#define __MYSHELL_H__
#include <stdio.h>
#define ARGS 64
void PrintCommandPrompt();
bool GetCommandString(char cmd_str_buff[], int len);
bool ParseCommandString(char cmd[]);
#endif
//myshell.cc
char* gargv[ARGS] = {NULL};
int gargc = 0;
- 将字符串
"ls -a -l"解析为"ls" 、"-a" 、"-l",并将单个字符串放入命令行参数表中。我们可以将字符串直接的空格替换成\0,依次获取单个字符串,遇到\0获得一个字符串,再从\0处往后,再次遇到\0获得下一个字符串,这里我们可以使用strtok函数来实现字符串截取
char *strtok(char *str, const char *delim):strtok返回一个指向字符串的指针,如果没有符合的子串,或者已经截取完成就会返回NULL(0)。传入的第一个参数是字符串数组,第二个参数是分割方式,这里我们是空格分割
cpp
#include <stdio.h>
#include <string.h>
int main()
{
char buffer[] = "ls -a -l -c -d";
char* substr = strtok(buffer, " ");
printf("%s\n", substr);
char* substr1 = strtok(NULL, " ");
printf("%s\n", substr1);
return 0;
}
- 如果需要往后获取后面的字符串,传入的第一个参数为NULL
- 首先,当出入的数组为空,则直接返回false,解析失败;如果不为空,根据空格将字符串分割
while((bool)(gargv[gargc++] = strtok(NULL, SEP))):将后面的字符串分割,并保存在gargv数组中,当解析完最后一个子串,再往后解析时,返回NULL,保存在数组中,gargc++(比如ls -a -l解析完成之后保存在数组中为"ls" 、"-a" 、"-l"、NULL,gargc为4,因为NULL返回NULL的时候也++了,因此gargc还需要--)- 同时使用条件编译来查看分割结果
cpp
//myshell.cc
// 解析命令
bool ParseCommandString(char cmd[])
{
if(cmd == NULL)
return false;
#define SEP " "
// "ls -a -l" -> "ls" "-a" "-l"
gargv[gargc++] = strtok(cmd, SEP);
while((bool)(gargv[gargc++] = strtok(NULL, SEP)));
gargc--;
#define DEBUG
#ifdef DEBUG
printf("gargc: %d\n", gargc);
printf("----------------------------------------\n");
for(int i = 0; i < gargc; i++)
{
printf("gargv[%d] : %s\n", i, gargv[i]);
}
printf("----------------------------------------\n");
for(int i = 0; gargv[i]; i++)
{
printf("gargv[%d] : %s\n", i, gargv[i]);
}
#endif
return true;
}

- 上面我们输入命令ls -a -l ,结果没问题,但是当我们再输入pwd,我们看到gargc = 4,gargv中保存的命令参数也是不对的。通过分析我们可以知道,当我们上次输入命令后再次输入命令,参数数组没有清空,导致这次参数数组中还保留着上次的gargc 和gargv
- 因此我们在最开始都得初始化
- 同时当我们在第2步操作获取用户输入命令时,如果没有获取到用户输入,那么就不需要进行第三步的命令行解析
cpp
//myshell.h
void InitGlobal();
//myshell.cc
void InitGlobal()
{
gargc = 0;
memset(gargv, 0, sizeof(gargv));
}
//main.cc
#define SIZE 1024
int main()
{
char commandstr[SIZE];
while(true)
{
//0.初始化
InitGlobal();
//1. 输出命令行提示符
PrintCommandPrompt();
//2. 获取用户输入命令
if(!GetCommandString(commandstr, SIZE))
continue;
//3.对命令行字符串进行解析
ParseCommandString(commandstr);
}
return 0;
}

- 从调试信息可以看到,代码解析已经没有问题,现在我们注释掉调试信息
实现命令
- 执行命令,我们不能够直接程序替换,调用exec*系列接口实现,因为一旦当前程序直接调用,那么后续执行完程序替换程序之后,该程序就结束了,不会再继续运行myshell的死循环进程
- 因此我们要创建子进程,让子进程来完成程序替换

- 我们的参数列表数组保存着解析完成的命令,没有包含命令路径,所以我们在选择exec*函数时要选择带p的;我们保存在数组,所以我们得选择带v(vector)的,所以我们可以选择execvp/execvpe,环境变量传入还是不传入没有关系,这里我选择execvp函数完成

实现普通命令
cpp
//执行命令
void ForAndExec()
{
pid_t id =fork();
if(id < 0)
{
perror("fork");
return;
}
else if(id == 0)
{
//子进程
execvp(gargv[0], gargv);
exit(0);
}
else
{
//父进程:等待
pid_t rid = waitpid(id, nullptr, 0);
}
}

实现内建命令
- 上面我们已经实现了大部分命令,但是cd ...命令没有生效。我们之前进行路径切换,本质是父进程bash进程路径切换,fork后,路径会被子进程继承下去,因此子进程pwd后能查到新路径。
- 但是我们代码子进程cd到某个路径之后,直接退出了,不会影响父进程,因此父进程路径不变

- 因此,像cd命令这样只能由父进程shell自己执行,不能交给子进程执行,我们把这样只能由shell自己执行的命令叫做内建命令
- 我们实现内建命令就得在实现普通命令之前判断是否为内建命令,如果是就shell自己执行,不是则交给子进程执行
- 常见内建命令:cd 、echo
cd命令
- cd命令,我们更改当前的工作路径,使用系统调用chdir函数

- 当gargc == 2,说明cd后面跟了目标路径,直接
chdir(gargv[1])即可,跟的是-或~时特殊处理(~:进入家目录;-:表示进入上一次目录);当gargc == 1,说明cd后面没有跟目标路径,直接回到家目录 - 处理~和-,当为 ~时,直接进入家目录;当为-时,我们需要使用getenv函数获取oldpwd,pwd和oldpwd不会自动根据进程而自动实时更新,都是需要我们自己来更新的 ,所以我们一般不使用getenv来获得当前工作路径参数,而是使用系统调用来getcwd来直接获取当前进程所处路径


cpp
//myshell.h
//使用系统调用,获取当前进程正所处的工作路径
static std::string GetPwd()
{
char buf[1024];
getcwd(buf, sizeof(buf));
return buf;
}
//通过getenv获取oldpwd
static std::string GetOldPwd()
{
std::string oldpwd = getenv("OLDPWD");
return oldpwd.empty() ? "None" : oldpwd;
}
//进入家目录
static std::string GetHomePath()
{
std::string home = getenv("HOME");
return home.empty() ? "/" : home;
}
bool BuiltInCommandExec()
{
//gargv[0]
std::string cmd = gargv[0];
bool ret = false;
if(cmd == "cd")
{
std::string currentPwd = GetPwd();
if(gargc == 2)
{
std::string target = gargv[1];
if(target == "~")
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
else if(target == "-")
{
std::string OldPwd = GetOldPwd();
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(OldPwd.c_str());
}
else
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(gargv[1]);
}
}
else if(gargc == 1)
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
else{}
}
}
return ret;
}
//main.cc
int main()
{
char commandstr[SIZE];
while(true)
{
//0. 初始化
InitGlobal();
//1. 输出命令行提示符
PrintCommandPrompt();
//2. 获取用户输入命令
if(!GetCommandString(commandstr, SIZE))
continue;
//3. "ls -a -l"->ls -a -l
//对命令行字符串进行解析->命令行参数表
ParseCommandString(commandstr);
//4.检测命令,内建命令让shell自己执行
if(BuiltInCommandExec())
{
continue;
}
// 4. 执行命令,让子进程执行
ForAndExec();
}
return 0;
}

更新环境变量表
- 上面我们使用的系统调用getcwd来获得当前进程的所处的路径,我们如何实时更新环境变量表中的当前路径呢?
- 我们的进程继承了父进程的环境变量表,首先我们创建一个全局变量,代表当前shell的新的环境变量表(环境变量表必须全局有效)

snprintf:将某个数据按照一定的格式到str中,
snprintf(pwd, sizeof(pwd), "PWD=%s", buf):按照PWD= 的字符写入到pwd中
cpp
static std::string GetPwd()
{
char buf[1024];
getcwd(buf, sizeof(buf));
snprintf(pwd, sizeof(pwd), "PWD=%s", buf);
putenv(pwd);
return buf;
}

命令行提示符修改
- 系统的shell命令行路径只显示当前目录shell,我们自己实现的shell显示出全部路径

cpp
static std::string GetPwd()
{
char buf[1024];
getcwd(buf, sizeof(buf));
snprintf(pwd, sizeof(pwd), "PWD=%s", buf);
putenv(pwd);
std::string pwd_label = buf;
const std::string pathsep = "/";
auto pos = pwd_label.rfind(pathsep);
if(pos == std::string::npos)
{
return "None";
}
pwd_label = pwd_label.substr(pos + pathsep.size());
return pwd_label.empty() ? "/" : pwd_label;
}
- 这样处理之后,我们cd -时获取当前路径就只有最后的目录了,因此我们再创建一个GetCwd函数来获取完整的当前工作路径
cpp
static std::string GetCwd()
{
char buf[1024];
getcwd(buf, sizeof(buf));
return buf;
}
echo命令
echo $?和echo打印字符
- 在系统shell中,我们输入一个命令,使用echo $?能够查到最近一个子进程的退出码,现在我们myshell也来实现这个功能
- 父进程waitpid等待时,关注返回信息,定义全局变量lastcode获取最近子进程退出码
cpp
int lastcode = 0; //获取最近子进程退出码
void ForAndExec()
{
pid_t id =fork();
if(id < 0)
{
perror("fork");
return;
}
else if(id == 0)
{
//子进程
execvp(gargv[0], gargv);
exit(0);
}
else
{
//父进程:等待
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
}
}
- echo命令也是内建命令,当echo后面的是
$?说明是查看退出码的指令,将lastcode打印出来,因为echo也是一个进程,因此打印之后将lastcode置为0;如果后面不是$?,直接将echo后面的内容打印在显示器上
cpp
bool BuiltInCommandExec()
{
//gargv[0]
std::string cmd = gargv[0];
bool ret = false;
if(cmd == "cd")
{
std::string currentPwd = GetPwd();
if(gargc == 2)
{
std::string target = gargv[1];
if(target == "~")
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
else if(target == "-")
{
std::string OldPwd = GetOldPwd();
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(OldPwd.c_str());
}
else
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(gargv[1]);
}
}
else if(gargc == 1)
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
}
else if(cmd == "echo")
{
if(gargc == 2)
{
std::string args = gargv[1];
if(args == "$?")
{
printf("lastcode: %d\n", lastcode);
lastcode = 0;
ret = true;
}
else
{
printf("%s\n", args.c_str());
ret = true;
}
}
}
return ret;
}

echo打印环境变量
cpp
bool BuiltInCommandExec()
{
//gargv[0]
std::string cmd = gargv[0];
bool ret = false;
if(cmd == "cd")
{
std::string currentPwd = GetPwd();
if(gargc == 2)
{
std::string target = gargv[1];
if(target == "~")
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
else if(target == "-")
{
std::string OldPwd = GetOldPwd();
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(OldPwd.c_str());
}
else
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(gargv[1]);
}
}
else if(gargc == 1)
{
setenv("OLDPWD", currentPwd.c_str(), 1);
ret = true;
chdir(GetHomePath().c_str());
}
}
else if(cmd == "echo")
{
if(gargc == 2)
{
std::string args = gargv[1];
if(args == "$")
{
if(args[1] == '?')
{
printf("lastcode: %d\n", lastcode);
lastcode = 0;
ret = true;
}
else
{
const char* name = &args[1];
printf("%s\n", getenv(name));
lastcode = 0;
ret = true;
}
}
else
{
printf("%s\n", args.c_str());
ret = true;
}
}
}
return ret;
}














