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 秒实验
strace -e trace=execve ls看那一行 execve 的参数:第一个是路径,第二个是 argv 数组。env | head -5看环境变量长什么样;再用env -i ls清空环境跑一次,感受 envp 传的是什么。exec 3>/tmp/t.txt; echo hi >&3; exec 3>&-——在 shell 里手玩描述符继承,这就是 exec 保留 fd 的直接体会。
常见误区
- 【exec 会创建新进程】。它不创建,只替换当前进程的内容。新进程是 fork 干的活。
- 【exec 成功之后会返回 0】。成功之后不返回(代码已经换掉了);只有失败才返回 -1。
- 【execve 会自己找 PATH】。不会。裸
execve必须给绝对或相对路径;要搜 PATH 得用execvp那一族包装函数。
自测三问(点开对答案)
1. exec 换掉了什么、留下了什么? —— 换掉代码段、数据段、堆栈、内存映射(部分信号处理也重置);留下进程号、父进程号、打开的文件、当前目录、资源限制、用户身份。 2. 为什么 fork 与 exec 要分成两步?举两个只能在中间做的事。 —— 因为两步之间那一小段是唯一能动手脚的窗口:**改文件描述符做重定向**、**改工作目录**、**换用户身份**、**恢复信号处理**。 3. `execve` 与 `execvp` 的差别是什么? —— `execve` 是唯一的系统调用,**不搜 PATH**;`execvp` 是包装函数,**会按 PATH 找程序**。- ☐ 3.4 结束进程
相关笔记
- fork()函数 — 复制那一步
- 结束进程 — 跑完怎么退场
- OS提供的程序 — shell 的两步动作
- 🐧 Linux是怎样工作的 · 索引