POSIX 标准详解教程 / 第十二章:Shell 与脚本
第十二章:Shell 与脚本
掌握 POSIX Shell 语法规范、内置命令、管道重定向、Shell 脚本编程最佳实践。
12.1 POSIX Shell 概述
12.1.1 什么是 POSIX Shell
POSIX Shell 规范(IEEE Std 1003.2,即 POSIX.2)定义了命令语言解释器的行为。常见的 POSIX 兼容 Shell:
| Shell | 路径 | 说明 |
|---|---|---|
sh | /bin/sh | POSIX Shell(可能是 dash、bash –posix) |
bash | /bin/bash | GNU Bourne-Again Shell(兼容 POSIX 并有扩展) |
dash | /bin/dash | 轻量 POSIX Shell(Debian/Ubuntu 默认 sh) |
zsh | /bin/zsh | Z Shell(大部分兼容 POSIX) |
ksh | /bin/ksh | Korn Shell(POSIX 兼容) |
注意:Bash 的许多特性(如
[[ ]]、数组、$RANDOM、<()进程替换)不是 POSIX 标准。编写可移植脚本应避免使用这些特性。
12.1.2 Shell 脚本基本结构
#!/bin/sh
# POSIX Shell 脚本模板
# 首行 shebang 指定解释器
set -eu # -e: 遇错退出; -u: 未定义变量报错
# 变量赋值(等号两侧不能有空格!)
name="POSIX Shell"
version="1.0"
# 输出
echo "=== ${name} v${version} ==="
# 获取命令输出
current_date=$(date '+%Y-%m-%d %H:%M:%S')
echo "当前时间: ${current_date}"
12.2 变量与参数展开
12.2.1 变量操作
| 语法 | 说明 | 示例 |
|---|---|---|
${var} | 变量引用 | ${HOME} |
${var:-default} | 未设置或为空时返回 default | ${PORT:-8080} |
${var:=default} | 未设置或为空时赋值 | ${PORT:=8080} |
${var:+alternate} | 已设置且非空时返回 alternate | ${DEBUG:+-v} |
${var:?error} | 未设置或为空时报错退出 | ${CONFIG:?配置缺失} |
${#var} | 字符串长度 | ${#name} |
${var#pattern} | 删除最短前缀匹配 | ${path#*/} |
${var##pattern} | 删除最长前缀匹配 | ${path##*/} |
${var%pattern} | 删除最短后缀匹配 | ${file%.txt} |
${var%%pattern} | 删除最长后缀匹配 | ${file%%.*} |
${var/pattern/repl} | 替换第一次匹配 | ${name/POSIX/UNIX} |
${var//pattern/repl} | 替换所有匹配 | ${name//s/S} |
12.2.2 参数展开示例
#!/bin/sh
set -eu
# 字符串操作
path="/home/user/documents/report.tar.gz"
echo "原路径: ${path}"
echo "目录名: ${path%/*}" # /home/user/documents
echo "文件名: ${path##*/}" # report.tar.gz
echo "去掉扩展名: ${path%.tar.gz}" # /home/user/documents/report
echo "前缀删除: ${path#*/}" # home/user/documents/report.tar.gz
# 默认值
echo "端口: ${PORT:-8080}" # PORT 未设置时使用 8080
echo "主机: ${HOST:=localhost}" # HOST 未设置时赋值 localhost
echo "主机确认: ${HOST}"
# 长度
str="Hello, POSIX!"
echo "字符串长度: ${#str}" # 14
# 错误检查
# config_file=${CONFIG:?"错误: 必须指定配置文件"}
12.3 条件判断
12.3.1 test / [ ] 命令
#!/bin/sh
# 文件测试
file="/etc/passwd"
if [ -f "$file" ]; then echo "$file 是普通文件"; fi
if [ -d "/tmp" ]; then echo "/tmp 是目录"; fi
if [ -r "$file" ]; then echo "$file 可读"; fi
if [ -w "$file" ]; then echo "$file 可写"; fi
if [ -x "/bin/ls" ]; then echo "/bin/ls 可执行"; fi
if [ -s "$file" ]; then echo "$file 非空"; fi
if [ -L "/bin/sh" ]; then echo "/bin/sh 是符号链接"; fi
# 字符串比较
name="posix"
if [ "$name" = "posix" ]; then echo "名称匹配"; fi
if [ -n "$name" ]; then echo "变量非空"; fi
if [ -z "" ]; then echo "字符串为空"; fi
# 整数比较
count=42
if [ "$count" -gt 10 ]; then echo "$count > 10"; fi
if [ "$count" -eq 42 ]; then echo "$count == 42"; fi
if [ "$count" -le 100 ]; then echo "$count <= 100"; fi
# 组合条件(POSIX 标准方式,不使用 [[ ]])
if [ "$count" -gt 0 ] && [ "$count" -lt 100 ]; then
echo "$count 在 0-100 范围内"
fi
if [ "$count" -lt 0 ] || [ "$count" -gt 100 ]; then
echo "$count 超出范围"
else
echo "$count 在范围内"
fi
12.3.2 文件测试运算符
| 运算符 | 说明 |
|---|---|
-f | 是普通文件 |
-d | 是目录 |
-e | 存在 |
-r | 可读 |
-w | 可写 |
-x | 可执行 |
-s | 非空(大小 > 0) |
-L | 是符号链接 |
-h | 是符号链接(同 -L) |
-p | 是命名管道 (FIFO) |
-S | 是套接字 |
-b | 是块设备 |
-c | 是字符设备 |
-nt | 比另一个文件新 (newer than) |
-ot | 比另一个文件旧 (older than) |
12.4 循环结构
#!/bin/sh
# for 循环
echo "=== for 循环 ==="
for file in /tmp/*.txt; do
[ -f "$file" ] || continue # 跳过非文件
echo "处理: $file"
done
# for 循环(C 风格,POSIX 不支持,但大多数 shell 支持)
# 使用 while 替代
i=0
while [ "$i" -lt 5 ]; do
echo " i=$i"
i=$((i + 1))
done
# while 循环
echo "=== while 读取文件 ==="
line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
echo " 行 $line_num: $line"
done <<EOF
第一行
第二行
第三行
EOF
# until 循环
count=0
until [ "$count" -ge 3 ]; do
echo " count=$count"
count=$((count + 1))
done
# case 语句
echo "=== case 语句 ==="
os=$(uname -s)
case "$os" in
Linux)
echo "操作系统: Linux"
;;
Darwin)
echo "操作系统: macOS"
;;
FreeBSD|NetBSD|OpenBSD)
echo "操作系统: BSD ($os)"
;;
*)
echo "未知操作系统: $os"
;;
esac
12.5 函数
#!/bin/sh
set -eu
# 定义函数
log_info() {
# $* = 所有参数
printf "[INFO] %s\n" "$*"
}
log_error() {
printf "[ERROR] %s\n" "$*" >&2 # 输出到 stderr
}
# 带返回值的函数
is_valid_port() {
# 参数: 端口号
port="$1"
case "$port" in
''|*[!0-9]*) return 1 ;; # 非数字
esac
[ "$port" -ge 1 ] && [ "$port" -le 65535 ]
}
# 使用函数
log_info "脚本启动"
port="8080"
if is_valid_port "$port"; then
log_info "端口 $port 有效"
else
log_error "端口 $port 无效"
fi
port="99999"
if is_valid_port "$port"; then
log_info "端口 $port 有效"
else
log_error "端口 $port 无效"
fi
log_info "脚本结束"
12.6 管道与重定向
12.6.1 管道链
#!/bin/sh
# 管道:前一个命令的输出作为后一个命令的输入
echo "=== .c 文件统计 ==="
find . -name '*.c' -type f | wc -l
# 多级管道
echo "=== 最大的 5 个文件 ==="
find . -type f -printf '%s %p\n' 2>/dev/null | sort -rn | head -5
# 管道中使用变量
count=$(ps aux | grep -v grep | grep -c "nginx" || true)
echo "Nginx 进程数: $count"
12.6.2 重定向
#!/bin/sh
# 标准重定向
echo "stdout 内容" > /tmp/stdout.txt # 覆盖写入
echo "追加内容" >> /tmp/stdout.txt # 追加写入
ls /nonexistent 2> /tmp/stderr.txt # 重定向 stderr
ls /nonexistent 2>/dev/null # 丢弃 stderr
# 合并 stdout 和 stderr
command > /tmp/all.log 2>&1 # 方式 1
command &> /tmp/all.log # 方式 2 (bash 扩展,非 POSIX)
# Here Document
cat <<EOF
这是 Here Document
多行文本
变量也会被替换: $(date)
EOF
# Here Document(不替换变量)
cat <<'EOF'
$HOME 和 $(date) 不会被替换
原样输出
EOF
# 文件描述符操作
exec 3>/tmp/custom_fd.txt # 打开 fd 3 用于写入
echo "写入 fd 3" >&3
exec 3>&- # 关闭 fd 3
# 输入重定向
while read -r line; do
echo "读到: $line"
done < /etc/hostname
12.7 内置命令
12.7.1 常用内置命令
| 命令 | 说明 | POSIX 标准 |
|---|---|---|
echo | 输出文本 | ✅ |
printf | 格式化输出 | ✅ |
read | 读取输入 | ✅ |
test / [ | 条件测试 | ✅ |
set | 设置选项和位置参数 | ✅ |
shift | 移动位置参数 | ✅ |
trap | 信号捕获 | ✅ |
export | 导出环境变量 | ✅ |
eval | 执行字符串命令 | ✅ |
exec | 替换当前 Shell 进程 | ✅ |
exit | 退出 Shell | ✅ |
return | 从函数/脚本返回 | ✅ |
cd | 切换目录 | ✅ |
pwd | 打印当前目录 | ✅ |
umask | 设置文件创建掩码 | ✅ |
type | 查看命令类型 | ✅ |
command | 执行命令(绕过别名) | ✅ |
getopts | 解析选项参数 | ✅ |
12.7.2 getopts 参数解析
#!/bin/sh
set -eu
# 标准参数解析
verbose=0
output=""
count=1
usage() {
echo "用法: $0 [-v] [-n count] [-o output] [file...]"
exit 1
}
while getopts "vn:o:h" opt; do
case "$opt" in
v) verbose=1 ;;
n) count="$OPTARG" ;;
o) output="$OPTARG" ;;
h) usage ;;
?) usage ;;
esac
done
shift $((OPTIND - 1)) # 移除已解析的选项
echo "verbose=$verbose, count=$count, output=$output"
echo "剩余参数: $*"
12.7.3 trap 信号捕获
#!/bin/sh
# 清理函数
cleanup() {
echo "清理临时文件..."
rm -f "$tmp_file"
echo "清理完成"
}
# 注册清理函数
trap cleanup EXIT
# 临时文件
tmp_file=$(mktemp /tmp/script_XXXXXX)
echo "临时文件: $tmp_file"
# 信号处理
trap 'echo "收到 SIGINT"; exit 1' INT
trap 'echo "收到 SIGTERM"; exit 1' TERM
echo "工作中... (Ctrl+C 中断)"
echo "数据写入临时文件" > "$tmp_file"
sleep 5
echo "脚本正常结束"
# EXIT trap 会自动执行 cleanup
12.8 Shell 脚本调试
#!/bin/sh
# 方法 1: set -x(打印每条命令)
set -x # 开启追踪
result=$((2 + 3))
set +x # 关闭追踪
echo "result=$result"
# 方法 2: 在脚本开头设置
# #!/bin/sh -x
# 方法 3: 运行时调试
# sh -x script.sh
# bash -x script.sh
# 调试辅助函数
debug() {
# 仅在 DEBUG 环境变量设置时输出
if [ "${DEBUG:-0}" = "1" ]; then
printf "[DEBUG] %s:%d %s\n" "$0" "$LINENO" "$*" >&2
fi
}
DEBUG=1
debug "脚本开始"
debug "变量值: x=42"
12.9 业务场景:自动化部署脚本
#!/bin/sh
# deploy.sh - POSIX 兼容的自动化部署脚本
set -eu
# 配置
APP_NAME="myapp"
DEPLOY_DIR="/opt/${APP_NAME}"
BACKUP_DIR="/opt/${APP_NAME}/backups"
LOG_FILE="/var/log/${APP_NAME}_deploy.log"
# 日志函数
log() {
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
msg="[${timestamp}] $*"
echo "$msg"
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
}
error() {
log "ERROR: $*" >&2
exit 1
}
# 清理函数
cleanup() {
[ -n "${tmp_dir:-}" ] && rm -rf "$tmp_dir"
}
trap cleanup EXIT
# 检查依赖
check_deps() {
for cmd in tar gzip rsync; do
command -v "$cmd" >/dev/null 2>&1 || error "缺少命令: $cmd"
done
}
# 备份当前版本
backup() {
timestamp=$(date '+%Y%m%d_%H%M%S')
backup_path="${BACKUP_DIR}/${APP_NAME}_${timestamp}.tar.gz"
if [ -d "$DEPLOY_DIR/current" ]; then
mkdir -p "$BACKUP_DIR"
log "备份当前版本到 $backup_path"
tar czf "$backup_path" -C "$DEPLOY_DIR" current 2>/dev/null
fi
}
# 部署新版本
deploy() {
package="$1"
[ -f "$package" ] || error "部署包不存在: $package"
tmp_dir=$(mktemp -d "/tmp/deploy_XXXXXX")
log "解压部署包到 $tmp_dir"
tar xzf "$package" -C "$tmp_dir"
backup
log "部署新版本..."
# 使用 rsync 同步(支持增量更新)
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "$tmp_dir/" "${DEPLOY_DIR}/current/"
else
rm -rf "${DEPLOY_DIR}/current"
cp -r "$tmp_dir" "${DEPLOY_DIR}/current"
fi
log "部署完成"
}
# 主流程
main() {
check_deps
if [ $# -lt 1 ]; then
echo "用法: $0 <部署包路径>"
exit 1
fi
log "=== 部署开始 ==="
deploy "$1"
log "=== 部署完成 ==="
}
main "$@"
12.10 注意事项
⚠️ 引用变量:始终使用
"$var"而非$var,防止分词和通配符展开。$var中如果包含空格、*、?等字符会导致意外行为。
⚠️ set -eu:
-e遇错退出,-u未定义变量报错。在生产脚本中必须设置。注意某些命令(如grep -c)可能返回非零退出码但不是错误。
⚠️
[ ]vs[[ ]]:[ ]是 POSIX 标准,[[ ]]是 Bash 扩展。使用[[ ]]的脚本不是 POSIX 可移植的。
⚠️ 数组:POSIX Shell 不支持数组。需要数组功能时,使用空格分隔的字符串或外部工具。
⚠️ $() vs 反引号:
$(cmd)和`cmd`等价,但$()支持嵌套,推荐使用。
12.11 扩展阅读
- POSIX Shell 规范:https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
man 1 sh、man 1 bash- ShellCheck:https://www.shellcheck.net/ — Shell 脚本静态分析工具
- Google Shell Style Guide:https://google.github.io/styleguide/shellguide.html
- The Art of Unix Programming — Eric Raymond 著
- Advanced Bash-Scripting Guide:https://tldp.org/LDP/abs/html/
12.12 本章小结
| 要点 | 说明 |
|---|---|
| POSIX Shell | /bin/sh 是标准接口,bash 是超集 |
| 变量引用 | 始终使用 "$var" |
| 参数展开 | ${var:-default} 提供默认值 |
| 条件判断 | 使用 [ ] 而非 [[ ]] |
| getopts | POSIX 标准的参数解析 |
| trap | 信号捕获和资源清理 |
| set -eu | 生产脚本必须设置 |