文件操作命令的语法很短,真正容易出错的地方却不在语法。例如,cp report.txt archive 究竟会创建一个名为 archive 的文件,还是生成 archive/report.txt?答案取决于 archive 在命令执行那一刻是否已经是目录。
再比如 rm *.tmp。rm 通常并没有收到字面量 *.tmp:Bash 先在当前目录匹配文件名,再把展开后的一组名称交给 rm。所以,安全使用命令的核心能力不是背下更多选项,而是在按回车前能预测“程序最终收到哪些参数”和“每个参数在文件系统中代表什么”。
这篇就沿着这条线展开。我们会创建、复制、移动、删除和链接文件,也会检查 inode、时间戳、权限、通配结果和退出状态。所有破坏性示例都限定在 /tmp 下的一次性目录里。
可以把一条文件命令拆成三层:Shell 先处理引号、变量和通配;命令再分辨选项与操作数;最后,内核沿路径逐层查找目录项,检查类型和权限。这三层中任何一层与预期不同,结果就可能偏离目标。

Shell 展开决定参数列表,命令解析决定源与目标,路径查找才落到真实对象。
cp、mv 和 ln 都有“最后一个操作数可能是目标目录”的语法。阅读命令时,先从左到右分出命令名、选项和操作数,然后回答四个问题:
例如,当前目录是 /tmp/work时,docs/report.txt 表示 /tmp/work/docs/report.txt,而 /tmp/work/docs/report.txt 不受当前目录影响。. 表示当前目录,.. 表示父目录。一条命令中可以同时出现绝对路径和相对路径,但每一项都要独立解析。
执行前先用不改状态的命令确认对象:
pwd
ls -ld -- source.txt archive
file -- source.txt archivels -ld 查看参数自身,不把目录展开成内容清单。这一点对判断“最后一项是目录还是文件”很有用。
未引用的 *、? 和 [...] 会触发 Bash 文件名展开。假设目录中有 a.tmp、b.tmp 和 notes.md:
printf '<%s>\n' *.tmp输出是:
<a.tmp>
<b.tmp>这不是 printf 会匹配文件,而是 Shell 把一个词 *.tmp 改写成了两个参数。写成 "*.tmp" 或 '*.tmp' 会阻止展开,命令收到字面星号。
还要记住两个 Bash 默认值:模式没有匹配时,它通常保留原样;文件名以 . 开头时,模式也需显式以 . 开头,除非改过 dotglob 等 Shell 选项。因此 * 不等于“绝对所有条目”。
mkdir 创建目录项,touch 主要管理访问时间和修改时间。因为 touch 对不存在的路径会创建空文件,它也常被用来准备占位文件。两者看似简单,但父目录是否存在、umask 与路径类型仍会决定结果。
mkdir -p 补齐路径,不修复已有目录直接创建一个目录:
mkdir reports如果 reports 已经是目录,命令报错并返回非零状态。这能暴露“不该已存在却存在”的逻辑问题。当目标是确保一条路径存在时,使用 -p:
mkdir -p project/src project/docs project/build/output-p 会创建缺失的中间目录,对已经存在的目录分量不报错。它不会把已有的普通文件变成目录,也不会顺手修改已有父目录的权限。
GNU mkdir -m 700 private 可以为直接指定的新目录设置模式。但当 -m 与 -p 同用时,-m 不是对所有新建父目录统一赋权的承诺。需要严格权限时,先设合适的 umask,创建后再用 stat 或 ls -ld 验证。

-p 解决的是缺失父目录,不代表会重写已有分量的类型或权限。
touch 创建只是兼容行为下面的命令创建两个空文件:
touch project/src/app.sh project/docs/README.md
stat -c '%n size=%s atime=%x mtime=%y ctime=%z' \
project/src/app.sh project/docs/README.mdtouch 的核心语义是改时间戳。默认会把 atime(最后访问时间)和 mtime(内容最后修改时间)调为当前时间。-a 只选 atime,-m 只选 mtime,-r reference 可以参考另一个文件的时间。
touch -r project/docs/README.md project/src/app.shctime 是 inode 状态最后变更时间。touch 不能把 ctime 指定为任意值;当 atime/mtime 被修改时,ctime 可能由文件系统自动更新。文件的 birth time(若支持)也不是 touch 可直接设定的字段。
如果只想更新已存在的文件,不希望拼错路径时意外创建新文件,加 -c:
touch -c project/src/maybe-missing.txt路径不存在时,这条命令不创建文件,也不把缺失当成错误。因此脚本若要求文件必须存在,还要先用 test -e 检查,不能只依赖 touch -c 的退出状态。
cp 会读取源并创建或写入目标,复制后的普通文件与源可以独立修改。但是,“复制到 dest”有两种完全不同的意思:把 dest 当作新名称,或把它当作容纳源基名的目录。
先看两个操作数:
cp source.txt destdest 不存在:创建名为 dest 的文件。dest 是普通文件:尝试将源内容写入它,存在覆盖风险。dest 是目录:目标路径是 dest/source.txt。多个源时,最后一项必须能被当作目标目录:
cp a.txt b.txt archive/结果是 archive/a.txt 和 archive/b.txt。如果 archive/ 不是已存在目录,命令不知道怎样将多个源合并为一个确切目标,因此失败。
GNU cp -T source dest 要求不要把最后一项特别当目录。这在脚本里可以消除“执行间有人刚好创建了 dest/”带来的目标漂移,但 -T 是 GNU 选项,需要跨系统脚本时应先查目标平台文档。

同一个最后操作数,会因“不存在、文件、目录”三种状态得到不同路径。
cp 默认不复制目录树。复制目录需要 -R:
cp -R project/src backup/假设 backup/ 已存在,结果通常是 backup/src/...。如果想复制 src 里的内容而不是 src 这一层,不要随手使用 src/*,因为它默认漏掉点文件,无匹配时还可能保留字面星号。可以在临时目录先比较两种明确写法:
mkdir -p backup/src-copy
cp -a project/src/. backup/src-copy/project/src/. 表示这个目录的内容视图,能包含点文件。-a 是 GNU 归档模式,包含递归,并尽量保留链接和属性。它不是所有复制任务的默认答案:如果来源树中有设备节点、FIFO、不可信的符号链接或挂载点,应先审查边界。
递归遇到符号链接时,选项决定是保留“链接这个对象”还是跟随它复制目标。GNU cp -a 保留符号链接;-L 要求跟随链接,可能把原本不在目录树内的数据带入复制。不理解来源树时,不要盲目加 -L。
一个普通文件不只有字节内容,还有 mode、owner/group、atime、mtime、ctime,文件系统还可能支持 ACL、扩展属性和安全上下文。普通 cp source dest 主要保证内容复制,不应默认所有元数据都一样。
对新建目标,GNU cp 以源的模式为基础,去掉特殊位后还会受 umask 或默认 ACL 限制。对已存在的目标,默认写入内容时保留目标自己的权限。下面的对比能把边界显示出来:
umask 077
cp source.txt plain.txt
cp -p source.txt preserved.txt
stat -c '%n mode=%a uid=%u gid=%g mtime=%Y ctime=%Z' \
source.txt plain.txt preserved.txt-p 等价于请求保留 mode、ownership 和 timestamps。“请求保留”仍有边界:普通用户未必能把目标所有者改成任意 UID;目标文件系统可能不支持同类 ACL/xattr;时间精度也可能不同。ctime 表示目标 inode 何时发生状态变更,不是应被复制成旧值的“创建时间”。
GNU cp -a 比 -p 更倾向保留整体结构,包括链接关系、ACL 和扩展属性等,但其中一些保留仍是“在平台允许时尽力而为”。重要迁移完成后要用 stat、getfacl 或相应属性工具验证,不要从选项名倒推结果。

mode、所有权、时间戳、ACL 和扩展属性的保留条件不同,需要分字段验证。
mv old new 只有一个命令形式,人们根据路径关系把它称为“改名”或“移动”。对文件系统来说,同一文件系统内的常见情况是改变目录项中的名称或所在目录,而不是先复制每个字节。
改名:
mv draft.txt final.txt当 final.txt 不存在时,draft.txt 这个名称消失,同一对象以 final.txt 出现。移入已存在目录:
mkdir -p archive
mv final.txt archive结果是 archive/final.txt。目录也可以移动,不需要像 cp 那样加 -R:
mkdir -p incoming release
touch release/version.txt
mv release incoming因为 incoming 已经是目录,结果是 incoming/release/version.txt。多源语法与 cp 同理:
mv a.txt b.txt archive/最后一项必须是可用的目标目录。GNU mv -T source dest 可以把 dest 锁定为确切目标,不使用“若它是目录则移入其中”的特殊规则。
当目标文件已存在时,mv 可能替换它。GNU mv -i 在每个覆盖前询问,-n 避免覆盖,-b 先为将被替换的目标留备份。这些选项的可用性和退出状态细节在非 GNU 平台上可能不同,脚本不应靠想当然处理“跳过”。
同一文件系统内的 rename 通常很快,但跨文件系统时无法直接把一个 inode 放进另一个文件系统的目录。GNU mv 会回退为类似 cp -a 的复制,复制成功后再移除源。这带来三个结论:
重要迁移要检查退出状态和两端结果:
mv -- source target
status=$?
printf 'mv status=%d\n' "$status"
ls -ld -- source targetrm 不是“把对象放进通用回收站”的命令。它移除文件名对应的目录项;当没有其他链接,也没有进程仍打开文件时,文件系统才能回收相关数据。日常操作不能把数据恢复当作可靠撤销。
能否删除一个名称,主要看它所在目录的写权限和穿越权限,不只看文件自身的写位。这是因为 rm 修改的是父目录中的名称映射。某些情况下,对不可写文件的交互删除会出现额外提示,但这不改变父目录权限才是授权核心的事实。带 sticky bit 的共享目录还会进一步限制谁能删除其中的名称。
rm 没有通用的 --dry-run 选项。需要“干跑”时,应将筛选与删除拆成两步:先用 find、printf 或 ls -ld 显示候选路径,人工确认后再把同一边界交给 rm。
删除单个文件前,先看参数自身:
ls -ld -- cache/result.tmp
rm -- cache/result.tmp删除当前目录中的 .tmp 文件前,可以用同一个通配模式预览:
printf 'candidate: %q\n' ./*.tmp%q 是 Bash printf 的可重新输入形式,含空格或特殊字符的名称会更容易分辨。但 Bash 默认在无匹配时保留字面模式,所以如果输出中仍出现 ./*.tmp,不要把它当作真实文件。
需要更明确的条件时,让 find 处理被引用的模式:
find ./cache -maxdepth 1 -type f -name '*.tmp' -print这里的单引号是必要的:它阻止 Shell 提前展开,让 find 在 ./cache 边界内匹配。这和 rm *.tmp 需要 Shell 展开是两种不同语义,不能机械套用“通配符永远加引号”。

安全性来自可见的候选列表、有限边界和执行后验证,不是某一个神奇选项。
rm file 默认不移除目录。删除整棵树需要 -r:
find ./scratch -depth -print
rm -r -- ./scratch
test ! -e ./scratch
printf 'rm status=%d\n' "$?"-- 终止选项解析,让后面的名称即使以 - 开头也按操作数处理。写成 ./scratch 还能在视觉上表明它是当前目录下的路径。
递归删除遇到符号链接时,标准语义是移除链接本身,不沿它进入另一棵目录树。不要因此就依赖肉眼判断:命令行末尾的斜杠、链接链和平台差异都可能干扰理解,先用 ls -ld 和 readlink 检查操作数本身更稳妥。
GNU rm -i 对每个项询问,rm -I 在多个操作数或递归删除前询问一次。它们都不是绝对保险:
rm 已经收到的路径,不会判断这些路径是否符合原始意图。alias rm='rm -i' 不会自动进入所有脚本和非交互 Shell。-f 会忽略缺失文件并禁止询问;与 -r 组合时尤其不适合当作日常默认值。先在狭小边界上完成只读预检,通常比把更多强制标志叠在一起更容易审查。
理解链接的关键是把“名字”和“文件对象”分开。在常见 Unix 文件系统中,目录项把文件名映射到 inode;inode 记录类型、权限和数据位置等信息。硬链接增加一个指向同一 inode 的目录项;符号链接则创建一个保存目标路径的独立链接文件。

硬链接的两个名称直接指向同一 inode;符号链接先保存一段路径,访问时再解析目标。
创建并观察硬链接:
printf 'version-1\n' > config.txt
ln config.txt config-hard.txt
stat -c '%n inode=%i links=%h' config.txt config-hard.txt两个名称显示同一 inode 号,链接计数为 2。通过其中任一个名称写入:
printf 'version-2\n' >> config-hard.txt
tail -n 1 config.txtconfig.txt 也读到 version-2,因为两个名称打开同一对象。移除一个名称:
rm -- config.txt
stat -c '%n inode=%i links=%h' config-hard.txt另一个名称仍能读取数据,链接计数降为 1。这也解释了为什么删除文件名常被称为 unlink:操作先解除一个目录项与 inode 的联系。
硬链接不能跨文件系统,因为 inode 号只在各自文件系统内有意义。系统也通常拒绝为目录创建硬链接,以避免形成让遍历程序无法按树结构理解的环。
创建符号链接的语法是“目标在前,链接名在后”:
ln -s target-path link-name假设目录如下:
project/
├── source/config.txt
└── views/要在 views/ 中建立指向 source/config.txt 的相对链接,可以写:
ln -s ../source/config.txt project/views/config-soft.txt
readlink project/views/config-soft.txt../source/config.txt 从链接所在的 project/views/ 开始解析,不是从执行 ln 时的当前目录开始。这是相对符号链接最常见的排错点。
符号链接可指向文件或目录,也可以在目标不存在时创建。后一种情况会得到 dangling symlink(断链):test -L link-name 为真,但 test -e link-name 可能为假。删除符号链接本身时,rm -- link-name 移除的是链接目录项,不是它指向的目标。
硬链接适合表达“这两个名称就是同一文件对象”,但其中任一名称的写入都会影响另一个。符号链接适合表达“请沿这个路径找目标”,但重命名或移动目标会让路径失效。它们都不是备份:备份需要在源被破坏或误删时仍能独立恢复。
文件名可以包含空格、制表符和大多数可打印字符,也可以以 - 开头。这些名称对文件系统是合法的,却会触发 Shell 分词或命令选项解析。正确做法是在参数边界处处理:引号保护一个完整路径,-- 终止选项列表,预览通配展开的真实参数。

引号保留单个参数的边界,-- 切换到纯操作数区,通配则会生成多个参数。
-- 解决选项歧义文件名为 季度 报告.txt 时,这两种写法都保留一个完整参数:
cp -- '季度 报告.txt' archive/
mv -- "季度 报告.txt" '季度 报告-定稿.txt'不加引号时,Shell 会在空格处分词,命令收到 季度 和 报告.txt 两个操作数。反斜杠也能保护单个空格,但路径稍长时引号更容易审查。引用变量时也要保留双引号:
file='季度 报告.txt'
cp -- "$file" archive/以短横线开头的名称是另一类问题:
touch -- -draft
rm -- -draft-- 之后的参数即使以 - 开头也是操作数。写成 rm ./-draft 也可以,因为整个操作数现在以 . 开头。仅仅写 rm '-draft' 没用:引号在 Shell 处理后会被去掉,rm 仍看到 -draft。
Bash 常用文件名模式:
Shell 通配不是 grep 的正则表达式。在通配中 * 自己就表示任意长度;在常见正则中,相似含义需要 .*。不要在 rm、cp 和 find -name 之间直接复制模式,要先确认是 Shell 还是工具自己解释。
* 默认不匹配以 . 开头的名称。.* 又容易让人忽视 . 和 .. 的特殊性,尤其不应与递归删除盲目组合。如果任务真的需要包含点文件,用有明确起点、深度和类型条件的 find 通常更容易检查。
下面的练习只在 /tmp 中创建一个随机后缀目录。每一步都先说明目的,然后给出检查命令。不要把路径替换为系统目录,也不要以特权身份练习。
准备隔离边界。 mktemp 避免与已有同名目录冲突;pwd 和 printf 让边界可见。
lab=$(mktemp -d /tmp/welearn-files.XXXXXX) || exit 1
cd "$lab" || exit 1
printf 'lab=%s\n' "$PWD"创建目录树和初始文件。 mkdir -p 补齐父目录,touch 创建占位文件,printf 写入可检查内容。
mkdir -p project/{src,docs,release,links}
touch project/docs/README.md
printf 'mode=development\n' > project/src/app.conf
find project -maxdepth 2 -printf '%y %p\n' | sort花括号是 Bash 的 brace expansion,Shell 会先把它展开成四个路径再调用 mkdir。这次展开不依赖文件是否存在,与通配展开不同。
复制并验证属性。 先为源设置可观察的 mode 和 mtime,再比较普通复制与 -p。
chmod 640 project/src/app.conf
touch -d '2024-01-02 03:04:05 UTC' project/src/app.conf
cp project/src/app.conf project/release/app.conf
cp -p project/src/app.conf project/release/app-preserved.conf
stat -c '%n mode=%a mtime=%Y ctime=%Z' \
project/src/app.conf \
project/release/app.conf \
project/release/app-preserved.confmtime 和 mode 的比较用来验证 -p 请求的属性;ctime 会提醒我们目标是新文件对象,不应和源 ctime 相同。
移动并确认目标解析。 release/ 已存在,所以文件会移入其中并保留基名。
printf 'draft\n' > project/notes.txt
mv project/notes.txt project/release/
test -f project/release/notes.txt
printf 'mv-check=%d\n' "$?"建立两种链接并比较。
ln project/release/app.conf project/links/app-hard.conf
ln -s ../release/app.conf project/links/app-soft.conf
stat -c '%n inode=%i links=%h type=%F' \
project/release/app.conf project/links/app-hard.conf
ls -l project/links/app-soft.conf
readlink project/links/app-soft.conf硬链接应与 release/app.conf 共享 inode,符号链接则显示保存的 ../release/app.conf。
先预检,再清理。 先回到 /tmp,避免 Shell 的当前目录仍位于将被删除的树中。
cd /tmp || exit 1
find "$lab" -depth -print
rm -r -- "$lab"
test ! -e "$lab"
printf 'cleanup=%d\n' "$?"find 的起点和 rm 的操作数是同一个引用变量,这让预检边界与真实删除边界对齐。test ! -e 检查清理结果,而不是只假设没看到诊断就代表成功。
命令失败时,不要立即加 -f 或提升权限。先保留现场,用下面的顺序查:
退出状态要紧跟命令保存,因为下一条命令会覆盖 $?:
cp -- missing.txt target.txt
status=$?
printf 'cp status=%d\n' "$status"状态 0 表示命令按它自己的规则成功,非零表示有失败。它不证明结果符合业务意图:cp report.txt archive 可以成功创建一个名为 archive 的文件,却不一定是你原本想要的 archive/report.txt。所以完整验证必须同时包括退出状态和对象检查。