
写了快十年的Shell脚本我发现最尴尬的时刻不是脚本报错而是别人突然问我某个语法到底该怎么写我居然要现场翻历史命令才能确认。Shell语法的特点是核心概念不算多但每个细节都能变着花样坑人。今天这篇速查手册就是把我平时写脚本真正用得上、用得勤的语法整理出来——变量、引号、通配符怎么处理if、case、for、while怎么写管道重定向和退出码怎么配合以及那些你迟早会踩的经典坑。适合刚入门的同学通读一遍也适合写了几年脚本的人放在手边当索引。1. 变量、引号与通配符先搞懂Shell的分词机制Shell语法里最容易出问题的部分其实不在if和for而在变量展开、引号和通配符的组合方式。很多人调试半天最后发现就是少了一对双引号。所以这一章我建议按分词来理解Shell在拿到一个命令行时会先做变量替换、命令替换、路径名展开再按IFS默认是空格、Tab、换行把结果切成一个个词最后才执行命令。引号的作用就是阻断这个分词过程。1.1 变量赋值与取值$符号到底放在哪先说最基础的赋值。namevalue等号两边绝对不能有空格否则Shell会把name当成一个命令去执行。这个错误新手几乎必踩写过两年的人偶尔也会在if [ ... ]里犯类似的毛病后面专门讲。取值用$name或${name}。大多数情况下两者没区别但当变量名后面紧跟字母、数字或下划线时必须用花括号限定边界prefixhello 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))注意$()内部的引号是独立的不需要在外面再加一层转义这是比反引号舒服太多的地方。单引号里没法直接转义单引号如果确实需要在单引号字符串里包含单引号可以用拼接的方式abcdef表达式会拆成三段最终得到abcdef。但实际上大多数场景用双引号加\反而更可读。双引号和命令替换配合时有个容易忽略的点双引号内的命令替换结果不会再次分词但命令替换内部的展开已经完成了。比如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找到匹配返回0if就执行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是常见低级错误。数值比较建议用(( ))里面可以直接写普通数学运算符count5 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) VERBOSE1 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 ((i0; i10; 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 重定向完整速查、、、、21、/dev/null重定向的本质是文件描述符FD的搬运。0是标准输入1是标准输出2是标准错误。常用符号写法含义cmd file标准输出写入file覆盖cmd file标准输出追加到filecmd 2 file标准错误写入filecmd 21标准错误重定向到标准输出当前指向的地方cmd file 21标准输出和标准错误都到filecmd fileBash专用同上cmd file标准输入来自filecmd EOFHere Document把下面直到EOF的内容作为输入cmd $varHere String把变量内容作为输入21的位置非常关键这算是Shell语法里最经典的坑之一。看一下区别cmd file 21 # 正确先让stdout指向file再让stderr指向stdout的目标file cmd 21 file # 错误先让stderr指向当前stdout终端再让stdout指向file第二条命令的结果是stdout去了filestderr还是留在终端。你想要的把错误也写进文件没有发生。很多人写成第二种然后死活找不到日志就是没理解重定向是立即生效的。Here Document也是高频用法写SQL、写配置文件都靠它cat config.txt EOF server { listen 8080; root /var/www; } EOF这里的EOF加了单引号意味着内容里的$var不会被展开如果不加引号Here Document内容里的变量和命令替换会正常展开。需要动态生成配置时用不带引号的版本需要原样输出时用带引号的版本。3.2 管道的执行模型与pipefail管道符|把左边命令的输出接到右边命令的输入。这个模型看起来简单但有两个关键点经常被忽略第一个关键点是管道两边的命令都在子Shell里执行。所以你在管道左边或右边修改变量等管道结束后父Shell里的变量值不会变。看这个经典反例count0 echo a b c | tr \n | while read -r line; do count$((count 1)) done echo $count # 输出0count没变正确的修法有两种。一种是用进程替换让while循环在当前Shell里执行count0 while read -r line; do count$((count 1)) done (echo a b c | tr \n) echo $count # 输出3另一种是干脆不用管道把结果先存文件再读。性能差不多但更直观。第二个关键点是管道的退出码默认取最后一条命令的退出码。如果前面的命令失败了后面的命令成功了整个管道的退出码依然是0这会导致外层set -e以为一切正常。解决办法是开启pipefailset -o pipefail cmd1 | cmd2pipefail开启后管道退出码取所有命令中最大的失败退出码。加上set -e只要管道里任何一个环节失败脚本就会及时停止。如果你的管道需要合并标准错误可以用|Bash 4cmd | tee error.log这等价于cmd 21 | 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_strtrap是生产脚本的灵魂。最简单的清理用法tmpdir$(mktemp -d) trap rm -rf $tmpdir EXIT脚本无论正常结束还是中途被信号打断都会执行EXIT陷阱里的清理命令临时文件不会留到第二天。exec用得最少但理解它很重要。exec会替换当前Shell进程所以exec之后的代码不会执行。它还有一绝技给脚本内部的整体重定向。你可以写# 脚本所有后续输出都写进日志 exec $LOGFILE 21后面的命令不再需要逐个追加重定向。这个技巧在批量任务里特别省事。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_modeon /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。坚持这些习惯之后出错率会明显降下来。你如果也在整理自己的排错清单建议按词法展开、控制结构、进程通信、调试工具四个维度归拢比按命令名逐个记要顺手得多。