强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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/shPOSIX Shell(可能是 dash、bash –posix)
bash/bin/bashGNU Bourne-Again Shell(兼容 POSIX 并有扩展)
dash/bin/dash轻量 POSIX Shell(Debian/Ubuntu 默认 sh)
zsh/bin/zshZ Shell(大部分兼容 POSIX)
ksh/bin/kshKorn 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 扩展阅读

  1. POSIX Shell 规范https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
  2. man 1 shman 1 bash
  3. ShellCheckhttps://www.shellcheck.net/ — Shell 脚本静态分析工具
  4. Google Shell Style Guidehttps://google.github.io/styleguide/shellguide.html
  5. The Art of Unix Programming — Eric Raymond 著
  6. Advanced Bash-Scripting Guidehttps://tldp.org/LDP/abs/html/

12.12 本章小结

要点说明
POSIX Shell/bin/sh 是标准接口,bash 是超集
变量引用始终使用 "$var"
参数展开${var:-default} 提供默认值
条件判断使用 [ ] 而非 [[ ]]
getoptsPOSIX 标准的参数解析
trap信号捕获和资源清理
set -eu生产脚本必须设置