跳转到内容

Shell 脚本入门

适用于所有 Enterprise Linux 发行版(Bash 4+/5)

在命令行里敲了上百次的命令,能不能一次写好、反复运行?这就是 Shell 脚本的价值。它把一串命令、判断和循环组织成一个文件,让备份、检查、部署这类重复工作自动完成。本页带你从 shebang 写起,到能写出健壮、可调试的实用脚本。

Enterprise Linux(CentOS Stream、AlmaLinux、Rocky Linux 等)默认的交互 Shell 就是 Bash,所以本页全程使用 Bash 语法。

  • 怎么创建、赋予执行权限并运行一个脚本
  • 变量、引号、命令替换与位置参数
  • if / case 条件判断和 for / while / until 循环
  • 函数、退出码与 set -euo pipefail 健壮性写法
  • 读取输入、算术、数组与 here-document
  • bash -x 调试、用 ShellCheck 静态检查
  • 两个可直接运行的实战脚本:目录备份与服务健康检查
  • 一台运行 EL 的系统,或任意带 Bash 4+/5 的 Linux
  • 会用文本编辑器(vimnano 均可)
  • 了解基本命令行操作(cdlscat 等)
  • 部分章节需要 sudo 权限(安装 ShellCheck、读取 systemd 状态)

新建一个文件 hello.sh,写入以下内容:

hello.sh
#!/usr/bin/env bash
echo "你好,$USER"
echo "当前目录是 $(pwd)"

第一行 #!/usr/bin/env bashshebang,告诉系统用哪个解释器运行这个文件。相比写死的 #!/bin/bash/usr/bin/env bash 会在 PATH 中查找 bash,可移植性更好。

接着给脚本加上执行权限,然后运行:

赋予执行权限并运行
$ chmod +x hello.sh
$ ./hello.sh

注意 ./ 不能省略。当前目录通常不在 PATH 里,直接敲 hello.sh 会提示找不到命令。

变量赋值时等号两边不能有空格,引用时用 $ 前缀:

变量基础
name="centos"
greeting="Hello, ${name}" # 推荐写法:用 ${} 明确边界
echo "$greeting"

引号的规则是脚本最容易踩坑的地方:

  • 双引号 "...":会展开变量,但能防止「单词拆分」和通配符展开。绝大多数情况都用双引号。
  • 单引号 '...':原样输出,不展开任何变量。
  • 不加引号:变量里的空格会把一个值拆成多个,* 会被当成通配符。
为什么要加引号
file="my report.txt"
rm "$file" # 正确:删除名为 "my report.txt" 的一个文件
# rm $file # 错误:会尝试删除 "my" 和 "report.txt" 两个文件

习惯写 "${var}":双引号保证安全,花括号让变量名边界清晰(例如 "${name}_backup")。

$(...) 把一条命令的输出赋给变量。它可以嵌套,比老式的反引号 `...` 更清晰,应优先使用:

命令替换
today=$(date +%Y-%m-%d)
count=$(ls -1 | wc -l)
echo "今天是 ${today},当前目录有 ${count} 个条目"

脚本接收的命令行参数通过位置变量获取:

变量含义
$0脚本自身的名字
$1 $2第 1、第 2 个参数
$#参数个数
$@所有参数(加引号 "$@" 时每个参数独立保留)
$?上一条命令的退出码
args.sh
#!/usr/bin/env bash
echo "脚本名:$0"
echo "第一个参数:$1"
echo "参数个数:$#"
echo "全部参数:$@"
带参数运行
$ ./args.sh alma rocky

Bash 用 if/elif/else/fi 做分支,测试条件推荐用 [[ ... ]](Bash 内建,比老式 [ ... ] 更安全、支持更多语法):

if 条件判断
if [[ "$1" == "start" ]]; then
echo "启动中…"
elif [[ "$1" == "stop" ]]; then
echo "停止中…"
else
echo "用法:$0 {start|stop}"
fi

常用测试运算符:

类别写法含义
字符串[[ "$a" == "$b" ]]相等
字符串[[ -z "$a" ]] / [[ -n "$a" ]]为空 / 非空
数值[[ "$a" -eq "$b" ]]等于
数值[[ "$a" -lt "$b" ]] / -gt / -le / -ge小于 / 大于 等
文件[[ -f "$p" ]]是普通文件
文件[[ -d "$p" ]]是目录
文件[[ -e "$p" ]]路径存在

&&(前一条成功才执行后一条)和 ||(前一条失败才执行后一条)做简短判断:

逻辑短路
mkdir -p /opt/app && echo "目录已就绪"
ping -c1 example.com || echo "网络不通"

匹配固定取值时,case 比一长串 elif 更清晰:

case 分支
case "$1" in
start) echo "启动" ;;
stop) echo "停止" ;;
restart) echo "重启" ;;
*) echo "用法:$0 {start|stop|restart}" ;;
esac

每个分支以 ;; 结尾,*) 是兜底分支,整体以 esac(case 倒写)结束。

for 循环的几种形式
# 遍历列表
for distro in alma rocky centos; do
echo "发行版:$distro"
done
# 数字范围
for i in {1..5}; do
echo "$i"
done
# 遍历文件(注意加引号,应对文件名含空格)
for f in /etc/*.conf; do
echo "配置文件:$f"
done

while 在条件为真时循环,until 则相反——在条件为假时循环,直到条件成立:

while 与 until
count=1
while [[ $count -le 3 ]]; do
echo "while: $count"
count=$(( count + 1 ))
done
n=1
until [[ $n -gt 3 ]]; do
echo "until: $n"
n=$(( n + 1 ))
done

break 提前跳出循环,用 continue 跳过本次进入下一轮:

break 与 continue
for i in {1..10}; do
[[ $i -eq 3 ]] && continue # 跳过 3
[[ $i -eq 6 ]] && break # 到 6 就停
echo "$i"
done

函数把重复逻辑封装起来,参数同样用 $1$2 获取:

函数定义与传参
log() {
echo "[$(date +%T)] $1"
}
add() {
local sum=$(( $1 + $2 )) # local 让变量只在函数内可见
return 0 # return 返回退出码(0~255),不是返回值
}
log "开始计算"
add 3 4
echo "退出码:$?"

每条命令执行后都有一个退出码:0 表示成功,非零表示失败。用 $? 读取上一条命令的退出码,用 exit N 让脚本以指定码退出:

退出码
if ! command -v git &>/dev/null; then
echo "未安装 git" >&2
exit 1
fi
exit 0

退出码是脚本之间、脚本与 systemd / CI 之间沟通成败的标准方式,务必让脚本在失败时返回非零。

几乎所有「正经」脚本都会在开头加上这一行:

健壮脚本的开头
#!/usr/bin/env bash
set -euo pipefail

逐项解释:

  • set -e:任何命令失败(返回非零)就立即终止脚本,避免错误被忽略后继续往下跑。
  • set -u:引用未定义的变量时报错退出,能及早发现拼写错误(如把 $name 写成 $naem)。
  • set -o pipefail:管道中只要有一环失败,整条管道就算失败。默认情况下管道退出码只看最后一条命令,容易漏掉前面的错误。

三者合起来让脚本「快速失败」,而不是带着错误状态硬撑。

读取输入
read -r -p "请输入主机名:" hostname
echo "你输入的是:$hostname"

read -r-r 表示不把反斜杠当转义符,应当总是带上,否则路径里的 \ 会被吃掉。

整数运算用 $(( ))

算术运算
a=10
b=3
echo "和:$(( a + b ))"
echo "商:$(( a / b ))" # 整数除法,结果为 3
echo "余:$(( a % b ))"
数组
distros=(alma rocky centos)
echo "第一个:${distros[0]}"
echo "全部:${distros[@]}"
echo "数量:${#distros[@]}"
distros+=(fedora) # 追加元素
for d in "${distros[@]}"; do
echo "- $d"
done

<<EOF 把多行文本直接喂给命令,常用于生成配置文件:

here-document
cat > /tmp/info.txt <<EOF
主机名:$(hostname)
日期:$(date +%F)
EOF

EOF 只是约定俗成的结束标记,可换成任意词。若不希望展开其中的变量,把开头写成 <<'EOF'(给标记加单引号)。

写法作用
cmd > file标准输出覆盖写入 file
cmd >> file标准输出追加到 file
cmd 2> file标准错误写入 file
cmd > file 2>&1标准输出和标准错误都写入 file
cmd1 | cmd2把 cmd1 的输出作为 cmd2 的输入
重定向与管道示例
# 把正常输出和错误都记进日志
./deploy.sh > deploy.log 2>&1
# 统计当前登录用户数
who | wc -l
# 只看错误,正常输出丢弃
./check.sh 2> errors.log > /dev/null

2>&1 中的 2 是标准错误、1 是标准输出,含义是「把标准错误指向标准输出当前所在的地方」,所以它要写在 > file 之后才有效。

让脚本逐行打印实际执行的命令,是排错最快的办法:

开启调试运行
$ bash -x ./myscript.sh

也可以在脚本内部只对某一段开启调试:

局部调试
set -x # 开始打印执行轨迹
risky_function
set +x # 关闭

set -x 会把每条展开后的命令前缀以 + 打印出来,能直观看到变量到底取到了什么值。

ShellCheck 是脚本界的「拼写检查器」,能在运行前发现引号缺失、未定义变量、可疑写法等问题。它在 EPEL 源中提供:

安装 ShellCheck(来自 EPEL)
$ sudo dnf install epel-release
$ sudo dnf install ShellCheck
检查脚本
$ shellcheck myscript.sh

ShellCheck 会指出问题并附上编号(如 SC2086),照着建议改即可。养成「写完先 shellcheck 一遍」的习惯,能避开本页提到的绝大多数坑。

实战一:带时间戳的目录备份脚本

Section titled “实战一:带时间戳的目录备份脚本”

把指定目录打包成带日期的 tar.gz,存到备份目录:

/opt/scripts/backup-dir.sh
#!/usr/bin/env bash
#
# backup-dir.sh —— 将指定目录打包备份,文件名带时间戳
# 用法:./backup-dir.sh <源目录> [备份目录]
#
set -euo pipefail
# 第一个参数是源目录;第二个可选,默认为 /var/backups
src="${1:-}"
dest="${2:-/var/backups}"
# 参数检查
if [[ -z "$src" ]]; then
echo "用法:$0 <源目录> [备份目录]" >&2
exit 1
fi
if [[ ! -d "$src" ]]; then
echo "错误:源目录不存在:$src" >&2
exit 1
fi
# 确保备份目录存在
mkdir -p "$dest"
# 用源目录名 + 时间戳组成归档文件名
base=$(basename "$src")
timestamp=$(date +%Y%m%d-%H%M%S)
archive="${dest}/${base}-${timestamp}.tar.gz"
echo "正在备份 ${src} -> ${archive}"
# -C 切换到父目录,避免归档里出现绝对路径
tar -czf "$archive" -C "$(dirname "$src")" "$base"
echo "完成,大小:$(du -h "$archive" | cut -f1)"

运行示例:

运行备份脚本
$ chmod +x /opt/scripts/backup-dir.sh
$ ./backup-dir.sh /etc /var/backups

检查一组 systemd 服务是否处于 active 状态,任意一个异常就以非零退出码结束——这样它能直接被监控系统或 CI 识别为失败:

/opt/scripts/health-check.sh
#!/usr/bin/env bash
#
# health-check.sh —— 检查关键服务是否正常运行
# 退出码:0 = 全部正常;非 0 = 有服务异常(数字为异常服务数)
#
set -euo pipefail
# 要检查的服务列表,按需修改
services=(sshd chronyd firewalld)
failed=0
for svc in "${services[@]}"; do
# is-active 在服务运行时返回 active 且退出码 0
if systemctl is-active --quiet "$svc"; then
echo "[OK] $svc 正在运行"
else
echo "[FAIL] $svc 未运行" >&2
failed=$(( failed + 1 ))
fi
done
if [[ "$failed" -eq 0 ]]; then
echo "全部服务正常。"
exit 0
else
echo "有 ${failed} 个服务异常。" >&2
exit "$failed"
fi

运行示例:

运行健康检查
$ chmod +x /opt/scripts/health-check.sh
$ ./health-check.sh
$ echo "退出码:$?"

systemctl is-active --quiet 在服务运行时退出码为 0,否则非 0,正好用作 if 的判断条件。脚本最后用异常服务数作为退出码,方便外部程序判断严重程度。

报错 [: command not found[[: command not found

Section titled “报错 [: command not found 或 [[: command not found”

方括号 [ 其实是一条命令,前后必须有空格。写成 if [$x -eq 1] 会被当成命令 [$x 而报错。正确写法是 if [[ "$x" -eq 1 ]],注意 [[、变量、运算符之间都留空格。

变量没加引号导致空格或通配符出错

Section titled “变量没加引号导致空格或通配符出错”

未加引号的变量会发生「单词拆分」和通配符展开。文件名含空格时 cp $file /backup 会出错,$dir/* 在某些情况下会被意外展开。几乎所有变量引用都应写成 "$var" 用 ShellCheck 能自动揪出这类问题(SC2086)。

set -e 不会对所有失败都退出。处在 if 条件、&&|| 左侧,或函数中被调用的命令,其失败可能不触发退出。如果你有意让某条命令允许失败,明确写 cmd || true;反过来,如果发现脚本「带病继续运行」,检查它是不是恰好落在这些豁免位置。

bad interpreter: No such file or directory(CRLF 换行)

Section titled “bad interpreter: No such file or directory(CRLF 换行)”

在 Windows 上编辑脚本、或从某些编辑器复制时,行尾可能是 CRLF(\r\n)。shebang 行末尾多出的 \r 会让系统去找不存在的解释器 /usr/bin/env bash\r,于是报 bad interpreter。修复方法:

转换换行符
$ sudo dnf install dos2unix
$ dos2unix myscript.sh

或用 sed -i 's/\r$//' myscript.sh。养成在 Linux 上编辑脚本、并配置编辑器使用 LF 换行的习惯。