【Linux】进程控制(下)

🔥铅笔小新z:个人主页

🎬博客专栏:Linux学习

💫滴水不绝,可穿石;步履不休,能至渊。


我们来看一个例子:

为什么我们执行完 sleep 100 之后,再执行 ls, ll .pwd程序没反应呢?

这刚好可以结合我们上节课讲的进程创建(fork() 和这节课要讲的程序替换(exec ,这个看似简单的现象背后,其实藏着操作系统对终端控制权和进程状态的管理机制。

  1. 核心原因:bash 被阻塞了

    首先要明确,你在终端里交互的那个程序叫做 bash 。bash 的主要工作流程是一个死循环:读取你的输入 -> 解析命令 -> 执行命令 -> 等待执行完毕 -> 再次读取。

    当你在 bash中执行一个命令时,默认情况下它是作为前台进程(Foreground Process) 运行的。

    1. bash 把终端的"控制权"交给了 sleep 100 这个进程。
    2. bash 自身会调用一个名为 wait()waitpid() 的系统调用,进入睡眠(阻塞)状态 。它在苦苦等待 sleep 这个子进程结束并汇报退出状态。
    3. 在这 100 秒内,bash 这个程序是"休眠"的,它不再主动读取你键盘敲击的新命令。
  2. 深入底层:结合上节课的知识

    1. fork 阶段: 你敲下 sleep 100,bash 进程通过 fork() 克隆了一个子进程。
    2. exec 阶段: 这个子进程通过 exec 族函数,把自己替换成了磁盘上的 sleep 程序。
    3. wait 阶段: 父进程(bash)调用 wait() 挂起自己,死死盯着这个子进程。因为它是前台运行的,bash 必须等它完事儿才能继续工作。

三、进程程序替换

3.1 替换原理

3.1.1 初始程序替换

我们先来看一个现象:

c 复制代码
1 #include<stdio.h>
  2 #include<unistd.h>
  3 
  4 int main()
  5 {
  6     printf("我的程序要运行了!\n");
  7     execl("/usr/bin/ls", "ls", "-a", "-l", NULL);                                                                                                                      
  8     printf("我的程序运行完毕了!\n");
  9     return 0;
 10 }

可见我们的程序把系统的命令执行了。

这种现象就是进程的程序替换

当一个程调用了程序替换函数后,操作系统会将磁盘上的新程序(全新的代码段、数据段)加载到内存中,直接覆盖掉当前进程的内存空间(包括原有的代码段、数据段、堆、栈等)。

  • PID 不变: 进程的身份证号(PID)不会发生任何改变。在操作系统看来,这还是原来的那个进程。
  • 从头开始: 替换成功后,新程序会从它的 main 函数开始执行。
  • 一去不复返: 因为原进程的代码已经被新程序的代码完全覆盖了,所以如果替换成功,替换函数之后的代码将永远没有机会执行。

3.1.2 深度剖析执行逻辑

如果我们在终端编译并运行这段代码,你会观察到一个非常奇妙的现象:

屏幕上会先打印出 "我的程序要运行了!" ,紧接着打印出当前目录下的文件列表(这是 ls -a -l 的输出结果)。

但是,你绝对看不到**"我的程序运行完毕了!"**这句话。

为什么第 8 行和第 9 行代码仿佛"凭空消失"了呢?这就是进程替换的原理在起作用。我们通过你写的这几行代码,来剖析一下底层的运作过程:

3.1.2.1 第 6 行

printf("我的程序要运行了!\n");

程序刚开始运行时,一切正常。操作系统为你这个程序分配了内存空间,把你的代码加载到代码段。CPU 顺着代码一行行执行,在终端打印出了第一句话。

3.1.2.2 第 7 行:(execl)

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

当 CPU 执行到这一行时,调用了系统底层的 execve 机制(execl 是其封装)。这时候发生了翻天覆地的变化:

  • 找新程序: 操作系统根据第一个参数 /usr/bin/ls,去磁盘上找到了 ls 这个可执行文件。

  • 清空与覆盖(核心动作): 操作系统非常暴力地把你当前进程内存中的代码段、数据段、堆区、栈区全部清空 。然后,把刚刚在磁盘上找到的 ls 程序的代码和数据,原封不动地搬进这个被清空的内存空间里。

  • 重置大脑: CPU 的程序计数器(PC 指针,用来记录下一行要执行哪条指令的寄存器)被强制重置,指向了刚刚加载进来的 ls 程序的 main 函数入口。

3.1.2.3 第 8 行去哪了?

printf("我的程序运行完毕了!\n");

答案是:这行代码在内存中已经不存在了。

在第 7 行执行成功的那一瞬间,你原本写的所有 C 代码(包括这句 printf 和后面的 return 0;),在内存中就已经被 ls 的二进制代码无情地覆盖掉了。CPU 正在忙着从头执行 ls 的逻辑,根本不知道曾经这里还有个第 8 行代码。

3.2 替换函数

在 Linux/Unix 环境下,我们主要使用 exec 函数族来进行程序替换。C 语言标准库为我们提供了 6 个常用的包装函数,以及 1 个底层的系统调用。

我们先来看看这 6 个主要函数的长相(包含在 <unistd.h> 头文件中):

c 复制代码
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

3.2.1 替换函数解释

这几个函数的作用是完全一样的:停下现在正在做的事,去执行那个新程序。 它们的区别仅仅在于传递参数的方式不同。

关键规则:

  1. 参数的结束标志: 无论传什么参数给新程序,参数列表的最后必须以 NULL 结尾,这是告诉操作系统"参数传完了"。

  2. 返回值:

    • 失败: 如果新程序找不到,或者权限不够,函数会返回 -1,并继续执行原程序接下来的代码。

    • 成功: 没有返回值! 因为如果成功,原来的代码都没了,根本无处可返。

3.2.2 函数命名解释

这 6 个函数看起来眼花缭乱,但其实它们的命名有着极其严谨的规律。理解了后缀字母的含义,你就能瞬间记住它们。

exec 是基础,后面的字母是"配置选项":

  • l (list - 列表): 表示参数采用列表 的形式,一个一个传进去。例如:"ls", "-a", "-l", NULL

  • v (vector - 矢量/数组): 表示参数打包成一个指针数组 传进去。你需要先定义一个 char *argv[] = {"ls", "-a", "-l", NULL};,然后把 argv 传进去。

(注:lv 是互斥的,必须选一个)

  • p (path - 路径):p 的函数,第一个参数只需要传可执行文件的名字 (比如 "ls"),操作系统会自动去环境变量 PATH 定义的目录里找它。如果不带 p,你就必须老老实实写绝对路径或相对路径(比如 "/bin/ls")。

  • e (environment - 环境变量):e 的函数,允许你组装一个自定义的环境变量数组 传递给新程序。如果不带 e,新程序会默认继承当前进程的环境变量。

补充: 实际上,只有 execve 是真正的 系统调用 (System Call),另外 5 个都是 C 标准库提供的 封装函数 (Library Functions)。它们在底层最终都会调用 execve 来向操作系统内核申请服务。

结合这个过程,你可以思考一个小问题:既然 execl 成功后会把当前进程完全替换掉,那我们在写 Bash 这样的命令行解释器时,如果用户输入了 ls 命令,Bash 是如何做到既能执行 ls,又能保证自己(Bash 进程)不被覆盖掉的呢?

------ 创建了子进程,让子进程进行进程替换! ------

结合刚才给出的那段 C 语言代码,如果 Bash 作为一个持续运行的程序,直接调用了 exec 函数去执行 ls,那么 Bash 自己的代码(包括死循环读取用户输入的逻辑)就会在瞬间被 ls 的代码完全覆盖。这样一来,ls 执行完之后,终端就直接退出了,因为 Bash 已经"自杀"了。

为了既能执行新程序,又能保全自己,Bash 采用了这种极其巧妙的策略。我们把它总结为 Linux 进程控制的 "黄金铁三角"

  • 第一步: fork() ------ 创造分身(替身使者)。 Bash 先克隆出一个一模一样的子进程。这个子进程拥有和 Bash 一样的权限和环境变量。

  • 第二步: exec() ------ 夺舍重生(替换灵魂)。 Bash 让子进程去执行 exec。子进程心甘情愿地牺牲掉自己的 Bash 代码,把内存空间让给 ls 程序。

  • 第三步: wait() ------ 幕后监工(收尸善后)。 作为父进程的 Bash 本尊,在幕后调用 wait 挂起自己,静静等待子进程(也就是 ls)执行完毕并回收它的状态码,然后再重新打印出命令提示符,等待你的下一次输入。

3.2.3 替换函数代码例子

3.2.3.1 execl
c 复制代码
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 #include<sys/wait.h>
  5 int main()
  6 {
  7     if (fork() == 0)
  8     {
  9         printf("我的程序要运行了!\n");
 10         execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
 11 
 12     }
 13     waitpid(-1, NULL, 0);                                                                                                                                              
 14     printf("我的程序运行完毕了!\n");
 15     return 0;
 16 }
3.2.3.2 execv
c 复制代码
    1 #include<stdio.h>
    2 #include<unistd.h>
    3 #include<sys/types.h>
    4 #include<sys/wait.h>
    5 int main()
    6 {
    7     if (fork() == 0)
    8     {
    9         printf("我的程序要运行了!\n");
   10 
   11         char *const argv[] = {
   12             "ls",                                                                                                                                                    
   13             "-a",
   14             "-l",
   15             NULL
   16         };
   17 
   18         execv("/usr/bin/ls", argv);
   19 
   20     }
   21     waitpid(-1, NULL, 0);
   22     printf("我的程序运行完毕了!\n");
   23     return 0;
   24 }
3.2.3.3 execvp
c 复制代码
    1 #include<stdio.h>
    2 #include<unistd.h>
    3 #include<sys/types.h>
    4 #include<sys/wait.h>
    5 int main()
    6 {
    7     if (fork() == 0)
    8     {
    9         printf("我的程序要运行了!\n");
   10 
   11         char *const argv[] = {
   12             "ls",
   13             "-a",
   14             "-l",
   15             NULL
   16         };
   17 
   18         execvp("ls", argv);                                                                                                                                          
   19 
   20     }
   21     waitpid(-1, NULL, 0);
   22     printf("我的程序运行完毕了!\n");
   23     return 0;
   24 }
3.2.3.4 execvpe
c 复制代码
    1 #define _GNU_SOURCE
    2 #include<stdio.h>
    3 #include<unistd.h>
    4 #include<sys/types.h>
    5 #include<sys/wait.h>
    6 
    7 char *const env[] = {
    8     "MY_AGE=20",
    9     "MY_SECRET=1",
   10      NULL
   11 };
   12 int main()
   13 {
   14     if (fork() == 0)
   15     {
   16         printf("我的程序要运行了!\n");
   17 
   18         char *const argv[] = {
   19             "ls",
   20             "-a",
   21             "-l",
   22             NULL
   23         };
   24                                                                                                                                                                      
   25 
   26         execvpe("ls", argv, env);
   27 
   28     }
   29     waitpid(-1, NULL, 0);
   30     for (int i = 0; env[i]; i++)
   31     {
   32         printf("%d -> %s\n", i, env[i]);
   33     }
   34     printf("我的程序运行结束了!\n");
   35     return 0;
   36 }

注意: 使用 execvpe时,要在文件第一行加上 #define _GNU_SOURCE

3.2.4 给子进程新增环境变量的方法

3.2.4.1 直接用 putenv

在子进程里先 putenv("MYVAL=123456789"),然后直接调用 execlexecv 等不带 e 的函数。系统会自动把当前(已经被 putenv 修改过)的环境变量传递过去。这是最推荐、最省事的做法。

3.2.4.2 "exec*e, putenv(); environ;"

如果你非要用带 e 的函数(比如 execvpe),你可以先通过 putenv() 把变量加到当前进程中,然后利用系统全局指针 environ(它指向当前进程完整的环境变量表),把 environ 作为第三个参数传给 exec*e

注意: 使用 environ 时,要加上声明:extern char **environ

相关推荐
企鹅的蚂蚁2 小时前
Ubuntu 22.04 终端进阶:Terminator 安装与快捷键完全手册
linux·运维·ubuntu
不会写程序的未来程序员2 小时前
nvm 安装教程:Node.js 版本管理全攻略 (Win/Mac/Linux) + .nvmrc 实战
linux·macos·node.js·前端开发·环境配置·nvm
黄焖鸡能干四碗2 小时前
网络安全风险评估报告(WORD版本)
大数据·运维·网络·人工智能·制造
跨境麦香鱼2 小时前
2026自动化抢鞋机器人:如何通过高并发代理提高成功率?
运维·网络·自动化
路由侠内网穿透2 小时前
本地部署开源发票管理系统 Invoice Ninja 并实现外部访问
运维·服务器·数据库·物联网·开源
Hello 0 13 小时前
“机房学生认证系统”与批量自动化部署方案
运维·自动化
KKKlucifer3 小时前
4A 平台合规自动化:从策略配置到审计追溯的全链路技术实现
运维·网络·自动化
信创DevOps先锋3 小时前
Gitee DevOps:构筑国产化数字基座,赋能企业信创转型
运维·gitee·devops
似水এ᭄往昔3 小时前
【Linux】--进程控制
linux·运维·服务器