Linux操作系统-进程的“夺舍”:程序替换如何清空内存、注入新魂?
这里我们要用到main函数的参数argv,也就是我们之前讲过的命令行参数,我们让arg指针指向argv的第二个数据,因为第一个数据是我们要执行的文件,后面才是要怎么执行,并将argv的第一个数据传给execv的第一个参数,之后将arg指向的从下标为1开始的数组传给第二个参数。这里我以execvpe为例,我们这次让子进程来执行我们自己写的程序,程序的内容就是打印出命令行参数和环境变量,程序的结果我们

🔥海棠蚀omo:个人主页
❄️个人专栏:《初识数据结构》,《C++:从入门到实践》,《Linux:从零基础到实践》
✨追光的人,终会光芒万丈
博主简介:

今天我们就到了进程的最后一个话题:进程程序替换,那这个话题是讲什么的呢?我们先来思考一个问题:fork()之后,父子进程会各自执行父进程代码的一部分,那你就没有想过让子进程去执行全新的程序吗?
这个问题想必大家之前都想过,这个问题换个问法就像我们之前深入fork函数时讲的例子:你父亲有两个工厂,有了你,等你长大后,去继承你父亲的一个厂子,你们父子二人共同去经营这两个厂。但是你就没想过你不去继承你父亲的厂子,而是你自己去打拼,有自己的厂子,自己去经营?
回到父子进程的话题,而要实现上面的操作,我们就需要今天要讲的知识:进程程序替换,下面且听我来说道说道。
一.进程程序替换的原理
首先我们要先大概了解一下进程程序替换的原理是什么,这样下面进行实操时大家就能知道是怎么个事了,废话不多说,我们先来看一张图:

页表左边的部分就是一个进程中含有的结构,包括:PCB,虚拟地址空间(mm_struct),页表,中间就是我们的物理内存,虚拟内存中的各部分,如:代码段,数据段都经过页表映射到物理内存中的相应位置。
而进程程序替换是怎么做的呢?
简单来讲就是将图中右边磁盘中新的程序(代码和数据)覆盖掉或者说替换掉原本物理内存中代码段和数据段的内容,那么当前进程在执行时就会按照新的代码和数据来运行。
我们要覆盖的是物理内存中的代码和数据,而物理内存又是被操作系统所管理的,那么换句话说也就是我们要完成上面的操作就要访问操作系统中的资源,那么我们该如果访问呢?
没错,正是通过系统调用,也就是进程程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!
有了上面对进程程序替换的初步认识,我们先面就来看看具体是如何操作的。
二.程序替换函数
在这里我将介绍6中常见的程序替换函数,每一种替换函数都有些许的不同,下面我一个一个来介绍。

2.1 execl函数
![]()
我们先看这个函数参数部分,第一个参数从名字我们就能猜到这个参数应该传什么,就是路径,也就是指定你要让当前的进程执行哪个新的程序?
而第二个参数的含义就是你想要执行程序的程序名称,如:“ls”,”pwd“,这里这么说大家可能不太理解,下面我们实操时大家就能明白。
而后面的参数是...,这个相比都不陌生,应该都见过,正是可变参数,这点在printf函数的参数中也是有的:

这里就不详细介绍什么叫做可变参数了,不是我们今天讲的重点,那么相比大家心中都有一个疑问:那就可变参数传什么呢?
我们上面说第二个参数要传执行程序的程序名称并举了例子,而那些例子我们都知道后面是可以跟选项的,如:ls -a -n -l等,可变参数传的就是后面的这些选项,并且最后一位一定是NULL,因为NULL代表程序替换结束的标志。
2.1.1 单个进程
废话不多说,我们直接来看实例:


这里我们选执行的是系统内部的程序,也是我们比较熟悉的命令ls,我们通过结果可以看到进程通过execl函数确实执行了ls命令,可能有人会疑惑:为什么没有执行后面的printf函数呢?
我们在上面介绍进程程序替换原理时就说了,程序替换是会替换掉你原先的代码和数据的,既然已经替换了,那么后面代码中的printf函数自然就没有了。
那为什么会执行前面的printf函数呢?
因为前面的printf函数在execl函数之前,而程序是顺序结构执行的,所以会先执行printf函数,在执行execl函数后程序的代码和数据会被替换为ls的可执行文件中的代码和数据。
这里可能有人会有疑问:那第一个参数含有ls,第二个参数还是ls,不会显得重复吗?
答案并不会,这两个语义是不同的,第一个参数我们要指定具体你要执行哪个可执行文件,而第二个参数是我们想怎么执行这个可执行文件。
我们通过上面的输出对比图就可以发现,第二个参数及后面的可变参数对应的不正是我们在命令行中输入的ls -l -a吗?所以说二者并不重复。
也就是说我们要执行哪个文件?
/usr/bin/ls这个路径下的可执行文件,怎么执行?
以ls -l -a的方式执行,我们在命令行怎么写,第二个参数及后面的可变参数就怎么传,这样大家就能更好地去理解。
而在上面的execl图中我们也注意到了execl这个函数是有返回值的,既然是替换,那么肯定会有失败的情况啊,我们来看看它返回的是什么:

这句话的意思是exec系列的函数只会在替换失败时返回-1,而替换成功则什么也不返回。
这种情况我们其实也能理解,替换成功了就去执行新的代码,即使成功了给你返回值,而你下面的代码也用了这个返回值,但是有什么意义呢?
我替换成功就直接将原来的代码和数据替换了,当然也包括你在execl函数后面写的用到返回值的代码,所以说成功了给返回值没有意义。
那什么情况下会出现替换失败呢?


那就是你想执行一个不存在的可执行文件,既然不存在那可不就会替换失败吗?
而替换失败就会接着执行后面的代码,我们在替换失败后打印出返回失败的返回值,结果也正如上图所示。
2.1.2 父子进程
上面我们只介绍了单独一个进程的程序替换,但是重点我们还是要放在fork之后产生的父子进程上面,下面我们就来看一个父子进程的实例:


根据我们上面所说,子进程在执行execl函数时就会去执行全新的代码,结果也符合我们的预期,而父进程同样也执行了自己的代码,但是我想在这正确的输出结果下有人可能会有疑问:不是说父子进程共享代码和数据(写时拷贝)吗,那子进程通过execl函数替换了代码,父进程为什么还会执行后面的代码?
原因是要保持父子进程的独立性,子进程替换了代码,但是因为进程之间的独立性,它并不能影响父进程的执行,那要如何做到保持父子进程之间的独立性呢?
答案就是写时拷贝,既然数据都能通过写时拷贝的方式来保持独立性,那么代码也同样能通过写时拷贝来保持独立性,方式和数据的写时拷贝是一样的:

经过写时拷贝,子进程的代码和数据就会执行新的地址空间,并修改页表中相应的映射关系,用这种方式也就保持了进程之间的独立性。
有了前面知识的铺垫,我们来思考一个问题:我们总说,要执行一个二进制文件,要先将程序加载到内存中,这是由冯诺依曼体系结构决定的,那到底是怎么加载的呢?
这个问题想必我们之前并没有人深入思考过,在这里我们就能理解个大概了:我们上面自己写的程序在某种意义上不正是一个” 解释器 “吗?
我们用自己写的代码借助系统调用接口将磁盘中的一个新的程序加载到了内存当中,这样说可能不太明显,下面通过其他的接口我们来证明一下。
2.2 execv函数
![]()
第二个函数我们来讲execv,这个函数相比于上面的execl看起来也就是参数有些不同罢了,其实也就是参数不同。
那么execv的第二个参数这个数组是怎么个事呢?
这个其实就是将execl后面的参数都装进了一个数组中,也就是说数组中的内容和之前是一样的,我们来看其基本应用:


这次我们换个程序执行,也是我们熟悉的pwd命令,这个函数要传一个数组,数组中的内容最后一个也要是NULL,和execl是一样的。
其实execv这个函数与execl就只是传参方式不同罢了,execl需要直接传参,而execv则是将要传的参数写入一个数组中,在将数组传进去,就这么点区别,所以execv的v也就是我们常见的vector。
说到这里,我们利用这个函数来给大家演示上面我们所说的” 解释器 “的证明:


这里我们要用到main函数的参数argv,也就是我们之前讲过的命令行参数,我们让arg指针指向argv的第二个数据,因为第一个数据是我们要执行的文件,后面才是要怎么执行,并将argv的第一个数据传给execv的第一个参数,之后将arg指向的从下标为1开始的数组传给第二个参数。
我们看命令行的输入,将./myexec当成解释器,这样写不就是我用解释器来解释后面要执行的代码吗?

结构就如上图所示,上面我们在写代码并没有写main函数的参数,所以只用./myexec即可,但其实底层在传参时是按照上面的参数进行传参的,无非就是execv中的参数有两个/usr/bin/ls而已,不影响,我们之前第二个参数不写路径是因为第一个参数已经写过路径了,系统已经能找到要执行的文件了,所以就省略没写。
2.3 execlp函数和execvp函数
因为上面的execl和execv非常相似,这里的execlp和execvp同样也是如此,所以将这两个函数放在一块讲,我们先来看看这两个函数:
![]()
![]()
依旧是传参形式不同,和上面的两个是一样的,不过我们之一到这两个函数的第一个参数不再是pathname而是file,是有什么区别呢?我们看例子就知道区别在哪儿了:



很显然,这两个函数相较于前面的两个函数区别在于第一个参数不再需要传路径了,它会自动去环境变量PATH含有的路径中去找你要执行的文件。写起来就更加简单和方便了,剩下的和上面两个函数没区别,这里就不过多介绍了。
2.4 execle函数和execvpe函数
![]()
![]()
这两个函数相比较上面四个函数多了一个参数,就是用红色方框圈起来的envp,这个参数也是一个数组,那么是什么呢?
答案是环境变量,这个参数用于指定新程序执行时的环境变量,既可以传我们自己定义的环境便变量数组,也可以传系统默认的环境变量数组,其实也就是我们之前提到过的环境变量表,下面我们一起来看看它的基本应用:
![]()



这里我以execvpe为例,我们这次让子进程来执行我们自己写的程序,程序的内容就是打印出命令行参数和环境变量,程序的结果我们也看到了命令行参数和环境变量的内容,其中环境变量就是我们所传进去的,并且命令行参数正是arg数组中的内容。
那我们要想打印出系统的默认环境变量呢?我们来看:


利用我们之前讲环境变量时的environ即可,它指向系统默认的环境变量表,这样我们就能获得所有体统的环境变量。
那有人又会问了:那我要想既想传自定义环境变量,又想传系统环境变量该如何做呢?
很简单,我们将自定义环境变量变成系统环境变量不就行了,那该如何做呢?

我们可以通过putenv这个函数来讲我们自定义的环境变量给加到系统的环境变量中,那效果怎么样呢?我们来看看:


从结果可以看到我们确实将自定义的环境变量加入到了系统的环境变量中,至此也就解决了上面的问题。
而execle函数的应用和execvpe差不错,这里就不再演示了。
最后其实还有最后一个exec系列的函数,我们简单介绍一下。
2.5 execve函数


我们对比这两张图可以发现上面的execve函数是在linux手册的第2章节,而我们上面介绍的exec的一系列函数都在手册的第3章节,有什么区别呢?
在第2章节的都是系统调用函数,而在第3章节的都是库函数,讲到这里想必有的朋友就明白我想说什么了。
没错,上面我们介绍的一系列exec函数都是底层都是由execve系统调用函数实现的,它们都是对execve函数进行了封装,传给它们的参数都会在函数内部进行转变,转变为execve函数的参数并将其传进去。
有了上面内容的铺垫我们就可以解决一个之前没有讲清楚的问题:我们之前总说父进程获得了命令行参数和环境变量,就比如:bash,它把命令行参数表和环境变量表传给了子进程,那到底是如何传的呢?
答案就是我们上面讲的exec系列函数,就和我们上面讲的” 解释器 “例子是一样的,bash通过这些函数讲命令行参数表和环境变量表传给了子进程,上面execve的argv和envp两个数组就是啊。
并且如果你不以参数的形式传给子进程,子进程也是能拿到的,为什么呢?

因为虚拟地址空间中就有命令行参数和环境变量啊,即使你不传,子进程也能通过虚拟地址空间和页表来找到物理地址中的命令行参数和环境变量。
以上就是进程的“夺舍”:程序替换如何清空内存、注入新魂?的全部内容。
更多推荐


所有评论(0)