最近遇到了一个关于PATH环境变量的问题,本篇文章先记录一下这个问题以及我的解决方案,对于更深层次的原理剖析留待之后再研究。

先简单描述一下标题中所说的path_helper的奇怪行为,path_helper会对PATH环境变量中的目录进行去重,同时对/etc/paths文件中的目录以及/etc/paths.d/目录下所有文件内容的目录进行“提升”,最终导致PATH变量的目录顺序与预期不符。

PATH环境变量

上面的描述可能有点抽象,下面详细展开说一下。环境变量的详细定义这里不做赘述,我个人的理解是这和代码中的变量没什么太大区别,用户可以访问可以修改,也有自己的作用域范围。PATH环境变量在 Unix 系统中以及 Windows 系统中的作用都是一致的,就是存放可执行文件的目录。Unix 系统中,可以通过运行echo $PATH命令来查看其内容,比如我的结果是这样的:

1
./node_modules/.bin:/Users/ek/.bun/bin:/Users/ek/Library/pnpm:/Users/ek/.volta/bin:/Users/ek/go/bin:/Users/ek/.cargo/bin:/Users/ek/miniconda3/condabin:/Users/ek/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts:/Users/ek/.fzf/bin

目录之间用:分隔,这些目录下的所有可执行文件都可以直接访问,不需要输入完整的路径,比如接下来要用到的一个命令tr,存放在/usr/bin目录下,由于/usr/binPATH变量中,所以可以直接运行tr,而不需要输入/usr/bin/tr整个路径。上面的输出结果是一行的,浏览的时候很不方便,所以我一般习惯用组合的命令echo $PATH | tr ':' '\n'来查看,其实就是把:替换成了换行符,这样一个目录一行,看起来比较省力,新命令的运行结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
./node_modules/.bin
/Users/ek/.bun/bin
/Users/ek/Library/pnpm
/Users/ek/.volta/bin
/Users/ek/go/bin
/Users/ek/.cargo/bin
/Users/ek/miniconda3/condabin
/Users/ek/.local/bin
/opt/homebrew/bin
/opt/homebrew/sbin
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/bin
/usr/sbin
/sbin
/Library/Apple/usr/bin
/Applications/Wireshark.app/Contents/MacOS
/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin
/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin
/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin
/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts
/Users/ek/.fzf/bin

有两个问题需要注意:

  1. 目录可以重复吗?
  2. 多个目录下有同名的可执行文件怎么办?

第一个问题,目录是允许重复的,这就是个变量,你给它赋什么值它就是什么,重复不会带来额外的问题。就我个人而言,在我懂一点shell脚本之前,我自定义的PATH命令都是这样设置的:

1
export PATH="$HOME/.local/bin:$PATH"

这会导致PATH有重复,其实如果不是强迫症,重复了问题应该也不大,但是我知道它重复了就不能忍了,所以现在要这样设置:

1
2
3
4
case ":${PATH}:" in
    *:"$HOME/.local/bin":*) ;;
    *) export PATH="$HOME/.local/bin:$PATH" ;;
esac

第二个问题,有同名文件的情况下,就涉及到了优先级,越靠前的目录,优先级越高。举一个我遇到的实际例子,mac 系统默认会装一个 vim,其路径是/usr/bin/vim,但是这个 vim 一般只有系统升级才会跟着升级,版本就比较老;我想要用稍微新一点的 vim,所以我就用 homebrew 又安装了一个 vim,其路径是/opt/homebrew/bin/vim,这里就遇到了同名文件的优先级问题,怎么知道输入vim命令的时候用的是哪一个 vim 呢?which vim可以查看到优先级最高的那个 vim 的路径,which -a vim可以查看所有的 vim 的路径。回看上边的 PATH 变量的内容中,/opt/homebrew/bin目录是在/usr/bin目录之前的,所以运行vim命令实际上使用的是 homebrew 安装的较新的那个。

1
2
3
 π ~ ❯ which -a vim
/opt/homebrew/bin/vim
/usr/bin/vim

zsh

说完PATH再来说一下 zsh,我是从 MacOS Big Sur 11.0 开始用 mac 系统的,当时的默认 shell 已经是 zsh 了,因为有 ohmyzsh 项目的存在,可以很方便的装一些插件,也可以搞一些很漂亮的样式,所以 zsh 也就自然而然地成为了我最常用的 shell。zsh 有一些配置文件,主要是zshenv zprofile zshrc zlogin zlogout,这些配置文件每一个都有两份,一份是系统级别的,使用这个系统的每个用户都会执行该配置,存放在/etc目录下;另一份是用户级别的,存放在用户的家目录下,同时文件名以.开头。比如我的系统 zshrc 文件路径是/etc/zshrc,我个人的 zshrc 文件存放路径是/Users/ek/.zshrc。每个文件的执行顺序和时机是不一致的,具体如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 每次都执行
/etc/zshenv
~/.zshenv
# 只有登录shell才执行
/etc/zprofile
~/.zprofile
# 只有交互式shell才执行
/etc/zshrc
~/.zshrc
# 只有登录shell才执行
/etc/zlogin
~/.zlogin
# 只有登录shell才执行,退出登录时执行
/etc/zlogout
~/.zlogout

其中zloginzlogout一般用不到,所以在后面的描述中就把这两个配置文件忽略掉了。登录 shell 就是作为一个新用户登录到这台电脑的情况下,交互式 shell 一般就是在终端程序中打开的 shell,与之相对的是非交互式 shell,比如 shell 脚本中调用的 shell 就是非交互式的。举个例子,在 MacOS 中,打开系统自带的终端,每新开一个 tab 或者窗口就会多一个登录式 shell,同时这个 shell 也是交互式的,也就是说上面列出来的所有配置文件都会执行,现在我在随便一个窗口里输入zsh,然后回车,这种情况下就是运行了一个交互式 shell,但没有登录,所以 zprofile 不会执行。如果有一个脚本比如aaa.zsh,我通过./aaa.zsh运行这个脚本的时候,只有 zshenv 配置文件会执行,其它的都不会执行。

另外,可以通过uptime命令查看这台电脑的运行时间以及当前登录了几个用户(调用一次login命令就算一个用户了),输出如下:

1
11:24  up 18 days, 16:01, 5 users, load averages: 2.70 2.41 2.30

那么在 MacOS 系统中,默认的配置文件有那些呢?通过ls -l /etc/z*命令,可以看到,系统默认情况下有如下几个配置文件:

1
2
3
-r--r--r--  1 root  wheel   255 Dec  2 15:00 /etc/zprofile
-r--r--r--  1 root  wheel  3094 Dec  2 15:00 /etc/zshrc
-rw-r--r--  1 root  wheel  9335 Dec  2 15:00 /etc/zshrc_Apple_Terminal

其中zshrc_Apple_Terminal并不是标准的配置文件,而是被/etc/zshrc调用的自定义的一个文件,具体可以查看 zshrc 的内容,在这里不重要。今天的主角是/etc/zprofile,前面扯了一大堆,终于到要进入正题了,运行cat /etc/zprofile,其内容如下:

1
2
3
4
5
6
7
8
# System-wide profile for interactive zsh(1) login shells.

# Setup user specific overrides for this in ~/.zprofile. See zshbuiltins(1)
# and zshoptions(1) for more details.

if [ -x /usr/libexec/path_helper ]; then
	eval `/usr/libexec/path_helper -s`
fi

这里的意思是,如果/usr/libexec/path_helper这个文件存在,并且它是个可执行文件,那么就把/usr/libexec/path_helper -s的输出结果当作一个命令来执行。然后运行一下/usr/libexec/path_helper -s看,结果如下:

1
2
PATH="/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./node_modules/.bin:/Users/ek/.bun/bin:/Users/ek/Library/pnpm:/Users/ek/.volta/bin:/Users/ek/go/bin:/Users/ek/.cargo/bin:/Users/ek/miniconda3/condabin:/Users/ek/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts:/Users/ek/.fzf/bin"; export PATH;
MANPATH="/usr/share/man:/usr/local/share/man:/Applications/Wireshark.app/Contents/Resources/share/man:/opt/homebrew/share/man:"; export MANPATH;

这段代码定义了PATH变量和MANPATH变量,下面运行man path_helper查看一下path_helper的手册:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
path_helper(8)                                                                     System Manager's Manual                                                                     path_helper(8)

NAME
     path_helper  helper for constructing PATH environment variable

SYNOPSIS
     path_helper [-c | -s]

DESCRIPTION
     The path_helper utility reads the contents of the files in the directories /etc/paths.d and /etc/manpaths.d and appends their contents to the PATH and MANPATH environment variables
     respectively.  (The MANPATH environment variable will not be modified unless it is already set in the environment.)

     Files in these directories should contain one path element per line.

     Prior to reading these directories, default PATH and MANPATH values are obtained from the files /etc/paths and /etc/manpaths respectively.

     Options:

     -c      Generate C-shell commands on stdout.  This is the default if SHELL ends with "csh".

     -s      Generate Bourne shell commands on stdout.  This is the default if SHELL does not end with "csh".

NOTE
     The path_helper utility should not be invoked directly.  It is intended only for use by the shell profile.

Mac OS X                                                                                March 15, 2007                                                                               Mac OS X

大意就是,这个命令用来组装PATH和MANPATH这两个环境变量,其中PATH的默认值是/etc/paths里面的内容,然后会把/etc/paths.d目录下所有的文件中的内容附加到默认值后面,这些文件内容的格式都是一行一个目录的形式,运行cat /etc/paths看一下:

1
2
3
4
5
6
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/bin
/usr/sbin
/sbin

MANPATH也同理。

path_helper的奇怪行为

到目前为止,一切正常,不过问题马上就要来了,因为有几个path_helper的行为上面并没有提到:

  1. 如果path_helper运行的时候PATH有值,这个值会被保留
  2. path_helper会对PATH的内容进行去重,保留靠前的值,即优先级高的值
  3. path_helper会对/etc/paths/etc/paths.d/*的内容进行“提升”,将这部分值放到PATH变量的最前面

第二个行为没啥毛病,问题出在一和三这里,我们来实验一下,在~/.zshenv ~/.zprofile ~/.zshrc三个文件的开头分别增加以下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ~/.zshenv
echo "~/.zshenv loaded"
export PATH="./aaaaa:./bbbbb:./aaaaa:$PATH"
echo $PATH

# ~/.zprofile
echo "~/.zprofile loaded"
echo $PATH

# ~/.zshrc
echo "~/.zshrc loaded"
echo $PATH

然后新打开一个终端,这里就以系统自带的Terminal为例,打开之后的输出如下:

1
2
3
4
5
6
7
Last login: Sun Dec 31 13:45:24 on ttys001
~/.zshenv loaded
./aaaaa:./bbbbb:./aaaaa:/usr/bin:/bin
~/.zprofile loaded
/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./aaaaa:./bbbbb
~/.zshrc loaded
/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./aaaaa:./bbbbb:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts

因为/etc/zprofile是在~/.zshenv之后,~/.zprofile之前执行的,也就是在其加载之前,PATH的值是./aaaaa:./bbbbb:./aaaaa:/usr/bin:/bin,然后执行/etc/zprofile以后,PATH的值当中,两个./aaaaa被移除了一个,剩下的那个和./bbbbb都被放到了末尾,这验证了上面的第二个和第三个行为。

这带来的问题就是,在~/.zshenv中的自定义路径优先级被调低了,如果恰好这里面的目录有个可执行文件与系统的同名,就无法取得预期的效果了。不过,如果使用终端的习惯是原生窗口和tab,那把这个定义放在~/.zshrc当中就行了,本来一般zshenv和zprofile也不常用。但是我是个习惯使用tmux的人,接着刚才的窗口,输入tmux命令(使用tmux默认配置),看一下结果:

1
2
3
4
5
6
~/.zshenv loaded
./aaaaa:./bbbbb:./aaaaa:./node_modules/.bin:/Users/ek/.bun/bin:/Users/ek/Library/pnpm:/Users/ek/.volta/bin:/Users/ek/go/bin:/Users/ek/.cargo/bin:/Users/ek/miniconda3/condabin:/Users/ek/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./aaaaa:./bbbbb:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts:/Users/ek/.fzf/bin
~/.zprofile loaded
/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./aaaaa:./bbbbb:./node_modules/.bin:/Users/ek/.bun/bin:/Users/ek/Library/pnpm:/Users/ek/.volta/bin:/Users/ek/go/bin:/Users/ek/.cargo/bin:/Users/ek/miniconda3/condabin:/Users/ek/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts:/Users/ek/.fzf/bin
~/.zshrc loaded
/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:./aaaaa:./bbbbb:./node_modules/.bin:/Users/ek/.bun/bin:/Users/ek/Library/pnpm:/Users/ek/.volta/bin:/Users/ek/go/bin:/Users/ek/.cargo/bin:/Users/ek/miniconda3/condabin:/Users/ek/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts:/Users/ek/.fzf/bin:/Users/ek/Library/Application Support/JetBrains/Toolbox/scripts

这时候我发现,我的PATH简直乱成了一锅粥啊,这就是由于上面提到的第一个行为导致的。我的预期是,我写在zshrc里面的PATH要放在最前面,这样能够方便的覆盖系统的可执行文件,就是按照我的预期去执行对应的程序,但现在path_helper全给我毁掉了。

一些解决方案

找到了问题的原因,那么针对性的就有一些解决方案:

  1. 最简单的方案就是,在~/.zshrc中,所有的PATH变量在赋值的时候不要检查是否重复,缺点就是当前shell中的PATH值会有重复路径,如前面提到的,我略微有些强迫症,感觉不够优雅,所以pass
  2. 我Google搜索到一个方案是,path_helper执行之前,清空PATH,具体可以查看MasteringThePathHelper,这当然也有问题,zshenv文件就永远不能设置PATH了,副作用有点大,比如cargo的默认行为就是给PATH放在~/.zshenv里面赋值,所以pass
  3. 改tmux配置,不让它作为登录shell,这样zprofile就不会执行了。我不太清楚为什么tmux要作为一个登录shell运行,因为我使用tmux一般都需要打开终端窗口,所以对于我的使用习惯,这个方案更加好一点。配置很简单,在tmux配置文件中增加一行set -g default-command "${SHELL}"即可

其他的解决方法我没有再花更多精力去研究,目前够用,后面如果遇到问题再说吧。

遗留的几个疑问

  1. ~/.zshenv执行的时候PATH就有默认值了,这个默认值是哪里来的呢?(每个终端运行的值不一致,猜测应该是终端程序自己设置的)
  2. 为啥MacOS中新开个终端窗口就算登录了?Linux好像不是这样的。
  3. Linux中PATH的行为是怎样的,有空了研究一下。