Shell01- 脚本语法基础
Shell 脚本语法基础
目录
- 脚本是什么,为什么值得学
- 脚本的本质:解释执行与 shebang
- 脚本的运行方式与权限
- 变量:定义、引用与展开规则
- Shell 展开机制:引号、命令替换与算术
- 位置参数与特殊变量
- 条件判断
- 分支结构:if 与 case
- 循环结构:for / while / until
- 函数:定义、参数、返回值与作用域
- 总结与练习
- 参考资料
0. 脚本是什么,为什么值得学
0.1 从命令到脚本
Shell 脚本是 Linux 运维最基础也最常用的一项技能。很多初学者的困境是:单条命令会敲,但把它写进一个 .sh 文件、加上变量和循环、做成一个「能自动干活的工具」,就不知道从哪下手。Shell 脚本解决的就是这个问题:把命令行的重定向、管道、变量、正则等基本功组织成可重复执行、可带逻辑的程序。
0.2 本篇内容
本篇以 bash 为准(Rocky/RHEL 的默认 shell),覆盖脚本的运行方式(解释执行、shebang、权限)、变量与展开、条件与分支、循环、函数。其主线是:Shell 先展开再执行——理解这条主线,很多行为就清楚了。
一、脚本的本质:解释执行与 shebang
1.1 Shell 脚本是什么
Shell 脚本本质上是一系列 Shell 命令按顺序写进一个文件,再加上一些编程结构(变量、条件、循环、函数)。你可以把它想成一份「操作清单」:把平时在终端里一条条手动敲的命令,提前写好、按顺序排列,再让 Shell 自动去执行。
一个重要的区别是编译型 vs 解释型:C、Java 这类语言需要先把源代码编译成可执行文件才能运行;而 Shell 脚本是解释型的——不需要编译,Shell 逐行读取、逐行解释执行。这意味着你可以随时改、随时跑,改完立刻生效。
关键认知:脚本里的每一行,其实都和你手动在终端敲的命令一样。写脚本 = 把「你会手动做的操作」用编程结构自动化、参数化。所以会敲命令,就已经会写脚本的一大半了。
1.2 shebang:脚本第一行的声明
脚本第一行通常是这一行特殊的内容:
#!/bin/bash
这行叫 shebang(读音接近 "she-bang")。它的作用是指定「用哪个解释器来执行这个脚本」。内核在直接执行一个脚本时,会读这第一行,根据它后面的路径调用对应的程序来解释运行这个文件:
#!/bin/bash:用 bash 解释(本系列默认);#!/bin/sh:用 POSIX sh(更精简,兼容性广,但功能少,比如没有[[ ]]和数组);#!/usr/bin/env python3:用 python3(通过 env 自动找解释器路径,这样脚本也能用于其他语言)。
注意 shebang 的写法是 #! 后面直接跟解释器的绝对路径,中间不留空格。它只有在你直接执行脚本(如 ./script.sh)时才起作用——那时内核会读取它来选定解释器。
1.3 没有 shebang 会怎样
如果你把一个文件直接执行(./script.sh)而它第一行没有 shebang,会报 Permission denied 或 bad interpreter 之类的错误,因为内核不知道该用哪个程序来跑它。
但只要你用显式指定解释器的方式运行,比如 bash script.sh,shebang 就无所谓了——因为是你手动告诉系统用 bash 来跑,不再需要从文件第一行去猜。
一句话:shebang 是给「直接执行」用的;显式
bash script.sh时它不起作用。
二、脚本的运行方式与权限
2.1 三种运行方式
写好的脚本有三种运行方式,各有各的用途:
# 方式1:显式用解释器运行(不需要可执行权限)
bash script.sh
# 方式2:直接执行(需要可执行权限 + shebang)
./script.sh
# 方式3:用 source(点号)执行——在当前 shell 里执行
source script.sh
. script.sh
2.2 三种方式的区别
这三种方式的差别,重点看两个维度:要不要可执行权限、在哪个 shell 里运行。
| 方式 | 是否要可执行权限 | 在哪个 shell 运行 | 效果 |
|---|---|---|---|
bash script.sh |
否 | 子 shell | 变量改动不回到当前 shell |
./script.sh |
是 | 子 shell | 同上 |
source script.sh / . |
否 | 当前 shell | 变量改动会留在当前 shell |
前两种(bash script.sh 和 ./script.sh)都会新开一个子 shell 来执行脚本。这个子 shell 就像脚本的「独立小房间」——脚本里改变或导出的变量,都不会影响你当前的终端(你当前的终端是「外面的大房间」,子 shell 是小房间,小房间里发生的事传不出去)。
而 source script.sh(或简写 . script.sh)是在当前 shell 里直接执行,不另开小房间。脚本里改的变量会直接影响当前终端。
区别要点:前两种在子 shell里运行,脚本里改的变量不会影响你当前的终端;
source在当前 shell里运行。source最常见的用途就是加载配置文件——比如.bashrc里定义了函数和别名,你用source .bashrc就是想让这些定义留在当前终端里生效。用source加载配置正是利用了这一点。
2.3 给脚本加执行权限
方式 2(直接 ./script.sh)需要脚本有可执行权限:
chmod +x script.sh # 加可执行权限
./script.sh # 直接执行
chmod +x 的意思是给文件加上「可执行」的权限位。没有执行权限时直接 ./script.sh 会报 Permission denied(权限被拒绝),加上 +x 即可。如果你平时都是 bash script.sh 这种方式跑,那其实可以不加执行权限。
三、变量:定义、引用与展开规则
3.1 定义变量
变量是给一段数据起个名字,方便后面反复引用。在 Shell 里定义一个变量很简单,就是 名字=值:
name="Tom" # 字符串
count=10 # 数字(在 shell 里也是字符串,只是算术时会当数字)
flag=true # 布尔(其实是字符串 true/false)
这里有个容易混淆的点:Shell 里其实没有严格的类型区分。count=10 存进去的也是字符串 "10",只是当你把它放进算术表达式时,Shell 会自动把它当数字用。同样,flag=true 也不是真正的布尔类型,只是约定俗成用字符串 true/false 表示真假。理解这一点,就不会奇怪为什么变量可以随便赋值了。
定义变量有几条硬性规则,违反了会报错或产生意外:
=两边不能有空格(name = "Tom"是错的——Shell 会把name当成一个命令名,然后报command not found);- 变量名只能由字母、数字、下划线组成,且不能以数字开头;
- 值如果含空格,必须用引号包起来(否则会被当成多个词)。
3.2 引用变量
定义了变量之后,怎么把它「取出来用」?用 $ 前缀引用:
echo "$name" # 输出 Tom
echo "Hello, ${name}" # 用 {} 明确边界
- 引用用
$前缀,$name就是「取 name 这个变量的值」; - 推荐写
${name}而不是$name。花括号的作用是明确变量名的边界,避免歧义。下面 3.3 就讲为什么这很重要。
3.3 边界歧义:为什么推荐 ${}
想象你要在一个词中间插入变量值,比如 "apple" 后面接 "juice",想拼出 "applejuice":
fruit="apple"
echo "$fruitjuice" # 错误!Shell 会把 fruitjuice 当成一个变量名(未定义,输出空)
echo "${fruit}juice" # 正确!输出 applejuice
第二行 $fruitjuice,Shell 会从左往右尽量取最长的合法变量名,所以它把 fruitjuice 整个当成一个变量名——而这个变量没定义过,结果就是空。第三行用 ${fruit} 明确告诉 Shell「变量名到 fruit 就结束了」,于是输出 applejuice。
$fruitjuice 会被解析成变量 fruitjuice 而不是 fruit+juice。用 ${fruit} 就明确了边界。凡是变量后面还紧跟其他字符,务必用 ${var}。
3.4 双引号 vs 单引号 vs 无引号
这是最容易踩坑的地方,务必理解。引号决定了 Shell 怎么看待 $、空格、通配符这些特殊字符:
| 写法 | 变量展开 | 命令替换 | 空格处理 | 通配符 |
|---|---|---|---|---|
"$name" 双引号 |
展开 | 执行 | 保留 | 不展开 |
'$name' 单引号 |
不展开 | 不执行 | 保留 | 不展开 |
$name 无引号 |
展开 | 执行 | 按空格分词 | 展开 |
逐条解释:
- 双引号:
$照常展开(取变量值),但空格被保留为一个整体,通配符*不展开。这是最常用的写法; - 单引号:里面的一切都是字面量,
$、*、空格全部原样输出,不做任何展开; - 无引号:
$展开,但结果会按空格再分一次词,通配符也会展开。
看个具体例子:
name="Tom Li"
echo $name # 输出: Tom Li(但 $name 被拆成两个参数 Tom 和 Li)
echo "$name" # 输出: Tom Li(作为一个整体参数)
echo '$name' # 输出: $name(字面,不展开)
注意第一行和第二行屏幕上看起来都是 Tom Li,但本质不同:第一行无引号,$name 被按空格拆成了 Tom 和 Li 两个独立参数;第二行有双引号,Tom Li 是一个整体参数。很多脚本 bug 就出在这里——比如 cp $src $dst 里 $src 含空格时会被拆开,导致复制出错。
推荐:凡是要把变量当「一个整体」用,都加双引号
"$var"。不加引号会在变量含空格时被拆成多个词,导致意外。这条习惯能帮你避开一大半的脚本坑。
3.5 只读与删除
有时你想让某个变量之后不能再被修改,或者想把它删掉:
readonly PI=3.14 # 只读变量,之后不能再改
unset name # 删除变量(变为未定义)
readonly 声明后,这个变量就变成只读的,再给它赋值会报错——适合定义「常量」;unset 则是把变量删掉,之后再引用它就是「未定义」。
3.6 变量的作用域
「作用域」指一个变量在哪些地方可见、可访问:
- 脚本顶层定义的变量是全局的,全脚本任何地方都能访问;
- 函数里默认也能读写全局变量(这点和很多语言不同,需要特别注意——函数改全局变量会真的改掉它);
- 函数内用
local声明的变量是局部的,只在该函数内有效(详见第 9 章)。
四、Shell 展开机制:引号、命令替换与算术
4.1 先理解:Shell 会「展开」
Shell 在执行一条命令之前,会先做展开(expansion)——把 $、*、$(...) 这些特殊写法替换成它们代表的实际内容,然后才真正去执行这条命令。你可以把它理解成「先翻译,再执行」。
这一条主线非常重要:你写 echo $name,Shell 不是把字面 $name 传给 echo,而是先把 $name 展开成它的值(比如 Tom),然后才执行 echo Tom。展开发生在执行之前。理解了这一点,前面引号、变量、通配符的种种行为就都能串联起来了——它们都是在「展开」这一步按不同规则进行的。
4.2 命令替换:$(...) 与反引号
「命令替换」是把一条命令的输出捕获下来,赋值给变量或用在别处:
today=$(date +%F) # 推荐写法:$(命令)
today2=`date +%F` # 旧写法:反引号
$(命令)会先执行里面的命令,把它标准输出的内容作为结果;$(date +%F)的结果就是今天的日期字符串(如2024-01-15);- 反引号
`...`是旧写法,功能一样,但嵌套时容易出错、可读性也差。
推荐统一用 $( )。它的一个额外好处是能嵌套:$(dirname $(which python)) 这种写法,反引号很难写清楚,而 $( ) 可以层层嵌套。
4.3 算术展开:$((...))
Shell 里的变量默认是字符串,要做整数运算,得用算术展开 $((...)):
sum=$((10 + 20)) # 30
double=$((count * 2)) # 用变量运算
mod=$((10 % 3)) # 取余 = 1
$((...))内可以做+ - * / %等整数运算,%是取余数;- 注意:
count在算术里**不用写$``。$((count * 2))里写count就行,Shell 会自动去取它的值。这跟变量引用(要写$``)不一样,是算术展开的专属便利。
4.4 波浪号、通配符与花括号展开
还有几种展开也常遇到:
echo ~ # 展开为家目录 /home/user
echo *.txt # 通配符展开为所有 .txt 文件名
echo file{1,2,3}.txt # 花括号展开: file1.txt file2.txt file3.txt
echo {a..e} # a b c d e
~展开为当前用户的家目录;*.txt是通配符(路径展开),Shell 会把它替换成当前目录下所有匹配.txt的文件名——如果没匹配到,就原样保留*.txt这个字符串;{1,2,3}是花括号展开,生成多个词;{a..e}是范围展开,生成 a 到 e。
这些也都是在执行前被展开成多个词的。注意花括号展开不会被双引号阻止(和通配符不同),但因为不太常用,知道有这回事即可。
4.5 展开的优先级(了解即可)
Shell 的展开顺序大致是:花括号 → 波浪号 → 变量/算术/命令替换 → 分词 → 通配符。不同展开类型之间还有先后。你不必死记这个顺序,但一定要理解「展开发生在执行之前」这条主线。遇到奇怪的语法现象,回到这条主线想:Shell 在动手执行前,先把我写的这些东西按规则翻译成了什么?
五、位置参数与特殊变量
5.1 位置参数:$1、$2、...
运行脚本时常常需要给它传参数,比如 ./backup.sh /data /backups。脚本里怎么接收这些参数?用位置参数:
#!/bin/bash
echo "脚本名: $0" # 脚本本身
echo "第1个参数: $1"
echo "第2个参数: $2"
echo "第3个参数: $3"
运行 ./script.sh a b c,则 $0=./script.sh,$1=a,$2=b,$3=c。$0 永远是脚本自身的名字,$1、$2... 依序对应第 1、第 2... 个参数。
5.2 特殊变量速查
Shell 内置了一批特殊变量,不用定义就能用,含义固定:
| 变量 | 含义 |
|---|---|
$0 |
脚本名 |
$1~`$9` |
第 1~9 个位置参数 |
${10} |
第 10 个及以上参数(要用花括号) |
$# |
参数个数 |
$@ |
所有参数(每个是独立词) |
$* |
所有参数(连成一个字符串) |
$? |
上一条命令的退出码 |
$$ |
当前脚本的进程 ID(PID) |
$! |
最近一个后台进程的 PID |
其中两个值得重点解释:
$#是参数的个数,常用于「检查用户有没有漏传参数」(见 5.4);$?是退出码(exit code):上一条命令成功返回0,失败返回非0。这是判断命令成败的核心手段,后面条件判断里会反复用到。
5.3 遍历所有参数
脚本里常要「把每个参数都处理一遍」,用 for 配合 "$@":
for arg in "$@"; do
echo "参数: $arg"
done
"$@" 会把每个参数当作一个独立的词,逐个赋值给 arg 循环处理。推荐用 "$@" 而非 $*:$* 会把所有参数挤成一个字符串,当某个参数本身含空格时会被错误地拆开,导致 bug。"$@" 则能保持每个参数的完整性。
5.4 参数个数检查
脚本开头最常做的事之一,就是检查参数个数,防止用户漏传参数导致脚本跑到一半才出错:
if [ $# -lt 2 ]; then
echo "用法: $0 <参数1> <参数2>"
exit 1
fi
$# 是参数个数,-lt 是「小于」(less than)。如果参数少于 2 个,就打印用法说明并 exit 1(用非 0 退出码表示「没按预期运行」)。exit 1 在这里很重要——它让脚本提前终止,并向上层返回一个「失败」的信号,而不是继续往下跑。
六、条件判断
6.1 四种条件写法
条件判断是脚本的分支基础——「如果满足某个条件,就做这件事」。bash 里有几种写法,容易混淆,这一节把它们讲清楚:
| 写法 | 用途 | 例子 |
|---|---|---|
test 表达式 |
POSIX 通用 | test -f /etc/passwd |
[ 表达式 ] |
test 的别名(注意空格) | [ -f /etc/passwd ] |
[[ 表达式 ]] |
bash 增强(推荐) | [[ -f /etc/passwd ]] |
(( 表达式 )) |
数值运算比较 | (( count > 10 )) |
其中 [ ... ] 和 test 是完全等价的——[ 其实是 test 命令的另一个名字(所以 [ 后面必须有个空格,因为它是命令名的一部分)。[[ ... ]] 是 bash 特有的增强版,功能更多、更安全。(( ... )) 则专门做数值运算和比较。
6.2 为什么推荐 [[ ]] 而不是 [ ]
[[ ... ]] 比 [ ... ] 更安全、功能更强,三个关键优势:
- 空变量不报错:
[ $var = x ]在$var为空时会因为展开成[ = x ]而语法报错;[[ $var = x ]]则能正确处理空值; - 支持正则
=~:[[ $str =~ ^root ]]可以用正则匹配(这是[ ]做不到的); - 支持
&&/||直接写在里面:[[ -f a && -f b ]]一个表达式搞定,而[ ]里要写别扭的-a/-o。
所以脚本里优先用 [[ ]]。
6.3 字符串比较
比较字符串用这些运算符:
[[ "$name" == "Tom" ]] # 相等
[[ "$name" != "Tom" ]] # 不等
[[ -z "$name" ]] # 字符串为空(长度 0)
[[ -n "$name" ]] # 字符串非空
==判断相等,!=判断不等;-z(zero)判断「是否为空字符串」,-n(non-zero)判断「是否非空」。
字符串比较用
==和!=,不要用-eq(那是数值比较)。用错了会得到错误结果甚至报错。
6.4 数值比较
数值比较有两套写法。一套是 -eq、-ne、-gt 这类字母运算符:
[[ 10 -eq 10 ]] # 等于
[[ 10 -ne 5 ]] # 不等于
[[ 10 -gt 5 ]] # 大于 greater than
[[ 5 -lt 10 ]] # 小于 less than
[[ 10 -ge 5 ]] # 大于等于
[[ 5 -le 10 ]] # 小于等于
另一套是用 (( ... )) 直接写常见的数学符号,更直观:
(( count > 10 )) # 用 > < = 等符号
(( count >= 5 ))
(( )) 里可以直接用 >、<、>=、<= 这些符号,因为它是专门的算术上下文,不会和重定向(>)混淆。
6.5 文件判断
脚本经常要判断「某个文件/目录存不存在、是什么类型、可不可读」,用这些文件判断:
| 写法 | 含义 |
|---|---|
-f 文件 |
是普通文件 |
-d 目录 |
是目录 |
-e 路径 |
存在(文件或目录) |
-r / -w / -x |
可读 / 可写 / 可执行 |
-s 文件 |
存在且非空 |
-L |
是符号链接 |
if [ -f /etc/passwd ]; then
echo "存在"
fi
if [ ! -d /tmp/mydir ]; then
echo "目录不存在"
fi
! 是「取反」,[ ! -d ... ] 就是「如果它不是目录」。注意第二段:判断的是 /tmp/mydir 这个目录,所以用 -d 而不是 -f。
6.6 逻辑组合:-a -o 或 && ||
把多个条件组合起来(「并且」「或者」):
# 在 [ ] 里用 -a(and) -o(or)
[ -f a.txt -a -f b.txt ]
# 在 [[ ]] 里用 && ||
[[ -f a.txt && -f b.txt ]]
# 推荐:多个 [[]] 用 && ||
[[ -f a.txt ]] && [[ -f b.txt ]]
-a 是「并且」(and),-o 是「或者」(or),但只在 [ ] 里用;&& / || 在 [[ ]] 里用更清晰。推荐用 [[ ]] + && / ||。
6.7 判断命令是否成功(退出码)
这是脚本里非常常用、也常被忽略的技巧:任何命令都可以直接当条件。因为命令都有退出码(成功为 0,失败非 0),退出码为 0 就相当于「条件为真」:
if grep -q "error" /var/log/app.log; then
echo "日志里有 error"
fi
if command -v curl; then
echo "curl 已安装"
fi
grep 如果匹配到就返回 0,没匹配到返回 1;command -v curl 如果 curl 存在就返回 0,不存在返回非 0。直接把命令写进 if 里,就是拿它的退出码当条件,完全不用专门去捕获 $?,非常简洁。
七、分支结构:if 与 case
7.1 if / elif / else
if 是最基础的分支结构,根据条件决定走哪条路:
if 条件1; then
命令1
elif 条件2; then
命令2
else
命令3
fi
执行逻辑:先判断「条件1」,为真则执行「命令1」并跳过其余;否则再判断「条件2」,为真执行「命令2」;都不满足则执行「命令3」。elif 可以有多个,else 可选。
注意两个语法细节:
- 条件后面要跟
;或换行,然后才是then(if 条件; then是一行写的固定格式); - 结尾必须是
fi(把if倒过来写),就像 case 以esac结尾、for 以done结尾一样——这是 bash 的「闭合词」约定。
看个实际例子:
#!/bin/bash
score=85
if (( score >= 90 )); then
echo "优秀"
elif (( score >= 60 )); then
echo "及格"
else
echo "不及格"
fi
7.2 case:多分支匹配
当你要根据一个值的多种可能来分支时,case 比一堆 if/elif 更清晰。典型就是「服务管理脚本」,根据第一个参数是 start/stop/restart 执行不同操作:
case "$1" in
start)
echo "启动服务"
;;
stop)
echo "停止服务"
;;
restart)
echo "重启服务"
;;
*)
echo "用法: $0 {start|stop|restart}"
exit 1
;;
esac
case 语法要点:
- 每个分支写
模式)开头,内容之后用;;(两个分号)结束这个分支; *)是默认分支(兜底),匹配所有没被前面分支接住的值;- 支持模式匹配:
start|stop)用|匹配多个值,[0-9]*)匹配以数字开头的值——模式里可以用通配符和字符类。
case 非常适合写「服务管理脚本」(start/stop/restart),比一堆 if/elif 简洁得多。
八、循环结构:for / while / until
循环就是「重复做某件事」。bash 有 for、while、until 三种,各适合不同场景。
8.1 for:遍历列表
for 适合「对一组东西逐个处理」。in 后面跟一个列表,循环体每次取列表里的一个元素赋给变量:
# 遍历固定列表
for fruit in apple banana orange; do
echo "水果: $fruit"
done
# 遍历通配符展开的文件
for f in /etc/*.conf; do
echo "配置文件: $f"
done
# 遍历命令输出
for user in $(cat /etc/passwd | cut -d: -f1); do
echo "用户: $user"
done
注意第二种写法 for f in /etc/*.conf:*.conf 会在执行前被展开成所有匹配的文件名(还记得第 4 章的展开吗),于是 for 就逐个处理这些文件。第三种 $(cat ... | cut ...) 是命令替换,把命令输出的每行(或按空格分词)作为列表。这三种都是 for in 列表 的标准用法。
8.2 for:遍历所有参数
结合第 5 章,for 最常见的用途之一就是遍历脚本的所有参数:
for arg in "$@"; do
echo "参数: $arg"
done
8.3 for:C 风格数值循环
当你要「循环固定次数、用下标控制」时,用 C 风格写法:
for ((i=1; i<=10; i++)); do
echo "第 $i 次"
done
((i=1; i<=10; i++)) 是 C 语言风格的循环三要素,用分号分隔:初始化(i=1)、条件(i<=10)、步进(i++,每轮 i 加 1)。这个写法在需要「从 1 数到 N」或「按数字下标操作数组」时非常方便。
8.4 while:条件为真时循环
while 是「只要条件为真,就一直循环」。条件用 (( )) 或 [[ ]] 判断:
count=1
while (( count <= 5 )); do
echo "count=$count"
(( count++ ))
done
(( count++ )) 是「count 自增 1」。循环体会一直执行,直到 count 超过 5。注意:while 循环里一定要有能改变条件的语句(这里是 count++),否则就是死循环。
8.5 逐行读文件(while read)
这是脚本处理文件的经典写法,一定要掌握。它的作用是「一行一行地读文件,每行处理一次」:
while IFS= read -r line; do
echo "读到的行: $line"
done < /etc/passwd
拆解几个细节:
read -r line:read从输入读一行,存进变量line;-r防止反斜杠被当成转义符(保留原样);IFS=:把 IFS(字段分隔符)设为空,这样read会保留行首尾的空格,不会把行按空格切开;< /etc/passwd:把文件作为整个while循环的输入,read每次从中取一行。
这个模式在「逐行处理配置文件、日志」时是绝对的主力,后面实战篇会反复用到。
8.6 until:条件为假时循环
until 和 while 相反:while 是条件为真继续循环,until 是条件为假继续循环、条件为真时停止:
count=1
until (( count > 5 )); do
echo "count=$count"
(( count++ ))
done
上面这段和 8.4 的效果一样,只是条件写成「count 大于 5 时停止」。until 用得相对少,但知道「它是 while 的反面」就够了。
8.7 break 与 continue
循环里有两个控制语句:break 提前终止整个循环,continue 跳过当前这一次迭代继续下一次:
for i in {1..10}; do
if (( i == 3 )); then
continue # 跳过本次,继续下一次
fi
if (( i == 8 )); then
break # 跳出整个循环
fi
echo "i=$i"
done
上面这段会输出 i=1、2,跳过 3,输出 4、5、6、7,到 8 时 break 跳出,所以不会输出 8、9、10。
continue:跳过当前迭代,进入下一次;break:终止整个循环。
九、函数:定义、参数、返回值与作用域
9.1 定义与调用
当一段逻辑要反复使用时,就应该把它封装成函数。函数可以理解为「一段可以重复调用的代码块」,把它抽出来,脚本更清晰、更易维护:
# 定义
say_hello() {
echo "Hello, $1"
}
# 调用
say_hello "World"
定义函数的格式是 函数名() { ... },调用时直接写函数名。函数内部能用的语法(变量、条件、循环)和脚本主体完全一样。
9.2 函数接收参数
函数也能接收参数。注意:函数内部的 $1、$2 是传给函数的参数,和脚本的位置参数是两回事——调用函数时写在函数名后面的参数,在函数体内就通过 $1、$2 拿到:
add() {
local a=$1
local b=$2
echo $((a + b))
}
result=$(add 3 5) # 用 $() 捕获函数输出
echo "3+5=$result" # 输出 3+5=8
add 3 5 调用函数时,函数内 $1=3、$2=5。echo $((a+b)) 输出结果,再用 $( ) 命令替换把函数的输出捕获给变量 result。
9.3 返回值:两种方式
函数有两种「返回值」方式,初学者最容易混淆,务必分清:
方式1:return 返回数字退出码(0-255)
check_root() {
if [[ $EUID -eq 0 ]]; then
return 0 # 成功
else
return 1 # 失败
fi
}
if check_root; then
echo "是 root"
else
echo "不是 root"
fi
return 只能返回一个 0-255 的数字退出码(就像命令的退出码一样),不能返回字符串。它的用途是「表示成功/失败」——return 0 表示成功,非 0 表示失败,然后可以用 if check_root; then 直接拿函数的退出码当条件(和第 6.7 节判断命令是否成功是同一套路)。
方式2:用 echo 输出 + $( ) 捕获(返回字符串)
get_date() {
echo "$(date +%F)"
}
today=$(get_date) # today = 当前日期字符串
如果你想让函数「返回一段数据」(比如一个字符串、一个计算结果),就在函数里用 echo 把数据打印出来,然后在调用处用 $( ) 命令替换把它捕获给变量。这是函数「返回字符串」的标准做法。
规律:要「是否成功」用 return(退出码);要「返回一段数据」用 echo 输出再由 $( ) 捕获。两者用途完全不同,别混用。
9.4 局部变量:local
函数里用 local 声明的变量,只在该函数内有效,不会影响函数外:
foo() {
local x=10 # 局部变量
y=20 # 全局变量(会污染外部)
echo "$x $y"
}
foo
echo "外部 x=$x" # 空(x 是局部的)
echo "外部 y=$y" # 20(y 被函数改了)
前面 3.6 提过:函数默认能读写全局变量。所以函数里直接 y=20 会修改全局的 y,函数调用后外部 $y 也变成了 20——这就是「污染」。用 local x=10 声明的 x 则只活在函数里,函数结束后外部没有 x。
最佳实践:函数内部变量一律用 local 声明,避免意外修改全局变量。这是写好脚本的重要习惯。
十、总结与练习
10.1 本篇核心速查
- shebang
#!/bin/bash指定解释器,直接执行时起作用; - 运行:
bash s.sh(子 shell)、./s.sh(需 +x)、source s.sh(当前 shell); - 变量:
=两边无空格;引用用"$var";推荐${var}明确边界; - 引号:双引号展开、单引号不展开、无引号分词;
- 展开:
$(命令)命令替换、$((算术))整数运算; - 特殊变量:$0、$1..$9、$#、$@、$?、$$;
- 条件:优先
[[ ]];字符串 ==、数值 -eq/-gt、文件 -f/-d、命令退出码; - 分支:if/elif/else/fi、case(多分支用
;;); - 循环:for(列表/C 风格)、while、until、break、continue、逐行 read;
- 函数:
local局部变量;return 返回退出码、echo+$( ) 返回数据。
10.2 动手练习
- 写一个脚本接收两个参数,输出它们的和(用 $(()));
- 用 if +
[[ -f ]]判断一个文件是否存在,不存在则提示; - 用 case 写一个 start/stop/restart 的迷你服务脚本;
- 用 for 遍历 /etc/*.conf 并打印每个文件名;
- 用 while read 逐行读取 /etc/passwd 打印第 1 列(用户名)。
参考资料
- GNU Bash 手册(变量与展开):https://www.gnu.org/software/bash/manual/
- 菜鸟教程 - Shell 变量:https://www.runoob.com/linux/linux-shell-variable.html
- 菜鸟教程 - Shell 流程控制:https://www.runoob.com/linux/linux-shell-process-control.html
Shell 脚本语法基础
目录
- 本系列说明与阅读方式
- 脚本的本质:解释执行与 shebang
- 脚本的运行方式与权限
- 变量:定义、引用与展开规则
- Shell 展开机制:引号、命令替换与算术
- 位置参数与特殊变量
- 条件判断
- 分支结构:if 与 case
- 循环结构:for / while / until
- 函数:定义、参数、返回值与作用域
- 总结与练习
- 参考资料
0. 本系列说明与阅读方式
0.1 这个系列讲什么
Shell 脚本是 Linux 运维最基础也最常用的一项技能。很多初学者的困境是:单条命令会敲,但把它写进一个 .sh 文件、加上变量和循环、做成一个「能自动干活的工具」,就不知道从哪下手了。这个系列就是解决这个问题的。
本系列共 6 篇,目标是从「能看懂别人的脚本」一路走到「写出能上生产的脚本」:
| 篇 | 文档 | 内容 |
|---|---|---|
| 第 1 篇 | Shell 脚本语法基础(本篇) | 运行方式、变量、展开、条件、循环、函数 |
| 第 2 篇 | Shell 脚本进阶与健壮性 | 数组、字符串处理、参数校验、set、trap、错误处理 |
| 第 3 篇 | grep 与正则表达式 | 正则原理 + grep 全部用法 |
| 第 4 篇 | sed 流编辑器 | 寻址、替换、增删行、就地修改 |
| 第 5 篇 | awk 文本处理 | 字段模型、条件、统计实战 |
| 第 6 篇 | Shell 脚本实战 | 端到端运维脚本,串起前 5 篇 |
第 1 篇(本篇)是地基。前面初级《Shell 基础与基本工具》已经讲过命令行的重定向、管道、变量、正则等基本功,这一篇从「命令行交互」过渡到「写脚本」,把脚本的语法讲透:脚本怎么运行、变量和展开是怎么回事、控制流怎么写、函数怎么组织。
0.2 怎么读这一篇
- 建议边读边敲,每个例子都在终端里试一遍,光看是记不住的;
- 本篇以 bash 为准(Rocky/RHEL 的默认 shell),脚本第一行写
#!/bin/bash; - 记住一条主线:Shell 是先展开再执行。这条主线贯穿整篇,理解了它,很多坑就迎刃而解。
一、脚本的本质:解释执行与 shebang
1.1 Shell 脚本是什么
Shell 脚本本质上是一系列 Shell 命令按顺序写进一个文件,再加上一些编程结构(变量、条件、循环、函数)。你可以把它想成一份「操作清单」:把平时在终端里一条条手动敲的命令,提前写好、按顺序排列,再让 Shell 自动去执行。
一个重要的区别是编译型 vs 解释型:C、Java 这类语言需要先把源代码编译成可执行文件才能运行;而 Shell 脚本是解释型的——不需要编译,Shell 逐行读取、逐行解释执行。这意味着你可以随时改、随时跑,改完立刻生效。
关键认知:脚本里的每一行,其实都和你手动在终端敲的命令一样。写脚本 = 把「你会手动做的操作」用编程结构自动化、参数化。所以会敲命令,就已经会写脚本的一大半了。
1.2 shebang:脚本第一行的声明
脚本第一行通常是这一行特殊的内容:
#!/bin/bash
这行叫 shebang(读音接近 "she-bang")。它的作用是指定「用哪个解释器来执行这个脚本」。内核在直接执行一个脚本时,会读这第一行,根据它后面的路径调用对应的程序来解释运行这个文件:
#!/bin/bash:用 bash 解释(本系列默认);#!/bin/sh:用 POSIX sh(更精简,兼容性广,但功能少,比如没有[[ ]]和数组);#!/usr/bin/env python3:用 python3(通过 env 自动找解释器路径,这样脚本也能用于其他语言)。
注意 shebang 的写法是 #! 后面直接跟解释器的绝对路径,中间不留空格。它只有在你直接执行脚本(如 ./script.sh)时才起作用——那时内核会读取它来选定解释器。
1.3 没有 shebang 会怎样
如果你把一个文件直接执行(./script.sh)而它第一行没有 shebang,会报 Permission denied 或 bad interpreter 之类的错误,因为内核不知道该用哪个程序来跑它。
但只要你用显式指定解释器的方式运行,比如 bash script.sh,shebang 就无所谓了——因为是你手动告诉系统用 bash 来跑,不再需要从文件第一行去猜。
一句话:shebang 是给「直接执行」用的;显式
bash script.sh时它不起作用。
二、脚本的运行方式与权限
2.1 三种运行方式
写好的脚本有三种运行方式,各有各的用途:
# 方式1:显式用解释器运行(不需要可执行权限)
bash script.sh
# 方式2:直接执行(需要可执行权限 + shebang)
./script.sh
# 方式3:用 source(点号)执行——在当前 shell 里执行
source script.sh
. script.sh
2.2 三种方式的区别
这三种方式的差别,重点看两个维度:要不要可执行权限、在哪个 shell 里运行。
| 方式 | 是否要可执行权限 | 在哪个 shell 运行 | 效果 |
|---|---|---|---|
bash script.sh |
否 | 子 shell | 变量改动不回到当前 shell |
./script.sh |
是 | 子 shell | 同上 |
source script.sh / . |
否 | 当前 shell | 变量改动会留在当前 shell |
前两种(bash script.sh 和 ./script.sh)都会新开一个子 shell 来执行脚本。这个子 shell 就像脚本的「独立小房间」——脚本里改变或导出的变量,都不会影响你当前的终端(你当前的终端是「外面的大房间」,子 shell 是小房间,小房间里发生的事传不出去)。
而 source script.sh(或简写 . script.sh)是在当前 shell 里直接执行,不另开小房间。脚本里改的变量会直接影响当前终端。
区别要点:前两种在子 shell里运行,脚本里改的变量不会影响你当前的终端;
source在当前 shell里运行。source最常见的用途就是加载配置文件——比如.bashrc里定义了函数和别名,你用source .bashrc就是想让这些定义留在当前终端里生效。用source加载配置正是利用了这一点。
2.3 给脚本加执行权限
方式 2(直接 ./script.sh)需要脚本有可执行权限:
chmod +x script.sh # 加可执行权限
./script.sh # 直接执行
chmod +x 的意思是给文件加上「可执行」的权限位。没有执行权限时直接 ./script.sh 会报 Permission denied(权限被拒绝),加上 +x 即可。如果你平时都是 bash script.sh 这种方式跑,那其实可以不加执行权限。
三、变量:定义、引用与展开规则
3.1 定义变量
变量是给一段数据起个名字,方便后面反复引用。在 Shell 里定义一个变量很简单,就是 名字=值:
name="Tom" # 字符串
count=10 # 数字(在 shell 里也是字符串,只是算术时会当数字)
flag=true # 布尔(其实是字符串 true/false)
这里有个容易混淆的点:Shell 里其实没有严格的类型区分。count=10 存进去的也是字符串 "10",只是当你把它放进算术表达式时,Shell 会自动把它当数字用。同样,flag=true 也不是真正的布尔类型,只是约定俗成用字符串 true/false 表示真假。理解这一点,就不会奇怪为什么变量可以随便赋值了。
定义变量有几条硬性规则,违反了会报错或产生意外:
=两边不能有空格(name = "Tom"是错的——Shell 会把name当成一个命令名,然后报command not found);- 变量名只能由字母、数字、下划线组成,且不能以数字开头;
- 值如果含空格,必须用引号包起来(否则会被当成多个词)。
3.2 引用变量
定义了变量之后,怎么把它「取出来用」?用 $ 前缀引用:
echo "$name" # 输出 Tom
echo "Hello, ${name}" # 用 {} 明确边界
- 引用用
$前缀,$name就是「取 name 这个变量的值」; - 推荐写
${name}而不是$name。花括号的作用是明确变量名的边界,避免歧义。下面 3.3 就讲为什么这很重要。
3.3 边界歧义:为什么推荐 ${}
想象你要在一个词中间插入变量值,比如 "apple" 后面接 "juice",想拼出 "applejuice":
fruit="apple"
echo "$fruitjuice" # 错误!Shell 会把 fruitjuice 当成一个变量名(未定义,输出空)
echo "${fruit}juice" # 正确!输出 applejuice
第二行 $fruitjuice,Shell 会从左往右尽量取最长的合法变量名,所以它把 fruitjuice 整个当成一个变量名——而这个变量没定义过,结果就是空。第三行用 ${fruit} 明确告诉 Shell「变量名到 fruit 就结束了」,于是输出 applejuice。
$fruitjuice 会被解析成变量 fruitjuice 而不是 fruit+juice。用 ${fruit} 就明确了边界。凡是变量后面还紧跟其他字符,务必用 ${var}。
3.4 双引号 vs 单引号 vs 无引号
这是最容易踩坑的地方,务必理解。引号决定了 Shell 怎么看待 $、空格、通配符这些特殊字符:
| 写法 | 变量展开 | 命令替换 | 空格处理 | 通配符 |
|---|---|---|---|---|
"$name" 双引号 |
展开 | 执行 | 保留 | 不展开 |
'$name' 单引号 |
不展开 | 不执行 | 保留 | 不展开 |
$name 无引号 |
展开 | 执行 | 按空格分词 | 展开 |
逐条解释:
- 双引号:
$照常展开(取变量值),但空格被保留为一个整体,通配符*不展开。这是最常用的写法; - 单引号:里面的一切都是字面量,
$、*、空格全部原样输出,不做任何展开; - 无引号:
$展开,但结果会按空格再分一次词,通配符也会展开。
看个具体例子:
name="Tom Li"
echo $name # 输出: Tom Li(但 $name 被拆成两个参数 Tom 和 Li)
echo "$name" # 输出: Tom Li(作为一个整体参数)
echo '$name' # 输出: $name(字面,不展开)
注意第一行和第二行屏幕上看起来都是 Tom Li,但本质不同:第一行无引号,$name 被按空格拆成了 Tom 和 Li 两个独立参数;第二行有双引号,Tom Li 是一个整体参数。很多脚本 bug 就出在这里——比如 cp $src $dst 里 $src 含空格时会被拆开,导致复制出错。
推荐:凡是要把变量当「一个整体」用,都加双引号
"$var"。不加引号会在变量含空格时被拆成多个词,导致意外。这条习惯能帮你避开一大半的脚本坑。
3.5 只读与删除
有时你想让某个变量之后不能再被修改,或者想把它删掉:
readonly PI=3.14 # 只读变量,之后不能再改
unset name # 删除变量(变为未定义)
readonly 声明后,这个变量就变成只读的,再给它赋值会报错——适合定义「常量」;unset 则是把变量删掉,之后再引用它就是「未定义」。
3.6 变量的作用域
「作用域」指一个变量在哪些地方可见、可访问:
- 脚本顶层定义的变量是全局的,全脚本任何地方都能访问;
- 函数里默认也能读写全局变量(这点和很多语言不同,需要特别注意——函数改全局变量会真的改掉它);
- 函数内用
local声明的变量是局部的,只在该函数内有效(详见第 9 章)。
四、Shell 展开机制:引号、命令替换与算术
4.1 先理解:Shell 会「展开」
Shell 在执行一条命令之前,会先做展开(expansion)——把 $、*、$(...) 这些特殊写法替换成它们代表的实际内容,然后才真正去执行这条命令。你可以把它理解成「先翻译,再执行」。
这一条主线非常重要:你写 echo $name,Shell 不是把字面 $name 传给 echo,而是先把 $name 展开成它的值(比如 Tom),然后才执行 echo Tom。展开发生在执行之前。理解了这一点,前面引号、变量、通配符的种种行为就都能串联起来了——它们都是在「展开」这一步按不同规则进行的。
4.2 命令替换:$(...) 与反引号
「命令替换」是把一条命令的输出捕获下来,赋值给变量或用在别处:
today=$(date +%F) # 推荐写法:$(命令)
today2=`date +%F` # 旧写法:反引号
$(命令)会先执行里面的命令,把它标准输出的内容作为结果;$(date +%F)的结果就是今天的日期字符串(如2024-01-15);- 反引号
`...`是旧写法,功能一样,但嵌套时容易出错、可读性也差。
推荐统一用 $( )。它的一个额外好处是能嵌套:$(dirname $(which python)) 这种写法,反引号很难写清楚,而 $( ) 可以层层嵌套。
4.3 算术展开:$((...))
Shell 里的变量默认是字符串,要做整数运算,得用算术展开 $((...)):
sum=$((10 + 20)) # 30
double=$((count * 2)) # 用变量运算
mod=$((10 % 3)) # 取余 = 1
$((...))内可以做+ - * / %等整数运算,%是取余数;count在算术表达式里不需要写$。在$((count * 2))内部直接写count即可,Shell 会自动读取变量的值。这和普通变量引用(需要写$)不一样,属于算术展开的专属便利写法
4.4 波浪号、通配符与花括号展开
还有几种展开也常遇到:
echo ~ # 展开为家目录 /home/user
echo *.txt # 通配符展开为所有 .txt 文件名
echo file{1,2,3}.txt # 花括号展开: file1.txt file2.txt file3.txt
echo {a..e} # a b c d e
~展开为当前用户的家目录;*.txt是通配符(路径展开),Shell 会把它替换成当前目录下所有匹配.txt的文件名——如果没匹配到,就原样保留*.txt这个字符串;{1,2,3}是花括号展开,生成多个词;{a..e}是范围展开,生成 a 到 e。
这些也都是在执行前被展开成多个词的。注意花括号展开不会被双引号阻止(和通配符不同),但因为不太常用,知道有这回事即可。
4.5 展开的优先级(了解即可)
Shell 的展开顺序大致是:花括号 → 波浪号 → 变量/算术/命令替换 → 分词 → 通配符。不同展开类型之间还有先后。你不必死记这个顺序,但一定要理解「展开发生在执行之前」这条主线。遇到奇怪的语法现象,回到这条主线想:Shell 在动手执行前,先把我写的这些东西按规则翻译成了什么?
五、位置参数与特殊变量
5.1 位置参数:$1、$2、...
运行脚本时常常需要给它传参数,比如 ./backup.sh /data /backups。脚本里怎么接收这些参数?用位置参数:
#!/bin/bash
echo "脚本名: $0" # 脚本本身
echo "第1个参数: $1"
echo "第2个参数: $2"
echo "第3个参数: $3"
运行 ./script.sh a b c,则 $0=./script.sh,$1=a,$2=b,$3=c。$0 永远是脚本自身的名字,$1、$2... 依序对应第 1、第 2... 个参数。
5.2 特殊变量速查
Shell 内置了一批特殊变量,不用定义就能用,含义固定:
| 变量 | 含义 |
|---|---|
$0 |
脚本名 |
$1~$9 |
第 1~9 个位置参数 |
${10} |
第 10 个及以上参数(要用花括号) |
$# |
参数个数 |
$@ |
所有参数(每个是独立词) |
$* |
所有参数(连成一个字符串) |
$? |
上一条命令的退出码 |
$$ |
当前脚本的进程 ID(PID) |
$! |
最近一个后台进程的 PID |
其中两个值得重点解释:
$#是参数的个数,常用于「检查用户有没有漏传参数」(见 5.4);$?是退出码(exit code):上一条命令成功返回0,失败返回非0。这是判断命令成败的核心手段,后面条件判断里会反复用到。
5.3 遍历所有参数
脚本里常要「把每个参数都处理一遍」,用 for 配合 "$@":
for arg in "$@"; do
echo "参数: $arg"
done
"$@" 会把每个参数当作一个独立的词,逐个赋值给 arg 循环处理。推荐用 "$@" 而非 $*:$* 会把所有参数挤成一个字符串,当某个参数本身含空格时会被错误地拆开,导致 bug。"$@" 则能保持每个参数的完整性。
5.4 参数个数检查
脚本开头最常做的事之一,就是检查参数个数,防止用户漏传参数导致脚本跑到一半才出错:
if [ $# -lt 2 ]; then
echo "用法: $0 <参数1> <参数2>"
exit 1
fi
$# 是参数个数,-lt 是「小于」(less than)。如果参数少于 2 个,就打印用法说明并 exit 1(用非 0 退出码表示「没按预期运行」)。exit 1 在这里很重要——它让脚本提前终止,并向上层返回一个「失败」的信号,而不是继续往下跑。
六、条件判断
6.1 四种条件写法
条件判断是脚本的分支基础——「如果满足某个条件,就做这件事」。bash 里有几种写法,容易混淆,这一节把它们讲清楚:
| 写法 | 用途 | 例子 |
|---|---|---|
test 表达式 |
POSIX 通用 | test -f /etc/passwd |
[ 表达式 ] |
test 的别名(注意空格) | [ -f /etc/passwd ] |
[[ 表达式 ]] |
bash 增强(推荐) | [[ -f /etc/passwd ]] |
(( 表达式 )) |
数值运算比较 | (( count > 10 )) |
其中 [ ... ] 和 test 是完全等价的——[ 其实是 test 命令的另一个名字(所以 [ 后面必须有个空格,因为它是命令名的一部分)。[[ ... ]] 是 bash 特有的增强版,功能更多、更安全。(( ... )) 则专门做数值运算和比较。
6.2 为什么推荐 [[ ]] 而不是 [ ]
[[ ... ]] 比 [ ... ] 更安全、功能更强,三个关键优势:
- 空变量不报错:
[ $var = x ]在$var为空时会因为展开成[ = x ]而语法报错;[[ $var = x ]]则能正确处理空值; - 支持正则
=~:[[ $str =~ ^root ]]可以用正则匹配(这是[ ]做不到的); - 支持
&&/||直接写在里面:[[ -f a && -f b ]]一个表达式搞定,而[ ]里要写别扭的-a/-o。
所以脚本里优先用 [[ ]]。
6.3 字符串比较
比较字符串用这些运算符:
[[ "$name" == "Tom" ]] # 相等
[[ "$name" != "Tom" ]] # 不等
[[ -z "$name" ]] # 字符串为空(长度 0)
[[ -n "$name" ]] # 字符串非空
==判断相等,!=判断不等;-z(zero)判断「是否为空字符串」,-n(non-zero)判断「是否非空」。
字符串比较用
==和!=,不要用-eq(那是数值比较)。用错了会得到错误结果甚至报错。
6.4 数值比较
数值比较有两套写法。一套是 -eq、-ne、-gt 这类字母运算符:
[[ 10 -eq 10 ]] # 等于
[[ 10 -ne 5 ]] # 不等于
[[ 10 -gt 5 ]] # 大于 greater than
[[ 5 -lt 10 ]] # 小于 less than
[[ 10 -ge 5 ]] # 大于等于
[[ 5 -le 10 ]] # 小于等于
另一套是用 (( ... )) 直接写常见的数学符号,更直观:
(( count > 10 )) # 用 > < = 等符号
(( count >= 5 ))
(( )) 里可以直接用 >、<、>=、<= 这些符号,因为它是专门的算术上下文,不会和重定向(>)混淆。
6.5 文件判断
脚本经常要判断「某个文件/目录存不存在、是什么类型、可不可读」,用这些文件判断:
| 写法 | 含义 |
|---|---|
-f 文件 |
是普通文件 |
-d 目录 |
是目录 |
-e 路径 |
存在(文件或目录) |
-r / -w / -x |
可读 / 可写 / 可执行 |
-s 文件 |
存在且非空 |
-L |
是符号链接 |
if [ -f /etc/passwd ]; then
echo "存在"
fi
if [ ! -d /tmp/mydir ]; then
echo "目录不存在"
fi
! 是「取反」,[ ! -d ... ] 就是「如果它不是目录」。注意第二段:判断的是 /tmp/mydir 这个目录,所以用 -d 而不是 -f。
6.6 逻辑组合:-a -o 或 && ||
把多个条件组合起来(「并且」「或者」):
# 在 [ ] 里用 -a(and) -o(or)
[ -f a.txt -a -f b.txt ]
# 在 [[ ]] 里用 && ||
[[ -f a.txt && -f b.txt ]]
# 推荐:多个 [[]] 用 && ||
[[ -f a.txt ]] && [[ -f b.txt ]]
-a 是「并且」(and),-o 是「或者」(or),但只在 [ ] 里用;&& / || 在 [[ ]] 里用更清晰。推荐用 [[ ]] + && / ||。
6.7 判断命令是否成功(退出码)
这是脚本里非常常用、也常被忽略的技巧:任何命令都可以直接当条件。因为命令都有退出码(成功为 0,失败非 0),退出码为 0 就相当于「条件为真」:
if grep -q "error" /var/log/app.log; then
echo "日志里有 error"
fi
if command -v curl; then
echo "curl 已安装"
fi
grep 如果匹配到就返回 0,没匹配到返回 1;command -v curl 如果 curl 存在就返回 0,不存在返回非 0。直接把命令写进 if 里,就是拿它的退出码当条件,完全不用专门去捕获 $?,非常简洁。
七、分支结构:if 与 case
7.1 if / elif / else
if 是最基础的分支结构,根据条件决定走哪条路:
if 条件1; then
命令1
elif 条件2; then
命令2
else
命令3
fi
执行逻辑:先判断「条件1」,为真则执行「命令1」并跳过其余;否则再判断「条件2」,为真执行「命令2」;都不满足则执行「命令3」。elif 可以有多个,else 可选。
注意两个语法细节:
- 条件后面要跟
;或换行,然后才是then(if 条件; then是一行写的固定格式); - 结尾必须是
fi(把if倒过来写),就像 case 以esac结尾、for 以done结尾一样——这是 bash 的「闭合词」约定。
看个实际例子:
#!/bin/bash
score=85
if (( score >= 90 )); then
echo "优秀"
elif (( score >= 60 )); then
echo "及格"
else
echo "不及格"
fi
7.2 case:多分支匹配
当你要根据一个值的多种可能来分支时,case 比一堆 if/elif 更清晰。典型就是「服务管理脚本」,根据第一个参数是 start/stop/restart 执行不同操作:
case "$1" in
start)
echo "启动服务"
;;
stop)
echo "停止服务"
;;
restart)
echo "重启服务"
;;
*)
echo "用法: $0 {start|stop|restart}"
exit 1
;;
esac
case 语法要点:
- 每个分支写
模式)开头,内容之后用;;(两个分号)结束这个分支; *)是默认分支(兜底),匹配所有没被前面分支接住的值;- 支持模式匹配:
start|stop)用|匹配多个值,[0-9]*)匹配以数字开头的值——模式里可以用通配符和字符类。
case 非常适合写「服务管理脚本」(start/stop/restart),比一堆 if/elif 简洁得多。
八、循环结构:for / while / until
循环就是「重复做某件事」。bash 有 for、while、until 三种,各适合不同场景。
8.1 for:遍历列表
for 适合「对一组东西逐个处理」。in 后面跟一个列表,循环体每次取列表里的一个元素赋给变量:
# 遍历固定列表
for fruit in apple banana orange; do
echo "水果: $fruit"
done
# 遍历通配符展开的文件
for f in /etc/*.conf; do
echo "配置文件: $f"
done
# 遍历命令输出
for user in $(cat /etc/passwd | cut -d: -f1); do
echo "用户: $user"
done
注意第二种写法 for f in /etc/*.conf:*.conf 会在执行前被展开成所有匹配的文件名(还记得第 4 章的展开吗),于是 for 就逐个处理这些文件。第三种 $(cat ... | cut ...) 是命令替换,把命令输出的每行(或按空格分词)作为列表。这三种都是 for in 列表 的标准用法。
8.2 for:遍历所有参数
结合第 5 章,for 最常见的用途之一就是遍历脚本的所有参数:
for arg in "$@"; do
echo "参数: $arg"
done
8.3 for:C 风格数值循环
当你要「循环固定次数、用下标控制」时,用 C 风格写法:
for ((i=1; i<=10; i++)); do
echo "第 $i 次"
done
((i=1; i<=10; i++)) 是 C 语言风格的循环三要素,用分号分隔:初始化(i=1)、条件(i<=10)、步进(i++,每轮 i 加 1)。这个写法在需要「从 1 数到 N」或「按数字下标操作数组」时非常方便。
8.4 while:条件为真时循环
while 是「只要条件为真,就一直循环」。条件用 (( )) 或 [[ ]] 判断:
count=1
while (( count <= 5 )); do
echo "count=$count"
(( count++ ))
done
(( count++ )) 是「count 自增 1」。循环体会一直执行,直到 count 超过 5。注意:while 循环里一定要有能改变条件的语句(这里是 count++),否则就是死循环。
8.5 逐行读文件(while read)
这是脚本处理文件的经典写法,一定要掌握。它的作用是「一行一行地读文件,每行处理一次」:
while IFS= read -r line; do
echo "读到的行: $line"
done < /etc/passwd
拆解几个细节:
read -r line:read从输入读一行,存进变量line;-r防止反斜杠被当成转义符(保留原样);IFS=:把 IFS(字段分隔符)设为空,这样read会保留行首尾的空格,不会把行按空格切开;< /etc/passwd:把文件作为整个while循环的输入,read每次从中取一行。
这个模式在「逐行处理配置文件、日志」时是绝对的主力,后面实战篇会反复用到。
8.6 until:条件为假时循环
until 和 while 相反:while 是条件为真继续循环,until 是条件为假继续循环、条件为真时停止:
count=1
until (( count > 5 )); do
echo "count=$count"
(( count++ ))
done
上面这段和 8.4 的效果一样,只是条件写成「count 大于 5 时停止」。until 用得相对少,但知道「它是 while 的反面」就够了。
8.7 break 与 continue
循环里有两个控制语句:break 提前终止整个循环,continue 跳过当前这一次迭代继续下一次:
for i in {1..10}; do
if (( i == 3 )); then
continue # 跳过本次,继续下一次
fi
if (( i == 8 )); then
break # 跳出整个循环
fi
echo "i=$i"
done
上面这段会输出 i=1、2,跳过 3,输出 4、5、6、7,到 8 时 break 跳出,所以不会输出 8、9、10。
continue:跳过当前迭代,进入下一次;break:终止整个循环。
九、函数:定义、参数、返回值与作用域
9.1 定义与调用
当一段逻辑要反复使用时,就应该把它封装成函数。函数可以理解为「一段可以重复调用的代码块」,把它抽出来,脚本更清晰、更易维护:
# 定义
say_hello() {
echo "Hello, $1"
}
# 调用
say_hello "World"
定义函数的格式是 函数名() { ... },调用时直接写函数名。函数内部能用的语法(变量、条件、循环)和脚本主体完全一样。
9.2 函数接收参数
函数也能接收参数。注意:函数内部的 $1、$2 是传给函数的参数,和脚本的位置参数是两回事——调用函数时写在函数名后面的参数,在函数体内就通过 $1、$2 拿到:
add() {
local a=$1
local b=$2
echo $((a + b))
}
result=$(add 3 5) # 用 $() 捕获函数输出
echo "3+5=$result" # 输出 3+5=8
add 3 5 调用函数时,函数内 $1=3、$2=5。echo $((a+b)) 输出结果,再用 $( ) 命令替换把函数的输出捕获给变量 result。
9.3 返回值:两种方式
函数有两种「返回值」方式,初学者最容易混淆,务必分清:
方式1:return 返回数字退出码(0-255)
check_root() {
if [[ $EUID -eq 0 ]]; then
return 0 # 成功
else
return 1 # 失败
fi
}
if check_root; then
echo "是 root"
else
echo "不是 root"
fi
return 只能返回一个 0-255 的数字退出码(就像命令的退出码一样),不能返回字符串。它的用途是「表示成功/失败」——return 0 表示成功,非 0 表示失败,然后可以用 if check_root; then 直接拿函数的退出码当条件(和第 6.7 节判断命令是否成功是同一套路)。
方式2:用 echo 输出 + $( ) 捕获(返回字符串)
get_date() {
echo "$(date +%F)"
}
today=$(get_date) # today = 当前日期字符串
如果你想让函数「返回一段数据」(比如一个字符串、一个计算结果),就在函数里用 echo 把数据打印出来,然后在调用处用 $( ) 命令替换把它捕获给变量。这是函数「返回字符串」的标准做法。
规律:要「是否成功」用 return(退出码);要「返回一段数据」用 echo 输出再由 $( ) 捕获。两者用途完全不同,别混用。
9.4 局部变量:local
函数里用 local 声明的变量,只在该函数内有效,不会影响函数外:
foo() {
local x=10 # 局部变量
y=20 # 全局变量(会污染外部)
echo "$x $y"
}
foo
echo "外部 x=$x" # 空(x 是局部的)
echo "外部 y=$y" # 20(y 被函数改了)
前面 3.6 提过:函数默认能读写全局变量。所以函数里直接 y=20 会修改全局的 y,函数调用后外部 $y 也变成了 20——这就是「污染」。用 local x=10 声明的 x 则只活在函数里,函数结束后外部没有 x。
最佳实践:函数内部变量一律用 local 声明,避免意外修改全局变量。这是写好脚本的重要习惯。
十、总结与练习
10.1 本篇核心速查
- shebang
#!/bin/bash指定解释器,直接执行时起作用; - 运行:
bash s.sh(子 shell)、./s.sh(需 +x)、source s.sh(当前 shell); - 变量:
=两边无空格;引用用"$var";推荐${var}明确边界; - 引号:双引号展开、单引号不展开、无引号分词;
- 展开:
$(命令)命令替换、$((算术))整数运算; - 特殊变量:$0、$1..$9、$#、$@、$?、$$;
- 条件:优先
[[ ]];字符串 ==、数值 -eq/-gt、文件 -f/-d、命令退出码; - 分支:if/elif/else/fi、case(多分支用
;;); - 循环:for(列表/C 风格)、while、until、break、continue、逐行 read;
- 函数:
local局部变量;return 返回退出码、echo+$( ) 返回数据。
10.2 动手练习
- 写一个脚本接收两个参数,输出它们的和(用 $(()));
- 用 if +
[[ -f ]]判断一个文件是否存在,不存在则提示; - 用 case 写一个 start/stop/restart 的迷你服务脚本;
- 用 for 遍历 /etc/*.conf 并打印每个文件名;
- 用 while read 逐行读取 /etc/passwd 打印第 1 列(用户名)。
参考资料
- GNU Bash 手册(变量与展开):https://www.gnu.org/software/bash/manual/
- 菜鸟教程 - Shell 变量:https://www.runoob.com/linux/linux-shell-variable.html
- 菜鸟教程 - Shell 流程控制:https://www.runoob.com/linux/linux-shell-process-control.html
