写Shell脚本这件事,我一直觉得最难的不是记住某个命令的用法,而是把零散的命令组织成一段有逻辑、能复用的代码。而组织逻辑靠的就是流程控制——if-else、for、while、case这四板斧。你去看任何一份有点规模的Shell脚本,不管它是部署脚本、日志清理脚本还是服务管理脚本,骨架几乎都是这四个结构在撑。
这篇教程我打算用保姆级的方式把这些结构一次讲透。不是给你丢一堆语法,而是从“为什么要这么写”开始,把常见写法、容易踩的坑、以及我实际排错时积累的经验全部揉进去。内容面向刚入门的Shell新手,也适合那些已经写了几个月脚本、但经常被各种诡异报错卡住的朋友。看完之后,你至少能独立写出结构清晰、健壮可用的脚本,而不是只会把命令一行行堆上去。
1. 先搞清楚:流程控制在Shell里到底解决了什么问题
很多人学Shell脚本是从echo "hello world"开始,但等到真正要写一个能用的脚本时,突然发现不会组织了。命令会敲,脚本却写不出来,缺的正是流程控制这个“骨架”。
我习惯把Shell脚本理解成流水线:每一条命令就是流水线上的工人,变量是工人之间传递的零件,而流程控制就是这条流水线的调度系统。没有流程控制,所有命令只能从上到下机械地执行一遍;有了流程控制,脚本才知道什么条件下做A、做B,做多少次,做到什么时候停。
具体来说,Shell里的流程控制解决三类问题:
- 选择:满足某个条件时执行这段逻辑,否则执行另一段?对应
if-else和case。 - 重复:同一批操作要执行N次,或者直到某个状态发生改变?对应
for和while。 - 分支跳转:根据不同的匹配结果走不同的处理分支?对应
case的多种模式匹配。
在动手写之前,有一个前置概念必须先理解:Shell的if和while判断的并不是“表达式的真值”,而是命令的退出码。退出码为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是理解“条件驱动”的基础,for和while覆盖绝大多数循环场景,case则是在多分支场景下比if-elif-else更优雅的替代方案。
2. if-else条件判断:让脚本学会做决定
2.1 基本语法与执行逻辑
if的标准语法长这样:
if 条件命令; then # 条件成立时执行 elif 另一个条件命令; then # 上一个条件不成立,但这个条件成立时执行 else # 所有条件都不成立时执行 fi有三个关键点必须记住:
then要和if放在同一行,用分号隔开;如果你写成if 条件换行后再写then,语法上其实是允许的,但可读性很差,我统一推荐if ...; then这种写法。- 结束符是
fi,if反过来写。很多新手漏掉这个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)会把Zhang和San当成两个独立的项,循环次数就会不对。后面我会在常见问题部分细讲这个。
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" done3.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 nullglobnullglob是bash的一个选项,开启后没有匹配项时通配符会展开成空列表,循环就不会执行。注意用完后关闭,因为它会影响同一Shell进程后续所有通配符行为。
如果要递归遍历子目录,可以用find加while 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" donebreak和continue后面还可以跟数字,表示跳出/跳过第几层循环。比如嵌套两层循环时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 done4. while循环:条件驱动与逐行处理
4.1 while与for的选型思路
很多刚开始学Shell的朋友搞不清楚for和while到底怎么选。我的判断标准很简单:如果我知道要处理的具体清单,用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 true和for (( ; ; ))等价,后者的(( ; ; ))是三个空表达式,也表示无限循环。但我写的时候永远用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解析脚本参数,支持用户传入目录、天数、保留份数。 - 用
while或for做保留最新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 -print0加while 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 ]会尝试用my和report.pdf两个参数去执行-f判断,直接报unexpected argument。有时候更隐蔽:变量是空的时候,[ -f $file ]变成[ -f ],-f被当成一个非空字符串,条件反而为真,逻辑完全跑偏。
我的铁律是:所有变量在引用时都加双引号,除非你明确知道为什么要不加。像find "$LOG_DIR" -name "*.log"这种路径变量,不加引号在绝大多数服务器上没问题,但一旦某个目录名里有个空格,脚本就挂了。与其到时候排查,不如一开始就养成加引号的习惯。
7.2 sh与bash的差异:为什么本地跑没问题,cron里就报错
我当年踩过一个非常郁闷的坑:写好的脚本在终端执行一切正常,放到crontab里后,各种语法错误,比如[[ not found、let: not found。原因就是cron执行脚本时默认用的/bin/sh,而在很多Linux发行版上/bin/sh指向的是dash而不是bash。dash是POSIX shell的轻量实现,很多bash特性它都不支持。
排查思路很简单,ls -l /bin/sh看看它指向哪里。如果是dash,你的脚本里有[[ ]]、${var,,}(大小写转换)、source等bash专属语法时,就会报错。
解决办法有两个:
- 脚本第一行shebang写成
#!/bin/bash,并且确保执行方式用的是./script.sh或bash 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等 |
忘记fi或esac | unexpected end of file | 写完后立即补上结束符 |
| 遍历文件没有匹配项 | 通配符保留字面量 | 开启nullglob或先判断 |
while read接管道 | 循环内变量修改丢失 | 用done < file或< <(cmd) |
| 脚本在cron里报错 | dash不支持bash语法 | shebang写#!/bin/bash并显式用bash执行 |
Windows换行符\r | bad interpreter | 转成Unix格式 |
set -e下允许单条命令失败 | 脚本意外退出 | 该命令后加|| true |
写在最后的几个建议
教程到这里,核心内容已经全部讲完了。按我的经验,Shell流程控制不需要背语法,你只要动手写几个小脚本,自然会记住。我建议你从今天这两个练习开始:一是把文中的服务管理脚本改成管理你本地的一个进程,二是把日志清理脚本跑在一个测试目录里看看效果。写的过程中遇到报错不要慌,用bash -x script.sh调试运行,看每一条命令实际执行了什么,这是排查Shell脚本问题最有效的手段,没有之一。
还有一个习惯值得养成:写脚本时开头加上set -euo pipefail。-e让脚本在出错时停止,-u让未定义变量直接报错,-o pipefail让管道中的任何一条命令失败都算整条管道失败。这三个组合能帮你把大部分低级错误挡在运行时之前。当然,加完它之后你再遇到if条件里变量没定义的情况,会先挨一记报错,这其实是好事,因为问题越早暴露,修复成本越低。