第07章 Shell脚本编程
本章是Linux学习的重要转折点。前面几章你学的都是"手动操作"——一行一行敲命令,就像用筷子一口一口吃饭。而这一章,你要学的是"自动化"——写一个脚本,让电脑自己干活,就像给厨师一份菜谱,他照着做就行。
学完本章,你就能让Linux替你完成重复性的工作,从此告别机械劳动,真正体验到"懒人改变世界"的乐趣。
7.1 什么是 Shell
7.1.1 先讲个故事:你、翻译官和外国人
想象一下这样一个场景:
你只懂中文,现在面前站着一个只懂英语的外国人。你想让他帮你搬个箱子,但你说的中文他听不懂,他说的英语你也听不懂。怎么办?
这时候你需要一个"翻译官"。你对翻译官说中文"请帮我搬一下箱子",翻译官把它翻成英语"Please help me move the box",外国人听懂了,就把箱子搬走了。
在Linux的世界里,你就是那个"只懂中文的人",Linux内核就是那个"只懂英语的外国人",而Shell就是你中间的翻译官。
- 你:通过键盘输入命令,比如 ls、cd、mkdir
- Shell(翻译官):接收你输入的命令,翻译成内核能听懂的"机器语言",交给内核去执行
- Linux内核(外国人):真正干活的,帮你创建文件夹、读取文件、管理内存
7.1.2 Shell 的准确定义
用稍微正式一点的话来说:
Shell 是一个命令行解释器(Command Interpreter),它位于用户和操作系统内核之间,负责接收用户输入的命令,将其解释并传递给内核执行,然后把执行结果返回给用户。
打个比方:
- 内核就像是汽车的发动机,是真正的动力来源,但它不会自己运转
- Shell就像是方向盘和油门,你通过它来控制发动机
- 你就是驾驶员,决定要去哪里
7.1.3 Shell 和图形界面有什么区别
你可能会问:那我平时用鼠标点击文件夹、双击打开文件,那不也是在操作电脑吗?这跟Shell有什么关系?
好问题!这里有两种"翻译官":
图形界面(GUI):就是你平时看到的窗口、图标、按钮。你用鼠标点击,它帮你翻译成命令。就像一个会手语的翻译官,用手势交流。
命令行界面(CLI / Shell):就是你敲字的那种黑底白字的窗口。你用键盘输入文字命令。就像一个用嘴巴说话的翻译官,用语言交流。
两种方式都能达到目的,但Shell有几个巨大的优势:
- 效率高:一行命令能完成图形界面要点十几次的操作
- 可自动化:可以把很多命令写成一个文件,让电脑自动执行
- 远程管理方便:通过SSH连接服务器时,通常只能用命令行
- 功能更强大:很多高级操作图形界面根本做不到
7.1.4 常见的 Shell 有哪些
就像翻译官有很多种(英语翻译、法语翻译、日语翻译),Shell也有不同的"品种":
| sh | Bourne Shell | 最古老的Shell,几乎所有Unix系统都有 |
| bash | Bourne Again Shell | sh的升级版,Linux默认Shell,最常用 |
| csh | C Shell | 语法类似C语言,BSD系统常用 |
| tcsh | TENEX C Shell | csh的增强版 |
| zsh | Z Shell | 功能最强,支持主题和插件,Mac默认 |
| fish | Friendly Interactive Shell | 对新手最友好,开箱即用 |
其中,bash 是最重要的,因为:
所以本章我们重点学习bash。
7.1.5 查看你的 Shell
打开终端,输入以下命令可以查看你当前使用的Shell:
echo $SHELL
输出可能类似这样:
/bin/bash
这说明你当前用的是bash。
如果你想知道系统里装了哪些Shell,可以输入:
cat /etc/shells
输出类似:
/bin/sh
/bin/bash
/usr/bin/sh
/usr/bin/bash
/bin/zsh
如果你想临时切换到别的Shell试试,直接输入它的名字就行,比如输入 zsh 就切换到zsh,输入 exit 退回原来的Shell。
7.1.6 Shell 脚本是什么
前面我们说的都是"交互式Shell"——你输一条命令,它执行一条。这就像你口述菜谱,厨师做一道菜。
但如果你想一次做一桌菜呢?难道要一道一道口述?当然不用,你把菜谱写在纸上,交给厨师,他照着纸一张张做就行。
Shell脚本就是这个"写在纸上的菜谱"。
把一系列命令写到一个文本文件里,然后让Shell一次性执行这个文件里的所有命令,这个文件就叫Shell脚本(也叫Shell脚本程序)。
比如下面就是一个最简单的Shell脚本:
#!/bin/bash
# 这是一个打招呼的脚本
echo "你好,世界!"
echo "今天是:"
date
echo "当前登录用户是:"
whoami
把上面这段代码保存到一个文件里(比如 hello.sh),然后运行它,它就会依次执行里面的每一条命令。
这就是Shell脚本的魔力——你写一次,可以运行无数次。
7.2 Bash Shell 基础
7.2.1 创建你的第一个脚本
纸上得来终觉浅,我们马上动手写第一个脚本。
第一步:创建脚本文件
打开终端,输入:
vim first.sh
这会用vim编辑器打开(或创建)一个名为 first.sh 的文件。文件名你可以随便起,但习惯上Shell脚本用 .sh 后缀,就像Word文档用 .docx,图片用 .jpg 一样,方便识别。
第二步:输入内容
按 i 进入插入模式,输入以下内容:
#!/bin/bash
# 这是我的第一个Shell脚本
echo "Hello World!"
echo "我的第一个脚本运行成功了!"
输入完后,按 Esc,然后输入 :wq 保存退出。
第三步:给脚本加执行权限
在Linux里,新建的文件默认是没有"执行权限"的。就像你写了一份菜谱,但厨师不让你贴在厨房墙上,你得先获得许可。
输入以下命令赋予执行权限:
chmod +x first.sh
chmod 是"change mode"(改变模式)的缩写,+x 表示"加上执行(eXecute)权限"。
第四步:运行脚本
./first.sh
如果你看到下面的输出,恭喜你,你的第一个脚本运行成功了!
Hello World!
我的第一个脚本运行成功了!
7.2.2 理解脚本的结构
我们来逐行分析刚才那个脚本:
#!/bin/bash
这一行叫Shebang(读作"希邦"),它是 # 和 ! 两个符号的组合。它的作用是告诉系统:“请用 /bin/bash 这个程序来解释执行本文件”。
就像你写了一份法语菜谱,在最上面标注"本菜谱用法语阅读",这样厨师一看就知道该怎么读。
注意:Shebang必须是文件的第一行,而且 # 和 ! 之间不能有空格。
# 这是我的第一个Shell脚本
这一行是注释。以 # 开头的行会被Shell忽略,不执行。它的作用是给人看的,用来解释代码的作用。写注释是一个好习惯,就像在书本上做批注,帮自己(和同事)理解代码。
echo "Hello World!"
echo 是"回声"的意思,它的作用是把后面的内容"喊"出来——也就是输出到屏幕上。就像你在山谷里喊"你好",山谷回应你"你好"一样。
7.2.3 脚本的几种运行方式
除了刚才的 ./first.sh,还有其他几种运行脚本的方式,各有用途:
方式一:相对路径运行(最常用)
./first.sh
需要先 chmod +x 加执行权限。./ 表示"当前目录下"。
方式二:绝对路径运行
/home/user/first.sh
同样需要执行权限。好处是不管你在哪个目录都能运行。
方式三:用bash命令运行(不需要执行权限)
bash first.sh
这种方式直接告诉系统"用bash来运行这个文件",所以不需要加执行权限,也不需要Shebang行。适合调试的时候用。
方式四:用source或点号运行
source first.sh
# 或者
. first.sh
这种方式的特殊之处在于:它在当前Shell环境中执行脚本,而不是开一个新的子Shell。
什么意思呢?打个比方:
- 前三种方式就像你去朋友家做饭——用的是朋友家的厨房(子Shell),你做完了出来,朋友家的厨房有什么变化你带走不了。
- source方式就像在你自己家做饭——用的是你自己的厨房(当前Shell),做的菜和产生的影响都在你自己家。
这种区别在后面讲变量的时候会很重要。
7.2.4 脚本的基本书写规范
写Shell脚本就像写作文,有一些格式上的"规矩":
#!/bin/bash
#=============================================================
# 脚本名称:backup.sh
# 脚本功能:备份重要文件
# 作者:张三
# 创建日期:2024-01-15
# 修改记录:无
#=============================================================
# ——– 第一部分:定义变量 ——–
BACKUP_DIR="/backup"
SOURCE_DIR="/home/user/data"
# ——– 第二部分:开始备份 ——–
echo "开始备份…"
mkdir -p $BACKUP_DIR
cp -r $SOURCE_DIR $BACKUP_DIR/
echo "备份完成!"
注意几个要点:
7.2.5 一个稍微复杂一点的示例
来感受一下Shell脚本能做什么。下面这个脚本可以显示系统的基本信息:
#!/bin/bash
# 显示系统基本信息
echo "========== 系统信息报告 =========="
echo ""
echo "【主机名】"
hostname
echo ""
echo "【系统版本】"
cat /etc/os-release | head -n 5
echo ""
echo "【内核版本】"
uname -r
echo ""
echo "【CPU信息】"
lscpu | grep "Model name"
echo ""
echo "【内存使用情况】"
free -h
echo ""
echo "【磁盘使用情况】"
df -h
echo ""
echo "【当前时间】"
date
echo ""
echo "========== 报告结束 =========="
你看,只要运行这一个脚本,就能一次性看到主机名、系统版本、内核版本、CPU、内存、磁盘、时间等信息,再也不用一条一条命令去敲了。这就是脚本的魅力!
7.3 变量定义与使用
7.3.1 什么是变量——用盒子来理解
还记得小学数学吗?老师会说:“设 x = 5”,然后问 “x + 3 等于多少?”
这里的 x 就是一个变量——它就像一个贴了标签的盒子,你可以往里面放东西(赋值),也可以拿出来用(引用)。
在Shell中,变量也是这个概念:
name="张三"
这行代码做了什么?它创建了一个名叫 name 的盒子,在里面放了"张三"这个字符串。
以后你想用"张三"的时候,不用每次都写一遍,只要说"把name盒子里的东西给我"就行:
echo $name
输出:
张三
7.3.2 变量定义的规则——最容易踩的坑
Shell里定义变量有一个非常奇葩的规则,很多新手都会踩坑:
等号两边绝对不能有空格!
# 正确写法(等号两边没空格)
name="张三"
# 错误写法1(等号左边有空格)
name ="张三"
# 错误写法2(等号右边有空格)
name= "张三"
# 错误写法3(两边都有空格)
name = "张三"
为什么Shell对空格这么敏感?因为在Shell里,空格是"命令和参数的分隔符"。
- name="张三" 被理解为:给变量name赋值"张三"
- name = "张三" 被理解为:运行一个名叫 name 的命令,参数是 = 和 "张三"
这跟其他编程语言(如Python、Java)完全不一样,所以从其他语言转过来的同学特别容易出错。
记住这个口诀:Shell赋值,等号贴脸,不贴就报错。
7.3.3 变量的命名规则
变量名不能随便起,有规矩:
# 合法的变量名
name="张三"
age=25
_user="admin"
my_home="/home/user"
URL="https://www.example.com"
# 非法的变量名
2name="李四" # 不能以数字开头
my-name="王五" # 不能用减号
my name="赵六" # 不能有空格
if="测试" # 不能用关键字
7.3.4 变量的赋值方式
方式一:直接赋值
name="张三"
age=25
height=175
注意:等号右边的值如果有空格,必须用引号包起来:
# 正确
greeting="你好 世界"
# 错误(Shell会以为 greeting="你好" 然后世界是另一个命令)
greeting=你好 世界
方式二:将命令结果赋给变量
用反引号 `命令` 或者 $() 包裹命令:
# 两种写法效果一样
current_date=`date +%Y-%m-%d`
current_date=$(date +%Y-%m-%d)
echo $current_date
推荐用 $() 这种写法,因为它更清晰,而且支持嵌套。
# 获取当前目录下的文件数量
file_count=$(ls | wc -l)
echo "当前目录有 $file_count 个文件"
# 获取当前用户名
user=$(whoami)
echo "你好,$user!"
方式三:交互式赋值(用read命令,后面会详细讲)
echo "请输入你的名字:"
read name
echo "你好,$name!"
7.3.5 使用变量——美元符号$
定义变量时不用 $,但使用变量时必须加 $:
name="张三"
# 定义时不用$
# 使用时必须用$
echo $name # 输出:张三
echo "我的名字是$name" # 输出:我的名字是张三
这就像:你往盒子里放东西的时候不用说"这是个盒子"(定义),但你想从盒子里拿东西的时候,得指明"我要从那个盒子里拿"(引用)。
花括号的作用
有时候变量名会和后面的文字"粘"在一起,Shell分不清哪里是变量名:
fruit="苹果"
echo "我喜欢吃$fruit汁" # Shell可能以为变量名是fruit汁
这时候用花括号 {} 明确告诉Shell变量名的范围:
fruit="苹果"
echo "我喜欢吃${fruit}汁" # 输出:我喜欢吃苹果汁
养成好习惯:在字符串中使用变量时,都用 ${变量名} 这种写法。
7.3.6 只读变量
有些变量你不想让别人修改,可以用 readonly 声明为只读:
PI=3.14159
readonly PI
PI=3.14 # 会报错:bash: PI: readonly variable
就像图书馆的书,你可以看,但不能在上面写字。
7.3.7 删除变量
用 unset 可以删除变量:
name="张三"
echo $name # 输出:张三
unset name
echo $name # 输出:空(什么都没有)
注意:只读变量不能被unset删除。
7.3.8 变量的类型——Shell没有真正的类型
在其他语言里(如Java、C),变量有明确的类型:整数就是整数,字符串就是字符串,不能混。
但在Shell里,所有变量的值都是字符串!
age=25
你以为 age 是个数字25?不,Shell觉得它是字符串"25"。只不过在你需要做数学运算的时候,Shell会临时把它当数字处理。
a=10
b=20
# 这不是数学加法,是字符串拼接
c=$a+$b
echo $c # 输出:10+20(不是30)
要做真正的数学运算,需要用特殊的方法,我们后面7.6节会讲。
7.3.9 完整示例:个人信息卡片
#!/bin/bash
# 个人信息卡片生成器
# 定义变量
name="李明"
age=22
gender="男"
city="北京"
job="程序员"
# 输出信息卡片
echo "========================="
echo " 个人信息卡片"
echo "========================="
echo "姓名:${name}"
echo "年龄:${age}岁"
echo "性别:${gender}"
echo "城市:${city}"
echo "职业:${job}"
echo "========================="
echo "生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
运行结果:
=========================
个人信息卡片
=========================
姓名:李明
年龄:22岁
性别:男
城市:北京
职业:程序员
=========================
生成时间:2024-01-15 14:30:25
7.4 环境变量与位置参数
7.4.1 局部变量 vs 环境变量——房间里的灯vs楼道里的灯
前面我们定义的变量都是局部变量(也叫自定义变量)。它们有个特点:只在当前Shell中有效,子进程看不到。
打个比方:
- 你在自己房间里开了一盏灯(定义局部变量),只有你在房间里能看到,隔壁房间的人看不到。
- 如果你在楼道里开了一盏灯(环境变量),所有房间的人都能看到。
看这个例子:
# 定义局部变量
my_var="你好"
# 开一个子Shell
bash
# 在子Shell里尝试访问
echo $my_var # 什么都不会输出!
因为 my_var 是局部变量,子Shell看不到。
如果想让所有子进程都能看到,就要用 export 把它变成环境变量:
# 定义并导出为环境变量
export my_var="你好"
# 开一个子Shell
bash
# 在子Shell里访问
echo $my_var # 输出:你好
7.4.2 export 命令详解
export 的作用就是把局部变量"升级"为环境变量。有两种写法:
# 写法一:先定义,再导出
NAME="张三"
export NAME
# 写法二:定义的同时导出(推荐)
export NAME="张三"
环境变量的典型用途是配置程序的行为。比如:
# 设置语言环境
export LANG="zh_CN.UTF-8"
# 设置Java路径
export JAVA_HOME="/usr/lib/jvm/java-11"
# 设置PATH(让系统知道去哪里找程序)
export PATH=$PATH:/usr/local/myapp/bin
7.4.3 查看环境变量
查看所有环境变量:
env
# 或者
printenv
查看单个环境变量:
echo $PATH
echo $HOME
echo $USER
查看所有变量(包括局部变量和环境变量):
set
7.4.4 常见系统环境变量
系统预先定义好了很多环境变量,常用的有:
| $HOME | 用户主目录 | /home/user |
| $USER | 当前用户名 | user |
| $PWD | 当前工作目录 | /home/user/docs |
| $SHELL | 当前Shell路径 | /bin/bash |
| $PATH | 命令搜索路径 | /usr/bin:/bin:… |
| $HOSTNAME | 主机名 | myserver |
| $LANG | 语言设置 | zh_CN.UTF-8 |
| $UID | 用户ID | 1000 |
| $RANDOM | 随机数(0-32767) | 29384 |
| $? | 上一条命令的返回值 | 0 |
其中 $PATH 非常重要,它决定了Shell去哪些目录找可执行命令。
举个例子:
echo $PATH
# 输出:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
当你输入 ls 的时候,Shell会依次去这些目录里找有没有叫 ls 的程序,找到就执行。如果这些目录里都没有 ls,就会报"command not found"。
7.4.5 让环境变量永久生效
用 export 设置的环境变量,关闭终端后就没了。要想永久生效,要写到配置文件里:
对当前用户生效:
# 编辑 ~/.bashrc 文件
vim ~/.bashrc
# 在文件末尾加上
export MY_VAR="你好"
# 保存后让配置立即生效
source ~/.bashrc
对所有用户生效:
# 编辑 /etc/environment 或 /etc/profile
sudo vim /etc/profile
# 在文件末尾加上
export MY_VAR="你好"
7.4.6 位置参数——给脚本传参数
还记得怎么运行脚本吗?./script.sh
如果我想在运行脚本的时候传一些数据进去呢?比如:
./greet.sh 张三 25
这里 张三 和 25 就是传给脚本的参数。在脚本内部,用位置参数来接收它们:
| $0 | 脚本本身的文件名 |
| $1 | 第1个参数 |
| $2 | 第2个参数 |
| $3 | 第3个参数 |
| … | 以此类推 |
| ${10} | 第10个参数(两位数要加花括号) |
| $# | 参数的总个数 |
| $* | 所有参数(作为一个整体字符串) |
| $@ | 所有参数(每个参数独立) |
| $$ | 当前脚本的进程号(PID) |
来个例子:
#!/bin/bash
# 参数演示脚本 param.sh
echo "脚本名称:$0"
echo "第一个参数:$1"
echo "第二个参数:$2"
echo "参数总个数:$#"
echo "所有参数(\\$*):$*"
echo "所有参数(\\$@):$@"
echo "当前进程号:$$"
运行:
chmod +x param.sh
./param.sh 张三 李四 王五
输出:
脚本名称:./param.sh
第一个参数:张三
第二个参数:李四
参数总个数:3
所有参数($*):张三 李四 王五
所有参数($@):张三 李四 王五
当前进程号:12345
7.4.7 $* 和 $@ 的区别
这两个看起来一样,但在被引号包裹时有区别:
#!/bin/bash
# 区别演示 diff.sh
echo "— 使用 \\$* —"
for arg in "$*"
do
echo $arg
done
echo "— 使用 \\$@ —"
for arg in "$@"
do
echo $arg
done
运行:
./diff.sh aaa bbb ccc
输出:
— 使用 $* —
aaa bbb ccc # 所有参数合成了一个整体
— 使用 $@ —
aaa # 每个参数独立
bbb
ccc
记住这个区别:"$*" 把所有参数当成一个整体,"$@" 把每个参数当成独立的个体。通常用 "$@" 更多。
7.4.8 位置参数的移位:shift
shift 命令可以把所有位置参数左移一位:$2变成$1,$3变成$2,原来的$1被丢弃。
#!/bin/bash
# shift演示
echo "移位前:\\$1=$1, \\$2=$2, \\$3=$3"
shift
echo "移位一次:\\$1=$1, \\$2=$2, \\$3=$3"
shift 2
echo "移位两次:\\$1=$1, \\$2=$2"
运行 ./shift.sh A B C D E,输出:
移位前:$1=A, $2=B, $3=C
移位一次:$1=B, $2=C, $3=D
移位两次:$1=E, $2=
shift 常用在while循环中逐个处理参数:
#!/bin/bash
# 逐个处理参数
while [ $# -gt 0 ]
do
echo "当前参数:$1"
shift
done
7.4.9 实战:计算器脚本
#!/bin/bash
# 简单计算器 calc.sh
if [ $# -ne 3 ]; then
echo "用法:$0 数字 运算符 数字"
echo "示例:$0 10 + 20"
exit 1
fi
num1=$1
operator=$2
num2=$3
case $operator in
+) result=$(echo "$num1 + $num2" | bc) ;;
–) result=$(echo "$num1 – $num2" | bc) ;;
\\*) result=$(echo "$num1 * $num2" | bc) ;;
/) result=$(echo "scale=2; $num1 / $num2" | bc) ;;
*) echo "不支持的运算符:$operator"; exit 1 ;;
esac
echo "$num1 $operator $num2 = $result"
7.5 字符串操作与数组
7.5.1 字符串的两种表示方式
在Shell中,字符串可以用单引号或双引号包裹,但两者有重要区别:
单引号:原样输出,不解析变量
name="张三"
echo '你好,$name' # 输出:你好,$name(原样输出)
双引号:会解析变量和命令
name="张三"
echo "你好,$name" # 输出:你好,张三(变量被解析了)
打个比方:
- 单引号就像一个"密不透风的盒子",里面是什么就是什么,不解释
- 双引号就像一个"半透明的盒子",里面的变量和命令能被"看穿"并替换
什么时候用单引号,什么时候用双引号?
- 需要解析变量时用双引号:"你好,$name"
- 不想被解析时用单引号:'$符号表示美元'
- 不确定的时候,默认用双引号(更安全,避免空格问题)
7.5.2 字符串的拼接
Shell拼接字符串特别简单——直接挨着写就行:
# 方式一:直接相连
greeting="你好,""世界"
echo $greeting # 输出:你好,世界
# 方式二:用变量拼接
part1="你好,"
part2="世界"
greeting=$part1$part2
echo $greeting # 输出:你好,世界
# 方式三:在双引号中拼接
name="张三"
greeting="你好,${name}!欢迎!"
echo $greeting # 输出:你好,张三!欢迎!
7.5.3 获取字符串长度
用 ${#变量名} 获取字符串长度:
str="Hello World"
echo ${#str} # 输出:11
name="张三"
echo ${#name} # 输出:2(中文字符也算1个)
7.5.4 截取子字符串
截取字符串有好几种方式,记住下面这几种就够了:
方式一:从第几个字符开始,截取几个字符
语法:${变量名:起始位置:截取长度}
注意:位置从0开始算。
str="Hello World"
# 从第0个位置截取5个字符
echo ${str:0:5} # 输出:Hello
# 从第6个位置截取5个字符
echo ${str:6:5} # 输出:World
# 从第6个位置截取到末尾(不写长度)
echo ${str:6} # 输出:World
方式二:从右边开始截取(用#和##)
- ${变量名#匹配模式}:从左边删除最短匹配
- ${变量名##匹配模式}:从左边删除最长匹配
file="photo.tar.gz"
# 删掉第一个.及左边所有内容
echo ${file#*.} # 输出:tar.gz
# 删掉最后一个.及左边所有内容
echo ${file##*.} # 输出:gz
方式三:从右边开始截取(用%和%%)
- ${变量名%匹配模式}:从右边删除最短匹配
- ${变量名%%匹配模式}:从右边删除最长匹配
file="photo.tar.gz"
# 删掉最后一个.及右边所有内容
echo ${file%.*} # 输出:photo.tar
# 删掉第一个.及右边所有内容
echo ${file%%.*} # 输出:photo
这些操作在处理文件名和扩展名时特别有用。
7.5.5 字符串替换
用 / 可以替换字符串中的内容:
str="Hello World World"
# 替换第一个匹配
echo ${str/World/Linux} # 输出:Hello Linux World
# 替换所有匹配(用两个/)
echo ${str//World/Linux} # 输出:Hello Linux Linux
# 从开头匹配替换(用#)
str2="Hello World"
echo ${str2/#Hello/Hi} # 输出:Hi World
# 从末尾匹配替换(用%)
echo ${str2/%World/Earth} # 输出:Hello Earth
7.5.6 字符串综合示例
#!/bin/bash
# 字符串操作演示 string_ops.sh
filename="report.2024.01.15.pdf"
echo "原始字符串:$filename"
echo "字符串长度:${#filename}"
# 提取各部分
echo "文件名(不含扩展名):${filename%.*}"
echo "主扩展名:${filename##*.}"
echo "日期部分:${filename#*.}" # 2024.01.15.pdf
echo "去掉.pdf后:${filename%.pdf}" # report.2024.01.15
# 替换
echo "替换.为_:${filename//./_}" # report_2024_01_15_pdf
# 截取
echo "截取前6个字符:${filename:0:6}" # report
7.5.7 数组——一排带编号的盒子
变量是一个盒子,数组就是一排带编号的盒子。
想象一下超市的储物柜,一排柜子,每个柜子都有编号(0号、1号、2号…),每个柜子里可以放不同的东西。
在Shell中定义数组:
# 方式一:用括号定义
fruits=("苹果" "香蕉" "橘子" "葡萄")
# 方式二:逐个赋值
fruits[0]="苹果"
fruits[1]="香蕉"
fruits[2]="橘子"
fruits[3]="葡萄"
# 方式三:用declare声明
declare -a colors
colors[0]="红"
colors[1]="绿"
colors[2]="蓝"
注意:数组的编号(索引)从0开始,不是从1开始!这是大部分编程语言的惯例。
7.5.8 读取数组元素
fruits=("苹果" "香蕉" "橘子" "葡萄")
# 读取单个元素
echo ${fruits[0]} # 输出:苹果
echo ${fruits[1]} # 输出:香蕉
echo ${fruits[2]} # 输出:橘子
# 读取所有元素
echo ${fruits[@]} # 输出:苹果 香蕉 橘子 葡萄
echo ${fruits[*]} # 输出:苹果 香蕉 橘子 葡萄
# 获取数组长度
echo ${#fruits[@]} # 输出:4
echo ${#fruits[*]} # 输出:4
# 获取单个元素的长度
echo ${#fruits[0]} # 输出:2("苹果"有2个字符)
7.5.9 遍历数组
#!/bin/bash
# 遍历数组 array_loop.sh
students=("张三" "李四" "王五" "赵六" "钱七")
# 方式一:用for遍历值
echo "=== 方式一 ==="
for student in "${students[@]}"
do
echo "学生:$student"
done
# 方式二:用索引遍历
echo "=== 方式二 ==="
for i in "${!students[@]}"
do
echo "第$((i+1))个学生:${students[$i]}"
done
# 方式三:用while遍历
echo "=== 方式三 ==="
i=0
while [ $i -lt ${#students[@]} ]
do
echo "索引$i:${students[$i]}"
let i++
done
7.5.10 数组的增删改
#!/bin/bash
# 数组增删改 array_ops.sh
# 定义数组
nums=(10 20 30 40 50)
echo "原始数组:${nums[@]}"
# 修改元素
nums[2]=300
echo "修改后:${nums[@]}" # 10 20 300 40 50
# 在末尾添加元素
nums+=(60 70)
echo "添加后:${nums[@]}" # 10 20 300 40 50 60 70
# 删除元素
unset nums[1]
echo "删除索引1后:${nums[@]}" # 10 300 40 50 60 70
echo "索引1的值:${nums[1]}" # 空(被删除了)
# 注意:删除后数组索引不连续!
echo "数组长度:${#nums[@]}"
7.5.11 关联数组(字典)
Bash 4.0以上版本支持关联数组,就是可以用字符串做索引的数组(类似Python的字典):
#!/bin/bash
# 关联数组 assoc.sh
# 声明关联数组
declare -A person
# 赋值(用字符串做索引)
person[name]="张三"
person[age]=25
person[city]="北京"
person[job]="程序员"
# 读取
echo "姓名:${person[name]}"
echo "年龄:${person[age]}"
echo "城市:${person[city]}"
# 遍历所有值
echo "所有信息:"
for key in "${!person[@]}"
do
echo " $key = ${person[$key]}"
done
输出:
姓名:张三
年龄:25
城市:北京
所有信息:
name = 张三
age = 25
city = 北京
job = 程序员
7.6 算术运算与expr
7.6.1 Shell算术运算的痛点
在其他语言里,做加减乘除特别简单:
# Python
a = 10
b = 20
c = a + b # c就是30
但在Shell里,因为所有变量都是字符串,直接用 + 是不行的:
a=10
b=20
c=$a+$b
echo $c # 输出:10+20(不是30!)
Shell觉得 $a 和 $b 是字符串"10"和"20",+ 只是它们中间的一个字符,拼在一起就是"10+20"。
要告诉Shell"我要做数学运算,不是字符串拼接",需要用特殊的方式。
7.6.2 方法一:expr命令
expr 是"expression"(表达式)的缩写,专门用来做数学运算:
a=10
b=20
# 加法
sum=`expr $a + $b`
echo $sum # 输出:30
# 减法
diff=$(expr $a – $b)
echo $diff # 输出:-10
# 乘法(注意:乘号要转义!)
product=$(expr $a \\* $b)
echo $product # 输出:200
# 除法
quotient=$(expr $b / $a)
echo $quotient # 输出:2
# 取余
remainder=$(expr $b % 3)
echo $remainder # 输出:2
注意几个坑:
7.6.3 方法二:双小括号 $(( ))(推荐)
这是最常用、最方便的方式:
a=10
b=20
# 加法
echo $((a + b)) # 输出:30
# 减法
echo $((a – b)) # 输出:-10
# 乘法(不需要转义!)
echo $((a * b)) # 输出:200
# 除法
echo $((b / a)) # 输出:2
# 取余
echo $((b % 3)) # 输出:2
# 幂运算(expr不支持,但双括号支持)
echo $((2 ** 10)) # 输出:1024
双小括号的优点:
赋值给变量:
result=$((a + b))
echo $result # 30
# 也可以直接修改变量自身
x=5
x=$((x + 1)) # x变成6
echo $x
7.6.4 方法三:let命令
a=10
b=20
let sum=a+b
echo $sum # 30
let "product = a * b"
echo $product # 200
# 自增自减
let a++
echo $a # 11
let b–
echo $b # 19
# 复合赋值
let a+=5
echo $a # 16
let 和双括号 $(()) 功能类似,但 let 后面不需要 $。
7.6.5 方法四:bc命令(支持小数)
前面三种方法都只支持整数运算。如果要做小数运算,需要用 bc(basic calculator):
# 基本用法
echo "10 + 20" | bc # 30
echo "10.5 + 20.3" | bc # 30.8
# 除法保留小数
echo "scale=2; 10 / 3" | bc # 3.33(scale=2表示保留2位小数)
# 乘方
echo "2 ^ 10" | bc # 1024
# 求平方根
echo "sqrt(144)" | bc # 12
在脚本中使用bc:
#!/bin/bash
# 计算圆的面积 circle.sh
read -p "请输入圆的半径:" radius
# 用bc计算(保留2位小数)
area=$(echo "scale=2; 3.14 * $radius * $radius" | bc)
echo "半径为 $radius 的圆,面积是 $area"
7.6.6 自增自减运算
#!/bin/bash
# 自增自减演示
x=10
# 方式一:用双括号
((x++)) # x = 11
echo $x
((x—)) # x = 10
echo $x
# 方式二:用let
let x++ # x = 11
echo $x
let x+=5 # x = 16
echo $x
# 方式三:传统方式
x=$((x + 1))
echo $x
7.6.7 算术运算综合示例
#!/bin/bash
# 简易成绩计算器 grade.sh
read -p "请输入语文成绩:" chinese
read -p "请输入数学成绩:" math
read -p "请输入英语成绩:" english
# 计算总分(整数运算)
total=$((chinese + math + english))
# 计算平均分(需要小数,用bc)
average=$(echo "scale=2; $total / 3" | bc)
echo ""
echo "========== 成绩单 =========="
echo "语文:$chinese"
echo "数学:$math"
echo "英语:$english"
echo "总分:$total"
echo "平均分:$average"
echo "============================="
7.6.8 数值比较
在条件判断中经常需要比较数字大小,Shell提供了这些运算符:
| -eq | 等于 (equal) | [ $a -eq $b ] |
| -ne | 不等于 (not equal) | [ $a -ne $b ] |
| -gt | 大于 (greater than) | [ $a -gt $b ] |
| -lt | 小于 (less than) | [ $a -lt $b ] |
| -ge | 大于等于 | [ $a -ge $b ] |
| -le | 小于等于 | [ $a -le $b ] |
在双括号中也可以用数学符号:
a=10
b=20
# 传统写法
if [ $a -lt $b ]; then
echo "$a 小于 $b"
fi
# 双括号写法(更直观)
if ((a < b)); then
echo "$a 小于 $b"
fi
7.7 条件判断 if 语句
7.7.1 如果…就…——生活中的条件判断
我们每天都在做条件判断:
- 如果下雨,就带伞
- 如果红灯,就停下来;否则继续走
- 如果成绩大于90,就评优;如果大于60,就及格;否则不及格
Shell的 if 语句就是干这个的,让程序根据不同的情况做不同的事。
7.7.2 if 语句的三种形式
形式一:单分支 if
语法:
if [ 条件 ]; then
命令
fi
示例:
#!/bin/bash
# 判断是否成年
age=20
if [ $age -ge 18 ]; then
echo "你已经成年了!"
fi
注意几个细节:
then
命令
fi
形式二:双分支 if-else
语法:
if [ 条件 ]; then
命令1
else
命令2
fi
示例:
#!/bin/bash
# 判断成绩是否及格
score=55
if [ $score -ge 60 ]; then
echo "恭喜,及格了!"
else
echo "抱歉,不及格!"
fi
形式三:多分支 if-elif-else
语法:
if [ 条件1 ]; then
命令1
elif [ 条件2 ]; then
命令2
elif [ 条件3 ]; then
命令3
else
命令n
fi
示例:
#!/bin/bash
# 成绩等级评定
score=85
if [ $score -ge 90 ]; then
echo "优秀!"
elif [ $score -ge 80 ]; then
echo "良好!"
elif [ $score -ge 70 ]; then
echo "中等!"
elif [ $score -ge 60 ]; then
echo "及格!"
else
echo "不及格!"
fi
7.7.3 test命令和方括号[]
其实 [ ] 是 test 命令的简写,下面两种写法完全等价:
# 写法一:用test
if test $a -eq $b; then
echo "相等"
fi
# 写法二:用方括号(更常用)
if [ $a -eq $b ]; then
echo "相等"
fi
[ ] 更常用,因为更简洁。但要注意 [ 后面和 ] 前面必须有空格!
7.7.4 条件判断的种类
一、数字比较
| -eq | 等于 |
| -ne | 不等于 |
| -gt | 大于 |
| -lt | 小于 |
| -ge | 大于等于 |
| -le | 小于等于 |
a=10
b=20
if [ $a -lt $b ]; then
echo "$a 小于 $b"
fi
二、字符串比较
| = | 相等 |
| != | 不相等 |
| -z | 字符串为空(长度为0) |
| -n | 字符串非空 |
str1="hello"
str2="world"
if [ $str1 = $str2 ]; then
echo "相等"
else
echo "不相等"
fi
# 判断字符串是否为空
name=""
if [ -z $name ]; then
echo "name是空的"
fi
三、文件判断
这是Shell特有的,用来判断文件的各种状态:
| -e 文件 | 文件存在 |
| -f 文件 | 存在且是普通文件 |
| -d 文件 | 存在且是目录 |
| -r 文件 | 存在且可读 |
| -w 文件 | 存在且可写 |
| -x 文件 | 存在且可执行 |
| -s 文件 | 存在且非空 |
| 文件1 -nt 文件2 | 文件1比文件2新 |
| 文件1 -ot 文件2 | 文件1比文件2旧 |
#!/bin/bash
# 文件检查 file_check.sh
file="/etc/passwd"
if [ -e $file ]; then
echo "文件存在"
if [ -f $file ]; then
echo "是普通文件"
elif [ -d $file ]; then
echo "是目录"
fi
if [ -r $file ]; then
echo "可读"
fi
if [ -w $file ]; then
echo "可写"
fi
if [ -x $file ]; then
echo "可执行"
fi
else
echo "文件不存在"
fi
7.7.5 逻辑运算符
就像数学里有"且"、“或”、“非”,Shell也有:
| -a 或 && | 逻辑与(且) | [ $a -gt 0 -a $a -lt 10 ] |
| -o 或 || | 逻辑或 | [ $a -lt 0 -o $a -gt 100 ] |
| ! | 逻辑非 | [ ! -e $file ] |
#!/bin/bash
# 逻辑运算演示
age=25
gender="男"
# 逻辑与:两个条件都满足
if [ $age -ge 22 -a $gender = "男" ]; then
echo "达到法定结婚年龄"
fi
# 逻辑或:满足一个即可
temperature=38
if [ $temperature -lt 0 -o $temperature -gt 37 ]; then
echo "体温异常!"
fi
# 逻辑非
file="/not_exist"
if [ ! -e $file ]; then
echo "文件不存在"
fi
用&&和||的简写形式:
# 如果命令1成功,就执行命令2
command1 && command2
# 如果命令1失败,就执行命令2
command1 || command2
# 实际例子
[ -d /tmp/test ] || mkdir -p /tmp/test
# 如果目录不存在,就创建它
[ -f config.txt ] && echo "配置文件存在"
# 如果文件存在,就输出提示
7.7.6 双括号的高级条件判断
用 (( )) 做数学比较更直观:
a=10
b=20
# 传统写法
if [ $a -lt $b ]; then
echo "$a < $b"
fi
# 双括号写法(可以用数学符号了!)
if ((a < b)); then
echo "$a < $b"
fi
# 也支持 && 和 ||
if ((a > 0 && a < 100)); then
echo "a在0到100之间"
fi
用 [[ ]] 做字符串比较更强大(支持模式匹配):
str="hello world"
# 支持*通配符
if [[ $str == hello* ]]; then
echo "以hello开头"
fi
# 支持正则匹配(用=~)
if [[ $str =~ ^[0-9]+$ ]]; then
echo "全是数字"
else
echo "不全是数字"
fi
7.7.7 if嵌套
if里面可以再套if:
#!/bin/bash
# 嵌套if示例
read -p "请输入年龄:" age
read -p "请输入是否有驾照(y/n):" has_license
if [ $age -ge 18 ]; then
echo "你已成年"
if [ "$has_license" = "y" ]; then
echo "你可以开车了!"
else
echo "你需要先考驾照"
fi
else
echo "你还未成年,不能开车"
fi
7.7.8 综合示例:判断闰年
#!/bin/bash
# 判断闰年 leap.sh
read -p "请输入年份:" year
# 闰年规则:
# 1. 能被4整除但不能被100整除,是闰年
# 2. 能被400整除,是闰年
if [ $(($year % 400)) -eq 0 ]; then
echo "$year 年是闰年"
elif [ $(($year % 4)) -eq 0 ] && [ $(($year % 100)) -ne 0 ]; then
echo "$year 年是闰年"
else
echo "$year 年不是闰年"
fi
7.8 循环结构 for、while、until
7.8.1 为什么要循环——懒人的福音
假设你要打印1到100,如果没有循环,你得写100行 echo:
echo 1
echo 2
echo 3
# … 97行省略
echo 100
太蠢了!有循环的话,只需要几行:
for i in {1..100}; do
echo $i
done
这就是循环的意义:让电脑替你做重复的事情。
7.8.2 for 循环
基本语法:
for 变量 in 列表; do
命令
done
示例一:遍历列表
#!/bin/bash
for fruit in 苹果 香蕉 橘子 葡萄; do
echo "我喜欢吃 $fruit"
done
输出:
我喜欢吃 苹果
我喜欢吃 香蕉
我喜欢吃 橘子
我喜欢吃 葡萄
执行过程:
示例二:遍历数字范围
# 方法一:用花括号
for i in {1..5}; do
echo "第 $i 次"
done
# 方法二:用seq命令
for i in $(seq 1 5); do
echo "第 $i 次"
done
# 带步长(每次加2)
for i in {1..10..2}; do
echo $i # 输出:1 3 5 7 9
done
# 倒序
for i in {5..1}; do
echo $i # 输出:5 4 3 2 1
done
示例三:C语言风格的for
如果你学过C/Java,这种写法更亲切:
for ((i=1; i<=5; i++)); do
echo "第 $i 次循环"
done
执行过程:
示例四:遍历命令结果
# 遍历当前目录所有.txt文件
for file in *.txt; do
echo "找到文件:$file"
done
# 遍历/etc目录下的内容
for item in $(ls /etc); do
echo "$item"
done
示例五:遍历数组
colors=("红" "橙" "黄" "绿" "蓝")
for color in "${colors[@]}"; do
echo "颜色:$color"
done
示例六:遍历位置参数
#!/bin/bash
# 遍历所有传入的参数
for arg in "$@"; do
echo "参数:$arg"
done
7.8.3 while 循环
while是"当…的时候"的意思,只要条件成立,就一直循环。
语法:
while [ 条件 ]; do
命令
done
示例一:计数器
#!/bin/bash
# 打印1到5
i=1
while [ $i -le 5 ]; do
echo "第 $i 次"
i=$((i + 1)) # 别忘了让计数器增加,否则死循环!
done
注意:while循环中最容易犯的错误就是忘记更新计数器,导致无限循环。就像跑步机,如果你不按停止键,它会一直转。
示例二:逐行读取文件
#!/bin/bash
# 逐行读取文件内容
file="/etc/passwd"
while read line; do
echo "行内容:$line"
done < $file
这个用法非常实用,< $file 把文件内容输入给while循环,每次读取一行赋给 line 变量。
示例三:交互式菜单
#!/bin/bash
# 交互式菜单 menu.sh
while true; do
echo ""
echo "===== 系统管理菜单 ====="
echo "1. 查看磁盘使用情况"
echo "2. 查看内存使用情况"
echo "3. 查看当前登录用户"
echo "4. 退出"
echo "======================="
read -p "请选择 [1-4]:" choice
case $choice in
1) df -h ;;
2) free -h ;;
3) who ;;
4) echo "再见!"; break ;;
*) echo "无效选择,请重试" ;;
esac
done
7.8.4 until 循环
until是"直到…才"的意思,和while正好相反:while是条件成立就循环,until是条件不成立就循环(直到条件成立才停止)。
语法:
until [ 条件 ]; do
命令
done
示例:
#!/bin/bash
# 等待某个用户登录
echo "等待用户admin登录…"
until who | grep -q "admin"; do
echo "admin还没登录,继续等待…"
sleep 5 # 等5秒再检查
done
echo "admin登录了!"
while和until的关系:
# while写法:条件为真时循环
while [ 条件为假 ]; do
命令
done
# 等价的until写法:条件为假时循环(条件为真时停止)
until [ 条件为真 ]; do
命令
done
7.8.5 break 和 continue
这两个关键字用来控制循环的流程:
- break:跳出整个循环(“我不干了,走人”)
- continue:跳过本次循环,进入下一次(“这次算了,下一个”)
break示例:
#!/bin/bash
# 找到第一个大于5的数就停止
for i in 1 2 3 4 5 6 7 8 9 10; do
if [ $i -gt 5 ]; then
echo "找到第一个大于5的数:$i"
break # 跳出循环
fi
echo "检查:$i"
done
echo "循环结束"
输出:
检查:1
检查:2
检查:3
检查:4
检查:5
找到第一个大于5的数:6
循环结束
continue示例:
#!/bin/bash
# 打印1到10,但跳过偶数
for i in {1..10}; do
if ((i % 2 == 0)); then
continue # 跳过偶数
fi
echo $i # 只打印奇数
done
输出:
1
3
5
7
9
7.8.6 嵌套循环
循环里面可以再套循环,最典型的例子是打印乘法表:
#!/bin/bash
# 九九乘法表 multiplication.sh
for i in {1..9}; do
for j in {1..9}; do
# 只打印 j <= i 的部分(形成三角形)
if [ $j -le $i ]; then
printf "%d×%d=%-4d" $j $i $((i * j))
fi
done
echo "" # 每行结束后换行
done
输出:
1×1=1
1×2=2 2×2=4
1×3=3 2×3=6 3×3=9
1×4=4 2×4=8 3×4=12 4×4=16
…(省略)
1×9=9 2×9=18 3×9=27 4×9=36 5×9=45 6×9=54 7×9=63 8×9=72 9×9=81
7.8.7 select 循环(菜单选择)
select 是一个专门用来做菜单的循环:
#!/bin/bash
# select菜单 select_menu.sh
echo "请选择你喜欢的编程语言:"
select lang in "Python" "Java" "C++" "Shell" "退出"; do
case $lang in
"退出")
echo "再见!"
break
;;
*)
echo "你选择了 $lang"
;;
esac
done
运行后效果:
请选择你喜欢的编程语言:
1) Python
2) Java
3) C++
4) Shell
5) 退出
#?
用户输入数字选择,非常方便。
7.8.8 循环综合示例:猜数字游戏
#!/bin/bash
# 猜数字游戏 guess.sh
# 生成1-100的随机数
target=$((RANDOM % 100 + 1))
count=0
echo "============================="
echo " 欢迎来到猜数字游戏!"
echo " 我已经想好了一个1-100的数字"
echo " 看你能猜几次猜中!"
echo "============================="
echo ""
while true; do
read -p "请输入你猜的数字(1-100):" guess
count=$((count + 1))
if [ $guess -lt $target ]; then
echo "太小了,往大猜!"
elif [ $guess -gt $target ]; then
echo "太大了,往小猜!"
else
echo ""
echo "恭喜你,猜中了!"
echo "正确答案是 $target"
echo "你一共猜了 $count 次"
break
fi
echo ""
done
7.9 case 多分支选择
7.9.1 为什么需要case——if-elif太多了
假设你要做一个菜单程序,有很多选项:
if [ $choice = "1" ]; then
...
elif [ $choice = "2" ]; then
...
elif [ $choice = "3" ]; then
...
elif [ $choice = "4" ]; then
...
elif [ $choice = "5" ]; then
...
else
...
fi
if-elif一多,代码就变得很长、很难看。这时候用 case 就优雅多了。
7.9.2 case 的语法
case 变量 in
模式1)
命令1
;;
模式2)
命令2
;;
模式3)
命令3
;;
*)
默认命令
;;
esac
解释一下:
- case 变量 in:开始case语句,检查"变量"的值
- 模式):每个分支以 ) 结尾(不是括号配对,就是单个右括号)
- ;;:每个分支结束的标志(相当于C语言里的break)
- *):默认分支(相当于else),* 匹配所有
- esac:case 反过来写,表示case语句结束
7.9.3 case 基础示例
#!/bin/bash
# 星期判断 day.sh
read -p "请输入数字(1-7):" day
case $day in
1) echo "星期一" ;;
2) echo "星期二" ;;
3) echo "星期三" ;;
4) echo "星期四" ;;
5) echo "星期五" ;;
6) echo "星期六" ;;
7) echo "星期日" ;;
*) echo "输入无效!" ;;
esac
是不是比用一堆if-elif清爽多了?
7.9.4 case 中的模式匹配
case的强大之处在于支持模式匹配:
#!/bin/bash
# 文件类型判断 filetype.sh
read -p "请输入文件名:" filename
case $filename in
*.txt)
echo "这是一个文本文件"
;;
*.sh)
echo "这是一个Shell脚本"
;;
*.jpg|*.jpeg|*.png|*.gif)
echo "这是一个图片文件"
;;
*.mp3|*.wav)
echo "这是一个音频文件"
;;
*.mp4|*.avi|*.mkv)
echo "这是一个视频文件"
;;
*)
echo "未知文件类型"
;;
esac
注意:
- | 表示"或",可以匹配多个模式
- * 是通配符,匹配任意内容
7.9.5 case 中的字符范围
可以用 [ ] 匹配字符范围:
#!/bin/bash
# 成绩等级 case_grade.sh
read -p "请输入成绩(0-100):" score
# 确保输入的是数字
if ! [[ $score =~ ^[0-9]+$ ]]; then
echo "请输入有效的数字!"
exit 1
fi
# 取十位数判断等级
level=$((score / 10))
case $level in
9|10)
echo "成绩 $score:优秀!"
;;
8)
echo "成绩 $score:良好!"
;;
7)
echo "成绩 $score:中等!"
;;
6)
echo "成绩 $score:及格!"
;;
*)
echo "成绩 $score:不及格!"
;;
esac
7.9.6 case 实战:简易计算器
#!/bin/bash
# 简易计算器 calculator.sh
echo "===== 简易计算器 ====="
echo ""
read -p "请输入第一个数字:" num1
read -p "请输入运算符(+,-,*,/):" op
read -p "请输入第二个数字:" num2
case $op in
+)
result=$((num1 + num2))
echo "$num1 + $num2 = $result"
;;
–)
result=$((num1 – num2))
echo "$num1 – $num2 = $result"
;;
\\*|x|X)
result=$((num1 * num2))
echo "$num1 × $num2 = $result"
;;
/)
if [ $num2 -eq 0 ]; then
echo "错误:除数不能为0!"
else
result=$(echo "scale=2; $num1 / $num2" | bc)
echo "$num1 ÷ $num2 = $result"
fi
;;
*)
echo "不支持的运算符:$op"
;;
esac
7.9.7 case 嵌套
case里面也可以嵌套case:
#!/bin/bash
# 系统管理菜单 sysmenu.sh
while true; do
echo ""
echo "===== 系统管理工具 ====="
echo "1. 磁盘管理"
echo "2. 内存管理"
echo "3. 网络管理"
echo "4. 退出"
echo "======================="
read -p "请选择:" main_choice
case $main_choice in
1)
echo ""
echo "– 磁盘管理 –"
echo "a. 查看磁盘使用情况"
echo "b. 查看挂载信息"
read -p "请选择:" sub_choice
case $sub_choice in
a|A) df -h ;;
b|B) mount ;;
*) echo "无效选择" ;;
esac
;;
2)
echo ""
echo "– 内存管理 –"
echo "a. 查看内存使用"
echo "b. 查看内存详情"
read -p "请选择:" sub_choice
case $sub_choice in
a|A) free -h ;;
b|B) cat /proc/meminfo ;;
*) echo "无效选择" ;;
esac
;;
3)
echo ""
echo "– 网络管理 –"
echo "a. 查看IP地址"
echo "b. 查看网络连接"
read -p "请选择:" sub_choice
case $sub_choice in
a|A) ip addr ;;
b|B) netstat -tlnp 2>/dev/null || ss -tlnp ;;
*) echo "无效选择" ;;
esac
;;
4)
echo "再见!"
break
;;
*)
echo "无效选择,请重试"
;;
esac
done
7.10 函数定义与调用
7.10.1 为什么需要函数——避免重复劳动
假设你在脚本中多次需要计算圆的面积,如果没有函数:
# 计算第一个圆
r1=5
area1=$(echo "scale=2; 3.14 * $r1 * $r1" | bc)
echo $area1
# 计算第二个圆
r2=10
area2=$(echo "scale=2; 3.14 * $r2 * $r2" | bc)
echo $area2
# 计算第三个圆
r3=15
area3=$(echo "scale=2; 3.14 * $r3 * $r3" | bc)
echo $area3
同样的代码写了三遍,太累了。
函数的作用就是把重复的代码打包,取个名字,用的时候叫名字就行。就像你教孩子系鞋带,教一次,以后他自己会系,不用每次都教。
7.10.2 定义函数
Shell中定义函数有两种语法:
语法一:
function 函数名() {
命令
...
}
语法二(省略function关键字):
函数名() {
命令
...
}
两种都可以,推荐用第二种(更简洁)。
7.10.3 调用函数
直接写函数名就能调用:
#!/bin/bash
# 函数基础 func_basic.sh
# 定义函数
say_hello() {
echo "你好,世界!"
}
# 调用函数
say_hello
say_hello
say_hello
输出:
你好,世界!
你好,世界!
你好,世界!
调用函数就像调用命令一样,直接写函数名即可,不需要加括号。
7.10.4 函数参数
函数内部可以用 $1, $2, $3… 接收参数(跟脚本的位置参数类似,但这是函数的参数,不是脚本的):
#!/bin/bash
# 函数参数 func_param.sh
# 定义带参数的函数
greet() {
echo "你好,$1!"
echo "你今年 $2 岁。"
echo "你住在 $3。"
}
# 调用并传参
greet "张三" 25 "北京"
echo "—"
greet "李四" 30 "上海"
输出:
你好,张三!
你今年 25 岁。
你住在 北京。
—
你好,李四!
你今年 30 岁。
你住在 上海。
注意函数内的 $1 和脚本外的 $1 是不同的——函数内的 $1 指的是调用函数时传的第一个参数,不是运行脚本时传的参数。
7.10.5 函数返回值
函数的返回值有两种方式:
方式一:用return返回状态码
#!/bin/bash
# return返回值
is_even() {
if [ $(($1 % 2)) -eq 0 ]; then
return 0 # 返回0表示成功(偶数)
else
return 1 # 返回非0表示失败(奇数)
fi
}
# 调用
is_even 4
if [ $? -eq 0 ]; then
echo "4是偶数"
fi
is_even 7
if [ $? -eq 0 ]; then
echo "7是偶数"
else
echo "7是奇数"
fi
注意:return 只能返回0-255的整数,通常用来表示成功(0)或失败(非0)。$? 用来获取上一条命令(或函数)的返回值。
方式二:用echo输出结果(更常用)
#!/bin/bash
# echo返回值
# 计算两个数之和
add() {
echo $(($1 + $2))
}
# 计算圆面积
circle_area() {
echo $(echo "scale=2; 3.14 * $1 * $1" | bc)
}
# 调用并接收返回值
result=$(add 10 20)
echo "10 + 20 = $result"
area=$(circle_area 5)
echo "半径5的圆面积 = $area"
这种方式更灵活,可以返回任意字符串。用 $(函数名 参数) 来捕获函数的输出。
7.10.6 局部变量 local
默认情况下,函数内定义的变量是全局的,会影响函数外:
#!/bin/bash
# 全局变量的问题
modify_var() {
x=100 # 这会修改全局的x!
}
x=1
echo "调用前:x=$x" # 输出:1
modify_var
echo "调用后:x=$x" # 输出:100(被函数修改了!)
如果你不想函数内的变量影响外面,用 local 声明局部变量:
#!/bin/bash
# local局部变量
safe_function() {
local x=100 # 局部变量,只在函数内有效
echo "函数内:x=$x"
}
x=1
echo "调用前:x=$x"
safe_function
echo "调用后:x=$x" # 还是1,没被修改
养成好习惯:函数内部用的临时变量都用 local 声明,避免意外修改全局变量。
7.10.7 函数的递归调用
函数可以调用自己,这叫递归。经典例子是计算阶乘:
#!/bin/bash
# 递归计算阶乘 factorial.sh
factorial() {
local n=$1
if [ $n -le 1 ]; then
echo 1
else
local prev=$(factorial $((n – 1)))
echo $((n * prev))
fi
}
read -p "请输入一个正整数:" num
result=$(factorial $num)
echo "$num! = $result"
执行过程(以 factorial 4 为例):
factorial(4) = 4 * factorial(3)
= 4 * (3 * factorial(2))
= 4 * (3 * (2 * factorial(1)))
= 4 * (3 * (2 * 1))
= 4 * (3 * 2)
= 4 * 6
= 24
7.10.8 在单独文件中定义函数
当函数多了,可以把函数放到单独的文件中,然后在主脚本中引用:
文件 lib.sh(函数库):
#!/bin/bash
# 函数库
# 打印分隔线
print_line() {
echo "================================"
}
# 打印信息
print_info() {
print_line
echo " $1"
print_line
}
# 检查文件是否存在
check_file() {
if [ -e "$1" ]; then
return 0
else
return 1
fi
}
主脚本 main.sh:
#!/bin/bash
# 引入函数库
source ./lib.sh
# 或者用 . ./lib.sh
# 使用库中的函数
print_info "系统检查开始"
if check_file "/etc/passwd"; then
echo "/etc/passwd 存在"
fi
print_info "系统检查结束"
7.10.9 综合示例:学生成绩管理
#!/bin/bash
# 学生成绩管理 grade_manager.sh
# 定义数组存储学生信息
declare -a students
declare -a scores
count=0
# 添加学生
add_student() {
local name=$1
local score=$2
students[$count]=$name
scores[$count]=$score
count=$((count + 1))
echo "添加成功:$name 的成绩是 $score"
}
# 显示所有学生
show_all() {
if [ $count -eq 0 ]; then
echo "暂无学生信息"
return
fi
echo "序号 姓名 成绩 等级"
echo "—————————-"
local i=0
while [ $i -lt $count ]; do
local grade
if [ ${scores[$i]} -ge 90 ]; then
grade="优秀"
elif [ ${scores[$i]} -ge 80 ]; then
grade="良好"
elif [ ${scores[$i]} -ge 60 ]; then
grade="及格"
else
grade="不及格"
fi
printf "%-4d %-6s %-4d %s\\n" $((i+1)) "${students[$i]}" "${scores[$i]}" "$grade"
i=$((i + 1))
done
echo "—————————-"
}
# 计算平均分
calc_average() {
if [ $count -eq 0 ]; then
echo "暂无数据"
return
fi
local total=0
local i=0
while [ $i -lt $count ]; do
total=$((total + scores[$i]))
i=$((i + 1))
done
local avg=$(echo "scale=2; $total / $count" | bc)
echo "平均分:$avg"
}
# 查找学生
find_student() {
local name=$1
local i=0
while [ $i -lt $count ]; do
if [ "${students[$i]}" = "$name" ]; then
echo "找到:$name 的成绩是 ${scores[$i]}"
return 0
fi
i=$((i + 1))
done
echo "未找到学生:$name"
return 1
}
# 主程序
add_student "张三" 95
add_student "李四" 82
add_student "王五" 58
add_student "赵六" 76
echo ""
show_all
echo ""
calc_average
echo ""
find_student "李四"
find_student "钱七"
7.11 输入输出与read命令
7.11.1 echo 命令详解
echo 是最常用的输出命令,前面已经用了很多次了。这里补充一些高级用法:
# 基本输出
echo "Hello"
# 输出不换行(用-n)
echo -n "请稍候"
echo -n "…"
echo "完成"
# 输出在一行:请稍候…完成
# 启用转义字符(用-e)
echo -e "第一行\\n第二行" # \\n换行
echo -e "制表符\\t这里" # \\t制表符
echo -e "这是\\b退格" # \\b退格
echo -e "\\033[31m红色文字\\033[0m" # 红色文字
echo -e "\\033[32m绿色文字\\033[0m" # 绿色文字
echo -e "\\033[33m黄色文字\\033[0m" # 黄色文字
颜色输出的格式:
\\033[颜色代码m 内容 \\033[0m
常见颜色代码:
| 30 | 黑色 |
| 31 | 红色 |
| 32 | 绿色 |
| 33 | 黄色 |
| 34 | 蓝色 |
| 35 | 紫色 |
| 36 | 青色 |
| 37 | 白色 |
封装成函数更方便:
#!/bin/bash
# 彩色输出 color.sh
red() { echo -e "\\033[31m$1\\033[0m"; }
green() { echo -e "\\033[32m$1\\033[0m"; }
yellow() { echo -e "\\033[33m$1\\033[0m"; }
blue() { echo -e "\\033[34m$1\\033[0m"; }
red "这是红色文字"
green "这是绿色文字"
yellow "这是黄色文字"
blue "这是蓝色文字"
7.11.2 printf 命令
printf 比echo更强大,支持格式化输出(跟C语言的printf类似):
# 基本用法(不会自动换行,需要手动加\\n)
printf "Hello\\n" # 输出:Hello(换行)
# 格式化输出
printf "%s今年%d岁\\n" "张三" 25 # 输出:张三今年25岁
# 对齐输出
printf "%-10s %-10s %5d\\n" "姓名" "城市" "年龄"
printf "%-10s %-10s %5d\\n" "张三" "北京" 25
printf "%-10s %-10s %5d\\n" "李四" "上海" 30
# 浮点数
printf "圆周率是:%.2f\\n" 3.14159 # 输出:圆周率是:3.14
常用格式符:
| %s | 字符串 |
| %d | 整数 |
| %f | 浮点数 |
| %-10s | 左对齐,占10个字符宽度 |
| %10s | 右对齐,占10个字符宽度 |
| %.2f | 保留2位小数 |
用printf输出表格:
#!/bin/bash
# 表格输出 table.sh
printf "%-8s %-8s %-6s %-8s\\n" "姓名" "语文" "数学" "英语"
printf "%-8s %-8s %-8s %-8s\\n" "—-" "—-" "—-" "—-"
printf "%-8s %-8d %-8d %-8d\\n" "张三" 90 85 92
printf "%-8s %-8d %-8d %-8d\\n" "李四" 78 95 88
printf "%-8s %-8d %-8d %-8d\\n" "王五" 88 76 90
输出:
姓名 语文 数学 英语
—- —- —- —-
张三 90 85 92
李四 78 95 88
王五 88 76 90
7.11.3 read 命令详解
read 用来从标准输入读取数据,通常用于和用户交互。
基本用法:
#!/bin/bash
# read基础
echo "请输入你的名字:"
read name
echo "你好,$name!"
加提示信息(-p):
read -p "请输入你的名字:" name
echo "你好,$name!"
-p 后面的提示信息会显示在用户输入之前,不需要单独用echo。
输入密码时隐藏内容(-s):
read -s -p "请输入密码:" password
echo ""
echo "密码已接收(我不会告诉别人的)"
-s 表示silent(静默模式),输入的内容不会显示在屏幕上。
限制输入长度(-n):
read -n 1 -p "继续吗?(y/n):" choice
echo ""
echo "你选择了:$choice"
-n 1 表示只读1个字符,用户输入一个字符后自动结束,不需要按回车。
设置超时(-t):
# 5秒内不输入就自动跳过
if read -t 5 -p "请输入你的名字(5秒内):" name; then
echo "你好,$name"
else
echo ""
echo "超时了,你太慢了!"
fi
读取多个变量:
read -p "请输入姓名和年龄(用空格分开):" name age
echo "姓名:$name,年龄:$age"
输入"张三 25",name就是"张三",age就是"25"。
读取整行到数组(-a):
read -a nums -p "请输入几个数字(空格分开):"
echo "你输入了 ${#nums[@]} 个数字"
echo "分别是:${nums[@]}"
echo "第一个是:${nums[0]}"
7.11.4 重定向
重定向就是改变输入输出的方向。
输出重定向(> 和 >>):
# > 覆盖写入(原来的内容没了)
echo "第一行" > file.txt
echo "第二行" > file.txt # file.txt里只剩"第二行"
# >> 追加写入(在原来的基础上添加)
echo "第一行" > file.txt
echo "第二行" >> file.txt # file.txt里有"第一行"和"第二行"
打个比方:
- > 就像擦黑板重新写——原来的内容被擦掉
- >> 就像在纸上接着写——原来的内容还在
错误重定向(2>):
# 把错误信息写入文件
ls /nonexistent 2> error.log
# 丢弃错误信息(扔到黑洞/dev/null)
ls /nonexistent 2> /dev/null
在Linux中,每个进程有三个标准流:
| 0 | stdin | 标准输入(键盘) |
| 1 | stdout | 标准输出(屏幕) |
| 2 | stderr | 标准错误(屏幕) |
所以 2> 就是重定向标准错误。
同时重定向正确和错误输出(&>):
# 把正确和错误输出都写入同一个文件
command &> output.log
# 等价写法
command > output.log 2>&1
输入重定向(<):
# 从文件读取内容(而不是从键盘)
wc -l < /etc/passwd # 统计文件的行数
# 在脚本中
while read line; do
echo "处理:$line"
done < input.txt
Here Document(<<):
Here Document是一种特殊的输入方式,可以在脚本中直接写多行文本:
#!/bin/bash
# here文档示例
cat << EOF
============================
欢迎使用本系统
版本:1.0
作者:张三
============================
EOF
<< 后面跟一个标记(通常是EOF,表示End Of File),之间的内容会被当作输入。当再次遇到这个标记时,输入结束。
实际用途——生成配置文件:
#!/bin/bash
# 自动生成配置文件
cat > /tmp/myapp.conf << EOF
# 应用配置文件
APP_NAME=MyApp
APP_PORT=8080
APP_DEBUG=true
APP_LOG=/var/log/myapp.log
EOF
echo "配置文件已生成"
cat /tmp/myapp.conf
7.11.5 管道 |
管道 | 把前一个命令的输出当作后一个命令的输入:
# 查看系统中运行的所有进程,然后只看包含nginx的
ps aux | grep nginx
# 统计当前目录有多少个文件
ls | wc -l
# 查看文件内容并分页显示
cat /etc/passwd | less
# 多级管道
cat /etc/passwd | grep bash | wc -l
# 找出使用bash的用户数量
管道就像流水线——前一个工序的产品,直接传给下一个工序继续加工。
7.11.6 综合示例:用户注册系统
#!/bin/bash
# 用户注册系统 register.sh
# 存储用户信息的文件
USER_FILE="users.txt"
# 如果文件不存在就创建
if [ ! -f "$USER_FILE" ]; then
touch "$USER_FILE"
fi
# 注册函数
register() {
echo ""
echo "===== 用户注册 ====="
read -p "请输入用户名:" username
# 检查用户名是否已存在
if grep -q "^${username}|" "$USER_FILE"; then
echo "错误:用户名 $username 已存在!"
return 1
fi
read -s -p "请输入密码:" password
echo ""
read -s -p "请再次输入密码:" password2
echo ""
if [ "$password" != "$password2" ]; then
echo "错误:两次密码不一致!"
return 1
fi
read -p "请输入邮箱:" email
# 保存到文件(用|分隔)
echo "${username}|${password}|${email}" >> "$USER_FILE"
echo "注册成功!欢迎 $username"
}
# 登录函数
login() {
echo ""
echo "===== 用户登录 ====="
read -p "请输入用户名:" username
read -s -p "请输入密码:" password
echo ""
# 验证
if grep -q "^${username}|${password}|" "$USER_FILE"; then
echo "登录成功!欢迎回来,$username"
else
echo "登录失败:用户名或密码错误!"
fi
}
# 主菜单
while true; do
echo ""
echo "=========================="
echo " 欢迎使用用户系统"
echo "=========================="
echo "1. 注册"
echo "2. 登录"
echo "3. 查看用户列表"
echo "4. 退出"
echo "=========================="
read -p "请选择 [1-4]:" choice
case $choice in
1) register ;;
2) login ;;
3)
echo ""
echo "已注册用户:"
if [ -s "$USER_FILE" ]; then
cut -d'|' -f1 "$USER_FILE"
else
echo " 暂无用户"
fi
;;
4) echo "再见!"; break ;;
*) echo "无效选择" ;;
esac
done
7.12 脚本调试与错误处理
7.12.1 常见错误类型
写Shell脚本时,常见的错误有:
错误一:语法错误
# if后面忘了then
if [ $a -gt 10 ]
echo "大于10"
fi
# 报错:syntax error near unexpected token `fi'
错误二:变量未定义
# 变量名打错了
username="张三"
echo $usernam # 少了个e,输出为空
错误三:权限问题
# 没有执行权限就运行
./script.sh
# 报错:Permission denied
错误四:路径错误
# 文件路径不存在
cat /etc/passwdd # 多了个d
# 报错:No such file or directory
7.12.2 调试模式 -x
在运行脚本时加 -x 参数,可以查看每一步执行了什么:
bash -x script.sh
或者在脚本开头加上:
#!/bin/bash -x
效果:
#!/bin/bash
# 调试示例 debug.sh
name="张三"
age=25
echo "$name 今年 $age 岁"
正常运行:
张三 今年 25 岁
用 -x 运行 bash -x debug.sh:
+ name=张三
+ age=25
+ echo '张三 今年 25 岁'
张三 今年 25 岁
每行前面的 + 表示这是Shell执行的命令,你能清楚地看到每一步做了什么。当脚本有bug时,用这个方法能快速定位问题。
7.12.3 调试模式 -v
-v 模式会显示脚本中每一行的原始内容(执行前):
bash -v script.sh
-v 和 -x 的区别:
- -v:显示要执行的行(原文)
- -x:显示执行后的命令(变量已替换)
通常两个一起用:bash -vx script.sh
7.12.4 在脚本中局部开启调试
你可以在脚本的某一部分开启调试,不需要全程开启:
#!/bin/bash
echo "这部分不调试"
set -x # 开启调试
name="张三"
age=25
echo "$name $age"
set +x # 关闭调试
echo "这部分也不调试"
7.12.5 set命令的常用选项
| set -e | 遇到错误就退出 | 任何命令返回非0,脚本立即停止 |
| set -u | 使用未定义变量就报错 | 防止变量名打错 |
| set -x | 打印执行的命令 | 调试用 |
| set -o pipefail | 管道中任一命令失败就失败 | 默认只看管道最后命令的返回值 |
推荐在脚本开头加上:
#!/bin/bash
set -euo pipefail
这三个选项能让你的脚本更健壮:
- -e:出错就停,不让错误蔓延
- -u:变量未定义就报错,避免空变量导致的bug
- -o pipefail:管道命令出错也能捕获
7.12.6 错误处理:检查命令返回值
每条命令执行后都有一个返回值(退出状态码 $?):
- 0:成功
- 非0:失败(不同的数字代表不同的错误)
#!/bin/bash
# 检查命令是否成功 check_cmd.sh
mkdir /tmp/test_dir
if [ $? -eq 0 ]; then
echo "目录创建成功"
else
echo "目录创建失败"
fi
简写形式(用&&和||):
# 成功时做A,失败时做B
mkdir /tmp/test_dir && echo "成功" || echo "失败"
7.12.7 trap命令:捕获信号
trap 可以在脚本收到特定信号时执行指定的命令。最常用的场景是:脚本中途被Ctrl+C中断时,清理临时文件。
#!/bin/bash
# trap演示 trap.sh
# 定义清理函数
cleanup() {
echo ""
echo "正在清理临时文件…"
rm -f /tmp/my_temp_*
echo "清理完成,再见!"
exit 0
}
# 捕获Ctrl+C(SIGINT)和退出信号
trap cleanup SIGINT SIGTERM
echo "脚本开始运行,按Ctrl+C可以中断"
echo "正在处理…"
# 模拟长时间运行
for i in {1..100}; do
echo "进度:$i%"
sleep 0.5
done
echo "处理完成!"
常用信号:
| SIGINT | 2 | Ctrl+C中断 |
| SIGTERM | 15 | 终止信号 |
| SIGKILL | 9 | 强制杀死(不能被trap捕获) |
| EXIT | 0 | 脚本退出 |
7.12.8 实用的错误处理模板
#!/bin/bash
# 带完整错误处理的脚本模板 template.sh
set -euo pipefail
# 定义日志函数
log_info() { echo "[INFO] $(date '+%H:%M:%S') $*"; }
log_warn() { echo "[WARN] $(date '+%H:%M:%S') $*" >&2; }
log_error() { echo "[ERROR] $(date '+%H:%M:%S') $*" >&2; }
# 定义错误处理函数
on_error() {
log_error "脚本在行 $1 发生错误"
# 这里可以做一些清理工作
exit 1
}
# 设置错误陷阱(ERR信号带行号)
trap 'on_error $LINENO' ERR
# 定义清理函数
cleanup() {
log_info "脚本结束,执行清理…"
# rm -f /tmp/temp_*
}
trap cleanup EXIT
# ===== 主程序开始 =====
log_info "脚本开始执行"
# 检查必要的目录
DATA_DIR="/tmp/myapp_data"
if [ ! -d "$DATA_DIR" ]; then
log_info "创建数据目录: $DATA_DIR"
mkdir -p "$DATA_DIR" || { log_error "无法创建目录"; exit 1; }
fi
# 检查必要的文件
CONFIG_FILE="$DATA_DIR/config.txt"
if [ ! -f "$CONFIG_FILE" ]; then
log_warn "配置文件不存在,创建默认配置"
echo "default=true" > "$CONFIG_FILE"
fi
log_info "读取配置…"
while read line; do
log_info "配置项: $line"
done < "$CONFIG_FILE"
log_info "所有操作完成"
# ===== 主程序结束 =====
这个模板包含了:
7.12.9 调试技巧总结
bash -n script.sh
shellcheck script.sh
echo "DEBUG: var_name = $var_name"
set -x
# 可能有问题的代码
set +x
7.13 实战脚本编写案例
7.13.1 案例:自动备份脚本
这是一个非常实用的脚本,自动备份指定目录:
#!/bin/bash
# 自动备份脚本 backup.sh
# 用法:./backup.sh 源目录 备份目录
set -euo pipefail
# ===== 配置 =====
SOURCE_DIR="${1:-/home/user/data}" # 源目录,默认/home/user/data
BACKUP_DIR="${2:-/backup}" # 备份目录,默认/backup
KEEP_DAYS=7 # 保留几天的备份
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_FILE="${BACKUP_DIR}/backup_${DATE}.tar.gz"
LOG_FILE="${BACKUP_DIR}/backup.log"
# ===== 函数定义 =====
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
error_exit() {
log "ERROR: $1"
exit 1
}
cleanup_old_backups() {
log "清理 ${KEEP_DAYS} 天前的旧备份…"
local deleted=0
for file in "${BACKUP_DIR}"/backup_*.tar.gz; do
if [ -f "$file" ]; then
local file_date=$(echo "$file" | grep -o '[0-9]\\{8\\}')
local diff_days=$(( ($(date +%s) – $(date –d "$file_date" +%s)) / 86400 ))
if [ $diff_days -gt $KEEP_DAYS ]; then
rm -f "$file"
log "已删除旧备份: $(basename $file)"
deleted=$((deleted + 1))
fi
fi
done
log "共删除 $deleted 个旧备份"
}
show_summary() {
local size=$(du -h "$BACKUP_FILE" | cut -f1)
local files=$(tar -tzf "$BACKUP_FILE" | wc -l)
echo ""
echo "========== 备份报告 =========="
echo "备份时间:$(date '+%Y-%m-%d %H:%M:%S')"
echo "源目录: $SOURCE_DIR"
echo "备份到: $BACKUP_FILE"
echo "文件大小:$size"
echo "文件数量:$files 个"
echo "=============================="
}
# ===== 主程序 =====
# 检查源目录
if [ ! -d "$SOURCE_DIR" ]; then
error_exit "源目录不存在: $SOURCE_DIR"
fi
# 创建备份目录
mkdir -p "$BACKUP_DIR" || error_exit "无法创建备份目录"
log "========== 开始备份 =========="
log "源目录: $SOURCE_DIR"
log "备份到: $BACKUP_FILE"
# 执行备份
log "正在压缩打包…"
tar -czf "$BACKUP_FILE" -C "$(dirname $SOURCE_DIR)" "$(basename $SOURCE_DIR)" 2>/dev/null || \\
error_exit "备份失败"
log "备份完成!"
# 清理旧备份
cleanup_old_backups
# 显示摘要
show_summary
log "========== 备份结束 =========="
7.13.2 案例:系统监控脚本
监控系统状态,异常时报警:
#!/bin/bash
# 系统监控脚本 monitor.sh
# ===== 阈值配置 =====
CPU_WARN=80 # CPU使用率警告阈值(%)
MEM_WARN=80 # 内存使用率警告阈值(%)
DISK_WARN=90 # 磁盘使用率警告阈值(%)
CHECK_INTERVAL=60 # 检查间隔(秒)
LOG_FILE="/var/log/sys_monitor.log"
# ===== 函数 =====
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}
alert() {
local msg="[ALERT] $1"
echo "$msg" | tee -a "$LOG_FILE"
# 这里可以加发邮件、发短信等通知逻辑
}
get_cpu_usage() {
# 获取CPU使用率
local idle=$(top -bn1 | grep "Cpu(s)" | awk '{print $8}')
local usage=$(echo "100 – $idle" | bc)
echo $usage | cut -d'.' -f1
}
get_mem_usage() {
local total=$(free | grep Mem | awk '{print $2}')
local used=$(free | grep Mem | awk '{print $3}')
echo $((used * 100 / total))
}
check_disk() {
local alerts=""
df -h | awk 'NR>1' | while read line; do
local partition=$(echo $line | awk '{print $1}')
local usage=$(echo $line | awk '{print $5}' | cut -d'%' -f1)
local mount=$(echo $line | awk '{print $6}')
if [ "$usage" -gt "$DISK_WARN" ]; then
alert "磁盘 $mount ($partition) 使用率 ${usage}%,超过阈值 ${DISK_WARN}%"
fi
done
}
check_system() {
log "— 开始检查 —"
# 检查CPU
local cpu=$(get_cpu_usage)
log "CPU使用率: ${cpu}%"
if [ "$cpu" -gt "$CPU_WARN" ]; then
alert "CPU使用率 ${cpu}%,超过阈值 ${CPU_WARN}%"
fi
# 检查内存
local mem=$(get_mem_usage)
log "内存使用率: ${mem}%"
if [ "$mem" -gt "$MEM_WARN" ]; then
alert "内存使用率 ${mem}%,超过阈值 ${MEM_WARN}%"
fi
# 检查磁盘
check_disk
# 检查僵尸进程
local zombies=$(ps aux | awk '{print $8}' | grep -c Z)
if [ "$zombies" -gt 0 ]; then
alert "发现 $zombies 个僵尸进程!"
fi
# 检查负载
local load=$(uptime | awk -F'load average:' '{print $2}' | awk '{print $1}' | cut -d',' -f1)
local cores=$(nproc)
log "系统负载: ${load} (核心数: ${cores})"
log "— 检查结束 —"
log ""
}
# ===== 主程序 =====
echo "系统监控脚本启动"
echo "检查间隔: ${CHECK_INTERVAL}秒"
echo "CPU阈值: ${CPU_WARN}% 内存阈值: ${MEM_WARN}% 磁盘阈值: ${DISK_WARN}%"
echo "日志文件: ${LOG_FILE}"
echo "按Ctrl+C停止…"
echo ""
while true; do
check_system
sleep $CHECK_INTERVAL
done
7.13.3 案例:批量重命名文件
#!/bin/bash
# 批量重命名文件 rename.sh
show_help() {
echo "用法:$0 <目录> <模式>"
echo ""
echo "模式:"
echo " upper – 文件名转大写"
echo " lower – 文件名转小写"
echo " prefix:X – 添加前缀X"
echo " suffix:X – 添加后缀X"
echo " replace:OLD:NEW – 替换文件名中的OLD为NEW"
echo " number – 按序号重命名"
echo ""
echo "示例:"
echo " $0 ./photos lower"
echo " $0 ./docs prefix:2024_"
echo " $0 ./files replace:IMG:photo"
}
if [ $# -lt 2 ]; then
show_help
exit 1
fi
TARGET_DIR="$1"
MODE="$2"
if [ ! -d "$TARGET_DIR" ]; then
echo "错误:目录 $TARGET_DIR 不存在"
exit 1
fi
count=0
cd "$TARGET_DIR" || exit 1
for file in *; do
# 跳过目录
[ -f "$file" ] || continue
# 获取文件名和扩展名
filename="${file%.*}"
extension="${file##*.}"
# 如果没有扩展名
if [ "$filename" = "$file" ]; then
extension=""
else
extension=".$extension"
fi
newname=""
case "$MODE" in
upper)
newname=$(echo "$filename" | tr 'a-z' 'A-Z')"$extension"
;;
lower)
newname=$(echo "$filename" | tr 'A-Z' 'a-z')"$extension"
;;
prefix:*)
prefix="${MODE#prefix:}"
newname="${prefix}${filename}${extension}"
;;
suffix:*)
suffix="${MODE#suffix:}"
newname="${filename}${suffix}${extension}"
;;
replace:*)
OLD="${MODE#replace:}"
OLD="${OLD%%:*}"
NEW="${MODE#*:}"
NEW="${NEW#*:}"
newname=$(echo "$filename" | sed "s/$OLD/$NEW/g")"$extension"
;;
number)
count=$((count + 1))
newname=$(printf "%03d%s" $count "$extension")
;;
*)
echo "未知模式: $MODE"
show_help
exit 1
;;
esac
if [ "$newname" != "$file" ] && [ -n "$newname" ]; then
mv "$file" "$newname" 2>/dev/null
if [ $? -eq 0 ]; then
echo "重命名: $file -> $newname"
else
echo "失败: $file -> $newname (可能已存在)"
fi
fi
done
echo "完成!"
7.13.4 案例:一键部署Web环境
#!/bin/bash
# 一键部署LNMP环境 deploy_lnmp.sh
set -euo pipefail
# ===== 颜色定义 =====
RED='\\033[31m'
GREEN='\\033[32m'
YELLOW='\\033[33m'
BLUE='\\033[34m'
NC='\\033[0m' # 恢复默认颜色
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ===== 配置 =====
WEB_ROOT="/var/www/html"
MYSQL_ROOT_PASSWORD="MySecure@Pass123"
PHP_VERSION="8.1"
# ===== 检查root权限 =====
check_root() {
if [ "$(id -u)" -ne 0 ]; then
error "请使用root用户运行此脚本"
fi
}
# ===== 检测系统类型 =====
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
info "检测到系统: $NAME $VERSION"
else
error "无法检测系统类型"
fi
}
# ===== 安装Nginx =====
install_nginx() {
info "开始安装 Nginx…"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
apt-get update -y
apt-get install -y nginx
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ]; then
yum install -y nginx
fi
systemctl start nginx
systemctl enable nginx
info "Nginx 安装完成"
nginx -v
}
# ===== 安装MySQL =====
install_mysql() {
info "开始安装 MySQL…"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
apt-get install -y mysql-server
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ]; then
yum install -y mariadb-server
fi
systemctl start mysqld 2>/dev/null || systemctl start mysql 2>/dev/null || systemctl start mariadb
systemctl enable mysqld 2>/dev/null || systemctl enable mysql 2>/dev/null || systemctl enable mariadb
info "设置MySQL root密码…"
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASSWORD}';" 2>/dev/null || true
info "MySQL 安装完成"
}
# ===== 安装PHP =====
install_php() {
info "开始安装 PHP…"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
apt-get install -y php-fpm php-mysql php-cli php-curl php-gd php-mbstring php-xml
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ]; then
yum install -y php-fpm php-mysqlnd php-cli php-curl php-gd php-mbstring php-xml
fi
systemctl start php-fpm 2>/dev/null || systemctl start php${PHP_VERSION}-fpm
systemctl enable php-fpm 2>/dev/null || systemctl enable php${PHP_VERSION}-fpm
info "PHP 安装完成"
php -v
}
# ===== 配置Nginx支持PHP =====
config_nginx_php() {
info "配置Nginx支持PHP…"
mkdir -p "$WEB_ROOT"
# 创建测试页面
cat > "$WEB_ROOT/index.php" << EOF
<?php
phpinfo();
?>
EOF
# 配置Nginx
cat > /etc/nginx/conf.d/default.conf << 'EOF'
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php index.html;
location / {
try_files \\$uri \\$uri/ =404;
}
location ~ \\.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;
include fastcgi_params;
}
}
EOF
# 检查配置并重启
nginx -t && systemctl restart nginx
info "Nginx配置完成"
}
# ===== 配置防火墙 =====
config_firewall() {
info "配置防火墙…"
if command -v ufw &>/dev/null; then
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 3306/tcp
info "ufw防火墙已配置"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd –permanent –add-service=http
firewall-cmd –permanent –add-service=https
firewall-cmd –permanent –add-port=3306/tcp
firewall-cmd –reload
info "firewalld已配置"
fi
}
# ===== 显示安装结果 =====
show_result() {
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} LNMP环境部署完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo " Nginx: 已启动,端口80"
echo " MySQL: 已启动,端口3306"
echo " root密码: $MYSQL_ROOT_PASSWORD"
echo " PHP: 已启动"
echo ""
echo " 网站根目录: $WEB_ROOT"
echo ""
echo " 测试页面: http://localhost/index.php"
echo ""
echo -e "${YELLOW} 请及时修改MySQL密码并删除测试页面!${NC}"
echo ""
}
# ===== 主程序 =====
info "===== LNMP一键部署脚本 ====="
echo ""
check_root
detect_os
install_nginx
install_mysql
install_php
config_nginx_php
config_firewall
show_result
info "部署完成!"
7.13.5 案例:日志分析脚本
#!/bin/bash
# Nginx日志分析脚本 log_analyze.sh
LOG_FILE="${1:-/var/log/nginx/access.log}"
if [ ! -f "$LOG_FILE" ]; then
echo "错误:日志文件 $LOG_FILE 不存在"
exit 1
fi
echo ""
echo "================================================"
echo " Nginx访问日志分析报告"
echo " 日志文件: $LOG_FILE"
echo " 分析时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "================================================"
echo ""
# 总请求数
total=$(wc -l < "$LOG_FILE")
echo "【总请求数】"
echo " $total 条"
echo ""
# 今日请求数
today=$(date '+%d/%b/%Y')
today_count=$(grep "$today" "$LOG_FILE" | wc -l)
echo "【今日请求数】"
echo " $today_count 条"
echo ""
# 独立IP数(Top 10)
echo "【访问量Top 10 IP】"
echo " 排名 IP地址 访问次数"
echo " —- ————– ——–"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \\
awk '{printf(" %-6s %-15s %d\\n", NR".", $2, $1)}'
echo ""
# 被访问最多的页面(Top 10)
echo "【被访问最多的页面 Top 10】"
echo " 排名 访问次数 页面"
echo " —- ——– —-"
awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \\
awk '{printf(" %-6s %-8d %s\\n", NR".", $1, $2)}'
echo ""
# HTTP状态码统计
echo "【HTTP状态码统计】"
awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn | \\
while read count code; do
case $code in
200) desc="OK 成功" ;;
301) desc="永久重定向" ;;
302) desc="临时重定向" ;;
304) desc="未修改" ;;
403) desc="禁止访问" ;;
404) desc="未找到" ;;
500) desc="服务器内部错误" ;;
502) desc="网关错误" ;;
503) desc="服务不可用" ;;
*) desc="其他" ;;
esac
printf(" %s %d 次 (%s)\\n" "$code" "$count" "$desc")
done
echo ""
# 404错误统计
not_found=$(grep ' 404 ' "$LOG_FILE" | wc -l)
echo "【404错误】"
echo " 总计: $not_found 次"
if [ $not_found -gt 0 ]; then
echo " 缺失页面Top 5:"
grep ' 404 ' "$LOG_FILE" | awk '{print $7}' | sort | uniq -c | sort -rn | head -5 | \\
awk '{printf(" %d次 %s\\n", $1, $2)}'
fi
echo ""
# 访问时间分布(按小时)
echo "【访问时间分布(按小时)】"
awk '{print $4}' "$LOG_FILE" | cut -c 14-15 | sort | uniq -c | \\
awk '{
bar=""
for(i=0; i<int($1/total*50); i++) bar=bar"█"
printf(" %s时 %5d %s\\n", $2, $1, bar)
}' total=$(wc -l < "$LOG_FILE") | sort -t'时' -k1 -n
echo ""
# 来源统计
echo "【来源统计 Top 5】"
awk -F'"' '{print $6}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5 | \\
awk '{printf(" %d次 %s\\n", $1, substr($0, index($0,$2)))}'
echo ""
# 流量统计
echo "【流量统计】"
total_size=$(awk '{sum+=$10} END {printf("%.2f", sum/1024/1024)}' "$LOG_FILE" 2>/dev/null)
echo " 总传输流量: ${total_size} MB"
echo ""
echo "================================================"
echo " 分析报告结束"
echo "================================================"
7.13.6 案例:批量创建用户
#!/bin/bash
# 批量创建用户脚本 batch_user.sh
# 用户列表文件格式:用户名:密码:备注
# 示例:
# zhangsan:Pass@123:研发部
# lisi:Pass@456:测试部
USER_LIST="${1:-users.txt}"
DEFAULT_PASSWORD="Default@123"
DEFAULT_SHELL="/bin/bash"
# 检查是否root
if [ "$(id -u)" -ne 0 ]; then
echo "错误:请使用root用户运行"
exit 1
fi
# 检查用户列表文件
if [ ! -f "$USER_LIST" ]; then
echo "用户列表文件 $USER_LIST 不存在"
echo "正在创建示例文件…"
cat > "$USER_LIST" << EOF
zhangsan:Zhang@123:研发部
lisi:Li@456:测试部
wangwu:Wang@789:运维部
EOF
echo "已创建 $USER_LIST,请修改后重新运行"
exit 0
fi
# 统计
success=0
fail=0
exist=0
echo "==============================="
echo " 批量创建用户脚本"
echo " 用户列表: $USER_LIST"
echo "==============================="
echo ""
# 读取用户列表
while IFS=: read -r username password comment; do
# 跳过空行和注释
[[ -z "$username" || "$username" == \\#* ]] && continue
# 设置默认密码
[ -z "$password" ] && password="$DEFAULT_PASSWORD"
[ -z "$comment" ] && comment="批量创建用户"
# 检查用户是否已存在
if id "$username" &>/dev/null; then
echo "[跳过] 用户 $username 已存在 ($comment)"
exist=$((exist + 1))
continue
fi
# 创建用户
useradd -m -s "$DEFAULT_SHELL" -c "$comment" "$username"
if [ $? -eq 0 ]; then
# 设置密码
echo "$username:$password" | chpasswd
# 强制首次登录修改密码
chage -d 0 "$username"
echo "[成功] 创建用户 $username ($comment) 密码: $password"
success=$((success + 1))
else
echo "[失败] 创建用户 $username 失败"
fail=$((fail + 1))
fi
done < "$USER_LIST"
echo ""
echo "==============================="
echo " 执行结果统计"
echo "==============================="
echo " 成功创建: $success 个"
echo " 已存在: $exist 个"
echo " 失败: $fail 个"
echo "==============================="
7.13.7 学习建议
恭喜你看完了这一整章!Shell脚本编程内容很多,不要期望一次就全部记住。给你几个学习建议:
Shell脚本是Linux运维的必备技能,掌握它,你的工作效率会大幅提升。从今天开始,试着把日常重复的操作写成脚本吧!
本章总结
| Shell概念 | 命令解释器,用户与内核的桥梁 |
| 脚本基础 | Shebang、注释、执行方式 |
| 变量 | 定义不用,使用要加,使用要加,使用要加,等号不空格 |
| 环境变量 | export导出,子进程可见 |
| 位置参数 | $1 $2 $@ $# 等 |
| 字符串操作 | 长度、截取、替换 |
| 数组 | 普通数组和关联数组 |
| 算术运算 | $(( )) 最方便,bc支持小数 |
| 条件判断 | if-elif-else、test、[ ]、[[ ]] |
| 循环 | for、while、until、break、continue |
| case | 多分支选择,支持模式匹配 |
| 函数 | 封装代码,参数用$1 $2,返回用echo |
| 输入输出 | echo、printf、read、重定向、管道 |
| 调试 | bash -x、set -euo pipefail、trap |
| 实战案例 | 备份、监控、重命名、部署、日志分析 |
下一章我们将学习更多Linux进阶知识,继续加油!
网硕互联帮助中心




评论前必须登录!
注册