4213 words
21 minutes
Shell 心智模型·炼气篇(一):从 ./script.sh 报错说起,把 / ~ . $PATH .zshrc 一次讲透

你新写了一个 script.sh,踏踏实实地放在当前目录里,敲下文件名直接执行——command not found。文件明明就在这,为什么 shell 死活找不到,非要在前面加上 ./ 才行?

这个几乎人人都撞上过的瞬间,其实是一条线索,顺着它能把 shell 找文件、找命令的整套逻辑挖出来。这篇就顺着这条线,聊四个话题——/ ~. ./$PATH.zshrc——它们不是四个孤立的知识点,而是同一件事的四个层次,层层往上搭:

  1. 地图:整个文件系统长什么样,你的家在哪(/~)
  2. 称呼”这里”:怎么指代你当前站的位置(../)
  3. 查找机制:给一个命令名,shell 按什么规则去地图上找到它、执行它($PATH)——也是开头那个报错之谜的答案
  4. 配置来源:这套查找规则是谁写的、从哪来的(.zshrc/.bashrc)

按这个顺序读下来,后面每一节都建立在前一节的概念上,不会遇到”用到还没讲的东西”。读完你就知道,那个报错到底在跟你说什么。

一、地图:/~ —— 房子与你的房间#

Terminal window
cd /
pwd
# /

/整个文件系统的根,系统文件、应用程序、所有用户的家目录全部挂在它下面:

/
├── System/
├── Applications/
├── usr/
└── Users/
├── sean/ ← 你的家目录
└── otherguy/ ← 另一个用户的家目录
/Users/sean
cd ~
pwd

~当前用户家目录的快捷符号,本质是环境变量 $HOME 的别名——/ 之下专属于你的那一小块地盘。

权限上的本质区别#

/(根)~(家目录)
拥有者root 用户你自己
写入权限大部分子目录需要 sudo完全自由,不用 sudo
心理模型整栋大楼的公共/系统区域你自己的房间

这也是为什么日常开发都应该待在 ~ 及其子目录下——那是你安全的地盘;/usr/System 这种是系统地基,乱动容易搬起石头砸自己的脚。

常见踩坑#

  1. cd 不带参数 = cd ~——很多人不知道空 cd 就是回家,其实是最常用的抄近路。
  2. ~ 只在 shell 里生效,加引号会失效:echo ~ 展开成路径,echo "~" 原样输出波浪线。写进脚本变量或字符串时更保险的写法是 $HOME,引号里也能正常展开。
  3. ~username 可以跳到别人的家目录(前提是权限允许)。
  4. macOS Finder 侧边栏的”Home”图标 ≠ 系统层面的 /——那个小房子指的是 ~,真正的文件系统根是 Macintosh HD 那一层。
  5. / 下清理东西,和在 ~ 下清理东西,风险不是一个量级——尤其带上 sudo 之后,出错的影响面从”搞乱自己的文件”变成”影响整个系统”。

二、称呼”这里”:../ —— 不是”进入”,是”就是这里”#

有了地图,下一个问题是:你站在地图上某个位置(比如 ~/project),怎么指代”我现在这个位置本身”?

很多人对 ./ 有种下意识的错觉——写 ./script.sh 的时候,感觉自己”进入”了当前目录再执行文件,好像发生了一次位移。

其实完全没有。. 不是一个可以”进入”的子文件夹,它是当前目录本身的别名./script.sh 翻译过来就是:

“从我现在所在的这个位置出发,指向 script.sh”

它和写绝对路径 /Users/sean/project/script.sh 是完全等价的东西,只是换了种写法,你全程没有挪窝。

真正容易踩坑的地方:执行命令 vs 引用路径,规则不一样#

同样一个 ./,在两种场景下”要不要写”的规则完全不同(原因要等下一节 $PATH 才能说透,这里先记住结论):

  • 执行一个命令(比如 script.sh):必须写 ./,不然报错 command not found

    Terminal window
    script.sh # ❌ command not found
    ./script.sh # ✅ 正确
  • 引用一个路径作为参数(比如 cat subfolder/file.txt):写不写 ./ 前缀都行。

    Terminal window
    cat subfolder/file.txt # ✅
    cat ./subfolder/file.txt # ✅ 一模一样

一句话记住:. 不是门,是”你现在脚下这块地”的名字,从没让你移动到任何新地方。

其他几个常见的连带踩坑#

  • rm -rf ./*rm -rf ./ 天差地别,操作前一定 pwd 确认自己在哪。
  • cd ..data 会被当成一个叫 ..data 的目录名报错,拼接时别漏了斜杠。
  • cp -r folder1/. folder2 利用的正是”. = 目录内容本身”这个含义,用来避免多一层嵌套目录。
  • find -name "*.csv" 有些系统上会报错,记得写成 find . -name "*.csv" 明确从当前目录开始。
  • Git 里 HEAD~1..HEAD.. 是”两个提交之间”的语法糖,和路径完全无关——同一个符号在不同工具里语义不同,操作前留个心眼。

三、查找机制:$PATH —— shell 到底怎么找到 echogrep 这些程序的#

上一节留了个问题:为什么裸命令名 script.sh 会报错,必须写 ./script.sh?答案就在 $PATH 这个环境变量里——这是这篇文章真正的核心机制,前两节的”地图”和”称呼”,都是为了讲清楚它做的铺垫。

/bin
echo $PATH

$PATH 是一份用冒号 : 分隔的目录清单。当你敲一个”裸命令名”(不带路径,比如 echo)时,shell 只做一件事:按顺序依次去这些目录里找,看有没有一个叫这个名字的可执行文件,找到第一个匹配就执行,不再往后找。

/usr/local/sbin/echo ? 没有
/usr/local/bin/echo ? 没有
...
/bin/echo ? 有!就是它,执行

顺序很重要——如果好几个目录里都有同名程序,shell 只认第一个找到的。这也是为什么装了新版本工具,敲命令跑出来的却还是旧版本:很可能 $PATH 里排在前面的某个目录,藏着一个更老的同名文件先被匹配到了。

这些目录(挂在”一、地图”里的 / 下面)各自装的是什么#

grep 举个例子:ls /bin 是找不到 grep 的,它躺在 /usr/bin 里。这不是巧合,是这几个目录各自的定位不同:

  • /bin——只放系统启动、单用户/救援模式下必须要有的极少数命令:shcatcpmvrmlskillpwddate 这类。老 Unix 系统里 /usr 有时候挂载在另一个硬盘分区上,系统刚启动、/usr 还没挂载好时,手头能用的只有 /bin/sbin,所以这里必须精简到”哪怕系统半残也能修”的程度(在我机器上只有 37 个文件)。
  • /usr/bin——放日常真正干活用的完整工具集:grepawksedfindcurlpython3 全在这。这些不是”救命必需品”,系统正常挂载完 /usr 之后才需要,所以数量可以很大(我机器上快 1000 个)。
  • /usr/local/bin——放不属于操作系统自身、由你或第三方装的软件,专门和系统维护的 /usr/bin 分开,避免互相污染。平时用 Homebrew 装的东西(brew install xxx)十有八九落在这(Intel Mac 是 /usr/local/bin,Apple Silicon Mac 默认是 /opt/homebrew/bin)。

这个”/usr 曾是独立分区”的历史原因,在很多现代 Linux 发行版里已经名存实亡(不少发行版直接把 /bin 做成指向 /usr/bin 的软链接,两者合并了)。但 macOS 仍然保留两者物理分离,而且 /bin/usr/bin 这些系统目录还被 SIP(System Integrity Protection) 保护着——哪怕是 root,默认也改不了里面的文件,苹果把这层锁死了,想自己装软件只能老老实实走 /usr/local

顺序不是随便排的:实测一遍#

回看 $PATH 的顺序:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin——/usr/local/bin 排在最前面,意味着如果你自己装的软件和系统自带的重名,你装的那个优先生效。用两个同名脚本实测一下,谁排前面谁被执行:

Terminal window
# dirA/mytool 和 dirB/mytool 内容不同,都叫 mytool
$ PATH="dirA:dirB:$PATH" mytool
我是 dirA 里的 mytool # dirA 排前面,执行的是它
$ PATH="dirB:dirA:$PATH" mytool
我是 dirB 里的 mytool # 换成 dirB 排前面,执行的变成它

同一个命令名,同一个 $PATH,唯一变量是谁排在前面,结果就完全不同——shell 找到第一个匹配就停,不会继续找,更不会”两个都执行”或者报错说重名。这也是为什么 Homebrew 装的 python3nodegit 版本能够”覆盖”系统自带的同名版本:不是真的覆盖或删除了系统版本(/usr/bin 里的原版还在,受 SIP 保护动不了),只是 /usr/local/bin(或 /opt/homebrew/bin)排在 $PATH 更前面,shell 优先找到了它。

which:帮你重演一遍这次搜索#

/bin/echo
which echo

which 不执行程序,只是模拟一遍 shell 会做的查找过程,告诉你”如果现在敲这个命令,实际执行的是哪个文件”。排查”为什么跑的是这个版本""这个命令到底装在哪”时非常好用。

回到”称呼”这一节:直接给路径 = 绕过整个 $PATH 搜索#

Terminal window
/bin/echo $PATH

这里直接写了完整路径,shell 不需要去 $PATH 翻,因为你已经明确指名道姓。上一节的 ./script.sh 是同一个原理——./ 就是在说”文件就在当前目录,不用去 $PATH 里搜”。

深层原因:为什么当前目录不在 $PATH 默认搜索范围里#

这倒不是说”当前目录容易有坏文件”这么简单,根子在于权限归属不对称:

谁能写入shell 是否默认搜索
$PATH 里的目录(/bin/usr/bin 等)只有 root✅ 默认搜索,因为可信
当前目录 .任何能把文件放进这个文件夹的人,包括攻击者❌ 默认不搜索,因为不可信

当前目录可能是你解压的压缩包、别人共享的网盘文件夹、git clone 下来的陌生仓库、多用户系统里大家都能写的共享目录——这些地方的内容所有权不一定在你手里

经典攻击场景:攻击者在共享目录里放一个名字就叫 ls 的可执行文件,内容是先偷偷做点坏事、再老实调用真正的 /bin/ls 掩人耳目。如果你的 $PATH. 排在前面,你在这个目录下敲 ls(不需要写 ./!)就会先执行攻击者的版本,而且表面上看起来完全正常。这就是安全圈里的经典漏洞类型 untrusted search path(CWE-426)。

所以老一辈 Unix 安全指南反复强调一句话:永远不要把 . 加进 $PATH,哪怕加在最后面也有风险窗口。

四、配置来源:.zshrc / .bashrc —— 这份查找规则从哪来#

$PATH、alias、命令行提示符样式……这些东西不是天上掉下来的,都是 shell 启动时读了几个配置文件、跑了里面的代码”配置”出来的。搞懂这几个文件什么时候读、按什么顺序读,能省下大量”为什么改了配置没生效”的排查时间。

常见的几个文件,以及它们何时被读取#

zsh(macOS 默认 shell)和 bash 各自有一套类似但不完全相同的启动文件:

zshbash什么时候读
~/.zshenv(无直接对应)总是读取,不管交互式还是脚本
~/.zprofile~/.bash_profile / ~/.profilelogin shell(比如打开一个全新终端窗口、SSH 登录)
~/.zshrc~/.bashrc交互式 shell(你能敲命令、看到提示符的那种)
~/.zlogin(无直接对应)login shell,在 .zshrc 之后

日常你手动编辑、加 alias、改 $PATH 的,基本都是 .zshrc(zsh)或 .bashrc(bash)——因为你关心的场景通常就是”我开个终端窗口,想让它是我要的样子”。

关键认知:配置文件经常只是个”入口”,真正的代码藏在它 source 的其他文件里#

which grep 有时会显示一个 alias,但直接翻 .zshrc 却找不到 alias grep=。原因是 .zshrc 里可能就一行:

Terminal window
source $ZSH/oh-my-zsh.sh

这一行会自动把 ~/.oh-my-zsh/lib/*.zsh 下所有核心库文件都 source 一遍,其中 lib/grep.zsh 才是真正定义这个 alias 的地方。也就是说,.zshrc 本身只有几十行,但它间接拉入的代码可能有几千行——插件管理器(oh-my-zsh、prezto、zinit)、Homebrew 的 shellenv、conda 的 profile.d/conda.sh,都是通过这种”一行 source,背后一大坨”的方式生效的。

排查 shell 行为异常时,思路应该是:

  1. 先看 .zshrc/.bashrc 里有没有直接写着的答案
  2. 如果没有,搜它 source 了哪些文件(grep -nE "^\s*(source|\.)\s" ~/.zshrc)
  3. 顺着这条线一层层往下挖,直到找到真正定义行为的那一行

真实例子:装 miniconda 那次,到底发生了什么#

很多人第一次真正碰 .zshrc,不是自己手动写的,而是装某个工具的时候它自己写进去的——miniconda 就是典型。装完跑一次 conda init,.zshrc 末尾会自动多出这样一段:

Terminal window
# >>> conda initialize >>>
# !! Contents within this block are managed by 'conda init' !!
__conda_setup="$('/Users/sean/miniconda3/bin/conda' 'shell.zsh' 'hook' 2> /dev/null)"
if [ $? -eq 0 ]; then
eval "$__conda_setup"
else
if [ -f "/Users/sean/miniconda3/etc/profile.d/conda.sh" ]; then
. "/Users/sean/miniconda3/etc/profile.d/conda.sh"
else
export PATH="/Users/sean/miniconda3/bin:$PATH"
fi
fi
unset __conda_setup
# <<< conda initialize <<<

拆开看,这段代码在干嘛:

  • >>> conda initialize >>> / <<< conda initialize <<< 这两行注释,是个约定俗成的”托管区域”标记——意思是”这一块是工具自动生成/更新的,你最好别手动改,以后重新跑 conda init 会整块替换掉”。不少工具(nvm、pyenv、rbenv 等)都用类似手法在你的配置文件里划一块自己的地盘。
  • 真正决定”优先用 conda 的 python”的,是最后那句 fallback:export PATH="/Users/sean/miniconda3/bin:$PATH"。注意它是把 miniconda 的 bin 目录塞到 $PATH 最前面,不是加到最后面——这正好是我们在”三、查找机制”里用 mytool 实测过的那条规则:谁排在 $PATH 前面,谁就被优先找到。所以激活 conda 之后敲 python,即使 /usr/bin/python3 依然存在,shell 也会先找到 miniconda 目录下的那份,不需要真的卸载或覆盖系统自带的 Python。
  • 上面那段 __conda_setup/eval 的写法比直接 export PATH=... 更”高级”:它先尝试调用 conda shell.zsh hook 动态生成一整套初始化代码(不只是改 $PATH,还包括 conda activate 这种命令能正常工作所需的 shell 函数),用 eval 执行;只有这个动态生成失败时,才退回到”直接改 $PATH”这种最朴素的兜底方案。

这也是为什么很多人会有种”conda 会偷偷改我的配置文件”的感觉——它确实会,而且是通过在 .zshrc 里插入这种自维护的代码块来实现”每次开终端都自动把 conda 环境准备好”。理解了 $PATH 前插 vs 后插的区别,这段乍看复杂的代码其实就是”把自己塞到队伍最前面”这一件事。

常见踩坑#

  1. 改了配置,新开的终端却没生效——因为改动只影响之后新启动的 shell,当前这个终端窗口读配置文件的那一刻早就过去了。想让当前窗口立刻生效,得手动 source ~/.zshrc 重新执行一遍。
  2. 改错了文件——你用的是 zsh,却在改 .bashrc,自然什么都不会发生。不确定自己用的是哪个 shell,echo $SHELLecho $0 能看出来。
  3. $PATH 越改越长、越改越乱——很多工具(Homebrew、nvm、conda、pyenv)安装时都会自作主张往 .zshrc 追加一段 export PATH=...,装的工具一多,.zshrc 底部一大坨,顺序混乱、甚至出现重复路径。定期 echo $PATH 看看有没有明显冗余,是个好习惯。
  4. .zshenv 里不要放太”重”的东西——因为它连非交互式场景(比如脚本、cron、某些 GUI 程序启动的子 shell)都会读,放个慢启动的东西(比如笨重的 conda 初始化)会拖慢一切调用 shell 的地方,不只是你手动开的终端。

一句话总结(按框架顺序)#

地图:/ 是房子,~ 是房子里分给你的那间屋子——在自己屋子里怎么折腾都行,走出房门到公共区域动手就要小心权限和影响范围。 称呼:. 不是门,是你脚下的地;./ 只是绕开 $PATH 搜索、显式指名道姓的声明,不是位移。 查找:$PATH 是一份”只信任 root 把关的目录”清单,按顺序找、谁排前面谁被执行;which 帮你重演一遍这次搜索。 配置:.zshrc/.bashrc 决定了 $PATH、alias 这些东西从哪来,但它自己经常只是个入口,真正的代码可能藏在它 source 的层层文件里(oh-my-zsh、conda 都是这么干的)。