- 1. Shell 心智模型·炼气篇(一):从 ./script.sh 报错说起,把 / ~ . $PATH .zshrc 一次讲透
- 2. Shell 心智模型·炼气篇(二):从 cat | grep | wc -l 严丝合缝说起,把管道 | 一次讲透
- 3. Shell 心智模型·炼气篇(三):从 `[ -d dir ] || mkdir dir` 说起,把 `[`、`[[ ]]`、`$`、`$?`、`&&`、`||` 一次讲透
- 4. Shell 心智模型·炼气篇(四):从 ./my_script.sh 里 export 出的变量,回终端却查无此变量说起,把子进程、export、source、命令替换一次讲透
你新写了一个 script.sh,踏踏实实地放在当前目录里,敲下文件名直接执行——command not found。文件明明就在这,为什么 shell 死活找不到,非要在前面加上 ./ 才行?
这个几乎人人都撞上过的瞬间,其实是一条线索,顺着它能把 shell 找文件、找命令的整套逻辑挖出来。这篇就顺着这条线,聊四个话题——/ ~、. ./、$PATH、.zshrc——它们不是四个孤立的知识点,而是同一件事的四个层次,层层往上搭:
- 地图:整个文件系统长什么样,你的家在哪(
/和~) - 称呼”这里”:怎么指代你当前站的位置(
.和./) - 查找机制:给一个命令名,shell 按什么规则去地图上找到它、执行它(
$PATH)——也是开头那个报错之谜的答案 - 配置来源:这套查找规则是谁写的、从哪来的(
.zshrc/.bashrc)
按这个顺序读下来,后面每一节都建立在前一节的概念上,不会遇到”用到还没讲的东西”。读完你就知道,那个报错到底在跟你说什么。
一、地图:/ 和 ~ —— 房子与你的房间
cd /pwd# // 是整个文件系统的根,系统文件、应用程序、所有用户的家目录全部挂在它下面:
/├── System/├── Applications/├── usr/└── Users/ ├── sean/ ← 你的家目录 └── otherguy/ ← 另一个用户的家目录cd ~pwd~ 是当前用户家目录的快捷符号,本质是环境变量 $HOME 的别名——/ 之下专属于你的那一小块地盘。
权限上的本质区别
/(根) | ~(家目录) | |
|---|---|---|
| 拥有者 | root 用户 | 你自己 |
| 写入权限 | 大部分子目录需要 sudo | 完全自由,不用 sudo |
| 心理模型 | 整栋大楼的公共/系统区域 | 你自己的房间 |
这也是为什么日常开发都应该待在 ~ 及其子目录下——那是你安全的地盘;/usr、/System 这种是系统地基,乱动容易搬起石头砸自己的脚。
常见踩坑
cd不带参数 =cd ~——很多人不知道空cd就是回家,其实是最常用的抄近路。~只在 shell 里生效,加引号会失效:echo ~展开成路径,echo "~"原样输出波浪线。写进脚本变量或字符串时更保险的写法是$HOME,引号里也能正常展开。~username可以跳到别人的家目录(前提是权限允许)。- macOS Finder 侧边栏的”Home”图标 ≠ 系统层面的
/——那个小房子指的是~,真正的文件系统根是 Macintosh HD 那一层。 - 在
/下清理东西,和在~下清理东西,风险不是一个量级——尤其带上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 到底怎么找到 echo、grep 这些程序的
上一节留了个问题:为什么裸命令名 script.sh 会报错,必须写 ./script.sh?答案就在 $PATH 这个环境变量里——这是这篇文章真正的核心机制,前两节的”地图”和”称呼”,都是为了讲清楚它做的铺垫。
echo $PATH$PATH 是一份用冒号 : 分隔的目录清单。当你敲一个”裸命令名”(不带路径,比如 echo)时,shell 只做一件事:按顺序依次去这些目录里找,看有没有一个叫这个名字的可执行文件,找到第一个匹配就执行,不再往后找。
/usr/local/sbin/echo ? 没有/usr/local/bin/echo ? 没有.../bin/echo ? 有!就是它,执行顺序很重要——如果好几个目录里都有同名程序,shell 只认第一个找到的。这也是为什么装了新版本工具,敲命令跑出来的却还是旧版本:很可能 $PATH 里排在前面的某个目录,藏着一个更老的同名文件先被匹配到了。
这些目录(挂在”一、地图”里的 / 下面)各自装的是什么
拿 grep 举个例子:ls /bin 是找不到 grep 的,它躺在 /usr/bin 里。这不是巧合,是这几个目录各自的定位不同:
/bin——只放系统启动、单用户/救援模式下必须要有的极少数命令:sh、cat、cp、mv、rm、ls、kill、pwd、date这类。老 Unix 系统里/usr有时候挂载在另一个硬盘分区上,系统刚启动、/usr还没挂载好时,手头能用的只有/bin和/sbin,所以这里必须精简到”哪怕系统半残也能修”的程度(在我机器上只有 37 个文件)。/usr/bin——放日常真正干活用的完整工具集:grep、awk、sed、find、curl、python3全在这。这些不是”救命必需品”,系统正常挂载完/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 排在最前面,意味着如果你自己装的软件和系统自带的重名,你装的那个优先生效。用两个同名脚本实测一下,谁排前面谁被执行:
# dirA/mytool 和 dirB/mytool 内容不同,都叫 mytool
$ PATH="dirA:dirB:$PATH" mytool我是 dirA 里的 mytool # dirA 排前面,执行的是它
$ PATH="dirB:dirA:$PATH" mytool我是 dirB 里的 mytool # 换成 dirB 排前面,执行的变成它同一个命令名,同一个 $PATH,唯一变量是谁排在前面,结果就完全不同——shell 找到第一个匹配就停,不会继续找,更不会”两个都执行”或者报错说重名。这也是为什么 Homebrew 装的 python3、node、git 版本能够”覆盖”系统自带的同名版本:不是真的覆盖或删除了系统版本(/usr/bin 里的原版还在,受 SIP 保护动不了),只是 /usr/local/bin(或 /opt/homebrew/bin)排在 $PATH 更前面,shell 优先找到了它。
which:帮你重演一遍这次搜索
which echowhich 不执行程序,只是模拟一遍 shell 会做的查找过程,告诉你”如果现在敲这个命令,实际执行的是哪个文件”。排查”为什么跑的是这个版本""这个命令到底装在哪”时非常好用。
回到”称呼”这一节:直接给路径 = 绕过整个 $PATH 搜索
/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 各自有一套类似但不完全相同的启动文件:
| zsh | bash | 什么时候读 |
|---|---|---|
~/.zshenv | (无直接对应) | 总是读取,不管交互式还是脚本 |
~/.zprofile | ~/.bash_profile / ~/.profile | 仅 login shell(比如打开一个全新终端窗口、SSH 登录) |
~/.zshrc | ~/.bashrc | 仅 交互式 shell(你能敲命令、看到提示符的那种) |
~/.zlogin | (无直接对应) | login shell,在 .zshrc 之后 |
日常你手动编辑、加 alias、改 $PATH 的,基本都是 .zshrc(zsh)或 .bashrc(bash)——因为你关心的场景通常就是”我开个终端窗口,想让它是我要的样子”。
关键认知:配置文件经常只是个”入口”,真正的代码藏在它 source 的其他文件里
which grep 有时会显示一个 alias,但直接翻 .zshrc 却找不到 alias grep=。原因是 .zshrc 里可能就一行:
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 行为异常时,思路应该是:
- 先看
.zshrc/.bashrc里有没有直接写着的答案 - 如果没有,搜它
source了哪些文件(grep -nE "^\s*(source|\.)\s" ~/.zshrc) - 顺着这条线一层层往下挖,直到找到真正定义行为的那一行
真实例子:装 miniconda 那次,到底发生了什么
很多人第一次真正碰 .zshrc,不是自己手动写的,而是装某个工具的时候它自己写进去的——miniconda 就是典型。装完跑一次 conda init,.zshrc 末尾会自动多出这样一段:
# >>> 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" fifiunset __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 后插的区别,这段乍看复杂的代码其实就是”把自己塞到队伍最前面”这一件事。
常见踩坑
- 改了配置,新开的终端却没生效——因为改动只影响之后新启动的 shell,当前这个终端窗口读配置文件的那一刻早就过去了。想让当前窗口立刻生效,得手动
source ~/.zshrc重新执行一遍。 - 改错了文件——你用的是 zsh,却在改
.bashrc,自然什么都不会发生。不确定自己用的是哪个 shell,echo $SHELL或echo $0能看出来。 $PATH越改越长、越改越乱——很多工具(Homebrew、nvm、conda、pyenv)安装时都会自作主张往.zshrc追加一段export PATH=...,装的工具一多,.zshrc底部一大坨,顺序混乱、甚至出现重复路径。定期echo $PATH看看有没有明显冗余,是个好习惯。.zshenv里不要放太”重”的东西——因为它连非交互式场景(比如脚本、cron、某些 GUI 程序启动的子 shell)都会读,放个慢启动的东西(比如笨重的 conda 初始化)会拖慢一切调用 shell 的地方,不只是你手动开的终端。
一句话总结(按框架顺序)
地图:/ 是房子,~ 是房子里分给你的那间屋子——在自己屋子里怎么折腾都行,走出房门到公共区域动手就要小心权限和影响范围。
称呼:. 不是门,是你脚下的地;./ 只是绕开 $PATH 搜索、显式指名道姓的声明,不是位移。
查找:$PATH 是一份”只信任 root 把关的目录”清单,按顺序找、谁排前面谁被执行;which 帮你重演一遍这次搜索。
配置:.zshrc/.bashrc 决定了 $PATH、alias 这些东西从哪来,但它自己经常只是个入口,真正的代码可能藏在它 source 的层层文件里(oh-my-zsh、conda 都是这么干的)。