【笔记】Linux 用户环境的 Shell 初始化与目录边界

用户环境里有两套经常混在一起的机制:Shell 启动文件决定变量和交互行为何时进入进程;XDG Base Directory 约定应用把配置、数据和运行状态放在哪里。

一句话心智模型是:先沿进程父子关系判断配置何时被加载,再沿数据生命周期判断文件应该存放在哪里。

1. Shell 的环境和启动模式都来自进程链

终端模拟器、sshd 或登录管理器启动 Zsh 时,通常会经过 execve(path, argv, envp)。path 指向实际装载的程序,argv 提供启动参数,envp 则把已有环境交给新程序。Zsh 再根据启动参数与输入状态确定运行模式,选择对应的启动文件。

flowchart LR
    A[登录管理器、终端或 sshd] -->|execve: argv + envp| B[Zsh 进程]
    B --> C[确定 login / interactive 状态]
    C --> D[读取对应启动文件]
    D --> E[export 修改当前环境]
    E --> F[子进程继承环境]

argv[0] 按约定表示程序名,但它仍是调用者提供的字符串,不是内核根据 path 认证后强制填写的身份。例如,调用者可以装载 /usr/bin/my-program,却向程序传入这组参数:

argv[0] = "自定义名称"
argv[1] = "--verbose"
argv[2] = "file.txt"

目标程序如何解释这些字符串,由它自己决定。Zsh 就会利用 argv[0] 的形式判断 login 模式,后文会回到这一点。

不要把进程级 argv[0] 与脚本里的 $0 机械地等同。例如执行 zsh script.zsh first 时,Zsh 进程收到的参数大致是 zsh、script.zsh、first;Zsh 选中脚本后,再让脚本中的 $0 表示 script.zsh、$1 表示 first。后者是 Shell 为脚本建立的参数语义,不是内核把进程级 argv[0] 原样映射成了 $0。

环境变量走的是同一条进程链。Shell 先继承上游的 envp,启动文件再用 export 修改当前 Shell 环境,后续子进程继续继承。这解释了两个常见现象:

  • 变量写进 .zprofile 后,登录 Shell 的后代通常都能继承;
  • 修改配置文件不会反向改变已经运行的父进程,只能重新启动相应 Shell,或在当前 Shell 中显式 source。

2. Login 与 Interactive 是两个独立维度

Zsh 启动后要分别回答两个问题:它是否承担一次登录会话的初始化或收尾,以及它是否要面向用户读取命令。前者是 login 维度,后者是 interactive 维度,两者彼此独立。

Login 是 Zsh 的启动模式,不是身份认证结果。真实登录时,login、sshd 或 PAM 等上游组件先验证密码、密钥或其他凭据,认证成功后才启动用户 Shell;Zsh 收到的 login 标记只用来选择初始化与收尾钩子。

上一节中 argv[0] 的作用在这里具体落地。Zsh 有两种常见方式进入 login 模式:显式传入 -l;或者在没有显式设置 LOGIN option 时,把传入 Zsh 的 argv[0] 设为以 - 开头的名称,例如传统的 -zsh。已经进入终端的普通用户也可以自行执行 zsh -l,不会再次认证,也不会获得更高权限。因此,把命令只放进 .zprofile 不能构成安全控制。

Interactive 表示 Shell 面向人使用,通常要提供提示符、历史、补全和行编辑。zsh -i 可显式强制交互模式;zsh script.zsh 这类用来执行脚本的 Shell 则通常是非交互的。

状态常见启动方式主要关注点
login + interactivezsh -l、配置为 login 的终端会话环境初始化和交互配置都会运行
non-login + interactive在现有终端执行 zsh只需要交互体验
login + non-interactivezsh -l -c 'command'登录环境,但不显示交互提示符
non-login + non-interactivezsh script.zsh、zsh -c 'command'自动化脚本

终端模拟器、SSH 服务和发行版如何启动 Shell 是外部行为,不能仅凭“打开了终端”或“通过 SSH”推断状态。可直接观察:

[[ -o login ]] && print login || print non-login
[[ -o interactive ]] && print interactive || print non-interactive

$- 会列出当前启用的单字符 Shell options,但阅读它不如用 [[ -o ... ]] 直接表达意图。ps -p $$ -o command= 中看到 -zsh 只能作为启动方式的线索;判断当前 Zsh 状态时,仍以 [[ -o login ]] 和 [[ -o interactive ]] 为准。

3. Zsh 启动文件是一组条件钩子

在默认启用 RCS 和 GLOBAL_RCS 时,Zsh 的启动顺序可以概括为:

所有 Zsh:       global zshenv → $ZDOTDIR/.zshenv
login Zsh:      global zprofile → $ZDOTDIR/.zprofile
interactive Zsh:global zshrc → $ZDOTDIR/.zshrc
login Zsh:      global zlogin → $ZDOTDIR/.zlogin

login shell 退出时顺序反过来:

$ZDOTDIR/.zlogout → global zlogout

$ZDOTDIR 未设置时默认为 $HOME。全局启动文件的实际目录是构建 Zsh 时配置的,Ubuntu/Debian 常见 /etc/zsh/,不能把 /etc/zshenv 当成所有系统的固定路径。

当前个人 dotfiles 恰好按这条读取链组织:~/Eden/shell/zshenv、zprofile 和 zshrc 是事实源,分别软链接到 ~/.zshenv、~/.zprofile 和 ~/.zshrc。下面在解释各启动文件职责时,同时用这组配置验证落点。

3.1 .zshenv:所有 Zsh 的共同入口

.zshenv 连非交互脚本也会读取,因此应保持无输出、低开销,并避免改变脚本依赖的交互选项。适合放:

  • ZDOTDIR 等必须在后续启动文件之前生效的 Zsh 自身变量;
  • 确实要求每一个 Zsh 进程都具备的少量环境变量。

“变量很重要”不等于必须放 .zshenv。若程序不是由 Zsh 启动,它仍然看不到这里的变量。

当前 ~/Eden/shell/zshenv 放置 XDG 基础目录、PATH 和通用环境变量,对应的就是“所有 Zsh 都需要”这个条件。

3.2 .zprofile:login 初始化

.zprofile 适合一次 login shell 启动所需的环境准备。这里的“一次”指每个 login Zsh 执行一次;嵌套执行 zsh -l 仍会再次读取,终端应用也可能为每个新窗口创建 login shell。它并不是整个桌面或 SSH 会话的全局单例。

适合放需要让后代进程继承、但无需每次交互式子 Shell 重做的初始化。耗时命令仍应谨慎,因为 login shell 不只由图形终端产生。

当前 ~/Eden/shell/zprofile 放置 Homebrew、OrbStack 和 mise shims 的 login 阶段初始化,避免在每个交互式子 Shell 中重复执行。

3.3 .zshrc:交互行为

.zshrc 适合仅在用户操作命令行时需要的内容:

  • prompt、补全和键绑定;
  • alias、交互函数;
  • 语法高亮、自动建议等插件。

在现有终端里执行 zsh 会再次读取 .zshrc,因此配置最好可重复执行,不要无限追加 PATH 或重复注册 hook。

当前 ~/Eden/shell/zshrc 中的 Oh My Zsh、插件、alias、Starship 和交互式 mise 激活,都依赖用户正在操作终端,所以落在这一层。

3.4 .zlogin 与 .zlogout

.zlogin 在交互启动文件之后读取,但它只取决于 login 状态,不保证一定存在终端。避免在没有检查终端的情况下输出欢迎信息。

.zlogout 在 login Zsh 正常退出时读取。进程被 SIGKILL、机器掉电或程序强制终止时,不能依赖它完成关键数据提交。它适合尽力而为的收尾,不适合作为唯一清理机制。

3.5 .env 与 .aliases 不是 Zsh 协议

Zsh 不会自动读取 ~/.env 或 ~/.aliases。它们只有被某个官方启动文件显式 source 时才生效:

[[ -r "$HOME/.aliases" ]] && source "$HOME/.aliases"

.env 还常被应用框架解释为 dotenv 格式;dotenv 并不保证支持完整 Zsh 语法。把同一个文件同时当 Shell 脚本和 dotenv 文件使用,会形成隐蔽的语法与密钥泄漏风险。

遇到一段新配置时,也可以沿这条读取链反向判断落点:先问脚本启动的 Zsh 是否也必须获得它,若是才考虑 .zshenv;否则继续区分它只属于 login 入口,还是只服务交互终端,分别放入 .zprofile 或 .zshrc。当前 dotfiles 只是这套判断的一个实例,具体工具会继续演进,分层依据不变。

4. .profile 属于兼容生态,不是 Zsh 启动文件

.profile 是 Bourne/POSIX Shell 传统入口。原生模式的 Zsh 不会因为自己是 login shell 就自动读取 ~/.profile;是否读取取决于系统启动链、兼容模式或用户自己的 source。

因此,从 Bash 切换到 Zsh 时不应直接删除所有 Bash/Profile 文件。先检查:

getent passwd "$USER"
rg -n 'source|\.|PATH|export' ~/.profile ~/.bash_profile ~/.bashrc ~/.zprofile ~/.zshrc 2>/dev/null

确认变量已迁移、没有其他程序依赖后,再决定是否保留。历史记录可删不代表初始化文件可无条件删;用户配置模板通常能从 /etc/skel 恢复,但个人内容未必可恢复。

5. XDG 解决的是文件位置,不是 Shell 加载顺序

XDG Base Directory Specification 为应用提供一组路径变量。变量未设置或不是绝对路径时,应用应使用规范默认值,而不是把相对路径偷偷拼到当前目录。

变量默认值生命周期与用途
XDG_CONFIG_HOME$HOME/.config用户配置
XDG_DATA_HOME$HOME/.local/share用户专属数据文件
XDG_STATE_HOME$HOME/.local/state应跨重启保留、但通常不可移植的状态
XDG_CACHE_HOME$HOME/.cache丢失后不影响数据正确性的非必要缓存
XDG_RUNTIME_DIR无静态默认值当前登录期的 socket、FIFO 等运行时对象
XDG_CONFIG_DIRS/etc/xdg按优先级搜索系统配置的目录列表
XDG_DATA_DIRS/usr/local/share:/usr/share按优先级搜索系统数据的目录列表

XDG_RUNTIME_DIR 应由登录系统创建,属于当前用户、权限为 0700,并绑定登录生命周期。应用不应随意用 /tmp 或自造固定路径代替其安全语义。

6. Config、Data、State 与 Cache 的真正边界

用应用 foo 举例:

~/.config/foo/config.toml          用户选择的行为
~/.local/share/foo/library.db      应用持有的用户数据
~/.local/state/foo/history         跨启动保留的历史和状态
~/.cache/foo/index                 可重新生成的索引
/run/user/1000/foo.sock            当前登录期 IPC

判断时不要使用“文件大小”或“是否文本”这样的表面特征:

  • Config:改变它会改变应用应该怎样工作;
  • Data:它是应用要保存和读取的用户数据;
  • State:它记录应用过去怎样运行,跨重启有价值,但不要求在另一台机器上可移植;规范明确举例包括日志、历史、最近使用文件和当前视图;
  • Cache:删除后应用仍能正确工作,只是需要重新计算或下载;
  • Runtime:只服务当前登录期,系统重启或完整登出后不应继续依赖。

是否备份是用户策略,不是规范直接替你决定的。Data 通常优先级最高;Config 和部分 State 是否备份取决于恢复目标。

7. share 与“共享给别人”无关

XDG_DATA_HOME 默认是 ~/.local/share。这里的 share 继承 Unix 安装布局中 architecture-independent data 的含义:数据不依赖 CPU 架构,可以被同一安装环境中的程序使用;它不表示自动共享给其他账户。

系统级的 XDG_DATA_DIRS 是有序搜索路径。应用通常先看用户目录,再看系统目录,从而允许用户资源覆盖全局默认资源。具体子目录如 applications/、icons/、mime/ 由其他 freedesktop 规范定义,不是 Base Directory Specification 自己穷举的固定目录表。

8. ~/.local/bin 不是 XDG Base Directory 变量

~/.local/bin 是常见的用户级可执行文件目录,但它不由 XDG_* 变量定义,也不属于 XDG_CACHE_HOME。程序本体不能放进随时可重建的 cache。

同样,不能看到 ~/.local/share 就推导 ~/.local/lib、include 都由 XDG Base Directory Specification 定义。它们来自更广泛的 Unix/FHS/发行版和工具链约定。

9. Shell 配置与 XDG 怎样连接

如果希望把 Zsh 配置移到 XDG 风格目录,可以在最早读取的用户文件中设置:

export ZDOTDIR="${XDG_CONFIG_HOME:-$HOME/.config}/zsh"

但存在启动悖论:Zsh 必须先找到默认位置的 .zshenv,才能知道新的 ZDOTDIR。因此通常仍保留一个很小的 ~/.zshenv 作为跳板。

对于自己开发的应用,应由程序读取 XDG 变量并实现默认值,不应要求用户先在 .zshrc 中导出默认路径。图形应用和系统服务未必由交互式 Shell 启动。

10. 排障顺序

配置或文件位置不符合预期时,按下面顺序检查:

  1. 谁启动了当前进程,继承了什么环境?
  2. 当前 Zsh 是 login、interactive,还是两者兼有?
  3. 实际 ZDOTDIR 和全局启动文件目录是什么?
  4. 哪个文件显式 source 了自定义片段?
  5. 应用是否真的支持 XDG,还是仍使用自己的传统目录?
  6. XDG 变量是否是绝对路径?列表变量的覆盖顺序是否正确?

可用的观测命令:

print -r -- "ZDOTDIR=${ZDOTDIR:-$HOME}"
env | sort | rg '^(XDG_|PATH=|SHELL=)'
zsh -xlic exit
zsh -xfic exit

-x 会暴露启动过程中执行的命令,输出可能包含路径和环境值,不应在含密钥的环境里直接公开日志。

11. 复习索引

  • Login 决定 .zprofile/.zlogin/.zlogout;interactive 决定 .zshrc。
  • Login 只是 Zsh 启动模式,不代表刚通过身份认证,也不会带来额外权限。
  • -l 可显式设置 login 模式;未显式设置时,Zsh 也会把以 - 开头的 argv[0] 解释为 login 标记。
  • .zshenv 影响每一个 Zsh,越早加载,越要精简。
  • 全局 Zsh 文件路径由构建配置决定,Ubuntu 常见 /etc/zsh/。
  • .env/.aliases 是自定义片段,不会被 Zsh 自动读取。
  • XDG 目录按数据生命周期分,不按扩展名或大小分。
  • State 跨重启但通常不可移植;Cache 可以重建;Runtime 绑定登录期。
  • ~/.local/bin 常见但不是 XDG Base Directory 变量。
  • Shell 只影响自己的后代,应用应自行实现 XDG 默认路径。

12. 核验入口