一文搞懂 Linux 进程替换:从 fork 到 exec 的完整链路

一文搞懂 Linux 进程替换:从 fork 到 exec 的完整链路

目录

进程替换是什么?

----------- 进程替换原理 ----------

1、进程替换会发生写实拷贝吗?

2、普通只读 vs COW 只读

3、exec 函数执行后,后续代码还会执行吗?

4、CPU 如何知道程序的入口地址?

5、子进程进行程序替换后,会影响父进程的代码和数据吗?

---------- exec 系列接口 ----------

1、execl

2、execlp

3、execv

4、execvp

5、execle

6、execvpe

exec 系列库函数与系统调用的关系

------------- 其他问题 -------------

问题:exec 系列函数只能执行系统命令吗?能不能执行自己写的程序?

问题:为什么我们的可执行程序、脚本,都能跨语言调用呢?

小tip:exec*有着加载器的作用

问题:父进程如何通过 exec* 给子进程传递命令行参数和环境变量

putenv(新增环境变量)

问题:如何彻底替换环境变量?


进程替换是什么?

进程替换就是:一个正在运行的进程,用一个全新的可执行程序,完全替换掉自己当前的代码、数据和堆栈,但进程的 PID 保持不变

简单来说,就像你正在用一个记事本,然后直接把它变成了一个浏览器 —— 窗口(PID)没变,但里面的程序已经完全换了。

我们先来看一个简单的演示,直观感受一下进程替换的效果。

#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "before: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl; execl("/usr/bin/ls", "ls", "-l", "-a", NULL); cout << "after: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl; return 0; }

从结果能看到,execl 之后程序就变成 ls 了,原进程后续的代码不再执行,这就是进程替换。


----------- 进程替换原理 ----------

fork 创建子进程后,子进程执行的是和父进程相同的程序(但可能执行不同的代码分支)。为了让子进程执行另一个程序,我们通常会在子进程中调用一种 exec 函数。

当进程调用 exec 函数时,该进程的用户空间代码和数据会被新程序完全替换,并从新程序的启动例程开始执行。注意,调用 exec 并不会创建新进程,所以调用 exec 前后,进程的 PID 并未改变。


1、进程替换会发生写实拷贝吗?

exec 替换不会触发写时拷贝(COW)✅

COW 的触发前提:只有 fork() 创建子进程后,父子进程共享内存页,且某一方对共享页执行写入操作时,才会触发 COW。

exec 的本质:它会直接丢弃当前进程的代码段、数据段等所有用户空间内容,重新加载新程序,相当于销毁了原有的地址空间映射,不再与任何进程共享内存页。

子进程场景fork() 后虽与父进程共享内存,但 exec() 会直接丢弃这些共享资源,无需写入,因此不触发 COW。

父进程场景:直接替换自身时,不存在任何共享内存的前提,自然也不会触发 COW。


2、普通只读 vs COW 只读

父进程依赖「物理内存页引用计数」触发 COW,无需页表带 COW 标志;子进程依赖「页表中的 COW 标志 + 临时只读属性」触发 COW,因此只有子进程的页表会存在 COW 标志,而父子进程修改共享页,最终都会触发 COW 实现数据隔离。


3、exec 函数执行后,后续代码还会执行吗?

执行成功:程序会被完全替换为新的可执行文件,exec 后面的代码永远不会执行。因为 exec 成功时没有返回值,不会回到原程序的执行流。

执行失败exec 会返回 -1,程序会继续执行 exec 之后的代码(通常这里会写错误处理逻辑,比如 perror 然后退出)。


4、CPU 如何知道程序的入口地址?

Linux 下的可执行文件是 ELF 格式,文件头部的 e_entry 字段会存储程序的入口地址。当 exec 加载新程序时,内核会读取 ELF 头中的这个地址,并把 CPU 的指令指针(PC)设置到该地址,让程序从这里开始执行。


5、子进程进行程序替换后,会影响父进程的代码和数据吗?

不会。子进程替换时会丢弃原有资源并构建全新地址空间,与父进程完全独立,因此不会影响父进程的代码和数据。✅


---------- exec 系列接口 ----------

execve() 是内核直接提供的系统调用,是 “真身”。

其他 exec 函数都是 C 库提供的封装,是 “马甲”,最终都会调用 execve() 来完成实际的进程替换。

exec系列接口

1、execl

📝 函数原型

int execl(const char *path, const char *arg, ...); 

🔍 参数说明

path要执行的程序的完整路径(如 /bin/ls),不能只写文件名,因为它不会去 PATH 里查找。

arg:传递给新程序的命令行参数,第一个参数通常是程序名本身,后续是其他参数。

...:可变参数列表,最后必须用 NULL 来标志参数结束。

✅ 返回值

调用成功时,函数不会返回(因为当前进程已被新程序替换)。

调用失败时,返回 -1,并设置 errno 来指示错误原因。

℡. 演示

#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "before: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl; // 调用 execl 执行 ls -l -a execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 如果 execl 执行成功,下面的代码永远不会执行 cout << "after: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl; return 0; }

2、execlp

📝 函数原型

int execlp(const char *file, const char *arg, ...); 

🔍 参数说明

file:要执行的程序文件名(如 "ls"),它会自动在系统 PATH 环境变量中查找该程序,无需写完整路径。

arg:传递给新程序的命令行参数,第一个参数通常是程序名本身,后续是其他参数。

...:可变参数列表,最后必须用 NULL 来标志参数结束。

✅ 返回值

调用成功时,函数不会返回(当前进程已被新程序替换)。

调用失败时,返回 -1,并设置 errno 来指示错误原因。

℡. 演示

#include <iostream> #include <unistd.h> #include <cstdio> using namespace std; int main() { cout << "Before execlp, PID: " << getpid() << endl; // 执行 ls -l -a,通过 PATH 环境变量自动查找 ls int ret = execlp("ls", "ls", "-l", "-a", NULL); // 如果 execlp 成功,下面的代码永远不会执行 if (ret == -1) { perror("execlp failed"); } cout << "After execlp (won't print if success)" << endl; return 0; }

3、execv

📝 函数原型

int execv(const char *path, char *const argv[]); 

🔍 参数说明

path:要执行的程序的完整路径(例如 /bin/ls),不会自动在 PATH 中查找。

argv:传递给新程序的命令行参数数组,格式为 {"程序名", "参数1", "参数2", ..., NULL},必须以 NULL 结尾。

✅ 返回值

调用成功时,函数不会返回(进程已被新程序替换)。

调用失败时,返回 -1,并设置 errno 指示错误原因。

℡. 演示

#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "Before execv, PID: " << getpid() << endl; // 构造参数数组 char *const argv[] = {"ls", "-l", "-a", NULL}; // 执行 /bin/ls -l -a int ret = execv("/bin/ls", argv); // 如果 execv 成功,下面的代码不会执行 if (ret == -1) { perror("execv failed"); } cout << "After execv (won't print if success)" << endl; return 0; }

4、execvp

📝 函数原型

int execvp(const char *file, char *const argv[]); 

🔍 参数说明

file:要执行的程序文件名(如 "ls"),会自动在系统 PATH 环境变量中查找该程序,无需写完整路径。

argv:传递给新程序的命令行参数数组,格式为 {"程序名", "参数1", "参数2", ..., NULL},必须以 NULL 结尾。

✅ 返回值

调用成功时,函数不会返回(进程已被新程序替换)。

调用失败时,返回 -1,并设置 errno 指示错误原因。

℡. 演示

#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "Before execvp, PID: " << getpid() << endl; // 构造参数数组 char *const argv[] = {"ls", "-l", "-a", NULL}; // 执行 ls -l -a,通过 PATH 自动查找 ls int ret = execvp("ls", argv); // 如果 execvp 成功,下面的代码不会执行 if (ret == -1) { perror("execvp failed"); } cout << "After execvp (won't print if success)" << endl; return 0; }

5、execle

📝 execle 函数原型

int execl(const char *path, const char *arg, ..., char *const envp[]); 

✅ 函数参数

path:要执行的程序的完整路径(比如 "/bin/ls")。

arg:传递给新程序的命令行参数,第一个参数通常是程序名,后续是选项或参数,最后必须以 NULL 结尾。

envp:你自定义的环境变量数组,格式为 KEY=VALUE,同样以 NULL 结尾,用来完全替换子进程的环境变量表。

✅ 返回值

调用成功时,函数不会返回(进程已被新程序替换)。

调用失败时,返回 -1,并设置 errno 指示错误原因。

℡. 演示

#include <iostream> #include <cstdlib> #include <unistd.h> #include <sys/wait.h> using namespace std; // 编译器默认定义的变量,指向当前进程环境变量表 extern char** environ; int main() { // 创建子进程 pid_t pid = fork(); if (pid == -1) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:使用 execle 显式传递 environ int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, environ); // 只有 execle 失败时,下面的代码才会执行 if (ret == -1) { perror("execle failed"); return 1; } } else { // 父进程:等待子进程执行完毕,避免子进程变成僵尸进程 wait(NULL); } return 0; }

目前,我们在调用 execle 时,第三个参数传递的是当前进程的 environ 变量,也就是把父进程的完整环境变量表传给子进程(如何传自定义环境变量表在文章最后会有讲解)

6、execvpe

这个接口我就不介绍了,execvpeexecle 的唯一区别在于,它使用数组来传递命令行参数,其余功能与 execle 完全一致。


exec 系列库函数与系统调用的关系

exec 系列库函数(如 execlexecle)都是对底层系统调用 execve 的封装,它们通过不同的参数传递方式(可变参数或数组)来提供更易用的接口。


------------- 其他问题 -------------

问题:exec 系列函数只能执行系统命令吗?能不能执行自己写的程序?

exec 系列函数不仅能执行系统命令(比如 lscat),也能执行你自己写的可执行程序;

只要你把程序编译成可执行文件,然后把它的完整路径(或文件名,如果在 PATH 里)传给 exec 函数,就能像执行系统命令一样运行它;

℡. 演示

#include <iostream> #include <unistd.h> using namespace std; int main() { int ret = execl("./testc", "testc", NULL); if (ret == -1) { cout << "execl failed" << endl; } return 0; }

这段演示清晰地证明了 exec 系列函数的强大能力:它不仅能执行 lscat 这类系统命令,也能无缝运行我们自己编写并编译好的程序。

很多人会疑惑:为什么在命令行执行自己的程序时需要加 ./(比如 ./testc),但在 exec 函数的参数里只传了 testc 就能运行?

这是因为:

在外部终端执行程序时,系统需要明确路径来找到可执行文件,所以必须加上 ./ 表示当前目录。

而在 exec 函数中,第一个参数是程序的完整路径(如 ./testc),它已经告诉系统去哪里找文件;后面的 testc 只是传递给程序的 argv[0] 参数,不是用来定位文件的,因此不需要再加路径(当然,加了也可以运行)


问题:为什么我们的可执行程序、脚本,都能跨语言调用呢?

因为所有语言运行起来,本质都是进程。不管是 C/C++ 编译出的可执行文件、Shell 脚本,还是 Python 脚本,最终都是由操作系统创建为独立的进程来执行的。调用的本质都是让操作系统加载并执行目标程序,和它原本用什么语言编写无关。


小tip:exec*有着加载器的作用

exec 系列函数是操作系统层面的程序加载与替换接口,核心作用是加载指定可执行文件,将当前进程的内存空间完全替换为新程序内容,从而启动新程序执行。

在终端中,我们输入的非内建指令(如 ls、自定义编译程序),本质上都是 Shell 先通过 fork() 创建子进程,再在子进程中调用 exec 系列函数加载并执行目标程序。因此,exec 是终端非内建指令执行的核心环节,扮演 “加载器” 的关键角色。


问题:父进程如何通过 exec* 给子进程传递命令行参数和环境变量

#include <unistd.h> #include <sys/wait.h> #include <stdio.h> int main() { pid_t pid = fork(); if (pid == -1) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程执行逻辑 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; int ret = execv("./testc", argv); // 只有 execv 失败时,下面的代码才会执行 if (ret == -1) { perror("execv failed"); return 1; } } // 等待子进程执行完毕,避免子进程变成僵尸进程 wait(NULL); return 0; }
#include <stdio.h> int main(int argc, char *argv[], char *env[]) { printf("-------------------这是命令行参数-------------------\n"); for (int i = 0; argv[i]; i++) { printf("[%d]: %s\n", i, argv[i]); } printf("-------------------这是环境变量-------------------\n"); for (int i = 0; env[i]; i++) { printf("[%d]: %s\n", i, env[i]); } return 0; }

从运行结果来看,我们传递的命令行参数被完整打印,但是我们没有传递环境变量为啥也能打印出来呢?

这是因为环境变量是在 fork() 创建子进程时,由操作系统自动从父进程复制并继承给子进程的,并非需要我们手动传递。后续调用 execv 替换子进程时,这份环境变量会被完整保留,所以新程序依然可以通过 mainenv[] 参数或全局变量 environ 访问并打印它们。

env[]:是 main 函数的第三个参数,它直接指向子进程继承来的环境变量表,是函数参数级别的访问入口。

environ:是系统为每个进程预设的全局变量,也指向同一份环境变量表,是全局级别的访问入口。

putenv(新增环境变量)

✅ putenv 是一个用于新增或修改当前进程环境变量的函数。

作用:将一个格式为 KEY=VALUE 的字符串添加到当前进程的环境变量表中;如果 KEY 已存在,则会覆盖原有值。

生效范围:仅对当前进程有效,并且会被后续 fork() 创建的子进程继承。

注意点:参数必须是 KEY=VALUE 格式,等号两边不能有空格。

#include <iostream> #include <cstdlib> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { // 设置环境变量,注意格式 KEY=VALUE 不能有空格 putenv("MY_ENV=666666"); // 创建子进程 pid_t pid = fork(); if (pid == -1) { perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:执行 testc 程序 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; int ret = execv("./testc", argv); // 如果 execv 失败才会执行到这里 if (ret == -1) { perror("execv failed"); return 1; } } else { // 父进程:等待子进程结束 wait(NULL); // 父进程打印自己的环境变量 cout << "-------------------父进程新增env-------------------" << endl; cout << getenv("MY_ENV") << endl; } return 0; }

1、子进程成功获取并输出了通过 putenv 设置的环境变量。

2、当前进程成功获取并输出了通过 putenv 设置的环境变量。

3、bash 父进程的环境变量并未新增该条目。


问题:如何彻底替换环境变量?

#include <iostream> #include <cstdlib> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { // 创建子进程 pid_t pid = fork(); if (pid == -1) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:用 execle 显式传递自定义环境变量 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; char* const myenv[] = { "MYAVL=111111", "HELLO=222222", NULL }; // 显式传递自定义环境变量数组 myenv int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, myenv); // 只有 execle 失败时才会执行到这里 if (ret == -1) { perror("execle failed"); return 1; } } else { // 父进程:等待子进程执行完毕,避免子进程变成僵尸进程 wait(NULL); } return 0; }

子进程环境变量被自定义 myenv 数组完全替换,仅能访问显式传递的 MYAVL/HELLO,未继承父进程的 PATH 等原有环境变量。

Read more

Docker Desktop 安装 wsl问题

Docker Desktop 安装 wsl 问题 遇到问题:wsl --install 或者 wsl --install 证书作者无效或不正确 解决: 1.根证书导致wsl --install命令出错。 分析:错误提示表明在尝试安装 WSL 时遇到了证书问题 解决:更新根证书。 打开 PowerShell(管理员)输入命令更新证书: certutil -generateSSTFromWU roots.sst 2.导入根证书 certutil -addstore -f ROOT roots.sst 再次执行就可成功下载或者更新wsl

By Ne0inhk
Linux ELF格式与可执行程序加载全解析:从磁盘文件到运行进程

Linux ELF格式与可执行程序加载全解析:从磁盘文件到运行进程

🔥个人主页:Cx330🌸 ❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》 《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔 《Git深度解析》:版本管理实战全解 🌟心向往之行必能至 🎥Cx330🌸的简介: 目录 前言: 一、ELF文件:Linux二进制的标准载体 1.1 ELF文件的四大类型 1.2 ELF文件的双重视角:Section与Segment 1.3 ELF核心结构:从头部到加载指引 (1)ELF Header(文件头) (2)Program Header Table(程序头表) (3)Section Header Table(节头表) 二. ELF 的生命周期:从源码到运行

By Ne0inhk
国产时序数据库的云原生实践:Apache IoTDB 与 TimechoDB 在物联网场景的深度应用

国产时序数据库的云原生实践:Apache IoTDB 与 TimechoDB 在物联网场景的深度应用

国产时序数据库的云原生实践:Apache IoTDB 与 TimechoDB 在物联网场景的深度应用 前言 随着物联网设备规模的指数级增长,传感器产生的海量时序数据对传统数据库的性能、可扩展性与成本控制提出了更高要求。Apache IoTDB 作为专为物联网场景设计的时序数据库,凭借高压缩比、百万级写入能力及毫秒级查询性能,成为物联网数据存储与分析的核心基础。本文将从 IoTDB 的核心特性出发,深入讲解其在 Kubernetes 环境中的部署实践、CRUD 操作示例,并延伸至 TimechoDB 的国产化增强能力,帮助读者全面掌握从单节点到云原生集群的 IoTDB 实战部署与应用方法。 Apache IoTDB 核心特性与价值 Apache IoTDB 专为物联网场景打造的高性能轻量级时序数据库,以"设备 - 测点"原生数据模型贴合物理设备与传感器关系,通过高压缩算法、百万级并发写入能力和毫秒级查询响应优化海量时序数据存储成本与处理效率。其主要优势包括:物联网原生优化,完美映射物理设备与传感器关系;极致性能表现,通过特殊编码算法实现10:

By Ne0inhk