进程程序替换
先简单见一下现象及初步理解
如下的程序,执行的结果只打印了"当前进程开始运行"和ls -a -l的指令的结果,然后就结束了。而"当前进程结束运行"的内容没有打印,相当于execl里面执行的ls -a -l的程序直接把当前的程序的内容给完全替换掉了。

再从下图来理解这一现象,原来的进程有自己的进程地址空间、页表、及内存中存的旧代码和旧数据,此时执行execl时,内存中存着的代码和数据被替换成了新的代码和数据(ls指令的),此时这些数据的物理地址和虚拟可能会发生了变化,只需要更改一下原虚拟地址与新物理地址的映射关系就可以了,而PCB不需要改变。
进程=内核数据结构+自己代码和数据。进程程序替换只替换代码和数据 而PCB不改变。

拿execl来做理解
替换失败呢?
程序替换成功的话,新的代码和数据会覆盖式替换旧的代码和数据,程序就会执行新的代码,而原来代码execl后半部分的代码不会被执行了。
而替换失败了会继续执行后半部分的代码,而execl只有发生错误才会有返回值-1,因为如果没有错误就去执行新的代码了,那它返回值也就没有意义了。


先看一下里面的参数

第一个参数是要替换的程序的路径名,如上面替换后的ls的路径/usr/bin/ls,作用就是告诉当前替换后要执行谁。
后面的参数是c语言中可变参数函数 ,是c提供的宏实现,总之在使用上我们只需要知道命令行怎么写后面的参数就怎么写,就例如我们要执行ls -a -l的命令,在命令行就是这么写的那么对于后面的参数就这么写,只不过需要一个一个用分别用引号括起来然后用逗号隔开 ,另外注意最后一个参数必须是NULL为结尾,如果没有会导致未定义行为。
所以**第一个参数就是告诉执行谁,后面参数告诉我们怎么执行。**我们这样的方式就类似链表式的一个一个参数传参 所以execl后面的 l 就是list的意思。
这样的程序替换之后,当前程序会被新的代码替换掉,后面的代码就不会被执行了
如果我们想我们后面代码也可以继续执行呢?
方式很简单,用fork来创建一个子进程,让子进程替换成新的要执行的进程!
如下代码,创建了一个子进程,让子进程来替换为ls命令,如果替换失败子进程直接结束,子进程结束之后父进程在回收子进程之后父进程的代码还可以继续执行。
新替换的程序为什么不会影响到父进程?
上面的代码子进程没有影响到父进程是因为进程之间具有独立性 ,当父子任何一方对数据进行修改的时候就会发生写时拷贝,而上面提到程序替换其实就是把代码和数据进行了替换,也就是新程序的代码和数据直接覆盖式的替换了子进程的代码和数据 ,而子进程的代码和数据都是继承于父进程的,在被替换之前,子进程的代码和数据都会发生写时拷贝。
加载器额外补充
加载器是为了让程序加载到内存当中,而载入之前要变成进程,所以加载本质就是在创建进程的过程,我们自己在命令行执行的命令其实就是Shell的子进程,所以可以理解为Shell先fork创建子进程,父进程只需wait等待这个子进程结束就可以,子进程来完成程序替换的任务,加载新的程序执行,命令就被子进程执行,exec系统接口属于加载器。
程序替换能替换成我们自己写的程序吗?c的程序能替换为c++的吗?
可以,一切能转换为进程的程序都可以替换。
如下可以看到一个c的程序替换为了一个c++的程序是可以正常执行的。
不止c++程序可以被c程序直接替换,python这样的解释性语言和Shell脚本都是可以被替换的,在linux中以进程为载体的都可以进行替换。
替换不会创建新的进程
下面结果可以看到,替换前后的pid是相同的,说明替换前后是同一个进程。
exec系列的函数有多个,他们都类似于execl,功能都是进行程序替换的,替换失败才会有返回值-1,根据exec后面字母的不同在使用上会有所区别。接下来看一下exec系列其他的函数来看下他们的区别。

exec系列的其他的接口
execlp

execl最后的l 代表list,因为后面的参数需要像list那样一个一个参数来写,而execlp中的 l 就和execl完全一样的,后面的参数需要像list那样一个一个来写。
而它的p 代表PATH,影响着第一个参数 ,之前execl的第一个参数需要带路径,execlp的第一个参数就不用带路径了,直接是要替换的程序名字就可以了,因为它会自动在环境变量PATH下去寻找,当然这里没有那么严格,带路径的方式也是可以的。

如上就是execlp的写法,第一个第二个参数都是ls,那是不是有些重复了呢?
虽然前两个参数是完全一样的,但是他们的意义是不同的,第一个ls是告诉去执行谁,后面的参数是告诉执行方式怎么执行。
execv

第一个参数和execl一样,为要替换程序的路径 ,v可以理解为vector,第二个参数需要数组的方式,需要提供一个命令行参数表(指针数组)。

在知道了这些之后,来理解一下下面的问题
像 ls 这样的指令就是二进制机器码的程序,里面会存在main函数的信息,而main函数的参数里面会有argc、argv、env这样的参数,他们是怎么来的?我们自己程序的这些参数又是谁传的?
在程序地址空间那里我们知道,一个子进程被创建的时候,子进程会继承父进程的程序地址空间,而程序地址空间中就有命令行参数和环境变量,所以子进程可以得到这些信息**。**
所以子进程在替换为另一个程序的时候就可以通过execv把自己进程已有的命令行参数和环境变量这些信息传给要替换的程序。
所以我们命令行参数上执行的程序可以理解为:父进程bash创建的子进程通过execv来把命令行参数和环境变量传过去的。
execvp

在知道了前三个之后,这里完全就是类似的。第一个参数和execp同样,第二个参数同execv同样
.
并且第一个参数可以直接用argv[0],因为argv[0] 就是要替换的程序名。

execvpe

vp就不用说了,有了e后要在最后多一个参数envp[],就是就是环境变量!
如下 other.cc程序会打印所有的当前main函数参数的命令行参数和环境变量

而如果我们用execvp,第二三个参数手动传自己随便写的命令行参数和环境变量,发现打印的命令行参数和环境变量就是我们自己手动传的,并且其实-a -b这些都是不存在的使用方式
所以执行该替换后的程序的时候就是通过第一个参数找到它,后面的参数会决定替换进程里面的main函数的argv和env,再结合之前知道像ls这样的命令带-a -l 这样的参数会有不同的功能就是通过main函数参数的argv env的,我们就更加能理解第一个参数决定替换的程序,后面的参数代表执行方式这一句话了。

注意有的execvpe可能并不能直接使用,查看man手册发现有下面这样的信息,如果要使用execvpe有两种方式
1.在代码中定义 #define _GNU_SOURCE
2.在用gcc/g++编译的时候添加 -D_GNU_SOURCE
进程的命令行参数表和环境变量表就是通过这样的方式得到的 那如果我们不传呢
有下面现象:
所以如果不手动传环境变量的话,execvp内部也会默认传,通过environ全局的指针传过去了。并且程序被替换之后,通过程序地址空间中存的也可以找到。

并且第一次替换的程序应该是./other,却写错写成当前的程序./code了,所以运行就无限循环了,这就更加清晰的认识到:就是通过第一个参数找到对应的程序的,后面传的参数仅仅只是决定着替换进程得到的argc、argv这些(会影响到执行的方式 带什么参数-a -l......)。
像我们自己写的程序平常根本就没有用到main函数的参数,所以不会受到什么影响。
而像ls这样的命令会受到影响,因为之前学习我们知道ls这样的命令就是通过命令行参数表里面的参数执行对应功能的,如下此时传过去的命令行参数表为 ls -w,所以替换会的程序再执行的时候就是执行的 ls -w,而ls没有ls -w所以出错了。
execle
这就不用说了 显然就是在execl的基础上可以手动传环境变量表给替换后的程序。
不覆盖式增加环境变量给替换的进程的两种方式
1.putenv 添加新环境变量到当前子进程的环境变量中,然后采用不手动传环境变量的方式如execvp,这样替换后的程序也会得到替换前进程的环境变量。


那么就要用execvpe呢,就要自己手动传还不替换的方式呢
- 先putenv 添加新环境变量到当前子进程的环境变量中, 然后环境变量的参数传environ过去,environ之前学习过就是指向环境变量的指针是个二级指针。被替换后的程序得到environ也就能找替换前子进程的环境变量了。.

execve---- exec系列接口的底层
除此之外,还有一个execve 它在2号手册,代表系统调用,而之前提到的exec接口都在三号手册,代表库函数,就和上篇进程终止中的exit和_exit的关系类似。

系统调用只有这一个execve,exec系统的接口都是对其进行的封装,底层都是调用它,而它需要路径、命令行参数和环境变量,所以对于它来说那些exec系列的接口传的什么它这里就是什么,没有传的就用当前进程自带的,所以就能理解了为什么自己传环境变量或者命令行参数会直接替换的方式了。
自定义Shell
在学习了之前的知识后,我们自己实现一下Shell
1.获取命令行
首先每一次都会有下面这样的命令行,我们需要打印下面这样的内容
用户名@主机名:当前工作路径及提示符$

获取这些可以通过系统调用获取,不过他们的系统调用学习意义不大,这里用环境变量的方式。
如下看到基本都一样了,不过我们打印的是绝对路径,而shell打印的是从当前用户家目录开始的 这里不管它


2.接下来需要等待输入读取字符串
输入上有多种方式,这里用fgets的方式
fgets
str:指向用于存储读取数据的字符数组的指针。n:要读取的最大字符数(包括结尾的空字符\0)。stream:文件流指针,可以是stdin(标准输入)、fopen打开的文件流等。
注意fgets会把最后的\n也给读取到 所以读取之后把最后一个位置值置为0

然后把这两步的内容封装一下,看起来更美观些
命令行的读取用到了snprintf
int snprintf(char *buffer, size_t size, const char *format, ...);
- buffer:目标缓冲区指针,需预先分配足够空间。
- size :缓冲区最大容量(字节数),推荐使用
sizeof(buffer)自动计算数组大小。- format :格式化字符串,支持与
printf相同的格式说明符(如%d、%f、%s)。- ...:可变参数列表,对应格式说明符的变量。


上面是预备工作,完成了命令行打印和获取用户输入的功能。
3.命令行解析,将读取的字符串拆分
之前的学习知道我们输入的命令会被拆分,然后放到一个命令行参数表当中,如"ls -a -l"->ls -a -l
这里字符串的切割用strtok函数
#include <string.h>
char *strtok(char *str, const char *delim);
str:
- 第一次调用时,传入待分割的字符串。
- 后续调用时,传入
NULL,表示继续分割同一个字符串。delim:分隔符字符串,可以包含多个字符(如" ,;"表示空格、逗号、分号都是分隔符)。- 成功时:返回指向当前 token 的指针。
- 失败时(没有更多 token):返回
NULL。

4.创建子进程,程序替换。
然后根据上面程序替换的内容知道此时应该创建一个子进程然后进行程序替换。
我们这里的父进程就相当于Shell 通过用户输入得到了命令行参数表,然后创建子进程,子进程会继承到父进程的命令行参数表,然后用execp传命令行参数表进行程序替换,这和上面进程程序替换那里的理解一致的。


此时初步的Shell 就能使用pwd ls这样的命令了,但是cd这样的命令却不能使用。
目前程序
cpp
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define COMMAND_SIZE 1024
#define FORMAT "%s@%s:%s$ "
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
const char* GetUsrName()
{
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;
}
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUsrName(), GetHostName(), GetPwd());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
bool GetCommandLine(char *out, int size)
{
char *w = fgets(out, size, stdin);
if(w == NULL) return false;
out[strlen(out)-1] = 0; // 清理\n
if(strlen(out) == 0) return false;
// printf("%s\n",out);
return true;
}
bool CommandParse(char *commandline)
{
#define SEP " "
g_argc = 0;
// 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
g_argv[g_argc++] = strtok(commandline, SEP);
while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
g_argc--;
return true;
}
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]->%s\n", i, g_argv[i]);
}
printf("argc: %d\n", g_argc);
}
int Execute()
{
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; // rid使用一下
return 0;
}
int main()
{
while(1)
{
// 1. 输出命令行提示符
PrintCommandPrompt();
// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
CommandParse(commandline);
// PrintArgv();
// 4. 执行命令
Execute();
}
return 0;
}
内建命令处理
因为cd这个命令是子进程执行的,而它是内建命令 应该由父进程来执行。
所以在执行程序之前我们需要先拿这个命令行参数表,来判断一下命令是否为内建命令,如果是内建命令就不用创建子进程进行程序替换了,直接由父进程来执行内建命令,下面就处理cd和echo内建命令。

cd需要用到chdir 更改路径 (之前在进程状态学习也见过)

另外这里有一个问题 cd使用后当前路径确实改变了,但是命令行上的路径没有改变。
这里我们需要先知道一个问题:我们使用cd的时候,是进程路径先改变,还需环境变量先改变的?
是进程路径先变,然后环境变量才变。 而我们之前实现的命令行打印路径用到的就是环境变量的方式,而我们cd并不会把环境变量同步修改。
解决方式
1.进程路径更新后更新环境变量
2.命令行路径的方式不用环境路径的方式而用系统调用的getcwp获取进程路径
这里用2的方式
然后处理一下echo
这里需注意 echo并不一定是内建命令在某些情况喜爱可能调用外部可执行的文件,可以看到在程序中echo同样是一个可执行的程序的方式,运行时也会作为shell的一个子进程
只是了解一下,这里就当成内建命令来处理
echo使用上目前学过的有三种方式
- 正常打印后面的内容
2.打印上一次程序的退出码
3.打印具体环境变量的内容

环境变量导入
shell启动的时候会从配置获取环境变量,我们这里无法做到,就模拟实现先获取环境变量表,然后导入到当前进程的环境变量表当中。









