shell 条件判断这玩意,属于那种你写了两年脚本都觉得自己会了,结果某天被一个 [ ] 和 [[ ]] 的区别问懵、或者在 -z 判断时没加引号导致线上脚本翻车的典型知识点。很多新手学 shell,上来先背 if 的格式,然后照着写,写完发现"明明语法没问题,结果却不对",然后开始怀疑人生。我今天就专门把条件语句这块掰开揉碎了讲一遍,把自己实际踩过的坑、总结过的套路全部倒出来,你照着看完、练完,基本能绕开大部分常见的暗坑。
这篇文章不是教条式地把 man test 复制一遍,而是从"它到底是怎么工作的"这个底层逻辑讲起,再一层层拨开 if、case、&&、|| 这些外壳。同时会补上大量我实际工作中写脚本的细节,比如为什么要加引号、为什么 [ $? -eq 0 ] 这种写法很蠢、为什么尽量用 [[ ]] 而不是 [ ]。我还会专门挑几个真实环境里最容易出错的反面例子,一步步分析“看着没问题但就是跑不对”的原因。适合所有刚接触 shell 脚本、或者写了段时间但总觉得基础不扎实的同学。
1. 条件判断的核心机制:从一条命令说起
先说结论:shell 里的条件判断不是"语言语法",它本质上是"命令退出码(exit code)的判断"。这可能是新手理解 shell 脚本最大的分水岭——if 后面跟的其实不是“条件表达式”,而是一条命令。这条命令运行成功(退出码为 0),if 就认为条件成立;失败(退出码非 0),if 就认为条件不成立。
这个机制如果不建立起来,你会发现很多写法“看起来不符合直觉”,比如 if grep -q "root" /etc/passwd; then,再比如 if cd /tmp; then。很多人以为 if 后面必须跟 [ ] 或 [[ ]],其实完全不是。if 后面的东西可以是任何命令,不一定非得是方括号。
理解了这个,再去记 [ ] 和 [[ ]] 就轻松了——它们本身其实是“命令”或者“关键字”。[ 这个字符,本质上是一条命令,这个命令的名字就叫 [,它要求最后一个参数必须是 ]。把你的条件当作参数传给它,它判断完以后,通过退出码告诉你结果。经典的坑就在这里:既然是命令,那命令的“参数之间必须有空格”,所以 [ $a = 1 ] 你写成 [$a=1] 绝对不行,因为系统会把 [$a=1] 当成一个单独的命令名,根本找不到这条命令。
1.1 一切都是退出码
Linux 下任何命令执行后都会返回一个数字给父进程,0 表示成功,非 0 表示失败。你可以直接在命令行用 echo $? 查看上一条命令的退出码。比如执行 ls /etc/passwd,然后 echo $?,正常会打印 0;执行 ls /no/such/file,然后 echo $?,通常打印 2(表示执行出错)。shell 的条件判断就是围绕这个数字展开的。
所以从技术上讲:
bash复制if ls /etc/passwd >/dev/null 2>&1; then
echo "文件存在"
fi
和
bash复制if [ -e /etc/passwd ]; then
echo "文件存在"
fi
本质上是一回事。都是通过退出码判断一条命令是否成功。[ ] 这个命令,就是专门帮我们做各种判断(文件类型、字符串比较、数值比较)然后返回相应退出码的。因此我们在看脚本时,遇到 if,第一反应应该是“它后面是一条什么命令,什么情况下会返回 0”,而不是机械地背方括号语法。
1.2 test、[ ] 与 [[ ]] 的差异
test 是 [ 的原始命令形式,[ 就是 test 的别名,多了一个要求以 ] 作为结尾的规矩。你写 test -e /tmp/a.txt 和 [ -e /tmp/a.txt ],效果一模一样。这俩是传统的 POSIX 写法,在几乎所有 shell 里都能用。
[[ ]] 是 bash、zsh 等增强 shell 引入的关键字,不是普通命令。它比 [ ] 强在几点:
[[ ]]里做字符串比较时,>和<是真正的字典序比较,而[ ]里>会被当成重定向符,一不小心就生成文件了。[[ ]]支持=~正则匹配,这个在做格式校验时非常方便。[[ ]]里即使变量没加引号,也不容易因为空变量、空格变量而出错。[[ ]]支持&&、||直接在内部做逻辑运算,而[ ]里通常要写成-a(and)和-o(or),但这两个在新代码里基本被嫌弃。[[ ]]里做模式匹配时,==右侧可以写通配符,例如[[ $name == a* ]]。
那我个人建议:如果你用的是 bash,写脚本直接在脚本头部加上 #!/bin/bash,条件判断一律用 [[ ]] 就好。但如果你写的是要在各种精简环境(比如 busybox、docker 镜像里的 alpine、某些最小化安装的发行版)跑的脚本,那默认还是 [ ] 更稳妥,因为它符合 POSIX 标准,兼容性最好。这是实际项目里的权衡问题,不是越高级越好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. if 语句的完整形态:单分支、双分支、多分支
if 语句是键盘上使用频率最高的条件判断结构。很多人学的时候只记住了“if then fi”这个框架,但实际写起来还是会在细节上翻车。我把四种形态都写出来,并配上对应的使用场景和坑点。
2.1 单分支:if ... then ... fi
bash复制if [ "$1" = "start" ]; then
echo "开始服务..."
fi
单分支一般用于"满足条件才做事,不满足就算了"的场景。最容易犯的错有三个:if 和 [ 之间要有空格;[ ] 里的变量最好加引号;then 不能单独另起一行,必须和 if 在同一行或者用分号隔开。所以写成 if [ "$1" = "start" ] 堪称最标准的单分支写法。
新手刚写时,最容易出现的是这种错:
bash复制if [ $1 = start ] # 严格说不是绝对报错,但存在隐患
如果 $1 为空,那么这句会展开成 [ = start ],直接语法错误;如果 $1 是 "hello world"(带空格),会展开成 [ hello world = start ],这会被当成 4 个参数导致判断异常甚至报错。所以给变量加引号是铁律,尤其在使用 [ ] 时。
2.2 双分支:if ... then ... else ... fi
双分支适合"二选一"。
bash复制if grep -q "^root:" /etc/passwd; then
echo "root 用户存在"
else
echo "root 用户不存在"
fi
这里 grep -q 本身就会返回退出码,-q 表示安静模式,只要匹配到内容就返回 0,不输出任何东西。很多人为了判断“root 用户是否存在”,会写 grep "^root:" /etc/passwd >/dev/null,这样做当然可以,但既然 grep 已经提供了 -q,直接用它更优雅。从这里也可以延伸出一个思路:做条件判断时,先想想有没有现成的命令可以直接返回退出码,不一定非要用 [ ] 去比较。
2.3 多分支:if ... elif ... else ... fi
elif 就是 else if 的缩写。适合处理多个互斥分支。
bash复制if [ "$1" = "start" ]; then
echo "启动"
elif [ "$1" = "stop" ]; then
echo "停止"
elif [ "$1" = "restart" ]; then
echo "重启"
else
echo "用法: $0 {start|stop|restart}"
fi
这里有个小技巧:$0 代表脚本自身的名字,在打印用法时用 $0 会很友好,哪怕用户把脚本改名字了,提示信息也会跟着变,不会出现脚本叫 abc.sh 但提示信息还写 sh run.sh 的尴尬。
对于这种多分支字符串匹配,其实用 case 更清爽。我一般遵循这样的选择标准:条件在三个以内、逻辑以真/假为主,用 if;条件超过三个且都是等值、模式匹配,用 case。
2.4 if 的常见书写错误与正确姿势
以下是我在帮同事排查脚本时,见得最多的问题。我把“错误示例”“现象”“正确示例”列成一张表,方便你直接对照:
| 错误写法 | 现象 | 正确写法 |
|---|---|---|
if [$a = 1] |
[$a: command not found |
if [ "$a" = 1 ] |
if [ $a = 1 ] |
变量 a 为空时报参数过多或语法错误 | if [ "$a" = 1 ] |
if [ $a -eq 1 ] |
a 是字符串时整数表达式无效 | if [[ "$a" == 1 ]] 或先校验类型 |
if [ "$a" = 1 ] 但 a=01 |
字符串比较不相等 | if [ "$a" -eq 1 ] |
if cmd && cmd2 拼成 if [ cmd && cmd2 ] |
在 [ ] 中 && 语法错误 |
if cmd && cmd2 / [[ ... && ... ]] |
第一条是几乎所有初学者都会遇到的“命令找不到”问题。原因前面说了,[ 是一条命令的名字,它和参数之间必须隔开。所以 [$a = 1] 等价于执行一个名称含 $a 的诡异命令,当然会报错。写代码时我习惯在 [ 后面敲个空格再写内容,形成肌肉记忆,就能绕开这个坑。
第二条是变量为空时的经典坑。如果 $a 为空,[ $a = 1 ] 展开为 [ = 1 ],等于 [ 命令只收到两个参数(= 和 1),运行就会怪怪的。给 $a 加上双引号变成 [ "" = 1 ],参数就完整了。所以 [ 内的变量尽量全加引号,这句话要刻在脑子里。
第三条和第四条涉及整数、字符串比较的区别。-eq 是按整数比较,= 是按字符串比较。01 和 1 用字符串比较不相等,但用 -eq 比较相等,因为作为整数它们是同一个数。反过来,1 和 1.0 用 -eq 会直接报"integer expression expected"。所以先明确你要比的是什么类型。
3. 文件、字符串、数值判断的细节与避坑
条件判断按对象可以分成三类:文件判断、字符串判断、数值判断。这三类在实际运维脚本、部署脚本里都特别常用。我一个个展开说,重点放在最容易出错的细节上。
3.1 文件判断:别忽略"文件存在但不可读"这种情况
[ -e 文件 ] 判断存在, [ -f 文件 ] 判断是普通文件, [ -d 目录 ] 判断是目录,[ -r 文件 ] 判断可读,[ -w 文件 ] 判断可写,[ -x 文件 ] 判断可执行。这些都是最基础的,但实际用的时候,有些细节值得注意。
比如你要判断一个目录是否存在,不要用 [ -e dir ] 然后就放心了。-e 只管“路径存在”,并不保证它一定是个目录。如果你后面要往这个路径里写文件,建议用 [ -d dir ] 更严谨。写日志脚本时我就遇到过:程序本来应该创建目录,结果之前某次运行因为权限问题创建失败,却留下一个同名空文件。我的脚本判断的是 [ -e "$log_dir" ],看到存在就跳过创建,结果后续写日志直接报"Not a directory"。后来一律改成 [ -d "$log_dir" ] || mkdir -p "$log_dir",再没出过这种问题。
再比如要执行某个可执行文件前,[ -x /path/to/bin ] 再好不过。它会区分“文件存在但没执行权限”“文件存在且可执行”这两种情况,避免你敲下去才收到 Permission denied。
3.2 字符串判断:-z、-n、==、!= 的细节
字符串相关判断里,[ -z "$var" ] 判断字符串长度是否为 0(即空串),[ -n "$var" ] 判断字符串长度是否非 0(即非空)。== 在 [ ] 里是可用的(虽然 POSIX 标准只规定了 =,但 bash 的 [ ] 里也支持 ==),一般我为了兼容性写 =,在 [[ ]] 里写 ==。
这里有两个踩过的大坑:
第一个是 -n 和引号的问题。[ -n $var ] 如果 var 为空,展开后变成 [ -n ]。[ 命令看到一个参数 -n,会把它当作“字符串长度为非零”的测试对象,然后字符串 "-n" 本身长度不是 0,于是返回真。可实际上 var 是空的。这就是经典反直觉 bug。解决办法很简单:变量严格加引号,写 [ -n "$var" ],空变量时会变成 [ -n "" ],正确返回假。
第二个是 = 比较时右侧通配符的问题。在 [ ] 中右侧写 * 并不会做通配匹配,就是字面比较。但在 [[ ]] 中,== 右侧可以是通配符,比如:
bash复制name="abc.txt"
if [[ $name == *.txt ]]; then
echo "是 txt 文件"
fi
这种写法写校验逻辑很方便。正则匹配就用 =~:
bash复制if [[ $email =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "邮箱格式正确"
fi
注意 =~ 在 bash 3.0+ 里可用,但在老版本里正则表达式两侧不能加引号,加了引号会被当成普通字符串。虽然新版本宽松了,但为了兼容,我依然习惯不加引号。
3.3 数值判断:-eq、-ne、-gt、-ge、-lt、-le
数值判断的选项分别是等于、不等于、大于、大于等于、小于、小于等于。shell 里整数比较不能直接写 > 或 <,因为在 [ ] 里它们会被解释成重定向。负数、0、大整数都建议用这些运算符。
再提醒一个非常容易在不同系统上翻车的点:在 [ ] 里使用 > 时,如果写法是 [ "$a" > 1 ],shell 会先把 > 1 当成重定向,生成一个名叫 1 的文件,然后把 $a 当作参数判断,结果整个逻辑就全乱了。我同事就遇到过脚本运行完以后目录里多出无数个 1、2、3 这种名字文件的诡异现象,查了半天才找到根因——if 里的比较符号被重定向了。所以在 [ ] 里比较整数必须用 -gt 这套;在 [[ ]] 里 > 和 < 是安全的,因为是关键字内部处理,不会触发重定向,但可读性上还是建议统一用 -gt 这套。
3.4 变量替换和命令替换在条件语句中的运用
既然提到字符串判断,不得不把 ${} 和 $() 拿出来说。这俩在条件语句里也经常配合使用。
${var:-default}:如果 var 未设置或为空,返回 default。${var:=default}:如果 var 未设置或为空,不仅返回 default,还把它赋值给 var。${var:+default}:如果 var 非空,返回 default,否则返回空。${var:?message}:如果 var 未设置或为空,输出 message 并退出。
举个例子:
bash复制if [ -z "${PORT:-}" ]; then
PORT=8080
fi
这种写法可以安全处理环境变量缺失的情况。也可以一行写成 ${PORT:=8080},然后直接引用 $PORT。
$() 是命令替换,把命令输出作为字符串放到当前位置。比如:
bash复制current_hour=$(date +%H)
if [ "$current_hour" -lt 12 ]; then
echo "上午"
fi
或者直接写:
bash复制if [ "$(date +%H)" -lt 12 ]; then
echo "上午"
fi
需要注意的是,如果命令的输出包含尾随换行符,命令替换会把它删掉,这在大多数场景是好事,偶尔会被忽略。比如 var=$(echo "abc"),$var 里没有换行符,跟手工写的字符串表现一致。但如果在 $(...) 里执行的是多行输出,保留的只有末尾换行会被去掉,中间换行仍在。这可能会影响字符串比较,所以要留意。
4. 逻辑组合与嵌套:&&、|| 和 ! 的正确玩法
单条件判断是基础,但真实脚本经常需要组合判断,比如“文件存在并且非空”“目录不存在则创建”“用户输入必须同时满足两个格式要求”。这时 &&、||、! 就是主角。
4.1 && 和 || 在 if 内外的差别
&& 表示“前一条命令成功才执行后一条”,|| 表示“前一条命令失败才执行后一条”。在 if 条件里,它们既可以出现在 [[ ]] 内部,也可以出现在 if 和 then 之间连接多条命令。
bash复制if [ -f /tmp/a.log ] && [ -s /tmp/a.log ]; then
echo "文件存在且非空"
fi
注意,这是在 if 层面用 && 连接两个 [ ] 命令。也可以写成:
bash复制if [[ -f /tmp/a.log && -s /tmp/a.log ]]; then
echo "文件存在且非空"
fi
两者都行。第一种更符合“命令组合”的原始思维,可读性也不差;第二种则在逻辑上更紧凑。我个人在 bash 脚本里更常用 [[ ]] 内部 &&,因为不用频繁写 [ ] 和空格,但为了兼容 POSIX 环境,我会保留第一种写法。
日常写脚本时还能用 && 和 || 做“爽快的一行式操作”:
bash复制[ -d "$backup_dir" ] || mkdir -p "$backup_dir"
这句的意思是:如果目录不存在,就创建它。很多人会随手写:
bash复制[ -d "$backup_dir" ] || mkdir -p "$backup_dir"
它的可读性比 if 写法更省空间,适合在脚本中做前置检查。但有一个非常大的坑:带 set -e 的脚本中,[ -d "$backup_dir" ] || mkdir -p 这种写法如果目录存在,[ -d ... ] 返回 0,整个复合命令成功,没问题;如果目录不存在,[ 返回 1,接着执行 mkdir,只要 mkdir 成功,整条复合命令返回 0,也没问题。但对于 cmd1 && cmd2 这种写法,如果 cmd1 失败且后面没有 || 兜底,整个复合命令返回失败,在 set -e 下会直接退出脚本。这点要注意。
4.2 ! 取反的优先级问题
! 放在命令前面表示取反退出码。但写法上有个容易出错的细节:! 和命令之间要有空格。
bash复制if ! [ -d "$dir" ]; then
echo "$dir 不存在"
fi
也可以写成 if [ ! -d "$dir" ],这是把 ! 作为 [ ] 内部的取反参数。两种写法都常见。区别在于,if [ ! ... ] 更直观,if ! [ ... ] 更接近“对命令退出码取反”的原始语义。在 [[ ]] 中我更习惯 if [[ ! -d $dir ]]。
还要注意,在 set -e 情况下,不要直接裸写 ! command,因为如果命令成功、取反后退出码是 1,脚本可能直接退出。一般在 if 条件中使用 ! 没问题,因为 if 本身会吞掉退出码。
4.3 嵌套判断的组织和“提前返回”策略
脚本里经常遇到多层判断,最常见的方式是层层嵌套。但嵌套过多会严重影响可读性,尤其超过三层后,人脑就不太容易跟踪“哪个 if 对应哪个 fi”了。
我有两个对策:
第一个是提前返回。函数里先做前置条件检查,不满足就直接 return 1,后面再写真正的逻辑。
bash复制check_input() {
if [ -z "$1" ]; then
echo "参数不能为空" >&2
return 1
fi
if [ ! -f "$1" ]; then
echo "文件不存在: $1" >&2
return 1
fi
# 后续正式逻辑
}
第二个是使用 elif 把互斥条件拉平,而不是嵌套。比如判断某个服务的状态,可以并列写多个分支,而不是套三层 if。
另外,条件里如果出现复杂的组合,可以先把每个子判断放进一个变量,再统一判断:
bash复制is_readable_file=false
if [ -f "$path" ] && [ -r "$path" ]; then
is_readable_file=true
fi
if $is_readable_file; then
echo "文件可读"
fi
这个做法在条件过长时会显著提升可读性。不过要注意,shell 里的布尔值实际是字符串 true/false,if $is_readable_file 执行的是 if true 或 if false,也就是执行一条叫 true 或 false 的命令,利用退出码判断。这技巧很实用,但别把变量名取成 is_readable_file 就忘了在 if 后引用它。
5. case 语句:模式匹配的威力
case 语句是 shell 里的 switch/case,但它比 C 语言的 switch 强很多,因为它支持 glob 模式的匹配。很多脚本里复杂的 if elif 链条,用 case 一写就非常清爽。
5.1 case 的基本语法
bash复制case "$1" in
start)
echo "启动"
;;
stop)
echo "停止"
;;
restart)
echo "重启"
;;
*)
echo "未知操作"
;;
esac
有几个细节要记住:模式后面必须紧跟右括号 );每个分支的结尾要用 ;;,表示跳出 case;* 是默认匹配,相当于 else。整个 case 以 esac(case 倒过来写)结束。变量加不加引号其实影响不大,但建议统一加,防止变量为空时语法仍正确。
5.2 模式匹配和正则的差别
case 里的 *、?、[] 都是 glob 通配符,不是正则表达式。* 匹配任意字符串,? 匹配单个字符,[abc] 匹配中括号里的任意一个字符。比如:
bash复制case "$file" in
*.tar.gz|*.tgz)
echo "tar 压缩包"
;;
*.zip)
echo "zip 压缩包"
;;
*.sh)
echo "shell 脚本"
;;
*)
echo "未知类型"
;;
esac
注意 | 在 case 里表示“或”,用来把多个模式合并到一个分支,跟 &&、|| 不是一回事。这是 case 使用中特别容易混淆的地方。当你看到 *.tar.gz|*.tgz) 时,它表示“文件名以 .tar.gz 结尾,或者以 .tgz 结尾”。
case 的分支匹配从上到下依次检查,第一个匹配成功的分支生效,后面的不再检查。所以写顺序也很重要。如果你有 * 分支,它应该放最后兜底,否则前面的分支永远匹配不到。
5.3 用 case 处理启动脚本风格的开关
很多服务管理脚本都有这样一个模式:
bash复制case "$1" in
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
sleep 1
start_service
;;
status)
check_status
;;
*)
echo "用法: $0 {start|stop|restart|status}"
exit 1
;;
esac
在这种场景下,case 天然比 if elif 更合适。可读性高、结构清晰、扩展新命令只需要加一个分支。我写的部署脚本、备份脚本、定时任务脚本,基本都用 case 处理入口参数。如果需要再对参数做校验,比如 start 还必须带环境名,可以在分支内部再做一层 if。
6. 实战:组合条件、判空、与其他判断的混合使用
我会用一个接近真实的脚本示例,把上面所有点串一遍。这个脚本的功能是:检查输入文件是否存在、后缀是否符合要求、根据输入执行不同操作,并判断操作是否成功。
bash复制#!/bin/bash
# 用法: process_file.sh <action> <filename>
action="$1"
filename="$2"
# 前置检查
if [ -z "$action" ] || [ -z "$filename" ]; then
echo "用法: $0 <action> <filename>" >&2
exit 1
fi
if [ ! -f "$filename" ]; then
echo "错误: 文件不存在: $filename" >&2
exit 1
fi
case "$action" in
check)
if [ -s "$filename" ]; then
echo "文件非空"
else
echo "文件为空"
fi
;;
backup)
backup_name="${filename}.bak.$(date +%Y%m%d%H%M%S)"
cp "$filename" "$backup_name"
if [ $? -eq 0 ]; then
echo "备份成功: $backup_name"
else
echo "备份失败" >&2
exit 1
fi
;;
*)
echo "不支持的 action: $action" >&2
echo "支持的 action: check|backup" >&2
exit 1
;;
esac
这个例子虽然小,但涵盖了参数判空、文件存在判断、文件非空判断、case 分支、命令执行结果判断、输出错误信息到标准错误、合理退出码这几个核心点。
关于 if [ $? -eq 0 ] 这种写法,我前面提过它不太优雅,但这里我故意先用它,然后说一下更好的写法。更多时候我们不是先执行命令再判断 $?,而是直接把命令放在 if 条件里:
bash复制if cp "$filename" "$backup_name"; then
echo "备份成功: $backup_name"
else
echo "备份失败" >&2
exit 1
fi
这样既简洁,又避免了“上一句命令和 $? 之间又插入了别的命令导致 $? 变化”的问题。最容易出现的错误是:
bash复制cp "$filename" "$backup_name"
echo "执行完拷贝"
if [ $? -eq 0 ]; then
...
fi
这样 $? 其实是 echo 的退出码,永远都是 0,导致判断永远成功。排查这种问题非常费劲。所以我强烈建议:判断命令是否成功,就把它直接放到 if 条件里,不要先存 $? 再比较(除非你有相当复杂的多命令逻辑,这时可以 ret=$? 后再比较)。
6.1 脚本技巧:用 set -e 让条件语句更安全
很多新手一开始不接触 set -e,导致脚本中途报错还能继续往下跑,产生一连串连锁错误。我建议在脚本头部加:
bash复制set -euo pipefail
解释一下:
set -e:当命令返回非 0 时立即退出脚本。前提是这条命令不是 if 条件的一部分、不在&&或||列表的末尾。set -u:引用未定义的变量直接报错退出。这个对排查拼写错误很有帮助。set -o pipefail:管道的退出码取最后一个失败命令的退出码,而不是只管最后一条命令。
加了 set -e 之后,前面提到的 cp 如果失败,脚本会直接退出,不需要自己写 if 去判断退出码。但要注意,有些命令会“合理地”返回非 0(比如 grep 没匹配到),这时不应该让脚本退出。你可以用 if grep ... 把它放进条件里,从而规避 set -e 的退出机制。或者用:
bash复制grep -q "pattern" file || true
|| true 会让整条命令的退出码变成 0,避免脚本退出。但这种方法要慎用,它会掩盖真实的错误意图,我一般只在确实希望“忽略这个失败”时使用。
6.2 常见面试和笔试中的坑
我收集了外网和内网论坛上几个常见 shell 条件判断面试题,这里放几个比较典型的:
-
test -z和test -n的区别:-z判断为空,-n判断非空。但核心是都要加引号。 -
[ $a = $b ]和[[ $a == $b ]]的区别:前者是 POSIX 字符串比较,在内部变量多空格时容易裂开;后者是 bash 关键字,支持模式匹配,变量建议加引号但不加也不至于立刻爆炸。 -
shell 里如何判断一个命令是否存在:
command -v foo >/dev/null 2>&1,然后if [ $? -eq 0 ]或者直接用if command -v foo >/dev/null 2>&1; then。 -
if [ 1 < 2 ]会怎样:在[ ]里<会被解释为输入重定向,从名为 2 的文件读取输入,通常会导致报错或生成奇怪文件。应该用-lt。 -
case里*和|的含义:*是默认匹配,|是模式分隔符,表示多种情况执行同一个分支。
这些题目其实都是“踩坑经验”的浓缩。能把细节搞清楚的人,写脚本时通常更稳。
6.3 再谈一个容易被忽略的点:空变量和未定变量的判断
在 set -u 开启的情况下,如果脚本里使用了一个未定义的变量,会直接报 unbound variable。但有时我们就是想判断“这个变量有没有被定义”,怎么处理?
可以用 ${var+x} 这个展开技巧。如果 var 被定义(哪怕是空值),${var+x} 会展开为字符串 x,否则展开为空。然后配合 -z 判断:
bash复制if [ -z "${myvar+x}" ]; then
echo "myvar 未定义"
else
echo "myvar 已定义,值为: ${myvar:-}"
fi
这在处理环境变量、配置文件可选项时非常实用。你不需要给环境变量预设默认值,就能知道它到底有没有被外部传入。
7. 说几个我实际踩过的"看着没问题但就是不对"的坑
前面其实已经穿插了很多坑,但这节我单独拎出来三个典型现场,都是真实发生过的,每个都花了不少时间定位。
7.1 行尾是 Windows 的 CRLF
从 Windows 那边传过来的脚本文件,每行末尾会带一个 \r 字符。在条件判断里,这个 \r 会让变量值显得很奇怪:
bash复制$ cat -A test.sh
#!/bin/bash^M
if [ "$1" = "start" ]; then^M
echo "start"^M
fi^M
执行时会报 $'\r': command not found,或者 if 判断一直不成立。因为你拿 "start\r" 和 "start" 去比,当然不相等。最直接的方法是运行 sed -i 's/\r$//' test.sh 转换。写完脚本如果是从 Windows 环境拉过来的,第一件事就是先检查 CRLF。
7.2 浮点数比较
shell 的 -eq、-gt 只能处理整数。如果脚本需要比较浮点数,比如 CPU 负载 0.75 是否大于 0.5,直接用 -gt 会报 integer expression expected。
解决办法有三种:第一种是用 awk:
bash复制if awk "BEGIN {exit !($load > 0.5)}"; then
echo "负载超过 0.5"
fi
awk 的 exit 参数 0 表示成功、非 0 表示失败,! 在这里做了逻辑反转,巧妙地把数值比较结果转成退出码。第二种是用 bc:
bash复制if [ "$(echo "$load > 0.5" | bc)" = "1" ]; then
echo "负载超过 0.5"
fi
第三种是把数放大成整数再比。比如保留两位小数就乘以 100,转成整数再 -gt。实际项目中我一般用 awk 方案,因为它不依赖额外包(bc 有些精简环境没装)。
7.3 权限判断和实际命令不一致
还有一种常见情况是:[ -w /some/path ] 返回真,但真正写文件时却失败了。这是因为 -w 判断的是“当前有效用户是否对路径有写权限”,但有些情况下会误判,比如文件在只读文件系统上,或者有 SELinux、ACL 等额外限制。所以我的经验是:[ -w ] 只用来做前置快速检查,真正的成败必须依靠实际操作命令的返回码来判断。写文件、删文件、改文件这类危险操作,一定要在事后判断退出码,不要把信任全压在 [ -w ] 上。
8. 工具与调试建议
条件语句写错时,表现往往不是“报错”而是“判断结果和预期不一致”,这是最折磨人的。这里分享几个实用的排查手段。
8.1 用 bash -x 追踪执行过程
bash -x script.sh 会在执行每条命令前,先把展开后的命令打印到标准错误。这是排查条件判断问题的第一神器。你能清清楚楚看到 [ "$a" = "start" ] 实际展开成了什么,变量值到底是什么,哪一步判断结果跟预想不一致。
比如你写 if [ "$1" = "start" ],-x 会输出类似于 + '[' start = start ']' 的内容。如果输出的是 + '[' '' = start ']',那就说明 $1 是空的,你马上就能知道是参数传没传的问题。
调试时还可以在脚本里临时加 set -x 和 set +x,只追踪某一段代码,避免整个脚本刷屏。
8.2 打印变量要带标记
我在排查问题时习惯打印变量时加上前后括号,比如 echo "value=[$value]"。尤其在判断字符串是否为空、是否带隐藏字符时,这种打印方式能立刻暴露问题。你如果写 echo "value=$value",值为空时输出 value=,看起来和正常输出也没多大区别,但写成 value=[] 就能明白看到括号之间有没有东西。这个方法成本极低,收益却极大。
8.3 用 shellcheck 做静态检查
shellcheck 是一个 shell 脚本静态分析工具,它能自动检测出大量常见问题,包括变量没加引号、在 [ ] 里用了 &&、把 == 用在不兼容环境等。安装很简单:
bash复制apt install shellcheck # Debian/Ubuntu
yum install shellcheck # CentOS/RHEL
brew install shellcheck # macOS
写完脚本跑一遍 shellcheck myscript.sh,你会得到带行号的警告。很多我以为自己写得很完美的脚本,跑完 shellcheck 也能发现一两个可疑点。这不是说 shellcheck 永远都对,但作为第一道防线非常值。
9. 推荐配合学习的相关主题
条件语句通常是 shell 编程学习的第二站(第一站是变量、命令执行、基本 IO),继续往前走还会遇到循环、函数、数组、文本处理三剑客(grep、sed、awk)等。根据热词里经常出现的 shell 面试题和 for 循环,我简单说一下后续怎么串。
- shell for 循环和条件语句是天然搭档,比如遍历文件列表时先判断类型再处理。
- 函数里也经常需要条件判断来做参数校验和返回值判断。
- 正则表达式配合
[[ =~ ]]可以完成强大的格式校验,这也是很多面试题喜欢考的点。 getopts用于解析命令行选项时,常配合 case 写分支,是大型运维脚本的基础建设。
如果你已经掌握了条件语句,下一步可以把 for、while、函数 function、数组、字符串处理串起来写一个完整脚本,然后配合 shellcheck 和 bash -x 反复打磨。这比不断背语法有效得多。
另外,关于 [ ] 和 [[ ]] 的适用场景,我再说一句经验之谈:如果你的脚本要跑在各种环境(不只是 bash),把 [[ ]] 当作增强语法,优先写 POSIX 兼容的 [ ];如果你的脚本只在可控的 bash 环境跑(比如自己服务器的定时任务、自己写的部署脚本),大胆用 [[ ]] 提升效率和安全性。写代码不能只看“我的机器上能用”,还要想“一年后别人在别的机器上运行时会不会出问题”。
我在实际项目中使用 shell 时,心里一直有一条准则:脚本最终是写给下一个维护者看的,包括未来的自己。所以条件判断写得清晰、变量命名准确、每个分支都考虑“如果什么意外发生”的兜底逻辑,比追求极致的简洁更重要。今天讲的这些内容,大部分来自线上脚本里真实出现过的 bug。如果不是踩了那么多坑,我也不会写这么啰嗦。但正是这些细节,真正把“能跑的脚本”和“扛得住业务变化的脚本”区分开了。
