☰
Shell脚本语法速查:从变量、引号到管道与循环避坑指南
2026/10/6 3:21:33 网站建设 项目流程

写了快十年的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常用于生成临时文件名
$!最近一个后台进程的PIDcmd &之后马上取
$-当前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 /tmp

ls {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" done

shift左移位置参数。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 &> fileBash专用,同上
cmd < file标准输入来自file
cmd << EOFHere 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 | cmd2

pipefail开启后,管道退出码取所有命令中最大的失败退出码。加上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 +x

PS4可以自定义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指挥,脚本直接退出。由于标准输出已经被前一步的重定向写进日志文件,所以终端上什么都看不到,看起来就像"脚本没执行"。

排查链路整理出来就三步:

  1. 先用bash -n排除语法问题。
  2. 再用bash -x跟踪执行,看命令实际展开成什么样、走到哪个分支。
  3. 检查退出码和set -e、pipefail的交互:哪个命令返回了非0,返回1是不是真的代表业务失败。

这套链路能解决绝大多数"脚本行为不符合预期"的问题。很多你觉得玄学的Shell问题,最后都会落到变量展开、退出码、子Shell这三件事上。

这篇速查先写到这。说实话Shell语法没有太多需要死记的东西,真正值得背下来的反而是那几条"为什么"——为什么要加引号,为什么管道里改变量没用,为什么set -e有时会在不该退出的地方退出。我自己现在写脚本,开头固定写set -uo pipefail(-e看场景),变量只要可能涉及路径就加双引号,写完先跑一遍bash -n和shellcheck。坚持这些习惯之后,出错率会明显降下来。你如果也在整理自己的排错清单,建议按"词法展开、控制结构、进程通信、调试工具"四个维度归拢,比按命令名逐个记要顺手得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询