- 1. Shell 心智模型·炼气篇(一):从 ./script.sh 报错说起,把 / ~ . $PATH .zshrc 一次讲透
- 2. Shell 心智模型·炼气篇(二):从 cat | grep | wc -l 严丝合缝说起,把管道 | 一次讲透
- 3. Shell 心智模型·炼气篇(三):从 `[ -d dir ] || mkdir dir` 说起,把 `[`、`[[ ]]`、`$`、`$?`、`&&`、`||` 一次讲透
- 4. Shell 心智模型·炼气篇(四):从 ./my_script.sh 里 export 出的变量,回终端却查无此变量说起,把子进程、export、source、命令替换一次讲透
有一次我在琢磨环境变量到底是怎么传递的,不想只靠看文档,就自己动手写了个 my_script.sh 试验,里面就一行:export API_KEY=abc123。跑一下 ./my_script.sh,回到终端敲 echo $API_KEY——空的,什么都没有。我来回确认了好几遍,脚本确实执行成功了,这行 export 也确实没报错,可这个变量就是像根本没发生过一样。这篇要讲透的,就是这个变量到底去哪了——子进程、export、source、$() 命令替换这几个概念,说的其实是同一件事:你的改动,到底能不能跨过”这是另一个进程”这道边界。
一、地基:每个进程都有自己的一份环境变量拷贝
你敲一个命令、跑一个脚本,shell 都会为它另外开一个子进程去执行——子进程和你正在打字的这个 shell(父进程)是两个完全独立的进程,只是有父子关系。
子进程诞生的那一刻,会从父进程那里”拍一张照片”——把父进程当时的环境变量整个拷贝一份,塞进自己的内存空间。注意是拷贝,不是共享同一份:
你的终端(父进程) │ │ 启动子进程执行 ./my_script.sh ▼子进程(跑脚本) │ export API_KEY=abc123 ← 只改了子进程自己这份拷贝 │ ▼子进程执行完,退出,这份拷贝连同它的改动一起消失子进程往自己那份拷贝里加什么、改什么,都不会反向传回父进程——这是操作系统层面的设计,继承是单向的,从父到子,子进程改自己的副本,原件动不了。可以想象成公司交接工作:新人入职第一天,把当时的规章手册复印一份带走,以后原件怎么改,新人手里那份复印件不会跟着变;新人自己在复印件上写写画画、夹小纸条,那些改动也只留在他自己那份上,不会传回档案室的原件。回到开头的谜题:export API_KEY=abc123 确实生效了,只是生效在子进程那份马上就要被丢弃的拷贝里,脚本一退出,连同这份拷贝一起烟消云散,从未有机会影响到你正在敲字的这个终端。
补充:这棵”父子进程”树,往上还能长多高
上面画的树只画了 shell 和它下面的子进程,其实这棵树往上还能接好几层,一路接到开机那一刻:
PID 1(内核启动的第一个进程,所有进程共同的祖先) │ Linux 上是 systemd,macOS 上是 launchd(macOS 没有 systemd,这是 Linux 专属) │ ├─ 各种系统服务/daemon:sshd、cron、docker 这些 │ ← 没有终端、常驻后台,由 PID 1 直接拉起或托管 │ └─ 登录会话 → 终端模拟器(Terminal.app / iTerm2 / gnome-terminal…) └─ shell(bash/zsh) ← 也就是上面说的"父进程" └─ 你跑的每个命令/脚本(子进程)ps -p 1 -o comm= 能看到自己机器上这个”祖先”叫什么——macOS 是 launchd,大多数现代 Linux 发行版是 systemd。终端模拟器本身也只是个普通进程,被你(背后还是 launchd/systemd 拉起来的)点开后,它自己再 fork 一个 shell 跑在里面——这一步 fork,跟脚本里 ./xxx.sh 那次 fork,是同一套机制。
为什么这类常驻后台的进程都叫 daemon,还爱在名字后面加个 “d”? daemon 这个词不是 “demon”(恶魔),是希腊语 “daimon”——默默做事、不需要人盯着的”守护灵”,1963 年 MIT 的 Project MAC 项目组最早这么用,灵感来自物理学的 Maxwell’s Demon(麦克斯韦妖):一个假想中在背后不知疲倦分拣分子的小精灵,跟”没人操作、在后台自己跑”的进程形象很贴。结尾加 “d” 是个命名约定,意思是”管理/服务某个东西的 daemon”——sshd(SSH daemon)、httpd(HTTP daemon)、crond(cron daemon)。systemd 就是 “system” + 这个 “d” 后缀,launchd 同理。严格说 PID 1 其实是所有 daemon 的祖先,不完全符合”daemon”最初”脱离终端、被别的进程 fork 出来”的定义,但这套命名习惯已经通用到不去较真了,看名字就知道”这是个后台管理进程”。
二、临时前缀赋值:只塞给这一个子进程,用完即焚
$ TZ=Asia/Tokyo dateFri Jul 24 21:03:00 JST 2026
$ echo $TZ
$TZ=Asia/Tokyo date 这种写法,是临时给 date 这一个子进程塞了一张便条——date 读到 TZ 就按东京时区打印时间,但这张便条只写给这一个子进程看,看完就扔。你当前的 shell 自己压根没设置过 TZ,所以下一行单独 echo $TZ 自然是空的。
三、主角登场:export 到底做了什么
要说清 export,得先分清两种身份:shell 变量和环境变量。普通赋值 VAR=val 只是在当前 shell 内部记了一笔,只有当前 shell 自己看得见,不会被塞进任何子进程的拷贝里:
$ DEBUG=1$ bash -c 'echo $DEBUG'
$DEBUG=1 只是个 shell 变量,子进程的那张”拷贝清单”里压根没有它,所以新开的 bash -c 什么都看不到。可以把 shell 变量想象成贴在自己显示器边上的便利贴——只有坐在这台电脑前的你自己看得到;export 做的事,就是把这张便利贴复印一份,塞进”以后每个新入职的人”的欢迎礼包里——升级成环境变量,标记进那份将来会被拷贝给子进程的清单里:
$ export DEBUG=1$ bash -c 'echo $DEBUG'1同一个变量,只差一个 export,子进程能不能看到就完全不同。export 只影响当前 shell 之后新启动的子进程——不会反向影响父进程,也不会影响已经在运行的、跟当前 shell 同级的其他终端窗口。想让它彻底消失,用 unset:
$ unset DEBUG$ echo $DEBUG
$unset 把变量从当前 shell 的环境里彻底删掉,就像直接把那张便利贴从墙上撕下来扔掉——之后不管是当前 shell 自己,还是它以后启动的子进程(以后新入职的人),都再也看不到这个变量了。
四、真正想让改动”跨过去”:source
如果就是想要脚本里的 export 影响到当前这个终端呢?答案是 source(简写 .):
$ source my_script.sh# 或者$ . my_script.shsource 不会另开子进程,而是让脚本内容直接在当前 shell 里逐行执行,就像你把脚本内容一行行手打进终端一样。前面 ./my_script.sh 那种跑法,更像是委托一个替身站在你的位置上操作——替身做的事再多,动的都是他自己的环境;source 则是你亲自坐到控制台前,一行行敲下去,所以脚本里的 export、cd 才会真实改动当前 shell 的状态,而不是改在一个跑完就扔的子进程拷贝里。
这也解释了系列第一篇里那行没细讲的代码——.zshrc 里那句 source $ZSH/oh-my-zsh.sh。如果去掉 source 直接执行这个文件,它只会在一个转瞬即逝的子进程里跑完就退出,里面定义的所有 alias、函数,一个都不会留在你的 shell 里。.zshrc、.bashrc 靠的从来不是”执行”,而是 shell 启动时把这些文件的内容 source 进自己,这些配置才能真正变成你当前 shell 的一部分。
五、$():明知回不去,那就只带一样东西回来
前面几节讲的都是”子进程改的东西出不去”——但 $()(命令替换,command substitution)恰恰是专门设计出来、显式把子进程的东西带回父进程的机制。可以把它想象成让子进程去”跑腿代购”:你委托它出门办一件事,它办完把小票(它的 stdout)交回给你,你拿着这张小票塞进自己的句子里用;但它跑腿路上做的其他事、记的其他笔记(它自己那份环境变量拷贝的任何改动),都不会一起带回来——只有你明确要它带回来的这一样东西,才回得来:
$ today=$(date +%Y-%m-%d)$ echo "today is $today"today is 2026-08-03$(date +%Y-%m-%d) 做的事:开一个子 shell 去跑 date +%Y-%m-%d,等它跑完,把它的标准输出整段读回来,去掉末尾的换行符,原样替换到这行命令里当一段文本——然后父 shell(也就是你当前这个 shell)才继续往下执行 today=... 这次赋值。
注意它带回来的不是环境变量、不是状态,而是数据——走的其实是系列第二篇讲过的那条路:子进程把内容打到自己的 stdout,父进程通过一根管道把这段 stdout 读回来。$() 本质上就是”开个子进程,把它的 stdout 接到一根管道,读完拼成字符串塞回命令行”,跟 cmd1 | cmd2 唯一的区别是,接收这份 stdout 的不是另一个命令,而是这行命令自己。
$ echo "共 $(ls | wc -l) 个文件"共 12 个文件老式写法用反引号 `cmd`,效果一样,但嵌套起来要靠反斜杠转义,可读性差;$(cmd) 可以直接嵌套 $(cmd1 $(cmd2)),现在基本都用这个。
一个常见陷阱:$() 捕获回来的字符串,如果不加引号直接展开,会被 shell 按空白字符重新拆词(word splitting),还可能被当成通配符展开:
$ for f in $(ls *.txt); do echo "文件: $f"; done如果某个文件名里带空格,比如 my notes.txt,上面这行会被拆成两个”文件”(“my” 和 “notes.txt”)处理,而不是一个。保险写法是老老实实用 for f in *.txt(交给 shell 自己的通配展开,不经过 $() 这道拆词),或者需要按行处理时用 while IFS= read -r line; do ...; done < <(cmd) 这种不经过词拆分的写法。
六、常见陷阱
陷阱一:脚本里 export 了,外面的终端还是看不到。 这是第一节讲过的根因——./script.sh 永远是开一个子进程去跑,子进程怎么改都出不去。想让改动生效,只有 source。
陷阱二:忘了用 source,虚拟环境”激活”了但没生效。 Python 的 venv/bin/activate、Node 的 nvm 切换版本,靠的都是往当前 shell 的 $PATH、$VIRTUAL_ENV 里塞东西——这些操作必须发生在你当前的 shell 里,才能让你之后敲的每个命令都读到。如果手滑写成 ./venv/bin/activate 而不是 source venv/bin/activate,脚本在子进程里悄悄改完自己那份拷贝就退出了,回到终端一切如常,python 用的还是系统那个,但也不会报错——这种”看起来什么都没发生”的沉默失败,比报错更难排查。
陷阱三export FOO=bar 只在当前这个 step 的子进程里生效,传不到下一个 step。这也是为什么 GitHub Actions 专门设计了 $GITHUB_ENV 这个文件——写脚本时把变量 echo 进这个文件,由 Actions 的 runner 负责在下一个 step 启动前,把文件内容重新读出来、注入到下一个子进程的环境里,绕开”子进程改动出不去”这道天然边界。
七、这道边界,和前两篇分别管的是两件不同的事,$() 是唯一一个明着”跨回来”的
前两篇讲的管道和 &&/||,都是命令之间的”横向”关系——数据往下一个命令流,或者退出状态决定要不要跑下一个命令。环境变量继承和 source 讲的是”纵向”关系——父进程和子进程之间,状态能不能传下去。$() 又是另一种:它是这四个机制里唯一一个,专门设计出来把子进程的东西显式带回父进程的:
|(管道) | && / || | 子进程环境变量继承 | $() 命令替换 | |
|---|---|---|---|---|
| 传递的是什么 | 数据(stdout → stdin) | 信号(退出状态 $?) | 状态快照(创建那一刻的环境变量拷贝) | 数据(子进程的 stdout,变成字符串) |
| 关系方向 | 前一个命令 → 后一个命令 | 前一个命令 → 后一个命令 | 父进程 → 子进程 | 父进程先 fork 出子进程,再把子进程的 stdout 读回来 |
| 子进程能不能把东西带回父进程 | 不适用 | 不适用 | 不能,子进程改的永远是自己那份拷贝 | 能,但只有 stdout 这一条,而且是父进程主动去读,不是子进程主动塞回来的 |
四者都在回答同一类问题——“这个命令能看到什么、看不到什么、什么东西能跨过父子进程这道边界”,只是各自守着不同的一道边界,$() 是唯一开了个口子、还是显式开的那个。
一句话总结
每个进程都有自己独立的一份环境变量拷贝,子进程诞生那一刻”拍照式”单向继承父进程当时的环境,之后子进程怎么改都是改自己那份,过不去这道边界;临时前缀赋值只塞给这一个命令,export 是把变量标记进”会被子进程继承”的清单,unset 是把它彻底删掉;source 跳过了”开子进程”这一步,让代码直接在当前 shell 里跑;$() 反过来,老老实实开一个子进程,但显式把它的 stdout 读回来拼成字符串——是这四个机制里唯一一条子进程的东西能跨回父进程的路,而且走的还是管道那套”接 stdout”的老办法。.zshrc、虚拟环境激活这种要改当前 shell 状态的场景,必须用 source,直接执行是没用的。