# cp、mv、rm 的三种意外：目标已存在、跨文件系统、通配符

覆盖是原地改写还是删了重建，mv 跨分区为什么变成复制加删除，以及通配符其实由 shell 先展开

> Linux 基础 · 文件系统 · 命令行操作 · 约 7 分钟 · 09 月 23 日

## 本篇要点

1. mkdir 是这四个命令里唯一在目标已存在时报错的；mkdir -p 会补齐中间层，且每一层已存在都不算错，因此可以重复执行。
2. cp 覆盖一个已存在的文件时，是以“只写 + 截断”方式打开目标再写入，所以目标的目录条目和文件本身不变，指向它的硬链接会看到新内容，它的权限和属主也不会被源文件替换，而且整个覆盖过程不是原子的。
3. 想要“先删除再新建”的语义，cp 可以用 --remove-destination；加 -f 则是在目标打不开时先删掉它再重试。
4. cp 复制目录到已存在的目标目录时，结果是放在里面并逐文件合并，目标里多出来的文件不会被删；不加 -p 时，新文件的属主、权限、时间戳都按默认规则重新生成。
5. mv 在同一文件系统内调用 rename()，只改目录条目、不搬数据、原子完成；源和目标不在同一文件系统时 rename() 返回 EXDEV，mv 退化成先复制再删源，此时目标是一个新文件，过程不再原子，源上的硬链接关系不会保留，部分属性可能在打印一条提示的情况下仍然算作命令成功。
6. 用 df 比较两个路径的 Filesystem 或 Mounted on 列，就能提前判断 mv 走哪条路径。
7. mv 的最后一个参数如果是已存在的目录，源会被放进去而不是替换它；想让它把目标当作普通名字要用 -T，此时目标是非空目录会以 ENOTEMPTY 失败，空目录则被替换。移动目录不需要 -r，需要 -r 的是 cp。
8. rm 默认不删目录，删目录要用 -r 或空目录用 -d；它相当于把文件名从目录里摘掉，所以不等于擦除，也不影响仍被硬链接指向的内容。
9. rm 的 -f 同时意味着忽略不存在的文件和永不提示（包括只读文件）；-I 在参数超过三个或递归时只问一次。
10. 通配符由 shell 在命令执行前展开成文件名列表，命令本身看到的是展开后的结果；* 不匹配 / 也不匹配开头的点，展开按字母序，匹配不到时 bash 默认把模式原样传给命令，nullglob 会让它变空，failglob 会让命令不执行。
11. 通配符展开的数量会改变命令的行为，所以把模式交给 rm 之前，可以先交给 echo 或 ls -d 看一眼展开结果。

---

上一篇在讲路径时，我们把“位置”说清楚了：绝对路径和相对路径怎么数、`/etc`、`/var`、`/usr` 各自装什么。那篇解决的是“指得到”的问题；这篇开始动手——创建、复制、移动、删除。

`mkdir`、`cp`、`mv`、`rm` 这四个命令本身很短，但它们在服务器上出的事故往往不是因为敲错命令，而是因为对三件事没预期：**目标已经存在时会怎样、源和目标在两个文件系统上时会怎样、参数里带了通配符时会怎样**。这篇就把这三种情况逐个拆开。

## mkdir：唯一一个“目标已存在”就停手的命令

```
mkdir logs
```

目录已存在时，它报 `File exists`，什么都不做。这一点和后面三个命令正相反，也是它比较安全的原因。

常用的是带 `-p` 的形式：

```
mkdir -p /srv/app/logs
```

`-p` 做两件事：中间缺的层一起建出来，并且每一层已经存在都不算错。所以 `mkdir -p` 可以反复执行而结果不变——写部署脚本时这一点很有用。

## cp：目标已存在时，它是在原文件里改写

这是全篇最值得记住的一步。

`cp a.txt b.txt`，如果 `b.txt` 已经存在，cp 并不是“删掉 b.txt，再放一个新文件进去”。按 POSIX 的规定，它做的事情是：**以“只写 + 截断”的方式打开 `b.txt`，把它的长度清成 0，然后写入 `a.txt` 的内容。**

这个实现细节解释了几个很实际的现象：

- `b.txt` 这个目录条目和它背后的文件没有变。所以如果别处有硬链接也指向 `b.txt`，那些链接会一起看到新内容。
- `b.txt` 的权限和属主**不会**变成 `a.txt` 的。它们属于那个早已存在的文件，打开一个已有文件不会重设它的 mode。
- 覆盖不是原子的。写到一半被中断，`b.txt` 就停在“旧内容已经没了、新内容只有一半”的状态。想要“先删掉再新建”的语义，用 `--remove-destination`，代价是中间有一瞬间目标不存在。
- 如果 `b.txt` 是一个指向普通文件的符号链接，cp 默认跟着链接走，改写的是链接指向的那个文件，链接本身留着。

几种覆盖策略的差别：

| 写法 | 目标已存在时 |
| --- | --- |
| `cp a b` | 覆盖 |
| `cp -i a b` | 每次覆盖前问你 |
| `cp -n a b` | 跳过，不动它 |
| `cp -u a b` | 只有当 a 比 b 新才覆盖 |

复制目录要加 `-r`。目标目录已存在时，`cp -r src dst` 的结果是 `dst/src`；如果 `dst/src` 也已经存在，两边会**合并**：同名文件按上面的规则覆盖，而 `dst/src` 里多出来的文件会原样保留——cp 不会为了“和源一致”而删东西。这是很多人对 `cp -r` 的误解。

另外，不加 `-p` 时，复制出来的新文件用的是你的默认属主和 umask 权限，时间戳也是新的。

## mv：同一个文件系统上只是改名，跨了就是复制加删除

`mv` 的机制和 `cp` 有本质区别，这一点决定了它跨分区时会有完全不同的代价。

在同一文件系统内，`mv a b` 实际调用的是 `rename()`。它只改目录里的条目，不搬任何数据：文件是 10 KB 还是 10 GB，都是瞬间完成，也不多占空间；而且是**原子**的，别人只可能看到“老名字还在”或“新名字已经可用”，看不到中间状态。

但 `rename()` 有一条硬限制：源和目标必须在同一个挂载的文件系统上，否则返回 `EXDEV`（跨设备链接）。这时 mv 会退化成**把源复制到目标，再把源删掉**。按 POSIX 要求，它不保证这两个位置能原子切换，中途被打断可能留下不完整的副本；目标是一个全新的文件；源上的硬链接关系也不会跟着过去。

还有一个容易忽略的后果：跨文件系统时，属主、时间戳这些属性可能复制不过去。这种情况下 mv 会往标准错误打一条提示，但**退出状态仍然是成功**——也就是说脚本里判断“上一条命令成功了吗”并不会因此失败，属性没跟上却没人拦你。

下图是这条判断路径：

```mermaid
flowchart LR
  A["mv 源 目标"] --> B{"源和目标在同一文件系统？"}
  B -->|"是"| C["调用 rename：只改目录项，不搬数据，原子"]
  B -->|"否，返回 EXDEV"| D["复制到目标再删源：新文件，非原子，属性可能丢失"]
```

想提前知道会走哪条路，比较两个位置所在的文件系统就行：

```
df /home/deploy/app /data
```

看输出的 Filesystem 或 Mounted on 两列是否相同。

**另一个必须记住的坑是“目标是已存在的目录”。** `mv` 的规则是：如果最后一个参数是一个已经存在的目录，就把前面的每个源**放进去**。于是

```
mv app.conf /etc/nginx
```

是把文件放进 `/etc/nginx/app.conf`。如果你本来想给 `app.conf` 改个名字，结果它进了目录里，而且 `/etc/nginx/app.conf` 若已存在会直接覆盖。同样，`mv dirA dirB`（`dirB` 已存在）会把 `dirA` 整个塞进 `dirB`，而不是替换 `dirB`。

想让 mv 把目标当作一个普通名字而不是“放进这个目录”，用 `-T`：

```
mv -T dirA dirB
```

此时如果 `dirB` 是非空目录，`rename()` 会以 `ENOTEMPTY` 失败；如果 `dirB` 是空目录，则会被替换掉。另外记一下：移动目录不需要 `-r`，`mv` 自己会处理，需要 `-r` 的是 cp。

## rm：删的是名字，不是内容

`rm` 默认只删文件，遇到目录直接报错。删目录要 `-r` 或 `-R`（递归），只想删空目录可以用 `-d`。

它做的事情接近 `unlink`：把文件名从目录里摘掉。只有当没有任何名字再指向那份数据时，磁盘空间才算被回收。所以 rm 不等于安全擦除，内容在一段时间内仍可能被恢复。同理，删掉一个还有硬链接指向的文件，内容依然在。

几个选项的含义值得分清楚：

- `-i`：每一个文件都问一次。
- `-I`：文件超过三个、或者带 `-r` 递归时，只问一次。比 `-i` 克制，但足以挡住大多数误操作。
- `-f`：两层含义——忽略不存在的文件，并且永不提示（包括对写保护的只读文件）。所以 `rm -rf` 是“递归、不问、找不到也算成功”。

`rm` 默认带着 `--preserve-root` 保护，会拒绝 `rm -rf /`。文件名以 `-` 开头时（比如 `-foo`），用 `rm -- -foo` 或 `rm ./-foo`。

## 通配符：命令根本看不到那个 `*`

最后这条会反过来影响上面所有命令，所以放在这里。

`rm *.log` 里，`rm` 从来没见过 `*.log` 这几个字符。是你的 shell **先**把它展开成匹配到的文件名列表，**再**把这些名字交给 `rm`。理解通配符，本质上就是理解 shell 在命令运行之前做了什么：

- `*` 匹配任意多个字符，但**不匹配 `/`**，也**不匹配文件名开头的 `.`**。所以 `rm *` 不会删掉 `.bashrc` 这类隐藏文件，得显式写 `rm .*` 或 `dotglob`。
- `?` 匹配一个字符，`[...]` 匹配集合里的一个字符。
- 展开结果按字母序排列，所以 `cp *.txt dir/` 的处理顺序是确定的。
- **一个模式匹配不到任何东西时，bash 默认把模式原样留下。** 于是在没有 `.log` 文件的目录里执行 `rm *.log`，实际请求是“删除一个名叫 `*.log` 的文件”，报 `No such file or directory`。用 `shopt -s nullglob` 会让匹配不到的模式变成空；用 `shopt -s failglob` 会让命令直接不执行并报错。注意 nullglob 的风险：`ls *.zip` 敲成 `ls *.zpi` 时会退化成 `ls`，把当前目录全部列出来。

因为它发生在命令执行之前，通配符展开的数量会直接改变命令的行为：`rm *.log` 展开出 300 个文件时，`-I` 只问你一次，`-i` 会问你 300 次。最危险的组合是通配符、变量和递归删除混在一起。不确定展开结果时，先把同一条模式交给 `echo` 或 `ls -d` 看一遍，再把它交给 `rm`。

## 收起来

拿到一个文件操作，按这个顺序想三件事：**目标存不存在**——mkdir 会因此报错，cp 和 mv 会覆盖（除非写了 `-n`、`-u`），rm 只看名字不看内容；**两边是不是同一个文件系统**——同则 mv 只是改个目录条目，跨则它退化成复制加删除；**参数里有没有通配符**——有的话，真正决定命令行为的是 shell 把它展开成了什么。

## 术语表

- 截断写入：cp 覆盖已有文件时先以只写方式打开目标、把长度清成 0 再写入，因此目标文件和指向它的硬链接都被原地改写。
- 原子操作：从外部看只有“完成前”和“完成后”两种状态、看不到中间过程；同一文件系统内的 mv 就具有这个性质。
- rename 系统调用：同文件系统内 mv 真正调用的底层操作，只改目录条目，不搬数据，跨文件系统会返回 EXDEV 而失败。
- EXDEV：源和目标不在同一挂载的文件系统时 rename() 返回的错误，mv 收到后改用“复制 + 删除”完成移动。
- 硬链接：同一份数据被多个文件名指向，重命名或复制文件时是否保留这种关系，取决于操作的机制而不是命令名字。
- 目录项：目录里“名字到文件”的对应关系，rm 删除的是它，mv 修改的也是它。
- shell 通配符展开：shell 在运行命令之前把 `*`、`?`、`[...]` 替换成匹配的文件名列表，命令收到的是展开结果。
- nullglob 与 failglob：两个 bash 选项，分别让匹配不到的模式变成空、或者直接报错不执行命令；默认行为是把模式原样保留。

## 来源

1. [GNU cp 手册 — 覆盖策略与 -i、-n、-u、-f、--remove-destination 等选项](https://man7.org/linux/man-pages/man1/cp.1.html)
2. [POSIX cp 规范 — 覆盖已有目标时以 O_WRONLY|O_TRUNC 打开，-f 先 unlink 再重试](https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/utilities/cp.html)
3. [rename(2) — EXDEV、ENOTEMPTY、EBUSY 等错误，以及不跨挂载点的限制](https://man7.org/linux/man-pages/man2/rename.2.html)
4. [GNU mv 手册 — -i、-f、-n、-T、-t、--update 的行为](https://man7.org/linux/man-pages/man1/mv.1.html)
5. [POSIX mv 规范 — 同文件系统用 rename，跨文件系统时复制整棵树再删源，以及中断时的保证](https://pubs.opengroup.org/onlinepubs/9799919799.2024edition/utilities/mv.html)
6. [GNU rm 手册 — -r、-d、-f、-i、-I、--preserve-root，以及以 - 开头的文件名的写法](https://man7.org/linux/man-pages/man1/rm.1.html)
7. [glob(7) — 通配符不匹配 / 和开头的点，匹配不到时 bash 保留模式，nullglob 的作用](https://linuxman7.org/linux/man-pages/man7/glob.7.html)

---

原文：https://pangzhengboyin.com/articles/cp-mv-rm-overwrite-cross-filesystem-globs-6e999d72

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
