前言
会写脚本和写好脚本之间,隔着无数深夜的坑。这篇把 Bash 的函数进阶用法和那些"约定俗成但没人明说"的陷阱一次讲清。
一、函数的正确写法
#!/usr/bin/env bash
# 定义(function 关键字可省, 括号必须有)
function log() {
# local 变量: 不污染全局 —— 函数内赋值一律 local!
local level=$1 msg=$2
printf '[%s] [%s] %s\n' "$(date '+%F %T')" "$level" "$msg"
}
# 调用(参数像脚本参数一样传)
log INFO "服务启动"
log ERROR "连接超时"
参数与返回值:
get_status() {
local svc=$1
# 函数的"返回数据"用 stdout
systemctl is-active "$svc"
}
# 捕获: 命令替换
status=$(get_status nginx)
echo "nginx 状态: $status"
# 返回码用 return(0成功/非0失败, 给 if 用)
is_alive() {
pgrep -x "$1" &>/dev/null
}
if is_alive nginx; then
echo "nginx 在跑"
fi
黄金法则:
| 输出通道 | 用途 |
|---|---|
| stdout | 函数的"数据返回"(被 $(…) 捕获) |
| stderr | 日志/调试信息(不污染数据流) |
| return 退出码 | 成功与否(给 if/&& 判断) |
fetch_data() {
echo "正在查询..." >&2 # 日志走 stderr
echo '{"ok":true}' # 数据走 stdout
}
data=$(fetch_data 2>/dev/null) # 拿到纯数据
二、陷阱一:管道中的子 shell
#!/usr/bin/env bash
count=0
cat /etc/passwd | while read -r line; do
count=$((count + 1))
done
echo "$count" # 0 !! 炸裂
原因:管道右侧的 while 跑在子 shell 里,count 的修改留在子进程,退出即丢。
三种解法:
# 解法1: 重定向代替管道(同一进程)
count=0
while read -r line; do
count=$((count + 1))
done < /etc/passwd
echo "$count" # ✅ 正确
# 解法2: 进程替换
while read -r line; do ... done < <(some_command)
# 解法3: bash 4.2+ 打开 lastpipe(需关闭 job control)
shopt -s lastpipe
cat file | while read -r line; do ...; done
三、陷阱二:没引号的变量
file="my document.txt"
rm $file # ❌ 实际执行: rm my document.txt → 删了两个不存在的文件
rm "$file" # ✅
# 单词拆分还能引发注入:
user_input="; rm -rf /"
eval "echo $user_input" # ❌ 灾难
三条铁律:
- 变量展开永远双引号:
"$var" eval是恶魔,能不用就不用(要用则严控输入)- 数组展开用
"${arr[@]}"(每元素独立引号)
四、陷阱三:set 选项的细节
set -e # 出错退出 —— 但有豁免区!
true # 在 if 条件、&& || 链、管道(见下)中不触发
cmd || true # 手动豁免(明示"允许失败")
set -e 与管道的恩怨:默认只看管道最后一个命令的退出码:
set -e
false | echo "hi" # 打印 hi, 脚本继续! false 的失败被吞了
解法:set -o pipefail(与 -e 联手,这也是骨架里 set -euo pipefail 三件套的由来)。
# -u: 未定义变量报错
set -u
echo "$TYPO_VAR" # 直接报错退出, 而不是静默空串
echo "${SAFE_VAR:-默认值}" # 显式给默认值
# -x: 调试模式(排查脚本神器)
set -x
cp "$src" "$dst"
# + cp /data/a.tar /backup/a.tar ← 每步实际执行什么都看到了
五、陷阱四:cd 与临时目录
#!/usr/bin/env bash
set -euo pipefail
# 脚本中途 cd 后, 相对路径全变 —— 处理完要回去
cleanup() { cd - >/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT
TMP=$(mktemp -d) # 用 mktemp 而不是 /tmp/xxx 固定名
cd "$TMP"
# trap ... EXIT: 无论正常/报错/被 Ctrl+C 都执行清理
# 常见配对: 临时文件、锁文件、中断恢复
六、陷阱五:数值与字符串比较
[ "10" -gt "9" ] # ✅ -gt 数值比较: true
[ "10" \> "9" ] # 字符串比较: "10" < "9"(字典序!)
[[ 10 > 9 ]] # 双括号里 > 不用转义, 但仍是字符串比较
[[ ]] 与 [ ] 的区别:
- [[ $a == $b ]] # 不发生单词拆分, 变量可不加引号(还是建议加)
- [[ $a =~ ^[0-9]+$ ]]# 支持正则 =~(很实用)
- 算术: (( count >= 10 ))
if (( count >= 10 )); then echo "够了"; fi
if [[ $input =~ ^[0-9]+$ ]]; then echo "是纯数字"; fi
七、陷阱六:空格与换行
# 命令替换会吃掉尾部换行
out=$(echo "hello
")
echo "[$out]" # [hello] —— 尾换行没了, 数长度要小心
# 文件名带横杠开头
cat -options.txt # 被当选项
cat ./-options.txt # ✅ 加路径
cat -- -options.txt # ✅ 或用 --
# 文件名含空格的遍历(错误 vs 正确)
for f in $(ls); do ... done # ❌ 空格文件名被拆碎
for f in ./*; do ... done # ✅ glob 展开, 认引号
find . -name "*.log" -print0 | while IFS= read -r -d '' f; do ...; done # ✅✅ 终极
八、一个"防御性"的完整示例
#!/usr/bin/env bash
# 可靠的日志归档脚本
set -euo pipefail
IFS=$'\n\t' # 收紧默认分词(防御性编程)
readonly LOG_DIR="/var/log/myapp"
readonly KEEP_DAYS=14
readonly ARCHIVE="/backup/myapp-$(date +%F).tar.gz"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
usage() { echo "用法: $0 [-k 保留天数]"; exit 2; }
while getopts ":k:h" opt; do
case $opt in
k) KEEP_DAYS=$OPTARG ;;
h) usage ;;
:) die "选项 -$OPTARG 缺参数" ;;
\?) die "未知选项 -$OPTARG" ;;
esac
done
[[ -d $LOG_DIR ]] || die "日志目录不存在: $LOG_DIR"
trap 'log "归档中断, 临时文件已清理"; rm -f "$ARCHIVE.tmp"' ERR
log "开始归档 $LOG_DIR"
tar -czf "$ARCHIVE.tmp" -C "$LOG_DIR" .
mv "$ARCHIVE.tmp" "$ARCHIVE" # 原子替换
log "归档完成: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS" -delete
log "已清理 ${KEEP_DAYS} 天前的旧日志"
亮点:>&2 日志分流、readonly、die 模式、trap ERR、mv tmp 原子化、getopts 严格报错。
小结
| 陷阱 | 解法 |
|---|---|
| 管道子 shell 变量丢失 | done < file / < <(cmd) |
| 空格拆分 | 永远 "$var" |
| 管道错误被吞 | set -o pipefail |
| 静默失败 | set -eu |
| 中断留下垃圾 | trap ... EXIT/ERR |
| 魔法数字/路径 | readonly 常量 |
| 固定临时文件名 | mktemp |
本文是「Linux 基础」系列第 9 篇。