10343 字
52 分钟
Shell第二章 核心语法:流程控制与函数封装

导言:从顺序执行到逻辑控制#

第一章我们学会了写顺序执行的脚本,命令从上到下依次运行。但真实运维场景里,我们往往需要脚本「会判断、会重复、能复用」——比如满足条件才执行某段代码,重复执行某个操作上百次,把常用逻辑封装起来反复调用。

本章我们就来学习Shell脚本的核心逻辑能力:数值运算、条件判断、循环结构、函数封装以及重定向。这些是所有编程语言的通用核心逻辑,也是你从「会敲命令」到「会写脚本」的关键一步。掌握这些能力后,你将能够编写真正有自动化价值的脚本,而不仅仅是命令的堆砌。


2.1 数值运算与运算符:让Shell学会算术#

在Bash中,变量默认被视为字符串,直接写 a=1+1 得到的是字符串 "1+1" 而不是 2。为了进行数学运算,我们需要借助特定的命令与运算符。

2.1.1 let 命令与 $((…)) 语法#

对于常规的整数运算,最推荐使用 let 或 $((表达式))。

math_demo.sh
#!/bin/bash
a=10
b=3
# 方式一:使用 let 命令
let c=a+b
let a++ # 变量自增
echo "c的值是:$c"
# 方式二:使用 $((...)) (企业最常用,强烈推荐!)
sum=$(( a + b ))
mod=$(( a % b )) # 取余运算
echo "sum=$sum, mod=$mod"

2.1.2 expr 命令#

expr 是一款老牌表达式计算工具。注意:它的运算符两边必须有空格,且乘号 * 需要转义。

expr_demo.sh
#!/bin/bash
# 注意空格和反斜杠转义
result=$(expr 10 \* 2 + 5)
echo "结果是:$result"

2.1.3 bc 命令:解决小数(浮点数)运算痛点#

前面两种方法仅支持整数。如果在运维中需要计算使用率(如 85.5%),必须使用 bc(Basic Calculator)计算器工具。

终端
# scale=2 表示保留两位小数
echo "scale=2; 10 / 3" | bc # 输出 3.33
echo "1.5 + 2.3" | bc # 输出 3.8
# 在脚本中结合变量使用
used=1045
total=2048
percent=$(echo "scale=2; $used / $total * 100" | bc)
echo "磁盘使用率为:${percent}%"

2.2 条件判断:if/else 分支与 test 测试表达式#

Shell里的条件判断,本质是先执行一个「测试表达式」:表达式成立则返回状态码0(代表真),不成立则返回非0值(代表假)。if语句就是根据这个状态码,决定执行哪条分支。

这个设计理念贯穿整个Unix哲学——一切皆状态码,通过状态码的成功/失败来驱动脚本流程,这也是为什么Shell脚本在系统运维中如此高效。

2.2.1 条件测试基础:test 与 [ ]#

最基础的测试命令是 test,它的等价简写是 [ 表达式 ],后者可读性更强,是行业通用写法。

WARNING

新手第一高频坑:方括号两侧必须有空格!

  • ❌ 错误写法:[$a -gt 10]
  • ✅ 正确写法:[ $a -gt 10 ]

这是Shell语法的严格要求,缺少空格会导致语法错误。

测试表达式主要分三大类,其中文件测试是运维场景最高频的用法。

1. 文件测试#

用来判断文件/目录是否存在、类型、权限等状态,是备份、监控类脚本的基础。

常用核心参数:

  • -e:判断文件/目录是否存在(最常用)
  • -d:判断目标是否为目录
  • -f:判断目标是否为普通文件
  • -r / -w / -x:判断是否有读/写/执行权限
  • -s:判断文件是否非空(大小大于0)
  • -L:判断是否为符号链接
TIP

运维场景建议:在脚本中执行文件操作前,总是先用-f或-d判断文件/目录是否存在,避免因文件不存在导致脚本报错中断。

示例:

Terminal window
# 判断当前目录下的 test.sh 是否为普通文件
[ -f test.sh ]
# 用 $? 查看上一条命令的返回值,0代表成立,1代表不成立
echo $?
# 判断目录是否存在且有写入权限(常见的目录检查)
if [ -d /var/log ] && [ -w /var/log ]
then
echo "目录存在且有写入权限"
fi

实战应用:备份脚本中的文件检查

#!/bin/bash
backup_dir="/backup/data"
# 检查备份目录是否存在
if [ ! -d "$backup_dir" ]
then
echo "备份目录不存在,正在创建..."
mkdir -p "$backup_dir"
fi
# 检查源文件是否存在且非空
if [ -s /etc/nginx/nginx.conf ]
then
cp /etc/nginx/nginx.conf "$backup_dir/nginx.conf.$(date +%Y%m%d_%H%M%S)"
echo "配置文件备份成功"
else
echo "源文件不存在或为空,跳过备份"
fi

2. 数值比较#

用于两个整数的大小对比,注意Shell数值比较不能直接用>、<符号,必须使用专用参数。

常用核心参数:

  • -eq:等于(equal)
  • -ne:不等于(not equal)
  • -gt:大于(greater than)
  • -lt:小于(less than)
  • -ge:大于等于
  • -le:小于等于
CAUTION

重要提示:Shell中>、<符号在数值比较中会被当作文件重定向操作符处理,不能用于数值比较!这是Shell新手最容易犯的错误。

示例:

Terminal window
# 判断 10 是否大于 5
[ 10 -gt 5 ]
echo $?
# 实战:磁盘空间监控脚本片段
used_percent=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ $used_percent -gt 80 ]
then
echo "警告:磁盘使用率超过80%"
elif [ $used_percent -gt 90 ]
then
echo "严重警告:磁盘使用率超过90%,请立即处理!"
fi

3. 字符串比较#

用于判断字符串是否相等、是否为空。

常用核心参数:

  • =:两个字符串相等
  • !=:两个字符串不相等
  • -z:字符串长度为0(空字符串)
  • -n:字符串长度不为0
NOTE

严谨写法建议:字符串比较时,变量建议包裹双引号,避免空字符串引发语法报错。这在参数校验中特别重要。

示例:[ "$name" = "张三" ]

Terminal window
# 判断字符串是否为空
if [ -z "$variable" ]
then
echo "变量为空,请设置值"
fi
# 判断字符串是否相等
username=$1
if [ "$username" != "root" ]
then
echo "只有root用户可以运行此脚本"
exit 1
fi
# 实战:参数校验最佳实践
if [ -z "$1" ]
then
echo "用法:$0 <用户名>"
exit 1
fi
user_input="$1"
if [ "$user_input" = "admin" ] || [ "$user_input" = "root" ]
then
echo "欢迎管理员登录"
else
echo "普通用户已登录"
fi

多条件组合#

可以通过逻辑运算符拼接多个测试条件:

  • -a:逻辑与,两边同时成立才为真
  • -o:逻辑或,任意一边成立即为真
  • !:逻辑非,对结果取反
Terminal window
# 判断数字a大于5 且 小于20
a=10
[ $a -gt 5 -a $a -lt 20 ] && echo "成立"
# 判断文件存在且有执行权限
[ -f script.sh -a -x script.sh ] && ./script.sh
# 判断用户不存在或用户ID为0(root)
id=$3
[ ! -n "$(id -u $username 2>/dev/null)" -o $id -eq 0 ] && echo "用户需要创建或已是root"
IMPORTANT

多条件组合提示:逻辑与(-a)的优先级高于逻辑或(-o),复杂条件建议用括号明确优先级,或者拆分成多个if语句,提升代码可读性。

2.2.2 if 单分支结构#

最基础的条件结构,满足条件就执行代码,不满足则直接跳过。

语法格式:

Terminal window
if [ 条件表达式 ]
then
满足条件时执行的代码
fi

结尾是 fi(if 倒写),必须书写,代表if语句结束。

示例:判断文件是否存在,存在则打印提示

#!/bin/bash
filename="test.txt"
if [ -f "$filename" ]
then
echo "文件${filename}存在"
fi

实战应用:日志轮转脚本片段

#!/bin/bash
logfile="/var/log/app.log"
if [ -f "$logfile" ]
then
# 检查文件大小是否超过100MB
size=$(stat -f%z "$logfile" 2>/dev/null || stat -c%s "$logfile" 2>/dev/null)
if [ $size -gt 104857600 ]
then
mv "$logfile" "$logfile.$(date +%Y%m%d_%H%M%S)"
touch "$logfile"
echo "日志文件已轮转"
fi
fi

2.2.3 if-else 双分支结构#

二选一结构:满足条件执行A逻辑,不满足执行B逻辑。

语法格式:

Terminal window
if [ 条件表达式 ]
then
满足条件时执行的代码
else
不满足条件时执行的代码
fi

示例:判断分数是否及格

#!/bin/bash
read -p "请输入你的分数:" score
if [ $score -ge 60 ]
then
echo "恭喜,考试及格"
else
echo "很遗憾,考试不及格"
fi

实战应用:服务健康检查脚本

#!/bin/bash
service_name="nginx"
port=80
if netstat -tln | grep -q ":$port "
then
echo "服务$service_name正常运行中"
exit 0
else
echo "警告:服务$service_name未响应,正在重启..."
systemctl restart $service_name
sleep 2
if netstat -tln | grep -q ":$port "
then
echo "服务重启成功"
else
echo "服务重启失败,需要人工介入"
exit 1
fi
fi

2.2.4 if-elif-else 多分支结构#

多条件依次判断,哪个先满足就执行对应分支,全部不满足则执行最终else。

语法格式:

Terminal window
if [ 条件1 ]
then
条件1成立执行的代码
elif [ 条件2 ]
then
条件2成立执行的代码
elif [ 条件3 ]
then
条件3成立执行的代码
else
所有条件都不成立执行的代码
fi

示例:根据分数输出评级

#!/bin/bash
read -p "请输入你的分数:" score
if [ $score -ge 90 ]
then
echo "优秀"
elif [ $score -ge 80 ]
then
echo "良好"
elif [ $score -ge 60 ]
then
echo "及格"
else
echo "不及格"
fi
TIP

条件范围设计原则:多分支从上到下依次匹配,必须保证条件范围从窄到宽。上面的例子中先判断>=90再判断>=80,是正确的顺序。如果反过来先判断>=80,90分的情况会被提前捕获,后续的>=90分支永远不会被执行。

实战应用:系统负载监控告警脚本

#!/bin/bash
# 获取1分钟内的平均负载
load=$(uptime | awk '{print $(NF-2)}' | sed 's/,//')
# 获取CPU核心数
cpu_cores=$(nproc)
# 计算相对负载(负载值/核心数)
relative_load=$(echo "$load / $cpu_cores" | bc -l)
if [ $(echo "$relative_load > 2.0" | bc -l) -eq 1 ]
then
echo "严重告警:系统负载过高($load),相对负载:$relative_load"
# 发送告警邮件或钉钉通知
elif [ $(echo "$relative_load > 1.0" | bc -l) -eq 1 ]
then
echo "警告:系统负载较高($load),相对负载:$relative_load"
elif [ $(echo "$relative_load > 0.5" | bc -l) -eq 1 ]
then
echo "系统负载正常($load),相对负载:$relative_load"
else
echo "系统负载低($load),系统空闲中"
fi

2.2.5 exit 命令提前终止脚本#

配合条件判断,我们常使用内置命令 exit 来退出脚本并返回状态码。如前文的参数校验和健康检查示例中均有使用。

  • exit 0:代表脚本正常执行完毕并退出。
  • exit 1(或其他非0值):代表脚本发生错误,异常退出。
Terminal window
if [ ! -f "config.yml" ]
then
echo "严重错误:配置文件不存在!"
exit 1 # 立即终止脚本,不再往下执行
fi

2.3 多分支选择:case 语句与 select 交互菜单#

当判断逻辑是「一个变量匹配多个固定值」时,多层if-elif会非常冗余,此时用 case 语句结构更清晰、可读性更强,特别适合菜单选择、参数匹配类场景。

2.3.1 case 语法格式#

Terminal window
case 变量名 in
值1)
匹配值1时执行的代码
;;
值2)
匹配值2时执行的代码
;;
值3|值4)
匹配值3或值4都执行这段代码
;;
*)
全部不匹配时执行的默认代码
;;
esac

语法说明:

  1. 结尾是 esac(case 倒写),必须书写,代表case语句结束
  2. 每个分支结尾用 ;; 标记结束,匹配成功后执行对应代码,随即跳出case结构
  3. | 可连接多个值,代表「或」的关系,允许一个分支处理多个输入值
  4. *) 是默认分支,匹配所有未命中的情况,相当于else,建议总是包含
NOTE

case 与 if-elif 的选择:

  • 当条件是「一个变量匹配多个固定值」时用case(如菜单选择、参数检查)
  • 当条件是「多个不同变量的复杂逻辑判断」时用if-elif(如多条件组合判断)

2.3.2 实战示例:简易系统管理菜单#

#!/bin/bash
# 系统管理工具菜单脚本
show_menu() {
echo "===== 系统管理菜单 ====="
echo "1. 查看当前日期"
echo "2. 查看当前用户"
echo "3. 查看磁盘使用率"
echo "4. 查看内存使用率"
echo "5. 查看进程列表"
echo "6. 退出程序"
echo "========================"
}
while true
do
show_menu
read -p "请输入你的选择[1-6]:" opt
case $opt in
1)
echo "当前日期是:$(date '+%Y年%m月%d日 %H:%M:%S')"
;;
2)
echo "当前登录用户是:$USER"
echo "用户ID:$(id -u)"
;;
3)
echo "===== 磁盘使用情况 ====="
df -h
;;
4)
echo "===== 内存使用情况 ====="
free -h
;;
5)
echo "===== 进程Top10(按CPU使用率) ====="
ps aux --sort=-%cpu | head -11
;;
6)
echo "感谢使用,欢迎下次使用"
exit 0
;;
*)
echo "输入错误,请输入1-6之间的数字"
;;
esac
echo ""
read -p "按Enter键继续..."
done
IMPORTANT

运维脚本最佳实践:

  • 交互式菜单几乎都采用case实现,代码结构清爽
  • 配合while循环实现菜单循环调用,用户可以连续执行多个操作
  • 后续新增选项非常方便,只需在case中添加新的分支即可

更复杂的实战应用:服务管理脚本

#!/bin/bash
# 服务启停脚本,支持多个服务
service_name=$1
action=$2
case $action in
start|运行)
systemctl start $service_name
echo "正在启动$service_name..."
sleep 1
systemctl status $service_name
;;
stop|停止)
systemctl stop $service_name
echo "正在停止$service_name..."
sleep 1
;;
restart|重启)
systemctl restart $service_name
echo "正在重启$service_name..."
sleep 2
systemctl status $service_name
;;
status|状态)
systemctl status $service_name
;;
enable|开机启动)
systemctl enable $service_name
echo "$service_name已设置为开机启动"
;;
*)
echo "用法:$0 <服务名> <start|stop|restart|status|enable>"
echo "示例:$0 nginx start"
exit 1
;;
esac

2.3.3 select 语句:自动生成交互菜单#

select 语句能极其方便地生成带编号的交互式菜单,常常与 case 配合使用,是编写运维工具箱的另一神器。它省去了我们手写 echo 打印菜单和 read 读取输入的过程。

select_demo.sh
#!/bin/bash
# 自定义提示符(默认是 #? )
PS3="请输入要部署的环境编号: "
select env in "开发环境" "测试环境" "生产环境" "退出"
do
case $env in
"开发环境")
echo "正在部署开发环境..."
;;
"测试环境")
echo "正在部署测试环境..."
;;
"生产环境")
echo "危险操作!正在部署生产环境..."
;;
"退出")
echo "退出脚本"
break # 跳出 select 循环
;;
*)
echo "输入无效,请重新选择"
;;
esac
done

执行时,系统会自动在选项前加上 1) 2) 3) 供用户选择,极大地简化了代码。


2.4 循环结构:for / while / until 循环详解#

循环的核心作用是让一段代码重复执行,是运维自动化的核心能力——批量创建用户、批量修改配置、遍历日志分析,都离不开循环。Shell里主流有三种循环:for、while、until。

2.4.1 for 循环#

for循环是日常使用最多的循环,适合已知循环次数或遍历一组固定内容的场景。

写法一:遍历列表式(最常用)#

语法:

Terminal window
for 变量名 in 值列表
do
循环体代码
done

每次循环,变量会依次取值列表中的每一项,执行一次循环体代码。

示例1:遍历输出1到5

#!/bin/bash
for num in 1 2 3 4 5
do
echo "当前数字是:$num"
done

连续序列可以用大括号简写,比如 {1..10} 代表1到10,{a..z} 代表小写字母a到z:

Terminal window
for num in {1..10}
do
echo $num
done
# 遍历所有英文字母
for letter in {a..z}
do
echo "字母:$letter"
done

示例2:批量创建10个系统用户

#!/bin/bash
# 批量创建用户,并设置初始密码
for i in {1..10}
do
username="user$i"
password="Init@2024_$i"
# 创建用户(如果已存在则跳过)
if id "$username" &>/dev/null
then
echo "用户$username已存在,跳过"
else
useradd -m -s /bin/bash "$username"
echo "$username:$password" | chpasswd
echo "用户$username创建成功,初始密码已设置"
fi
done
echo "所有用户创建完成"

示例3:遍历当前目录下所有.sh脚本文件,统计行数

#!/bin/bash
total_lines=0
for file in *.sh
do
if [ -f "$file" ]
then
lines=$(wc -l < "$file")
echo "文件:$file,行数:$lines"
total_lines=$((total_lines + lines))
fi
done
echo "所有脚本文件总行数:$total_lines"

示例4:批量处理日志文件

#!/bin/bash
# 处理/var/log下所有.log文件,压缩超过7天的日志
log_dir="/var/log"
days=7
for logfile in $log_dir/*.log
do
# 判断文件修改时间是否超过7天
if find "$logfile" -mtime +$days -print | grep -q "$logfile"
then
echo "正在压缩:$logfile"
gzip "$logfile"
fi
done

写法二:C语言风格for循环#

适合精确控制循环次数,语法和C语言一致。

语法:

Terminal window
for (( 初始值; 循环条件; 变量变化 ))
do
循环体代码
done

这种写法的优点是初始值、循环条件、变量递增都清晰直观,适合已知循环次数的场景。

示例:计算1到100的和

#!/bin/bash
sum=0
for (( i=1; i<=100; i++ ))
do
sum=$(( sum + i ))
done
echo "1到100的和是:$sum"

示例:模拟倒计时

#!/bin/bash
echo "启动系统关闭,倒计时60秒..."
for (( countdown=60; countdown>0; countdown-- ))
do
echo "还剩${countdown}秒..."
sleep 1
done
echo "系统将关闭"
# shutdown -h now

示例:性能测试循环执行

#!/bin/bash
# 重复执行某个命令100次,测试性能
for (( i=1; i<=100; i++ ))
do
curl -s "http://localhost:8080/api/test" > /dev/null
if [ $? -eq 0 ]
then
echo "请求$i:成功"
else
echo "请求$i:失败"
fi
done
TIP

for循环选择建议:

  • 遍历列表/文件时用第一种写法(for var in list)
  • 精确控制循环次数时用第二种写法(for (( i=1; i<=100; i++ )))

2.4.2 while 循环#

while循环的规则是:条件为真就持续循环,条件变为假则停止,适合「不确定循环次数,靠状态终止」的场景。

语法:

Terminal window
while [ 条件表达式 ]
do
循环体代码
done

示例1:计算1到100的和(while版本)

#!/bin/bash
sum=0
i=1
while [ $i -le 100 ]
do
sum=$(( sum + i ))
i=$(( i + 1 ))
done
echo "1到100的和是:$sum"

示例2:逐行读取文件内容 while循环最经典的用法之一就是逐行读取文件,是日志分析脚本的基础:

#!/bin/bash
# 逐行打印test.txt的内容
while read line
do
echo "读取到一行:$line"
done < test.txt

示例3:监控进程是否运行,如果停止则重启

#!/bin/bash
# 进程守护脚本
app_name="nginx"
check_interval=10
while true
do
# 检查进程是否运行
if ! pgrep -f "$app_name" > /dev/null
then
echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name进程已停止,正在重启..."
systemctl start $app_name
# 重启后再检查一次
sleep 2
if pgrep -f "$app_name" > /dev/null
then
echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name重启成功"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name重启失败,告警!"
fi
fi
# 等待指定间隔后再检查
sleep $check_interval
done

示例4:实时监控文件变化(应用于日志监控)

#!/bin/bash
# 实时显示新增的日志行(类似tail -f)
logfile="/var/log/app.log"
last_size=0
while true
do
current_size=$(stat -c%s "$logfile" 2>/dev/null || stat -f%z "$logfile" 2>/dev/null)
# 如果文件大小增加,显示新增内容
if [ $current_size -gt $last_size ]
then
tail -c $((current_size - last_size)) "$logfile"
fi
# 如果文件大小减小(可能是日志轮转),重新开始
if [ $current_size -lt $last_size ]
then
echo "[日志文件已轮转]"
tail -f "$logfile"
break
fi
last_size=$current_size
sleep 1
done

2.4.3 until 循环#

until循环和while正好相反:条件为假就持续循环,条件变为真则停止。它非常适合「等待某个状态达成」的场景,比如等待服务启动、等待网络连通。

语法:

Terminal window
until [ 条件表达式 ]
do
循环体代码
done

示例:等待网络连通,连通后给出提示

#!/bin/bash
echo "等待网络连通..."
until ping -c 1 baidu.com &> /dev/null
do
echo "网络还没通,3秒后重试..."
sleep 3
done
echo "网络已连通!"

示例:等待服务端口开放

#!/bin/bash
# 启动服务后等待端口开放,再执行后续操作
service_name="mysql"
port=3306
max_wait=30
elapsed=0
echo "正在启动$service_name..."
systemctl start $service_name
echo "等待$service_name启动完成..."
until netstat -tln 2>/dev/null | grep -q ":$port " || [ $elapsed -ge $max_wait ]
do
echo "等待中... ($elapsed/$max_wait秒)"
sleep 2
elapsed=$((elapsed + 2))
done
if netstat -tln 2>/dev/null | grep -q ":$port "
then
echo "$service_name已启动,端口$port已开放"
else
echo "等待超时,$service_name可能启动失败"
exit 1
fi

示例:重试机制(API调用失败时重试)

#!/bin/bash
# HTTP接口调用重试脚本
api_url="http://api.example.com/data"
max_retries=5
retry_count=0
until [ $retry_count -ge $max_retries ] || curl -sf "$api_url" > /dev/null
do
echo "API请求失败,第$((retry_count+1))次重试..."
retry_count=$((retry_count + 1))
sleep 2
done
if [ $retry_count -lt $max_retries ]
then
echo "API请求成功"
else
echo "API请求失败,已达到最大重试次数$max_retries"
exit 1
fi
IMPORTANT

循环类型选择速查表:

循环类型适用场景特点
for已知循环次数或遍历列表最高效,语义清晰
while不确定循环次数,靠条件判断灵活,常用于逐行读取
until等待某个状态达成与while逻辑相反,少用但在特定场景优雅

2.5 循环控制:break 与 continue 的区别与使用#

循环执行过程中,有时需要提前终止循环,或者跳过当前这一轮循环,就需要用到两个控制命令:break 和 continue。

2.5.1 break:终止整个循环#

执行break后,整个循环直接结束,后续所有轮次都不再执行。后续的代码会继续执行。

示例:循环输出1到10,数字到5时终止循环

#!/bin/bash
for i in {1..10}
do
if [ $i -eq 5 ]
then
break
fi
echo "当前数字:$i"
done
echo "循环结束"

运行后只会输出1-4,到5时直接跳出整个循环。

示例:用户认证失败后停止尝试

#!/bin/bash
# 用户登录脚本,3次错误尝试后退出
max_attempts=3
attempt=0
while true
do
read -p "请输入密码:" password
attempt=$((attempt + 1))
if [ "$password" = "correct_password" ]
then
echo "登录成功"
break
else
echo "密码错误,第$attempt次尝试"
if [ $attempt -ge $max_attempts ]
then
echo "尝试次数过多,账户已锁定"
break
fi
fi
done

2.5.2 continue:跳过本次循环#

执行continue后,仅跳过当前这一轮循环的剩余代码,直接进入下一轮循环,循环整体不会终止。

示例:输出1到10,跳过所有偶数

#!/bin/bash
for i in {1..10}
do
# 判断是否为偶数
if [ $(( i % 2 )) -eq 0 ]
then
continue
fi
echo "奇数:$i"
done

运行后只会输出1、3、5、7、9,所有偶数都被跳过。

示例:批量处理文件,跳过隐藏文件和特殊文件

#!/bin/bash
# 遍历目录,处理普通文件,跳过隐藏文件和目录
for file in *
do
# 跳过隐藏文件(以.开头)
if [[ "$file" == .* ]]
then
continue
fi
# 跳过目录,只处理普通文件
if [ ! -f "$file" ]
then
continue
fi
# 处理普通文件
echo "处理文件:$file"
# 你的处理逻辑...
done

示例:日志分析,跳过注释行和空行

#!/bin/bash
# 分析配置文件,跳过注释行和空行
config_file="/etc/app.conf"
while read line
do
# 跳过空行
if [ -z "$line" ]
then
continue
fi
# 跳过注释行(以#开头)
if [[ "$line" == "#"* ]]
then
continue
fi
# 处理有效配置行
echo "配置:$line"
# 解析和处理配置...
done < "$config_file"
NOTE

break 和 continue 的一句话总结:

  • break = 「直接下班,全部结束」,终止整个循环
  • continue = 「这次跳过,下次继续」,仅跳过当前一轮循环

在实际使用中,continue出现频率远高于break,因为很多情况下我们需要跳过某些不符合条件的项,继续处理后续项。


2.6 函数封装:自定义函数、参数传递与返回值#

当脚本中有一段代码需要反复使用时,每次复制粘贴既冗余又难维护。此时我们可以把这段代码封装成「函数」,起一个名字,后续使用时直接调用函数名即可。

函数的核心价值:代码复用、模块化拆分、提升脚本可读性与可维护性。好的函数设计可以把复杂的脚本拆分成多个功能单元,每个单元职责单一,逻辑清晰。

2.6.1 函数的定义与调用#

定义语法#

有两种等价写法,效果完全一致,带function关键字的写法可读性更强。

Terminal window
# 写法1:简写版
函数名() {
函数体代码
}
# 写法2:标准关键字版
function 函数名 {
函数体代码
}

两种写法都被广泛使用,选择哪种主要取决于个人习惯和团队规范。

调用方式#

直接书写函数名即可调用,不需要加括号:

Terminal window
函数名

示例:封装一个打印欢迎语的函数

#!/bin/bash
# 定义函数
say_hello() {
echo "======================"
echo " 欢迎使用运维脚本工具"
echo "======================"
}
# 调用函数
say_hello
WARNING

重要提示:函数必须「先定义,后调用」,不能把调用语句写在定义语句前面。Shell脚本是顺序执行的,执行到函数调用时必须已经读取过函数的定义。

示例:函数调用顺序错误示例

#!/bin/bash
# 错误示例:先调用后定义,会报错
say_hello # 报错:command not found
say_hello() {
echo "你好"
}

2.6.2 函数的参数传递#

函数也可以像脚本一样接收外部参数,用法和脚本的位置参数完全一致:

  • $1:第一个参数
  • $2:第二个参数
  • $#:传入参数的总个数
  • $@:所有参数的完整列表
  • $0:函数名(在函数内,$0仍然是脚本名,不是函数名)

示例:封装加法函数,接收两个数字参数并输出结果

#!/bin/bash
add() {
sum=$(( $1 + $2 ))
echo "$1 + $2 = $sum"
}
# 调用函数,传入两个参数
add 10 20
add 30 40

示例:接收可变数量的参数

#!/bin/bash
# 打印所有传入的参数
print_all_args() {
echo "参数总数:$#"
echo "所有参数:$@"
for arg in "$@"
do
echo "- $arg"
done
}
# 调用
print_all_args apple banana orange

示例:参数校验最佳实践

#!/bin/bash
# 创建用户函数,需要接收用户名和初始密码
create_user() {
# 参数校验
if [ $# -lt 2 ]
then
echo "错误:参数不足。用法:create_user <用户名> <密码>"
return 1
fi
local username=$1
local password=$2
local shell=${3:-/bin/bash} # 第三个参数可选,默认为/bin/bash
# 检查用户是否已存在
if id "$username" &>/dev/null
then
echo "用户$username已存在,跳过创建"
return 1
fi
# 创建用户
useradd -m -s "$shell" "$username"
echo "$username:$password" | chpasswd
echo "用户$username创建成功"
return 0
}
# 调用函数
create_user "testuser" "Pass@2024"
create_user "devuser" "Dev@2024" "/bin/zsh"
TIP

local关键字最佳实践: 函数内定义的变量默认是全局变量,会污染脚本的命名空间。建议在函数内部使用local关键字声明局部变量,避免与脚本其他部分的变量冲突。

示例:

Terminal window
my_function() {
local local_var="局部变量" # 仅在函数内有效
global_var="全局变量" # 在函数外也可以访问
}

2.6.3 函数的返回值#

Shell里函数返回结果有两种方式,适用场景不同,新手极易混淆,需要明确区分。

方式1:return 返回状态码(官方标准)#

用 return 语句返回,只能返回 0-255之间的整数,一般用来表示函数执行状态(0代表成功,非0代表失败)。调用后通过 $? 获取返回值。

这是Unix哲学的标准做法,所有命令都遵循这个约定。

示例:判断一个数字是否为正数

#!/bin/bash
is_positive() {
if [ $1 -gt 0 ]
then
return 0 # 是正数,返回成功状态
else
return 1 # 非正数,返回失败状态
fi
}
# 调用函数
is_positive 5
if [ $? -eq 0 ]
then
echo "是正数"
else
echo "不是正数"
fi

示例:检查服务是否运行

#!/bin/bash
check_service_running() {
local service_name=$1
if systemctl is-active --quiet $service_name
then
return 0 # 服务运行中
else
return 1 # 服务未运行
fi
}
# 调用
if check_service_running "nginx"
then
echo "nginx服务正常运行"
else
echo "nginx服务未运行,正在启动..."
systemctl start nginx
fi

方式2:echo 返回数据(企业实战最常用)#

由于return只能返回0-255的整数,无法返回字符串、大数字等复杂数据,因此实战中更通用的方式是:函数内部用echo输出结果,调用时用$()捕获输出内容。

这种方式不受返回值范围限制,可以返回任意文本数据,是生产脚本的主流写法。函数的最后一条输出语句的结果会被捕获。

示例:计算两数之和,将结果返回给变量

#!/bin/bash
add() {
echo $(( $1 + $2 ))
}
# 捕获函数的输出,赋值给变量
result=$(add 100 200)
echo "计算结果是:$result"

示例:返回复杂数据(字符串)

#!/bin/bash
get_system_info() {
local hostname=$(hostname)
local kernel=$(uname -r)
local uptime=$(uptime | awk '{print $3}' | sed 's/,//')
# 返回格式化的字符串
echo "主机名:$hostname | 内核:$kernel | 运行时间:$uptime天"
}
# 调用并使用返回值
info=$(get_system_info)
echo "系统信息:$info"

示例:返回多行数据

#!/bin/bash
list_large_files() {
local directory=$1
local size_threshold=${2:-100M} # 默认100MB
# find输出文件列表,echo捕获所有输出
find "$directory" -type f -size +$size_threshold 2>/dev/null | sort -rh
}
# 调用并遍历返回的多行数据
while read filepath
do
echo "大文件:$filepath"
done < <(list_large_files /var 50M)
IMPORTANT

return vs echo 选择指南:

场景推荐方式原因
返回成功/失败状态return符合Unix约定,可被if直接判断
返回单个整数结果return高效,直接通过$?获取
返回字符串echoreturn无法直接返回字符串
返回多行数据echoreturn范围限制,echo可返回任意内容
返回计算结果echo灵活性高,结果可能超过255

实战组合用法:函数同时返回状态码和数据

#!/bin/bash
get_user_home() {
local username=$1
if id "$username" &>/dev/null
then
# 输出数据
echo $(eval echo ~$username)
return 0 # 返回成功状态
else
echo "用户不存在"
return 1 # 返回失败状态
fi
}
# 调用时同时检查状态码和获取数据
home=$(get_user_home "root")
if [ $? -eq 0 ]
then
echo "root用户主目录:$home"
else
echo "获取主目录失败"
fi

2.7 数据流转:输入/输出重定向与黑洞设备#

在运维脚本中,我们经常需要把命令的输出保存到文件,或者屏蔽不需要的报错信息,这就需要用到重定向(Redirection)。

2.7.1 标准输入、输出与错误#

Linux中一切皆文件,每个进程默认打开三个数据流:

  • 0(STDIN):标准输入,默认是键盘输入。
  • 1(STDOUT):标准输出,默认输出到屏幕。
  • 2(STDERR):标准错误,报错信息,默认也输出到屏幕。

2.7.2 输出重定向:> 与 >>#

  • > 或 1>:覆盖输出。把结果写入文件,原内容会被清空。
  • >> 或 1>>:追加输出。把结果写到文件末尾。
终端
echo "第一行" > log.txt # 创建并写入
echo "第二行" >> log.txt # 追加到末尾

2.7.3 错误重定向与黑洞设备#

  • 2>:专门把报错信息写入文件(正确的输出还在屏幕上)。
  • 2>&1 或 &>:合并输出。把正确和报错信息全部写入同一个文件(脚本日志记录必备)。
  • /dev/null:系统黑洞。任何丢进这里的数据都会消失,常用于屏蔽不想看的输出。
终端
# 屏蔽命令的所有输出(不论对错)
ping -c 1 baidu.com &> /dev/null
# 后台运行服务,并记录所有日志
./server_start.sh > app.log 2>&1 &

2.7.4 输入重定向与 Here Document#

  • <:从文件读取数据作为命令的输入。
  • <<:Here Document。在脚本中直接写入多行文本块,极其适合自动生成配置文件!
heredoc_demo.sh
#!/bin/bash
# 自动生成 Nginx 配置文件
cat << EOF > /etc/nginx/conf.d/myweb.conf
server {
listen 80;
server_name www.example.com;
root /var/www/html;
}
EOF
echo "Nginx 配置生成完毕!"

2.8 本章综合实战:完整的系统备份脚本#

学完了本章的所有知识点,我们用一个综合脚本来实战应用,这个脚本涵盖了条件判断、循环、函数等核心内容。

#!/bin/bash
# 完整的系统数据备份脚本
# 支持多个备份源,压缩存储,定期清理过期备份
# ============ 配置段 ============
BACKUP_BASE_DIR="/backup"
BACKUP_RETENTION_DAYS=30 # 保留最近30天的备份
LOG_FILE="$BACKUP_BASE_DIR/backup.log"
# 备份源配置(数组)
declare -a BACKUP_SOURCES=(
"/etc"
"/home"
"/root"
)
# ============ 函数定义 ============
# 初始化备份环境
init_backup_env() {
if [ ! -d "$BACKUP_BASE_DIR" ]
then
mkdir -p "$BACKUP_BASE_DIR"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 备份目录不存在,已创建" | tee -a "$LOG_FILE"
fi
}
# 检查磁盘空间是否足够
check_disk_space() {
local required_space=$1 # MB
local available_space=$(df "$BACKUP_BASE_DIR" | awk 'NR==2 {print $4}') # KB
available_space=$((available_space / 1024)) # 转换为MB
if [ $available_space -lt $required_space ]
then
return 1 # 空间不足
else
return 0 # 空间充足
fi
}
# 备份单个源目录
backup_source() {
local source=$1
local backup_name=$(basename "$source")
local timestamp=$(date '+%Y%m%d_%H%M%S')
local backup_file="$BACKUP_BASE_DIR/${backup_name}_${timestamp}.tar.gz"
# 检查源是否存在
if [ ! -e "$source" ]
then
echo "[ERROR] 源路径不存在:$source" | tee -a "$LOG_FILE"
return 1
fi
# 检查磁盘空间
local source_size=$(du -s "$source" 2>/dev/null | awk '{print $1}')
source_size=$((source_size / 1024)) # 转换为MB
if ! check_disk_space $source_size
then
echo "[ERROR] 磁盘空间不足,无法备份$source(需要${source_size}MB)" | tee -a "$LOG_FILE"
return 1
fi
# 开始备份
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份:$source" | tee -a "$LOG_FILE"
tar -czf "$backup_file" -C "$(dirname "$source")" "$(basename "$source")" 2>/dev/null
if [ $? -eq 0 ]
then
local backup_size=$(du -h "$backup_file" | awk '{print $1}')
echo "[SUCCESS] 备份完成:$backup_file (大小:$backup_size)" | tee -a "$LOG_FILE"
return 0
else
echo "[ERROR] 备份失败:$source" | tee -a "$LOG_FILE"
rm -f "$backup_file"
return 1
fi
}
# 清理过期备份
cleanup_old_backups() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始清理过期备份(保留最近${BACKUP_RETENTION_DAYS}天)" | tee -a "$LOG_FILE"
local delete_count=0
while read backup_file
do
rm -f "$backup_file"
echo "[CLEANUP] 已删除:$backup_file" | tee -a "$LOG_FILE"
delete_count=$((delete_count + 1))
done < <(find "$BACKUP_BASE_DIR" -name "*.tar.gz" -type f -mtime +$BACKUP_RETENTION_DAYS)
if [ $delete_count -gt 0 ]
then
echo "[SUCCESS] 共清理过期备份${delete_count}个" | tee -a "$LOG_FILE"
else
echo "[INFO] 没有需要清理的过期备份" | tee -a "$LOG_FILE"
fi
}
# 显示备份统计信息
show_backup_summary() {
local total_backups=$(find "$BACKUP_BASE_DIR" -name "*.tar.gz" -type f | wc -l)
local total_size=$(du -sh "$BACKUP_BASE_DIR" 2>/dev/null | awk '{print $1}')
echo "" | tee -a "$LOG_FILE"
echo "===== 备份统计 =====" | tee -a "$LOG_FILE"
echo "备份文件总数:$total_backups" | tee -a "$LOG_FILE"
echo "备份总大小:$total_size" | tee -a "$LOG_FILE"
echo "日志文件:$LOG_FILE" | tee -a "$LOG_FILE"
}
# ============ 主程序 ============
main() {
echo "===============================================" | tee -a "$LOG_FILE"
echo "系统备份脚本启动 $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$LOG_FILE"
echo "===============================================" | tee -a "$LOG_FILE"
# 初始化环境
init_backup_env
# 遍历所有备份源,依次备份
local success_count=0
local fail_count=0
for source in "${BACKUP_SOURCES[@]}"
do
if backup_source "$source"
then
success_count=$((success_count + 1))
else
fail_count=$((fail_count + 1))
fi
done
# 清理过期备份
cleanup_old_backups
# 显示总结
echo "" | tee -a "$LOG_FILE"
echo "===== 备份结果 =====" | tee -a "$LOG_FILE"
echo "成功:$success_count 个" | tee -a "$LOG_FILE"
echo "失败:$fail_count 个" | tee -a "$LOG_FILE"
show_backup_summary
echo "===============================================" | tee -a "$LOG_FILE"
# 根据结果返回状态码
if [ $fail_count -eq 0 ]
then
echo "备份完成,所有备份源备份成功" | tee -a "$LOG_FILE"
return 0
else
echo "备份完成,但部分备份源备份失败" | tee -a "$LOG_FILE"
return 1
fi
}
# 执行主程序
main
exit $?

运行示例:

Terminal window
chmod +x backup.sh
./backup.sh
# 输出:
# ===============================================
# 系统备份脚本启动 2024-11-29 15:30:45
# ===============================================
# [2024-11-29 15:30:45] 开始备份:/etc
# [SUCCESS] 备份完成:/backup/etc_20241129_153045.tar.gz (大小:15M)
# ...
NOTE

这个脚本展示了以下核心知识点:

  • 数组定义和遍历(for循环)
  • 函数定义和调用,参数传递
  • 条件判断和文件测试(if-else)
  • 函数返回值和调用后的状态检查
  • 实际的日志记录和错误处理
  • 业务流程的合理设计

2.9 本章知识点梳理与速查表#

2.9.1 本章知识点总结#

  1. 数值运算(2.1)

    • $((...)) 和 let 用于整数计算(推荐)。
    • expr 注意空格和乘号转义。
    • bc 结合管道 | 解决浮点数计算。
  2. 条件判断(2.2)

    • 通过[ 表达式 ]实现条件测试,分为文件测试、数值比较、字符串比较三类
    • 文件测试:-f(普通文件)、-d(目录)、-e(存在)、-r/-w/-x(权限)
    • 数值比较:-eq、-ne、-gt、-lt、-ge、-le
    • 字符串比较:=、!=、-z(空)、-n(非空)
    • if单分支、if-else双分支、if-elif-else多分支
    • exit 提前终止脚本
  3. 多值匹配(2.3)

    • case语句适合固定值匹配场景,比多层if结构更简洁
    • 使用;;结束每个分支,*)作为默认分支
    • 常用于菜单类脚本、参数检查
    • select 语句能自动生成带编号的交互菜单
  4. 循环结构(2.4)

    • for循环:已知循环次数或遍历列表,有两种写法(列表式和C语言式)
    • while循环:条件为真就循环,适合逐行读文件、不确定次数的场景
    • until循环:条件为假就循环,适合等待某状态达成的场景
  5. 循环控制(2.5)

    • break:终止整个循环
    • continue:仅跳过当前一轮循环,进入下一轮
  6. 函数封装(2.6)

    • 函数定义:function name {} 或 name() {}
    • 参数传递:$1、$2、$#、$@
    • 返回值两种方式:
      • return(返回0-255整数,代表状态码)
      • echo(返回任意数据,企业常用)
    • 使用local关键字定义局部变量
  7. 重定向(2.7)

    • > 覆盖,>> 追加。
    • 2>&1 或 &> 合并正确与错误输出。
    • /dev/null 系统黑洞设备。
    • << EOF 生成多行文本配置。

2.9.2 常见语法对比速查表#

Terminal window
# 条件测试快速参考
[ -f /path/to/file ] # 文件存在
[ -d /path/to/dir ] # 目录存在
[ $a -gt $b ] # 数值比较
[ "$str" = "value" ] # 字符串比较
[ $a -gt 5 -a $b -lt 10 ] # 逻辑与
[ $a -gt 5 -o $b -lt 10 ] # 逻辑或
# 循环快速参考
for i in 1 2 3; do echo $i; done # 列表遍历
for i in {1..10}; do echo $i; done # 序列遍历
for (( i=1; i<=10; i++ )); do echo $i; done # C语言式
while [ condition ]; do echo $i; done # while循环
until [ condition ]; do echo $i; done # until循环
# 函数快速参考
myfunc() { echo "hello"; } # 定义函数
myfunc # 调用函数
myfunc arg1 arg2 # 传参调用
result=$(myfunc) # 捕获输出

2.9.3 常见坑点总结#

坑点错误做法正确做法说明
方括号空格[$a -gt 10][ $a -gt 10 ]方括号两侧必须有空格
数值比较[ $a > $b ][ $a -gt $b ]不能用>/<,要用-gt/-lt
字符串为空[ $var = "" ][ -z "$var" ]建议用-z判断空字符串
函数顺序先调用后定义先定义后调用Shell顺序执行,函数必须先定义
函数参数myfunc()myfunc arg1 arg2调用时不需要括号,参数空格分隔
返回数据用return返回字符串用echo返回数据return只支持0-255整数

2.10 进阶练习与扩展#

练习1:监控脚本(综合应用条件判断+循环+函数)#

#!/bin/bash
# 系统监控告警脚本,定期检查CPU、内存、磁盘状态
# 告警阈值配置
CPU_THRESHOLD=80
MEM_THRESHOLD=80
DISK_THRESHOLD=85
# 检查CPU使用率
check_cpu() {
local cpu_usage=$(top -bn1 | grep "Cpu(s)" | awk '{print int($2)}')
if [ $cpu_usage -gt $CPU_THRESHOLD ]
then
echo "警告:CPU使用率过高 - ${cpu_usage}%"
return 1
else
echo "正常:CPU使用率 - ${cpu_usage}%"
return 0
fi
}
# 检查内存使用率
check_memory() {
local mem_usage=$(free | grep Mem | awk '{printf("%.0f\n", ($3/$2)*100)}')
if [ $mem_usage -gt $MEM_THRESHOLD ]
then
echo "警告:内存使用率过高 - ${mem_usage}%"
return 1
else
echo "正常:内存使用率 - ${mem_usage}%"
return 0
fi
}
# 检查磁盘使用率
check_disk() {
local disk_usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ $disk_usage -gt $DISK_THRESHOLD ]
then
echo "警告:磁盘使用率过高 - ${disk_usage}%"
return 1
else
echo "正常:磁盘使用率 - ${disk_usage}%"
return 0
fi
}
# 主监控函数
main() {
echo "===== 系统监控开始 $(date '+%Y-%m-%d %H:%M:%S') ====="
local warn_count=0
if ! check_cpu; then warn_count=$((warn_count+1)); fi
if ! check_memory; then warn_count=$((warn_count+1)); fi
if ! check_disk; then warn_count=$((warn_count+1)); fi
echo "===== 监控完成,告警数:$warn_count ====="
return $warn_count
}
main

练习2:日志分析脚本(结合while循环逐行处理)#

#!/bin/bash
# 分析Apache访问日志,统计IP访问频率和错误日志
log_file="/var/log/apache2/access.log"
error_threshold=5
if [ ! -f "$log_file" ]
then
echo "日志文件不存在:$log_file"
exit 1
fi
echo "===== 日志分析开始 ====="
echo ""
# 方式1:统计访问频率最高的10个IP
echo "访问频率最高的Top10 IP:"
awk '{print $1}' "$log_file" | sort | uniq -c | sort -rn | head -10 | while read count ip
do
echo " $ip: $count次访问"
done
echo ""
# 方式2:统计HTTP状态码分布
echo "HTTP状态码分布:"
awk '{print $9}' "$log_file" | sort | uniq -c | sort -rn | while read count status
do
case $status in
200|304)
echo " $status(成功): $count次"
;;
3*)
echo " $status(重定向): $count次"
;;
4*)
echo " $status(客户端错误): $count次"
;;
5*)
echo " $status(服务器错误): $count次"
;;
*)
echo " $status: $count次"
;;
esac
done
echo ""
echo "===== 日志分析完成 ====="

。

练习3:简易计算器与菜单选择(综合应用select、数值运算)#

#!/bin/bash
# 交互式简易计算器,支持浮点数
PS3="请选择计算模式: "
select opt in "加法" "减法" "乘法" "除法" "退出"
do
if [ "$opt" == "退出" ]
then
echo "再见!"
exit 0
fi
# 校验是否选择了有效菜单
if [ -z "$opt" ]
then
echo "无效选择,请重新输入编号"
continue
fi
read -p "请输入两个数字(空格分隔): " num1 num2
case $opt in
"加法")
echo "$num1 + $num2 = $(echo "$num1 + $num2" | bc)"
;;
"减法")
echo "$num1 - $num2 = $(echo "$num1 - $num2" | bc)"
;;
"乘法")
echo "$num1 * $num2 = $(echo "$num1 * $num2" | bc)"
;;
"除法")
echo "$num1 / $num2 = $(echo "scale=2; $num1 / $num2" | bc)"
;;
esac
done
Shell第二章 核心语法:流程控制与函数封装
https://www.6ixblog.site/posts/linux-shell-2/
作者
Licwic
发布于
2025-05-02
许可协议
CC BY-NC-SA 4.0