1. 先搞清楚 cp 到底在做什么:一次拷贝背后的真实开销

我在日常运维里用得最多的命令,排第一的永远是 cp ,不是 ls 也不是 cd 。原因很简单,只要涉及部署、备份、改配置留后路,第一步一定是"先复制一份"。但奇怪的是,问十个刚接触 Linux 的人,九个都觉得自己会用 cp,结果一到真实故障现场,不是漏了隐藏文件,就是把链接拷成了实体,或者把几百 G 的目录拷了三遍还没搞明白慢在哪。这篇就把 cp 命令 从表面用法一路拆到文件系统层面,把那些文档里不会写的坑一次讲透。

先说清楚它是什么。cp 是 coreutils 包里最基础的文件复制工具,功能就是把源文件(或目录)的内容写到目标位置。听起来毫无技术含量,但真正决定它行为的东西有一大堆:源和目标是不是同一个文件系统、有没有开 CoW(写时复制)、目标是否已存在、当前 shell 有没有给它加别名、SELinux 和 ACL 这类扩展属性怎么处理。这些因素组合起来,能让你写下的同一条命令在不同机器上跑出完全不一样的结果。

这篇文章适合三类人看:一是想系统补齐 linux常用命令 基础、准备面试的初学者;二是天天写脚本但没深究过参数语义的运维和开发;三是需要在国产发行版、容器镜像里做构建,被 cp 的边界情况折磨过的工程同学。我不打算只罗列参数表,那种内容你查 man 手册就有,我要讲的是每个参数为什么存在、什么时候会咬你一口,以及我踩过的那些坑。

后面所有的例子都基于 GNU coreutils 的 cp,绝大多数发行版(包括不少国产化发行版)用的都是它。如果你在 BusyBox 或者 BSD 环境下,参数支持会有差异,我在对应位置会专门标注。

2. cp 的基础用法:五种高频姿势与参数语义

2.1 最常用的五条命令,覆盖九成场景

先把最常打交道的五种形态过一遍,这几条命令我几乎每天都会敲,记住它们的语义差异比背参数表有用得多:

cp a.txt b.txt                    # 文件到文件,目标不存在则新建,存在则覆盖
cp a.txt /data/                   # 文件到目录,结果是 /data/a.txt
cp -r src/ dst/                   # 目录到目录,递归复制
cp -a src/ dst/                   # 归档模式,保留权限、时间、链接、扩展属性
cp -i a.txt b.txt                 # 覆盖前询问,防止手抖

这里有个非常容易搞混的规则: 当目标是已存在的目录时,源会被放进这个目录里 。也就是说 cp a.txt /data/ 得到的是 /data/a.txt ,而不是把 /data 这个目录替换掉。如果你写的是 cp -r src/ /data/ ,结果是 /data/src/ ;如果 /data/src 已经存在,那源目录会被塞进去变成 /data/src/src ,这个"套娃"是新手最常见的翻车点,尤其是写脚本时反复执行同一行,越跑层级越深。

反过来,如果目标目录不存在, cp -r src/ /data/ 会把 /data 当作新目录名创建,把 src 的内容装进去。判断依据就是"目标是否已经是一个目录",这个规则在脚本里必须用 -T 显式规避。 cp -T src /data 会强制把目标当作普通路径处理,不做"放进目录"的推断,写自动化脚本时我强烈建议加上它,能省掉大量非幂等带来的隐患。

2.2 -r、-a、-p、-i、-f 到底差在哪

很多人把 -r 和 -a 当成同义词用,这是个隐患。它们的关系是: -a 基本等价于 -dR --preserve=all ,也就是递归加上保留链接本身、保留所有可保留的属性。 -r 只保证递归,权限位会按 umask 重新计算,属主变成当前用户,时间戳变成此刻。区别在什么时候爆发?备份场景。你用 -r 备份一批可执行文件,恢复后发现执行位没了(umask 022 下新文件是 644),服务直接起不来,这种事故我见过不止一次。

-p 是 --preserve=mode,ownership,timestamps 的简写,比 -a 少保留了链接和扩展属性。保留属主这一条有个前提:只有 root 才能把文件属主设成别人,普通用户加 -p 时 ownership 会被静默忽略,mode 和 timestamps 仍然生效。所以普通用户做备份,别指望 -p 能把 root 的属主关系带过来。

-i 和 -f 是一对相反的开关。 -i 在覆盖前交互询问, -f 强制覆盖、遇错删掉目标再重试。要注意的是,很多发行版的 /etc/profile 或者 ~/.bashrc 里默认有 alias cp='cp -i' ,导致你在交互终端里 cp 永远会问一句,但脚本里因为不加载别名,行为又变回直接覆盖。这种"同一个命令两种行为"的割裂,是脚本调试时最迷惑的事情之一。想绕过别名可以用 \cp 或者 /bin/cp ,我个人的习惯是在关键脚本里一律写绝对路径。

2.3 参数速查:哪些参数你真正需要记住

参数 作用 典型使用场景 备注
-r / -R 递归复制目录 拷目录基础用法 属性会按 umask 重置
-a 归档模式,等同 -dR --preserve=all 备份、迁移 最推荐的目录拷贝方式
-p 保留权限、属主、时间 恢复配置、保持时间线 普通用户无法保留属主
-i 覆盖前询问 交互操作防手抖 常被 alias 默认加上
-f 强制覆盖 目标文件有写保护时 与 -i 同用时后者失效,取决于顺序
-n 不覆盖已存在文件 幂等脚本 比 -i 更适合自动化
-u 仅当源更新时复制 增量同步小场景 基于 mtime 比较
-v 打印每个动作 调试、确认范围 生产脚本慎用,日志量会爆炸
-l 创建硬链接代替复制 省空间、省时间 不能跨文件系统
-s 创建符号链接 做软链替代品 同样不能跨文件系统创建
-t 指定目标目录,源在后 配合 find/xargs 批量 顺序直观,安全性好
-T 目标按普通路径处理 避免目录套娃 脚本必备
--parents 保留源路径层级 合并多个目录树 GNU 扩展

这张表里我认为最被低估的是 -t 和 -T 。 -t 把目标目录放到参数前面对批量处理非常友好,配合 find ... -exec cp -t /dst {} + 可以减少大量进程创建。 -T 则是脚本幂等性的保险丝,很多人根本不知道有这个东西,结果每次执行都往目录里再套一层。

3. 深水区:cp 的隐藏文件、链接与属性保留机制

3.1 隐藏文件为什么总是漏掉

这是被问得最多的问题,没有之一:为什么 cp -r /src/* /dst/ 拷完之后, .env 、 .gitignore 、 .bashrc 全都不见了?原因不在 cp,而在 shell。 * 是 shell 做的通配符展开,而通配符默认不匹配以点开头的文件名。cp 收到的参数列表里压根就没有这些文件,它当然不会拷。

正确的做法有几种,按场景选:

cp -a /src/. /dst/                # 用点表示"目录内容",包含隐藏文件
cp -a /src/. /dst/                # 目标不存在时会自动创建(GNU 行为)
shopt -s dotglob && cp -a /src/* /dst/   # bash 开启点文件匹配
find /src -mindepth 1 -maxdepth 1 -exec cp -a {} /dst/ \;   # 最通用

第一种 cp -a /src/. /dst/ 是我最推荐的, . 代表源目录自身的内容,shell 展开后会把包括隐藏文件在内的所有条目都传进去。注意它和 cp -a /src /dst/ 的区别:后者如果 /dst/src 存在,会往里再套一层。写备份脚本时我基本固定用 /src/. 这个写法。

第二种用 shopt -s dotglob 是临时的 shell 选项,只在当前会话有效,适合交互式操作,但不要写在别人也要用的脚本里,因为它会改变整个会话所有通配符的行为,副作用不可控。

3.2 符号链接、硬链接与 cp 的三种处理策略

cp 遇到链接时有三种处理方式,选错了后果差别很大。默认情况下,源是符号链接时 cp 会 跟随链接 ,把目标文件的内容复制过去,结果是一个普通文件。加 -d (或 -a 里的 d)则 保留链接本身 ,拷出来还是个软链,指向原来的路径。

-l 是另一回事,它不是复制,而是给同一个 inode 再挂一个名字,也就是 硬链接 。这就是热搜里那个 cp -lf 里 -l 的含义:不复制数据,只建链接,速度极快、几乎不占空间,而且 -f 保证目标已存在时先删掉再建。

但硬链接有两个硬性限制必须记住:第一,只能在同一个文件系统内创建,跨分区会直接报 Invalid cross-device link ,这不是权限问题,是机制问题;第二,因为是同一个 inode,改任何一个名字下的文件,另一个也跟着变,这在备份场景里是致命的——你以为备份了,其实备份和目标指向同一份数据,源文件被误删改,备份一起完蛋。所以 -l 只适合做"同一份内容的多个入口",绝不适合做"留后路"的备份。

-s 则相反,创建的是符号链接而不是复制内容。它同样不能跨文件系统,而且记录的是路径字符串,源文件移动或删除后会变成断链。做发布版本目录切换(比如 current -> releases/v1.2.3)时用它很合适,做数据备份就完全不合适。

3.3 权限、属主、时间戳与扩展属性:谁保留、谁丢失

属性保留是 cp 最容易出事故的地方,我把它拆成几个层次:

  • 基础权限位(mode) :默认会按 umask 重新计算, -p 或 -a 才会原样保留。执行位丢失导致脚本无法运行,属于经典事故。
  • 属主与属组(ownership) :只有 root 能设置成任意用户,普通用户加 -p 时这一项被静默忽略。跨机器迁移时,目标机上如果没有对应的 UID/GID,会显示成数字 ID。
  • 时间戳(timestamps) :默认是当前时间, -p 保留 mtime 和 atime。做构建缓存判断、增量同步时这个很关键。
  • ACL 与扩展属性(xattr) : -a ( --preserve=all )在 GNU 下会保留, -p 不会。安全标签、能力位(capabilities)都藏在这里。
  • SELinux 上下文 :cp 默认会按目标目录的默认策略重新打标签,而不是保留源标签。要保留得加 -Z ,或者拷完跑一次 restorecon -R /dst 。我遇到过拷完文件后服务起不来,最后定位就是上下文不对导致读取被拒。

这三层属性里,我认为最值得警惕的是"静默忽略"。cp 不会因为你用普通用户加 -p 想保留属主而报错,它只是默默不做。这类"看起来成功了"的操作,比直接报错危险得多。所以做迁移前,先用 ls -l 和 getfacl 对比源和目标,是我固定要做的验证步骤。

4. 实操环节:几套可以直接抄的拷贝方案

4.1 目录备份的稳妥写法与参数推导

假设要把 /opt/app 完整备份到 /backup/app-20240601 ,要求保留权限、时间、链接和扩展属性,并且可重复执行不出错。我的写法是:

DST=/backup/app-20240601
mkdir -p "$DST"
cp -a --no-target-directory /opt/app/. "$DST"/
sync

逐项解释为什么这么写。 -a 一次覆盖了递归、链接保留、属性保留三项需求,比手动拼 -rp 更全。 --no-target-directory 就是 -T 的长写法,保证目标目录被当作一个确定的普通路径,不会因为已存在而套娃。源用 /opt/app/. 是为了把隐藏文件带进去,同时避免把 app 这一层目录名也拷进去(如果用 /opt/app ,结果会是 $DST/app/ )。 mkdir -p 保证目标存在,让行为完全可预测,不依赖 GNU 的自动创建。最后的 sync 是把页缓存刷到磁盘,做正经备份时别省这一步,尤其是马上要拔盘或者关机的情况。

验证环节我一般跑两条命令: diff -r /opt/app "$DST" 比对内容, ls -l 抽查关键文件权限。大目录下 diff 会慢,可以先用 du -s 比字节数,再抽查。

4.2 批量筛选拷贝:find 与 cp -t 的组合拳

把一堆符合条件的文件汇总到一个目录,是发布打包的常见需求。最稳的写法是用 find 配合 cp -t :

find /var/log/app -type f -name '*.log' -mtime -7 -print0 \
  | xargs -0 -r -n 50 cp -t /tmp/recent-logs

关键点在几个地方。 -print0 和 xargs -0 是一对,用 NUL 分隔,能正确处理带空格、换行、中文的文件名,这是新手最容易忽略的隐患——用默认空格分隔,一个带空格的文件名会被拆成两个参数,轻则报错重则拷错位置。 -r 让 xargs 在输入为空时不执行命令,避免无谓的 cp 调用。 -n 50 每次传 50 个文件,减少进程数。

cp -t /dst 的意义在这里体现得很清楚:目标目录放在前面,后面跟一堆源文件,语义是"把这些源都拷到 /dst 里",比用 find -exec cp {} /dst \; 每个文件起一个进程高效得多。如果非要用 -exec ,记得用 + 结尾而不是 \; ,前者会批量拼接参数。

还有一种保留目录层级的场景,比如把多个模块的配置按原路径合并到目标:

cp -a --parents conf/app/db.yaml conf/app/web.yaml /release/

--parents 会把 conf/app/ 这层结构在目标里重建出来,做配置合并时非常好用,这是 GNU 的扩展,BusyBox 里没有。

4.3 大文件与性能:为什么有时候 cp 慢得离谱

拷一个 50G 的镜像文件,有人半小时,有人几十秒,差距就在文件系统和调用方式上。现代 GNU cp 会优先尝试 copy_file_range() 这个系统调用,在支持 CoW 的文件系统(比如 btrfs、XFS 的 reflink)上,它可以只复制元数据、不搬数据块,几秒钟完成。控制开关是 --reflink :

cp --reflink=auto big.img /data/big.img    # 能 CoW 就 CoW,不能就正常复制
cp --reflink=always big.img /data/big.img  # 强制,不支持则报错

auto 是默认值,但在同一个文件系统内、且文件系统支持时才生效。跨分区或跨设备就退回普通读写,速度取决于磁盘带宽。想确认真的是秒级完成,看时间: time cp --reflink=auto big.img /data/ ,如果显示零点几秒,那是真 CoW。

另一个影响速度的点是稀疏文件。某些镜像、数据库文件是稀疏的,逻辑大小 10G,实际占盘 1G。默认 --sparse=auto 会自动检测并保持稀疏,但如果你拷到不支持稀疏的文件系统上,会变成实打实的 10G。用 du 和 ls -l 对比出来的差异就知道有没有中招。

要实时进度的话,cp 本身不提供(老版本没有,新版本也没有通用的进度条)。我常用的替代是 pv :

pv big.img > /data/big.img

或者干脆用 rsync --progress ,它自带进度、断点续传、校验,做大数据量迁移比 cp 更合适。cp 的优势在于简单和即时,不涉及额外依赖。

5. 报错现场:常见问题排查速查与避坑心得

5.1 报错信息对照与快速定位

报错信息 真实原因 处理方式
omitting directory 'xxx' 没加递归参数就拷目录 加 -r 或 -a
cannot overwrite non-directory with directory 目标已存在且是文件 先删目标或改用目录名
Invalid cross-device link 用 -l/-s 跨文件系统 改回普通复制,或换到同一分区
Permission denied 源无读权限或目标无写权限 检查权限,必要时用合适权限账户
Text file busy 目标是被运行中的程序占用的可执行文件 停服务或拷到其他名字
No space left on device 目标分区写满 df -h 查看,注意 inode 也可能耗尽
拷完文件是 644,执行不了 用了 -r 而非 -a 改 -a 或手动补权限
拷完 SELinux 拒绝访问 上下文丢失或错误 restorecon -R 或 cp -Z

这张表里我最想强调的是 Text file busy 。它出现在覆盖一个正在运行的程序文件时,是内核的保护机制。很多人第一反应是 rm -f 强删,其实删掉后进程仍然持有旧的 inode,磁盘空间不会释放,而新文件也未必能立刻生效。正确做法是先停服务,或者按"先拷到临时名、再原子替换"的方式做。

5.2 排查思路:从现象倒推原理

遇到 cp 相关的问题,我习惯按这个顺序排查,基本能覆盖九成情况:

第一步,先确认你敲的 cp 到底是哪个 cp。执行 type cp ,如果输出是 cp is aliased to 'cp -i' ,说明你的交互行为被改写过。这一步能解掉大量"为什么终端里和脚本里结果不一样"的疑惑。

第二步,确认源和目标的文件系统。 df -h /src /dst 和 stat -f 看类型,跨设备、CoW 支持与否直接决定能不能用 -l 、 --reflink 。

第三步,确认通配符展开范围。在命令前加 echo ,比如 echo cp -a /src/* /dst/ ,看 shell 到底展开了哪些文件,隐藏文件漏没漏一眼就清楚。

第四步,用 -v 或 strace -f -e trace=openat,read,write cp ... 看细节。strace 能直接告诉你 cp 在哪个文件上被拒、返回了什么 errno,是排查权限和属性问题的终极手段。

5.3 我踩过的坑与固定习惯

几个用血换来的经验,直接列出:

交互终端和脚本里的 cp 可能不是同一个命令,关键词是 alias。写脚本永远用 /bin/cp 或 \cp 。

覆盖前先想清楚目标是不是目录, -T 能救命。我用 -T 之后,脚本套娃类故障基本绝迹。

备份场景一律 -a 加 /src/. 的写法,属性、隐藏文件、目录层级三件事一次搞定。

大文件迁移先问一句"同文件系统吗",能 CoW 就别硬搬数据块,省下来的是实打实的机器时间。

涉及安全策略的环境,拷完补一次 restorecon ,比事后查日志快得多。

另外说一个权限方面的心得:普通用户做目录拷贝时, -a 里的 ownership 保留是无效的,别以为拷完属主就对了。需要保持属主关系的迁移,要么用 root,要么先用 --no-preserve=ownership 明确关掉这项避免误导,再用 chown 单独处理。这一条在容器镜像构建里尤其重要,镜像层里的属主关系不对,运行时权限问题会一层层冒出来。

最后再分享一个把 cp 和内核联系起来的小观察。cp 在用户态看起来简单,本质上就是不断调用 openat 、 read 、 write 、 close 这几个系统调用,或者更高效的 copy_file_range 。这意味着任何在内核层拦截这些文件操作的设计,都会同时影响 cp 的行为,包括性能表现和错误返回。所以我排查 cp 异常时,从来不会只在 cp 的参数上找原因,还会考虑下层是否被改过——这是只在用户态打转的人容易忽略的视角。

至于 -u 这类增量参数,它比较的是 mtime,不看内容,所以"只拷更新的文件"这个说法有前提:源的时间戳必须可信。做构建产物的同步时,我更倾向于用会做校验的工具,或者先统一时间戳再拷,省得被一堆时间错乱的文件坑到。cp 是个好工具,但它不是同步工具,这个边界心里要有数。

更多推荐