命令行里敲得最多的命令,cd排第二,没人敢排第一。可偏偏就是这条几乎每天都要摸一遍的命令,三种写法——cd ..、cd /、cd ~——在很多人的脑子里是糊成一片的。我在带人做运维交付的时候,见过不少脚本跑着跑着把文件写到了意料之外的目录,回头一查,问题就出在这三个写法被混用上。它们看起来都是"往回走"或"找个地方待着",实际含义、作用范围、失效条件完全不是一回事。这篇东西我想把它彻底讲透:从路径系统的底层逻辑讲起,拆清三种写法各自的行为边界,再补上 shell 在背后替你做的那些"隐形动作",最后把我这些年踩过的坑整理成可直接对照的排查表。不管你是刚接触命令行、分不清..和/的新手,还是写了几年脚本、但从没认真想过cd ~为什么会失效的老手,应该都能从这里捞到点东西。
1. 先把三个符号的底子钉死:路径系统到底怎么读
1.1 绝对路径与相对路径,是所有混乱的源头
在动手拆cd之前,得先把路径的两套坐标系理清楚,不然讲什么都是空中楼阁。文件系统是一棵倒挂的树,最顶上那个节点叫根目录,写作一个斜杠/。绝对路径是从根目录出发、一路写到底的完整地址,比如/var/log/app,它的含义是唯一的,跟你现在站在哪儿没有任何关系。相对路径则相反,它是相对于你"当前所在位置"来描述的,比如logs/app,意思是从我现在站的这个地方往下走两层。同一个相对路径,站在不同的目录里执行,指向的目标完全不同。
关键在于,相对路径的解析必须有一个"参照点",这个参照点就是进程的当前工作目录(current working directory,简称 cwd)。每个进程都有且只有一个 cwd,它是进程属性的一部分,不是某个文件的属性。你用pwd命令打出来的那一行,就是在问 shell 进程:你的 cwd 是什么。这里有个新手特别容易搞混的点——cwd 是进程级的,不是终端级的,也不是用户级的。你开两个终端窗口,它们是两个独立的 shell 进程,各有各的 cwd,互不干扰;你在一个窗口里cd到了别处,另一个窗口纹丝不动。
理解了这一层,再看cd这个命令就好办了:它干的事情本质上只有一件——把当前 shell 进程的 cwd 换掉。换掉之后,后续所有相对路径的解析基准就全变了。这也是为什么cd的影响范围是"从这一刻起,这个 shell 里执行的所有命令",而不是"这一个命令"。很多人写脚本时习惯在开头cd一下,然后一路相对路径往下走,就是吃了这个特性的红利;反过来,忘了cd回去,或者cd到了错误的地方,后面的操作就全歪了。
1.2 点、双点、斜杠、波浪号,四个符号四种出身
这四个符号虽然经常一起出现,但它们的"出身"完全不同,搞清楚这一点,区别自然就出来了。
.和..是文件系统层面真实存在的目录项。每个目录在被创建的时候,内核会自动往里塞两条硬链接:一条叫.,指向自己;一条叫..,指向父目录。所以你ls -a任何一个目录,都能看到这俩货。它们是实实在在的目录条目,由文件系统维护,内核在做路径解析时会直接处理,跟你用哪个 shell 没关系。根目录的..有点特殊,它指向自己,所以你在/下执行cd ..永远不会报错,只会原地踏步。
/单独出现时是根目录这个节点的名字,它同时还是路径分隔符。这两个身份要分开看:作为分隔符时它只是个符号,比如/usr/bin里那两个斜杠;作为独立路径时它是树的顶点。
~的出身最不一样,它是shell 层面的展开符号,文件系统里根本不存在叫~的目录。你敲下cd ~回车之前,shell 已经先把~替换成了$HOME的值,然后才把替换后的结果交给cd。这个顺序很重要,后面讲失效场景时会反复用到。顺带说一句,~的展开规则属于 shell 的"波浪号展开"(tilde expansion)范畴,和变量展开、通配符展开是并列关系,规则细节各 shell 略有差异。
1.3 为什么这三个写法特别容易糊在一起
我观察下来,混淆主要来自三个认知惯性。第一,日常使用中三者经常"看起来都回到了某个熟悉的地方",比如你从/home/user/project/src出发,cd ..到了project,cd ~到了/home/user,cd /到了根,三个操作都让你"离开了当前目录",体感相似,于是大脑就懒得区分了。第二,教程里往往只给一条命令加一句"回到上级目录",不解释边界条件,等你真遇到符号链接、遇到 HOME 没设、遇到在根目录下操作,才发现那句话说漏了一大半。
第三,也是最阴的一个:这三个写法在"正常情况下"都能跑通,只有在边界情况下才暴露差异。而脚本恰恰最容易撞上边界情况——定时任务里的环境变量是精简的,容器里的 HOME 可能压根没设,CI 里跑的位置和你想的不一样。所以我一直建议,别等到出事再去查,趁着现在没出事,把这三个写法的行为边界一次性搞清楚,成本极低,收益是长期的。
2. 三种写法在实际行为上的差异拆解
2.1cd ..:相对上级,走到哪算哪
cd ..的语义是"进入当前工作目录的父目录"。注意这里的"当前工作目录"是个变量,所以这条命令的结果完全取决于你执行时站在哪儿。在/home/user/project/src下执行,落到/home/user/project;在/etc/nginx下执行,落到/etc;在/下执行,还是/。它是一条"跟着你走"的命令,没有任何固定目标。
正因为结果不固定,cd ..在脚本里是最容易出事的写法之一。你写脚本时脑子里想的是"从脚本所在目录往上退一级,找到项目根",可脚本被别的东西调用时,cwd 可能是任意位置,cd ..退到的自然也是任意位置。这类 bug 有个特点:本地测的时候一切正常,上了定时任务或者被别的脚本 source 之后就疯了,排查起来极其费劲,因为代码本身一个字都没改。
还有一个细节值得单独拎出来:cd ..在符号链接场景下的行为,取决于 shell 用的是逻辑路径还是物理路径。bash 默认走逻辑模式(相当于cd -L),它维护的PWD是你"走过来的那条路",所以cd ..退的是逻辑上的上一级;而cd -P ..会先按物理路径解析,退的是真实文件系统结构上的父目录。这两者在有软链的目录树里结果可能完全不一样,后面第 3 章会拿具体例子跑一遍。
2.2cd /:绝对定位,最没有歧义的一条
cd /是三兄弟里最"死"的一条:不管你在哪儿、HOME有没有设、CDPATH配成什么样,它的目标永远是根目录。执行完之后pwd一定是/,没有例外,没有条件分支。这种确定性在脚本里特别值钱——当你需要一个"绝对干净的起点"时,cd /是唯一能保证结果的选择。
但它的"死"也意味着风险:根目录是整个文件系统的顶点,从这儿往下走的路都是绝对路径,你一旦在根下做了什么事,影响面就是全系统。我见过有人在部署脚本开头写cd /想"回到干净状态",结果后面的相对路径rm -rf tmp/*直接作用到了系统根下的/tmp,而不是项目里的tmp目录。所以用cd /的时候,必须保证后面的所有操作都用绝对路径,或者立刻cd到明确的目标目录,别在根目录下停留太久。
另外提一句,cd /在权限上没门槛(根目录对所有用户都有执行权限),但在"容量"上有讲究——很多系统把根分区做得很小,你在根下随手建目录写文件,容易把分区撑满,进而影响日志、临时文件、甚至系统启动。这是我带人时反复强调的一条:别把根目录当工作区。
2.3cd ~:看起来是回家,其实是变量展开
cd ~的直觉含义是"回到自己的家目录",这句话在绝大多数情况下成立,但它成立的原因不是cd有什么特殊逻辑,而是 shell 在调用cd之前把~换成了$HOME的值。所以准确的说法是:cd ~等价于cd "$HOME",~只是个人类友好的简写。
这个区别在什么时候会咬人?第一,HOME没设或者为空的时候。某些精简环境、某些以特定方式启动的进程、某些容器镜像里,HOME可能压根不存在。这时cd ~在 bash 里会直接报错,提示类似"HOME not set";而cd不带任何参数的行为也依赖HOME,同样会失败。第二,~被引号包住的时候。cd "~"不会展开成家目录,因为引号里的内容不参与波浪号展开,shell 会老老实实找一个名字就叫~的目录,找不到就报错。第三,~后面跟用户名时(比如~someuser),展开目标是那个用户的家目录,但你能不能进去还得看权限,这是另一回事。
还有一个隐藏的坑:cd ~的目标由HOME决定,而HOME是可以被覆盖的。你在脚本里改了HOME去做测试,后面的cd ~就跟着跑偏;有些工具为了让配置隔离,也会临时改HOME,这时候"回家"回的就是那个临时的家。这个行为本身是合理的设计,但如果你不知道,就容易怀疑人生。
2.4 一张表把差异摆平
| 写法 | 目标由什么决定 | 结果是否固定 | 依赖环境变量 | 典型失效场景 |
|---|---|---|---|---|
cd .. | 当前工作目录的父目录 | 否,随 cwd 变化 | 否 | 脚本 cwd 不可控;符号链接下逻辑/物理路径不一致 |
cd / | 文件系统根目录 | 是,永远是/ | 否 | 后续相对路径误伤系统目录;根分区空间被占 |
cd ~ | $HOME的值 | 否,随 HOME 变化 | 是(HOME) | HOME 未设/为空/被改写;被引号包裹导致不展开 |
这张表建议直接抄进你的备忘里。判断该用哪个的标准其实很简单:你要的是"确定的位置",还是"相对于现在的位置"。要确定的位置,用绝对路径或者cd /;要相对位置,才用cd ..。而cd ~只在"我确实要回家,且我确认 HOME 可信"的时候用,脚本里更稳妥的做法是直接写cd "$HOME",至少你一眼能看出它依赖什么。
3. 底层原理:cd为什么必须是内建命令
3.1 从chdir系统调用说起
想知道cd为什么跟ls、grep这些命令不是一类东西,得先明白进程模型。每个进程有自己的地址空间、自己的文件描述符表、自己的 cwd。cwd 是进程的私有属性,存在内核里,用户态程序想改它,只能通过系统调用chdir()(或者fchdir(),用文件描述符来切)。这个调用的效果是:把调用者自己这个进程的 cwd 换掉,仅此而已。
问题就来了。你在 shell 里敲ls,shell 会 fork 出一个子进程去执行ls,执行完子进程退出,父进程(shell)继续等你的下一条命令。如果cd也是一个外部程序,那么它被 fork 出来、调用chdir()改了 cwd、然后退出,改的是它自己那个短命进程的 cwd,父进程 shell 的 cwd 一点没变。你敲完cd /tmp再看pwd,还在原地,命令等于白敲。
所以cd必须是 shell 自己实现的内建命令(builtin),shell 在自己的进程里直接调chdir(),改的才是那个真正影响后续所有命令的 cwd。POSIX 里甚至把cd归为"特殊内建命令"(special built-in),在查找顺序、错误处理上都有额外的规定。这也解释了一个常见困惑:为什么which cd在有些系统上给不出可执行文件路径,因为压根就没有对应的二进制文件。
顺带说一个推论:任何"想改变父 shell 环境"的操作,都必须是内建命令或者被source执行,比如cd、export、umask。你用bash -c "cd /tmp"是没用的,那只改了子 shell 的目录;同理,脚本里cd了没用,因为脚本本身跑在子 shell 里——除非你source它。这个点很多人在写"切换目录的脚本"时栽过跟头。
3.2PWD与OLDPWD:shell 帮你记的账
bash 在你每次成功cd之后,会同步维护两个环境变量:PWD存当前目录,OLDPWD存上一次的目录。cd -就是靠OLDPWD实现的,它会切回去并且把目标路径打印出来,方便你确认。这也是为什么cd -在线上一层层退的时候特别好用,比手敲一长串路径靠谱得多。
这里有个容易忽略的事实:PWD是 shell 维护的逻辑路径,不一定等于内核里那个物理 cwd。在 bash 默认的-L模式下,你从软链进去、再往上退,PWD记录的是你"走过的那条路",看起来更符合人的直觉,但和文件系统真实结构可能有出入。想要看物理路径,用pwd -P,或者干脆cd -P切到物理模式。
我个人的习惯是:在脚本里不信任PWD的直白含义,需要确定位置时用pwd -P拿到物理路径再操作。特别是在有软链的部署目录里,逻辑路径和物理路径分叉是很常见的,你不搞清楚自己在哪一层,写出来的相对路径就是薛定谔的。
3.3 符号链接下的逻辑与物理之争
拿个虚构场景跑一遍就明白了。假设有这么一个结构:/data/app是真实目录,/data/current是一个指向/data/app的符号链接。你先cd /data/current,然后pwd,bash 默认会告诉你/data/current——这是逻辑路径。此时你执行cd ..,落在哪儿?
在默认的逻辑模式下,bash 认为你的上一级是/data,所以cd ..之后你在/data。但如果你用cd -P ..,bash 先把/data/current解析成物理路径/data/app,再取父目录,结果还是/data,这里恰好一样。真正分叉的情形是软链跨了层级,比如/data/current指向/opt/releases/v3/app,这时候逻辑上退一级是/data,物理上退一级是/opt/releases/v3,差别就出来了。
这个差异不是学术问题。部署脚本里用软链做版本切换(current指向某个版本目录)是极常见的做法,脚本里如果用cd ..去定位"项目根",在逻辑模式下和在物理模式下拿到的目录可能压根不是同一个,后面的相对路径自然全错。稳妥的做法是:要么全程用绝对路径,要么用cd -P明确按物理结构走,别让默认行为替你做决定。
3.4HOME为空时到底会发生什么
HOME未设置的情况下,不同 shell 的表现略有差异,但共同点是:cd ~和裸cd都会失败,而不是"猜一个默认目录"。bash 的报错大意是提示HOME没有设置;某些 shell 可能退回到密码库里记录的家目录,但这属于实现细节,不能当成可依赖的行为。
在容器化环境里这个坑出现的频率不低。有些精简镜像为了减小体积,创建用户时不写完整的家目录信息,或者启动进程时环境被清理得很干净,HOME就空了。这时候脚本里一句cd ~直接让整个流程挂掉。解决办法不复杂:要么在脚本开头显式export HOME=/some/path(前提是你知道该指哪),要么干脆别用~,直接写死绝对路径。我倾向于后者,脚本里出现~的地方越少越好,因为它把脚本的正确性绑定在了一个外部变量上。
还有个小细节:cd ~在HOME指向一个不存在或者没权限的目录时,报错信息是"没有那个目录"或"权限不够",跟HOME没设的报错不一样。排查的时候先看报错内容,能省不少时间。
4. 完整实操:把每种行为亲手验一遍
4.1 先搭一个安全的实验沙箱
理论讲完,得动手。我建议所有验证都在一个临时沙箱里做,别拿真实项目目录练手,免得一个手滑把不该动的东西动了。下面这套步骤在任何常规系统上都能跑,全程只在自己建的临时目录里折腾。
# 建一个实验场,位置放在用户临时目录下 mkdir -p /tmp/cd-lab/data/app/v3/bin mkdir -p /tmp/cd-lab/data/releases/v3 cd /tmp/cd-lab ln -s /tmp/cd-lab/data/releases/v3 /tmp/cd-lab/data/current ls -la /tmp/cd-lab/data跑完你会看到current是个指向releases/v3的软链,app是另一个独立的真实目录。这套结构故意做成"软链和真实目录混在一起",就是为了把逻辑路径和物理路径的差异逼出来。先别急着往下走,确认一下ls -la的输出里箭头方向对得上,软链建错了后面全白搭。
沙箱建好后,我习惯先在纸上(或者注释里)写清楚"我预期会看到什么",再跑命令。这个习惯看着多余,实际上是排查能力的基本功:预期和实际的差异,才是信息的来源。如果你跑完只是看一眼觉得"哦好像对",那这一遍基本白跑。
4.2 逐条验证三种写法
先验cd ..的相对性。从深目录出发往上退,观察每一级的pwd:
cd /tmp/cd-lab/data/app/v3/bin pwd # /tmp/cd-lab/data/app/v3/bin cd .. pwd # /tmp/cd-lab/data/app/v3 cd .. pwd # /tmp/cd-lab/data/app cd ../.. pwd # /tmp/cd-lab/data注意最后那条cd ../..,它是连续退两级,等价于连敲两次cd ..。这种写法在脚本里能省行数,但可读性会下降,我一般只在很明确的两级场景用。再验一下根目录的边界:
cd / pwd # / cd .. pwd # 仍然是 /这一条说明cd ..不会"退出去",根目录的父目录就是它自己。很多新手担心在根下cd ..会出错,其实不会,但也别指望它带你去别的地方。
再验cd /的绝对性。这个最简单,随便站在哪儿执行都一样:
cd /tmp/cd-lab/data/app cd / pwd # /最后验cd ~的展开本质。关键是看它到底依赖什么:
echo "$HOME" # 比如 /home/someuser cd ~ pwd # /home/someuser cd "~" # 报错:没有那个文件或目录第三条命令就是那个经典陷阱:加了引号,~不展开,shell 去找一个名字字面叫~的目录,当然找不到。这一条我建议每个人都亲手跑一次,报错长什么样记住了,以后遇到能秒认。
4.3 把软链下的一级一级退出来看清楚
这一步是整个实操里最有价值的部分。进软链,然后用两种模式分别退一级:
cd /tmp/cd-lab/data/current pwd # /tmp/cd-lab/data/current (逻辑路径) cd .. pwd # /tmp/cd-lab/data (逻辑上的上一级) cd /tmp/cd-lab/data/current pwd -P # /tmp/cd-lab/data/releases/v3 (物理路径) cd -P .. pwd -P # /tmp/cd-lab/data/releases对比一下两次pwd的结果:同一个起点,cd ..到了/tmp/cd-lab/data,cd -P ..到了/tmp/cd-lab/data/releases。差了整整一层。这不是 bug,是两种路径视角的必然结果。你在写部署脚本时如果用了软链做"当前版本"的指针,就务必要想清楚:我要的是"软链所在目录的上一级",还是"真实版本目录的上一级"。这两个答案经常不一样,而且错的那个通常不会立刻报错,只会在后续操作里悄悄写错地方。
再补充一个验证OLDPWD和cd -的小实验:
cd /tmp/cd-lab/data/app cd /tmp/cd-lab/data/releases cd - # 输出 /tmp/cd-lab/data/app 并切过去 cd - # 又切回 /tmp/cd-lab/data/releasescd -会打印目标路径,这个打印行为在交互式使用时很贴心,但在脚本里会污染标准输出。所以脚本里要用cd -的话,记得把输出重定向掉,比如cd - > /dev/null。
4.4 脚本里到底该怎么选
把上面的验证结论落到工程实践,我的建议可以归纳成几条硬规则。
第一,脚本开头先定位自己,再谈其他。不要假设调用者的 cwd,用脚本自身路径反推目录:
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) cd "$SCRIPT_DIR" || exit 1这段看起来有点绕,但每一段都有理由。CDPATH=是为了屏蔽用户环境里配置的搜索路径,那玩意儿会让cd跑到意料之外的地方;--是为了防目录名以短横线开头被当成选项;$(dirname -- "$0")拿到脚本所在目录;pwd -P拿物理路径,绕开软链歧义;&&保证目录切换成功才取路径;|| exit 1是防止切换失败后继续往下跑,在错误的位置干错误的事。
第二,需要确定位置就写绝对路径,别用cd ..去"猜"。cd ..在交互式终端里很舒服,在脚本里就是不确定性的一大来源。真要退一级,用cd "$(dirname "$PWD")"之类显式表达意图的写法,让读代码的人一眼看出你在干什么。
第三,别在根目录下停留。cd /之后立刻切到目标目录,中间不要插入任何相对路径的操作。这条是纯粹的经验之谈,出过事的人自然懂。
第四,环境变量该兜底就兜底。脚本里如果需要家目录,先判断HOME是否存在:
if [ -z "${HOME:-}" ]; then echo "HOME 未设置,无法确定目标目录" >&2 exit 1 fi这种防御性代码看着啰嗦,但在容器、定时任务、CI 这些环境里能救你很多次。我现在的习惯是,所有依赖环境变量的脚本,入口处统一做一遍校验,缺什么报什么,别等到跑到一半才炸。
5. 常见问题与排查实录
5.1 高频问题速查表
下面这张表是我这些年被问得最多、自己也踩得最多的问题合集,遇到现象直接对号入座。
| 现象 | 最可能的原因 | 排查动作 | 处理办法 |
|---|---|---|---|
cd ~报 HOME 未设置 | 运行环境里HOME为空 | echo "[$HOME]"看是否为空 | 显式指定绝对路径或补设HOME |
cd "~"报目录不存在 | 引号阻止了波浪号展开 | 看命令里~是否被引号包住 | 去掉引号,或改用"$HOME" |
脚本里cd后路径仍不对 | 脚本跑在子 shell 里,或 cwd 非预期 | 在脚本开头打pwd确认起点 | 用脚本自身路径定位,别依赖调用者 cwd |
软链目录下cd ..位置不符预期 | 逻辑路径与物理路径不一致 | 对比pwd与pwd -P | 明确用cd -L或cd -P,别靠默认 |
cd /后相对路径操作误伤系统目录 | 在根下使用了相对路径 | 检查后续命令里是否有相对路径 | 全部改绝对路径,或立刻切到目标目录 |
cd -输出干扰脚本 | cd -会打印目标路径 | 看脚本标准输出里是否混入路径 | 重定向到/dev/null |
cd到某目录提示权限不够 | 目标目录缺少执行(搜索)权限 | ls -ld看权限位 | 补x权限,或换有权限的路径 |
补充一点权限的细节:进入一个目录需要的是执行权限(对目录来说叫"搜索权限"),只有读权限是进不去的,只能列目录名。这个点在排查"为什么我能ls但不能cd"时特别有用,很多人第一反应是去查所有者,其实问题就在权限位上。
5.2 脚本里的三个隐蔽坑
第一个坑是CDPATH。这个变量平时几乎没人用,但一旦有人在你环境里设了它,cd的查找顺序就变了:它会在当前目录找不到时,去CDPATH列出的目录里找同名的子目录。在交互式场景这可能是个便利功能,在脚本里就是灾难,因为cd logs可能切到一个完全无关的地方,而且不报错。所以脚本里凡是cd,前面都带上CDPATH=清零,成本很低。
第二个坑是cd失败但脚本继续跑。cd失败时返回非零退出码,但如果你用的是;连接或者没检查返回值,后面的命令照样在原来的目录里执行。这时候破坏力反而更大——你以为在项目目录里删临时文件,实际在别的地方删。所以关键位置的cd一定要跟&&或者|| exit 1,别让失败被吞掉。
第三个坑是把cd写进管道或者子 shell 里。比如(cd /tmp && do_something),这个写法本身没问题,但要清楚它只影响括号里的子 shell,外面的 cwd 不变。有些人指望用这种写法"临时切目录然后自动回来",思路是对的,但得接受一个事实:里面的所有操作都发生在子 shell 里,外面拿不到里面设置的任何变量。想保留效果,就得把结果通过标准输出传出来。
5.3 几条不怎么写在文档里的实操心得
第一条,在交互式终端里,cd -比cd ..好用得多。你从 A 目录跳到 B 目录干活,干完想回去,cd -一步到位,而cd ..得数清楚退几级,目录深一点就数错了。我现在的习惯是重要目录之间来回切全用cd -,只有明确的层级关系才用..。
第二条,给常用目录设书签式的变量,但别用~去凑。比如export PROJ=/data/work/project-x,然后cd "$PROJ"。这么写的好处是位置集中管理,改一处全生效,而且不依赖HOME这种容易被改的外部变量。比起在每个脚本里cd ~/work/project-x,可维护性高一个量级。
第三条,排查目录问题时,先打pwd -P再动手。我养成了一个近乎条件反射的动作:只要进入一个不熟悉的目录、或者目录路径里带软链,第一件事就是pwd加pwd -P各打一次,两边一对比,心里立刻有数。这一个动作帮我省掉的排查时间,比我学过的任何调试技巧都多。
第四条,别在脚本里用cd ~,用cd "$HOME"。理由前面说过,写出来依赖关系一目了然,读代码的人不用在脑子里做一次展开运算。而且如果哪天HOME被改了,"$HOME"的写法在代码审查时更容易被注意到,~则容易被眼睛自动忽略过去。
说到底,这三个写法没有哪个"更好",只有哪个"更匹配你当下的意图"。cd /是绝对定位,cd ..是相对上级,cd ~是变量展开。把这个判断标准记住,比背一堆用法例句管用得多。我现在写脚本,凡是涉及目录切换的地方,都会先问自己一句:这里我要的是"确定的位置"还是"相对的位置"?答案清楚了,代码自然就写对了。