最近遇到了一个关于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/bin
在PATH
变量中,所以可以直接运行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
复制
有两个问题需要注意:
目录可以重复吗? 多个目录下有同名的可执行文件怎么办? 第一个问题,目录是允许重复的,这就是个变量,你给它赋什么值它就是什么,重复不会带来额外的问题。就我个人而言,在我懂一点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
复制
其中zlogin
和zlogout
一般用不到,所以在后面的描述中就把这两个配置文件忽略掉了。登录 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的行为上面并没有提到:
如果path_helper运行的时候PATH有值,这个值会被保留 path_helper会对PATH的内容进行去重,保留靠前的值,即优先级高的值 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全给我毁掉了。
一些解决方案# 找到了问题的原因,那么针对性的就有一些解决方案:
最简单的方案就是,在~/.zshrc
中,所有的PATH变量在赋值的时候不要检查是否重复,缺点就是当前shell中的PATH值会有重复路径,如前面提到的,我略微有些强迫症,感觉不够优雅,所以pass 我Google搜索到一个方案是,path_helper执行之前,清空PATH,具体可以查看MasteringThePathHelper ,这当然也有问题,zshenv文件就永远不能设置PATH了,副作用有点大,比如cargo的默认行为就是给PATH放在~/.zshenv
里面赋值,所以pass 改tmux配置,不让它作为登录shell,这样zprofile就不会执行了。我不太清楚为什么tmux要作为一个登录shell运行,因为我使用tmux一般都需要打开终端窗口,所以对于我的使用习惯,这个方案更加好一点。配置很简单,在tmux配置文件中增加一行set -g default-command "${SHELL}"
即可 其他的解决方法我没有再花更多精力去研究,目前够用,后面如果遇到问题再说吧。
遗留的几个疑问# 在~/.zshenv
执行的时候PATH就有默认值了,这个默认值是哪里来的呢?(每个终端运行的值不一致,猜测应该是终端程序自己设置的) 为啥MacOS中新开个终端窗口就算登录了?Linux好像不是这样的。 Linux中PATH的行为是怎样的,有空了研究一下。