
写 Shell 脚本这些年我最深刻的感受是能不能把if、for、while、case这四样东西用得顺手基本就是会敲命令和会写程序的分水岭。命令堆叠谁都会但一旦你要处理按条件执行批量操作多分支选择这种逻辑流程控制就是绕不开的骨架。很多朋友一开始学 Shell 觉得简单真上手写脚本就卡在语法上——if后面该跟什么、for循环怎么遍历文件、case里通配符怎么匹配这些细节全是坑。这篇教程我会把 Shell 流程控制里最常用的几个语法块全部拆开讲每个知识点都配可直接跑的示例并且把我实际踩过的坑一并写出来。适合刚接触 Shell 脚本的入门者也适合写过一点脚本但总是被诡异报错卡住的朋友。不搞虚的直接看内容。1. 流程控制的基础先搞懂 Shell 靠什么做判断1.1 命令退出码if 判断的真正依据很多新手第一次写if语句时会下意识地把if当成表达式求值的工具觉得它像其他语言一样需要返回布尔值。但 Shell 的世界观完全不是这样。在 Shell 里if判断的不是真假而是一条命令的退出码。if command; then echo command 执行成功 fi这里的command可以是任何 Linux 命令。退出码为 0 代表成功非 0 代表失败。if就检查这个退出码0 走then分支非 0 走else分支如果有的话。验证方法很简单ls /tmp echo $?$?保存了上一条命令的退出码。如果输出0说明上一条命令成功。这个机制是理解 Shell 一切条件判断的钥匙——你不是在比较值你是在执行命令并查看结果。1.2 test、[]和[[]]三种写法怎么选既然if判断的是命令退出码那if [ $a $b ]到底发生了什么其实[是一个命令等价于test命令只是写法上多了一个右中括号来闭合。它执行的逻辑是比较两个值然后返回 0 或非 0 的退出码。if test $a $b; then echo 相等 fi # 等价写法 if [ $a $b ]; then echo 相等 fi而[[ ]]是 Shell 的内置关键字Bash、Zsh 都支持不是外部命令功能更强大支持正则匹配、、||直接写在里面而且对空变量更宽容。三者的对比如下特性test/[ ][[ ]]本质外部命令/内建命令Bash 关键字空格要求[后必须有空格同样严格要求空变量处理不加引号会报错基本不会出问题逻辑运算符只能用-a、-o支持、正则匹配不支持支持~兼容性POSIX 标准sh可用仅 Bash/Zsh 可用我的建议是写 Bash 脚本时优先用[[ ]]省心得多。但如果你要写严格兼容sh的脚本比如某些系统初始化脚本就得用[ ]并且养成变量加引号的习惯。注意if [ $name test ]这种写法当$name为空时[命令会变成[ test ]语法直接报错。所以用[ ]时变量一定要加双引号if [ $name test ]。2. if-else 条件判断把分支逻辑写清楚2.1 单分支、双分支、多分支的完整语法if的基础结构很简单但实际写的时候经常会漏掉fi、漏掉分号。我建议刚开始的时候把完整模板写出来再改不要凭记忆敲。# 单分支 if [ $count -gt 10 ]; then echo count 大于 10 fi # 双分支 if [ -f /etc/passwd ]; then echo 文件存在 else echo 文件不存在 fi # 多分支 if [ $score -ge 90 ]; then echo 优秀 elif [ $score -ge 60 ]; then echo 及格 else echo 不及格 fi这里有个常见语法困惑then为什么有时在同一行有时在下一行因为if的完整结构是if 条件; then ... fithen前面必须用分号或换行与条件分隔。写成if [ $a $b ] then echo ok fi这种多行写法也是合法的因为换行本身就充当了分隔符。个人习惯我推荐if condition; then写在同一行一眼就能看清条件是什么也不容易漏分号。2.2 数值、字符串、文件判断的核心参数速查Shell 的比较运算符和高级语言差异很大尤其是数值比较用的是-gt而不是字符串比较用而不是[[ ]]里两者都支持。我把最常用的整理成了一张表建议收藏类型运算符含义示例数值-eq等于[ $a -eq 10 ]数值-ne不等于[ $a -ne 10 ]数值-gt/-lt大于 / 小于[ $a -gt 5 ]数值-ge/-le大于等于 / 小于等于[ $a -le 5 ]字符串相等[ $a hello ]字符串!不相等[ $a ! hello ]字符串-z为空串[ -z $a ]字符串-n非空串[ -n $a ]文件-f是普通文件[ -f /etc/hosts ]文件-d是目录[ -d /tmp ]文件-e存在[ -e /tmp/test.txt ]文件-r/-w/-x可读 / 可写 / 可执行[ -x /usr/bin/ls ]文件-s文件存在且非空[ -s /var/log/message ]数值比较千万别写成[ $a 10 ]。表面上看没报错因为在[ ]里被当成输出重定向符处理会把10这个字符串写到名为10的文件里然后[ $a ]判断变量非空就返回真。这种 bug 极难发现我就在生产脚本里见过一次排查了半天才发现是被重定向了。2.3 多条件组合、||的优先级问题多个条件组合时[[ ]]可以直接写和||[ ]则要用-a和-o。但-a和-o的优先级问题很容易踩坑比如想表达a 或b 且 c写成[ $a -o $b -a $c ]会得到预料外的结果。# 推荐写法用 [[]] 加括号明确优先级 if [[ $a x || ( $b y $c z ) ]]; then echo 条件满足 fi # 或者用多个 if 嵌套最稳妥 if [ $a x ] || { [ $b y ] [ $c z ]; }; then echo 条件满足 fi我的经验是条件一复杂优先用[[ ]]可读性和正确性都有保障。如果脚本必须兼容sh那就拆成多个if嵌套别硬刚优先级。3. for 循环批量处理场景的绝对主力3.1 遍历列表的几种写法for循环是 Shell 脚本里使用频率最高的循环结构它和 C 语言的for不太一样本质是遍历一个词列表。# 写法一直接列出元素 for name in zhangsan lisi wangwu; do echo name: $name done # 写法二遍历通配符展开结果 for file in /etc/*.conf; do echo 配置文件: $file done # 写法三遍历命令输出 for ip in $(cat server_list.txt); do echo server ip: $ip done # 写法四C 风格适合数字范围 for ((i 1; i 10; i)); do echo 第 $i 次 done # 写法五使用大括号展开 for i in {1..10}; do echo number: $i done五种写法各有适用场景。我只强调一点遍历命令输出时$(...)的结果会按空格和换行分词。如果server_list.txt里有一行是192.168.1.1 192.168.1.2这两个 IP 会被当成两个元素遍历。大部分时候这是你想要的但如果文件名或内容本身含空格就会出问题。3.2 实战批量重命名文件批量重命名是for循环最经典的落地场景。假设你有一堆2023_*.log文件想统一把年份改成 2024或者给所有.txt文件加一个_backup后缀。这时需要用到 Shell 的参数扩展来操作变量。#!/bin/bash for file in *.txt; do # ${file%.txt} 表示去掉结尾的 .txt 后缀 newname${file%.txt}_backup.txt mv $file $newname echo 已重命名: $file - $newname done参数扩展里%是从右边截掉最短匹配%%是截掉最长匹配#是从左边截掉最短匹配##是截掉最长匹配。这是 Shell 里非常实用的字符串处理方式配合for循环能解决一大批批量操作需求。如果文件名里有空格比如 my report.txt循环本身没问题但 mv $file $newname 必须加引号。不加引号的话mv 会把 my 和 report.txt 当成两个文件处理。这个引号问题贯穿整个 Shell 编程后面我专门讲。 ### 3.3 C 风格 for 与循环控制指令 C 风格的 for 在处理数字步进、数组下标时有优势尤其是步长不是 1 的时候 bash # 打印 1 到 20 之间的奇数 for ((i 1; i 20; i 2)); do echo $i done循环里常用的控制指令是break和continue。break是跳出整个循环continue是跳过本次迭代进入下一次。这个和 C、Python 一样没什么好说的但只要写复杂循环就一定用得上。for ip in $(cat ip_list.txt); do # 跳过以 127 开头的 IP if [[ $ip 127.* ]]; then continue fi ping -c 1 $ip /dev/null 21 if [ $? -eq 0 ]; then echo $ip 可达 else echo $ip 不可达 fi done提示ping -c 1 $ip /dev/null 21的写法是把标准输出和标准错误都丢弃只关心退出码。这是 Shell 里我只想知道成功失败的标配写法。4. while 循环按行读取与守护进程的正确姿势4.1 while read 逐行处理文件的细节for循环按空格分词处理带空格的文件内容会很痛苦。这时候就需要while read逐行读取。这是 Shell 脚本里处理文本文件最可靠的模式。while IFS read -r line; do echo 读取到: $line done /etc/hosts这句代码里三个关键点我逐个解释IFS把内置字段分隔符设为空防止行首行尾的空白被自动去掉。-r禁用反斜杠转义防止文件里的\被吃掉。 /etc/hosts输入重定向把文件内容喂给while循环。如果不写IFS遇到一行末尾有空格的行读出来的内容会不一致如果不写-r遇到路径C:\Windows这种含反斜杠的内容就会被转义。如果你处理的是标准 Unix 配置文件可能没感觉但一旦遇到 Windows 格式的文本这些细节就决定成败。4.2 无限循环写守护脚本和定时任务while true是 Shell 里最常用的无限循环写法后面通常跟sleep控制节奏避免 CPU 被占满。典型场景是监控进程是否存活、写简单的日志轮转守护。#!/bin/bash while true; do if ! pgrep -f my_service /dev/null 21; then echo $(date): my_service 挂了重启中... /opt/my_service/start.sh fi sleep 5 done这里!是取反pgrep -f my_service找到了进程返回 0取反后非 0表示没找到。配合sleep 5每 5 秒检查一次既达到监控效果又不消耗太多资源。while true也可以用while :代替:是 Shell 内置的空操作命令永远返回 0写法更简洁。我还试过用until循环它和while正好相反是条件为假才继续执行条件为真就退出。# 等待某个文件出现最多等 60 秒 count0 until [ -f /tmp/ready_flag ] || [ $count -ge 60 ]; do sleep 1 count$((count 1)) done if [ -f /tmp/ready_flag ]; then echo 文件已就绪 else echo 超时 fiuntil用的场景没有while多但等待某个条件成立这种需求它表达得非常自然。4.3 循环里的管道陷阱变量丢失问题这是 Shell 脚本里最经典的坑之一。下面这段代码你猜count最后输出什么count0 cat /etc/hosts | while read -r line; do count$((count 1)) done echo 总行数: $count答案很可能是0。原因是管道后面的while运行在子 Shell里子 Shell 里修改的变量不会影响父 Shell。管道左边的命令和右边的命令处于一个子 Shell 进程中循环里改的count随子 Shell 结束就没了。解决方式有两种。一种是使用进程替换而不是管道count0 while read -r line; do count$((count 1)) done (cat /etc/hosts) echo 总行数: $count另一种是使用for循环代替注意按行分词问题或者在子 Shell 里完成所有处理再输出。我个人推荐进程替换既能逐行读取又不会丢变量。5. case 语句比 if 链更优雅的多分支选择5.1 基本语法与匹配规则当判断条件超过三个if嵌套读起来就像俄罗斯套娃这时候case是最优雅的方案。case的语法结构是匹配模式匹配成功就执行对应分支两个分号表示该分支结束。case $1 in start) echo 启动服务 ;; stop) echo 停止服务 ;; restart) echo 重启服务 ;; *) echo 用法: $0 {start|stop|restart} exit 1 ;; esaccase的匹配不是简单字符串等值比较而是模式匹配支持通配符模式含义start精确匹配 start*.txt)任何以 .txt 结尾的字符串[0-9]单个数字a|b匹配 a 或 b*匹配任意字符串通常放最后做默认分支5.2 通配符与组合模式的实用写法case真正强大的是它可以像写正则一样匹配输入。比如处理用户输入的数字和字符组合read -p 输入一个字符: ch case $ch in [0-9]) echo 数字 ;; [a-z]) echo 小写字母 ;; [A-Z]) echo 大写字母 ;; ) echo 空输入 ;; *) echo 其他字符 ;; esac多个模式可以合并在一起用|分隔共用同一个执行块case $(uname -s) in Linux|Darwin) echo 类 Unix 系统 ;; MINGW*|MSYS*) echo Windows 下的模拟环境 ;; *) echo 未知系统 ;; esac这种写法非常适合写跨平台脚本比如安装脚本里根据系统不同执行不同逻辑。case比一堆if [ $x a ] || [ $x b ]要清晰太多。5.3 实战写一个完整的服务管理脚本把case和函数结合起来是写运维脚本最常见的方式。下面是一个完善的模板直接拿去改就能用#!/bin/bash APP_NAMEmy_service PID_FILE/var/run/${APP_NAME}.pid do_start() { if [ -f $PID_FILE ] kill -0 $(cat $PID_FILE) 2/dev/null; then echo $APP_NAME 已经在运行 return 1 fi echo 启动 $APP_NAME ... nohup /opt/${APP_NAME}/bin/${APP_NAME} /opt/${APP_NAME}/logs/run.log 21 echo $! $PID_FILE echo 启动完成PID: $(cat $PID_FILE) } do_stop() { if [ ! -f $PID_FILE ]; then echo 未找到 PID 文件 return 1 fi PID$(cat $PID_FILE) kill $PID rm -f $PID_FILE echo 已停止 $APP_NAMEPID: $PID } do_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) do_start ;; stop) do_stop ;; restart) do_stop sleep 1 do_start ;; status) do_status ;; *) echo 用法: $0 {start|stop|restart|status} exit 1 ;; esackill -0是个很有用的命令它不真的发送结束信号只检查进程是否存在且有权访问。配合 PID 文件判断进程存活状态比ps | grep靠谱得多不会误匹配到别的进程。6. 常见坑点与排查技巧实录6.1 空格、引号、换行符的经典三坑Shell 对空格极其敏感这可能是新手最痛苦的部分。我整理了三类最常见的错误赋值的等号两边不能有空格name test会被解析成执行name命令并传两个参数。正确写法是nametest。[和]内侧必须有空格[$a $b]会报command not found或语法错误。正确写法是[ $a $b ]。文件里的 CRLF 换行会导致命令找不到Windows 上编辑的脚本传到 Linux 执行每行结尾的\r会让 Shell 认为命令名带了回显字符报command not found或$\r的诡异错误。用sed -i s/\r$// script.sh或dos2unix script.sh处理。6.2 变量加引号的必要性不光是文件名含空格的问题变量为空时也会出大事。看这个例子dir if [ -d $dir ]; then echo 目录存在 fi当dir为空时[ -d $dir ]变成[ -d ]而-d带一个参数时[命令会判断-d这个字符串是否非空结果返回真这就导致一个不存在的目录被判定为存在。这个逻辑错误非常隐蔽一旦在生产脚本里出现排查成本极高。全部变量都加双引号是最省心的习惯[ -d $dir ]。用[[ ]]时右侧比较和文件判断不强制要求引号但养成加引号习惯没有坏处。6.3 循环里的子 Shell 陷阱再强调除了 4.3 里提到的while管道问题for循环里如果用了管道、$(...)等结构同样可能涉及子 Shell。另一个常见场景是cat file.txt | while read -r line; do # 在循环里调用 ssh、cd 等命令不影响父 Shell 环境 done循环里cd到某个目录循环结束后发现还在原目录这就是子 Shell 导致的。如果需要循环里的操作改变当前环境就要用进程替换或把逻辑写进同一个循环里。6.4 调试三板斧bash -x、set -x、echo 打点脚本出了问题最笨也最有效的方法就是跟踪执行。我给三个级别的调试手段全脚本跟踪bash -x script.sh执行前把每条命令展开并打印到 stderr变量替换后的真实命令一目了然。局部跟踪在脚本里写set -x开启跟踪set x关闭跟踪只跟踪有问题的片段。关键位置打点在关键变量处加echo DEBUG: 变量名$变量名 2打印到 stderr 避免影响正常输出。用法示例set -x for file in *.txt; do echo $file done set xset -x输出的每行前面带你马上能看到变量被展开后的真实值。大部分循环看起来没问题但结果不对的诡异 bug用-x跑一遍就现原形了。最后分享一个我在实际脚本生涯里体会到的小习惯每写完一个包含流程控制的脚本先用bash -n script.sh做语法检查再用bash -x script.sh跑一遍小规模数据验证逻辑最后才放到生产环境。语法检查只能发现语法错误发现不了逻辑错误-x跟踪才能帮你看到 Shell 视角下的真实执行路径。尤其是第一次写循环和case的时候多花两分钟调试能省下来的是几小时排查问题的时间。