🐧 第三章 进程管理
📄 一句话概括

exec 不造进程,它只是把当前进程的内容整个换掉:代码、数据、堆栈全换,进程号、打开的文件、当前目录照旧。fork 与 exec 分成两步,是为了在中间那一小段改环境。

📝 本节和前面几节的关系

3.2 讲 fork 复制了一个自己,这一节讲怎么把这个复印件换成别的程序。两步合起来,才是 shell 跑一条命令的全过程。

一、换内容,不换身份

execve(path, argv, envp) 干的事很暴力:把当前进程的地址空间整个扔掉,从磁盘上把 path 那个程序装进来,从它的入口重新开始跑。 没换的东西:进程号、父进程号、进程组、会话、当前目录、打开的文件描述符(默认)、资源限制、用户身份。 换掉的东西:代码段、数据段、堆、栈、信号处理函数(部分重置)、内存映射。 一句话:exec 是【魂换壳不换】——里面的人换了一个,身份证号没变。

二、参数与环境变量

argv 是参数数组,第一个元素约定是程序名(所以 argv[0] 可以是假的,有些程序靠这个判断自己是以什么名字被调起来的)。 envp 是环境变量数组,格式是 名字=值。它才是子进程继承上下文的主渠道——PATH、HOME、LANG 都从这里进。 argv 与 envp 都以空指针结尾,内核靠这个知道数组到哪结束。 这条约定有个安全后果:数组里必须放一个 NULL,否则内核会一直读下去,读到不该读的内存——很多提权漏洞就出在这类边界上。

三、为什么非要分成两步

如果只要一个【跑 ls】的调用,为什么不让它一步到位? 因为两步之间那一小段,是 shell 唯一能动手脚的窗口。 在这段窗口里,shell 可以: 1. 改文件描述符——ls > out.txt 就是把 1 号 fd 换成那个文件。 2. 改工作目录——cd /tmp && ls 里的 cd 是内建的,因为它要改 shell 自己。 3. 改用户身份——sudo 就是在 exec 之前把身份换掉。 4. 改信号处理——把不该继承的处理方式恢复成默认。 如果一步到位,这些事没有地方做,只能塞进 exec 的参数里——那 exec 的参数表会膨胀到没法用。

这就是 Unix 的设计口味:把动作拆成最小的几步,每步只干一件事,中间留出可组合的缝。 管道 |、重定向 >、&& 全靠这些缝拼起来。

四、exec 家族与两个容易混的点

execve 是唯一的系统调用,其余(execl、execv、execvp、execle)都是包装函数:差别只在【参数怎么传】与【要不要自动搜 PATH】。 execvp 会自己按 PATH 找程序——这就是你敲 ls 不用敲 /bin/ls 的原因。裸 execve 不会搜 PATH,路径写错就直接失败。 第二个容易混的点:exec 成功之后不返回(因为原来的代码已经不在了);只有失败才返回 -1。所以紧跟 exec 的那句错误处理,其实只处理失败。

五、动手 Demo:exec——换个程序继续跑,进程号不变

沙箱是 Windows,没有 execve。这里用【父进程 + 子进程】对照,看两件事:换程序之后进程号是不是还是那个,以及描述符为什么能传下去。

me = os.getpid()
# 效果上等价于 exec:起一个跑【另一段代码】的进程
code = "import os; print('我是新程序,我的进程号是', os.getpid())"
pr = subprocess.Popen([sys.executable, "-c", code],
                      stdout=subprocess.PIPE, text=True)
line = pr.communicate()[0].strip()

print("子进程说:", line)
print("父进程号:", me)

实跑输出:

--------------------------------------------------------------------
exec——换个程序继续跑,进程号不变
--------------------------------------------------------------------
① 两种做法,差一件事

  做法               进程号           跑的代码
  -------------------------------------------------
  只 fork            新的             还是老子那份
  fork 之后 exec     新的(不换)     换成指定程序

      子进程自己报的             我是新程序, 我的进程号是 13104
      我这个父进程的号           1632

  **exec 不造进程,它只是【把当前进程的内容换掉】。**
  代码段、数据段、堆栈全换,**进程号、打开的文件描述符、当前目录照旧留着。**

  **所以 shell 跑一条命令是两步,不是一步**:
  1. `fork`——复制一个自己(这时它还是 shell 的副本)
  2. `exec`——把这个副本换成 `ls`
  两步分开的理由很实际:**fork 之后、exec 之前,中间那一小段可以改环境**
  (重定向、改工作目录、关掉不要的 fd)。

② 描述符为什么不换

  东西           exec 之后     为什么
  --------------------------------------------------
  进程号         不变          还是同一个进程
  打开的文件     默认保留      重定向靠这个传下去
  代码与数据     全换          这就是 exec 干的事
  信号处理       部分重置      新程序不该继承老习惯

  **【打开的文件默认保留】这条,就是 `ls > out.txt` 能成立的原因**:
  shell 先把 stdout 换成文件,再 exec 成 ls——ls 一写,写进的就是那个文件。

对照表要看的不是这两行输出,是【换与不换】的分界:exec 换掉的是代码与数据,留下的是身份(进程号)与家当(打开的文件)。 「打开的文件默认保留」这条,就是重定向的全部秘密:shell 先把 stdout 换成文件,再 exec——ls 一写就写进那个文件,它自己都不知道。 这也解释了管道:ls | grep x 里,shell 把 ls 的 stdout 接到管道的写端,grep 的 stdin 接到读端,然后各 exec 一次。两个进程完全不知道对方存在。

📋 在真 Linux 上看 exec 前后:strace -e trace=execve ls,你会看到一行 execve("/usr/bin/ls", ...)——这就是那一刻的换魂。

30 秒实验

  1. strace -e trace=execve ls 看那一行 execve 的参数:第一个是路径,第二个是 argv 数组。
  2. env | head -5 看环境变量长什么样;再用 env -i ls 清空环境跑一次,感受 envp 传的是什么。
  3. exec 3>/tmp/t.txt; echo hi >&3; exec 3>&-——在 shell 里手玩描述符继承,这就是 exec 保留 fd 的直接体会。

常见误区

  1. 【exec 会创建新进程】。它不创建,只替换当前进程的内容。新进程是 fork 干的活。
  2. 【exec 成功之后会返回 0】。成功之后不返回(代码已经换掉了);只有失败才返回 -1。
  3. 【execve 会自己找 PATH】。不会。裸 execve 必须给绝对或相对路径;要搜 PATH 得用 execvp 那一族包装函数。
自测三问(点开对答案) 1. exec 换掉了什么、留下了什么? —— 换掉代码段、数据段、堆栈、内存映射(部分信号处理也重置);留下进程号、父进程号、打开的文件、当前目录、资源限制、用户身份。 2. 为什么 fork 与 exec 要分成两步?举两个只能在中间做的事。 —— 因为两步之间那一小段是唯一能动手脚的窗口:**改文件描述符做重定向**、**改工作目录**、**换用户身份**、**恢复信号处理**。 3. `execve` 与 `execvp` 的差别是什么? —— `execve` 是唯一的系统调用,**不搜 PATH**;`execvp` 是包装函数,**会按 PATH 找程序**。

相关笔记