阶段六 · Shell 脚本基础

函数,参数与错误处理

一句话总结

脚本从"能跑"到"靠谱",差的就是这一篇:用函数把逻辑拆成可复用的块,用位置参数接收外部输入,用 set -euo pipefail 让脚本一出错就停,用 trap 保证退出时清理现场.
这些是把玩具脚本变成生产脚本的工程化手段.

函数:把逻辑打包复用

当一段逻辑在脚本里要用好几次,或者脚本长到需要拆分时,就该用函数.Shell 函数定义很简洁:

#!/usr/bin/env bash

# 定义函数(两种等价写法,推荐第一种)
greet() {
echo "Hello, $1!" # $1 是传给函数的第一个参数
}

log_info() {
echo "[INFO] $*" # $* 是所有参数
}

# 调用:像命令一样,函数名后空格跟参数,不要括号
greet "alice" # Hello, alice!
log_info "服务启动中" # [INFO] 服务启动中

调用函数不用括号

定义时写 greet(),但调用时不写括号:直接 greet "alice",参数像命令行参数一样空格分隔.写成 greet("alice") 是其他语言的习惯,在 Shell 里是错的.

函数的参数与返回

函数内部用 $1 $2 ... 取参数--和脚本接收命令行参数用的是同一套位置参数机制,只是作用域变成了函数内部.

add() {
local sum=$(( $1 + $2 )) # local 声明局部变量,只在函数内有效
echo "$sum" # 用 echo "返回"结果,由调用方捕获
}

result=$(add 3 5) # 用命令替换接住输出
echo "结果是 $result" # 结果是 8

return 返回的是退出码,不是数据

Shell 函数的 return N 只能返回 0–255 的退出码(表示成功/失败),不能返回字符串或大数字.要"返回数据",标准做法是函数内 echo 结果,调用方用 $(...) 捕获--就像上面 result=$(add 3 5).把 return 当成"成功与否的信号",把 echo 当成"数据出口".

变量默认全局,务必用 local

Shell 函数里的变量默认是全局的--函数内改了一个同名变量,会污染函数外.养成习惯:函数内的临时变量一律加 local 声明,避免难以排查的"变量被偷偷改掉"的 bug.

脚本参数:与外界的接口

脚本通过位置参数接收命令行传入的值,这是脚本最常见的输入方式.一套特殊变量专门用于此:

变量 含义
$0 脚本自身的名字
$1 $2 ... $9 第 1,2...个参数;两位数要写 ${10}
$# 参数个数
$@ 所有参数(各自独立,加引号后每个参数完整保留)
$* 所有参数(合并成一个字符串)
$? 上一条命令的退出码
$$ 当前脚本进程的 PID
#!/usr/bin/env bash
echo "脚本名: $0"
echo "参数个数: $#"
echo "第一个参数: $1"
echo "全部参数: $@"

# 运行:
$ ./demo.sh foo bar baz
脚本名: ./demo.sh
参数个数: 3
第一个参数: foo
全部参数: foo bar baz

都加双引号时:"$@" 展开成多个独立的,各自带引号的参数("foo" "bar" "baz");"$*" 展开成一个字符串("foo bar baz").需要原样转发参数给另一个命令时,永远用 "$@"--它能正确处理含空格的参数.

参数校验与默认值

#!/usr/bin/env bash

# 参数个数校验:不足就报用法并退出
if [[ $# -lt 1 ]]; then
echo "用法: $0 <文件名>" >&2 # 错误信息发到 stderr
exit 1
fi

# 给参数设默认值:未提供时用默认
target="${1:-/tmp}" # 没传 $1 就用 /tmp
env="${ENV:-production}" # 没设环境变量 ENV 就用 production

echo "目标目录: $target,环境: $env"

${变量:-默认值} 是极常用的"取值,没有就用默认"语法,让脚本更健壮.

退出码:脚本的成败信号

脚本本身也要向调用它的人(或 CI,cron)报告成败,靠的是 exit N:

  • exit 0--成功(也是默认,脚本正常跑完退出码就是最后一条命令的)
  • exit 1 及其他非零值--失败,不同数字可表示不同错误类型
if ! command -v git > /dev/null; then    # command -v 检查命令是否存在
echo "错误: 未安装 git" >&2
exit 127 # 用特定码表示"命令找不到"
fi

退出码是脚本交班时的"工作报告":0 是"一切顺利",非 0 是"出事了,而且这个数字告诉上层是哪类事".
上层程序(如 && 链,CI 流水线)正是靠读这个数字决定要不要继续往下走.

严格模式:set -euo pipefail

Shell 默认非常宽容--命令失败了还往下跑,用了未定义变量当空串处理,管道中间出错被忽略.这种"装作没事"的默认行为是脚本事故的温床.生产脚本几乎都会在开头加这一行开启严格模式:

#!/usr/bin/env bash
set -euo pipefail
选项 作用
set -e 任何命令失败(非零退出)立即终止脚本,不再硬着头皮往下跑
set -u 使用未定义变量时报错退出,防止拼错变量名导致的诡异行为
set -o pipefail 管道中任何一环失败,整条管道就算失败(默认只看最后一个命令)

为什么 pipefail 重要

默认情况下 cat 不存在的文件 | grep foo 的退出码取决于 grep(最后一个命令),cat 的失败被吞掉了.加上 pipefail 后,只要管道里 cat 失败,整条管道就判失败,配合 set -e 脚本会立即停下--避免基于残缺数据继续执行.

set -e 的例外:允许某条命令失败

开了 set -e 后,如果明知某条命令可能失败且不想让脚本退出,用 || true 兜底:grep "可能不存在" file || true.或者把它放进 if 里判断--if 条件中的命令失败不会触发 set -e.

trap:退出时的清理钩子

脚本经常会创建临时文件,占用锁,起后台进程.如果脚本中途出错退出,这些残留物不会自动清理.trap 让你注册一个"无论怎么退出都会执行"的清理函数:

#!/usr/bin/env bash
set -euo pipefail

tmpfile=$(mktemp) # 创建临时文件

cleanup() {
rm -f "$tmpfile" # 删除临时文件
echo "已清理临时文件"
}
trap cleanup EXIT # 注册:脚本退出时(无论成功失败)都执行 cleanup

# ... 主逻辑:哪怕这里 exit 1 或被 Ctrl-C,cleanup 也会执行
echo "数据" > "$tmpfile"
process "$tmpfile"

trap 函数 信号 可以挂到不同事件上:

信号/事件 触发时机
EXIT 脚本以任何方式退出时--最常用,清理收尾
INT 用户按 Ctrl-C 时(回顾第 14 篇信号)
TERM 收到 SIGTERM 终止信号时
ERR 有命令出错时(配合 set -e 做错误日志)

trap ... EXIT 就像出门前设的"自动关灯锁门"程序--无论是正常下班,临时被叫走(Ctrl-C),还是中途绊倒(出错退出),离开时灯总会关,门总会锁.
临时文件,锁,后台进程的清理交给它,就不怕脚本异常退出后留一地垃圾.

组合:一个健壮脚本的骨架

把这一篇的要点拼起来,就是一个生产级脚本的标准开头:

#!/usr/bin/env bash
set -euo pipefail # 严格模式

# ---------- 配置 ----------
readonly SCRIPT_NAME="$0" # readonly 声明常量,防止被改
workdir="${1:-$PWD}" # 参数默认值

# ---------- 清理钩子 ----------
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT

# ---------- 函数 ----------
log() { echo "[$(date +%T)] $*"; }
die() { echo "错误: $*" >&2; exit 1; } # 打印错误并退出的快捷函数

# ---------- 参数校验 ----------
[[ -d "$workdir" ]] || die "目录不存在: $workdir"

# ---------- 主逻辑 ----------
log "开始处理 $workdir"
# ... 业务逻辑
log "完成"

把这个骨架存成模板

严格模式 + trap 清理 + log/die 辅助函数 + 参数校验,几乎是每个正经脚本都需要的.把它存成一个 template.sh,以后写新脚本直接复制改,既省事又不容易漏掉这些"安全带".

快速回顾

  • 函数:用 name() { ... } 定义,调用不带括号;内部参数同样是 $1 $2
  • 函数返回:return 只能给 0–255 退出码,返回数据要靠 echo + $(...) 捕获;临时变量加 local
  • 位置参数:$1 取值 · $# 个数 · "$@" 转发参数(几乎总用它,别用 $*) · $? 退出码
  • 默认值:${1:-默认} 让缺参数时不崩;$# -lt N 做参数校验
  • 退出码:exit 0 成功,非 0 失败,是脚本向上层报告成败的唯一信号
  • 严格模式 set -euo pipefail:出错即停 + 未定义变量报错 + 管道任一环失败即失败,生产脚本标配
  • 允许失败:用 || true 或放进 if;trap cleanup EXIT 保证退出时清理临时文件/锁

动手练习

  1. 函数与 local:写一个 add() 函数接收两个数字,用 echo 返回和,在主程序用 $(...) 捕获并打印;给函数内变量加 local 验证作用域
  2. $@ vs $*:写一个脚本打印 $0 $# $@,用不同数量,含空格的参数运行,观察 "$@""$*" 的区别
  3. 默认值:用 ${1:-default} 给脚本第一个参数设默认值,不传参数时验证默认生效
  4. set -u 验证:在脚本开头加 set -euo pipefail,故意引用一个拼错的变量名,观察 -u 如何报错退出
  5. pipefail 对比:写一个 cat 不存在的文件 | grep foo 的管道,分别在有/无 pipefail 时用 echo $? 看退出码差异
  6. trap 清理:用 trap cleanup EXIT 注册清理函数删除一个 mktemp 临时文件,然后让脚本中途 exit 1,确认临时文件仍被删除
  7. 脚本骨架:套用本篇的"健壮脚本骨架"写一个真实小脚本(如统计某目录下文件数),把它存成自己的 template.sh