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
- 会用文本编辑器(
vim、nano均可) - 了解基本命令行操作(
cd、ls、cat等) - 部分章节需要 sudo 权限(安装 ShellCheck、读取 systemd 状态)
新建一个文件 hello.sh,写入以下内容:
#!/usr/bin/env bashecho "你好,$USER!"echo "当前目录是 $(pwd)"第一行 #!/usr/bin/env bash 叫 shebang,告诉系统用哪个解释器运行这个文件。相比写死的 #!/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 个参数 |
$# | 参数个数 |
$@ | 所有参数(加引号 "$@" 时每个参数独立保留) |
$? | 上一条命令的退出码 |
#!/usr/bin/env bashecho "脚本名:$0"echo "第一个参数:$1"echo "参数个数:$#"echo "全部参数:$@"$ ./args.sh alma rockyBash 用 if/elif/else/fi 做分支,测试条件推荐用 [[ ... ]](Bash 内建,比老式 [ ... ] 更安全、支持更多语法):
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" ]] | 路径存在 |
&& 与 ||
Section titled “&& 与 ||”用 &&(前一条成功才执行后一条)和 ||(前一条失败才执行后一条)做简短判断:
mkdir -p /opt/app && echo "目录已就绪"ping -c1 example.com || echo "网络不通"case 多分支
Section titled “case 多分支”匹配固定取值时,case 比一长串 elif 更清晰:
case "$1" in start) echo "启动" ;; stop) echo "停止" ;; restart) echo "重启" ;; *) echo "用法:$0 {start|stop|restart}" ;;esac每个分支以 ;; 结尾,*) 是兜底分支,整体以 esac(case 倒写)结束。
for 循环
Section titled “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"donewhile 与 until
Section titled “while 与 until”while 在条件为真时循环,until 则相反——在条件为假时循环,直到条件成立:
count=1while [[ $count -le 3 ]]; do echo "while: $count" count=$(( count + 1 ))done
n=1until [[ $n -gt 3 ]]; do echo "until: $n" n=$(( n + 1 ))done用 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 4echo "退出码:$?"每条命令执行后都有一个退出码:0 表示成功,非零表示失败。用 $? 读取上一条命令的退出码,用 exit N 让脚本以指定码退出:
if ! command -v git &>/dev/null; then echo "未安装 git" >&2 exit 1fiexit 0退出码是脚本之间、脚本与 systemd / CI 之间沟通成败的标准方式,务必让脚本在失败时返回非零。
健壮性:set -euo pipefail
Section titled “健壮性:set -euo pipefail”几乎所有「正经」脚本都会在开头加上这一行:
#!/usr/bin/env bashset -euo pipefail逐项解释:
set -e:任何命令失败(返回非零)就立即终止脚本,避免错误被忽略后继续往下跑。set -u:引用未定义的变量时报错退出,能及早发现拼写错误(如把$name写成$naem)。set -o pipefail:管道中只要有一环失败,整条管道就算失败。默认情况下管道退出码只看最后一条命令,容易漏掉前面的错误。
三者合起来让脚本「快速失败」,而不是带着错误状态硬撑。
读取输入、算术与数组
Section titled “读取输入、算术与数组”读取用户输入
Section titled “读取用户输入”read -r -p "请输入主机名:" hostnameecho "你输入的是:$hostname"read -r 的 -r 表示不把反斜杠当转义符,应当总是带上,否则路径里的 \ 会被吃掉。
整数运算用 $(( )):
a=10b=3echo "和:$(( a + b ))"echo "商:$(( a / b ))" # 整数除法,结果为 3echo "余:$(( a % b ))"distros=(alma rocky centos)echo "第一个:${distros[0]}"echo "全部:${distros[@]}"echo "数量:${#distros[@]}"
distros+=(fedora) # 追加元素for d in "${distros[@]}"; do echo "- $d"donehere-document
Section titled “here-document”用 <<EOF 把多行文本直接喂给命令,常用于生成配置文件:
cat > /tmp/info.txt <<EOF主机名:$(hostname)日期:$(date +%F)EOFEOF 只是约定俗成的结束标记,可换成任意词。若不希望展开其中的变量,把开头写成 <<'EOF'(给标记加单引号)。
重定向与管道
Section titled “重定向与管道”| 写法 | 作用 |
|---|---|
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/null2>&1 中的 2 是标准错误、1 是标准输出,含义是「把标准错误指向标准输出当前所在的地方」,所以它要写在 > file 之后才有效。
让脚本逐行打印实际执行的命令,是排错最快的办法:
$ bash -x ./myscript.sh也可以在脚本内部只对某一段开启调试:
set -x # 开始打印执行轨迹risky_functionset +x # 关闭set -x 会把每条展开后的命令前缀以 + 打印出来,能直观看到变量到底取到了什么值。
用 ShellCheck 静态检查
Section titled “用 ShellCheck 静态检查”ShellCheck 是脚本界的「拼写检查器」,能在运行前发现引号缺失、未定义变量、可疑写法等问题。它在 EPEL 源中提供:
$ sudo dnf install epel-release$ sudo dnf install ShellCheck$ shellcheck myscript.shShellCheck 会指出问题并附上编号(如 SC2086),照着建议改即可。养成「写完先 shellcheck 一遍」的习惯,能避开本页提到的绝大多数坑。
实战一:带时间戳的目录备份脚本
Section titled “实战一:带时间戳的目录备份脚本”把指定目录打包成带日期的 tar.gz,存到备份目录:
#!/usr/bin/env bash## backup-dir.sh —— 将指定目录打包备份,文件名带时间戳# 用法:./backup-dir.sh <源目录> [备份目录]#set -euo pipefail
# 第一个参数是源目录;第二个可选,默认为 /var/backupssrc="${1:-}"dest="${2:-/var/backups}"
# 参数检查if [[ -z "$src" ]]; then echo "用法:$0 <源目录> [备份目录]" >&2 exit 1fi
if [[ ! -d "$src" ]]; then echo "错误:源目录不存在:$src" >&2 exit 1fi
# 确保备份目录存在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实战二:服务健康检查脚本
Section titled “实战二:服务健康检查脚本”检查一组 systemd 服务是否处于 active 状态,任意一个异常就以非零退出码结束——这样它能直接被监控系统或 CI 识别为失败:
#!/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 )) fidone
if [[ "$failed" -eq 0 ]]; then echo "全部服务正常。" exit 0else 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 没有按预期退出
Section titled “set -e 没有按预期退出”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 换行的习惯。
- 定时任务(Timer) —— 让脚本按计划自动运行
- 命令速查表 —— 常用命令快速查询
- 文件权限 —— 深入理解
chmod与可执行位