变量与控制流
第9章:Shell 变量与控制流
Shell 脚本的核心在于变量与控制流。掌握变量的作用域、类型声明、字符串展开技巧,以及 if/for/while/case/getopts 的正确用法,是从"写脚本"进化到"写可维护脚本"的关键一跃。本章系统梳理 bash 变量体系与所有控制结构,附带完整的 getopts 参数解析实战示例。
9.1 变量基础:赋值、引号与展开
bash 变量赋值的首要规则:等号两侧绝对不能有空格。name=value 正确,name = value 会被解析为命令 name 加参数,报错。
# === 赋值规则 ===
name="Alice" # 正确:字符串含空格时用引号
count=42 # 正确:纯数字无需引号
path=/usr/local/bin # 正确:路径无特殊字符可不加引号
msg="hello world" # 正确:含空格必须加引号
# 错误示例(等号旁有空格)
# name = "Alice" ← 错误:bash 认为 name 是命令
# === 引号的区别 ===
var="world"
echo "Hello $var" # 双引号:变量被展开 → Hello world
echo 'Hello $var' # 单引号:完全字面量 → Hello $var
echo "Today: $(date)" # 双引号内 $() 仍被执行 → Today: Fri Apr 25 ...
echo 'Today: $(date)' # 单引号内 $() 不执行 → Today: $(date)
# 反引号(旧式命令替换,等价于 $(),但不可嵌套)
files=`ls /tmp`
# 推荐使用 $() 代替反引号,支持嵌套
files=$(ls /tmp)
nested=$(echo $(date +%Y)) # $() 可以嵌套
# === 变量展开 ===
echo $name # 简单展开
echo ${name} # 花括号展开(在紧跟其他字符时必须用)
echo "${name}s" # 正确:展开后接 s → Alices
echo "$names" # 错误:bash 查找变量 names(可能为空)
# 未定义变量默认为空字符串
echo "[$undefined]" # → []
# 设置严格模式:未定义变量报错退出
set -u
# echo $undefined # → bash: undefined: unbound variable
9.2 特殊变量
bash 内置了一系列只读或自动维护的特殊变量,熟练使用它们能大幅简化脚本逻辑:
#!/bin/bash
# 假设脚本以 ./myscript.sh -v foo bar 运行
echo $0 # 脚本名称:./myscript.sh
echo $1 # 第一个参数:-v
echo $2 # 第二个参数:foo
echo $9 # 第九个参数(超出时为空)
echo ${10} # 第十个及以上参数必须用花括号
echo $# # 参数个数:3(不含脚本名)
echo $@ # 所有参数列表(独立引用):"-v" "foo" "bar"
echo $* # 所有参数合并为一个字符串:"-v foo bar"
# $@ 与 $* 的关键区别(在双引号内体现)
for arg in "$@"; do echo "arg: $arg"; done # 每个参数独立
for arg in "$*"; do echo "arg: $arg"; done # 所有参数合并为一个
echo $? # 上一条命令的退出码(0 = 成功,非0 = 失败)
echo $$ # 当前 Shell 的 PID
echo $! # 最近一个后台进程的 PID
echo $_ # 上一条命令的最后一个参数
# bash 特有
echo $BASHPID # 当前进程 PID(子 Shell 中与 $$ 不同)
echo $PPID # 父进程 PID
echo $BASH_VERSION # bash 版本号
echo $LINENO # 当前行号(调试用)
echo $FUNCNAME # 当前函数名(在函数内使用)
# 退出码判断示例
ls /nonexistent 2>/dev/null
if [ $? -ne 0 ]; then
echo "命令失败"
fi
# 更简洁写法:
ls /nonexistent 2>/dev/null || echo "命令失败"
9.3 declare 类型声明
bash 默认所有变量为字符串类型。declare 内置命令可以为变量附加属性,实现类型约束:
# -i 整数属性(赋值时自动做算术)
declare -i count=10
count=count+5 # 不需要 $(()) ,直接算术赋值
echo $count # → 15
count="abc" # 非数字赋值结果为 0
echo $count # → 0
# -r 只读(readonly 的等价写法)
declare -r PI=3.14159
# PI=3.14 # → bash: PI: readonly variable
# -l 值自动转为小写
declare -l username
username="ALICE"
echo $username # → alice
# -u 值自动转为大写
declare -u STATUS
STATUS="running"
echo $STATUS # → RUNNING
# -x 导出为环境变量(export 的等价写法)
declare -x MYAPP_ENV="production"
# 子进程可以读取 MYAPP_ENV
# -p 查看变量的当前属性和值
declare -p count # → declare -i count="15"
declare -p PI # → declare -r PI="3.14159"
declare -p MYAPP_ENV # → declare -x MYAPP_ENV="production"
# -f 查看函数(不带参数列出所有函数)
declare -f myfunc # 查看函数 myfunc 的定义
# -a 索引数组,-A 关联数组(第10章详解)
declare -a fruits=("apple" "banana" "cherry")
declare -A colors=([red]="#ff0000" [blue]="#0000ff")
9.4 字符串操作速查
bash 的参数展开(Parameter Expansion)提供了强大的字符串处理能力,无需外部工具即可完成大多数字符串操作:
| 语法 | 含义 | 示例(str="Hello World") |
|---|---|---|
| ${#str} | 字符串长度 | 11 |
| ${str:6} | 从第6位到末尾 | World |
| ${str:0:5} | 从第0位取5个字符 | Hello |
| ${str#H*o} | 从头删除最短匹配 | " World" |
| ${str##H*o} | 从头删除最长匹配 | "rld" |
| ${str%o*d} | 从尾删除最短匹配 | "Hello W" |
| ${str%%o*d} | 从尾删除最长匹配 | "Hell" |
| ${str/World/Shell} | 替换第一次匹配 | Hello Shell |
| ${str//l/L} | 替换全部匹配 | HeLLo WorLd |
| ${var:-default} | var 为空则用 default(不赋值) | — |
| ${var:=default} | var 为空则赋值 default | — |
| ${var:+alt} | var 有值则用 alt,否则为空 | — |
| ${var:?msg} | var 为空则打印 msg 并退出 | — |
str="Hello World"
# 长度与子串
echo ${#str} # → 11
echo ${str:6} # → World
echo ${str:0:5} # → Hello
echo ${str: -5} # → World (负数:从末尾数,注意空格)
# 前缀/后缀删除(常用于路径处理)
file="/var/log/nginx/access.log"
echo ${file##*/} # → access.log (取文件名)
echo ${file%/*} # → /var/log/nginx (取目录名)
echo ${file##*.} # → log (取扩展名)
echo ${file%.*} # → /var/log/nginx/access (去扩展名)
# 替换
echo ${str/World/Shell} # → Hello Shell
echo ${str//l/L} # → HeLLo WorLd
echo ${str/#Hello/Hi} # → Hi World (只替换前缀)
echo ${str/%World/Shell} # → Hello Shell (只替换后缀)
# 默认值展开
unset color
echo ${color:-blue} # → blue (color 仍为空)
echo ${color:=green} # → green (color 现在 = green)
echo ${color:+found} # → found (color 有值)
echo ${color} # → green
# 强制要求变量存在
: ${REQUIRED_VAR:?"ERROR: REQUIRED_VAR must be set"}
# 如果 REQUIRED_VAR 为空,脚本退出并打印错误信息
9.5 算术运算
# $(( )) — bash 内置算术(整数)
a=10; b=3
echo $((a + b)) # → 13
echo $((a - b)) # → 7
echo $((a * b)) # → 30
echo $((a / b)) # → 3 (整数除法,截断)
echo $((a % b)) # → 1 (取模/余数)
echo $((a ** b)) # → 1000 (幂运算)
# 自增/自减
i=5
echo $((i++)) # → 5 (先取值再加)
echo $((++i)) # → 7 (先加再取值)
echo $((i--)) # → 7 (先取值再减)
((i += 10)) # → i = 17
((i -= 3)) # → i = 14
# 比较(返回 0 或 1)
echo $((5 > 3)) # → 1
echo $((5 == 3)) # → 0
# let 命令(等价于 $(()),但不能用于赋值给变量)
let sum=a+b
let "product = a * b"
echo $sum $product # → 13 30
# expr(外部命令,比 $(()) 慢,已过时)
result=$(expr $a + $b)
echo $result # → 13
# bc — 支持浮点运算
echo "scale=4; 10 / 3" | bc # → 3.3333
echo "scale=2; sqrt(2)" | bc -l # → 1.41
# -l 加载数学库(sin/cos/sqrt/log 等)
pi=$(echo "scale=10; 4*a(1)" | bc -l)
echo $pi # → 3.1415926535
9.6 条件测试:test / [ ] / [[ ]]
bash 有三种条件测试语法:test 命令、[ ](test 的别名)、[[ ]](bash 关键字,功能更强)。现代脚本推荐始终使用 [[ ]]。
# === 字符串测试 ===
str="hello"
[[ $str == "hello" ]] && echo "相等"
[[ $str != "world" ]] && echo "不等"
[[ -z $str ]] && echo "空字符串" # zero length
[[ -n $str ]] && echo "非空字符串" # non-zero length
[[ $str == h* ]] && echo "通配符匹配([[ ]] 支持)"
[[ $str =~ ^h[aeiou] ]] && echo "正则匹配([[ ]] 专有)"
# === 数字比较(必须用 -eq 等,不能用 == / 0)"
[[ -L /bin/sh ]] && echo "是符号链接"
[[ file1 -nt file2 ]] && echo "file1 比 file2 新"
[[ file1 -ot file2 ]] && echo "file1 比 file2 旧"
# === 逻辑组合 ===
# [[ ]] 内使用 && ||(推荐)
[[ -f $file && -r $file ]] && echo "文件存在且可读"
[[ $a -eq 0 || $b -eq 0 ]] && echo "有一个为零"
[[ ! -d /tmp/missing ]] && echo "目录不存在"
# [ ] 内必须用 -a -o(容易出错,不推荐)
[ -f "$file" -a -r "$file" ] && echo "OK"
陷阱: 使用
[ ]时,变量必须加双引号:[ "$var" = "hello" ]。如果$var为空或包含空格,不加引号会导致语法错误。[[ ]]内变量展开更安全,通常不需要引号(但加上也无妨)。
9.7 if / elif / else
#!/bin/bash
# 基本 if-elif-else 结构
score=75
if [[ $score -ge 90 ]]; then
echo "优秀"
elif [[ $score -ge 80 ]]; then
echo "良好"
elif [[ $score -ge 60 ]]; then
echo "及格"
else
echo "不及格"
fi
# 单行写法(简单条件)
[[ -f /etc/hosts ]] && echo "hosts 文件存在" || echo "hosts 文件不存在"
# 检查命令是否成功(不用 $?,直接判断命令)
if grep -q "root" /etc/passwd; then
echo "root 用户存在"
fi
# 多条件组合
user=$(whoami)
if [[ $user == "root" && $(id -u) -eq 0 ]]; then
echo "以 root 运行"
fi
# 嵌套 if(建议使用 elif 代替深层嵌套)
if [[ -d "$1" ]]; then
if [[ -r "$1" ]]; then
echo "目录 $1 存在且可读"
else
echo "目录 $1 存在但不可读"
fi
else
echo "路径 $1 不是目录"
fi
# 条件为算术表达式
n=7
if (( n % 2 == 0 )); then
echo "偶数"
else
echo "奇数"
fi
9.8 for 循环
# 列表循环
for fruit in apple banana cherry; do
echo "水果: $fruit"
done
# 遍历数组
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# 遍历命令输出
for user in $(cut -d: -f1 /etc/passwd); do
echo "用户: $user"
done
# 遍历文件(glob 展开,不用 ls)
for file in /var/log/*.log; do
[[ -f "$file" ]] || continue
echo "处理: $file ($(wc -l
## 9.9 while / until 循环
```bash
# while — 条件为真时循环
count=0
while [[ $count -lt 5 ]]; do
echo "count = $count"
(( count++ ))
done
# while true — 无限循环(需要 break 退出)
while true; do
read -p "输入命令(quit 退出): " cmd
[[ $cmd == "quit" ]] && break
echo "执行: $cmd"
eval "$cmd" 2>&1
done
# 逐行读取文件(最常用模式)
while IFS= read -r line; do
echo "行: $line"
done /dev/null; do
(( elapsed >= timeout )) && { echo "超时"; exit 1; }
sleep 1
(( elapsed++ ))
echo "等待服务启动... ${elapsed}s"
done
echo "服务已就绪"
9.10 case 模式匹配
#!/bin/bash
# 基本 case 结构
read -p "请输入操作(start/stop/restart/status): " action
case $action in
start)
echo "启动服务..."
;;
stop)
echo "停止服务..."
;;
restart)
echo "重启服务..."
;;
status)
echo "查看状态..."
;;
*)
echo "未知操作: $action"
exit 1
;;
esac
# 多模式匹配(用 | 分隔)
read -p "继续?(y/n): " answer
case $answer in
y|Y|yes|YES|true)
echo "用户选择继续"
;;
n|N|no|NO|false)
echo "用户选择退出"
;;
*)
echo "无效输入"
;;
esac
# 通配符模式
file="photo.jpg"
case $file in
*.jpg|*.jpeg|*.png|*.gif)
echo "图片文件"
;;
*.mp4|*.avi|*.mkv)
echo "视频文件"
;;
*.sh)
echo "Shell 脚本"
;;
[0-9]*)
echo "以数字开头"
;;
*)
echo "其他文件"
;;
esac
# ;;& — 继续匹配下一个(bash 4+)
value="hello123"
case $value in
*hello*)
echo "包含 hello"
;;& # 继续检查下面的分支
*[0-9]*)
echo "包含数字"
;;&
hello*)
echo "以 hello 开头"
;;
esac
# 输出:包含 hello / 包含数字 / 以 hello 开头(三个都匹配)
9.11 getopts 参数解析
getopts 是 bash 内置的标准参数解析器,正确处理 -f value、-fvalue、组合参数 -vf 等多种写法:
#!/bin/bash
# 完整 getopts 示例脚本
# 用法: ./deploy.sh [-v] [-e env] [-o output] [-h] [target...]
VERBOSE=0
ENV="production"
OUTPUT="/tmp/deploy.log"
usage() {
cat &2
exit 1
;;
\?)
# 未知选项
echo "错误: 未知选项 -$OPTARG" >&2
exit 1
;;
esac
done
# 移除已解析的选项,$@ 变为剩余的位置参数
shift $((OPTIND - 1))
# 此时 $@ 是非选项参数(target...)
TARGETS=("$@")
# 使用解析结果
[[ $VERBOSE -eq 1 ]] && echo "[VERBOSE] 模式已启用"
echo "环境: $ENV"
echo "日志: $OUTPUT"
echo "目标: ${TARGETS[*]:-(无)}"
# OPTIND — 下一个待处理参数的索引(getopts 自动维护)
# OPTARG — 当前选项的参数值
# ============================================================
# 嵌套 getopts(子命令模式)
# ============================================================
# 如果脚本有子命令(类似 git commit / git push),
# 通常在子命令处理函数中重置 OPTIND=1 后再次调用 getopts:
parse_commit_opts() {
local OPTIND=1 # 局部 OPTIND,不影响外层
local message=""
while getopts ":m:" opt; do
case $opt in
m) message="$OPTARG" ;;
esac
done
echo "Commit message: $message"
}
parse_commit_opts -m "fix: typo"
getopts vs getopt: bash 内置的
getopts只支持短选项(-v),不支持长选项(--verbose)。如果需要长选项,可以使用外部命令getopt(GNU 版本)或手动解析$@。大多数脚本用短选项已经足够。
9.12 read 交互输入
# 基本读取
read name
echo "你好, $name"
# -p 提示符(prompt)
read -p "请输入用户名: " username
# -s 静默输入(密码)
read -s -p "请输入密码: " password
echo # 换行(因为静默模式不会输出换行)
# -t 超时(秒)
if read -t 5 -p "5秒内按回车继续: " answer; then
echo "用户响应: $answer"
else
echo "超时,使用默认值"
fi
# -n 限制读取字符数(读到 n 个字符立即返回,无需回车)
read -n 1 -p "按任意键继续..." key
echo
# -r 原始模式(不处理反斜杠转义,读取文件时必须用)
read -r line # 反斜杠不被解释为转义符
# -a 读取到数组
read -r -a items -p "输入多个值(空格分隔): "
echo "第一个: ${items[0]}, 共 ${#items[@]} 个"
# -d 自定义分隔符
read -d ':' -r field >> $line"
done **章节总结:** 本章系统覆盖了 bash 变量体系的全部核心:从赋值规则、特殊变量、declare 类型声明,到字符串操作速查表、算术运算、条件测试,再到所有控制流(if/for/while/case)和实用工具(getopts/read)。下一章将深入函数定义、数组操作与高级字符串处理。
上一章
← 第8章:存储与磁盘
下一章
第10章:函数与数组 →