【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

相关推荐
A小辣椒21 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式