进程控制-下篇
一.进程程序替换
1.1替换原理
进程程序替换的原理是利用操作系统提供的 exec
系列函数,使一个进程放弃当前执行的程序,加载并执行另一个程序。操作系统负责更新进程的虚拟内存映射、页表、进程控制块(PCB),并将新程序的代码和数据从磁盘加载到物理内存中。替换完成后,进程的程序计数器被设置为新程序的入口点,从而开始执行新程序,而进程ID保持不变。
进程的程序替换是指在一个已经存在的进程中加载并执行一个新的程序。这通常涉及到使用 exec
系列函数。
我们可以来快速看看程序替换是什么样的,下面有一个样例:
1.1.1进程替换的细节:
-
PCB(进程控制块):包含进程的状态信息,如进程ID、寄存器状态、内存管理信息等。当进程调用
exec
函数时,PCB中的程序相关信息会被更新。 -
虚拟内存:进程的虚拟内存空间包括代码段、数据段和堆栈。在程序替换时,这些虚拟内存区域会被新程序的相应部分替换。
-
页表:页表负责将虚拟内存地址映射到物理内存地址。当新程序加载时,页表会更新以反映新的内存映射。
-
物理内存:物理内存中的内容会被新程序的代码段、数据段和堆栈替换。原有的物理内存内容(属于旧程序)会被丢弃。
-
磁盘:新程序的代码和数据从磁盘加载到物理内存中。这涉及到从磁盘读取新程序的ELF(Executable and Linkable Format)文件,并将其映射到进程的虚拟内存空间。
1.1.2进程替换的过程:
-
调用
exec
函数:进程调用execl
、execv
、execle
或execve
函数之一来替换当前进程的程序。 -
加载新程序:操作系统从磁盘读取新程序的ELF文件,并将其加载到物理内存中。(ELF文件,全称为Executable and Linkable Format(可执行与可链接格式),是一种常见的文件格式,用于存储可执行文件、目标代码和共享库等。ELF文件格式广泛应用于Unix系统(如Linux)和类Unix系统(如FreeBSD、NetBSD和Solaris)中。它具有可移植性、模块化、灵活性和扩展性等特点。)
-
更新页表:操作系统更新页表,将新程序的虚拟内存地址映射到物理内存地址。
-
替换虚拟内存:新程序的代码段、数据段和堆栈替换原有程序的相应部分。
-
更新PCB:操作系统更新PCB中的程序相关信息,如程序计数器(PC)设置为新程序的入口点。
-
开始执行新程序:进程开始执行新程序,从新程序的入口点开始。
进程的程序替换是通过 exec
系列函数实现的。它涉及到从磁盘加载新程序、更新页表、替换虚拟内存和更新PCB。这使得进程可以执行完全不同的程序,而进程ID保持不变。
所以在程序替换的过程中,并没有创建新的进程,只是把当前进程的代码和数据用新的程序的代码和数据覆盖式的进行替换!
1.2替换函数
1.2.1替换函数解析
我们如果execl这行代码给的是错误路径:
我们先来看看execl函数的返回值吧:
图片中的说明指出:
-
exec()
函数仅在发生错误时才会返回。 -
如果
exec()
函数成功执行,它将不会返回,而是直接开始执行新程序。 -
如果发生错误,
exec()
函数返回-1
。 -
同时,错误码会被存储在全局变量
errno
中,用于指示具体的错误类型。
这意味着,如果 exec()
函数调用成功,调用它的进程将被新程序替换,并且不会返回到原来的程序。如果需要处理 exec()
调用失败的情况,应该检查返回值是否为 -1
,并检查 errno
以了解错误原因。(也就是 exec() 类函数,只有失败的返回值,没有成功的返回值!!!因为成功之后,后续的代码已经被替换了)
后面我们就没必要拿取exec()类函数的返回值了,直接使用exit(),进行程序的退出。
以下是 exec
函数族的六个成员,它们都定义在 unistd.h
头文件中,用于在当前进程中执行新的程序:
execl
int execl(const char *path, const char *arg, ...);
用于执行指定路径的程序,并将后续参数作为命令行参数传递。(程序路径+命令(也可以是命令的路径)+选项)
参数列表以 NULL 结尾。
execl("/usr/bin/ls","ls","-a","-l",NULL);
//等价于:
lfz@hcss-ecs-ff0f:~/lesson/lesson17$ ls -a -l
当然,我们也可以直接-al,而不是分开的去写。
execlp
int execlp(const char *file, const char *arg, ...);
用于执行在用户环境变量 PATH
中搜索到的程序。(就跟我们直接在命令行上直接输入" ls ",也就是意味着使用该接口最好是对于系统级命令才合适,自己写的可执行程序就够呛的了!)
file
参数是程序名称,而不是完整路径。
参数列表以 NULL 结尾。
execlp("ls","ls","-al",NULL);
这里传了两个" ls "是不冲突的,直接的,在语言层上是符合的,重要的是,这两个表达的语义是完全不一样的 ,因为第一个参数是表达:我要执行谁,第二个参数是表达:我要怎么执行他!
execle
int execle(const char *path, const char *arg, ..., char *const envp[]);
类似于 execl
,但允许指定环境变量。
参数列表以 NULL 结尾,最后一个参数是指向环境变量数组的指针。
execv
int execv(const char *path, char *const argv[]);
用于执行指定路径的程序,并将参数数组传递给程序。
argv
是一个以 NULL 结尾的字符串数组。(v=vector)(也就是提供一个命令行参数表,即指针数组)
char *const argv[]={
(char *const)"ls",
(char *const)"-a",
(char *const)"-l",
NULL
};
execv("/usr/bin/ls",argv);
execvp
int execvp(const char *file, char *const argv[]);
类似于 execv
,但会在用户环境变量 PATH
中搜索程序。
file
参数是程序名称,而不是完整路径。
char *const argv[]={
(char *const)"ls",
(char *const)"-a",
(char *const)"-l",
NULL
};
execvp(argv[0],argv);//这里ls也是argv[0],所以这里我就写了这个
execvpe
int execvpe(const char *file, char *const argv[], char *const envp[]);
类似于execvp
,但允许指定环境变量。
file
:要执行的程序名称。
argv
:是一个以NULL
结尾的字符串数组,包含传递给新程序的命令行参数。
envp
:是指向环境变量数组的指针。
execve
这个是在2号手册的,其他都是在3号手册的,其他的都不是系统调用,Linux系统当中,直接执行程序替换,只提供了一个:execve,这是当中唯一的系统调用,其他是C语言级别的封装:为我们提供各种的传参形式。(这就是上层提供给我们比较好用的接口,最总还是调用系统调用)
execve
是一个在 Unix 和类 Unix 系统中用于执行新程序的系统调用。它替换当前进程映像为新程序的映像,但保留进程的 PID、文件描述符、信号处理函数等内核态信息。execve
允许指定新程序的路径、命令行参数以及环境变量。
#include <unistd.h>
int execve(const char *path, char *const argv[], char *const envp[]);
-
path
:一个指向以 null 结尾的字符串的指针,指定了可执行文件的路径。 -
argv
:一个字符串数组,以 null 结尾,包含传递给新程序的命令行参数。argv[0]
通常是执行文件的名称,argv[1]
是第一个参数,依此类推,argv[argc]
为 null。 -
envp
:一个字符串数组,以 null 结尾,包含环境变量,每个环境变量也是以"name=value"
的形式出现,最后一个元素为 null。
返回值:
-
成功时,
execve
不会返回。新程序将从其main
函数开始执行。 -
失败时,返回 -1 并设置
errno
以指示错误。
总结来说,execve
是底层的系统调用,而其他的 exec
系列函数都是基于 execve
的不同封装,提供了不同的功能和便利性。
问题
然而,程序替换有一个特性,就是影响当前进程,假设我不想让他影响当前进程,所以,我们可以用创建子进程的方式来进行代码的分流管理:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我的程序要运行了...\n");
if(fork()==0)
{
//child
execl("/usr/bin/ls","ls","-a","-l",NULL);
//后面的代码如果执行,那么就是exec失败了
exit(1);
}
waitpid(-1,NULL,0);
printf("我的程序运行完毕了!\n");
return 0;
}
因为进程具有独立性,所以exec并没有影响父进程,虽然子进程拷贝自父进程,但是子进程代码和数据是修改的了,发生写时拷贝。
我们编译的时候要有编译器,形成可执行程序之前还要有链接动静态库,链接完成程序要加载,,那么,加载就需要加载器,加载就是把程序载入到我们的内存当中,后来我们学习了操作系统我们知道:任何程序在载入内存的时候,首先要变成进程,所以,程序的加载本质就是一个动态创建一个进程的过程,我们大概了解了shell之后,我们基本所有的命令都是bash的子进程。我们之前的认识到的命令是怎么跑起来的?大概就是:shell是一个进程,然后fork出子进程,父进程只需要wait等待就行了,子进程转而去进行程序替换,加载新的程序执行,此时我们对应的命令就可以被子进程跑起来了,所以我们今天就可以简单理解成:我们的exec系列的系统接口其实就属于加载器的范畴,也就是说:如果今天存在一个加载器,这个加载器底层用到的系统调用接口就是exec类接口。我们可以先有进程,然后再进程替换,也可以先创建子进程,让子进程进行程序替换来加载新的代码来执行,这种形式就是加载。
那我们能替换我们自己写的程序吗?
一切能转化成进程运行的程序都是可以替换的:
我们定义一个other.cc文件:
//other.cc
#include <iostream>
using namespace std;
int main()
{
cout<<"hello C++"<<endl;
return 0;
}
我们可以将other.cc编译形成的可执行程序在proc程序中进行程序替换:
//proc.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我的程序要运行了...\n");
if(fork()==0)
{
//child
execl("./other","other",NULL);
//后面的代码如果执行,那么就是exec失败了
exit(1);
}
waitpid(-1,NULL,0);
printf("我的程序运行完毕了!\n");
return 0;
}
我们看看结果:
我们就可以根据程序替换将python/shell脚本等语言写的程序调用起来!!!
我举个python的例子来看看吧:
我们可以进行进程替换:
execl("/usr/bin/python3","python","other.py",NULL);
任何一个脚本语言去执行的根本不是对应的脚本"./other.py",而是对应的解释器"/usr/bin/python3",执行的命令是"other.py"。
我要执行解释器"/usr/bin/python3",通过"python" "other.py"去执行他!
总结:程序替换是系统层面的,只要是进程,就可以进行程序替换,不管到底是什么语言!!!
还有一个问题:
进程替换是否创建新进程?
我们用下面代码来测试:(我们其实可以看看pid是否相等就可以判断了)
我们修改other.cc源文件代码:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int main()
{
cout<<"hello C++, my pid is "<<getpid()<<endl;
return 0;
}
proc.c:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我的程序要运行了...\n");
if(fork()==0)
{
//child
//execl("/usr/bin/python3","python","other.py",NULL);
printf("I am child, my pid is %d\n", getpid());
execl("./other","other",NULL);
//后面的代码如果执行,那么就是exec失败了
exit(1);
}
waitpid(-1,NULL,0);
printf("我的程序运行完毕了!\n");
return 0;
}
结果:我们发现子进程进程替换后的pid不变:
所以我们exec只是进行了代码数据的替换,并没有创建新进程!!!
main函数的argc,argv,argv的参数是如何通过exec实现的传递的
在Unix/Linux系统中,main
函数的参数argc
和argv
是通过exec
系列函数传递给子进程的。exec
系列函数用于在当前进程中执行新的程序,它们替换当前进程的映像,使其执行不同的程序,而不是创建新的进程。这些函数可以替换当前进程的用户态内容(如代码段、数据段、堆、栈等),而进程的内核态资源(如PID、打开的文件描述符等)被保留。
当父进程通过fork()
创建子进程后,子进程会继承父进程的地址空间,包括环境变量和命令行参数。如果父进程随后在子进程中调用exec
系列函数(如execve
),它会用新的程序映像替换当前进程的映像,但可以指定新的命令行参数(通过argv
)和环境变量(通过envp
)。这样,即使子进程是由父进程派生的,它也可以执行一个完全不同的程序,并拥有一套新的参数和环境变量。例如,execv
函数接受程序路径和参数数组,参数以NULL
结束。而execve
函数则允许指定环境变量数组,使得新程序可以在不同的环境变量设置下运行。
测试:
other.cc:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int main(int argc, char* argv[], char* envp[])
{
cout<<"hello C++, My pid is "<<getpid()<<endl;
for(int i=0;i<argc;i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
cout<<endl;
for(int i=0;envp[i];i++)
{
printf("envp[%d]: %s\n", i, envp[i]);
}
return 0;
}
proc.c:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我的程序要运行了...\n");
if(fork()==0)
{
printf("I am child, my pid is %d\n", getpid());
sleep(1);
char *const argv[]={
(char *const)"other",
(char *const)"-a",
(char *const)"-b",
NULL
};
char *const envp[]={
(char *const)"MYVAL=123456789",
NULL
};
execvpe("./other",argv,envp);
exit(1);
}
waitpid(-1,NULL,0);
printf("我的程序运行完毕了!\n");
return 0;
}
测试结果:
这时候打印出来的就不是之前的环境变量了(之前默认是系统的环境变量:environ全局指针指向的环境变量表,而且环境变量就直接在虚拟地址空间上),这里要求了被替换的子进程,使用了全新的envp列表了!!!
我们这时候可以讲讲:
putenv
函数在 C 语言中用于修改环境变量。它接受一个字符串参数,该字符串包含了要设置的环境变量及其值。putenv
函数会将这个字符串添加到进程的环境变量列表中,从而影响当前进程及其子进程的环境变量:(新增式的导入)
int putenv(const char *envstring);
envstring
:一个以 null 结尾的字符串,格式为 "name=value"
,其中 name
是环境变量的名称,value
是该环境变量的值。
-
成功时返回 0。
-
失败时返回非 0 值。
我们这里设置一个全局的环境变量表,不然会有坑。
我们直接传自己写的addenvp[]默认会导致原本的envp[]被覆盖,所以,我们可以使用putenv来在原有环境变量表上新增自己的addenvp[]表的内容:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
char *const addenvp[]={
(char *const)"MYVAL=123456789",
NULL
};
//char* newnew=(char*)"myVAL=666666";
int main()
{
printf("我的程序要运行了...\n");
if(fork()==0)
{
printf("I am child, my pid is %d\n", getpid());
sleep(1);
char *const argv[]={
(char *const)"other",
(char *const)"-a",
(char *const)"-b",
NULL
};
for(int i = 0;addenvp[i];i++)
{
putenv(addenvp[i]);
}
extern char** environ;
execvpe("./other",argv,environ);
exit(1);
}
waitpid(-1,NULL,0);
printf("我的程序运行完毕了!\n");
return 0;
}
共同点:
-
所有函数在成功执行时都不会返回,如果返回则表示出错,返回值为
-1
,并且会设置errno
以指示错误类型。 -
这些函数会替换当前进程的映像为新程序的映像,但不会改变进程ID。
不同点:
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
-
l(list):表示参数采用列表形式输入
-
v(vector):参数用数组
-
p(path):有p自动搜索环境变量PATH
-
e(envp):表示自己维护环境变量