Linux 基础 19:Shell 脚本(上)——引号、条件与退出码

发布于 2022-04-27 20:30 更新于 2022-04-27 22:10 1570 字 8 min read ... 访问量 手写 每个字都是我自己敲的。

Linux 基础 25:收尾——自查清单与继续深入的方向Linux 基础 24:启动流程与救援——UEFI、GRUB、initramfs 与 emergency modeLinux 基础 23:容器与内核边界——namespace、cgroup 与 docker exec 背后Linux 基础 22:日志与故障排查方法论——从现象到根因的六步Linux 基础 21:定时任务——crontab 的坑与 systemd timerLinux 基础 20:Shell 脚本(下)——trap、日志、幂等与并发锁Linux 基础 19:Shell 脚本(上)——引号、条件与退出码Linux 基础 18:性能观测与 strace——机器到底卡在哪Linux 基础 17:防火墙——iptables、nftables、firewalld 与 ufw 的关系Linux 基础 16:SELinux——当权限都对,服务还是被拒绝Linux 基础 15:权限进阶——sudoers 的通配陷阱、ACL 与 capabilitiesLinux 基础 14:SSH 与远程运维——密钥、权限与三种隧道Linux 基础 13:网络基础——自下而上的排错顺序Linux 基础 12:磁盘与文件系统——从块设备到挂载点Linux 基础 11:systemd——unit、systemctl 与 journalctl 的正确用法Linux 基础 10:包管理——apt 到底做了什么,以及四个高频报错Linux 基础 09:用户与组——passwd、shadow、sudoers 三件套Linux 基础 08:环境变量与 shell 启动文件——为什么 ssh 跑命令找不到你的 PATHLinux 基础 07:进程与信号——TERM 和 KILL 到底差在哪Linux 基础 06:管道与重定向——三条流、一个顺序问题Linux 基础 05:文本处理——把 grep/sort/uniq 变成你的分析工具Linux 基础 04:权限与属主——rwx 在文件和目录上不是一回事Linux 基础 03:ls、cp、mv、rm 的真实语义,以及那个由 shell 展开的 *Linux 基础 02:/ 底下为什么是这些目录,inode 里到底存了什么Linux 基础 01:终端、Shell,和命令是怎么被找到的
shebang 与两种执行方式的差别、"$VAR" 与 $VAR 以及 "$@" 与 $* 的引号陷阱、[ ] 与 [[ ]] 的取舍、while read -r 为什么必须带 -r、参数与 getopts 最小示例、set -euo pipefail 各自解决什么,以及退出码约定与 shellcheck。

这一节要解决什么

把一串能在终端里跑通的命令粘进脚本,往往立刻翻车:文件名里有空格,被拆成两个参数;路径里的变量没加引号,rm -rf $DIR/ 在 DIR 为空时差点把根目录带走;脚本明明"成功"了,&& 后面却接着执行了。

这一节讲清 shell 最容易咬人的三样东西:引号、条件、退出码。这三样没搞懂,脚本就是定时炸弹。

一、shebang 与两种执行方式

#!/usr/bin/env bash      # 用 PATH 里的 bash,可移植性最好
#!/bin/bash              # 写死路径
#!/bin/sh                # POSIX sh,功能受限,见后文
bash script.sh           # 把文件当输入交给 bash 解释,不需要 x 位
chmod +x script.sh       # 加上可执行位
./script.sh              # 靠 shebang 决定用哪个解释器

关键结论:bash script.sh 忽略 shebang,你写 #!/usr/bin/env bash 也没用;只有 ./script.sh 才读 shebang。调试期常用前者,交付时用后者。

二、引号:这是最贵的坑

f="my file.txt"
cat $f              # 被拆成两个参数 my 和 file.txt,报错
cat "$f"            # ✅ 一个整体
echo $f             # 词分割加通配符展开,空格被吃掉
echo "$f"           # ✅ 原样输出

接收参数的两个特殊变量必须分清:

echo "共 $# 个参数"
printf '[%s]\n' "$@"     # ✅ 每个参数独立成一项,推荐
printf '[%s]\n' $*       # ❌ 先词分割再拼接,等于什么都没保住
写法含义
"$@"每个参数是一个独立单词,循环里首选
"$*"所有参数被拼成一个字符串
未加引号的 $*先词分割再拼接,最糟的组合

批注:写脚本的默认动作是给所有变量加双引号,除了明确需要词分割或通配的场景。这一条能挡掉一半以上的诡异 bug。

$DIR 为空时,rm -rf $DIR/ 会展开成 rm -rf /。删除类命令永远先 echo 一遍确认展开结果,或者写 rm -rf "${DIR:?DIR is unset}",让变量为空时直接报错退出。

三、条件:[ ] 还是 [[ ]]

[ -f "$path" ] && echo exists       # POSIX test,变量必须加引号
[[ -f $path ]] && echo exists       # bash 关键字,不加引号也安全
[[ $name == *.log ]]                # ✅ 支持通配模式匹配
[[ $s =~ ^[0-9]+$ ]]                # ✅ 支持正则匹配
[[ $a -lt 10 && $b -gt 5 ]]         # 逻辑组合不用 -a/-o
检查含义
-z "$s"字符串为空
-n "$s"字符串非空
-e / -f / -d存在 / 是普通文件 / 是目录
-x可执行
-r / -w可读 / 可写

关键结论:脚本头用 #!/usr/bin/env bash 时优先 [[ ]]——它不会因变量未加引号而被词分割,还支持 ==、=~ 模式匹配。只有必须 POSIX 时才退回 [ ],那时引号一个都不能省。

四、循环

for f in *.log; do echo "$f"; done

while IFS= read -r line; do        # ✅ -r 阻止反斜杠被解释
  echo "$line"
done < input.txt

为什么不用 for line in $(cat file):命令替换会做词分割,空格和空行全被吃掉,一行被拆成好几行;while read -r 按行读,原样保留。-r 是防止行尾的 \ 被当转义符,几乎永远该加。

五、参数与 getopts

echo "$0"             # 脚本名
echo "$1"; echo "$2"  # 第一、第二个参数
echo "${@:2}"         # 从第二个开始的所有参数

while getopts "vho:" opt; do
  case $opt in
    v) set -x ;;
    o) out=$OPTARG ;;
    h) echo "usage: $0 [-v] [-o out]"; exit 0 ;;
    *) exit 2 ;;
  esac
done
shift $((OPTIND-1))   # 移走已解析的选项,剩下的才是位置参数

六、set -euo pipefail 各自解决什么

set -euo pipefail
set -x               # 调试时打开:打印每条实际执行的命令
选项作用
-e命令失败(非零退出)立刻退出脚本
-u使用未定义变量时报错退出
-o pipefail管道中任一环失败,整个管道就算失败
-x打印每条展开后的命令,用于调试

批注(-e 的坑):-e 在 if、&&、||、while 的条件位置不生效——那里失败是预期行为。所以"允许失败"要写成 cmd || true 或 if ! cmd; then,别指望 -e 帮你兜住这些。

七、退出码与 shellcheck

true; echo $?        # 0 = 成功
false; echo $?       # 非 0 = 失败
exit 1               # 脚本显式以失败结束

关键结论:0 是成功,非 0 全是失败;$? 永远是上一条命令的结果。写完脚本跑一遍 shellcheck script.sh——它会揪出没加引号的变量、误用的 [ ]、无用调用等,这是最便宜的代码审查。

八、可移植性

#!/bin/sh 是 POSIX,bash 特性一律不可用:[[ ]]、数组、function 关键字、${@:2}、进程替换 <(...) 都不存在。要跨发行版(尤其 Alpine 的 busybox sh)就老实写 POSIX,或者显式声明依赖 bash。

九、动手清单

bash -n script.sh                                  # 1. 只做语法检查,不执行
shellcheck script.sh                               # 2. 静态审查
bash -x script.sh 2>&1 | head                      # 3. 跟踪执行
printf '[%s]\n' "$@" | head                        # 4. 验证参数展开
[[ -d /tmp && -w /tmp ]] && echo ok                # 5. 条件测试
while IFS= read -r l; do echo "$l"; done < /etc/hosts | head   # 6. 逐行读文件
bash -c 'set -euo pipefail; false; echo never'     # 7. 验证 -e 会提前退出

小结

  • bash script.sh 忽略 shebang、不需要 x 位;只有 ./script.sh 才读 shebang。
  • 默认给变量加双引号;"$@" 保留每个参数,"$*" 会把它们拼成一串。
  • bash 脚本优先 [[ ]](免引号加模式匹配),POSIX 才用 [ ](引号一个不能省)。
  • 按行读文件用 while IFS= read -r line,不要 for x in $(cat file)。
  • set -euo pipefail 分别管"失败即退 / 未定义变量 / 管道失败",但 -e 在条件位置不生效。
  • 退出码 0 成功、非 0 失败;shellcheck 是所有脚本的必跑项。

下一节预告

下一节:Shell 脚本(下)——函数与 local、trap 清理现场、统一日志函数、幂等写法、flock 防重复运行,并给一个完整可运行的备份脚本逐句拆解。

执笔 手写

喜欢的话,留下你的评论吧~

加载中...