前言:
当你敲下**
ls -l** 并回车时,看似简单的一个命令,背后却藏着操作系统最经典的进程管理逻辑:Shell 父进程 fork 出子进程,子进程通过 exec 函数完成程序替换,最终执行 ls 命令 ------ 这就是「进程程序替换」的核心应用场景,也是 Linux 系统启动所有外部程序的底层方式。
1、开门见山------直接看效果
当我们fork创建一个子进程,然后父子进程就会各自执行父进程代码的一部分,进程程序替换就是让子进程去执行一个全新的程序。
下面通过一个程序替换函数迅速见一下效果:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我的程序要开始运行了\n");
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 必须以NULL结尾
printf("我的程序运行结束了\n");
return 0;
}

通过运行结果可知,在一个程序运行的过程中又执行了ls -a -l 这个指令,同时,程序替换函数后面的代码不再执行,相当于ls -a -l 指令覆盖了后面的代码。
2、进程程序替换的原理
进程 = PCB + 代码和数据,当执行到程序替换函数,并不会继续创建新的进程,而是把需要执行的新的程序的代码和数据覆盖式的替换掉原来的代码和数据,然后执行该新程序。
fork 创建子进程,当子进程调用程序替换函数,子进程发生写时拷贝,重新申请空间,并将虚拟内存地址映射到物理内存。

3、替换函数详解
exec函数族是Linux中提供的进程程序替换的接口函数,本质是让当前进程丢弃原有代码和数据,加载并执行新程序(PID 保持不变)。
exec 不是单个函数,而是6 个功能相似、参数不同的函数统称 ,均属于 C 标准库封装的接口,底层最终都会调用内核的**
execve()系统调用**。满足各种场景的调用,更加方便。
bashman 3 exec
• 只要程序替换成功,就去执行新的程序,原始代码的后半部分被覆盖。• exec* 函数,只有失败返回值,返回 -1,并设置errno;没有成功返回值(成功返回无意义),因此exec* 系列函数不能作返回值判断。
• 头文件:#include<unistd.h>
• 命名规律(快速记忆)
•
l(list):参数以列表形式逐个传入(可变参数,编译器 / 函数底层无法提前知道你传入了多少个参数,必须通过一个明确的 "终止符" 来判断参数的结束位置 ------NULL就是这个终止符,所以最后以NULL结尾);•
v(vector):参数放入字符串数组中传入;•
p(path):自动从PATH环境变量查找程序路径,无需写绝对 / 相对路径;•
e(environment):自定义环境变量表,替代默认的**environ**。
3.1、execl
objectivecexecl(const char *path, const char *arg, ...);•path:要替换的新程序的路径(绝对路径/相对路径)+ 程序名。
例如,指令 ls:/usr/bin/ls。
• arg:可变参数,我们在命令提示上怎么写的就怎么传。
例如:执行ls -a -l,就传:"ls","-a","-l";执行我们自己的程序:test,就传"./test"或者"test"即可。
++注意:++最后一定要用 NULL结尾,作为可变参数的终止符。
替换执行:ls -a -l
objectivec
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
替换执行:test

objectivec
execl("/home/hds/code/lesson18/test", "test", NULL);
fork()+ execl:
objectivec#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<sys/wait.h> int main() { printf("我的程序开始运行\n"); pid_t id = fork(); if(id == 0) { // 子进程 sleep(1); execl("/home/hds/code/lesson18/test", "test", NULL); exit(1); } waitpid(id, NULL, 0); printf("我的程序运行结束了\n"); return 0; }
3.2、execlp
objectivecexeclp(const char *file, const char *arg, ...);p------PATH,环境变量,帮助shell查找二进制文件,所以我们第一个参数不需要传完整的路径,只需要传名字即可。第二个参数同上。
objectivec
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我的程序要开始运行了\n");
execlp("ls", "ls", "-a", "-l", NULL);
printf("我的程序运行结束了\n");
return 0;
}

++注意:++虽然传参时第一个参数和第二个参数相同,但意义完全不同,所以不能省略。
3.3、execle
objectivecexecle(const char *path, const char *arg, ..., char *const env p[]);第一个参数为路径(同上),可变参数同上,最后char *const envp[ ]代表环境变量表。
我们可以定义自己的环境变量表,替代默认的 environ。
objectivec
int main()
{
printf("我的程序要开始运行了\n");
char *env[] = {"PATH=/home/hds/code/lesson18/test", NULL}; // 自定义环境变量表
int ret = execle("/home/hds/code/lesson18/test", "test", NULL, env);
if(ret == -1)
{
perror("execle");
exit(1);
}
printf("我的程序一下结束了\n");
return 0;
}

3.4、execv
objectivecexecv(const char *path, char *const argv[]);•path:路径 + 程序名。
•argv[ ]:命令行参数表,相当于把execl的可变参数放到了一个char* 类型的数组中。
objectivec
int main()
{
printf("我的程序要开始运行了\n");
char *const argv[] = {"ls", "-l", NULL}; // 命令行参数表
execv("/usr/bin/ls", argv);
printf("我的程序一下结束了\n");
return 0;
}

3.5、execvp⭐️
objectivecexecvp(const char *file, char *const argv[]);环境变量PATH帮助查找二进制文件。
objectivec
int main()
{
printf("我的程序要开始运行了\n");
char *const argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
printf("我的程序一下结束了\n");
return 0;
}
3.6、execvpe
objectivecexecvpe(const char *file, char *const argv[], char *const envp[]);•file:名字。
• argv[ ]:命令行参数表。
• envp[ ]:环境变量表。
4、自主Shell命令行解释器
shell命令行解释器其实就是一个死循环,当我们输入指令,则创建一个子进程,利用进程程序替换让子进程执行一个新的程序。
4.1、制作命令行提示符
先观察一下命令提示行:由用户名,主机名和当前工作目录组成。

前面我们已经学过了环境变量表,而用户名这些数据都可以从环境变量表中获得,这里再向大家提一提一个函数,用来获取我们想要的环境变量值。
头文件:<stdlib.h>
cppchar *r1 = getenv("USER"); // 查看USER的值(用户名) char *r2 = getenv("HOSTNAME"); // 查看HOSTNAME的值(主机名)如果还不太清楚环境变量相关的内容:可以移步 《【linux】环境变量(详解)》
💦 getcwd函数获得当前工作路径,并将其写入数组buf,但我们最后需要保留路径的最后部分,才和系统的命令行提示符保持一致 (如下get_dirPWD函数)。
cpp
// 获取用户名
const char *getUSERNAME()
{
char *username = getenv("USER");
return username == NULL ? NULL : username;
}
// 获取主机名
const char *getHOSTNAME()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
// 获取当前工作路径
// 定义全局变量
char cwd[1024]; // 存储当前工作路径
char cwdenv[1024]; // 存储环境变量(当前工作路径切换后需要修改环境变量的值)
const char *getPWD()
{
const char* pwd = getcwd(cwd,sizeof(cwd)); // getcwd函数
if(pwd != NULL)
{
snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
putenv(cwdenv); // 修改环境变量(覆盖式修改),保证环境变量值是最新的(cd 可能会切换路径)
}
return pwd == NULL ? "None" : pwd;
}
// 获取当前路径的最后部分(当前目录名)
#define SLASH "/" // 路径分隔符
string get_dirPWD(const char *pwd)
{
string path = pwd;
if(path == SLASH) // 当当前路径为家目录
return SLASH;
size_t pos = path.rfind(SLASH);
if(pos == string::npos) // 没找到
return "BUG";
return path.substr(pos + 1);
}
#define FORMAT "[%s@%s %s]# "
// 制作命令行提示符
void Make_Commandprompt(char *prompt, int size)
{
snprintf(prompt, size, FORMAT, getUSERNAME(), getHOSTNAME(), get_dirPWD(getPWD()).c_str());
}
4.2、打印命令行提示符
cpp
// 打印命令行提示符
void Print_Commandprompt()
{
char prompt[COMMAND_SIZE]; // 创建命令行字符数组
Make_Commandprompt(prompt, sizeof(prompt)); // 制作命令行
printf("%s" ,prompt);
fflush(stdout);
}
4.3、获取指令
首先在main函数定义一个字符数组commandline,记录输入的指令(字符串)。
通过fgets 函数从标准输入流获取字符串并写入到指定的字符数组commandline。
++注意:++
**•**由于最后我们需要Enter,而 '\n' 也会被写入到数组commandline,所以还需要将'\n' 去掉。
• 同时,由于存在alias 这样的内建命令,所以当用户输入类似 ll 的指令时需要特殊处理,这里我们创建一个别名映射表,且为全局变量(因为我们不仅在当前子进程生效)。
cpp// 别名映射表 unordered_map<string, string> alias_list;当遇到alias 指令,如 alias ll=ls -l,则key = ll,value = ls -l;然后插入到别名映射表。
cpp
// 获得命令行(等待用户输入)
bool Get_Commandline(char *commandline, int size)
{
// ls -a -l, fgets写入commandline数组后自动在结尾添加'\0',即"ls -a -l\0"
char *line = fgets(commandline, size, stdin);
if(line == NULL) // 写入失败
return false;
size_t len = strlen(commandline);
commandline[len - 1] = 0; // 清理\n
if(len == 0) return false; // 没有指令
// 如果是alias重命名后的指令,就需要更新commandline中的内容,然后才能正确执行
auto it = alias_list.find(line);
if(it != alias_list.end())
{
string value_name = it->second;
size_t size = value_name.size();
for(size_t i = 0; i < size; i++)
{
commandline[i] = value_name[i];
printf("%c", value_name[i]);
}
cout << endl;
}
return true;
}
4.4、命令行分析(命令行参数表)
系统将我们输入的命令行,即commandline中的内容,放在命令行参数表(argv)中,即将我们输入的命令按照空格进行分割。
定义我们自己的命令行参数表:
cpp// 命令行参数表 #define MAXARGC 128 char *g_argv[MAXARGC]; // 命令行参数表 int g_argc = 0; // 实际输入的命令行参数个数
cpp
// 分隔命令行,方便执行选项
#define DIV " " // 分隔符
bool Commandline_Prase(char *commandline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, DIV); // 用strtok()函数分割命令行
while((bool)(g_argv[g_argc++] = strtok(nullptr, DIV))); // 继续向后分割需要传nullptr作为第一个参数
g_argc--;
return true;
}
4.5、判断内建命令
由于内建命令直接由shell进程执行,所以我们需要将用户输入的内建命令单独处理。这里我们只对cd,echo,alias三个内建命令进行简单实现。而我们之前已经将命令行参数放到了g_argv数组,所以,只需要判断g_argv的首元素是否是内建命令即可。
• 对于cd,无非就是cd / cd ~------切换到家目录,cd ..------切换到上级目录,cd - ------切换到最近的一个目录,cd **------切换到指定目录。
chdir函数用于切换目录到path**。**
• echo:
(1)我们可以实现打印上一个进程的退出码(echo $?),所以我们需要维护一个全局变量last_exit_code存储退出码信息,而只有当子进程正常退出时,退出码才有意义,所以我们需要在父进程waitpid等待成功的时候更新退出码信息;
(2)打印环境变量值(echo $**),所以我们还需要维护一张我们自己的环境变量表;
(3)打印指定内容(echo ***)。
cpp
// 获取家目录
const char* GetHome()
{
const char* home = getenv("HOME");
return home == NULL ? "" : home;
}
// 内建命令cd
bool Cd()
{
// cd 命令:cd, cd .., cd -, cd ~, cd /, cd **
if(g_argc == 1) // cd:切换到家目录
{
string home = GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}
else
{
string where = g_argv[1];
if(where == "-")
{
// ...
}
else if(where == "~")
{
// 同cd
}
else
{
chdir(where.c_str());
}
}
return true;
}
// 内建命令 echo
int last_exit_code = 0; // 全局变量,存储上一个进程的退出码
void Echo()
{
// echo $?, echo $PATH, echo **
if(g_argc == 2)
{
string str = g_argv[1];
if(str == "$?")
{
cout << last_exit_code << endl;
last_exit_code = 0;
}
else if(str[0] == '$') // 打印环境变量值
{
string env_name = str.substr(1); // 获取环境变量名
const char *env_value = getenv(env_name.c_str()); // 获取环境变量值
if(env_value)
cout << env_value << endl;
}
else
{
cout << str << endl;
}
}
}
// 内建命令alias
void Alias()
{
// alias ll=ls -a -l
string cmd = g_argv[1];
auto pos = cmd.find("=");
string key(cmd.begin(), cmd.begin() + pos);
string value(cmd.begin() + pos + 1, cmd.end());
for(int i = 2; g_argv[i]; i++)
{
value += " ";
value += g_argv[i];
}
alias_list[key] = value;
}
// 判断是否是内建命令并执行
bool CheckBuildin()
{
string cmd = g_argv[0];
if(cmd == "cd")
{
Cd();
return true;
}
else if(cmd == "echo")
{
Echo();
return true;
}
else if(cmd == "alias")
{
Alias();
return true;
}
return false;
}
4.6、创建环境变量表
本来要从配置文件生成环境变量表 (实现不了),所以通过environ获得,environ是一个全局外部二级指针,核心作用是指向当前进程的环境变量表(以 NULL 结尾的字符串数组)
而shell启动时就有一个environ 指向其环境变量表,而我们就从shell启动时创建我们的环境变量表。
cpp
// 环境变量表
#define MAXENVS 128
char *g_env[MAXENVS]; // 环境变量表
int g_envs = 0; // 实际环境变量个数
void InitEnv()
{
extern char **environ;
// 处理环境变量表
memset(g_env, 0, sizeof(g_env));
g_envs = 0;
for (int i = 0; environ[i]; i++)
{
g_env[i] = (char*)malloc(strlen(environ[i]) + 1); // 申请空间
strcpy(g_env[i],environ[i]); // 将shell的环境变量表内容向子进程环境变量表拷贝
g_envs++; // 环境变量个数
}
// 环境变量表最后放置NULL
g_env[g_envs++] = (char*)"MY_ENV_FLAG=HDS";
g_env[g_envs++] = NULL;
// 导成环境变量表
for (int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
environ = g_env; // 更新子进程environ全局变量
}
4.5、进程程序替换------执行
目前我们已经有了命令行参数表,环境变量表,所以,自然而然 execvp 函数就是我们最好的接口选择。
objectivecexecvp(const char *file, char *const argv[]);命令行参数表第一个元素就是程序名字,传给 file 参数
cpp
// 执行指令
int Execute()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
execvp(g_argv[0], g_argv);
exit(1);
}
// 父进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
// 等待成功
if(rid > 0)
{
last_exit_code = WEXITSTATUS(status); // 更新退出码信息
}
return 0;
}
4.6、main函数
cpp
// 头文件
#include<iostream>
using namespace std;
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<map>
#include<unordered_map>
#define COMMAND_SIZE 1024
int main()
{
// 环境变量表在启动shell时生成
InitEnv();
while(true)
{
// 1. 打印命令行提示符
Print_Commandprompt();
char commandline[COMMAND_SIZE];
bool flag = Get_Commandline(commandline, sizeof(commandline)); // 2.获取指令
if(!flag)
continue;
Commandline_Prase(commandline);// 3.命令行分析
// 4.判断是否是内建命令
if(CheckBuildin())
continue;
// Print_argv(); // for test
// 4.执行
Execute();
}
return 0;
}
5、完整源码
效果演示

😄 创作不易,你的点赞和关注都是对我莫大的鼓励,再次感谢您的观看😘







