Shell 脚本编程基础
Shell 脚本编程基础:命令、变量、管道、展开与分词的时机模型、case 与数组、控制流、严格模式与工程实践
1. 从”点菜”说起:Shell 是什么
1.1 一个餐厅的类比
想象你去一家餐厅(操作系统),你想让服务员(Shell)帮你做一件事:
- 你说:“来一份红烧肉”(输入命令)
- 服务员去后厨让厨师做(调用系统程序)
- 服务员把菜端上来(返回结果)
Shell 就是操作系统的”服务员”:它读取你输入的命令,调用系统程序执行,并把结果返回给你。它既是交互式工具(你在终端里敲命令),也是脚本语言(把命令写进文件批量执行)。
多数 Linux 发行版默认 bash,macOS 自 10.15(2019)起默认 zsh;Windows 的 PowerShell 是另一套体系,但 Git Bash/WSL 可以运行 bash 脚本。
1.2 Shell 脚本的价值:做”胶水”
Shell 脚本的本质是胶水:把已有的小工具(ls、grep、awk、curl)串成自动化流程。
为什么需要 Shell 脚本?因为现实工作中有大量重复操作:
- 部署:拉代码 → 构建 → 重启服务
- 监控:检查日志 → 统计 → 告警
- 数据:下载 → 处理 → 汇总
这些操作一步步手敲会累死,写成脚本后一条命令搞定。Shell 是运维与后端开发者的基本功。
1.3 本章目标
本章是 Shell 的”第一课”,带你建立完整的基础框架。学完后你应该能:
- 理解命令、变量、管道、控制流四大基础;
- 说清”一行命令被执行前,Shell 对它做了什么”(展开与分词的时机模型)——这是所有引号类 Bug 的总根源;
- 写出安全、健壮的简单脚本;
- 知道”生产脚本必须做什么”(严格模式)。
2. 基本命令与语法
2.1 命令结构
命令名 [选项] [参数]示例:
ls -la /home/user # 列出目录详细信息
grep -rn "TODO" ./src # 递归搜索文本
curl -s https://example.com # 请求网页每条命令执行后返回退出码(exit code):0 表示成功,非 0 表示失败。$? 保存上一条命令的退出码。
ls /tmp
echo "退出码: $?" # 0 = 成功
ls /不存在的目录
echo "退出码: $?" # 2 = 失败2.2 变量
#!/bin/bash
# 变量赋值:等号两侧不能有空格
name="FANDEX"
count=42
# 使用变量:$name 或 ${name}
echo "项目名称: $name"
echo "计数: ${count}"
# 命令替换:把命令输出存入变量
files=$(ls | wc -l)
echo "文件数: $files"规则:
- 等号两侧不能有空格(
name = "alice"会被当成执行命令) - 双引号内
$var会展开,单引号内不会 $(...)是命令替换的现代写法(反引号已过时)
${var:-默认值}、${var:?报错} 这类参数扩展的完整速查见环境变量与配置文件第 5 节。
2.3 管道与重定向
# 管道:把前一个命令的输出作为后一个命令的输入
cat access.log | grep "ERROR" | sort | uniq -c | sort -rn
# 重定向:写入文件 / 追加 / 从文件读
echo "hello" > out.txt
echo "world" >> out.txt
wc -l < out.txt
# 错误流合并
command > all.log 2>&1管道是 Shell 最强大的能力:无需中间文件,数据在进程间流动。2>&1 把标准错误合并到标准输出,便于统一记录日志。三条流与管道的系统讲法见管道与重定向。
2.4 第一个脚本
#!/bin/bash
# 输出 "Hello, World!"
echo "Hello, World!"
# 保存为 hello.sh 后:
chmod +x hello.sh # 添加执行权限
./hello.sh # 执行3. 展开与分词:一行命令执行前发生了什么
这是整章最重要的心智模型。你以为 Shell “拿到一行文本就执行”,实际上在按下回车到命令启动之间,它按固定顺序做了一串文本变换:
- 变量(参数)展开:
$var替换成它的值; - 分词(word splitting):把展开结果按空白重新切成多个词;
- 路径名展开(glob):
*.log这类通配符替换成匹配的文件名列表(详见命令行基础第 5 节); - 执行。
90% 的”引号类”事故都发生在第 2 步。亲手做一个不会损坏任何文件的实验:
f="report final.pdf"
printf '[%s]\n' $f # 不加引号
printf '[%s]\n' "$f" # 加双引号[report]
[final.pdf] ← 第 1 条输出:一个变量被分词成了两个参数
[report final.pdf] ← 第 2 条输出:双引号关掉了分词,参数保持完整读法:$f 展开成 report final.pdf 后,因为不加引号,空白处被重新切词,printf 收到两个参数;加了双引号,展开后的结果被保护成一个整体。由此得到贯穿整个 Shell 生涯的一条纪律:
变量展开处永远套双引号:
"$f"。 单引号是另一回事——它连展开都关掉('$f'就是字面的$f),用在”内容不需要变化”的场合。
这条纪律的直接推论解释了大量事故:
rm $file在文件名含空格时变成rm report final.pdf两个目标——防不住的事故先于事故存在;[ $n = 1 ]在n为空时只剩[ = 1 ],直接语法错误;写成[ "$n" = 1 ]永远安全;- 循环
for x in $(ls)会把含空格的文件名拆散——按行处理文件的正确写法见 4.2。
反过来也要知道边界:引号不是万能盾牌。"$@"、数组(4.3)与 find -exec 才是”参数里有任意字符”时的完全体;而密码、Token 这类值在赋值处就该进环境变量或 .env,靠引号保护明文属于安慰剂。
4. 控制流
4.1 条件判断
#!/bin/bash
file="config.yaml"
if [ -f "$file" ]; then
echo "文件存在"
elif [ -d "$file" ]; then
echo "是目录"
else
echo "不存在"
fitest 表达式速查:
| 表达式 | 含义 |
|---|---|
-f 文件 | 文件存在且是普通文件 |
-d 目录 | 是目录 |
-z 字符串 | 字符串为空 |
-n 字符串 | 字符串非空 |
-eq / -lt / -gt | 数字相等 / 小于 / 大于 |
要点:[ ... ] 是 test 命令的语法糖,[[ ... ]] 是 bash 扩展(支持正则与更安全比较)。变量必须加引号防止空值导致语法错误(第 3 节刚解释了为什么)。
4.2 循环
#!/bin/bash
# for 循环:遍历文件
for file in *.log; do
echo "处理 $file"
done
# 数字循环
for i in $(seq 1 5); do
echo "第 $i 次"
done
# while 循环:读取文件每一行
while IFS= read -r line; do
echo "$line"
done < data.txt要点:IFS= 防止行首尾空白被吃掉,-r 防止反斜杠转义——这是读取文件行的标准姿势。glob 遍历(*.log)优于 for x in $(ls):前者在 Shell 内部展开、每个文件名天然是一个词,不受第 3 节分词之害。
4.3 case:分支选择的专用工具
多个 elif 比较同一个值时,case 更清晰,且支持模式匹配:
#!/bin/bash
read -rp "请输入操作 [backup/restore/quit]: " op
case "$op" in
backup) echo "开始备份" ;;
restore) echo "开始恢复" ;;
q|quit) echo "再见"; exit 0 ;;
*) echo "未知操作: $op"; exit 1 ;;
esac要点:
模式)开头、;;结尾,|连接多个模式(q|quit),*)兜底——等价于其他语言的 switch default;- 模式支持通配符:
*.sh)匹配所有以 .sh 结尾的值,??)匹配两个字符——这让 case 成为解析”选项/子命令”的主力(getopts 的配套分支、git 风格子命令都靠它); - 变量记得套引号:
case "$op" in——第 3 节的纪律对 case 同样适用。
4.4 数组与算术:bash 不止字符串变量
#!/bin/bash
# 数组:圆括号赋值,下标从 0 开始
targets=("web-1" "db main" "cache")
echo "共 ${#targets[@]} 个目标"
echo "第 2 个: ${targets[1]}" # db main:引用同样要套双引号
for t in "${targets[@]}"; do # "${arr[@]}" 是数组版 "$@":逐元素、不拆散空格
echo "部署 $t"
done
# 算术:$(( )) 里不需要 $ 前缀
count=3
count=$((count + 1))
echo "第 $count 次部署, 剩余 $((5 - count)) 次"要点:
- 数组解决”一个变量装一组值”:比空格拼接字符串可靠——第 3 节的分词陷阱在字符串拼接方案里无解;
"${targets[@]}"的双引号是数组版的"$@"纪律(180 篇有"$@"与$*的完整对照);$(( ))是整数算术,括号内变量不用$;需要小数时用awk(210 篇)。
4.5 函数(预告)
#!/bin/bash
# 函数定义
log() {
local level="$1"
shift
echo "[$level] $*"
}
# 调用
log INFO "构建开始"
log ERROR "构建失败"要点:$1、$2 是位置参数,$* 是所有参数;local 声明局部变量,避免污染全局。函数的返回值、"$@"、getopts 参数解析是函数与参数处理的完整专题。
5. 严格模式与错误处理
5.1 为什么需要严格模式
Shell 脚本默认”宽容”:命令失败不报错、未定义变量当空值、管道只看最后一段结果。这种宽容在生产环境是灾难——脚本会带着错误状态继续执行,产生半成品数据。
5.2 set -euo pipefail
生产脚本必须在开头启用严格模式:
#!/bin/bash
set -euo pipefail| 选项 | 行为 |
|---|---|
set -e | 任何命令失败立即退出脚本(避免”失败后继续执行”的连锁错误) |
set -u | 使用未定义变量即报错(捕获拼写错误) |
set -o pipefail | 管道中任意命令失败,整体视为失败(默认只看最后一个命令) |
5.3 trap 清理
#!/bin/bash
set -euo pipefail
cleanup() {
echo "清理临时文件..."
rm -f /tmp/tmp.$$
}
trap cleanup EXITtrap ... EXIT 保证脚本无论正常结束还是出错退出都会执行清理函数——这是”无论成败都要清理”的标准姿势。
set -e 的例外场景、trap 事件表与 shellcheck 静态检查是脚本调试与严格模式的完整专题。
6. 常用文本处理(概览)
6.1 grep:行匹配
grep -i "error" log.txt # 忽略大小写
grep -v "^#" config.conf # 排除注释行
grep -E "err|warn" log.txt # 扩展正则,匹配多个词6.2 sed:流编辑
sed -i 's/old/new/g' file.txt # 全局替换并写回
sed -n '10,20p' file.txt # 打印第 10-20 行6.3 awk:列处理与统计
awk '{print $1, $3}' data.txt # 打印第 1 列和第 3 列
awk 'END {print NR}' file.txt # 统计行数
awk '{sum += $2} END {print sum}' data.txt # 按第 2 列求和详细用法见本模块《文本处理三剑客》。
7. 工程实践:部署脚本模板
把以上知识组合成第一个”生产级”脚本:
#!/bin/bash
set -euo pipefail
APP_DIR="/opt/myapp"
VERSION="${1:?请提供版本号}"
echo "开始部署 v${VERSION}"
# 1. 拉取代码
git -C "$APP_DIR" fetch --tags
git -C "$APP_DIR" checkout "v${VERSION}"
# 2. 构建
(cd "$APP_DIR" && pnpm install --frozen-lockfile && pnpm build)
# 3. 重启服务(systemd 示例)
systemctl restart myapp
systemctl --no-pager status myapp
echo "部署完成"要点:
${1:?...}在缺少参数时直接报错退出(cd ... && ...)在子 shell 中切换目录,不影响当前脚本- 每步失败立即退出(set -e),不留半成品状态
8. 常见陷阱
陷阱一:忘记引号。 rm $file 在文件名含空格时被拆成多个参数。所有变量加双引号。根源是第 3 节的分词步骤——理解了时机,这条就不用背了。
陷阱二:不用严格模式。 命令失败继续执行,产生半成品状态。生产脚本必须 set -euo pipefail。
陷阱三:rm -rf 误删。 删除前先 echo 预览,使用 set -u 防止空变量导致 rm -rf /。
陷阱四:脚本不可移植。 避免 GNU 专属选项,或用 bash 明确 shebang。
陷阱五:忽略退出码。 command || { echo "失败"; exit 1; } 显式处理。
陷阱六:for x in $(ls) 处理文件。 受分词与 glob 双重影响,含空格文件名必炸;遍历文件用 glob(*.log)或 find ... -exec。
9. 动手实践:遮代码自检
先只读任务与提示,自己写完再看参考实现。
任务:写一个 backup.sh,满足——
- 用法:
./backup.sh <源目录> <备份根目录>,缺参数时报错退出(退出码 1); - 备份到
<备份根目录>/backup-<时间戳>/,时间戳格式20261005-1530; - 源目录不存在时报错退出;备份完成后打印”备份了 N 个文件”;
- 开头带严格模式;所有变量展开处都按第 3 节的纪律处理。
提示:时间戳用 date "+%Y%m%d-%H%M"(date "+..." 的格式串建议套单引号或双引号——想想为什么 % 不需要、但整个串加引号更稳);“N 个文件”用 find "$src" -type f | wc -l;第 4 条就是让 "$src" 这类展开全部带引号。
参考实现(先自己写完再看):
#!/bin/bash
set -euo pipefail
src="${1:?用法: $0 <源目录> <备份根目录>}"
dest_root="${2:?用法: $0 <源目录> <备份根目录>}"
if [ ! -d "$src" ]; then
echo "错误: 源目录不存在: $src" >&2
exit 1
fi
stamp=$(date "+%Y%m%d-%H%M")
dest="$dest_root/backup-$stamp"
mkdir -p "$dest"
cp -r "$src"/. "$dest"/ # /. 复制隐藏文件也带上
count=$(find "$src" -type f | wc -l)
echo "备份完成: $dest(备份了 $count 个文件)"要点自查:
${1:?...}一次性满足第 1 条(缺参数时报错并退出);- 错误消息写到
>&2(标准错误):这是”日志走 stderr、数据走 stdout”的纪律,180 篇讲输出捕获时会看到为什么; - 全部展开处有双引号:
"$src"/.在路径含空格时依然正确; find | wc -l在文件名含换行时会数错——极端场景的正确解是find ... -printf '.' | wc -c,知道边界即可,日常路径不必过度设计。
任务二(口头自检):说出下面两段的行为差异,并解释各发生在第 3 节流程的哪一步:
v="*.sh"
echo $v # A
echo "$v" # B答案:A 展开成 *.sh 后又被路径名展开(第 3 步)替换成当前目录所有 .sh 文件名;B 的双引号同时关掉分词与 glob,输出字面的 *.sh。引号保护的从来不只是空格。