周三下午运维在群里问:/data/upload 下面一个业务目录没了,能不能恢复。查下来是每天凌晨跑的清理脚本干的,核心就一行 rm -rf $f。$f 的值是 订单 2024-03.tmp 的完整路径,空格把它劈成两个参数,rm 收到的是 /data/upload/订单 和 2024-03.tmp——目录删了,该清的临时文件还在原地。
这篇文章只讲一件事:引号和空格在 shell 里怎么把参数边界搞丢,以及每个具体场景下怎么写才不丢。
不加引号的变量,被 shell 动了两刀
shell 执行一条命令前,对每个没被引号包住的词依次做两步:
- 分词:按
IFS切开,默认IFS是空格、制表符、换行 - 路径展开:把
*、?、[a-z]换成匹配到的文件名,没匹配到就保持字面量
完整顺序是:花括号 {} → 波浪号 ~ → 参数/命令/算术展开 → 分词 → 路径展开 → 去引号。这个顺序解释了后面很多“看起来莫名其妙”的现象。
跑一段就能看见:
#!/usr/bin/env bash
f='订单 2024-03.tmp'
printf '<%s>\n' $f # 不加引号
echo '---'
printf '<%s>\n' "$f" # 加引号
输出:
<订单>
<2024-03.tmp>
---
<订单 2024-03.tmp>
四种写法的差别:
| 写法 | 变量为空 | 含空格 | 含 * |
|---|---|---|---|
$f |
整个词消失 | 拆成多个参数 | 展开成匹配的文件名 |
"$f" |
一个空串参数 | 一个参数 | 字面量 * |
'$f' |
字面量 $f |
字面量 | 字面量 |
第三行不是“变量”,列在这里是提醒:单引号里任何东西都不展开。
空变量配上通配符,就是灾难片:
UPLOAD_DIR=
rm -rf $UPLOAD_DIR/* # 实际执行的是 rm -rf /*
$UPLOAD_DIR 为空 → 分词后这个词直接消失 → 剩下 /* 被路径展开成根目录下所有条目。注意 set -u 只挡“未定义”,挡不住“定义了但是空”,所以还要显式检查:
#!/usr/bin/env bash
set -euo pipefail
: "${UPLOAD_DIR:?UPLOAD_DIR 必须设置}" # 为空或未定义直接退出,报错带上变量名
同样的坑在 [ ] 里更常见:
MODE=
if [ $MODE = 'prod' ]; then echo prod; fi
# bash: [: =: unary operator expected
x=
if [ -n $x ]; then echo '非空'; fi # 会打印「非空」
第二个更阴:$x 消失后变成 [ -n ],这是单参数测试,被测字符串是 -n 本身,非空 → 真,逻辑直接反了。加引号就对了:[ -n "$x" ]。
规则很简单:bash 里一律用 [[ ]],它不做分词也不做路径展开;必须写 POSIX sh(#!/bin/sh、Dockerfile 的 RUN)时才用 [ ],且每个变量都加引号。
遍历文件名:for 的三个错法和一种对法
三个错法,全在带空格/换行的文件名上挂掉:
# 错法 1:ls 的输出不可解析
for f in $(ls /data/upload); do rm -f "$f"; done
# 错法 2:find 的输出同样被分词
for f in $(find /data/upload -name '*.tmp' -mtime +7); do rm -f "$f"; done
# 错法 3:管道右边在子 shell 里跑
count=0
find /data/upload -name '*.tmp' | while read -r f; do
count=$((count + 1))
done
echo "$count" # 永远是 0
错法 3 有两个问题:| 右边在子 shell 执行,count 的修改出不来;read 不加 -r 会把 \ 当转义符吃掉,不加 IFS= 会削掉首尾空白。
正解:
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
local root=$1
local n=0
# -print0 用 \0 分隔,read -d '' 按 \0 读,两者必须配对
while IFS= read -r -d '' f; do
rm -f -- "$f"
n=$((n + 1))
done < <(find "$root" -maxdepth 1 -type f -name '*.tmp' -mtime +7 -print0)
echo "deleted $n"
}
cleanup /data/upload
逐项解释:
IFS=清空,read不再削首尾空白-r关闭反斜杠转义-d ''用 NUL 做分隔符(bash 里空串等价于\0)< <(...)是进程替换,while跑在当前 shell,计数能带出来rm -f --挡住以-开头的文件名
不需要计数、不需要变量带出来时,find -exec {} + 更省事也更快:
find /data/upload -maxdepth 1 -type f -name '*.tmp' -mtime +7 -exec rm -f -- {} +
{} + 把多个文件拼成一条命令(批量模式),{} \; 是每个文件起一个进程。一万个文件时差距是一个量级:
| 写法 | 一万个文件起的进程数 | 备注 |
|---|---|---|
while read 里调 rm |
10000+ | 每次 fork,最慢 |
-exec {} \; |
10000+ | 同上 |
-exec {} + |
几十(受 ARG_MAX 限制) |
快 |
xargs -0 -n 100 |
约 100 | 快,还能 -P 并行 |
自己测比看数字准:
seq 1 10000 | xargs -I{} touch "/tmp/t {}"
time find /tmp -maxdepth 1 -name 't *' -exec rm -f -- {} \;
xargs 也有脾气:默认引号不配对就直接报错退出;-I{} 隐含 -L1,等于每条命令只处理一个文件,性能掉回 -exec {} \; 的水平。批量场景用 -0 -n 100,别用 -I。
引号被吃了两层:ssh、sudo、find -exec sh -c
# 本地 shell 先把 $CACHE_DIR 展开,远程收到的是展开后的字符串
ssh web01 "rm -rf $CACHE_DIR/*"
# 想让它远程展开,用单引号包住整条命令
ssh web01 'rm -rf "$CACHE_DIR"/*'
第一行的危险在于 $CACHE_DIR 在本地可能是空的,于是远程执行 rm -rf /*。第二行要求 CACHE_DIR 在远程 shell 里有定义——注意非交互 shell 未必加载 ~/.bashrc,写进 ~/.profile 或直接在命令里赋值更稳。
文件名带空格时更麻烦,得让本地拼出来的字符串在远程被当成一个整体:
FILE='My Report 2024.pdf'
ssh web01 "cat $FILE" # 远程执行:cat My Report 2024.pdf
ssh web01 "cat '$FILE'" # 文件名里有单引号就炸
ssh web01 "cat $(printf '%q' "$FILE")" # 让 shell 自己生成转义,稳
printf '%q' 是 bash 内建,输出一种“再喂给 shell 还能还原”的形式。任何要拼进另一个 shell 的变量,都该过一遍它。
find -exec sh -c 有一模一样的问题,还多一个坑:
# 错:文件名成了 $0,$1 是空的
find . -name '*.log' -exec sh -c 'gzip -9 "$1"' {} \;
# 对:第一个参数是 $0 的占位符,习惯上写成 _
find . -name '*.log' -exec sh -c 'gzip -9 "$1"' _ {} \;
sh -c 'script' 后面的第一个参数会变成 $0,第二个才是 $1。省掉 _ 时 {} 占了 $0,$1 为空,gzip -9 "" 直接报错。
sudo 同理:sudo 后面的命令由本地 shell 解析,sudo rm -rf $DIR/* 里的 $DIR 和 * 都在本地就展开了,提权上去的 root 根本没参与这次展开。
加了引号反而不对的地方
[[ ]] 的模式匹配,右侧不能加引号:
f=a.log
[[ $f == *.log ]] && echo match # match
[[ $f == "*.log" ]] || echo nomatch # nomatch,加了引号变成字面比较
[ ] 里正好相反,但错得更隐蔽:
cd /tmp && touch a.log
f=a.log
[ "$f" = *.log ] && echo same # 打印 same —— 但这是巧合
*.log 没被引号保护,会先做路径展开。当前目录恰好有 a.log,于是变成 [ "a.log" = a.log ],成立。换个没有 .log 文件的目录,*.log 保持字面量,又不成立。同一行代码的行为取决于当前目录里有什么文件,这种 bug 最难查,因为它在你机器上永远是好的。
数组的 @ 和 *:
files=("a.txt" "b c.txt" "d*.log")
for f in ${files[@]}; do echo "<$f>"; done # b c.txt 被拆开,d*.log 被展开
for f in "${files[@]}"; do echo "<$f>"; done # 正确,三个参数原样
printf '%s\n' "${files[*]}" # 用 IFS 首字符拼成一个字符串
规则:"${arr[@]}" 是“每个元素一个参数”,"${arr[*]}" 是“拼成一个字符串”,$@ / $* 同理。参数转发永远写 "$@":
log() { printf '[%s] %s\n' "$(date +%T)" "$*"; } # 只做日志拼接,$* 可以
wrap() { some_cmd --flag "$@"; } # 转发参数,必须 "$@"
边界条件:- 开头、换行、以及 set -e 的脾气
以 - 开头的文件名会被当成选项:
touch -- '-rf'
f='-rf'
rm "$f" # rm: invalid option -- 'r'
rm -- "$f" # 对
rm "./$f" # 也对,但后续改名/移动逻辑要跟着改
-- 是 POSIX 约定的“选项结束”标记,rm、cp、mv、grep、xargs 都支持。写清理脚本时不用想,无脑加。
文件名里带换行,NUL 分隔能处理,行式工具不能:
touch $'a\nb.txt'
find . -maxdepth 1 -name '*.txt' | wc -l # 2,实际只有 1 个文件
find . -maxdepth 1 -name '*.txt' -print0 | tr -cd '\000' | wc -c # 1
set -e 也不是万能的:
set -e
if grep -q foo file; then :; fi # if 条件里 set -e 不生效,正常
grep -q foo file || true # || 右边同理
n=$(grep -c foo file) # 这里会退出:grep 没匹配到返回 1
pipefail 配上 head 会直接要命:
set -o pipefail
find / -name '*.log' 2>/dev/null | head -5 # 退出码 141,脚本当场结束
head 读够 5 行就关管道,find 收到 SIGPIPE,退出码 141,pipefail 把最右边的非零码抛上来。规避办法:
find / -name '*.log' 2>/dev/null | head -5 || [ $? -eq 141 ]
或者干脆别在 set -e 的脚本里用裸的 | head。
还有一个常被忽略的:命令替换会吞掉尾部所有换行。
out=$(printf 'a\n\n\n')
printf '%s' "$out" | wc -c # 1,不是 4
要保留就 out=$(cmd; printf x); out=${out%x}。
收尾:脚本头 + 一份照着删的清单
#!/usr/bin/env bash
set -euo pipefail
# 必需变量:为空或未定义直接死
: "${UPLOAD_DIR:?}"
main() {
local root=${1:?用法: $0 <目录>}
[ -d "$root" ] || { echo "目录不存在: $root" >&2; exit 1; }
# ...
}
main "$@"
有人喜欢在脚本头写 IFS=$'\n\t',这样 for f in $(cmd) 不会被空格劈开。代价是 read 的默认分隔符变了、"$*" 的连接符也变了,旧代码里到处用逗号分隔的地方会悄悄改行为。我的建议是别动全局 IFS,老老实实每个变量都加引号。
提交前跑这三条:
shellcheck -S warning *.sh # SC2086 (Double quote to prevent globbing and word splitting) 必须清零
bash -n deploy.sh # 只做语法检查,不执行
bash -x deploy.sh # 执行时打印每一步展开后的真实参数
清单:
- [ ] 每个变量展开写成
"$var",例外只有[[ ]]的==右侧和=~右侧 - [ ] 遍历文件名用
while IFS= read -r -d ''+-print0,或者find -exec {} + - [ ]
rm/cp/mv后面跟了-- - [ ] 拼进
ssh/sudo/sh -c的变量走了printf '%q' - [ ] 参数转发写的是
"$@",不是$@也不是"$*" - [ ]
set -e的脚本里没有裸的| head - [ ] shellcheck 的 SC2086 是零