Shell流程控制完全指南:if、for、while、case进阶与避坑实战
2026/9/10 9:12:30 网站建设 项目流程

写Shell脚本这件事,我一直觉得最难的不是记住某个命令的用法,而是把零散的命令组织成一段有逻辑、能复用的代码。而组织逻辑靠的就是流程控制——if-else、for、while、case这四板斧。你去看任何一份有点规模的Shell脚本,不管它是部署脚本、日志清理脚本还是服务管理脚本,骨架几乎都是这四个结构在撑。

这篇教程我打算用保姆级的方式把这些结构一次讲透。不是给你丢一堆语法,而是从“为什么要这么写”开始,把常见写法、容易踩的坑、以及我实际排错时积累的经验全部揉进去。内容面向刚入门的Shell新手,也适合那些已经写了几个月脚本、但经常被各种诡异报错卡住的朋友。看完之后,你至少能独立写出结构清晰、健壮可用的脚本,而不是只会把命令一行行堆上去。

1. 先搞清楚:流程控制在Shell里到底解决了什么问题

很多人学Shell脚本是从echo "hello world"开始,但等到真正要写一个能用的脚本时,突然发现不会组织了。命令会敲,脚本却写不出来,缺的正是流程控制这个“骨架”。

我习惯把Shell脚本理解成流水线:每一条命令就是流水线上的工人,变量是工人之间传递的零件,而流程控制就是这条流水线的调度系统。没有流程控制,所有命令只能从上到下机械地执行一遍;有了流程控制,脚本才知道什么条件下做A、做B,做多少次,做到什么时候停。

具体来说,Shell里的流程控制解决三类问题:

  • 选择:满足某个条件时执行这段逻辑,否则执行另一段?对应if-elsecase
  • 重复:同一批操作要执行N次,或者直到某个状态发生改变?对应forwhile
  • 分支跳转:根据不同的匹配结果走不同的处理分支?对应case的多种模式匹配。

在动手写之前,有一个前置概念必须先理解:Shell的ifwhile判断的并不是“表达式的真值”,而是命令的退出码。退出码为0表示命令执行成功,非0表示失败。if后面的“条件”本质上是运行一条命令,然后看它的返回值。

if grep -q "error" /var/log/app.log; then echo "日志里发现了 error" fi

这里grep就是那个被运行的条件命令,它找到匹配时返回0,then后面的逻辑才会执行。理解了这一点,你再看任何Shell条件判断都会豁然开朗——为什么有人会在[ ]里加空格?因为[本身就是一个命令。为什么[[ ]]里能用&&[ ]里不行?因为[是个普通命令,&&在它那里是参数,而在[[ ]]里是bash内置的关键字。这些细节后面我会逐个展开。

学习路径上,我建议按if -> for -> while -> case的顺序来。if是理解“条件驱动”的基础,forwhile覆盖绝大多数循环场景,case则是在多分支场景下比if-elif-else更优雅的替代方案。

2. if-else条件判断:让脚本学会做决定

2.1 基本语法与执行逻辑

if的标准语法长这样:

if 条件命令; then # 条件成立时执行 elif 另一个条件命令; then # 上一个条件不成立,但这个条件成立时执行 else # 所有条件都不成立时执行 fi

有三个关键点必须记住:

  • then要和if放在同一行,用分号隔开;如果你写成if 条件换行后再写then,语法上其实是允许的,但可读性很差,我统一推荐if ...; then这种写法。
  • 结束符是fiif反过来写。很多新手漏掉这个fi,脚本直接报unexpected end of file
  • elif可以有多个,else最多一个,而且整个结构以fi收尾。

一个我常用的例子,判断传入脚本的参数个数:

#!/bin/bash if [ $# -eq 0 ]; then echo "用法: $0 <文件名>" exit 1 elif [ $# -eq 1 ]; then echo "收到一个参数: $1" else echo "参数太多了,我只用第一个: $1" fi

$#是特殊变量,代表参数个数。这个脚本先判断有没有参数,再判断是否只有一个参数,逻辑非常清晰,也是if-elif-else最典型的使用场景。

2.2 test、[ ] 和 [[ ]]:三兄弟到底怎么选

Shell里写条件判断,最绕不开的就是[ ][[ ]]。我的结论先放这里:写bash脚本优先用[[ ]],但前提是你确定脚本的解析器是bash而不是sh。

为什么?因为[其实是一个命令,它还有一个名字叫test。你写的[ 条件 ],本质是调用test这个命令,然后把条件当作参数传进去。既然是命令,它的参数之间就必须用空格分隔,所以[ "$a" = "$b" ]两边的空格一个都不能少,少了就会报command not found或者unexpected operator

[[ ]]是bash(以及zsh、ksh)的关键字,不是外部命令。它更智能:

  • 支持&&||做逻辑运算,而[ ]里只能用-a-o,可读性差还容易出歧义。
  • 支持=~做正则匹配,这是[ ]做不到的。
  • 变量即使没有加引号,也不会因为内容里有空格而被拆成多个词。

看个对比就明白了:

name="hello world" # [ ] 必须加引号,否则会拆词报错 if [ $name = "hello world" ]; then # 错误,$name 被拆成 hello 和 world echo "match" fi if [ "$name" = "hello world" ]; then # 正确 echo "match" fi # [[ ]] 不加引号也能正确处理 if [[ $name == "hello world" ]]; then echo "match" fi

[ ]还有存在的必要吗?有。如果你写的脚本要跑在POSIX sh环境下,或者你用了#!/bin/sh做shebang,就得用[ ],因为[[ ]]在纯sh环境下不可用。比如很多系统的sh其实指向dash,它就不支持[[ ]]。这是我实际踩过的坑:脚本本地用bash跑得好好的,扔到crontab里各种报错,最后发现是cron默认用的sh解析器。

我的建议是:确定只用bash就跑,用[[ ]];要兼容sh,就用[ ]并老老实实加引号。还有一种折中方案,脚本开头注明#!/bin/bash,然后放心用[[ ]]

2.3 数值、字符串、文件判断:最常用的判断场景

条件判断里具体比什么,我按三类总结,每类都是高频场景。

数值比较。注意这里不能用><,因为[ 3 > 2 ]会把>当成重定向符,生成一个名为2的文件。数值比较要用专门的运算符:

  • -eq:等于
  • -ne:不等于
  • -gt:大于
  • -ge:大于等于
  • -lt:小于
  • -le:小于等于
mem=$(free -m | awk '/^Mem:/ {print $7}') if [ "$mem" -lt 512 ]; then echo "可用内存低于512M,需要关注" fi

字符串比较。等于用===,不等用!=-z判断字符串为空,-n判断不为空。

if [ -z "$1" ]; then echo "第一个参数为空" fi if [[ "$mode" == "debug" || "$mode" == "test" ]]; then echo "当前是调试/测试模式" fi

有个经典坑:[ "$var" = 0 ]这种写法,如果$var是空的,在[ ]里会变成[ = 0 ],直接语法错误。所以在[ ]里一定要给变量加引号,或者改用[[ ]]。我自己的经验是:所有变量不仅在引用时加引号,在判断时也优先用[[ ]],能省掉一多半奇怪的报错

文件判断。这块在写运维脚本时用得最多:

  • -f:是否为普通文件
  • -d:是否为目录
  • -e:是否存在
  • -s:文件存在且非空
  • -x:是否有执行权限
  • -w:是否可写
  • -r:是否可读
  • -nt/-ot:是否比某个文件新/旧
if [ ! -f /etc/nginx/nginx.conf ]; then echo "nginx配置文件不存在,退出" exit 1 fi if [ -d "$backup_dir" ]; then echo "备份目录存在,开始备份" else mkdir -p "$backup_dir" fi

这里我特别想说一个习惯:条件判断里用到文件路径时,路径变量一定加引号,因为路径里一旦有空格,不加引号就是一场灾难。很多喜欢把文件名写成2024 report final(2).pdf的朋友,脚本跑挂了第一反应是“我的命令是不是写错了”,其实是引号问题。

2.4 单行写法:&& 和 || 的巧用

有些判断比较简单,不想写完整的if-then-fi,可以用&&||做单行短路逻辑。

# 命令成功则执行后面的 mkdir -p "$out_dir" && echo "目录创建成功" || echo "目录创建失败"

这里mkdir成功后执行&&后面的echo;失败则执行||后面的echo

这个写法很爽,但也埋着一个坑:command1 && command2 || command3并不是严格的 if-else。只有当command1失败时,command3才执行;如果command1成功但command2失败了,command3依然会执行,因为||只看它前面的整体退出码。换句话说,这条链的语义是“如果前面整段都成功,否则执行command3”,而不是“command1成功则command2,否则command3”。

更好的做法是遇到这种情况用if包一层,或者用函数封装。不过在一些极简场景,比如确认目录存在再进入:

[ -d "$dir" ] && cd "$dir" || exit 1

这个是安全的,因为cd失败的时候exit 1会执行,符合预期。写这种单行命令时,我建议始终保持清醒:你到底想要的是哪种逻辑,然后选对写法。

3. for循环:批量处理任务的万金油

3.1 最基础的for-in写法

for循环是Shell脚本里性价比最高的一种循环。它天生就是“遍历一个列表”,而这个列表可以来自各种地方:直接写出来的单词、命令的输出、通配符展开的结果、或者位置参数。

最基本的语法:

for 变量 in 列表; do # 循环体 done

举个例子,批量创建目录:

for dir in images css js fonts; do mkdir -p "assets/$dir" done

列表也可以是命令替换的结果:

for user in $(cat userlist.txt); do echo "创建用户: $user" done

但这里要注意一个经典坑:如果userlist.txt里的每一行是Zhang San这种带空格的名字,$(cat userlist.txt)会把ZhangSan当成两个独立的项,循环次数就会不对。后面我会在常见问题部分细讲这个。

3.2 C风格for循环:需要计数的场景

如果for-in解决不了计数场景,比如要循环10次,或者按步长递增,就可以用C语言风格的写法:

for ((i = 1; i <= 10; i++)); do echo "第 $i 次执行" done

语法和C语言几乎一模一样:初始化、条件、步进,三个表达式用分号分隔,整个结构放在双层括号(( ))里。支持步长:

# 从0到100,每次加10 for ((i = 0; i <= 100; i += 10)); do echo "当前值: $i" done # 倒序 for ((i = 10; i >= 1; i--)); do echo "倒计时: $i" done

这种循环适合需要精确控制的场景。比如你要生成一组带序号的文件名:

for ((i = 1; i <= 5; i++)); do touch "report_$i.txt" done

3.3 遍历文件与目录的实用写法

遍历文件是运维和开发脚本里最常干的活,Shell的通配符在这里发挥巨大作用。

for f in *.log; do echo "处理日志文件: $f" gzip "$f" done

这个写法背后的逻辑是:*.log会在执行前由Shell展开成当前目录下所有.log文件名,然后for逐个遍历。这是Shell最优雅的地方之一,不需要显式调用ls或者find

但也有个坑:如果当前目录下没有任何.log文件,*.log不会展开成空列表,而是保留字面量*.log,循环体还是会执行一次,处理一个不存在的文件。解决方法是先判断一下:

shopt -s nullglob for f in *.log; do echo "处理日志文件: $f" gzip "$f" done shopt -u nullglob

nullglob是bash的一个选项,开启后没有匹配项时通配符会展开成空列表,循环就不会执行。注意用完后关闭,因为它会影响同一Shell进程后续所有通配符行为。

如果要递归遍历子目录,可以用findwhile read,或者直接让for接收find的输出:

find . -name "*.tmp" -print0 | while IFS= read -r -d '' f; do echo "删除临时文件: $f" rm "$f" done

这个写法里-print0配合-d ''是为了解决文件名含换行或空格的问题,属于进阶但非常实用的技巧。后面讲while的时候我会再展开。

3.4 break、continue:循环的急刹车与跳过键

写循环时经常需要提前退出或者跳过本轮。break用于立即终止整个循环,continue用于跳过当前这一次,直接进入下一次迭代。

for ((i = 1; i <= 10; i++)); do if [ $i -eq 3 ]; then echo "跳过第3次" continue fi if [ $i -ge 8 ]; then echo "到达8,提前结束循环" break fi echo "当前: $i" done

breakcontinue后面还可以跟数字,表示跳出/跳过第几层循环。比如嵌套两层循环时break 2会直接跳出两层。这个用法不常见,但遇到了知道是这么回事就行。

一个实用例子:检查一组IP哪些能ping通,连通一个就停止:

for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do if ping -c 1 -W 1 "$ip" > /dev/null 2>&1; then echo "可用网关: $ip" break fi done

4. while循环:条件驱动与逐行处理

4.1 while与for的选型思路

很多刚开始学Shell的朋友搞不清楚forwhile到底怎么选。我的判断标准很简单:如果我知道要处理的具体清单,用for;如果我不知道有多少次,只知道“只要条件成立就一直做”,用while

换句话,for适合遍历已知列表,while适合“条件驱动”。比如等待某个服务起来,你不知道它需要3秒还是30秒,用for写个固定次数很别扭,用while就非常自然:

while ! curl -s http://localhost:8080/health > /dev/null; do echo "服务还没起来,1秒后重试..." sleep 1 done echo "服务已就绪"

这个例子同时也呼应第一节说的:while后面的也是一个命令,命令退出码为0就继续循环,非0就退出。curl请求成功返回0,失败返回非0,配合!取反,就实现了“直到请求成功才退出”。

4.2 逐行读取文件的正确姿势

while最常见的使用场景之一是逐行读文件。正确的标准写法是:

while IFS= read -r line; do echo "读取到: $line" done < input.txt

这里有几个容易忽略的细节:

  • read默认会按IFS(内部字段分隔符)对行内容做切分,把开头和结尾的空白吞掉。设置IFS=意思是把IFS设为空,这样行首行尾的空格能原样保留。
  • -r参数让read不把反斜杠当成转义符。如果文件里正好有个Windows路径C:\Users\name,没有-r的话\U会被当作转义。
  • 输入重定向< input.txt放在done后面,表示整个while循环从input.txt读取输入。

还有一个常见错误写法,是把管道接到while上:

cat input.txt | while read -r line; do echo "读取到: $line" done

这种写法看起来没问题,但实际有隐患:管道右边的while运行在子Shell里,循环内部定义的变量、对变量的修改,在循环结束后全部丢失。看这个例子:

count=0 cat input.txt | while read -r line; do count=$((count + 1)) done echo "总行数: $count" # 输出0,因为count的修改发生在子Shell里

解决办法有两个:一是用done < file的重定向写法,二是如果必须用管道,可以把结果通过进程替换搬到外层:

count=0 while read -r line; do count=$((count + 1)) done < <(cat input.txt) echo "总行数: $count"

< <(命令)是bash的进程替换,它看起来像文件,但不会启动子Shell包裹循环体,变量修改能正常保留。这个技巧我几乎每周都会用。

4.3 无限循环与进程守护场景

while true是Shell脚本里写无限循环的经典方式,另一种等价写法是while :。这里的:是Shell的内置空命令,什么也不做,永远返回0。

while true; do echo "监控中... $(date)" sleep 5 done

无限循环最常见的实际用途是写简单的进程守护。比如某个Java应用总挂,写个最简陋的守护脚本:

#!/bin/bash while true; do if ! pgrep -f "my-app.jar" > /dev/null; then echo "$(date) 进程不存在,重启服务" nohup java -jar /opt/app/my-app.jar > /var/log/my-app.log 2>&1 & fi sleep 10 done

这个脚本每10秒检查一次进程是否存在,不存在就拉起。虽然简陋,但作为临时方案足够了。注意pgrep -f匹配的是完整命令行,这里用-f是为了能按jar包名匹配。

while truefor (( ; ; ))等价,后者的(( ; ; ))是三个空表达式,也表示无限循环。但我写的时候永远用while true,可读性更好。

4.4 do-while的替代写法

C语言里有个do-while,特点是“先执行一次循环体,再判断条件”。Shell没有直接对应的语法,但可以用while true加条件break模拟:

while true; do echo "至少执行一次" read -p "继续吗?(y/n) " answer if [[ "$answer" != "y" ]]; then break fi done

这个模式经常用在交互式菜单、需要先做一次再决定是否继续的场景。break前面加条件判断,实际上充当了循环出口,逻辑非常清晰。

5. case语句:多分支匹配的利器

5.1 基本语法与匹配逻辑

当分支多了,if-elif-else会变得又臭又长,这时候case是最好的替代方案。它的语法是:

case 表达式 in 模式1) 命令 ;; 模式2) 命令 ;; *) 默认分支 ;; esac

几个关键点:

  • 表达式通常是变量,比如$1
  • 每个分支以)结尾,小写)是模式结束符。
  • 每个分支的命令块必须以;;结束,这是分支结束符,相当于其他语言里的break
  • *是默认分支,匹配所有没被前面分支捕获的情况,类似C语言switch里的default
  • 整个结构以esac结束,它是case倒过来写,和if/fi一个套路。

一个最基础的例子:

case "$1" in start) echo "启动服务" ;; stop) echo "停止服务" ;; restart) echo "重启服务" ;; *) echo "用法: $0 {start|stop|restart}" exit 1 ;; esac

这段代码比if [ "$1" = "start" ]写三个分支清爽得多,而且扩展新命令只需要加一个分支,不需要动其他逻辑。

5.2 模式匹配:通配符与多模式合并

case比常规switch强的地方在于,它支持Shell的通配符模式匹配。这意味着一个分支可以匹配一类值,而不只是精确值。

case "$1" in start|begin) echo "启动" ;; stop|end) echo "停止" ;; v*.log) echo "匹配以 v 开头 .log 结尾的参数" ;; esac

|用来合并多个模式,*匹配任意字符串,?匹配单个字符,[abc]匹配其中任意一个字符。这些和通配符规则完全一样。

基于这个特性,可以写出很优雅的分发逻辑。比如根据文件扩展名做不同处理:

case "$filename" in *.tar.gz|*.tgz) tar -xzf "$filename" ;; *.zip) unzip "$filename" ;; *.txt) echo "文本文件,直接查看" ;; *) echo "不支持的格式: $filename" exit 1 ;; esac

这里*.tar.gz就是通配符模式。注意case的匹配是自上而下、执行第一个匹配到的分支,所以通配符模式要放在更specific的精确模式后面。默认分支*一般放最后当兜底。

5.3 实战:服务管理脚本的完整实现

我写一个真实场景:CentOS/Ubuntu下经常要给内网部署的应用写启动脚本,用case管理start|stop|restart|status四个动作是标准玩法。

#!/bin/bash APP_NAME="my-app" PID_FILE="/var/run/${APP_NAME}.pid" LOG_FILE="/var/log/${APP_NAME}.log" start() { if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then echo "$APP_NAME 已在运行" return 0 fi echo "启动 $APP_NAME ..." nohup java -jar /opt/${APP_NAME}/${APP_NAME}.jar >> "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" echo "启动完成,PID: $(cat "$PID_FILE")" } stop() { if [ -f "$PID_FILE" ]; then pid=$(cat "$PID_FILE") kill "$pid" rm -f "$PID_FILE" echo "已停止 $APP_NAME (PID: $pid)" else echo "进程未运行或PID文件不存在" fi } status() { if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then echo "$APP_NAME 运行中 (PID: $(cat "$PID_FILE"))" else echo "$APP_NAME 未运行" fi } case "$1" in start) start ;; stop) stop ;; restart) stop sleep 2 start ;; status) status ;; *) echo "用法: $0 {start|stop|restart|status}" exit 1 ;; esac

这个脚本里我把每个动作封装成函数,case负责分发。kill -0是个冷门但实用的技巧,它不发送信号,只检查进程是否存在,用于状态判断非常合适。启动时把进程PID写到PID文件,停止时靠它找到进程,这是最标准的服务管理套路。

5.4 case与if-else的选型对比

什么时候用case,什么时候用if-elif-else?我的判断标准有三个:

  • 分支数量:3个及以上用case,1到2个用if
  • 匹配方式:需要做数值大小比较(比如$age -gt 18)、组合条件(&&||)时用if;做精确值或通配符模式匹配时用case
  • 可读性case的模式一目了然,扫描起来比一长串elif快得多。

举个对比。如果不用case,上面那个服务管理脚本的start|stop|restart|status就得写成一长串if,不仅代码行数翻倍,后续新增一个reload动作时还得在多个elif里找地方加。用case则永远只需要加一个分支。这不是“性能”问题,而是代码维护成本的问题。

另外,case支持通配符模式匹配,这在if里需要额外写正则、写循环才能实现,case天然就有。所以遇到“一类值走同一个分支”的场景,别犹豫,用case

6. 实战案例:批量日志清理脚本

6.1 需求定义与思路拆解

理论讲了这么多,我把它们串起来写一个能直接用的实战脚本——批量日志清理。这种脚本在运维场景里几乎天天需要,功能是:遍历指定目录下的日志文件,删除超过N天的文件,同时保留最近M个文件作为兜底。

拆解思路:

  • for遍历目录下的*.log文件。
  • if判断文件的修改时间和数量。
  • find配合-mtime统计过期文件。
  • case解析脚本参数,支持用户传入目录、天数、保留份数。
  • whilefor做保留最新N份的筛选。

6.2 完整脚本与逐段讲解

#!/bin/bash # 批量清理旧日志脚本 # 用法: ./clean_logs.sh -d /var/log/myapp -k 7 -m 10 LOG_DIR="/var/log/myapp" # 默认目录 KEEP_DAYS=7 # 默认保留7天内的 KEEP_COUNT=10 # 默认至少保留10份 # 使用 while getopts 解析参数 while getopts "d:k:m:h" opt; do case "$opt" in d) LOG_DIR="$OPTARG" ;; k) KEEP_DAYS="$OPTARG" ;; m) KEEP_COUNT="$OPTARG" ;; h) echo "用法: $0 -d 目录 -k 保存天数 -m 最少保留份数" exit 0 ;; *) echo "未知参数" exit 1 ;; esac done if [ ! -d "$LOG_DIR" ]; then echo "目录不存在: $LOG_DIR" exit 1 fi echo "清理目录: $LOG_DIR" echo "保留天数: $KEEP_DAYS" # 找出超过KEEP_DAYS天的日志文件 old_files=$(find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS") # for + if 完成删除与统计 deleted_count=0 for f in $old_files; do if [ -f "$f" ]; then echo "删除过期日志: $f" rm -f "$f" deleted_count=$((deleted_count + 1)) fi done # 保留最新KEEP_COUNT份日志文件 remaining_files=$(find "$LOG_DIR" -name "*.log" | sort) total=$(echo "$remaining_files" | wc -l) remove_count=$((total - KEEP_COUNT)) if [ "$remove_count" -gt 0 ]; then echo "$total 份日志,需要移除 $remove_count 份" echo "$remaining_files" | head -n "$remove_count" | while IFS= read -r f; do echo "按保留份数删除: $f" rm -f "$f" done else echo "日志数量未超过最少保留份数 $KEEP_COUNT" fi echo "清理完成,共删除 $deleted_count 份过期日志"

这段脚本把几个核心结构都串起来了:

  • while getopts "d:k:m:h"是参数解析的标准写法,getopts是bash内置命令,搭配case处理每个选项,比一个个$1$2判断健壮得多。
  • find ... -mtime +7找出修改时间超过7天的文件,+表示“大于”。
  • for f in $old_files遍历find的结果。这里有个隐含前提是日志文件名不含空格,如果你的环境可能有空格,建议用前面的find -print0while read -d ''的写法。
  • 保留份数的逻辑:把文件按名称排序(sort),计算超出的数量,用head -n取出最旧的N个进行删除。
  • deleted_count=$((deleted_count + 1))是算术运算的标准写法,注意等号两边不能有空格,$(( ))里不需要加$前缀。

这个脚本我已经在很多服务器上跑过,功能完全够用。你可以根据自己的日志命名规则调整find的条件,比如匹配.log.20240101这种带日期的文件名。

7. 常见问题与排查技巧实录

7.1 变量忘了加引号:空格引发的血案

我见过最多的Shell脚本Bug,就是变量引用没加引号。变量内容一旦包含空格,就会被Shell拆成多个词。最典型的一个:

file="my report.pdf" if [ -f $file ]; then # 展开后变成 [ -f my report.pdf ],其实是两个参数 echo "文件存在" fi

[ -f my report.pdf ]会尝试用myreport.pdf两个参数去执行-f判断,直接报unexpected argument。有时候更隐蔽:变量是空的时候,[ -f $file ]变成[ -f ]-f被当成一个非空字符串,条件反而为真,逻辑完全跑偏。

我的铁律是:所有变量在引用时都加双引号,除非你明确知道为什么要不加。像find "$LOG_DIR" -name "*.log"这种路径变量,不加引号在绝大多数服务器上没问题,但一旦某个目录名里有个空格,脚本就挂了。与其到时候排查,不如一开始就养成加引号的习惯。

7.2 sh与bash的差异:为什么本地跑没问题,cron里就报错

我当年踩过一个非常郁闷的坑:写好的脚本在终端执行一切正常,放到crontab里后,各种语法错误,比如[[ not foundlet: not found。原因就是cron执行脚本时默认用的/bin/sh,而在很多Linux发行版上/bin/sh指向的是dash而不是bashdash是POSIX shell的轻量实现,很多bash特性它都不支持。

排查思路很简单,ls -l /bin/sh看看它指向哪里。如果是dash,你的脚本里有[[ ]]${var,,}(大小写转换)、source等bash专属语法时,就会报错。

解决办法有两个:

  • 脚本第一行shebang写成#!/bin/bash,并且确保执行方式用的是./script.shbash script.sh,而不是sh script.sh
  • 如果必须兼容sh,就老老实实用[ ]写条件,不要用bash扩展语法。

写脚本头部的shebang时,我建议直接用#!/bin/bash,因为你既然用bash写,就让自己能享受bash的完整能力,不必为了“兼容POSIX”而自我阉割。但要注意,crontab里执行脚本时最好显式写bash /path/to/script.sh,因为cron的环境变量和交互式终端不一样,PATH可能都没有包含/usr/local/bin

7.3 DOS换行符问题:脚本莫名报错

如果你在Windows上用记事本或某些文本编辑器写过脚本,然后传到Linux上执行,可能会遇到/bin/bash^M: bad interpreter这种错误。原因就是Windows换行符是\r\n,Linux是\n\r被当成了脚本内容的一部分。

排查方法:用vim打开脚本,输入:set fileformat=unix再保存;或者用sed -i 's/\r$//' script.sh批量去掉行尾的CR字符。这也解释了为什么我前面在read循环里强调用-r选项,因为数据处理上游经常混入Windows格式的文件。

7.4 set -e 和“忽略错误继续执行”

很多严格的脚本会在开头加set -e,表示“一旦有任何命令返回非0,脚本立即退出”。这是个好习惯,能避免错误被忽略后继续执行导致更严重的后果。

set -e有个很多人不知道的细节:它不会在if的条件命令上生效。这是故意的,因为if本来就依赖条件命令的退出码来做判断,如果条件命令返回非0就退出,if就没法用了。同理,while的条件、&&||左边的命令,都不受set -e影响。

与之相反,如果你的脚本某个命令明确允许失败,不想让它触发退出,可以在这条命令后面加|| true

# 这个命令可能失败,但我不希望脚本退出 rm -f /tmp/cache/*.tmp || true

热搜里那个“shell忽略错误继续执行”,本质上就是在讲这个场景。set -e+|| true的组合,能让你在“安全退出”和“允许特定失败”之间找到平衡。

7.5 参数解析与shift命令

Shell脚本处理参数时,除了用$1$2这样的位置变量,还有一个实用的shift命令。shift的作用是把位置参数整体左移一位,$2变成$1$3变成$2,以此类推。它通常和while配合,用来逐个吃掉参数:

while [ $# -gt 0 ]; do case "$1" in -f) FILE="$2" shift 2 ;; -v) VERBOSE=1 shift ;; *) echo "未知参数: $1" exit 1 ;; esac done

这里shift 2表示一次左移两位,因为-f和它的值$2都被消费掉了。注意while [ $# -gt 0 ]这个写法,配合shift,就是一个经典的参数消费循环。不过实际写脚本时,能用getopts我一般优先用getopts,它更规范、支持选项参数组合,但shift的理解依然重要,因为在处理“位置参数和选项混合”的场景时它是绕不开的。

7.6 经典陷阱速查表

最后把这个领域最常见的坑整理成一张速查表,复制粘贴到你的笔记里,写脚本时对着看,能少踩很多坑:

坑点现象正确写法
[ ]里比较变量不加引号空格拆词、语法报错[ "$var" = "x" ][[ $var == "x" ]]
[ ]里用><比较数值产生重定向文件-gt-lt
忘记fiesacunexpected end of file写完后立即补上结束符
遍历文件没有匹配项通配符保留字面量开启nullglob或先判断
while read接管道循环内变量修改丢失done < file< <(cmd)
脚本在cron里报错dash不支持bash语法shebang写#!/bin/bash并显式用bash执行
Windows换行符\rbad interpreter转成Unix格式
set -e下允许单条命令失败脚本意外退出该命令后加|| true

写在最后的几个建议

教程到这里,核心内容已经全部讲完了。按我的经验,Shell流程控制不需要背语法,你只要动手写几个小脚本,自然会记住。我建议你从今天这两个练习开始:一是把文中的服务管理脚本改成管理你本地的一个进程,二是把日志清理脚本跑在一个测试目录里看看效果。写的过程中遇到报错不要慌,用bash -x script.sh调试运行,看每一条命令实际执行了什么,这是排查Shell脚本问题最有效的手段,没有之一。

还有一个习惯值得养成:写脚本时开头加上set -euo pipefail-e让脚本在出错时停止,-u让未定义变量直接报错,-o pipefail让管道中的任何一条命令失败都算整条管道失败。这三个组合能帮你把大部分低级错误挡在运行时之前。当然,加完它之后你再遇到if条件里变量没定义的情况,会先挨一记报错,这其实是好事,因为问题越早暴露,修复成本越低。

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

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

立即咨询