FAQ-6:Shell 脚本常见问题
预计阅读时间:12 分钟
📖 目录
FAQ-6:Shell 脚本常见问题(Q70-Q81)
问题速查表
| 问题 | 章节 |
|---|---|
| Q70: 变量值为空导致错误? | Q70 |
| Q71: 如何处理命令行参数? | Q71 |
| Q72: 如何检查是否以 root 运行? | Q72 |
| Q73: 如何在脚本中发送通知? | Q73 |
| Q74: 如何处理 Ctrl+C 中断? | Q74 |
| Q75: 如何判断文件/目录是否存在? | Q75 |
| Q76: 如何进行浮点数运算? | Q76 |
| Q77: 如何生成随机数? | Q77 |
| Q78: 如何在脚本中记录日志? | Q78 |
| Q79: 如何计算脚本执行时间? | Q79 |
| Q80: 如何逐行读取 CSV? | Q80 |
| Q81: 如何请求用户确认? | Q81 |
Q70: 变量值为空导致错误?
Shell 变量未定义或值为空时直接使用会报错。用默认值或 set -u 严格模式预防。
# ${VAR:-default}:如果 VAR 未设置或为空,使用 default 值
echo "${VAR:-default_value}"
# set -u:未定义变量时报错退出(推荐在脚本开头启用)
set -u
# 此后使用未定义变量会报:-bash: VAR: unbound variable
Q71: 如何处理命令行参数?
getopts 是 Shell 内置的参数解析工具,支持短选项(-v)和带值选项(-f file)。
# getopts 解析命令行参数
# "hvf:" 表示支持 -h、-v、-f(后跟冒号表示需要参数)
while getopts "hvf:" opt; do
case $opt in
h) usage;; # 显示帮助
v) VERBOSE=1;; # 开启详细输出
f) FILE="$OPTARG";; # -f 后面的值存在 OPTARG 中
*) usage; exit 1;;
esac
done
# shift $((OPTIND-1)) 可以移除已解析的参数,处理剩余的位置参数
Q72: 如何检查是否以 root 运行?
# $EUID 是当前用户的 effective UID(有效用户 ID)
# root 的 UID 永远是 0
if [[ $EUID -ne 0 ]]; then
echo "错误:此脚本需要 root 权限运行"
echo "请使用 sudo 执行"
exit 1
fi
Q73: 如何在脚本中发送通知?
# 邮件通知(需要配置 mailx 或 sendmail)
echo "备份完成" | mail -s "备份通知" admin@example.com
# Webhook 通知(如 Slack、企业微信、钉钉)
curl -X POST -H "Content-Type: application/json" \
-d '{"text":"脚本执行完成"}' \
https://hooks.slack.com/services/xxx/yyy/zzz
Q74: 如何处理 Ctrl+C 中断?
trap 捕获信号并执行清理操作。INT(Ctrl+C)和 TERM(kill 默认信号)是最常见的需要处理的信号。
# trap '清理函数' 信号列表
trap 'echo "收到中断信号"; cleanup; exit 1' INT TERM
cleanup() {
# 清理临时文件、释放锁等
rm -f /tmp/myapp.lock
}
# 常见信号:
# INT (2):Ctrl+C
# TERM (15):kill 默认信号
# EXIT (0):脚本退出时(bash 特有)
trap 'cleanup' EXIT # 脚本退出时自动清理
Q75: 如何判断文件/目录是否存在?
# -f:普通文件存在
[[ -f "$file" ]] && echo "文件存在"
# -d:目录存在
[[ -d "$dir" ]] && echo "目录存在"
# -r:文件存在且可读
# -w:文件存在且可写
# -x:文件存在且可执行
# -s:文件存在且大小不为 0
Q76: 如何进行浮点数运算?
Bash 原生只支持整数运算,浮点运算需要用 bc 或 awk。
# bc(任意精度计算器)
# scale=2:保留 2 位小数
echo "scale=2; 10/3" | bc
# 输出:3.33
# awk(更适合处理浮点运算)
awk "BEGIN {print 10/3}"
# 输出:3.33333
# Bash 原生整数运算
echo $((10 / 3)) # 输出:3(截断)
Q77: 如何生成随机数?
# $RANDOM:Shell 内置,范围 0-32767
echo $RANDOM
# 生成指定范围的随机数(如 1-100)
echo $(( RANDOM % 100 + 1 ))
# openssl:生成加密安全的随机数
openssl rand -hex 16 # 32 位十六进制随机字符串
# /dev/urandom:系统级随机源
head -c 32 /dev/urandom | base64
Q78: 如何在脚本中记录日志?
# 定义日志函数:同时输出到终端和日志文件
log() {
echo "[$(date '+%F %T')] $*" | tee -a /var/log/script.log
}
# 使用
log "开始备份"
log "错误:磁盘空间不足"
# tee 命令:从标准输入读取,同时写入文件和标准输出
Q79: 如何计算脚本执行时间?
# 记录开始时间(秒级时间戳)
START=$(date +%s)
# ... 执行耗时操作 ...
END=$(date +%s)
echo "执行耗时 $((END - START)) 秒"
# 更精确的计时:使用 time 命令
time ./myscript.sh
Q80: 如何逐行读取 CSV?
# IFS=',' 设置字段分隔符为逗号
# read -r col1 col2 col3:按逗号分隔读入三个变量
# -r:不处理反斜杠转义
while IFS=',' read -r col1 col2 col3; do
echo "姓名:$col1,邮箱:$col2,电话:$col3"
done < file.csv
Q81: 如何请求用户确认?
# -p:提示信息
# -n 1:只读取 1 个字符
# -r:不处理反斜杠转义
# REPLY:read 未指定变量名时的默认变量
read -p "确认执行?(y/N) " -n 1 -r
echo # 换行(因为 -n 1 后没有自动换行)
[[ $REPLY =~ ^[Yy]$ ]] || exit 1
真实案例
案例 A:变量为空导致 rm -rf 删除整个目录
现象:脚本中 rm -rf $DIR/*,当 $DIR 变量为空时,实际执行了 rm -rf /*,删除了整个根目录。
# 危险写法
DIR=""
rm -rf $DIR/* # 展开为 rm -rf /*
# 安全写法:用 set -u 和双引号
set -u
rm -rf "$DIR"/* # $DIR 为空时报错而非误删
根因:Shell 变量未加双引号,空值被展开后参数位置发生变化。$DIR 为空时,$DIR/* 展开为 *,而 * 在 Shell 中代表当前目录所有文件。这是 Shell 脚本中最危险的陷阱之一。
修复:脚本开头加 set -euo pipefail(严格模式),所有变量引用加双引号。
案例 C:浮点数运算精度丢失导致配置错误
现象:Shell 脚本计算磁盘使用率 used=$(echo "scale=2; 80/100" | bc),结果是 .80 而非 80,后续判断 if [ $used -gt 90 ] 报语法错误。
# bc 输出 .80(没有前导零)
used=$(echo "scale=2; 80/100" | bc)
echo $used # .80
# 判断时语法错误
if [ .80 -gt 90 ]; then ... # bash: [: .80: integer expression expected
根因:bc 在 scale=2 且整数部分为 0 时省略前导零。后续用整数比较命令 -gt 比较浮点数会报错。
修复:用 awk 替代 bc 进行浮点运算,或在判断前将百分比乘以 100 用整数比较。
案例 B:trap 清理函数在子 shell 中无效
现象:脚本用管道 cmd1 | cmd2,trap 只在最后一个子 shell 中生效,cmd1 的清理被跳过。
根因:管道中的每个命令都在独立的子 shell 中执行,trap 只作用于当前 shell。
修复:使用进程替换 cmd1 < <(cmd2) 或重写为顺序执行,或用 mkfifo 控制管道行为。
预防措施
- 脚本开头始终加
set -euo pipefail(严格模式):未定义变量报错、管道错误传播 - 所有变量引用加双引号
"$var",防止空值和特殊字符导致意外行为 - 使用 trap 处理 EXIT 信号确保清理逻辑一定执行
- 使用
shellcheck script.sh静态分析工具检查常见错误 - 跨平台脚本避免依赖 sed/grep 的 GNU 扩展,优先用 POSIX 兼容语法
- 脚本中使用
[[ ]]替代[ ]进行条件测试,支持正则匹配且更安全 - 生产脚本添加
--norc --noprofile执行,避免用户环境干扰 - 脚本执行前用
bash -n script.sh做语法检查,避免运行时才发现语法错误 - 避免使用反引号 `` `command` ``,改用
$(command),支持嵌套且可读性更好 - 使用
mktemp创建临时文件,避免硬编码/tmp/xxx导致竞争条件 - 脚本中避免使用
eval,除非完全控制输入内容 - Shell 脚本中使用
"$@"传递参数,保留原始参数的分词和引用
案例 C:glob 通配符无匹配文件时的行为
现象:for f in *.log; do echo $f; done 在目录中没有 .log 文件时,循环体执行一次,输出 *.log 字面量。
# 问题代码
$ ls *.log
ls: cannot access '*.log': No such file or directory
$ for f in *.log; do echo "$f"; done
*.log ← glob 未匹配时保留字面量
# 修复:启用 nullglob
$ shopt -s nullglob
$ for f in *.log; do echo "$f"; done
(无输出——正确)
# 脚本中设置
#!/bin/bash
shopt -s nullglob
# 另一种写法:用数组
files=( *.log )
if [[ ${#files[@]} -eq 0 ]]; then
echo "没有找到 .log 文件"
else
printf '%s\n' "${files[@]}"
fi
# 对比 zsh(zsh 默认无匹配时为空)
# zsh: for f in *.log; do echo "$f"; done
# 无输出——zsh 行为与 bash 不同
根因:Bash 默认在 glob 无匹配时保留原始字符串(与 zsh 不同)。循环会将 *.log 作为文件名处理,导致后续命令报错或产生意外行为。这是 Bash 和 zsh 的一个重要差异,从 zsh 迁移到 bash 时尤其需要注意。
修复:脚本开头添加 shopt -s nullglob,无匹配时 glob 展开为空,循环不执行。或用数组方式收集匹配结果,检查数组长度后再处理。从 zsh 迁移到 bash 时尤其需要注意这个差异。
案例 D:sed -i 在 macOS 和 Linux 上不兼容
现象:脚本在 Linux 上运行正常,macOS 上报错 sed: 1: "...": invalid command code。
# Linux(GNU sed)
sed -i 's/old/new/g' file.txt # 正常
# macOS(BSD sed)
sed -i 's/old/new/g' file.txt # 报错
sed -i '' 's/old/new/g' file.txt # 正确——BSD sed -i 需要备份后缀
# 跨平台兼容写法
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/old/new/g' file.txt
else
sed -i 's/old/new/g' file.txt
fi
# 或使用 perl(行为一致)
perl -pi -e 's/old/new/g' file.txt
# 检测 sed 版本
if sed --version 2>/dev/null | grep -q "GNU"; then
echo "GNU sed"
else
echo "BSD sed"
fi
# 使用 awk 替代 sed(行为更一致)
awk '{gsub(/old/, "new"); print}' file.txt > tmp && mv tmp file.txt
根因:GNU sed 的 -i 直接原地编辑,BSD sed 的 -i 需要指定备份后缀(空字符串 '' 表示不备份)。macOS 使用 BSD sed,大多数 Linux 发行版使用 GNU sed。
修复:跨平台脚本使用 perl -pi -e 替代 sed,或检测 $OSTYPE 分支处理。避免依赖 sed 的 GNU 扩展功能。使用 awk 替代简单文本替换也能提高跨平台兼容性。
案例 E:source 与 exec 的区别导致脚本行为异常
现象:脚本 A 用 source scriptB.sh 加载配置,scriptB 中的 exit 导致整个脚本 A 退出。
# scriptA.sh
#!/bin/bash
echo "开始"
source scriptB.sh # scriptB 中有 exit 0
echo "这行不会执行"
# scriptB.sh
#!/bin/bash
export DB_HOST="localhost"
exit 0 # 导致 scriptA 也退出
# 修复:用子 shell 隔离
(
source scriptB.sh
export DB_HOST="$DB_HOST"
)
# 或用 source 加载配置时不用 exit,用 return
# source vs exec 的区别:
# source:在当前 shell 中执行,继承当前环境
# exec:替换当前进程为新程序,原进程消失
# 子 shell ( ):创建子进程执行,不影响父 shell
# Docker 中的 exec 示例
# exec "$@" 替换当前进程为传入的命令
# 这是 Docker ENTRYPOINT 的常见写法
根因:source 在当前 shell 进程中执行脚本,exit 会终止当前 shell。exec 会替换当前进程为新程序,常用于 Docker 容器的 ENTRYPOINT。理解这三者的差异是编写可靠 Shell 脚本的基础。
修复:配置文件用 return 代替 exit;或用 export VAR=$(command) 捕获输出;或用子 shell ( source file ) 隔离。在 Docker 中用 exec "$@" 确保信号正确传递给子进程。理解 source/exec/子 shell 的行为差异是编写可靠 Shell 脚本的基础。
案例 F:管道中 set -e 失效导致错误被忽略
现象:脚本启用了 set -e,但管道中某命令失败后脚本仍继续执行。
# set -e 不捕获管道中非最后一个命令的失败
set -e
cat nonexistent.txt | grep "pattern" # cat 失败,但 grep 成功
echo "继续执行" # 这行仍会执行
# 修复:启用 pipefail
set -eo pipefail
cat nonexistent.txt | grep "pattern"
# 现在会报错退出
# 调试技巧:用 set -x 查看执行过程
set -x
cat nonexistent.txt | grep "pattern"
# + cat nonexistent.txt
# + grep pattern
# + echo '继续执行'
# 验证 pipefail 设置
$ bash -c 'set -o | grep pipefail'
pipefail on
# 注意:dash(/bin/sh)不支持 pipefail
# 确保脚本使用 bash(#!/bin/bash)而非 sh
根因:set -e 只检查管道最后一个命令的退出状态。启用 pipefail 后,管道中任意命令失败都会传播。这是 Shell 脚本中常见的隐蔽 bug,尤其在使用管道处理数据时容易被忽略。
修复:脚本开头始终使用 set -euo pipefail(严格模式),同时启用未定义变量检查和管道错误传播。用 set -x 调试执行过程,用 shellcheck 静态分析常见错误。注意 dash(/bin/sh)不支持 pipefail,确保脚本使用 bash。
案例 G:heredoc 未引用导致变量被提前展开
现象:脚本中使用 heredoc 生成配置文件,期望输出 $DB_HOST 变量引用,但实际输出为空或被当前 Shell 变量值替换。
# 问题代码:heredoc 未引用,变量被当前 Shell 展开
cat > config.yml << EOF
database:
host: $DB_HOST
port: $DB_PORT
EOF
# 输出:host: (空) port: (空)
# 因为当前 Shell 中 $DB_HOST 未定义
# 修复:引用 heredoc 标记符阻止变量展开
cat > config.yml << 'EOF'
database:
host: $DB_HOST
port: $DB_PORT
EOF
# 输出:host: $DB_HOST port: $DB_PORT(原样输出)
# 期望部分展开时的技巧
cat > config.yml << EOF
database:
host: ${DB_HOST:-localhost} ← 需要展开的变量
port: \$DB_PORT ← 需要原样输出,用反斜杠转义
EOF
根因:Bash heredoc 默认对内容做变量展开和命令替换。引用标记符(如 'EOF')会禁用所有展开,不引用则全部展开。这是 Shell 的基础行为,但在生成配置文件时容易出错。
修复:需要原样输出时用引号引用标记符(<< 'EOF');需要部分展开时混合使用 ${VAR:-default} 和转义字符。用 cat 预览输出确认正确后再写入文件。
案例 H:脚本中 eval 注入漏洞
现象:脚本使用 eval 执行用户输入的命令,攻击者通过注入恶意命令获取了 root 权限。
# 危险代码
read -p "Enter filename: " FILENAME
eval "echo $FILENAME" ← 用户输入 ; rm -rf / 时会执行删除
# 攻击示例
Enter filename: test; rm -rf /
# 实际执行:echo test; rm -rf /
# 修复:避免 eval,使用变量直接引用
read -p "Enter filename: " FILENAME
echo "$FILENAME" ← 双引号保护,不会分词
# 如果必须使用 eval,验证输入
if [[ "$FILENAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then
eval "echo $FILENAME"
else
echo "Invalid filename"
fi
根因:eval 将参数作为 Shell 命令执行,任何分号、管道、命令替换都会被解释。用户输入直接传给 eval 等于给了任意命令执行权限,这是严重的安全漏洞。
修复:避免使用 eval,改用变量引用和参数展开。如果必须用 eval,严格校验输入内容(白名单字符集)。Shell 脚本安全的第一原则:永远不要信任用户输入。
延伸阅读
- 2.1:Shell 脚本入门 Shell 脚本入门——变量、条件分支、循环与 set -euo pipefail 纪律
- 2.8:Shell 脚本进阶 Shell 脚本进阶——数组、trap、进程替换与 set -x 调试
- 2.10:正则表达式 正则表达式——grep、sed、awk 中的正则实战
- 4.19:Shell 实战案例集 Shell 实战案例集——生产环境 Shell 脚本的常见陷阱与最佳实践
- 4.4:Shell 自动化实战 Shell 自动化实战——用 Shell 脚本实现运维自动化
- 1.4:基本文件操作命令 基本文件操作命令——Shell 脚本中常用的文件操作基础
- 1.9:重定向与管道 重定向与管道——重定向、管道与tee 的核心用法