cp src/* 为什么拷不全?从一个星号看 Shell 展开
一、一次”看起来成功”的拷贝
前段时间迁移一个服务,需要把项目目录整体拷到新位置。目录结构很简单:
1 | $ ls -A src |
我顺手敲了一行再熟悉不过的命令:
1 | $ cp -r src/* backup/ |
没有报错,ls 看了一眼,文件都在。结果服务一启动就报找不到配置。再仔细看:
1 | $ ls -A backup |
.env 和 .gitignore 不见了。
后来换成 cp -r src/. backup/,一个文件都没少。同样是”拷贝 src 下的所有内容”,* 和 . 的结果为什么不同?要回答这个问题,得先弄清楚 cp 到底收到了什么。
二、cp 收到的根本不是星号
bash 提供了一个调试开关 set -x,它会把命令真正执行前的样子打印出来:
set -x(全称set -o xtrace)的作用可以概括为一句话:每条命令在展开之后、执行之前,先把它最终的样子打印到标准错误(stderr)
1 | $ set -x |
此时命令行返回
1 | + ls --color=auto -l --color=auto |
注意最后一行++开头的这是正常现象,不是报错。它是终端提示符每次显示前自动执行的一条命令,被 set -x 一起跟踪打印出来了。
后续为了文档美观方便读者观看,我把这行从后续输出中人为删除,并不代表shell不返回
我们开始执行cp -r 命令,看看到底发生了什么
1 | cp -r src/* backup/ |
* 不见了,取而代之的是三个具体的文件名。如果觉得这只是 bash 自己打印的说法,可以用 strace 看一眼内核实际收到的 execve 系统调用:
1 | $ strace -f -e trace=execve bash -c 'cp -r src/* backup/' |
典型的返回如下

这就是 cp 的全部输入:一个包含 6 个字符串的参数数组。cp 启动时,* 已经被 bash 替换掉了,cp 根本不知道用户输入过星号,自然也没有机会去拷贝隐藏文件。
问题因此转移到了 bash 身上:它把 src/* 展开成这三个名字,依据的是什么规则?
三、bash 如何展开 *
bash 解析完一行命令后,在执行前会对每个词做一系列展开处理,最后一步叫路径名展开(Pathname Expansion),也就是常说的 glob。它的工作过程大致如下:
- 发现某个词里含有未被引号包住的
*、?或[,就把它当作模式; - 读取模式所在的目录(这里是
src),逐个拿目录项去匹配; - 把匹配到的名字按字母顺序排好,替换掉原来的词。
关键在第 2 步里一条特殊规则:以 . 开头的文件名,只有在模式本身也以 . 开头时才会被匹配。* 不以点开头,所以 .env、.gitignore 被直接跳过。这条规则同时也让 * 不会匹配到 . 和 .. 这两个特殊目录项。
这里有一点值得强调:所谓”隐藏文件”,只是 shell 和 ls 等工具共同遵守的一个约定。对文件系统来说,.env 和 app.py 没有任何区别,内核也不存在”隐藏”这个属性。
理解了展开是 shell 的行为,另外两个现象就好解释了。
第一,如果目录是空的,模式一个都匹配不到,bash 默认会把它原样保留:
1 | $ cp -r empty/* backup/ |
cp 收到的是字面上的 empty/*,它当然找不到一个名叫 * 的文件。
第二,只要用引号或反斜杠把星号包起来,展开就不会发生:
1 | $ echo src/* |
只有第一条命令里的星号被展开了,后面三种写法都让 * 原样保留了下来。
到这里,* 为什么会漏掉隐藏文件已经清楚了。
但另一半问题还没解决:src/. 同样交给了 cp,它凭什么能拷全?这就要看 cp 拿到参数之后是怎么干活的。
顺带一提,常见的 cp nginx.conf{,.bak} 中的 {} 并不是通配符,它属于另一种机制,叫花括号展开(Brace Expansion)。它在所有展开中最先执行,而且是纯文本替换,不会去查文件系统:echo src/{app.py,index.js} 会输出 src/app.py src/index.js,哪怕 index.js 根本不存在。正因为先执行,它的结果还会继续参与路径名展开,比如 src/{*,.[!.]*} 会先被拆成 src/* 和 src/.[!.]* 两个模式,再分别去目录里匹配,所以 * 的那些限制它一个也没少。
四、cp 如何决定目标路径
当最后一个参数是一个已存在的目录时,cp 对前面的每个源参数做同一件事:取源路径的最后一个部分,拼到目标目录后面,作为目标路径。加上 -v 参数就能看到它的计算结果。
先看 * 展开后的情况,三个源分别落到 backup/ 下:
1 | $ cp -rv src/* backup/ |
再看直接传目录名的情况。源的最后一部分是 src,所以目标路径是 backup/src,整个目录被嵌套了一层:
1 | $ cp -rv src backup/ |
注意这次隐藏文件是拷上了的。因为 -r 递归时,cp 用 readdir 自己读取目录内容,读到什么就拷什么,不经过 shell,也就没有”跳过点开头文件”的规则。
隐藏文件的问题解决了,新的问题却随之而来:这种写法会把 src 目录本身也一起拷过去,结果 backup 下多出了一层 src,文件全都落在了 backup/src/ 里,而不是我们想要的 backup/。
那么,有没有办法既让 cp 自己去读整个目录,又让它算出来的目标路径恰好就是 backup 本身?src/. 正是这样一个写法。
五、. 为什么能做到
首先,src/. 里没有任何通配符,bash 不会动它,原样交给 cp。
其次,. 不是 shell 或 cp 发明的语法,而是每个目录中真实存在的一个目录项,指向目录自身。
ls -i 会在文件名前显示 inode 号,也就是文件系统用来标识一个文件或目录的编号;加上 -d 则只显示目录本身,而不是列出它里面的内容。用它对比一下 src 和 src 里的 .:
1 | $ ls -id src |
src 和 src/. 是同一个 inode,所以内核解析 src/. 时,得到的就是 src 这个目录。cp 打开它、用 readdir 读出全部内容,包括隐藏文件。
最后再看目标路径。前面说过,cp 会取源路径的最后一部分,拼到目标目录后面:源路径 src/. 的最后一部分是 .,于是目标路径变成 backup/.,而 backup/. 就是 backup 自己。-v 的输出把这个过程展示得很清楚:
1 | $ cp -rv src/. backup/ |
这三种写法的区别可以归结为一点:*** 由 shell 解释,在 cp 运行前就被替换掉了;. 由文件系统解释,cp 拿到的是一个完整的目录**。前者受 glob 规则约束,后者不受。
用三种写法套一下就清楚了:
| 命令 | 源路径的最后一部分 | 目标路径 | 结果 |
|---|---|---|---|
cp -r src/app.py backup/ |
app.py |
backup/app.py |
正常 |
cp -r src backup/ |
src |
backup/src |
多一层 src |
cp -r src/. backup/ |
. |
backup/. = backup |
内容直接合并进 backup |

cp 只机械地取最后一部分来拼接,而 src/. 的最后一部分恰好是 .,拼出来的 backup/. 恰好又指向 backup 自己。两个巧合叠在一起,才有了”合并内容”的效果。
六、还有哪些写法,该怎么选
搞清楚原理后,网上常见的几种替代写法也就能逐一判断了。
cp -r src/.* backup/ 看似能补上隐藏文件,在 RHEL 7/8 上却是最危险的一种。bash 5.2 之前,.* 会匹配到 . 和 ..:
1 | $ printf '%s\n' src/.* |
src/.. 就是 src 的父目录。这条命令会把父目录整个递归拷进 backup,项目放在家目录下的话,可能就是把整个家目录拷了一遍。
src/.[!.]* 能排除 . 和 ..,但会漏掉 ..foo 这类以两个点开头的文件名,而且只能匹配隐藏文件,需要和 src/* 搭配使用。另外,两个模式中只要有一个匹配不到,就会触发前面提到的字面量原样保留的问题。
shopt -s dotglob 可以让 * 匹配隐藏文件(但依然不会匹配 . 和 ..),适合在脚本里使用。不过它改变的是当前 shell 的全局行为,交互式终端里开了容易忘记关。
rsync -a src/ backup/ 也是常用做法。注意源路径末尾的斜杠,它和 . 一样,是由 rsync 程序自己解释的,表示”拷目录的内容而不是目录本身”。
日常使用中,我的选择是:
1 | cp -a src/. backup/ |
-a 相当于 -dR --preserve=all,会保留权限、属主和时间戳,迁移配置时比 -r 更稳妥。这种写法不依赖任何 shell 选项,不受隐藏文件规则影响,空目录也不会报错,参数个数也永远只有两个。
写在最后
回过头看,这个坑本身并不复杂,但它涉及一个很容易被忽略的事实:我们在终端里敲的命令,并不是程序最终收到的命令。中间隔着 shell,它会按自己的规则改写很多东西,星号只是其中之一。
所以下次再遇到命令行为和预期不一致时,可以先问自己一个问题:这个字符是谁在解释,shell 还是程序? 拿不准的时候,set -x 会告诉你答案。