有些命令在终端里能跑,放进定时任务却提示找不到;有些变量用 echo 明明能看到,脚本里却是空的;还有些 .bashrc 修改只对新窗口有效,SSH 登录和 bash -c 又是另一套结果。这些现象看起来零散,背后其实只有两条主线:进程在启动时收到了哪些 name=value,以及 Bash 这一次按哪种模式启动。
这里不会把环境变量写成一张需要背诵的大表。我们先建立“父进程向子进程传递启动快照”的模型,再用它解释 export、env、PATH、locale 和启动文件。最后会把修改配置的备份、语法检查、隔离验证与恢复通道连成一套可复用流程。
一个新程序被执行时,会收到一组形如 name=value 的字符串。这组字符串就是该进程的环境。名称区分大小写,值可以是空字符串;值中不能包含 NUL,因为 NUL 用来结束字符串。常见的全大写命名是约定,不是“大写就会自动导出”的语法。
父进程启动子进程时,子进程获得那一刻的环境副本。之后父进程改值,已在运行的子进程不会被追溯刷新;子进程改自己的值,也不会反向写回父 Shell。如果两个进程需要在运行期持续交换状态,应该用文件、管道、套接字或其他进程间通信机制,不能指望环境自动同步。

图:export 决定哪些 Shell 变量进入随后子进程的启动快照。
PROJECT=welearn 在当前 Bash 中创建或修改 Shell 变量。等号两侧不能留空格,值中有空格时要引用:
PROJECT=welearn
COURSE_NAME='Linux 自动化'
printf '%s\n' "$PROJECT"
declare -p PROJECT COURSE_NAMEprintf '%s\n' "$PROJECT" 证明当前 Shell 能展开这个值,却不证明它已导出。declare -p 更适合查当前 Bash 变量,因为输出会同时表示属性:declare -- 表示普通变量,declare -x 表示已标记导出。
printenv NAME 和无参数 env 查的是环境,所以看不到未导出 Shell 变量。不要为了“查一个值”就直接运行 env:完整环境可能包含会话标识、代理地址或凭据,输出容易进入终端回滚、日志和截图。

图:printf、declare -p、printenv 分别回答值、Shell 属性和进程环境三个问题。
export、export -n、unset 和 readonly 的生命周期export 给名称加导出属性,可以与赋值同时完成:
export PROJECT=welearn
declare -p PROJECT # declare -x PROJECT="welearn"
bash -c 'printf "%s\n" "$PROJECT"'export -n PROJECT 只去掉导出属性,值仍留在当前 Bash。unset PROJECT 则删除变量,随后子进程也不会再收到它。readonly PROJECT=welearn 同时设置值与只读属性,随后的赋值和 unset 都会失败;它用于保护当前 Shell 逻辑中的不变量,不是密密存储机制。
export PROJECT=welearn
export -n PROJECT
printf 'shell=%s\n' "$PROJECT"
bash -c 'printf "child=%s\n" "${PROJECT-<unset>}"'
unset PROJECT预期是当前 Shell 输出 welearn,子 Bash 输出 <unset>。这个对比比单纯背诵命令更有用:值、导出属性、只读属性和变量是否存在,是四个不同问题。
下面的实验故意在两次启动之间改值:
LOCAL_ONLY=local-value
EXPORTED=first
export EXPORTED
bash -c 'printf "local=%s exported=%s\n" "${LOCAL_ONLY-<unset>}" "$EXPORTED"'
EXPORTED=second
bash -c 'printf "exported=%s\n" "$EXPORTED"'预期输出:
local=<unset> exported=first
exported=second第一个子 Bash 看不到未导出的 LOCAL_ONLY,收到启动时的 EXPORTED=first。第二个子 Bash 是父 Shell 改值之后启动的,所以收到 second。这也解释了为什么“改了终端里的环境变量”不会自动影响早已启动的编辑器、服务或桌面应用。
子进程反向修改也同样无效:
STATUS=parent
export STATUS
bash -c 'STATUS=child; export STATUS; printf "inside=%s\n" "$STATUS"'
printf 'outside=%s\n' "$STATUS"预期 inside=child、outside=parent。这不是 Bash “没有保存”,而是进程隔离正常工作的结果。
NAME=value command 是好用的临时环境,但要看命令类型对外部命令,前缀赋值只改这一次执行的环境:
MODE=normal
export MODE
MODE=debug bash -c 'printf "inside=%s\n" "$MODE"'
printf 'outside=%s\n' "$MODE"预期子 Bash 输出 debug,外层仍是 normal。这种写法很适合临时 locale、调试开关或某个程序的单次配置,因为作用范围和命令出现在同一行。
但它不是对所有 Bash 命令都完全一样的通用局部变量语法:
:、.、export、readonly、unset 等特殊内建命令前的赋值会在命令结束后保留。NAME=temp export NAME 还会让 export 操作当前 Shell 的导出属性。
图:前缀赋值的结果与命令种类、Bash 模式有关,不能只记“本次有效”一句话。
当配置必须绝对不污染外层 Shell 时,最清楚的边界是显式子 Shell:
(
export MODE=debug
run-something
)
#子 Shell 结束后,外层 MODE 不受影响env -i 和 env -u 构造可解释的运行条件env -u NAME command 会在这次启动前删掉指定环境变量,不改父 Shell。env -i 则从空环境开始,适合追查“是否偶然依赖终端里某个隐式变量”:
KEEP=visible DROP=hidden \
env -u DROP bash -c 'printf "KEEP=%s DROP=%s\n" "$KEEP" "${DROP-<unset>}"'
env -i \
PATH=/usr/bin:/bin \
HOME=/tmp/welearn-clean-home \
/bin/sh -c 'printf "PATH=%s HOME=%s USER=%s\n" "$PATH" "$HOME" "${USER-<unset>}"'env -i 后要显式加回命令真正需要的最小集合。上例用绝对路径启动 /bin/sh,并给出只含基础系统目录的 PATH,这样不会因清空环境而连 Shell 都找不到。干净环境是调试工具,不等于自动安全:如果你又把不可信 PATH、凭据或动态库配置加回去,风险仍在。
环境还不是无限容量的配置库。执行新程序时,命令参数与环境会共享 exec 相关限额;上限与内核、架构和运行时资源限制有关,不要在脚本里假设一个所有系统通用的固定数字。可以用 getconf ARG_MAX 了解当前环境公布的参数/环境总量约束,并把大型配置放到权限正确的文件或专用配置系统中。
EMPTY= 创建了一个值为空字符串的变量;unset MISSING 则使名称不存在。它们用 printf '%s' "$NAME" 看起来都是空,但对默认值展开的意义不同:
unset MISSING
EMPTY=
printf 'dash: [%s] [%s]\n' "${MISSING-fallback}" "${EMPTY-fallback}"
printf 'colon: [%s] [%s]\n' "${MISSING:-fallback}" "${EMPTY:-fallback}"预期是 dash: [fallback] []、colon: [fallback] [fallback]。选哪个不是风格问题,而是要先定义空值的业务含义:如果空字符串表示“明确关闭”,就应保留它;如果空值与未配置等价,就用带冒号的形式。
set -u 会让未设置变量的直接展开报错,帮助暴露拼写错误。但对可选参数仍应显式写 ${OPTIONAL-default} 或先检查是否设置,不要因为开了 set -u 就把所有空值与未设置混成一类。
PATH 是带优先级的信任列表当命令名不含 /,且它不是别名、函数或内建命令时,Bash 按 PATH 中以冒号分隔的目录从左到右搜索,第一个可执行匹配胜出。如果命令自带斜杠,例如 ./deploy 或 /usr/bin/env,就直接按该路径解析,不走 PATH 搜索。
PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
export PATH
type -a python
command -V python将个人目录放在前面,意味着个人同名命令会覆盖系统版本;放在后面,则只在前面没有同名程序时命中。这是信任与覆盖决策,不是“个人目录总应该放在最前”的固定答案。
PATH 中的空元素是一个危险的历史行为::/usr/bin、/usr/bin: 或 /usr/bin::/bin 都包含空元素,可代表当前目录。把 . 显式放进 PATH 也有同类风险,尤其不能放在前面:进入一个包含恶意 ls、git 或针对常见拼写错误的程序的目录后,简短命令就可能被劫持。要执行当前目录中的文件,显式写 ./name 更清楚。

图:PATH 的顺序决定同名命令的优先级,空元素会把当前目录加入搜索。
Bash 会记住已搜索到的外部命令完整路径,避免每次都扫描整个 PATH。这是性能优化,也会带来一个调试错觉:你在更靠前的目录新建了同名命令,Bash 却可能继续使用已哈希的旧路径。
hash -t command_name # 查已记住的位置
hash -d command_name # 忘记一个名称
hash -r # 清空全部命令路径哈希给 PATH 重新赋值也会使 Bash 清空哈希表。需要注意的是,在命令已哈希时,type -P name 也可显示记住的路径,并不总是重新扫描后的“理论第一个”。调试同名命令时,可以将 type -a、hash -t、hash -r 和目标文件权限放在一起查。

图:环境变量是程序可读的配置输入,具体程序是否采用以其文档与实现为准。
支持 XDG Base Directory 约定的程序,可使用下列变量选择目录:
这些默认是应用解释规范时采用的路径,不是 Bash 在登录时必然自动导出每个 XDG 变量、创建所有目录。程序是否支持该约定也要查它的文档。$HOME/.local/bin 可用于个人可执行文件,但是否加入 PATH、放在哪个优先级,仍是系统或用户的明确决策。
LC_ALL 、具体 LC_* 、LANG 的顺序决定locale 会影响消息语言、字符分类、排序、数字和日期格式等。LANG 给各类别提供默认;LC_TIME、LC_COLLATE、LC_CTYPE、LC_MESSAGES、LC_NUMERIC、LC_MONETARY 等具体变量只覆盖对应类别;非空 LC_ALL 覆盖所有类别和 LANG。
对某个类别,优先级可以记成:
非空 LC_ALL > 非空的对应 LC_* > 非空 LANG > 实现默认LANG=C.utf8 LC_TIME=C LC_ALL=C.utf8 locale | grep '^LC_TIME='
env -u LC_ALL LANG=C.utf8 LC_TIME=C locale | grep '^LC_TIME='
env -u LC_ALL -u LC_TIME LANG=C.utf8 locale | grep '^LC_TIME='在同时提供 C 和 C.utf8 的环境中,三次有效值依次由 LC_ALL、LC_TIME、LANG 决定。实际系统安装了哪些 locale 不固定,可先用 locale -a 查可用值,不要把不存在的 locale 写进全局配置。

图:LC_ALL 是最高优先级强制层,具体 LC_* 只控制自己的类别,LANG 是回落值。
脚本需要稳定字节排序或可预测英文诊断时,常对单条命令使用 LC_ALL=C command。我们之所以强调“单条命令”,是因为在启动文件长期 export LC_ALL 会压过用户对各个类别的设置,往往比只设 LANG 更粗暴。
把 API token 放进环境变量有一个便利之处:不需把凭据写进命令行参数,程序也很容易读取。但便利不等于密密保管。凭据会跟着 export 传给所有后代程序,可能出现在调试输出、错误报告、进程检查工具或同权限进程可读的界面中。
Linux 下的 /proc/PID/environ 以 NUL 分隔条目,表示该进程在 execve 时收到的初始环境。可在自己启动的短命演示进程上这样读:
tr '\0' '\n' < "/proc/$pid/environ"这个文件不是进程后来修改的所有变量的实时视图。实测中,目标 Bash 以 PUBLIC=initial 启动,然后在进程内改成 PUBLIC=changed 并导出 LATE=added;读目标 Bash 的 /proc/PID/environ 仍看到 PUBLIC=initial 且没有 LATE,它后来启动的子进程则收到新值。

图:凭据进入环境后会扩散到后代进程;/proc/PID/environ 又提供 exec 初始快照的受权访问面。
/proc/PID/environ 的读取受进程身份、ptrace 访问检查、安全模块和 /proc 挂载选项影响,不是所有用户都能任意读。沙盒中的同权限读取能看到演示 token,换成另一 UID 后则返回 Permission denied。正确结论是“访问有权限边界,但环境仍扩大了暴露面”,不是“任何人都能读”或“有权限就可当密钥库”。
长期凭据更适合放进权限严格、可轮换、有审计与最小授权能力的凭据机制。必须通过环境注入时,限定到目标进程的最小子树,禁止不必要的环境打印,并让凭据短命、可撤销。
交互 Shell 会显示提示符、从终端读命令,并启用作业控制等交互能力。可用 case $- in *i*) ... esac 查 $- 是否含 i。登录 Shell 是会话的登录初始 Shell,也可用 bash --login 显式请求。交互和登录不是同一个开关,所以存在四种组合。

图:启动文件的选择先由“是否交互”与“是否登录”决定,再考虑发行版自己的衔接配置。
.bashrc 的衔接通常是配置主动完成登录 Bash 先读 /etc/profile(如果存在且可读),然后按顺序查下列用户文件,只读取第一个存在且可读的文件:
~/.bash_profile → ~/.bash_login → ~/.profile这是“三选一”,不是三个全部执行。登录 Bash 也不会在用户 profile 之后自动再读 .bashrc。常见做法是在 ~/.bash_profile 中明确衔接:
if [[ -r "$HOME/.bashrc" ]]; then
. "$HOME/.bashrc"
fi这段代码的意义是 profile 主动在当前登录 Shell 中 source rc。一些发行版还会通过 /etc/profile、/etc/profile.d/ 或发行版自己的系统 rc 文件进一步衔接配置。例如 /etc/bash.bashrc 是某些发行版的系统级约定,不是 Bash 上游对所有系统规定的必读路径。查问题时必须把“Bash 规则”和“发行版配置”分开。
普通非交互 Bash 不读 profile 或 .bashrc。如果环境中设置了 BASH_ENV,Bash 会展开它的值,把结果当作要在当前 Shell 中读取的文件名,且不会用 PATH 搜索该文件。因为它相当于给每个后代非交互 Bash 注入代码,只应指向可信、内容稳定且不产生乱输出的文件。
交互登录 Shell 退出时会读 ~/.bash_logout;非交互登录 Shell 执行 exit 时也会处理它。退出文件中的动作应快速、幂等且可容错,不要把唯一的重要持久化逻辑寄托于“一定能正常 logout”。
source 改的是当前 Shell,直接执行改的是子进程假设 change-env.sh 内容是:
#!/usr/bin/env bash
CONFIG_VALUE=changed
export CONFIG_VALUE
printf 'script=%s\n' "$CONFIG_VALUE"直接执行与 source 的对比:
CONFIG_VALUE=before
export CONFIG_VALUE
./change-env.sh
printf 'after execute=%s\n' "$CONFIG_VALUE"
. ./change-env.sh
printf 'after source=%s\n' "$CONFIG_VALUE"直接执行时,内核根据 shebang 启动一个解释器进程,脚本只改自己和后代环境,返回后外层仍是 before。. 和 source 是同义的 Bash 用法,它们在当前 Shell 上下文执行文件,因此第二次后外层变成 changed。
这就是为什么“运行一个脚本来更新当前 Shell 环境”无效,而虚拟环境激活脚本常要求 source。同时,source 也意味着被读文件可以改当前目录、选项、函数、trap 和变量,所以只对可信文件使用。
启动文件会反复加载。一段配置如果每执行一次就把相同目录再追加一次,PATH 会越来越长;如果每次都启动后台代理或打印消息,子 Shell 会重复启动服务或污染脚本输出。所以配置应幂等:运行一次和运行多次的最终状态相同。
一个不制造重复项、也不制造空元素的简化 PATH 片段:
path_prepend() {
case ":${PATH-}:" in
*":$1:"*) ;;
*) PATH=$1${PATH:+":$PATH"} ;;
esac
}
[[ ! -d "$HOME/.local/bin" ]] || path_prepend "$HOME/.local/bin"
export PATH
unset真正修改前,建议用这条流程:
先备份目标文件,例如 cp -a ~/.bashrc ~/.bashrc.bak-$(date +%Y%m%d-%H%M%S)。备份是为了让恢复只需一条可验证的命令,不是为了增加仪式。
只做一个小改动,然后运行 bash -n ~/.bashrc。-n 会解析但不执行命令,可以拦住括号、引号和关键字等语法错误;它不能证明命令存在或运行逻辑正确。
不要立即关闭当前可用 Shell。用 bash --noprofile --rcfile ~/.bashrc -i 开一个新的测试 Shell,检查 PATH、type -a、locale、提示符和有无多余输出;再测一次登录方式。
不要在启动文件中无条件运行图形命令、向标准输出打印欢迎词、硬编码 TERM/DISPLAY,或长期全局设置 LD_LIBRARY_PATH。这些配置在特定终端里可能“看不出问题”,却会污染脚本、远程会话、编辑器子进程和工具的自身检测。
PATH 与 locale下面的脚本将所有文件放在 /tmp/welearn-ch11,用 trap 清理;容器再使用 --rm,退出后删除一次性可写层。为了让演示聚焦,这里保留主要观测:
docker run --rm --name welearn-linux-ch11-lab \
debian:bookworm-slim bash -c '
set -eu
lab=/tmp/welearn-ch11
trap '\''rm -rf "$lab"'\'' EXIT INT TERM
mkdir -p "$lab/early" "$lab/late" "$lab/cwd"
LOCAL_ONLY=local
EXPORTED=parent
export EXPORTED
declare -p LOCAL_ONLY EXPORTED
bash -c '\''printf "child local=%s exported=%s\n" "${LOCAL_ONLY-<unset>}" "$EXPORTED"'\''
EMPTY=
unset MISSING || true
printf "dash=[%s][%s] colon=[%s][%s]\n" \
"${MISSING-fallback}" "${EMPTY-fallback}" \
"${MISSING:-fallback}" "${EMPTY:-fallback}"
关键输出应包含:
declare -- LOCAL_ONLY="local"
declare -x EXPORTED="parent"
child local=<unset> exported=parent
dash=[fallback][] colon=[fallback][fallback]
PATH=/usr/bin:/bin HOME=/tmp/welearn-ch11/home USER=<unset>
LC_TIME="C.utf8"
LC_TIME=C这些输出依次证明未导出/已导出的差异、空值/未设置的差异、env -i 不会凭空保留 USER,以及 LC_ALL 取消后才轮到 LC_TIME。实验结束的 rm -rf 只指向脚本刚创建的固定沙盒目录,不接收外部输入路径。
启动文件问题最容易被现有配置干扰。可以在沙盒中创建一个全新 HOME,让每个文件只向 marker 日志写入自己的名称,再分别启动:
HOME="$lab/home" bash --login -i -c 'exit'
HOME="$lab/home" bash --login -c 'exit'
HOME="$lab/home" bash --noprofile -i -c 'exit'
HOME="$lab/home" bash -c 'true'
HOME="$lab/home" BASH_ENV=沙盒的真实 marker 结果是:
交互登录:bash_profile,bash_logout
非交互登录:bash_profile,bash_logout
交互非登录:bashrc
普通非交互:无
非交互 + BASH_ENV:BASH_ENV同时存在 .bash_profile、.bash_login、.profile 时只有 bash_profile 留下 marker,证明三选一。自建 .bash_profile 没有 source .bashrc,所以登录两组也没有 bashrc marker。交互测试如果没有分配真实控制终端,Bash 可提示无作业控制;这是测试输入条件的限制,不影响启动文件 marker 的观测。
同一沙盒还验证了 /proc/PID/environ:目标 Bash 的初始快照保留 PUBLIC=initial,它后来启动的子进程收到 PUBLIC=changed-after-exec 和新的 LATE;换成另一 UID 读目标 /proc/PID/environ 返回 Permission denied。实验结束前终止短命后台进程并 wait,最后确认 background_jobs=0、沙盒目录已删除、同名容器不存在。
遇到“交互终端能跑,脚本/服务不能跑”时,可以按这个顺序查:
declare -p NAME 查当前 Bash 是否有值、是否带 -x,再用 printenv NAME 查环境可见性。type -a command、command -V command、hash -t command 和 printf '%s\n' "$PATH" 查命令来源,同时查空元素、目录顺序与执行权限。$- 中有无 i,确认调用方式是否带 --login,再对照启动文件四象限。不要先猜“应该读了 .bashrc”。env -i 加回最小变量集复现,通过二分法找出隐式依赖。对 locale 问题按 LC_ALL > LC_* > LANG 检查。bash -n,再开新 Shell 验证。保留 bash --noprofile --norc 恢复入口,确认配置重复加载不会累积副作用。这条链条的核心不是多记几个变量名,而是对每个结论都追问“这是当前 Shell 的值,还是已经进入环境?这是新子进程的启动快照,还是早已运行进程的状态?这个文件是 Bash 规则自动读的,还是发行版/profile 主动 source 的?”。问清这三组问题,环境故障就从“看起来很玄”变成一条可验证的证据链。
如果新配置阻止 Shell 启动,用 bash --noprofile --norc 进入不读用户启动文件的恢复 Shell,然后用备份还原。恢复通道要在出错前就知道,而不是出错后才临时搜。