写Shell脚本这件事,入门容易写精难,尤其是条件语句,看着简单的if和[ ],用不好就是一场灾难。我见过太多脚本因为一个空格、一个引号、一个判断符号导致运行结果南辕北辙。这篇内容就专门把Shell编程里的条件语句讲透,从if到底层退出码,从[ ]和[[ ]]的差异到case分支,再配合几个可以直接复制的真实场景脚本,帮大家彻底搞明白条件判断的本质。无论你是刚接触Linux的新手,还是写了几年脚本想查缺补漏的老手,这里的内容都能让你有收获。
1. 条件语句的底层逻辑:Shell里的“判断”到底是怎么回事
1.1 条件判断的本质是检查“退出码”
很多人写if语句时,思维还停留在C语言或Python里那种“表达式值为真/假”的模式,总觉得if [ $a -gt 10 ]里的$a -gt 10是一个被计算成布尔值的表达式。但Shell不是这么工作的。Shell里的if后面跟的其实是一段命令,这段命令执行完之后会有一个返回值,也就是退出码,退出码为0表示成功,非0表示失败。if结构真正判断的是这条命令的退出码是不是0。理解这一点,就理解了Shell条件语句的一半。
举个例子,if grep -q "error" /var/log/messages; then ...这一段里,grep执行后如果找到了关键词,退出码是0,条件成立;如果没找到,退出码是1,条件不成立。这跟[ ]测试比较的本质完全一致,因为[本身也是一个命令,一个用来做条件测试的命令。有了这层认知,你就能明白为什么有时候写if cd /some/dir; then这种看起来怪异的写法是合法的:因为cd命令如果切换成功,它就会返回0,if就会进入then分支。
还有一个关键点是$?变量,它会保存上一条命令的退出码。调试脚本时我最常做的事情就是在可疑的判断语句后面手动执行一遍,然后echo $?看一眼结果,只要看到0或非0,你的判断逻辑成不成立立刻明朗。$?不是条件语句专属,但排查条件语句问题基本都从它入手。
1.2 条件语句是脚本分支的基石
没有条件语句的脚本就是一条直线走到底,但这世界上的事不是非黑即白。脚本要处理“文件存在还是不存在”“磁盘空间够还是不够”“输入参数合法还是不合法”“服务进程活着还是挂了”这些情况,就必须依赖条件判断来分流。从土一点的角度说,条件语句就是脚本里的“红绿灯”,控制着流程往哪走。如果你在一个脚本里看到十几个if,别觉得啰嗦,这正是脚本健壮性的体现,真正的生产环境脚本,最怕的不是判断多,而是该判断的地方没判断,导致一个异常直接带崩整个流程。
我在写自动化部署脚本时特别推崇“先验证后执行”的套路,脚本开头先判断前置条件,比如某个配置目录是否存在、某个依赖工具是否安装、当前用户是否具备权限。只有这些前置条件全部满足,才继续往下走,否则直接报错退出。这种思路用一行if很容易实现,但能避免后续十行百行代码白跑。
2. 测试命令三兄弟:test、[ ] 与 [[ ]] 的纠葛
2.1 test命令与[ ]的关系
Linux系统里有一个test命令,专门用于条件测试,比如:
test -f /etc/passwd这条命令用来判断/etc/passwd是否为普通文件,如果判断成立,test返回退出码0。为了方便书写,Shell提供了[这个等价写法,左中括号后面要求有一个空格,整个表达式最后也要有个右中括号,而且右中括号前面必须有空格。[ -f /etc/passwd ]和test -f /etc/passwd完全等价,因为[就是一个命令名,-f、/etc/passwd、]都是它的参数。
这里就不得不唠叨一下空格问题,这是新手最容易翻车的地方。[ -f /etc/passwd ]里,左括号后面的空格、右括号前面的空格都不能省,因为Shell在解析命令行时以空格作为单词分隔符,如果写成[ -f "/etc/passwd" ]没问题,但是[-f /etc/passwd]就成了一个叫[-f的命令,直接报错command not found。另外比较操作符两边也必须有空格,比如[$a -eq 1]是错的,[ $a -eq 1 ]才正确。这个坑基本每个写Shell的人都踩过,踩过之后长记性了就不会再忘。
2.2 [[ ]] 扩展与推荐理由
如果脚本是用bash跑的,我更推荐用[[ ]]而不是[ ]。[[ ]]是bash的关键字而不是普通命令,所以它的词法解析规则更宽松、更符合直觉。最大的好处有三个:
第一个好处,[[ ]]内部不需要转义重定向符号。在[ ]里写[ $a < 5 ],<会被解释成输入重定向,必须写成[ $a \< 5 ]才能正确比较,而在[[ ]]里直接写[[ $a < 5 ]]就没事。第二,[[ ]]支持更丰富的模式匹配和正则匹配,比如[[ $str == foo* ]]可以判断字符串是否以foo开头,[[ $date =~ ^202[4-9]- ]]可以判断日期字符串是否符合指定年份开头,这在[ ]里面做起来要麻烦得多。第三,[[ ]]里可以使用&&和||来组合条件,比如[[ $a -gt 1 && $b -gt 2 ]],看着就比[ $a -gt 1 ] && [ $b -gt 2 ]整洁,也更符合其他语言的阅读习惯。
不过有一个现实约束:/bin/sh在某些系统上只支持POSIX语法,[[ ]]在纯粹的POSIX shell下不可用。如果你的脚本#!/bin/sh开头跑在精简发行版的容器里,最好老老实实使用[ ]或者test。如果她能确认是bash环境,那就放心用[[ ]],高效方便。
2.3 逻辑组合:多个判断条件的拼接
处理多个条件时,除了在[[ ]]里用&&和||组合之外,[ ]写法也有对应的-a和-o。-a表示“并且”,-o表示“或者”。老脚本里经常能看到[ -f "$file" -a -x "$file" ]这种写法,意思就是文件存在并且可执行。但我不太建议在外层用-a和-o,因为一旦表达式里有复杂的空格或者操作符嵌套,解析优先级容易出问题,尤其是混用-a和-o时,逻辑分组和优先级不清晰,踩坑概率很高。稳妥方案是分开写多个[ ]再组合,或者直接用[[ ]]。
举一个实际场景:判断输入参数是一个非空且大于10的数字。用[[ ]]写:
[[ -n $num ]] && [[ $num -gt 10 ]]或者更紧凑地:
[[ -n $num && $num -gt 10 ]]这两种写法在bash里都能正常工作,但如果用[ -n $num -a $num -gt 10 ],在变量内容含空格的极端情况下就可能出错。所以,我的习惯是:条件多的时候优先[[ ]],没有环境限制就不在[ ]里做复杂组合。
3. 数值、字符串与文件的三大类判断操作符
3.1 数值比较:别把大于号用在[ ]里
数值比较是Shell条件语句里最常用的操作之一,但要注意它的符号和大多数语言不一样。Shell里大于小于不是>和<,而是-gt和-lt。完整的数值比较操作符不多,列个表大家一目了然:
| 操作符 | 含义 | 示例 |
|---|---|---|
-eq | 等于 | [ $a -eq 5 ] |
-ne | 不等于 | [ $a -ne 5 ] |
-gt | 大于 | [ $a -gt 5 ] |
-ge | 大于等于 | [ $a -ge 5 ] |
-lt | 小于 | [ $a -lt 5 ] |
-le | 小于等于 | [ $a -le 5 ] |
为什么要设计成字母形式?因为Shell里的>会优先解释为重定向,比如[ $a > 5 ]实际上是把命令输出重定向到名为5的文件里,然后[ $a ]只判断变量是否非空,这跟你想要的效果差了十万八千里。所以请大家改掉使用>、<比较数字的习惯,除非你用的是[[ ]],在[[ $a > 5 ]]这里>没有歧义,但为了保证跨shell的可读性,我依然倾向于用-gt这类写法。
实际写脚本时还需要注意变量内容必须是纯数字,有时候从配置文件中读取的值可能带空格或者换行,这时候比较前最好先trim一下。我遇到过最经典的问题是从文本中读出的数字末尾带了一个\r(Windows换行符),导致[ $size -gt 100 ]直接报错,因为在bash里当表达式格式错误时它会尝试把两侧内容当作算术运算以外的字符,直接报syntax error。
3.2 字符串比较:引号和空值陷阱
字符串比较看起来简单,但坑最多。判断字符串“相等”用=,注意不是==也不是equals,==在[[ ]]里能用,在[ ]里通常也能用,但POSIX标准里[ ]只认=,为了兼容性我一律用=。不相等是!=。判断空串用-z,判断非空用-n。
最典型的问题出现在变量没加引号的时候。看这段:
str= if [ $str = "hello" ]; then你猜会发生什么?这里$str展开后是空,Shell实际执行的是[ = hello ],语法直接报错。如果变量有空格,比如str="hello world",那就变成[ hello world = hello ],更离谱。解决办法是始终给变量加双引号:
if [ "$str" = "hello" ]; then加了引号以后就算$str为空,表达式也变成[ "" = "hello" ],成为一个合法的字符串比较,不会报错。
还有一个容易忽视的点,-z "$str"判断空串,但[ ! -z "$str" ]这种写法在有些shell里因为!的优先级问题会有意外,我更喜欢用-n "$str"直接判断非空。字符串比较的另一个常见坑是空格和引号混用,比如判断变量是否等于一个带空格的值,必须确保比较右边也加引号,写成[ "$name" = "John Smith" ]。
字符串可能用于用户输入或配置文件读取,你还得考虑变量不存在的情况,例如用了set -u的脚本里,未定义变量直接展开就会报错。这种情况下我通常先给默认值:local str="${1:-}",这样即使没有传参,$str也是一个空串而不是未定义变量,后续判断就安全多了。
3.3 文件判断:-f、-d、-x、-s 一个都不能漏
文件判断是条件语句里另一块常用阵地,尤其是写部署脚本、备份脚本、日志清理脚本时,离不开这些操作符。最常用的有这么几个:
| 操作符 | 含义 |
|---|---|
-e | 文件存在(不管什么类型) |
-f | 是普通文件 |
-d | 是目录 |
-r | 当前用户可读 |
-w | 当前用户可写 |
-x | 当前用户可执行 |
-s | 文件存在且大小非0 |
-L | 是符号链接 |
写文件判断时一个经常被忽略的点是顺序:如果你想判断“这是一个普通文件并且大小大于0”,应该先-f再-s,顺序反过来也问题不大,但如果-s遇到不存在的路径一样返回假,不会炸脚本。有一点要特别注意,-x判断可执行权限时,它判断的是当前用户的权限,而不是文件的绝对模式位。如果当前用户是root,很多对普通用户不可执行的文件在root眼里也可能显示可执行,因为root能越过部分权限限制。所以别在root环境下验证权限判断,容易误判。
使用文件判断时要特别注意路径中带空格的情况,跟字符串变量一样,给路径加引号是一个好习惯。比如:
if [ -f "/home/user/my config/settings.conf" ]; then另外,-e和-f的区别要搞清楚,-e只要文件系统里有这个名字的文件就成立,包括目录、设备、链接都算,而-f严格要求是普通文件。判断一个路径是目录还是文件时,先-d再-f是常见的组合写法。
4. if语句的分支结构与实战写法
4.1 单分支、双分支与多分支的完整语法
Shell的if语句结构其实就三块:if、then、fi,中间看情况嵌else、elif。最基础的单分支:
if [ -f /etc/nginx/nginx.conf ]; then echo "配置文件存在" fi注意这里的fi是if倒过来写的,用来表示整个语句结束,少了它语法直接报错。双分支加else:
if grep -q "listen 443" /etc/nginx/nginx.conf; then echo "已经配置HTTPS" else echo "未发现HTTPS配置" fi多分支用elif,可以理解为“否则如果”,写起来非常直观:
if [ $# -eq 0 ]; then echo "没有参数" elif [ $# -eq 1 ]; then echo "一个参数" elif [ $# -eq 2 ]; then echo "两个参数" else echo "参数太多" fielif可以写很多个,但不建议滥用。如果分支超过四五个,我会考虑用后面的case或者把判断逻辑拆成独立的函数。脚本的可读性比耍酷重要得多,没人愿意维护一个几十行嵌套if的脚本。
还有一种写法是把if里的命令改为两个命令的复合结构,比如用&&和||来压缩判断。cmd1 && cmd2表示cmd1成功才执行cmd2,cmd1 || cmd2表示cmd1失败才执行cmd2。这两个运算符可以替代部分简单的if语句。典型用法:
[ -d build/ ] || mkdir build/这行的意思是如果build/目录不存在就创建它。再比如:
[ "$DEBUG" = "1" ] && set -x这种写法简洁,但可读性见仁见智。我自己的习惯是简单的条件判断用&&和||,超过两个条件或涉及命令多行才写完整的if。
4.2 嵌套判断:保持脚本清醒的必要原则
嵌套if并不复杂,但容易把自己绕晕。看看这段:
if [ -f "$cfg" ]; then if [ -r "$cfg" ]; then echo "可以读取" else echo "没有读取权限" fi else echo "配置文件不存在" fi逻辑上是先判断存在,再判断可读,层次分明。但嵌套层级越深越难检查语法,尤其是多写一个fi或者少写一个fi,系统只报unexpected end of file这种模糊错误,找起来要命。我自己调试嵌套if时有个习惯:缩进严格用两个或四个空格,并且把fi和对应的if对齐,这样一眼就能看出哪个if还没闭合。另外,能合并成elif的绝不嵌套,能提前exit处理的就不包多层。
还有一点我要特别强调:先处理异常,再处理正常逻辑。很多人写脚本喜欢层层嵌套判断最后才写成功分支,不如反过来,遇到异常条件直接退出:
if [ ! -f "$cfg" ]; then echo "配置文件不存在,退出" >&2 exit 1 fi if [ ! -r "$cfg" ]; then echo "配置文件不可读,退出" >&2 exit 1 fi这样相当于把多个前置校验平铺开,避免了嵌套,也符合“防御式编程”的思路。脚本一旦发现有条件不满足就直接退出,处理完全部前置条件后才进入主逻辑,主逻辑部分就只剩下一条直线,可读性大幅提升。
4.3 case语句:多值匹配的更优解
如果你要判断的变量是几个固定值,比如一级菜单选项、服务名、星期几,case比一连串的elif清爽太多。case的语法结构:
case $action in start) systemctl start nginx ;; stop) systemctl stop nginx ;; restart) systemctl restart nginx ;; *) echo "用法: $0 {start|stop|restart}" exit 1 ;; esac这里有几个关键点需要说明。第一个是每个分支后面都要写双分号;;,表示这个分支结束。第二个是*是通配所有,类似于default。第三个是case支持模式匹配,不只是精确的字符串,你可以写:
case $lang in zh*|cn*) echo "中文" ;; en*) echo "英文" ;; *) echo "其他" ;; esaczh*表示任何以zh开头的字符串都能命中,两种模式用|分隔表示“或”。这种模糊匹配能力让case灵活性很高,适合处理带前缀后缀的参数。有一点要注意case里不加引号时反斜杠转义、变量替换这些规则和普通命令行不太一样,初期使用尽量保持匹配模式简单,避免过度花哨的匹配写法。
case还有一个我很喜欢的特点:不用因为某个条件不匹配就fi,所有分支写完一个esac收尾,整个结构是扁平的。对于几十种取值的情况,case远比if链更容易维护。在我的脚本里,凡是要处理用户输入的选择、命令行子命令、配置状态枚举,一律优先case。
5. 实战:一个服务器资源监控脚本的完整实现
5.1 需求分析与脚本设计
光说不练假把式,我用一个完整的实例把这些条件语句串起来。假设我们要写一个简单的服务器资源监控脚本,它需要做以下事情:
- 检查磁盘空间使用率是否超过阈值(默认85%)
- 检查内存可用内存是否过低(默认低于500MB)
- 检查关键进程是否存在,不存在则提示
这个脚本的需求很典型,适合用来演示条件语句的组合用法。我先把脚本整体结构设计成“前置检查先行,逐步输出结果”的形式,函数封装每个检查项,避免一个长if塞到底。脚本大概长这样,大家可以复制跑一跑。
5.2 完整脚本示例与逐段解读
#!/usr/bin/env bash # 服务器资源监控脚本:磁盘/内存/进程检查 set -o nounset # 未定义变量直接报错 set -o pipefail # 管道中任一命令失败则整体失败 DISK_THRESHOLD=85 MEM_THRESHOLD=500 # 单位MB PROC_NAME="mysqld" # 检查磁盘使用率 check_disk() { local usage usage=$(df / | awk 'NR==2 {print $5}' | tr -d '%') echo "当前磁盘使用率: ${usage}%" if [ -z "$usage" ]; then echo "错误:无法获取磁盘使用率" >&2 return 1 fi if [ "$usage" -gt "$DISK_THRESHOLD" ]; then echo "警告:磁盘使用率超过 ${DISK_THRESHOLD}%,请及时清理。" >&2 return 1 fi return 0 } # 检查可用内存 check_mem() { local available available=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo) if [ -z "$available" ]; then echo "错误:无法获取内存信息" >&2 return 1 fi echo "当前可用内存: ${available}MB" if [ "$available" -lt "$MEM_THRESHOLD" ]; then echo "警告:可用内存低于 ${MEM_THRESHOLD}MB,存在OOM风险。" >&2 return 1 fi return 0 } # 检查关键进程 check_process() { if pgrep -x "$PROC_NAME" > /dev/null 2>&1; then echo "进程 ${PROC_NAME} 运行中" return 0 else echo "警告:进程 ${PROC_NAME} 未运行!" >&2 return 1 fi } # 主流程 echo "----- 服务器资源检查开始 -----" check_disk || true check_mem || true check_process || true echo "----- 服务器资源检查结束 -----"这里每个检查函数返回1时不中断主流程,因为最后用了|| true,这样即使某个检查告警了,其余检查也照常运行,这个细节在生产场景很有用。我特别想提醒的是set -o nounset这一行,开了它以后脚本里只要用了未定义变量就直接报错退出,能帮你拦截大量拼写错误,代价是要给所有可能为空的变量设置默认值。这种“宁可报错也不猜”的风格,在运维脚本里能避免灾难性的误操作。
里面的内存信息提取是我常用的一个方式,读取/proc/meminfo中的MemAvailable,然后从KB换算成MB。如果你跑的是精简内核,可能没有MemAvailable字段,那就改用Free加Buffers等方式估算,不同系统略有差异。至于进程检查,pgrep -x表示精确匹配进程名,不加-x时pgrep mysql可能把mysqld和mysql_client都匹配上,用-x才能精确匹配到指定名称本身的进程。
5.3 为脚本加入日志记录与故障排查能力
单纯的告警在屏幕上不够用,生产环境更常见的需求是记录日志,并允许用户指定不同阈值。在之前的脚本基础上加一个日志函数和参数解析,这个脚本就变成可以实际部署的样子了。日志函数可以这样写:
log() { local level="$1" local msg="$2" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $msg" >> "$LOG_FILE" }然后在每个检查函数里把告警信息同时输出到屏幕和日志文件。注意这里$LOG_FILE需要提前定义,并判断它是否存在对应的目录,如果目录不存在,写日志时可能会报错。为了兼顾复杂度和可读性,真实脚本里参数解析推荐用getopts,但这里简单演示就够了。
加入参数解析后,执行方式可以变成:
./check_srv.sh --disk-threshold 90 --mem-threshold 800 --log-file /tmp/check.log这样一个简单的检查工具就变成更灵活的维护脚本。条件语句在这种需求里承担了从参数解析、阈值判断、日志写入到结果输出的全部流程控制,用到的技术点跟前面讲的完全一一对应。
6. 条件语句常见的坑:问题排查与避坑经验
6.1 频繁出现的报错与排查速查表
Shell脚本调试起来不像Java或Python有完整的堆栈信息,很多时候只是给你一个简短报错,得靠经验来猜。我把常遇到的条件语句报错整理成一张速查表,方便大家对照:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
command not found: [or | 左括号和后面的内容之间没有空格,或脚本编码问题 | 检查[后面必须有空格 |
unexpected token | ]前缺空格,或者fi漏写 | 检查右括号和fi的位置 |
integer expression expected | 变量内容不是整数,常见空值或带中文 | 用"$var"加引号,确认内容是纯数字 |
unary operator expected | [里只写了一个操作数,比如[ != xxx ] | 检查变量是否被正确展开,空值要加引号 |
too many arguments | 变量含空格导致拆成多个参数 | 变量两侧加双引号 |
syntax error near unexpected token fi | if里面命令写错或多写了fi | 用bash -n做语法检查,数清楚fi数量 |
其中integer expression expected和unary operator expected是我见过频率最高的两个。前者几乎都是因为变量为空串或非数字字符进入数值比较导致的,后者大多是因为变量展开后缺少了操作数,比如[ -z ]被解释成了判断字符串-z是否为空,而不是判断某个变量是否为空。在变量加引号之后,这些错误基本都能避免。
6.2 用set -x和bash -n快速定位问题
调试条件语句有两条经验法则:先做静态语法检查,再加动态追踪。静态检查用bash -n script.sh,它会只解析语法不执行命令,如果脚本里有if语句缺失fi、case缺失esac这类问题,它会直接报错。我每次写完脚本都习惯性先跑一次bash -n,成本几乎为零,但能提前拦截一大类语法问题。
动态追踪最有力的工具是set -x,执行脚本前在命令行输入bash -x script.sh,或者脚本开头加一行set -x,它会把每一步执行的真实命令展开打印出来。比如变量展开之后实际执行的是[ 85 -gt 80 ]还是[ "" -eq 1 ],一眼就能看出来。看输出的时候注意最后的退出码,也能发现逻辑问题。我调试复杂条件语句时的完整流程是:先bash -n排除语法,再看具体分支,直接执行对应命令并用echo $?确认退出码,确认无误后把set -x关掉。
还有一个实用工具是shellcheck,这是Shell脚本的静态检查工具,能查出一大堆潜在问题,比如变量没加引号、[ ]里用了不推荐的操作符、管道问题等等。多数Linux发行版的软件源里直接有shellcheck包,装好后对脚本执行shellcheck script.sh,输出里会标注警告和错误的原因。这个工具我一般配合自己的调试习惯一起用,它能发现很多我一眼没注意到的隐患。
6.3 我踩过几次坑之后总结的独家经验
最后说几个我自己真金白银踩出来的经验。第一个教训是关于-z和-n判断的误用。有一回我写清理脚本,本意是“如果参数非空才执行清理”,结果写成了if [ -z "$param" ]; then ...,等于只在参数为空时清理,方向完全反了,把该保留的文件全清了。自那以后,凡是涉及反向判断的,我都刻意多读一遍条件,甚至输出日志验证一下。
第二个教训是关于elif分支里漏写exit或return造成“穿透”。Shell不支持switch的fallthrough控制,但是如果你在某个分支里没提前退出,脚本会继续往下执行,这可能不是你想要的。我写多分支时会刻意在每个分支结尾写exit 0或return 0,保证每个分支逻辑独立。
第三个经验是关于case分支里的*。如果你把*写在前面,后面的其他分支永远不会被命中,因为*会匹配任意内容。这个位置错误很隐蔽,因为脚本不会报错,就是结果不对。排查时多用bash -x看实际命中了哪个分支,就能发现问题。
第四个经验更通用:变量永远加引号,不管是[ ]还是[[ ]],不管你有没有把握。养成这个习惯以后,空值问题能少一半。少数情况你想让变量拆词,比如遍历列表时要用for word in $list,那是另一个场景,与条件判断无关。条件判断里加引号永远是安全的。
再分享一个调试小技巧:写几个常见的测试小函数放进公共脚本库,比如assert_eq、assert_file_exists,用这些函数来组织脚本的自检逻辑,既方便复用又让脚本自带验证能力。条件语句理解起来不难,真正难的是在各种边界条件都正确应对。见过太多脚本在“一切正常”的演示环境里没毛病,一到新机器、新目录结构、新用户环境下就崩,根源往往就是变量没有做好保护措施。把上面这些习惯练成本能,脚本的健壮性会明显上一个台阶。