写了快十年的Shell脚本,我发现最尴尬的时刻不是脚本报错,而是别人突然问我某个语法到底该怎么写,我居然要现场翻历史命令才能确认。Shell语法的特点是:核心概念不算多,但每个细节都能变着花样坑人。今天这篇速查手册,就是把我平时写脚本真正用得上、用得勤的语法整理出来——变量、引号、通配符怎么处理,if、case、for、while怎么写,管道重定向和退出码怎么配合,以及那些你迟早会踩的经典坑。适合刚入门的同学通读一遍,也适合写了几年脚本的人放在手边当索引。
1. 变量、引号与通配符:先搞懂Shell的"分词"机制
Shell语法里最容易出问题的部分,其实不在if和for,而在变量展开、引号和通配符的组合方式。很多人调试半天,最后发现就是少了一对双引号。所以这一章我建议按"分词"来理解:Shell在拿到一个命令行时,会先做变量替换、命令替换、路径名展开,再按IFS(默认是空格、Tab、换行)把结果切成一个个词,最后才执行命令。引号的作用就是阻断这个分词过程。
1.1 变量赋值与取值:$符号到底放在哪
先说最基础的赋值。name=value,等号两边绝对不能有空格,否则Shell会把name当成一个命令去执行。这个错误新手几乎必踩,写过两年的人偶尔也会在if [ ... ]里犯类似的毛病,后面专门讲。
取值用$name或${name}。大多数情况下两者没区别,但当变量名后面紧跟字母、数字或下划线时,必须用花括号限定边界:
prefix="hello" echo $prefix_world # 空,因为Shell把prefix_world当成一个变量 echo ${prefix}_world # hello_world位置参数也是一个重点。$1到$9是第1到第9个参数,第10个起必须写成${10},否则会被解析成$1后面跟一个字符串0。脚本名本身是$0,参数个数是$#,全部参数是$@或$*。
我每次写脚本都会用到一组特殊变量,整理成表:
| 变量 | 含义 | 记忆要点 |
|---|---|---|
$0 | 脚本名 | 注意不是第一个参数 |
$# | 参数个数 | 判断是否传参 |
$@ | 所有参数,各成独立词 | 加引号用"$@"最安全 |
$* | 所有参数,合并成一个字符串 | 和$@的区别在双引号下才体现 |
$? | 上一条命令的退出码 | 用完立即保存,否则会被覆盖 |
$$ | 当前Shell的PID | 常用于生成临时文件名 |
$! | 最近一个后台进程的PID | cmd &之后马上取 |
$- | 当前Shell的启用选项 | 可以判断是否交互式 |
关于$@和$*,我直接说结论:遍历参数时永远用"$@"。如果参数里有空格,"$*"会把它们合并成一个带空格的字符串,"$@"会保持参数边界,这是复制参数列表最稳妥的方式。
赋值时还有个常见需求是设置默认值,Shell提供了简短语法:
# 变量为空时用默认值,但不改变原变量 echo "${VAR:-default}" # 变量为空时赋默认值,并且重新赋值给VAR echo "${VAR:=default}" # 变量为空时报错退出,适合关键变量 echo "${VAR:?VAR is not set}" # 变量非空时用替代值,为空则什么都不输出 echo "${VAR:+replacement}"第三行的:?写法在自动化脚本里非常有用。比如某个脚本必须依赖OUTPUT_DIR,直接写: "${OUTPUT_DIR:?}",变量没设置就立刻报错,比后面用到时才发现问题早得多。
还要注意作用域。普通变量默认是全局的,但子进程不是子Shell,分清楚这两点:( ... )括号、管道两侧、命令替换$(...)都会启动子Shell,子Shell里改变量不会影响父Shell;而export导出的环境变量会传给子进程,但子进程修改它也不会反向影响父进程。这是很多人调试半天变量的根源。
1.2 引号家族:单引号、双引号、命令替换
引号本质上是在控制Shell对文本的展开行为。我习惯把它们分成三个等级:
- 无引号:变量展开、命令替换、路径名展开全部参与,结果还会继续分词。这是最"危险"的等级。
- 双引号:变量展开和命令替换仍然进行,但分词、路径名展开被禁止。绝大多数参数都应该加双引号。
- 单引号:所有展开都停止,里面的字符全部按字面处理。需要原样输出时用。
先看一个最典型的例子:
path="/home/user/my docs" cd $path # 报错,路径被拆成两个参数 cd "$path" # 正确$path无引号时会被拆成/home/user/my和docs两个词,cd自然找不到目录。这个规律几乎适用于所有场景:变量如果可能包含空格、换行、通配符,就要用双引号包起来。
命令替换有两种写法:反引号\cmd`和$(cmd)。我强烈建议用后者。反引号的转义规则很繁琐,嵌套时需要一层层加反斜杠,而$()`可以干净地嵌套:
echo "今天是 $(date +%F)" echo "路径为 $(dirname "$(pwd)")"注意$()内部的引号是独立的,不需要在外面再加一层转义,这是比反引号舒服太多的地方。
单引号里没法直接转义单引号,如果确实需要在单引号字符串里包含单引号,可以用拼接的方式:'abc'"'"'def',表达式会拆成三段,最终得到abc'def。但实际上大多数场景用双引号加\"反而更可读。
双引号和命令替换配合时有个容易忽略的点:双引号内的命令替换结果不会再次分词,但命令替换内部的展开已经完成了。比如:
echo "$(ls)"如果当前目录有文件名带空格,$(ls)输出的空格不会在双引号里造成二次分词,所以输出还是一行完整的文件名列表。但如果你写成echo $(ls),文件名里的空格就会被当成分隔符,输出结果就乱了。这就是加不加双引号在命令替换上的区别。
1.3 通配符展开:*、?、[]和{}的边界问题
通配符(glob)是Shell层面的文件名匹配,不是为了匹配文本。常见的有:
*:匹配任意长度字符串,但不匹配以.开头的隐藏文件?:匹配任意单个字符[abc]:匹配方括号内的任意一个字符[a-z]:匹配范围[!a-z]:排除范围
这里最需要注意的反直觉点是:*不会匹配隐藏文件。*.txt不会匹配.hidden.txt,因为路径名展开对.开头的文件有特殊处理。要匹配隐藏文件需要用.[!.]*(匹配第一个点是.、第二个字符不是.,这样能避开.和..)。
通配符和正则表达式的*含义完全不同,这算得上一个高频混淆点。Shell通配符的*是"任意字符串",而正则的*是"前一个字符重复任意次"。
大括号展开{}是另一个机制,它不依赖文件是否存在,纯粹生成文本组合:
echo {a,b,c} # a b c echo {1..5} # 1 2 3 4 5 cp app.{conf,bak} /tmp # 等价于 cp app.conf app.bak /tmpls {jpg,png}/*.png这类写法非常常用,能避免重复输入目录路径。
通配符展开结果不再进行二次分词,这是一个很重要的细节。也就是说:
for file in *.txt; do echo "$file" done即使文件名是my file.txt,通配符*.txt作为一个整体展开后会生成一个词my file.txt,不会被空格拆开。所以遍历文件时直接用通配符是安全的,反而用for file in $(ls *.txt)会出事,因为命令替换的结果会重新分词。这个坑后面专章展开。
如果某个命令不需要通配,可以临时关闭:set -f,或者用set -o noglob。在脚本里处理用户输入的搜索词时,为了防止星号被展开成文件列表,我一般会先关掉glob再处理。
2. 条件判断与循环:写分支和循环前需要背下来的语法骨架
Shell的控制结构语法一眼看上去很唬人,其实骨架非常固定。只要记住"每个控制结构都要以fi、done、esac之类成对关键字结尾",就不太会写错。真正值得花时间的不是关键字本身,而是判断条件的写法差异。
2.1 if判断的三个关键差异:test、[ ]与[[ ]]
先理解一个本质:if后面跟着的是一条命令,判断依据是这条命令的退出码,而不是某个布尔表达式。所以if grep -q "error" log.txt; then是合法且常见的写法——grep找到匹配返回0,if就执行then分支。
条件表达式有两种主流写法。[ ]是test命令的等价形式,而[[ ]]是Bash的关键字。理解这个区别能解释很多怪问题:
[ ]内部每个部分之间必须有空格,因为[是一个命令,参数之间靠空格分隔。[[ ]]内部逻辑更宽松,不需要把变量加引号也能安全处理空值。[[ ]]支持&&、||、<、>等运算符,[ ]里写&&会被当成参数报错。[[ ]]支持正则匹配=~,这是[ ]做不到的。
字符串判断最常用的是-z(空字符串)、-n(非空)、=(相等)、!=(不等)。文件判断用-e(存在)、-f(普通文件)、-d(目录)、-r(可读)、-w(可写)、-x(可执行)、-s(非空文件)、-nt(较新)、-ot(较旧)。
一个综合示例:
if [[ -f "$config_file" && -r "$config_file" ]]; then echo "配置文件存在且可读" elif [[ -d "$config_dir" ]]; then echo "配置目录存在,但没找到文件" else echo "配置缺失" fi注意elif是else if的缩写,不是elseif,少写一个e是常见低级错误。
数值比较建议用(( )),里面可以直接写普通数学运算符:
count=5 if (( count >= 10 )); then echo "数量过多" fi # 等价于 if [ "$count" -ge 10 ]; then(( ))和[[ ]]一样是Bash语法,如果脚本要在sh下跑,就要退回[ ]加-eq、-ne、-gt、-ge、-lt、-le这些运算符。
2.2 case的匹配模式与常见用法
case是Shell里做字符串枚举匹配最舒服的语法,比一长串if判断清晰得多。基本结构:
case "$1" in start) echo "Starting..." ;; stop) echo "Stopping..." ;; restart|reload) echo "Restarting or reloading..." ;; *) echo "Usage: $0 {start|stop|restart}" >&2 exit 1 ;; esac几个要点:
- 右括号模式不用加引号,加了反而让它变成字面量。比如
"start")只会匹配带引号的字符串start,实际输入没有引号,就兜底到*)了。 - 一个分支可以写多个模式,用
|分隔,上面例子里的restart|reload就是。 - 分支里的模式支持通配符,
*.log可以匹配access.log,但不建议过度依赖。 *)是兜底分支,通常用来处理非法参数和打印用法。- 每个分支结尾的
;;不可省略,漏了会直接语法错误。
case在参数解析里特别顺手。比如写一个启动脚本的入口:
while [ $# -gt 0 ]; do case "$1" in --verbose|-v) VERBOSE=1 shift ;; --output|-o) OUTPUT="$2" shift 2 ;; *) echo "未知参数: $1" >&2 exit 1 ;; esac done这个模式配合shift,可以处理几乎所有命令行参数解析需求。比手写getopts更灵活,也更符合直觉。
2.3 for循环的三种写法与while/until的适用场景
for循环本质是遍历一个词列表。最简单的写法:
for day in 周一 周二 周三; do echo "今天是$day" done列表位置能放什么,取决于Shell做了哪些展开。通常有这几种:
- 变量:
for name in "$names",如果变量本身是一整个字符串,一般不是想要的。 - 通配符:
for file in *.log,这是遍历文件最安全的方式。 - 命令替换:
for file in $(find . -name "*.txt"),文件多时容易出问题,见第5章。 - 数组:
for item in "${arr[@]}",遍历数组成员最正确的姿势。 - 数字序列:
for i in {1..10}或seq 1 10。
数组遍历是很多人踩坑的地方。直接写for item in $arr,得到的是数组第一个成员,而不是全部成员。正确的写法是:
arr=("a b" "c" "d") for item in "${arr[@]}"; do echo "$item" done"${arr[@]}"保证每个数组成员作为独立词传给for,即使成员内部有空格也不会拆开。
C风格的for循环用于需要写步长或条件控制的场景:
for ((i=0; i<10; i++)); do echo "第 $i 次" done这个写法里变量名不需要加$,是bash的算术上下文,容易习惯就好。
while循环最常见的用途是逐行读取文件:
while IFS= read -r line; do echo "读取到: $line" done < "data.txt"这里的IFS=表示不把行首行尾的空格切掉,read -r表示不处理反斜杠转义。这两个参数几乎是按行读取的固定组合,别去掉任何一个。如果写成while read line,行尾反斜杠会被吞掉,行首空格会被清理,结果和你预期的原始文件内容有偏差。
until和while相反,条件为假时执行循环体,用得少,但有个场景很合适:等待某个服务就绪。
2.4 break、continue和shift:循环控制与参数挪移
break用来跳出循环,默认跳出一层,break 2可以跳两层。continue用来跳过本轮循环,直接进入下一轮。这两个控制在管道嵌套循环时非常有用:
for dir in */; do [ -d "$dir/.git" ] || continue # 不是git仓库就跳过 echo "处理仓库: $dir" doneshift左移位置参数。shift等价于shift 1,把$2变成$1,原来的$1就被丢弃。在参数解析循环里,每处理完一个参数就shift掉,循环条件用$#判断:
while [ $# -gt 0 ]; do echo "当前处理: $1" shift done这个语法在手工解析可选参数时比getopts更灵活,因为你可以根据参数的不同跳不同的步数:消耗一个参数就shift,消耗一个"键值对"参数就shift 2。配合case使用,就是上一节展示的参数解析模板。
3. 管道、重定向与退出状态:让命令边工作边通信
Shell脚本能自动化大量工作,核心在于一条命令的输出能喂给下一条命令,失败能及时被感知。这三样东西——重定向、管道、退出码,是理解Shell进程间通信的地基。
3.1 重定向完整速查:>、>>、<、<<、2>&1、/dev/null
重定向的本质是文件描述符(FD)的搬运。0是标准输入,1是标准输出,2是标准错误。常用符号:
| 写法 | 含义 |
|---|---|
cmd > file | 标准输出写入file,覆盖 |
cmd >> file | 标准输出追加到file |
cmd 2> file | 标准错误写入file |
cmd 2>&1 | 标准错误重定向到标准输出当前指向的地方 |
cmd > file 2>&1 | 标准输出和标准错误都到file |
cmd &> file | Bash专用,同上 |
cmd < file | 标准输入来自file |
cmd << EOF | Here Document,把下面直到EOF的内容作为输入 |
cmd <<< "$var" | Here String,把变量内容作为输入 |
2>&1的位置非常关键,这算是Shell语法里最经典的坑之一。看一下区别:
cmd >file 2>&1 # 正确:先让stdout指向file,再让stderr指向stdout的目标(file) cmd 2>&1 >file # 错误:先让stderr指向当前stdout(终端),再让stdout指向file第二条命令的结果是stdout去了file,stderr还是留在终端。你想要的"把错误也写进文件"没有发生。很多人写成第二种然后死活找不到日志,就是没理解重定向是立即生效的。
Here Document也是高频用法,写SQL、写配置文件都靠它:
cat > config.txt <<'EOF' server { listen 8080; root /var/www; } EOF这里的'EOF'加了单引号,意味着内容里的$var不会被展开;如果不加引号,Here Document内容里的变量和命令替换会正常展开。需要动态生成配置时用不带引号的版本,需要原样输出时用带引号的版本。
3.2 管道的执行模型与pipefail
管道符|把左边命令的输出接到右边命令的输入。这个模型看起来简单,但有两个关键点经常被忽略:
第一个关键点是管道两边的命令都在子Shell里执行。所以你在管道左边或右边修改变量,等管道结束后父Shell里的变量值不会变。看这个经典反例:
count=0 echo "a b c" | tr ' ' '\n' | while read -r line; do count=$((count + 1)) done echo "$count" # 输出0,count没变正确的修法有两种。一种是用进程替换,让while循环在当前Shell里执行:
count=0 while read -r line; do count=$((count + 1)) done < <(echo "a b c" | tr ' ' '\n') echo "$count" # 输出3另一种是干脆不用管道,把结果先存文件再读。性能差不多,但更直观。
第二个关键点是管道的退出码默认取最后一条命令的退出码。如果前面的命令失败了,后面的命令成功了,整个管道的退出码依然是0,这会导致外层set -e以为一切正常。解决办法是开启pipefail:
set -o pipefail cmd1 | cmd2pipefail开启后,管道退出码取所有命令中最大的失败退出码。加上set -e,只要管道里任何一个环节失败,脚本就会及时停止。
如果你的管道需要合并标准错误,可以用|&(Bash 4+):
cmd |& tee error.log这等价于cmd 2>&1 | tee error.log,把错误信息也交给tee复制一份。
3.3 退出码与set -e的正确使用方式
Shell里一切皆命令,命令执行完退出码就确定了。0表示成功,非0表示失败,范围是0到255。超出范围的数值会被取模,比如exit 300实际返回44。
$?能拿到最近一条命令的退出码,但它是"易耗品",下一条命令一执行就被覆盖:
grep -q "pattern" file.txt ret=$? # 立即保存 if [ $ret -eq 0 ]; then echo "找到了" fi如果不先保存,紧接着的if [ ... ]命令本身就会把$?覆盖掉,后面想检查等于什么都没查到。
&&和||本质上是根据退出码做短路判断:
mkdir -p "$OUTPUT_DIR" && echo "目录创建成功" grep -q "error" log.txt || echo "没有错误"mkdir成功才会执行后面的echo,一旦失败就跳到下一行。这个写法比到处写if要紧凑,但别滥用成不可读的一长串。
set -e的意思是:任何命令返回非0,脚本立即退出。这能避免一个失败之后继续往下执行造成更大破坏,但有几个坑:
if的条件命令、while/until的条件命令、&&和||的一部分,这些"被当作条件判断"的命令即使失败也不会触发退出。- 管道默认只看最后一条命令,所以
set -e可能会漏掉管道左侧的失败,需要配合set -o pipefail。 grep没找到匹配时返回1,这在很多场景下不是错误,但set -e会让脚本退出。常见修法是给这类命令加|| true,或者在命令后面显式处理。
我在生产脚本里的建议是:该用set -euo pipefail的时候用,但要充分理解它的触发边界。新人最容易踩的坑是"脚本在某个业务分支上莫名退出,去掉set -e就好了",那不是去不掉,而是没搞清哪个命令返回了非0。
4. 函数、内置命令与调试:让脚本从"能跑"变成"敢上线"
写脚本写到后面,真正的分水岭不是会不会写语法,而是有没有结构化拆分、有没有调试手段。函数和内置命令能提升可读性,调试技巧能砍掉一半排查时间。
4.1 函数定义、返回值与local变量的正确姿势
函数定义有两种写法:
# 推荐,可读性好 my_func() { echo "hello" } # 老式写法,别混用 function my_func { echo "hello" }函数有自己独立的参数列表,$1、$2在函数内部代表传给函数的参数,而不是脚本的原始参数。函数可以用return返回0到255的整数,用来表示成功或失败:
check_file() { if [[ -f "$1" ]]; then return 0 else return 1 fi } if check_file "$config"; then echo "配置存在" fi如果想从函数返回一个字符串,办法是echo输出,然后在函数外面捕获:
get_base_dir() { echo "$(dirname "$1")" } BASE_DIR=$(get_base_dir "/a/b/c.txt")这个模式非常常用,本质上是把函数当成一个"子命令"。
函数里变量默认是全局的,这会导致污染。几乎每个脚本都用local声明局部变量:
process_file() { local file="$1" local base="${file%.txt}" echo "$base" }不加local的话,函数里改一个变量,脚本主流程的同名变量也会被改,排查起来非常痛苦。我的习惯是:函数内变量全部加local,除非我明确想修改一个全局状态。
4.2 常用内置命令快查:cd、read、exec、trap等
Shell内置命令是跟随Shell进程执行的,不新起子进程。常用的一套我列在这里,每一项都配一句典型用法:
# cd:切换目录,-表示回到上一个目录 cd /var/log cd - # read:从标准输入读一行,-r禁止转义,-p输出提示,-n限制字符数 read -r -p "请输入名字: " name # exec:用新命令替换当前Shell进程,或者改变当前Shell的重定向 exec grep "foo" file.txt exec 3> logfile # 给当前Shell的fd3挂一个文件 # trap:捕获信号或者退出事件,做清理 trap 'rm -f "$tmpfile"' EXIT # source/.:在当前Shell里执行脚本文件,让变量和函数留在当前环境 source /etc/profile . ./common.sh # export:导出环境变量,让子进程能继承 export PATH="$HOME/bin:$PATH" # shift:前面已讲,参数左移 shift 2 # set:设置Shell选项 set -u # 未定义变量直接报错 set -e # 命令失败即退出 set -x # 打印每一条执行的命令 set -o pipefail # 管道失败传递 # : 永远返回0,常用于占位、初始化、创建空文件 : > log.txt : if condition; then :; else echo "false"; fi # eval:把字符串当作命令执行,危险但偶尔用得上,慎用 eval "$cmd_str"trap是生产脚本的灵魂。最简单的清理用法:
tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT脚本无论正常结束还是中途被信号打断,都会执行EXIT陷阱里的清理命令,临时文件不会留到第二天。
exec用得最少,但理解它很重要。exec会替换当前Shell进程,所以exec之后的代码不会执行。它还有一绝技:给脚本内部的整体重定向。你可以写:
# 脚本所有后续输出都写进日志 exec > "$LOGFILE" 2>&1后面的命令不再需要逐个追加重定向。这个技巧在批量任务里特别省事。
4.3 调试三板斧:bash -n、bash -x和set -u
写脚本时最常见的两种错误是语法错误和逻辑错误,各有各的排查手段。
语法错误用bash -n快速检查,只做语法解析,不真正执行:
bash -n myscript.sh如果某行语法有问题,会直接报错并给出行号,不会误伤系统。这条命令我几乎每改完一段脚本就会跑一次,比肉眼检查可靠得多。
逻辑错误用bash -x跟踪执行:
bash -x myscript.sh执行时每一行都会先打印展开后的命令,前面带一个+号。看到具体的展开结果,很多问题当场就清楚了——比如变量为什么变成空字符串、if条件为什么走到了错误分支。如果只想跟踪脚本片段,可以在脚本内局部开启和关闭:
set -x # 这段会被完整跟踪 set +xPS4可以自定义bash -x的提示前缀,比如带上行号:
export PS4='+${LINENO}: '这样跟踪输出会显示每条命令所在行号,定位问题快很多。
set -u是另一个强力选项。开启后,任何未定义变量的引用都会直接报错,而不是静默当成空字符串。很多人写的脚本里藏着"变量名拼错"的问题,正是靠set -u在开发阶段暴露出来的。
此外强烈建议跑一遍shellcheck做静态检查。它不是内置工具,需要单独安装,但它能查出大量"语法没问题但逻辑有隐患"的问题,比如漏引号、变量赋值错误、不兼容sh等。很多团队把shellcheck当作shell脚本的lint工具,确实值得。
5. Shell脚本里那些让人怀疑人生的坑与排查链路
光讲语法不讲坑等于白讲。下面这些坑都是我在真实项目里遇到过、而且不止一次遇到过的。每个坑都不是孤立的,背后都对应某条语法规则。
5.1 变量不加引号被"二次加工"
这个坑源于Shell的分词机制。变量展开后如果没有引号保护,结果会被按照IFS(默认空格、Tab、换行)切分,同时对出现的*、?、[等字符再做一次路径名展开。
最典型的例子:
dir="/tmp/my files" rm -rf $dir/*这里$dir被拆成/tmp/my和files/*,于是你原本想删/tmp/my files目录下的东西,实际上把/tmp/my和files/*当成了两个路径。如果当前目录恰好有个files目录,里面文件就遭殃了。这个坑破坏性极大,因为命令执行前不会询问。
也就是说:只要变量里存的可能包含空格或通配符,使用时就加双引号,路径判断、命令参数、循环列表都是一样的规则。唯一例外是想故意让分词的场景,但这种场景比想象中少得多。
5.2 if判断空格消失:unary operator expected
[命令要求它的每个参数之间有空格。写成:
if [$var == "ok"]; then会直接被Shell解析成命令[... ],报错。正确写法是:
if [ "$var" = "ok" ]; then还有个更隐蔽的坑:如果$var为空,[ "$var" = "ok" ]仍然安全,但如果写[ $var = "ok" ],展开后变成[ = ok ],[会认为第一个参数=前面缺了什么,报[: =: unary operator expected。这个过程用bash -x一看就明白。所以条件判断里的变量一定要加引号。
5.3 bash和sh的兼容性坑
很多服务器默认的/bin/sh并不是bash,而是dash。在Ubuntu/Debian系的系统上,sh是dash的符号链接。[[ ]]、(( ))、数组、source这些bash特性在dash下全部不存在,脚本运行时直接语法错误。
最常见的问题是把脚本写成bash风格,但执行方式却是sh myscript.sh,或者shebang写的是#!/bin/sh。
建议:
- 自己写脚本时,shebang明确写
#!/usr/bin/env bash,避免系统差异。 - 用
bash script.sh执行,而不是sh script.sh执行。 - 如果必须兼容
sh,就别用[[ ]]和(( )),退回[ ]和-gt等写法,也别用数组。
一个快速检查方法是看当前Shell的解析器:
ls -l /bin/sh readlink -f /bin/sh # 很可能是 dash这个问题排查起来很隐蔽,因为你在本地是bash环境跑得好好的,部署到服务器上就报语法错误,第一反应往往是"脚本没问题啊"。
5.4 for循环文件名带空格的经典翻车现场
这个坑我之前提过,但值得再展开一次。不少人写脚本遍历文件用的是命令替换:
for file in $(find . -name "*.txt"); do echo "$file" done如果找到的文件叫my notes.txt,$(find ...)的输出会被Shell重新分词,my和notes.txt变成两个独立的词,for循环就遍历到了两个错误文件。
正确写法优先用通配符:
for file in *.txt; do echo "$file" done通配符展开的结果不会二次分词,所以带空格的文件名也能被完整保留。
如果必须用find,比如要递归搜索,推荐这个模式:
while IFS= read -r -d '' file; do echo "$file" done < <(find . -name "*.txt" -print0)-d ''让read以空字符作为分隔符,-print0让find用空字符分隔文件名,这是唯一能安全处理文件名里所有奇怪字符(空格、换行、引号)的方式。注意用了进程替换<(...)而不是管道,是为了避免子Shell导致循环内修改变量失效。
5.5 一个真实的排查链路:脚本"看起来没执行"到底发生了什么
有一次同事跑部署脚本,反复说"脚本没执行成功,连日志都没打出来"。脚本开头是:
echo "开始部署" set -e if grep -q "maintain_mode=on" /etc/app.conf; then echo "进入维护模式,跳过部署" exit 0 fi实际运行结果什么都没有。我用bash -x跟踪,发现第一行echo确实执行了,但输出被重定向了;set -e之后,grep查找配置文件里的关键字失败返回1,因为if语句的测试条件在set -e下是豁免的,所以脚本没有立即退出,问题出在下一段某个赋值命令。
继续往下跟踪才发现,有一行ret=$(curl -s http://... )返回了非0,被set -e指挥,脚本直接退出。由于标准输出已经被前一步的重定向写进日志文件,所以终端上什么都看不到,看起来就像"脚本没执行"。
排查链路整理出来就三步:
- 先用
bash -n排除语法问题。 - 再用
bash -x跟踪执行,看命令实际展开成什么样、走到哪个分支。 - 检查退出码和
set -e、pipefail的交互:哪个命令返回了非0,返回1是不是真的代表业务失败。
这套链路能解决绝大多数"脚本行为不符合预期"的问题。很多你觉得玄学的Shell问题,最后都会落到变量展开、退出码、子Shell这三件事上。
这篇速查先写到这。说实话Shell语法没有太多需要死记的东西,真正值得背下来的反而是那几条"为什么"——为什么要加引号,为什么管道里改变量没用,为什么set -e有时会在不该退出的地方退出。我自己现在写脚本,开头固定写set -uo pipefail(-e看场景),变量只要可能涉及路径就加双引号,写完先跑一遍bash -n和shellcheck。坚持这些习惯之后,出错率会明显降下来。你如果也在整理自己的排错清单,建议按"词法展开、控制结构、进程通信、调试工具"四个维度归拢,比按命令名逐个记要顺手得多。