第53章:Bash 脚本基础

第五十三章:Bash 脚本基础

53.1 Shell 与 Shell 脚本

你每天都在终端里敲命令:ls 看文件、cd 换目录、git pull 拉代码。如果同一串命令每天都要重复敲一遍,迟早会烦。

Shell 脚本就是把这些命令按顺序写进一个文件,以后运行这个文件,就等于把命令从头到尾替你敲了一遍。

53.1.1 Shell 是什么

Shell 是 Linux 的命令行解释器,它处在「你」和「内核」之间:你输入的是给人看的命令,内核只认系统调用,Shell 负责翻译。

graph LR
    A["用户<br>输入 ls -l"] -->|命令| B["Shell<br>bash / zsh / fish"]
    B -->|翻译成系统调用| C["Linux 内核"]
    C -->|返回结果| B
    B -->|格式化成文本| A

常见的 Shell:

Shell说明
bashBourne Again Shell,绝大多数 Linux 发行版的默认 Shell,本教程的主角
zshmacOS 的默认 Shell,交互体验好,语法与 bash 高度兼容
fish交互友好,但语法与 bash 不兼容,别拿它跑 bash 脚本
shPOSIX 标准的最小实现,不是一个具体程序,通常是指向别的 Shell 的软链接
dashDebian/Ubuntu 上 /bin/sh 实际指向它,启动快,但缺 bash 特性

这里有一个非常容易踩的坑:sh 不等于 bash。

1
2
3
4
5
# Debian / Ubuntu:/bin/sh 是 dash 的软链接
ls -l /bin/sh          # /bin/sh -> dash

# RHEL / CentOS / Rocky:/bin/sh 是 bash 的软链接
ls -l /bin/sh          # /bin/sh -> bash

所以用 sh script.sh 运行一个用了 [[ ]]、数组、{1..5} 的脚本,在 Ubuntu 上可能报错,在 CentOS 上却正常——这跟脚本本身无关,纯粹是 /bin/sh 指向不同。

查看当前 Shell 和系统认可的 Shell 列表:

1
2
3
echo $SHELL      # 登录 Shell 的路径,例如 /bin/bash
echo $0          # 当前进程的名字;在交互式 Shell 里也是它自己
cat /etc/shells  # 系统认为是"合法登录 Shell"的列表

注意 $SHELL 是登录时设定的默认 Shell,你手动敲了一句 bash 进了子 Shell,$SHELL 通常还是老值。

53.1.2 脚本能做什么

应用场景示例
自动化运维批量部署、配置管理、定时任务
数据处理日志分析、文件整理、格式转换
系统监控检查服务状态、发送告警通知
备份恢复自动备份数据库、清理过期文件
开发辅助编译项目、批量测试、环境初始化

几个真实的小例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 场景一:批量压缩图片(需要 ImageMagick 提供的 convert)
for img in *.jpg; do
    convert "$img" -quality 85 "compressed_$img"
done

# 场景二:一行搞定的部署脚本 deploy.sh
#!/bin/bash
set -euo pipefail
git pull
npm install
pm2 restart all
echo "部署完成"

# 场景三:交给 crontab 每天凌晨 3 点自动备份
# 0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Shell 脚本的定位:它擅长"把现成的命令行工具串起来"。真要做复杂的字符串处理、JSON 解析、并发控制,Python/Go 会比 Shell 舒服得多。判断标准很简单——一旦脚本超过两三百行还全是 awk 和 sed,就该考虑换语言了。

53.2 第一个脚本

53.2.1 创建与编写

1
vim greeting.sh        # 或者 nano / code,随你喜欢

内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
# greeting.sh - 我的第一个正经脚本

# 定义变量
name="小明"
today=$(date +%Y年%m月%d日)

# 输出问候语
echo "========================================"
echo "  您好,$name!"
echo "  今天是:$today"
echo "========================================"

53.2.2 Shebang 到底是什么

第一行 #!/bin/bash 叫 Shebang(读作 shee-bang)。它只在一种情况下起作用:你把脚本当成可执行文件直接运行时(./greeting.sh)。这时内核会读第一行,知道要用 /bin/bash 来解释这个文件。

如果换成 bash greeting.sh 运行,那么这一行就只是一句普通注释,写不写都一样。

三条硬性要求:

  1. #! 必须是文件最开始的两个字节,前面不能有空行、空格。
  2. 保存文件时不能带 UTF-8 BOM,否则内核看到的是乱码,会报 bad interpreter。
  3. 不能是 Windows 换行符(CRLF),否则解释器路径会带上一个 \r,报错长这样:
1
2
bash: ./greeting.sh: /bin/bash^M: bad interpreter: No such file or directory
# 修复:dos2unix greeting.sh   (或者 sed -i 's/\r$//' greeting.sh)

写 Shebang 有两种常见写法:

1
2
#!/bin/bash          # 绝对路径,明确、最常用
#!/usr/bin/env bash  # 从 PATH 里找 bash,换机器(如 macOS 自带 bash 3.2)时更省心

53.2.3 运行脚本的三种方式

1
2
3
4
5
6
7
8
9
# 方式一:加执行权限后直接运行(最标准)
chmod +x greeting.sh
./greeting.sh

# 方式二:显式指定解释器(脚本不需要执行权限)
bash greeting.sh

# 方式三:交给 sh 运行(POSIX 标准,bash 专有语法可能报错)
sh greeting.sh
方式需要执行权限在哪个进程里运行典型用途
./script.sh需要新起的子 Shell(由 Shebang 决定)日常执行脚本
bash script.sh不需要新起的 bash 子进程临时执行、换解释器
source script.sh(等价 . script.sh)不需要当前 Shell加载配置、函数库

bash script.sh 和 source script.sh 的区别极其重要,新手几乎都在这翻过车:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# test_env.sh
#!/bin/bash
export MY_VAR="Hello from script"
cd /tmp

# 用 bash 运行:改动只发生在子进程里
bash test_env.sh
echo "$MY_VAR"    # 输出空行,变量不存在
pwd               # 还在原来的目录,cd 没有影响你

# 用 source 运行:改动直接作用在当前 Shell
source test_env.sh
echo "$MY_VAR"    # Hello from script
pwd               # /tmp

原因:bash script.sh 会 fork 一个子进程,脚本里的变量、cd、export 全都在子进程里,进程一退出就灰飞烟灭。source 则是在当前 Shell 里逐行执行,等于你亲手把那些命令敲了一遍。

⚠️ 所以:source 一个来源不明的脚本,等于把它的每一条命令都交给当前 Shell 执行,风险比 bash 运行大得多。修改完 ~/.bashrc 用 source ~/.bashrc 生效是正常用法;从网上抄来的脚本,先看内容再决定。

53.2.4 脚本的组成结构

一个规整的脚本通常长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# ============================================
# 脚本名称:greeting.sh
# 作者:小明
# 日期:2026-03-24
# 描述:打印一句问候和一个日期
# ============================================

set -euo pipefail    # 出错就停,详见 53.10 节

NAME="Linux"         # 1. 变量定义
VERSION="1.0"

main() {             # 2. 主逻辑封装成函数
    echo "Hello, $NAME!"
    echo "Version: $VERSION"
}

main "$@"            # 3. 调用入口

exit 0               # 4. 退出码:0 表示成功

53.2.5 后缀名重要吗

不重要。.sh 只是给人看的约定,Linux 靠 Shebang 判断解释器:

1
2
mv greeting.sh greeting
./greeting              # 照样能跑

约定俗成:

  • 工具脚本用 .sh 后缀,别人一眼能看出是什么;
  • 可执行的命令(放在 ~/.local/bin、/usr/local/bin)通常不带后缀,这样 deploy 比 deploy.sh 更像一个命令;
  • 被 source 加载的库文件常用 .bash(如 ~/.bashrc)或 _lib.sh,避免误当命令执行。

53.3 变量

53.3.1 定义与赋值

1
2
3
4
5
name="Linux"        # 字符串
version=1.0         # 数值(本质仍是字符串,做运算时才知道要当数字)
count=0
empty=""            # 空字符串
readonly PI=3.14    # 只读变量,之后赋值会报错

等号两边绝对不能有空格,这是 Shell 与几乎所有语言最大的不同:

1
2
3
name="Linux"        # ✅ 赋值
name = "Linux"      # ❌ 被解析成"执行 name 命令,参数是 = 和 Linux"
                    #    报错:name: command not found

变量没有类型,也没有布尔值。所谓"布尔变量"只是习惯用法:

1
2
3
4
is_ready=true
if [ "$is_ready" = "true" ]; then    # 必须和字符串比较
    echo "就绪"
fi

53.3.2 使用变量

取值要带 $,推荐加花括号(${})明确边界:

1
2
3
4
5
6
name="Linux"

echo $name            # Linux
echo "$name"          # Linux(推荐:加双引号)
echo "${name}"        # Linux
echo "${name}_rule"   # Linux_rule;如果写 $name_rule 会去找名为 name_rule 的变量

字符串拼接直接把变量挨着写就行,不需要 +:

1
greeting="Hello, $name!"

花括号还支持一批"参数扩展",处理默认值和空值非常方便:

写法含义
${var}普通取值
${var:-default}var 未定义或为空时,临时用 default,不改 var
${var:=default}同上,但会把 default 赋给 var
${var:?提示信息}var 为空时报错退出,适合检查必填参数
${#var}字符串长度
${var^^} / ${var,,}转大写 / 转小写(bash 4+)
${var#前缀} / ${var%后缀}掐掉最短的前缀 / 后缀
1
: "${1:?用法: $0 <文件名>}"   # 没传参数就报错退出,脚本开头的常用保险

53.3.3 特殊变量

变量含义示例
$0脚本名(含路径)./backup.sh
$1 … $9第 1–9 个参数$1 是第一个参数
${10}第 10 个及以后的参数必须加花括号
$#参数个数3
$@所有参数;"$@" 会保留每个参数的边界
$*所有参数;"$*" 会拼成一个字符串
$?上一条命令的退出状态,0 表示成功
$$当前 Shell 的进程号12345
$!最近一个后台进程的 PID
$LINENO当前行号,调试时有用
$RANDOM0–32767 的随机整数4823
$USER $HOME $PWD $PATH用户名 / 家目录 / 当前目录 / 命令搜索路径

"$@" 和 "$*" 的差别值得单独看一眼:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 假设执行:./test.sh "a b" c
for arg in "$@"; do echo "[$arg]"; done
# [a b]
# [c]          ← 保留了两个参数

for arg in "$*"; do echo "[$arg]"; done
# [a b c]      ← 被拼成了一个参数

for arg in $@; do echo "[$arg]"; done
# [a]
# [b]
# [c]          ← 没加引号,被空格拆开了,参数边界丢失

结论:传递参数永远用 "$@"。

53.3.4 命令替换

把命令的标准输出当成字符串赋给变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 推荐:$()
today=$(date +%Y-%m-%d)
disk_line=$(df -h / | tail -1)

# 不推荐:反引号,嵌套和转义都很别扭
today=`date +%Y-%m-%d`

# 反引号里再嵌一层,就要疯狂转义:
echo "今天是 `date +%Y` 年的第 `date +%j` 天"      # 能跑,但难读
echo "今天是 $(date +%Y) 年的第 $(date +%j) 天"    # 清晰得多

只捕获标准输出,标准错误仍然会直接打印到终端。要一起捕获就写 2>&1:

1
out=$(some_command 2>&1)

53.3.5 只读与删除

1
2
3
4
5
6
readonly MAX_RETRIES=3
MAX_RETRIES=5          # 报错:MAX_RETRIES: readonly variable

name="Linux"
unset name
echo "$name"           # 空

注意:如果打开了 set -u(未定义变量报错),unset 之后再读会直接让脚本退出。

53.4 引号、输出与输入

53.4.1 四种"引号"的区别

写法变量是否展开通配符是否展开说明
'单引号'否否所见即所得,一个字都不动
"双引号"是否变量、$( )、反引号会展开
`反引号`——命令替换(旧写法,见 53.3.4)
不加引号是是最危险,会做分词和通配符展开
1
2
3
4
5
6
7
8
name="Linux"

echo '$name'          # $name                (原样输出)
echo "$name"          # Linux                (展开变量)
echo $name            # Linux                (也能展开,但不安全,见下)

echo "*"              # *                    (双引号阻止通配符)
echo *                # 当前目录所有文件名    (通配符被展开)

为什么到处都要加双引号——因为不加引号时,变量的值会先被"拆词",再做通配符匹配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
file="my document.txt"

cat $file             # 等于 cat "my" "document.txt",报错:my 不存在
cat "$file"           # 正确
rm $file              # 如果目录里刚好有 my 和 document.txt 两个文件…你就删错了
rm "$file"            # 正确

dir=""
ls $dir/*             # 等于 ls /*  ——灾难现场
ls "$dir"/*           # 至少不会有意外

记住一句口诀:变量要加双引号,除非你明确知道自己在做分词或通配符展开。这条规则能挡掉八成的 Shell 脚本事故。

53.4.2 输出:echo 与 printf

echo 是 Shell 内置的,行为在不同实现间有差异。最重要的一条:

1
2
3
4
5
6
7
8
9
# echo 默认【不】解析反斜杠转义!
echo "Hello\nWorld"
# Hello\nWorld          ← 原样输出,不是换行

echo -e "Hello\nWorld"  # 加 -e 才解析转义
# Hello
# World

printf 'Hello\nWorld\n' # ✅ 需要转义时首选 printf,POSIX 标准、行为一致

所以看到老脚本里 echo "a\tb" 期待制表符的,基本是写错了。要么加 -e,要么换成 printf。

53.4.3 Here Document

把一段多行文本喂给命令的标准输入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 变量会展开
cat << EOF
=================================
    用户信息报表
=================================
用户名:$USER
日期:$(date +%Y-%m-%d)
主机:$HOSTNAME
=================================
EOF

# 分隔符加引号,则一个字都不展开(写配置、写脚本时很有用)
cat << 'EOF'
变量 $USER 不会被替换
EOF

在脚本里生成需要 root 权限的文件,配合 sudo tee 可以解决"重定向不受 sudo 管"的问题:

1
2
3
sudo tee /etc/example.conf > /dev/null << 'EOF'
key = value
EOF

53.4.4 Here String

1
2
3
4
5
# 把字符串当作标准输入(bash 专有语法)
cat <<< "Hello World"

# 等价于
echo "Hello World" | cat

53.5 运算符与条件测试

53.5.1 算术运算

Shell 的算术只支持整数,浮点要用 bc 或 awk。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 方法一:$(( )),最推荐
result=$((1 + 2))
echo "$result"      # 3

a=10
b=3
echo $((a + b))     # 13
echo $((a - b))     # 7
echo $((a * b))     # 30
echo $((a / b))     # 3    ← 整数除法,小数被丢掉
echo $((a % b))     # 1
echo $((a ** 2))    # 100  ← 幂运算

# 复合赋值
echo $((a += 5))    # 15(同时 a 变成 15)
echo $((a -= 3))    # 12
echo $((a *= 2))    # 24

# 方法二:let,写法更紧凑
let "c = a + b"

# 方法三:$[ ],已废弃,别用
result=$[1 + 2]

# 浮点运算:交给 bc 或 awk
echo "scale=2; 10/3" | bc            # 3.33
awk 'BEGIN{printf "%.2f\n", 10/3}'   # 3.33

53.5.2 数值比较 vs 字符串比较

这是出错最多的地方:数值比较用 -eq -lt 这类字母形式,字符串比较才用 = <。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
x=10
y=20

# 数值比较
[ "$x" -eq "$y" ]    # equal
[ "$x" -ne "$y" ]    # not equal
[ "$x" -gt "$y" ]    # greater than
[ "$x" -lt "$y" ]    # less than
[ "$x" -ge "$y" ]    # greater or equal
[ "$x" -le "$y" ]    # less or equal

# 字符串比较
[ "$s1" = "$s2" ]    # 相等(POSIX 用单个 =)
[ "$s1" != "$s2" ]   # 不等
[ -z "$s" ]          # 字符串为空(zero length)
[ -n "$s" ]          # 字符串非空

[[ "$s1" == "$s2" ]]    # bash 里可以用 ==,还能写通配符
[[ "$s" == a* ]]        # 前缀匹配
[[ "$s" =~ ^[0-9]+$ ]]  # 正则匹配(bash 3.0+)

一个经典陷阱:[ "10" \> "9" ] 是字符串比较,10 排在 9 前面,结果是假;想比大小必须用 -gt。

53.5.3 [ ]、[[ ]]、(( )) 该怎么选

写法是什么特点
[ ... ]test 命令的别名POSIX 标准,sh 也能用;变量必须加引号,> < 会被当成重定向
[[ ... ]]bash 关键字不做分词,变量可省引号;支持 && `
(( ... ))算术求值里面写数学表达式,不用加 $;结果非 0 为真
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# [ ] 是命令,所以尖括号会被当成重定向,要转义
[ "$a" \> "$b" ]        # 能用但难读
[[ "$a" > "$b" ]]       # 清晰

# [[ ]] 里可以直接写 && ||
[[ -f "$f" && -r "$f" ]]
[ -f "$f" ] && [ -r "$f" ]    # [ ] 必须拆成两条

# (( )) 天生用来算数和比较
if (( count > 10 )); then
    echo "太多了"
fi

test 也可以直接当命令用:

1
test -f /etc/passwd && echo "文件存在"

53.5.4 逻辑运算与文件测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 逻辑运算
cmd1 && cmd2     # cmd1 成功才执行 cmd2
cmd1 || cmd2     # cmd1 失败才执行 cmd2
! cmd            # 取反

# 文件测试
[ -e "$path" ]   # 存在
[ -f "$path" ]   # 是普通文件
[ -d "$path" ]   # 是目录
[ -L "$path" ]   # 是符号链接
[ -r "$path" ]   # 可读
[ -w "$path" ]   # 可写
[ -x "$path" ]   # 可执行
[ -s "$path" ]   # 存在且非空

# 文件之间的比较
[ a -nt b ]      # a 比 b 新(newer than)
[ a -ot b ]      # a 比 b 旧
[ a -ef b ]      # 两者是同一个文件(硬链接或同一 inode)

53.6 条件判断:if 与 case

53.6.1 if 的基本写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 单分支
if [ 条件 ]; then
    ...
fi

# 双分支
if [ 条件 ]; then
    ...
else
    ...
fi

# 多分支
if [[ 条件1 ]]; then
    ...
elif [[ 条件2 ]]; then
    ...
else
    ...
fi

三种写法的执行路径:

flowchart TD
    S["开始"] --> C1{"条件1 成立?"}
    C1 -->|是| B1["执行分支 1"]
    C1 -->|否| C2{"条件2 成立?"}
    C2 -->|是| B2["执行分支 2"]
    C2 -->|否| B3["执行 else 分支"]
    B1 --> E["继续往下执行"]
    B2 --> E
    B3 --> E

在终端里手敲时,别忘了 fi 结尾——if 反着写就是 fi。

53.6.2 完整例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash
# check_number.sh - 判断数字大小

number=15

if (( number > 20 )); then
    echo "$number 比 20 大"
elif (( number > 10 )); then
    echo "$number 比 10 大,不超过 20"
else
    echo "$number 不超过 10"
fi
# 输出:15 比 10 大,不超过 20

多条件判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
age=25
status="admin"

# 逻辑与:两个条件都成立
if [[ $age -ge 18 && $age -lt 65 ]]; then
    echo "劳动力人口"
fi

# 逻辑或
if [[ $status == "admin" || $status == "root" ]]; then
    echo "管理员权限"
fi

# 嵌套
if [[ $age -ge 18 ]]; then
    echo "已成年"
    if [[ $status == "admin" ]]; then
        echo "而且是管理员"
    fi
else
    echo "未成年"
fi

53.6.3 if 的常见坑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 坑一:忘了空格。[ ] 是命令,[ 后面和 ] 前面必须有空格
if [$x -eq 1]; then        # ❌ 报错:command not found: [1
if [ $x -eq 1 ]; then      # ✅

# 坑二:变量没加引号,变量为空时语法就崩了
x=""
if [ $x = "a" ]; then      # ❌ 展开成 [ = "a" ],报错
if [ "$x" = "a" ]; then    # ✅
if [[ $x == "a" ]]; then   # ✅ [[ ]] 不做分词

# 坑三:用了 == 却跑在 sh 里
if [ "$a" == "$b" ]; then  # bash 能跑,dash 报错
if [ "$a" = "$b" ]; then   # ✅ POSIX 写法

53.6.4 case 语句

分支很多时,case 比一长串 if...elif 清爽得多:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
case "$变量" in
    模式1)
        命令
        ;;
    模式2|模式3)        # 用 | 表示"或"
        命令
        ;;
    *)
        默认分支
        ;;
esac

注意三点:case 结尾是 esac;每个分支以 ;; 结束(最后一条可以省略,但建议都写上);* 分支相当于 else,习惯放在最后。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
# menu.sh - 简单的菜单选择

echo "请选择你喜欢的操作系统:"
echo "1) Linux"
echo "2) macOS"
echo "3) Windows"

read -r choice

case "$choice" in
    1) echo "明智的选择" ;;
    2) echo "设计优美" ;;
    3) echo "好吧……" ;;
    *) echo "无效选择,请输入 1-3" ;;
esac

case 的模式支持通配符,处理文件类型、分数段这类需求很合适:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 按文件名后缀分类
case "$filename" in
    *.jpg|*.jpeg|*.png) echo "图片文件" ;;
    *.txt|*.md)         echo "文本文件" ;;
    *.sh)               echo "Shell 脚本" ;;
    *)                  echo "未知类型" ;;
esac

# 按分数段分类(注意 case 只做模式匹配,不理解数值大小)
case "$score" in
    100|9[0-9]) echo "优秀" ;;
    [7-8][0-9]) echo "良好" ;;
    6[0-9])     echo "及格" ;;
    [0-9])      echo "个位数,需要努力" ;;
    *)          echo "输入不合法" ;;
esac

⚠️ case 擅长的是字符串和模式匹配,不擅长数值范围。分数段这种需求用 if (( )) 表达更清楚,也不会漏掉边界值。

53.7 循环:for / while / until

53.7.1 for 循环

1
2
3
4
5
6
7
8
9
# 形式一:遍历一组值
for 变量 in 值1 值2 值3; do
    命令
done

# 形式二:C 语言风格(bash 专有)
for (( i=0; i<5; i++ )); do
    命令
done

实用例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 遍历通配符匹配到的文件
for file in *.txt; do
    echo "处理文件: $file"
done

# 遍历数字序列(注意:{1..5} 是 bash 的括号展开,sh 不支持)
for i in {1..5}; do
    echo "第 $i 次迭代"
done

# 带步长
for i in {0..10..2}; do
    echo "$i"       # 0 2 4 6 8 10
done

# C 语言风格
for (( i=1; i<=5; i++ )); do
    echo "计数: $i"
done

# 遍历数组(必须用 "${arr[@]}")
colors=("红" "绿" "蓝")
for color in "${colors[@]}"; do
    echo "颜色: $color"
done

遍历文件时要小心两件事:

1
2
3
4
5
6
7
# 1) 目录里一个 .txt 都没有时,*.txt 会原样保留,循环会跑一次,
#    此时 file 的值就是字面量 "*.txt"
shopt -s nullglob      # 让没匹配到的通配符展开成空,循环体一次都不执行

# 2) 文件名带空格时,ls 的输出不可靠,永远不要在脚本里解析 ls 的输出
for f in *.txt; do echo "$f"; done        # ✅ 通配符由 Shell 展开,天然安全
for f in $(ls *.txt); do echo "$f"; done  # ❌ 遇到 "my file.txt" 会被拆成两个

53.7.2 批量重命名

1
2
3
4
5
6
7
8
9
# 给所有 .txt 加 .bak 后缀
for file in *.txt; do
    mv "$file" "$file.bak"
done

# 把大写的 .JPG 统一改成 .jpg
for img in *.JPG; do
    mv "$img" "${img%.JPG}.jpg"
done

批量重命名属于"一旦写错就回不去"的操作,动手之前先跑一遍不带 mv 的版本:

1
2
3
4
5
# 先干跑:只打印,不改
for img in *.JPG; do
    echo "mv \"$img\" \"${img%.JPG}.jpg\""
done
# 确认输出没问题,再把 echo 换成真的执行

53.7.3 while 循环

条件成立时一直执行:

1
2
3
4
5
6
7
#!/bin/bash
# 计数器
count=1
while (( count <= 5 )); do
    echo "计数: $count"
    (( count++ ))
done

⚠️ (( count++ )) 在 count 为 0 时,表达式的值是 0,命令退出码为 1。配合 set -e 会让脚本莫名其妙退出。保险写法是 count=$((count + 1))。

逐行读取文件的标准写法(这是 Shell 脚本里最常用的模式之一):

1
2
3
while IFS= read -r line; do
    echo "行内容: $line"
done < /etc/hostname

三个细节缺一不可:

  • IFS= 保留行首行尾的空格,否则 read 会把它们吃掉;
  • -r 不让反斜杠被当成转义符,否则路径里的 \ 会丢失;
  • < 文件 放在 done 后面,这样 read 才在同一个 Shell 里跑。

对比一下错误写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# ❌ 用管道,while 在子 Shell 中执行,循环里的变量在循环外拿不到
count=0
cat file.txt | while IFS= read -r line; do
    count=$((count + 1))
done
echo "$count"    # 空!子 Shell 里的修改丢了

# ✅ 用重定向,同一个 Shell
count=0
while IFS= read -r line; do
    count=$((count + 1))
done < file.txt
echo "$count"    # 正确
1
2
3
4
5
# 无限循环,Ctrl+C 停止
while true; do
    echo "按 Ctrl+C 停止..."
    sleep 1
done

53.7.4 until 循环

until 是 while 的反面:条件为假时继续,条件成立就停。

1
2
3
4
5
6
7
8
9
#!/bin/bash
# 倒计时
count=5
until (( count == 0 )); do
    echo "倒计时: $count"
    sleep 1
    (( count-- ))
done
echo "发射!"

它最常见的用途是等待某个服务就绪:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
# 等 MySQL 端口可用后再继续(最多等 60 秒)
port=3306
waited=0

until nc -z 127.0.0.1 "$port" 2>/dev/null; do
    if (( waited >= 60 )); then
        echo "等待超时,MySQL 仍未就绪" >&2
        exit 1
    fi
    echo "MySQL 还未启动,等待中..."
    sleep 2
    waited=$((waited + 2))
done

echo "MySQL 已就绪!"

⚠️ 等待循环一定要有超时。没有超时的 until 遇上服务永远起不来,脚本就会一直挂在那里。

53.7.5 循环控制

1
2
3
4
5
6
7
8
9
for i in {1..10}; do
    if (( i == 3 )); then
        continue      # 跳过本次,继续下一次
    fi
    if (( i == 8 )); then
        break         # 直接跳出整个循环
    fi
    echo "$i"         # 1 2 4 5 6 7
done

53.8 函数

53.8.1 定义与调用

两种写法完全等价,推荐第二种:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 形式一:function 关键字(bash 专有)
function greet {
    echo "你好!"
}

# 形式二:POSIX 风格,更通用
greet() {
    echo "你好!"
}

greet     # 调用时写函数名,不要加 ()

注意:函数必须在调用之前定义。Shell 是逐行解释执行的,不像某些语言会先扫描整个文件。

53.8.2 函数参数

Shell 函数没有形参列表,参数通过 $1、$2… 取:

1
2
3
4
5
6
7
8
9
#!/bin/bash

greet_user() {
    local name="$1"        # 用 local 声明,避免污染全局
    local age="$2"
    echo "你好,$name!你 $age 岁了。"
}

greet_user "小明" 25

几个要点:

  • local 只能在函数内用,能让变量不泄漏到函数外,强烈建议所有局部变量都加;
  • 函数里的 $1 是函数自己的参数,不会和脚本的 $1 冲突(同一个名字在函数内被遮蔽,脚本名用 $0 仍然拿得到);
  • 想转发全部参数:inner "$@";
  • 想处理不定长参数,用 for n in "$@":
1
2
3
4
5
6
7
8
9
sum() {
    local total=0
    for n in "$@"; do
        (( total += n ))
    done
    echo "$total"
}

sum 1 2 3 4       # 10

53.8.3 返回值

Shell 函数有两个"返回"通道,别混淆:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 通道一:return 返回【状态码】,只能是 0–255 的整数,0 表示成功
check_file() {
    if [[ -f "$1" ]]; then
        return 0
    else
        return 1
    fi
}

if check_file "/etc/passwd"; then
    echo "文件存在"
else
    echo "文件不存在"
fi

# 通道二:把数据打到标准输出,用命令替换接住
get_date() {
    date +%Y-%m-%d
}
today=$(get_date)
echo "今天是: $today"

常见错误:把大数字或字符串 return 出去。return 300 会被截断成 44(300 对 256 取余),return "abc" 直接报错。要返回数据就用 echo。

另外,如果函数最后一条命令失败了,函数整体也会返回非 0,容易和"显式 return"混淆。写了 set -e 之后尤其要留意函数里的中间命令。

53.8.4 函数库

把常用函数写到一个文件,用 source 加载:

1
2
3
4
5
6
7
8
9
# 文件:common.sh
#!/bin/bash

red()    { printf '\033[31m%s\033[0m\n' "$*"; }
green()  { printf '\033[32m%s\033[0m\n' "$*"; }

log() {
    printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 文件:deploy.sh
#!/bin/bash
set -euo pipefail

# 用脚本自身的位置定位库文件,这样在任何目录下执行都不会找不到
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"

green "开始部署"
log "拉取代码..."
git pull
green "部署完成"

${BASH_SOURCE[0]} 是当前文件路径,比 $0 更准(被 source 加载时 $0 是外层脚本名)。这套 SCRIPT_DIR 写法几乎是每个正经 Shell 项目的标配。

53.9 数组

53.9.1 定义与访问

bash 的数组是从 0 开始的整数索引数组(字符串索引的关联数组需要 bash 4+):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 方式一:直接赋值(元素用空格分隔)
fruits=("苹果" "香蕉" "橙子" "葡萄")

# 方式二:按下标赋值
colors[0]="红色"
colors[1]="绿色"
colors[2]="蓝色"

# 方式三:稀疏数组,中间可以空着
sparse[0]="第一"
sparse[5]="第六"

# 方式四:由通配符或命令输出生成
files=(*.txt)                 # ✅ 通配符展开,文件名带空格也安全
files=($(ls *.txt))           # ❌ 会拆词,禁止

访问:

1
2
3
4
5
6
7
8
echo "${fruits[0]}"        # 苹果
echo "${fruits[2]}"        # 橙子
echo "${fruits[-1]}"       # 葡萄(bash 4.3+ 支持负数下标,从末尾数)

echo "${fruits[@]}"        # 所有元素
echo "${#fruits[@]}"       # 元素个数:4
echo "${#fruits[0]}"       # 第一个元素的字符数:2("苹果")
echo "${!fruits[@]}"       # 所有下标:0 1 2 3

"${arr[@]}" 和 "${arr[*]}" 的区别与 "$@" / "$*" 一样:前者保留每个元素的边界,后者拼成一个字符串。遍历一律用 "${arr[@]}"。

53.9.2 切片

1
2
3
4
5
fruits=("苹果" "香蕉" "橙子" "葡萄" "西瓜")

# ${数组[@]:起始下标:个数}
echo "${fruits[@]:1:3}"    # 香蕉 橙子 葡萄(下标 1、2、3 三个元素)
echo "${fruits[@]:2}"      # 橙子 葡萄 西瓜(从下标 2 一直到末尾)

注意切片按下标位置取,不是按名称;稀疏数组用切片会跳过不存在的下标,容易出现意想不到的结果。

53.9.3 增删改

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fruits=("苹果" "香蕉")

# 追加元素
fruits+=("橙子")                       # 最常用
fruits=("${fruits[@]}" "葡萄")         # 也能用,但啰嗦

# 修改元素
fruits[0]="红苹果"

# 删除某个元素(下标 1),后面的元素不会自动前移
unset 'fruits[1]'

# 清空整个数组
unset fruits

unset 'fruits[1]' 里的引号不能省:不加引号时,如果当前目录恰好有能匹配 fruits[1] 的文件名,方括号会被通配符展开,删掉的就是别的东西。

53.9.4 关联数组

bash 4.0 起支持用字符串当键:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
declare -A person        # -A 表示 associative(关联数组),必须先声明
person["name"]="小明"
person["age"]=25
person["city"]="北京"

echo "${person["name"]}"          # 小明
echo "${!person[@]}"              # 所有键:name age city
echo "${#person[@]}"              # 键的个数:3

# 遍历键值对
for key in "${!person[@]}"; do
    echo "$key = ${person[$key]}"
done

注意:关联数组的遍历顺序是不确定的,不要依赖它的输出顺序。

53.9.5 遍历数组

1
2
3
4
5
6
7
8
9
# 遍历值
for fruit in "${fruits[@]}"; do
    echo "水果: $fruit"
done

# 遍历下标(适合需要下标或要修改元素的场景)
for i in "${!fruits[@]}"; do
    echo "下标 $i -> ${fruits[$i]}"
done

53.10 脚本调试与错误处理

53.10.1 调试选项

选项作用
bash -n script.sh只做语法检查,不执行(提交代码前跑一次,能挡掉低级错误)
bash -v script.sh把读到的每一行原样打印出来
bash -x script.sh打印每条真正执行的命令(最常用)
1
2
3
bash -n script.sh               # 检查语法
bash -x script.sh               # 追踪执行过程
bash -x script.sh 2>&1 | less   # 输出多的时候配合 less 慢慢看

输出里带 + 前缀的就是 -x 打出来的追踪行,比如:

1
2
3
+ name=小明
+ echo '你好,小明'
你好,小明

变量值异常时,-x 是最快的定位手段。

53.10.2 在脚本内部开关调试

1
2
3
4
5
6
7
8
#!/bin/bash

set -x              # 从这里开始追踪
name="小明"
echo "你好,$name"
set +x              # 关闭追踪

echo "这行不会被追踪"

想追踪得更好看,可以自定义提示符:

1
2
export PS4='+ [${BASH_SOURCE}:${LINENO}] '
set -x

这样每条追踪行都会带上文件名和行号,比光秃秃的 + 有用得多。

53.10.3 让脚本在出错时立刻停下

默认情况下,某条命令失败后 Shell 会继续往下跑,这非常危险:cd 失败后继续删文件——事故多半出在这里。

1
2
#!/bin/bash
set -euo pipefail
参数作用
set -e任何命令返回非 0 就退出脚本(少数场景例外,见下)
set -u使用未定义的变量时报错退出,避免 rm -rf "$DIR"/ 里 $DIR 为空
set -o pipefail管道中任一环节失败,整条管道就算失败(默认只看最后一个命令)

pipefail 为什么重要:

1
2
3
4
set -e
false | true       # 默认整条管道返回 0,脚本继续跑
set -o pipefail
false | true       # 整条管道返回 1,脚本会退出

set -e 的例外情况(它并不可靠,需要知道):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 出现在 if / while / until 的条件里,或 && || 的左侧时,-e 不生效
if false; then echo "不会执行"; fi     # 不会退出脚本

# 命令替换里的失败同样不受 -e 直接保护
out=$(false)

# 所以关键步骤要显式判断
if ! cp "$src" "$dst"; then
    echo "复制失败" >&2
    exit 1
fi

53.10.4 trap:退出时清理现场

脚本临时创建的文件、占用的锁、开的连接,都应该在退出时清理干净——不管是正常结束还是被 Ctrl+C 打断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
set -euo pipefail

tmp_file="$(mktemp)"
lock_dir="/tmp/myscript.lock"

cleanup() {
    rm -f "$tmp_file"
    rmdir "$lock_dir" 2>/dev/null || true
    echo "已清理临时文件"
}

trap cleanup EXIT INT TERM      # 正常退出、Ctrl+C、被 kill 都会触发

mkdir "$lock_dir"
echo "开始执行..."

trap ... EXIT 的特点是总会执行(除非脚本被 kill -9 强杀),比把清理代码写在脚本末尾可靠得多。

其他常用的信号:

1
2
trap 'echo "收到 Ctrl+C,正在退出..."' INT
trap '' INT        # 忽略 Ctrl+C(谨慎使用)

53.10.5 其他实用技巧

1
2
3
4
5
# 1. 明确打出错误信息,并输出到标准错误
echo "错误:找不到配置文件" >&2

# 2. 出错时给出出错的行号,方便定位
trap 'echo "出错于第 $LINENO 行" >&2' ERR
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 3. 查看内置命令的帮助
help set              # set 是内置命令,用 help 而不是 man
help -d set           # 一行摘要

# 4. 查看函数定义
declare -F            # 只列出所有函数名
declare -f            # 列出所有函数的完整定义
declare -f my_func    # 查看指定函数

# 5. 打开语法检查工具(强烈推荐)
shellcheck script.sh

ShellCheck 相当于 Shell 脚本的"编译器检查",能自动指出没加引号的变量、$(ls) 解析、未定义变量等问题。写完脚本跑一遍,比反复 bash -x 高效得多:

1
2
3
4
# Debian/Ubuntu
sudo apt install shellcheck
# RHEL 系列(需要 EPEL)
sudo dnf install ShellCheck

本章小结

本章我们走完了 Bash 脚本的基础路线:

知识点关键内容
Shell 与脚本Shell 是命令解释器;脚本把命令写进文件自动执行
Shebang#!/bin/bash,直接执行时才有用;不能有 BOM / CRLF
运行方式./s.sh、bash s.sh(子进程)、source s.sh(当前 Shell)
变量无类型、等号不能有空格、取值加 $ 和双引号
特殊变量"$@" 传参数、$# 个数、$? 退出码、$0 脚本名
引号单引号原样、双引号展开变量、不加引号会分词和通配
输出echo 默认不解析 \n,要转义就用 printf 或 echo -e
测试[ ](POSIX,要加引号)vs [[ ]](bash,更安全)vs (( ))(算术)
条件if / elif / else / fi,多分支用 case ... esac
循环for、while、until;读文件用 while IFS= read -r
函数参数用 $1 "$@";return 只给状态码,返回数据用 echo;局部变量加 local
数组"${arr[@]}" 遍历、${arr[@]:1:3} 切片、declare -A 关联数组
调试bash -n、bash -x、set -euo pipefail、trap ... EXIT、ShellCheck

几条能救命的原则,建议贴在显示器边上:

  1. 变量加双引号:"$var"、"${arr[@]}"、"$@"。
  2. 脚本开头写 set -euo pipefail,让错误尽早暴露。
  3. 别解析 ls 的输出,用通配符或 find -print0 | xargs -0。
  4. $? 只代表上一条命令,中间插一句 echo 就把它冲掉了。
  5. rm、mv、cd 之前先 echo 干跑一遍,尤其是带通配符的时候。

Shell 脚本是 Linux 运维的基石。这些语法看着琐碎,但掌握之后,原本要敲半小时的重复劳动,几行脚本就能搞定。

下一章预告:第五十四章进入 Bash 脚本进阶——字符串处理、正则表达式、sed、awk 这些真正的"文本处理三剑客",以及如何写出健壮、可维护的运维脚本。

最后修改 September 19, 2026: 更新 (3489033b1)