
你有没有遇到过这种情况在终端里敲了一行export JAVA_HOME/opt/jdk17紧接着在这个终端里启动工具一切正常可是打开一个新终端JAVA_HOME 又变没了。如果你去问身边的人大概率会得到一句因为你没写进 .bashrc。这个回答没错但很多人其实并没搞懂背后的原理——为什么写进 .bashrc 就行了export 到底在做什么source 又是怎么帮我们重新加载的这些问题我几年前自己也是一知半解直到有一次排查一个 CI 里诡异的环境变量丢失问题才把 export、source、子进程、配置文件这几块彻底串了起来。这篇文章就围绕 linux 里这两个最常用却又最容易被忽略的命令展开把变量从定义到被程序读取完整链路讲清楚顺带把我排查环境变量问题时用过的方法都分享出来。1. export 到底导出了什么一场 fork 与 exec 的接力交接1.1 shell 变量与环境列表内核只认这一份在 bash 里你随手敲一句FOObar回头echo $FOO能看到 bar但这个 FOO 只是当前 shell 的局部变量shell 从来不会主动把它传给子进程。要理解为什么得先想清楚底层事实当你执行任意一条外部命令时Linux 内核的处理路径是——先 fork 出一个子进程子进程的内存里完整复制一份父进程的数据然后在子进程里调用 execve 系统调用加载新程序。变量这种东西本质上只是当前 shell 进程内存里的一块数据。既然子进程是复制出来的那子进程里当然也能在看到 FOObar 这份数据。可为什么实际子进程里看不到关键在 execve 这一步。一旦执行 exec当前进程的地址空间会被新程序整个替换掉。而新程序的环境从哪里来内核在加载新程序时只会把父进程专门传递过来的一个环境列表一个字符串数组作为新程序的环境。在 C 语言里它对应 main 函数的第三个参数 envp或者通过 getenv() 读取。shell 变量 FOObar 虽然存在于 shell 进程的内存里却从来没被放进环境列表这个区段里exec 之后新程序自然就看不到了。打个不太严谨但好懂的比方shell 变量是你写在草稿纸上的草稿环境列表是装进信封递给对方的正式文件。exec 这个动作等价于把信封交给新程序然后销毁草稿对方当然只能看到信封里装了什么。export 的作用就是决定哪些变量会被抄进信封。1.2 export 的实质把变量塞进可继承名单export命令做的事就是告诉当前 shell把这个变量登记到环境列表里以后我起的任何子进程都能通过环境变量访问到它。比如MY_NAMEzhangsan export MY_NAME此时env | grep MY_NAME就能查到一行MY_NAMEzhangsan。如果去掉 export只保留第一行赋值你在env的输出里永远找不到这个变量。更准确地说export 在 bash 里做了两步一是把变量标记为已导出状态二是如果变量还没有值则把当前值作为导出值。这跟env命令不同env是临时把环境列表调好再执行一个命令export 是永久在当前 shell 生命周期内修改环境列表。有一点容易忽视环境变量本身没有全局和局部之分。一个变量一旦进入环境列表它对当前 shell 和所有后代进程都是可见的所谓局部只是说它没有进入环境列表只在当前 shell 进程里能读取。所以网上很多文章把 export 描述成把局部变量变成全局变量其实不严谨它只负责能否被继承不负责作用域。1.3 一次实验看清继承边界理论说多了容易绕直接做三个小实验一看就明白。实验一未导出的变量FOOhello env | grep FOO # 无输出实验二导出后export FOOhello env | grep FOO # FOOhello实验三写一个子脚本读取cat /tmp/test_env.sh EOF #!/bin/bash echo FOO$FOO EOF chmod x /tmp/test_env.sh ./tmp/test_env.sh # 未 export 时输出 FOO # export 后输出 FOOhello看到实验三的输出变化就理解了继承的含义子进程从父进程那里拷贝走的不是父进程的全部内存而是父进程的环境列表。这也是为什么你写了某个变量但忘了 export子进程里永远是空——不是变量丢了是它从没被列入传递给子进程的名单。2. export 操作里的细节比命令本身更容易翻车2.1 export VARvalue 和 export $VAR 别搞混很多人习惯写export PATH/usr/local/bin:$PATH这没问题。但有一种很典型的错法VARvalue export $VAR如果VARvalue那export $VAR会被 shell 展开成export value此时 bash 会把value当作一个要导出的变量名而没有值。这通常不是你想要的。正确的写法是VARvalue export VAR或者合成一句export VARvalue如果你的值来自一段命令计算想先算好了再导出可以这样写VERSION$(cat /opt/app/version.txt) export VERSION这种写法的好处是变量名只写一次避免拼写不一致。“先赋值、再导出”在脚本里可读性反而更好别人一眼就能看出名字和值分别是什么。2.2 单行命令前置导出调试环境变量的利器有一种语法很容易被忽略VARvalue command这会在执行command的时候临时设置一个环境变量命令结束后变量就消失不影响当前 shell。例如LD_LIBRARY_PATH/opt/mylib ./myapp或者调试时给脚本额外传个开关DEBUG1 ./run_test.sh这跟env VARvalue command效果类似区别在于前者是 shell 内置的临时环境语法后者调用外部env程序。两种都很好用尤其是在不想污染当前环境、只想验证某个变量改了以后程序行为是否变化的时候。需要注意一个坑VARvalue command这种临时环境语法只对外部命令和部分内置命令生效。对 shell 函数不生效——如果 command 是一个函数VAR 的赋值会在函数执行期间临时存在函数结束后恢复而且函数内部看到的是临时值。网上有人拿这个测试发现跟预期的子进程继承不一致其实就是函数不走 execve 这条路径。2.3 如何收回导出和彻底清除变量如果要让一个变量不再传递给子进程但还留在当前 shell 里用export -n VAR这个命令取消 VAR 的已导出状态变量变成普通 shell 变量env | grep VAR就查不到了但echo $VAR还能看到值。如果想彻底删除变量用 unsetunset VAR两三天没碰 Linux 后很容易把export -n和unset搞混。我的记忆方法是export 是登记-n 是撤下登记unset 是撕掉草稿也扔掉信封。另外有个细节如果变量是 readonlyunset 会报错readonly R_VAR1 unset R_VAR # bash: unset: R_VAR: cannot unset: readonly variable这个特性常被用来保护关键配置比如防止脚本中途把 PATH 改坏。2.4 为什么 export 永远无法跨终端很多初学者最困惑的就是明明在这个终端 export 成功了开一个新终端就没了。原因是每个终端窗口都在跑一个独立的 shell 进程。你在终端 A 里 export修改的是终端 A 的 shell 进程的环境列表终端 B 启动时它的父进程是图形登录会话或终端模拟器它从父进程那里继承环境列表跟终端 A 半毛钱关系都没有。换句话说export 的持久性上限就是当前 shell 进程的生命周期。要想让变量对每次打开新终端都生效就得把 export 语句写进 shell 启动时会读取的配置文件然后重新加载或重新登录。这也是 source 命令最常用的场景之一后面会详细说。3. source 到底在干嘛在当前进程里念一遍脚本3.1 source 与 bash script.sh 的本质区别source 这个命令中文资料里常被解释成在当前 shell 中执行脚本文件这个描述是准确的但很多人并不知道在当前 shell 中和新起一个子进程执行到底差在哪。最简单的验证方式是用 PIDecho $$ # 假设输出 12345 cat /tmp/show_pid.sh EOF #!/bin/bash echo 子进程 PID $$ EOF bash /tmp/show_pid.sh # 子进程 PID 24680一个不同于 12345 的数字 source /tmp/show_pid.sh # 子进程 PID 12345和当前 shell 相同这个差异的本质是bash script.sh会重新 fork 一个 bash 进程去执行脚本的内容脚本里所有赋值、cd、export都发生在这个新进程里执行完整个进程就退出了对父 shell 一点影响都没有。而source script.sh是在当前 bash 进程里逐行读取并解释执行脚本内容脚本里cd /tmp当前 shell 的工作目录就真的变了脚本里export FOObar当前 shell 的环境列表就真的多了 FOO。这顺便解释了另一个经典困惑为什么直接cd /tmp从一个脚本里写进fun.sh再执行终端的工作目录没变因为bash fun.sh时 cd 发生在子进程里。改成source fun.sh就能改变当前目录了。3.2 三个隐蔽的坑exit、路径、循环引用第一个坑是 exit。如果一个脚本里写了exit那么bash script.sh时 exit 只会退出那个子进程但用source script.sh时exit 会直接退出你当前的终端会话。我见过有人把exit 1写进配置文件然后 source 一下终端直接关闭差点没缓过来。在配置脚本里正确做法是用return它既能结束当前脚本又不会退出外层 shell。第二个坑是路径。source 有一个特性如果参数里没有/bash 会在当前目录和 PATH 里找不到文件时再查一遍当前目录其实不是bash 的行为在 4.1 之后有点变化source filename会先在 PATH 里查找 filename找到则执行如果没找到再尝试当前目录。这个查找顺序容易导致我以为 source 的是当前目录的 options.sh实际执行了 PATH 里另一个同名文件。为了避免踩坑最好写成source ./options.sh强制指定路径。第三个坑是循环引用。A.sh 里 source B.shB.sh 里又 source A.sh处理不好就是无限递归。bash 虽然没有像某些语言那样明确报递归深度超限但会把栈打爆表现为脚本卡死或直接段错误。所以模块化配置时尽量保持引用方向单向比如只允许入口脚本统一 source 各模块模块之间不要互相 source。3.3 source 与点号的等价关系source 还有一个别名——点命令。source /etc/profile和. /etc/profile完全等价都是 POSIX shell 里的点命令表示法。这不是什么冷知识而是排查时会派上用场。因为一些老脚本或系统脚本里你搜 source 时搜不到任何结果但实际用了点命令。比如某些发行版的 /etc/profile 里就有. /etc/bash.bashrc这种写法。看到点别懵它就是把那个文件的内容在当前 shell 里执行了一遍。4. export source 的组合拳配置热加载与跨脚本传值4.1 为什么 source ~/.bashrc 能立即生效配置文件的加载机制很多人只有一个模糊印象。简单梳理如下登录 shell比如 SSH 登录、su - 切换用户启动时会读取 /etc/profile、~/.bash_profile 或 ~/.profile交互式非登录 shell比如打开终端窗口启动时一般读取 ~/.bashrc.bashrc 里通常会有大量 alias、函数定义和 export 语句。问题来了你编辑完 ~/.bashrc 后当前已经打开的终端不会自动重新执行它因为终端里的 shell 启动时已经读过了。想让新的环境变量立即生效传统做法是source ~/.bashrc这条命令本质上是把 .bashrc 里所有 export、alias、函数定义在当前的 shell 里重新执行一遍。于是 PATH、JAVA_HOME、自定义函数全部热更新。不用退出终端不用重新登录。不过要注意之所以热加载能成功是因为 .bashrc 里的内容设计成可重复执行的。如果里面写了export PATH/opt/tool/bin:$PATHsource 一次就追加一次 /opt/tool/bin多 source 几次 PATH 会越来越长。很多人没注意这个累积效应配置越搞越乱。改进做法是每次重置再赋值export PATH/opt/tool/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:$HOME/.local/bin4.2 让子脚本回传变量的唯一返回通道shell 脚本之间传递变量官方管道机制是上游设置环境变量下游通过 export 继承。但反过来子进程改了自己的环境变量是没法直接传回父进程的因为父子进程的内存是复制关系子进程改的是自己的一份拷贝。那怎么让一个子脚本里的计算结果回到主脚本最常用也最干净的办法就是让子脚本用 source 方式被加载。比如主脚本 run.sh 里#!/bin/bash source ./detect_env.sh echo 编译工具路径: $TOOLCHAINdetect_env.sh 内容if [ -x /opt/gcc-12/bin/gcc ]; then export TOOLCHAIN/opt/gcc-12/bin/gcc else export TOOLCHAIN$(which gcc) fi因为 detect_env.sh 是被 source 进来的它里面 export 的 TOOLCHAIN 直接就在 run.sh 的当前 shell 里生效了。如果改成bash detect_env.shTOOLCHAIN 就只存在于子进程里run.sh 的 echo 打印出来的会是空。这种模式在构建系统、CI 脚本里特别常见本质就是把环境探测结果通过 source 注入到主流程。4.3 把一堆 export 拆成模块化小文件当环境变量越来越多时把所有 export 都塞进 .bashrc 可读性会很差。我自己的习惯是按用途拆分~/.config/env/java.shJAVA_HOME、CLASSPATH、MAVEN_HOME~/.config/env/python.shPYENV_ROOT、PYTHONPATH~/.config/env/tools.sh各种小工具的安装路径然后在 .bashrc 里统一加载if [ -d ~/.config/env ]; then for f in ~/.config/env/*.sh; do [ -r $f ] source $f done fi这样做有两个好处一是以后装新工具只需要往目录里丢一个 .sh 文件不用去改 .bashrc二是每个文件都可以单独执行排查问题用得上。坏处是 source 的加载顺序可能会互相影响比如 java.sh 里想用 tools.sh 定义的变量就必须保证在 for 循环里先加载 tools.sh否则 java.sh 拿不到。正规做法是给模块加明确依赖声明或在统一入口里手动排序。5. 环境变量不生效的排查路径我遇到过的四类真实案例5.1 三步定位法从当前 shell 到新进程逐步验证环境变量不生效时最忌讳瞎改配置文件。我总结了一个三步定位法每次都能快速把问题缩小到某一层。第一步先确认变量在当前 shell 里到底有没有值echo $VAR env | grep VAR第二步确认能不能被新起的子进程继承export VARtest_value bash -c echo $VAR如果 bash -c 能输出 test_value说明继承链路没问题如果当前 shell 有值但 bash -c 里没有几乎可以断定 VAR 没有被 export。第三步确认跨会话后有没有值新开一个终端或者tmux new-session再 echo。如果这里没值说明启动文件里没有读取、没有 export或者改错文件了。这套方法的妙处在于每一步都能直接对应到变量定义、导出、继承、持久化四层问题中的一个。5.2 案例一改了 .bashrc 却没有效果有一次我在虚拟机里配 JAVA_HOME明明在 ~/.bashrc 里加了export JAVA_HOME/opt/jdk17source 完在当前终端也验证通过但重新 SSH 登录后echo $JAVA_HOME还是空的。排查发现这台机器每次 SSH 登录启动的是登录 shell而登录 shell 默认读取的是 ~/.bash_profile。系统里这个文件的内容居然覆盖了 PATH 并压根没有 source ~/.bashrc。要想让 JAVA_HOME 对登录 shell 生效要么把 export 写进 ~/.bash_profile要么在 ~/.bash_profile 里追加一行[ -f ~/.bashrc ] source ~/.bashrc踩过这次坑之后我的习惯是区分登录 shell和交互 shell的读取文件别一根筋只写 .bashrc。5.3 案例二tmux 里的旧环境快照另一个典型场景是 tmux。你 tmux 已经开着很久了后来更新了 .bashrc 里的某个 export再打开新 window 里echo $VAR竟然还是旧值。原因是 tmux 在创建会话时会把当时客户端的环境变量快照保存下来新开 window 时会用这份快照作为初始环境而不是重新去读你的 .bashrc。所以source ~/.bashrc只影响你当前那个已经打开的 pane新开 window 用的还是 tmux 保存的旧环境。解决办法也很简单重启 tmux server让它重新捕获当前 shell 的环境。tmux kill-server然后重新 tmux attach 通常就解决了。或者先tmux set-environment -g手动修。临时赶工时也可以先unset TMUX再开一个新 window不推荐直接重启最省事。5.4 案例三管道子 shell 里的赋值消失这个案例非常经典即使不是环境变量也经常有新手被绕晕echo hello | while read line; do RESULT$line done echo $RESULT # 输出为空原因是管道两端的命令是在子 shell 里执行的while 循环里的 RESULT 赋值发生在子 shell 进程里循环一结束子 shell 退出赋值就丢了。解决方案有三种用进程替换把 while 移出管道用shopt -s lastpipe在 bash 4 里可以把管道最后一个命令放到当前 shell 执行但要注意需配合 job control 关闭或者根本不用管道改用重定向while read line; do RESULT$line done (echo hello)这个坑虽然跟 export 关系不大但在写脚本时如果稍不注意就会误以为变量继承和 source 有问题实际只是子 shell 作用域的问题。5.5 案例四服务进程根本不加载 shell 配置文件这类问题通常发生在手动启动服务时一切正常但用 systemd 或 cron 启动时环境变量全没了。原因在于 systemd 服务由 PID 1 直接管理运行用户是配置里指定的用户但这个服务进程的父进程不是该用户的 shell所以它根本不会读取 ~/.bashrc、~/.profile。cron 更严格它只读 crontab 里设置的环境变量和一个极简 PATH。这种情况下去改 .bashrc 是徒劳的。正确做法是在 service 文件或 cron 任务里显式指定环境变量或者在启动脚本里额外 source 一份专门的环境文件比如统一放在 /etc/default/myapp 或 ~/.config/env 下然后在 ExecStart 前的 EnvironmentFile 里加载。也就是说当进程的父进程不是交互 shell 时你得主动把环境喂给它而不能指望它读你的默认配置。5.6 收尾的几个小习惯写了这么多年 shell以下几条是我个人认为最能减少环境变量相关 bug 的习惯第一任何要长期生效的环境变量都写进配置文件不靠临时 export。临时 export 只在当下终端验证时用。第二在配置文件里统一用先重置再拼接的方式赋值避免重复 source 时 PATH 字段越撑越长。第三脚本里该用 source 还是 bash 执行先想清楚如果你希望脚本的变量、cd、函数定义影响当前环境用 source如果你只想运行一次并拿到最终输出用 bash 执行或子 shell 执行保证隔离。第四排查问题就按当前 shell、子进程、新会话、服务四层去查一层一层过不要乱改文件。这四个习惯几乎可以把 90% 的我 export 了怎么不生效source 了好奇怪问题挡在门外。剩下 10% 基本就是上面案例里的那几个特殊场景照着排查思路走一遍基本都能定位到根因。