【Linux】进程控制(二) 深入理解进程程序替换与 exec 系列函数

文章目录


一、进程程序替换

我们之前讲过fork () 之后,父子进程各自执行父进程代码的一部分,也就是代码共享,数据默认也"共享",但是发生写入后就会以写时拷贝各自私有。那如果子进程想执行一个全新的程序成为一个真正独立的进程呢?这就需要通过进程的程序替换来完成这个功能!

程序替换是通过特定的系统调用接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!

替换原理

进程替换原理很简单,就是进程调用某种系统调用,从磁盘中加载一份全新的代码和数据到该进程物理内存中,覆盖掉原进程在内存中的代码和数据。程序替换并没有创建新进程,只是改变了进程的物理内存。

我们空讲无用,小编打算先实际上手代码让大家见一见程序替换的效果,之后再回头结合原理讲解。

进程替换需要调用exec(注意不是excel表格)系列接口,一共有六个,还有一个接口我们后面补充:

我们先看最简单的excel:

cpp 复制代码
int execl(const char* path, const char* arg, ...);

我们要执行一个程序首先要找到它,第一个参数就是用来帮助我们找到它,第二个参数是我们要执行程序的程序名,三个点表示可变参数,可填可不填,如果要填这部分参数指的是给程序传递的命令行选项,并且该部分参数传递完毕后必须以NULL结尾。

传递参数注意事项:除了path外,后面的参数你在命令行中怎么写,就在这里怎么传递。

下面直接上示例:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("我是一个进程: %d\n", getpid());
    sleep(1);

    execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);

    printf("运行结束\n");
    return 10;
}

运行结果:

我们看到execl替换后的执行结果和ls命令一样,说明这样确实就可以让这个程序不执行自己的代码和数据,转而去执行ls的代码和数据。

但是这里还有个现象,替换后 printf("运行结束\n"); 这条代码为什么没有运行了呢? 很容易理解,因为你的程序替换后开始执行另一个程序的代码了,你自己的代码已经被覆盖了。所以程序替换一但成功,后续代码不再执行,因为没有了!

那既然程序替换有成功,那也一定有失败,我们下面直接让程序替换失败来看现象:(执行一个不存在的指令就会失败)

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("我是一个进程: %d\n", getpid());
    sleep(1);

    int n = execl("/usr/bin/lllls", "ls", "-a", "-l", "-n", NULL);                                                              

    printf("运行结束, n = %d\n", n);
    return 10;
}

运行结果:

我们可以看到程序替换失败后后续代码还会正常执行,所以一但程序替换后续的代码被执行了,就表示程序替换失败。

我们还可以看到替换失败后execl返回-1,那如果替换成功还需要返回值吗?我们仔细想想,程序替换成功后execl的返回值就没有意义了,就算有后续代码也不会执行。所以程序替换如果成功,不需要、也不会有返回值!------>所以execl系列函数,一但返回,必然失败!

所以我们的代码应该这样写:

cpp 复制代码
#include <stdio.h>  
#include <unistd.h>  

int main()  
{  
    printf("我是一个进程: %d\n", getpid());  
    sleep(1);  

    execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);                                                                      

    printf("程序替换失败!\n");     
    return 10;                      
}

子进程程序替换示例

有了上面的认识,我们再回过头看程序替换理论,前面都是替换当前进程的代码和数据,但我们一开始介绍程序替换概念的时候说程序替换是用来让子进程执行全新的代码的,所以接下来将介绍子进程是如何程序替换。

在开始编写代码之前,我们要先理解一些概念,子进程确实可以被替换,那么子进程替换后会影响父进程吗?我们知道进程之间具有独立性一定不会影响,但是先前不是讲的父子共用同一段代码吗?子进程替换数据我们知道会发生写时拷贝,其实子进程进行代码替换时操作系统也会进行类似写时拷贝的工作。

所以当子进程进行程序替换时,会把子进程的代码和数据加载进内存,而此时父子进程共享代码和数据,所以就会发生写时拷贝,系统会为子进程开辟新的物理内存,子进程的代码和数据就会加载进物理内存中,这样就保证了进程的独立性。
下面我们直接上代码,注意子进程替换后它本质还是那个子进程,当子进程执行完替换后的程序退出时也需要父进程来等待回收它。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        //子进程
        sleep(2);
        printf("我是一个进程:%d\n", getpid());
        sleep(1);
        execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
        exit(1);
    }

    //父进程
    pid_t rid = waitpid(id, NULL, 0);
    if (rid > 0)
    {
        printf("wait: %d success\n", rid);
    }

    return 0;
}

加载器

小编这里再补充一些和历史知识的勾连,我们知道要运行一个二进制文件要先把它加载到内存,这是冯诺依玛体系结构规定的,因为CPU不能直接访问外设,只有加载进内存的代码和数据才能被CPU执行。加载是把数据从一个硬件加载到另一个硬件,所以一定需要操作系统来执行加载任务,只有操作系统有这个权力,所以加载底层一定会调用系统调用,那么对于linux而言,加载的本质其实就是调用程序替换的系统调用接口。

(很多人会疑惑:为什么 Linux 不能像 Windows 一样 "直接创建进程加载代码"?这是 Linux 的历史设计逻辑:fork()(创建子进程,复制父进程)和execve()(替换子进程)是分离的两个系统调用,这样可以在fork()和execve()之间插入额外逻辑(如修改环境变量、重定向输入输出),灵活性更高。而 Windows 的CreateProcess是 "一站式" 接口,将 "创建进程" 和 "加载程序" 合并,无需单独的程序替换步骤。)

加载器示例代码:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
    printf("我是一个进程: %d\n", getpid());
    sleep(1);

    char** myargv = &argv[1];
    execv(myargv[0], myargv);

    printf("程序替换失败!\n");
    return 10;
}

运行结果:

示意图:

以上就是写的一个简易加载器程序,myexec就是加载器本体,可以通过它加载其他程序。

六个exec系列函数串讲

下面我们把exec系列系统调用串在一起讲,我们先梳理一下这批参数的共性,第一个参数是指你要执行谁,第二个以及后续参数是指你要如何执行,快速记忆就是在命令行上怎么写就在这里怎么填。

这些函数原型看起来很容易混,但只要掌握了规律就很好记:

execl:

cpp 复制代码
int execl(const char *path, const char *arg, ...);

execl我们前面已经介绍过了,execl中的l表示list,因为传递参数是以一个一个单独的字符串传递的。第一个参数是要执行程序的路径,第二个参数我们以ls命令为例,可以写成 ls ,也可以写成 /usr/bin/ls ,后续可变参数是要ls命令的选项,最后一个参数以NULL结尾。

exec系列函数的所有代码小编都只贴上面子进程程序替换中的子进程内部逻辑,因为其他基本都一样。

cpp 复制代码
if (id == 0)
{
    //子进程
    printf("我是一个进程: %d\n", getpid());
    sleep(1);

    execl("/usr/bin/ls", "ls", "-a", "-l", "-n", NULL);

    printf("程序替换失败!\n");
    exit(1);
}

execv:

cpp 复制代码
int execv(const char *path, char *const argv[]);

我们可以看到execv第二个参数是以字符串数组的方式传递的,所以execv中的v表示vector,用法和execl类似。数组的最后一个元素也要为NULL。

cpp 复制代码
if (id == 0)
{
    //子进程
    printf("我是一个进程: %d\n", getpid());
    sleep(1);

    //"pwd"本质是const char*类型,
    // 需要把它强转为char*类型以匹配char* const类型的myargv数组
    char* const myargv[] = { (char*)"pwd", NULL };
    execv("/usr/bin/pwd", myargv);

    printf("程序替换失败!\n");
    exit(1);
}

execlp:

cpp 复制代码
int execlp(const char *file, const char *arg, ...);

execlp除了第一个参数其他参数和execl一样。execl第一个参数传要执行程序的路径,而execlp第一个参数只用传要执行程序的程序名就行(比如 ls 和 ./mycmd ),代表的依旧是你要执行谁。原理就是使用execlp我们只用传要执行命令的名字,execlp自己会去环境变量path中寻找指定的程序并执行。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("我是一个进程: %d\n", getpid());                                                                                     
    sleep(1);                                                            
    execlp("ls", "ls", "-a", "-l", "-n", NULL);                                  
    printf("程序替换失败!\n");                
    return 10;                                 
}:

execvp:

c 复制代码
int execvp(const char* file, char* const argv[]);

有了前面的三个的介绍想必execvp各位都能理解了把,直接上代码。

cpp 复制代码
if (id == 0)
{
    //子进程
    printf("我是一个进程: %d\n", getpid());
    sleep(1);
    execlp("ls", "ls", "-a", "-l", "-n", NULL);
    printf("程序替换失败!\n");

    exit(1);
}

execvpe:

cpp 复制代码
int execvpe(const char *file, char *const argv[],char *const envp[]);

带e的程序替换接口可以传递任意环境变量给替换后的程序,无论是自定义的环境变量还是系统的环境变量。

execle:

cpp 复制代码
int execle(const char *path, const char *arg, ...,char *const envp[]);

第七个程序替换接口

我们之前介绍的六个exec系列函数都是man 3号手册的库函数,而接下来的这个execve是man 2号手册的系统调用,所有程序替换操作最后都会调用这个系统调用,也就是说上面介绍的6个程序替换库函数底层都会调用execve。所以这六个库函数只是传参形式不同,设计这六个库函数的目的是为了满足未来不同场景的需求。

子进程执行用户写的程序

子进程不仅可以替换系统命令,也可以通过程序替换执行我们自己写的程序,只要能找到就行了,示例如下,让子程序执行我们自己用C++写的mycmd程序:
mycmd.cc

cpp 复制代码
#include <iostream>

int main()
{
    std::cout << "hello C++" << std::endl;
    return 0;
}

myexec.c:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //子进程
        sleep(2);
        printf("我是一个进程:%d\n", getpid());
        sleep(1);
        execl("./mycmd", "./mycmd", NULL);
        exit(1);
    }
    //父进程
    pid_t rid = waitpid(id, NULL, 0);
    if(rid > 0)
    {
        printf("wait: %d success\n", rid);
    }
    return 0;
}

运行结果:

程序替换可以调用任意语言的程序

有了上面用C语言替换C++程序的铺垫后,小编想说不论是什么语言编写的代码运行后都会变成进程,而程序替换其实就是替换进程,所以我们可以用C语言写的程序替换代码调用其他语言运行起来的程序。

程序替换是操作系统的功能,所以不止C语言,任何语言编写的程序都能完成程序替换。

传递命令行参数和环境变量的2种方式

1、有了上面关于程序替换的认识,我们学习到了命令行参数和环境变量有两种传递方式,第一种方式是通过程序替换接口(exec**e)的方式将当前程序的命令行参数和环境变量传递给替换该当前程序的程序。

2、第二种方式是父子进程之间通过虚拟地址空间传递,因为在父进程的进程地址空间中会存在它的命令行参数和环境变量,就如同全局变量一样,子进程会继承到父进程的进程虚拟空间和页表,自然子进程也能拿到命令行参数和环境变量。所以就算我们不通过子进程的main函数显示传递命令行参数和环境变量,子进程也能拿到。

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

相关推荐
ShareBeHappy_Qin2 小时前
Linux 命令 —— 常用命令总结
linux·运维·服务器
Ronin3053 小时前
【Linux网络】Socket编程:TCP网络编程
linux·网络·网络编程·tcp
是店小二呀3 小时前
【技术文档:Dify 本地 Docker 环境邮件服务排错指南】
运维·docker·容器
不会c嘎嘎3 小时前
Linux -- 网络层
linux·运维·网络
想学全栈的菜鸟阿董3 小时前
Ubuntu Linux 入门指南
linux·运维·ubuntu
Broken Arrows3 小时前
如何在Linux服务器上部署jenkins?
linux·jenkins
猫头虎3 小时前
AI_NovelGenerator:自动化长篇小说AI生成工具
运维·人工智能·python·自动化·aigc·gpu算力·ai-native
Yyyy4824 小时前
Ansible Role修改IP地址与主机名
linux·服务器·php
zmjjdank1ng4 小时前
什么是Ansible 清单
服务器·自动化·ansible