1. 引言:为什么流程控制是 Shell 脚本的"方向盘"
凌晨两点,运维小李被报警电话叫醒:一台生产服务器的磁盘使用率已经飙到 92%。他熟练地登录服务器,敲下几行命令清理了过期日志,然后打开一个月前写好的 Shell 监控脚本,确认脚本里"磁盘超过阈值就自动清理并告警"的逻辑已经生效,才放心地继续睡觉。这个脚本的核心,正是我们今天要讲的主角------条件判断与流程控制。
很多初学者以为 Shell 脚本就是"把命令一行行排在一起从上往下执行"。这种理解在任务简单时没问题,但一旦遇到真实场景------"日志目录超过 2GB 就压缩归档""批量重启 50 台服务器并在失败时重试""根据用户输入执行不同操作"------你很快会发现,没有 if、for、while、case 这些"方向盘",脚本就像一辆只会直行的车,稍微拐个弯就冲出跑道。
流程控制语句让脚本拥有了"思考"和"循环"的能力:它可以根据条件选择走哪条路,也可以不厌其烦地重复同一件事成千上万次。掌握了它们,你的脚本才真正从"命令清单"升级为"自动化程序"。
本文将从最基础的条件测试讲起,逐步深入 if、for、while、case 四大核心语句,配合大量可直接运行的示例和真实工作场景,帮你彻底打通 Shell 流程控制这一关。
2. 基础铺垫:test 命令与条件测试
在学 if 之前,必须先搞懂"条件"是怎么表达和判断的。Shell 中判断一个条件是否成立,靠的是 test 命令。
2.1 test 的两种写法
test 是一个专门用来做判断的命令,判断结果通过退出状态码 (exit code)返回:条件成立返回 0,条件不成立返回 1。这一点和大多数编程语言"真为 1、假为 0"的直觉相反,请务必牢记:Shell 中 0 代表成功/真,非 0 代表失败/假。
bash
test 10 -gt 5 # 判断 10 是否大于 5
echo $? # 输出 0,表示条件成立
test 有个更常用的等价写法------方括号 [ ]。注意,[ 其实是一个命令(没错,它和 test 是同一个命令的两个名字),所以左方括号后面、右方括号前面都必须有空格:
bash
[ 10 -gt 5 ]
echo $? # 输出 0
此外,bash 还提供了增强版的 [[ ]],它是 bash 的关键字(而非外部命令),功能更强、更安全,后文会详细对比。
2.2 常用的条件测试
条件测试主要分为三类,下面用表格汇总最常用的操作符。
数值比较:
| 操作符 | 含义 | 示例 |
|---|---|---|
-eq |
等于(equal) | [ "$a" -eq "$b" ] |
-ne |
不等于(not equal) | [ "$a" -ne "$b" ] |
-gt |
大于(greater than) | [ "$a" -gt "$b" ] |
-ge |
大于等于(greater or equal) | [ "$a" -ge "$b" ] |
-lt |
小于(less than) | [ "$a" -lt "$b" ] |
-le |
小于等于(less or equal) | [ "$a" -le "$b" ] |
字符串比较:
| 操作符 | 含义 | 示例 |
|---|---|---|
= 或 == |
字符串相等 | [ "$a" = "$b" ] |
!= |
字符串不相等 | [ "$a" != "$b" ] |
-z |
字符串长度为 0(为空) | [ -z "$a" ] |
-n |
字符串长度不为 0(非空) | [ -n "$a" ] |
文件测试:
| 操作符 | 含义 |
|---|---|
-e |
文件存在 |
-f |
是普通文件 |
-d |
是目录 |
-r / -w / -x |
可读 / 可写 / 可执行 |
-s |
文件存在且非空 |
-nt / -ot |
比另一个文件新 / 旧 |
2.3 条件组合
多个条件可以用逻辑运算组合起来:
bash
[ "$score" -ge 60 ] && [ "$score" -lt 100 ] # 与:两个都要成立
[ "$role" = "admin" ] || [ "$role" = "root" ] # 或:满足其一即可
[ ! -f /tmp/lock ] # 非:取反
&&(与):左边成立才执行右边||(或):左边失败才执行右边!(非):取反
注意事项 :
&&和||具有"短路"特性。A && B中如果 A 失败,B 根本不会执行;A || B中如果 A 成功,B 也不会执行。这个特性常被用来写简洁的"一行式"判断,比如[ -f config ] && echo "存在" || echo "不存在"。
2.4 [ ] 与 [[ ]] 的区别
刚开始写脚本时,很多人的报错都来自 [ ] 和 [[ ]] 用混。简单说:
[ ]是 POSIX 标准的 test 命令,兼容性最好,但语法要求苛刻(空格、引号一个都不能少)。[[ ]]是 bash 扩展,功能更强:支持&&/||直接写在括号内、支持=~正则匹配、通配符匹配,且不需要对变量加双引号也不会因变量为空而报错。
bash
#!/bin/bash
name=""
# 用 [ ] 判断空字符串,忘记加引号会报错
[ -z $name ] # 出错:一元操作符预期错误
# 用 [[ ]] 则安全得多
[[ -z $name ]] && echo "name 为空" # 正常输出
建议 :写 bash 脚本时优先使用 [[ ]] 做条件判断,既安全又强大;只有在追求跨平台 POSIX 兼容(如 dash、某些精简版 Linux)时才用 [ ]。
3. if 条件判断:让脚本学会"见机行事"
if 是流程控制的基石,它让脚本根据条件的真假选择不同的执行路径。你可以把它想象成十字路口的红绿灯:条件成立走绿灯方向,不成立走红灯方向。
3.1 语法格式
if 有三种基本形态。
单分支:
bash
if 条件; then
命令
fi
双分支:
bash
if 条件; then
命令1
else
命令2
fi
多分支:
bash
if 条件1; then
命令1
elif 条件2; then
命令2
else
命令3
fi
注意几点:
if和then写在同一行时,中间要用分号;隔开;也可以把then单独放下一行,此时分号可以省略。- 每个 if 必须以
fi结尾(fi 就是 if 倒过来写,帮助记忆)。 elif是 else if 的简写,一个 if 中可以有多个 elif。
3.2 示例一:判断成绩等级
bash
#!/bin/bash
read -p "请输入你的分数(0-100):" score
if [[ $score -ge 90 ]]; then
echo "优秀!继续保持!"
elif [[ $score -ge 80 ]]; then
echo "良好,还可以更进一步。"
elif [[ $score -ge 60 ]]; then
echo "及格,需要加油哦。"
else
echo "不及格,建议重新学习一遍基础知识。"
fi
示例输出:
请输入你的分数(0-100):85
良好,还可以更进一步。
3.3 示例二:判断文件类型(结合文件测试)
bash
#!/bin/bash
path="/tmp/demo"
if [[ -d $path ]]; then
echo "$path 是一个目录"
elif [[ -f $path ]]; then
echo "$path 是一个普通文件"
elif [[ -e $path ]]; then
echo "$path 存在,但不是普通文件或目录(可能是链接或设备)"
else
echo "$path 不存在"
fi
3.4 示例三:真实场景------磁盘空间告警
这个例子直接对应引言中的运维场景,建议动手跑一跑:
bash
#!/bin/bash
# 获取根分区使用率(去掉百分号)
usage=$(df -h / | awk 'NR==2{print $5}' | tr -d '%')
if [[ $usage -ge 90 ]]; then
echo "【严重】磁盘使用率 ${usage}%,请立即清理!"
elif [[ $usage -ge 80 ]]; then
echo "【警告】磁盘使用率 ${usage}%,建议关注。"
else
echo "【正常】磁盘使用率 ${usage}%。"
fi
示例输出:
【正常】磁盘使用率 42%。
注意事项:
- if 后面的条件本质上是一条命令,判断的是它的退出状态码,所以
if 命令; then也是合法的(比如if grep -q "error" logfile; then ...)。- 比较数值时一定要用
-gt、-lt这类专用操作符,不要用>、<------它们在[ ]里会被当作重定向符号,导致意想不到的错误。
4. for 循环:把重复的事情交给"复读机"
运维工作中有大量"批量操作"场景:批量创建用户、批量拷贝文件、遍历目录下的所有日志。for 循环就是为这类场景而生------它把一件事重复做 N 次,每次换一个不同的"元素"。
4.1 语法格式
for 有两种常用写法。
写法一:遍历列表(最常用):
bash
for 变量 in 列表; do
命令
done
列表可以是直接写出的值(用空格分隔)、一个变量的展开结果、命令的输出(如 $(ls))、通配符展开的文件名(如 *.log)等。
写法二:C 语言风格:
bash
for ((初始值; 条件; 步进)); do
命令
done
这种写法适合需要精确控制循环次数、有明确起始值和步长的场景,比如从 1 累加到 100。
4.2 示例一:遍历一个列表
bash
#!/bin/bash
for fruit in apple banana cherry; do
echo "我喜欢吃 $fruit"
done
示例输出:
我喜欢吃 apple
我喜欢吃 banana
我喜欢吃 cherry
4.3 示例二:批量处理文件(真实场景)
假设 /var/log/app 目录下有很多 .log 文件,要把它们统一压缩归档:
bash
#!/bin/bash
for file in /var/log/app/*.log; do
echo "正在压缩 $file ..."
gzip "$file"
done
echo "全部处理完成!"
注意事项 :如果目录下没有任何
.log文件,*.log不会被展开,循环体仍会执行一次,此时$file的值就是字面的/var/log/app/*.log。为避免这种尴尬,可以在循环开头判断文件是否存在:
bash
for file in /var/log/app/*.log; do
[[ -e $file ]] || continue # 文件不存在就跳过本次循环
gzip "$file"
done
4.4 示例三:C 风格 for 计算 1 到 100 的和
bash
#!/bin/bash
sum=0
for ((i=1; i<=100; i++)); do
sum=$((sum + i))
done
echo "1 到 100 的和是:$sum"
示例输出:
1 到 100 的和是:5050
4.5 示例四:批量创建用户
bash
#!/bin/bash
for user in zhangsan lisi wangwu; do
if id "$user" &>/dev/null; then
echo "用户 $user 已存在,跳过"
else
useradd "$user" && echo "用户 $user 创建成功"
fi
done
这个例子展示了 for 与 if 的组合使用:循环负责"遍历用户列表",if 负责"判断用户是否已存在"。流程控制语句可以任意嵌套,这是写出实用脚本的关键。
5. while 循环:不达目的不罢休的"哨兵"
for 循环适合"已知要执行多少次"的场景,而 while 循环适合"不知道几次,直到条件不满足为止"的场景。它像一个永不懈怠的哨兵,只要条件成立,就一直站在原地执行任务;条件不成立,它立刻收工。
5.1 语法格式
bash
while 条件; do
命令
done
只要"条件"成立(退出状态码为 0),循环体就会一直执行。因此循环体内部必须有能改变条件的语句,否则就会陷入死循环。
5.2 示例一:计数器循环
bash
#!/bin/bash
count=1
while [[ $count -le 5 ]]; do
echo "第 $count 次执行"
count=$((count + 1))
done
echo "循环结束,共执行了 $((count - 1)) 次"
示例输出:
第 1 次执行
第 2 次执行
第 3 次执行
第 4 次执行
第 5 次执行
循环结束,共执行了 5 次
注意 count=$((count + 1)) 这行------它是让循环"刹车"的关键。如果漏掉这行,脚本就会无限打印"第 1 次执行",只能按 Ctrl+C 强制中断。
5.3 示例二:逐行读取文件(真实场景)
while 配合 read 命令逐行读取文件,是处理配置、日志、名单的经典手法:
bash
#!/bin/bash
# 假设 ip_list.txt 每行一个 IP 地址
while read -r ip; do
echo "正在探测 $ip ..."
ping -c 1 -W 1 "$ip" &>/dev/null \
&& echo "$ip 在线" \
|| echo "$ip 不通"
done < ip_list.txt
< ip_list.txt 把文件内容重定向给 while 循环,read 每次读一行赋给变量 ip,读到文件末尾时 read 返回非 0,循环自动结束。
注意事项 :这里必须用
done < file而不是cat file | while read,虽然后者也能工作,但它会在子 shell 中运行循环体,导致循环内部的变量修改无法传递到循环外。初学者踩这个坑最多,务必记住。
5.4 示例三:无限循环与服务守护
while 也常用于写"常驻脚本",比如每隔几秒检查一次服务状态:
bash
#!/bin/bash
while true; do
if ! pgrep -x "nginx" &>/dev/null; then
echo "$(date):nginx 已挂,尝试拉起..."
systemctl start nginx
fi
sleep 10
done
while true 的写法表示"永远循环",全靠 sleep 10 控制节奏、靠外部 Ctrl+C 才能退出。这种结构常用于守护进程、实时监控脚本。
6. case 多分支选择:菜单脚本的"总开关"
当需要根据一个变量的多个取值来执行不同操作时,if-elif-elif-... 会越写越长、越写越难读。case 语句就是为"多选一"场景量身定做的------它像老式电话总机的插线板,把不同的"来电"(变量取值)转接到对应的"分机"(命令块)。
6.1 语法格式
bash
case 变量 in
模式1)
命令1
;;
模式2)
命令2
;;
模式3|模式4)
命令3
;;
*)
默认命令
;;
esac
语法要点:
- 每个分支以
模式)开头,以;;结尾(双分号表示分支结束)。 模式3|模式4中的|表示"或",匹配其一即可。*)是通配符,匹配所有其它情况,相当于 if 的 else,通常放在最后。- case 以
esac结尾(case 倒过来写)。
6.2 示例一:服务管理脚本(真实场景)
这是 case 最经典的应用------写一个服务启停脚本:
bash
#!/bin/bash
read -p "请输入操作(start/stop/restart/status):" action
case $action in
start)
echo "正在启动服务..."
systemctl start nginx
;;
stop)
echo "正在停止服务..."
systemctl stop nginx
;;
restart)
echo "正在重启服务..."
systemctl restart nginx
;;
status)
echo "当前服务状态:"
systemctl status nginx
;;
*)
echo "用法:$0 {start|stop|restart|status}"
;;
esac
示例输出:
请输入操作(start/stop/restart/status):restart
正在重启服务...
6.3 示例二:匹配模式(通配符的力量)
case 的模式匹配支持 Shell 通配符,这使它比 if 的等值判断强大得多:
bash
#!/bin/bash
read -p "请输入文件名:" filename
case $filename in
*.txt)
echo "这是一个文本文件"
;;
*.sh)
echo "这是一个 Shell 脚本"
;;
*.tar.gz|*.zip)
echo "这是一个压缩包"
;;
*)
echo "无法识别的文件类型:$filename"
;;
esac
注意事项:
- 每个分支结尾的
;;不能省略,否则会"穿透"到下一个分支继续执行(Shell 不像 C 语言那样默认贯穿)。- case 对变量的取值不加引号 也不会做单词拆分,但如果变量里可能包含空格或特殊字符,建议写成
case "$变量" in更稳妥。
7. 循环控制:break、continue 与 exit
循环有了,还需要"方向盘"和"急刹车"。break、continue、exit 就是控制循环流程的三件套。
| 命令 | 作用 | 类比 |
|---|---|---|
break |
立即跳出整个循环 | 直接下车结束行程 |
continue |
跳过本次循环,进入下一次 | 这一站不下车,继续开 |
exit |
退出整个脚本 | 终止整趟旅程 |
bash
#!/bin/bash
for i in {1..10}; do
if [[ $i -eq 3 ]]; then
continue # 跳过 3 不打印
fi
if [[ $i -eq 8 ]]; then
break # 到 8 直接跳出循环
fi
echo "当前数字:$i"
done
echo "循环提前结束"
示例输出:
当前数字:1
当前数字:2
当前数字:4
当前数字:5
当前数字:6
当前数字:7
循环提前结束
break 和 continue 还可以带数字参数,表示跳出/跳过几层 嵌套循环,例如 break 2 表示跳出两层循环。多层嵌套时这个技巧非常实用。
8. 知识扩展:Shell 脚本是如何执行起来的
理解了四大语句的用法后,不妨往底层看一眼:当我们敲下 ./script.sh 时,系统到底做了什么?理解这个过程,能帮你解释很多"玄学"报错。
- 解释执行,而非编译 。Shell 脚本是解释型语言,由
/bin/bash(或 shebang 指定的解释器)逐行读取、逐行解释执行。所以脚本里哪怕第 100 行有语法错误,前面 99 行也已经执行完了------这和编译型语言"一处报错、全部罢工"完全不同。 - 进程与子进程 。脚本运行时,bash 会 fork 出一个子进程来执行它。脚本里的每条外部命令又会在子进程下再 fork 出"孙进程"。而
if、for、while、case这些是 bash 的内建语法,由 bash 自己解释,不产生新进程,这就是它们比外部命令快的原因之一。 - test 命令的身世 。前面提到
[是 test 命令的别名。早期 Unix 系统中并没有[,人们只能写test 10 -gt 5。后来开发者觉得这种写法不够直观,就创建了一个[的硬链接指向 test,于是[ 10 -gt 5 ]诞生了。那个看似多余的右括号]其实只是个"装饰参数",test 并不真正使用它,但为了可读性和语法完整性,它被保留至今。而[[ ]]则是 bash 后来引入的关键字,它不再是外部命令,因此不需要 fork 子进程、支持更多高级特性。
原理探究 :可以用
type命令验证这一点------type [会显示[是一个 shell 内建命令,type test类似,而which [能找到/usr/bin/[这个实际存在的可执行文件。内建命令优先于外部命令被执行,这是 Shell 效率优化的一个细节。
9. 常见错误与避坑指南
这一节汇集了初学者最常踩的坑,提前看过能省下大量排错时间。
9.1 空格之坑
[ 与 ]、操作符、操作数之间都必须有空格,少了任何一个都会报错:
bash
[10 -gt 5] # 错:缺少空格
[ 10 -gt 5] # 对
[$a -eq $b] # 错
[ $a -eq $b ] # 对
判断字符串相等时尤其容易出错:
bash
[ $a="hello" ] # 错!这实际上是把 "hello" 赋值给 $a 的写法
[ "$a" = "hello" ] # 对:= 两边要有空格
9.2 赋值与比较混淆
赋值用一个 等号且不加空格,比较用两个等号且加空格:
bash
a=5 # 赋值
[[ $a == 5 ]] # 比较(在 [[ ]] 中 = 和 == 都可以)
9.3 变量未加引号
变量为空或含空格时,不加引号会引发各种诡异错误:
bash
name=""
[ $name = "zhangsan" ] # 展开后变成 [ = "zhangsan" ],语法错误
[ "$name" = "zhangsan" ] # 正确
在 [ ] 中养成"变量一律加双引号"的习惯,能避免 90% 的此类问题;使用 [[ ]] 则天然规避。
9.4 死循环
while 忘记写改变条件的语句,或 for 的列表恒为真,都会导致 CPU 占满:
bash
while true; do
echo "停不下来了..."
# 没有 sleep 也没有 break,危险!
done
写无限循环时,务必在循环体内加 sleep 或确保有 break 出口。
10. 最佳实践:写出专业可维护的脚本
- 文件头规范 :第一行写 shebang(
#!/bin/bash),并加上脚本用途、作者、日期等注释。 - 变量命名见名知意 :
disk_usage比x好,log_dir比d好。 - 优先
[[ ]]:写 bash 代码时默认用[[ ]]做条件判断,安全又现代。 - 复杂条件拆解:单个 if 条件超过两三个逻辑运算时,拆成多个 if 或用变量承载中间结果,可读性远胜于"一行梭哈"。
- 循环加防护 :批量删除、覆盖等危险操作前,先
echo打印要操作的命令,确认无误再真正执行;或加read -p "确认继续?"二次确认。 - 使用
set -euo pipefail:在脚本开头加上这行,可以在命令失败时自动退出、未定义变量时报错、管道中任意一环失败都返回失败,极大提升脚本健壮性。
bash
#!/bin/bash
set -euo pipefail
11. 总结与练习
至此,我们已经完整掌握了 Shell 条件判断与流程控制的核心知识:用 test/[[ ]] 表达条件,用 if 做选择,用 for/while 做循环,用 case 做多分支,用 break/continue/exit 控制流程走向。它们就像搭积木一样可以自由组合,几乎能实现你能想到的任何自动化逻辑。
核心回顾:
- 条件判断的本质是命令的退出状态码,0 代表真,非 0 代表假。
if适合"二选一"或"多选一"的决策,elif越多越建议换成case。for适合已知次数的遍历,while适合未知次数的持续执行。case擅长根据变量取值分派任务,模式匹配支持通配符。break/continue/exit是循环的"方向盘、变速器和总闸"。
练习题(建议动手完成后再看提示):
练习 1:写一个脚本,读取用户输入的年份,判断是否为闰年(能被 4 整除但不能被 100 整除,或能被 400 整除)。
提示:使用
[[ $year % 4 -eq 0 ]]做取模判断,注意组合条件。
练习 2:用 for 循环打印九九乘法表。
提示:双层 for,内层循环变量从 1 到外层变量;
echo -n不换行,内层结束后echo换行。
练习 3:用 while 循环实现一个简单的"猜数字"游戏:程序生成随机数(1-100),用户猜,大了提示"大了",小了提示"小了",猜中退出。
提示:用
$RANDOM生成随机数,\[ `$RANDOM % 100 + 1` ] 得到 1-100 的数;循环条件为"没猜中"。
练习 4 :写一个 case 版的文件类型识别器,能识别 .txt、.md、.jpg/.png、.mp4 以及其它类型。
提示:结合
*.jpg|*.png)的"或"模式,其余用*)兜底。
练习 5 :扫描指定目录下所有 .log 文件,统计每个文件的单词数,超过 10000 个单词的文件打印"大文件"标记。
提示:for 遍历 +
wc -w提取单词数 + if 判断,注意$(...)命令替换的用法。
学习 Shell 没有捷径,最好的方法就是把每个示例敲一遍、改一遍、错一遍、再修一遍。当你在凌晨从容地依赖自己写的脚本守护生产环境时,会感谢今天每一个"踩坑"的瞬间。祝你写得开心,运维不加班!