变量、流程控制与函数

系统讲解 Shell 变量的词分裂机制、单双引号差异与 ${var:-default} 默认值语法,以及 if/for/while/case 流程控制和函数定义的完整实战。

1. 变量基础与赋值

一句话总结: Shell 变量没有类型,一切皆字符串;赋值时等号两侧不能有空格,读取时要在变量名前加 $。

Shell 变量分为环境变量、普通变量和特殊变量三类。环境变量通过 export 传递给子进程,普通变量只在当前进程内可见。

# 赋值:等号两侧不能有空格
name="plume"             # ✅ 正确
# name = "plume"         # ❌ 会被解析成执行命令 name 加参数

# 读取:前面加 $,推荐用花括号隔离边界
echo "$name"
echo "欢迎 ${name} 用户"   # 花括号防止 name 与 "用户" 粘连成新变量名

# 环境变量:export 后子进程可见
export PATH="$PATH:/opt/mybin"

1.1 特殊变量速查

变量含义例子
$0脚本名./build.sh
$1~$9位置参数第 1 到第 9 个参数
$#参数个数3
$@全部参数,逐个独立"a" "b" "c"
$*全部参数,合并成串"a b c"
$$当前进程 PID12345
$?上一条命令退出码0 成功
$!最近后台任务 PID12346

2. 词分裂与引号差异

一句话总结: 不加引号的变量会被 IFS 拆分,这就是词分裂;双引号保留一个单词但展开变量,单引号全部字面量。

词分裂(Word Splitting) 是 Shell 新手最常踩的坑:未加引号的 $var 会按 IFS(默认空格、制表符、换行)拆成多个词。

dirs="one two three"
# 无引号:拆成 3 个参数
for d in $dirs; do echo "dir=$d"; done

# 加引号:当成 1 个整体
for d in "$dirs"; do echo "whole=$d"; done

2.1 三种引号的语义对比

写法行为示例输出
$var展开并词分裂one two three(3 个词)
"$var"展开但不分裂one two three(1 个词)
'$var'完全不展开字面量 $var
msg="hello world"
printf '<%s>\n' $msg      # <hello> <world>  词分裂
printf '<%s>\n' "$msg"    # <hello world>    整体
printf '%s\n' '$msg'      # $msg             字面量

# 参数数组也一样:$@ 与 "$@" 是天壤之别
set -- "a b" "c d"
printf '<%s>\n' $@        # <a> <b> <c> <d>   分裂
printf '<%s>\n' "$@"      # <a b> <c d>       保留

一句话总结: 除极少数故意要词分裂的场景,变量一律加双引号。文件路径、日志消息、用户输入尤其必须加。

3. 变量展开高级语法

一句话总结: ${var:-默认值} 系列语法在参数缺失时兜底,是脚本健壮性的第一道防线。

Bash 提供一套强大的参数展开语法,让"变量可能不存在"的情况变得可控:

# :- 有值用值,无值用默认(变量保持原状)
echo "${USER:-guest}"
# := 无值时顺带把默认值写回变量
echo "${port:=8080}"
# :? 无值时报错退出(调试利器)
echo "${required_var:?必须设置}"
# :+ 有值才替换,无值留空
echo "${DEBUG:+已开启调试}"

3.1 子串与替换

写法作用例子
${#var}字符串长度${#name} → 5
${var:2}从第 3 个字符截断${name:2}
${var:2:3}截取 3 个字符${name:2:3}
${var#pattern}去最短前缀${url#*/}
${var##pattern}去最长前缀${url##*/}
${var%pattern}去最短后缀${file%.*}
${var%%pattern}去最长后缀${file%%.*}
${var/a/b}替换首个${url/http/https}
file="archive.tar.gz"
echo "${file%.*}"      # archive.tar   去最短后缀
echo "${file%%.*}"     # archive       去最长后缀
echo "${file##*.}"     # gz            取扩展名

# 大小写转换(Bash 4+)
echo "${name^^}"       # 全大写
echo "${name,,}"       # 全小写

4. 条件判断 if 与 test

一句话总结: [[ ]] 比 [ ] 更强也更安全,数字比较用 -eq、字符串用 =、文件用 -f 等测试符。

if 的骨架是 if 命令; then ...; else ...; fi。命令的退出码 0 为真,非 0 为假。

if grep -q "error" /var/log/app.log; then
  echo "日志中存在 error"
fi

# 常见写法:if test 条件
if [ "$count" -gt 5 ]; then
  echo "count 大于 5"
elif [ "$count" -eq 5 ]; then
  echo "正好 5"
else
  echo "小于 5"
fi

4.1 测试操作符

类型操作符含义
数字-eq -ne -gt -ge -lt -le等于/不等/大于/…
字符串= != -z -n相等/不等/为空/非空
文件-f -d -x -r -w -s是文件/目录/可执行/可读/可写/非空
组合&& ||且 / 或
# 推荐使用 [[ ]],支持模式匹配与正则
[[ "$name" == p* ]] && echo "以 p 开头"
[[ "$url" =~ ^https?:// ]] && echo "合法 URL"

# 防御性写法:变量加双引号避免 test 报错
[ -n "$var" ] && echo "非空"

5. 循环 for while until

一句话总结: for 遍历列表,while 依赖条件为真继续,until 条件为真退出,各自适合不同的循环场景。

# 标准 for
for env in dev test prod; do
  echo "部署到 $env"
done

# 花括号与 seq 生成序列
for i in {1..5}; do
  echo "i=$i"
done

# C 风格 for(Bash 支持)
for ((i=0; i<10; i++)); do
  printf '%d ' "$i"
done
echo

5.1 while 与 read 逐行处理

# 逐行读取文件(核心惯用法)
while IFS= read -r line; do
  printf '处理: %s\n' "$line"
done < config.txt

# until:直到条件成立才退出
until ping -c 1 db-host >/dev/null 2>&1; do
  echo "等待数据库..."
  sleep 2
done

一句话总结: 处理文件请用 while IFS= read -r line,-r 防止反斜杠被吞,IFS= 保留行首行尾空格。

6. case 多分支

一句话总结: case 用模式匹配做分支,天然支持通配符与 | 合并,比一串 if/elif 清晰得多。

case "$1" in
  start)
    echo "启动服务"
    ;;
  stop|shutdown)
    echo "停止服务"
    ;;
  restart)
    echo "重启服务"
    ;;
  *)
    echo "用法: $0 {start|stop|restart}"
    exit 1
    ;;
esac

6.1 模式匹配要点

模式匹配内容说明
*.tar.gz以 .tar.gz 结尾通配符
dev|proddev 或 prod竖线合并
[0-9]*数字开头字符类
?单个字符任意单字符
# 判断扩展名
case "$file" in
  *.md)  echo "Markdown";;
  *.sh)  echo "Shell 脚本";;
  *)     echo "未知类型";;
esac

# case 里也能用变量展开与命令替换
case "$(date +%u)" in
  6|7) echo "周末";;
  *)   echo "工作日";;
esac

7. 函数定义与作用域

一句话总结: 函数像小脚本:local 声明局部变量,return 返回退出码,$1 在函数内指函数参数而非脚本参数。

# 两种定义等价,推荐 function 关键字风格
log() {
  local level="$1"
  shift
  echo "[$level] $*"
}

deploy() {
  local app="$1"
  if [[ -z "$app" ]]; then
    return 1        # 返回退出码 1
  fi
  echo "部署 $app"
}

log INFO "开始构建"
deploy "web" && log OK "部署完成"

7.1 函数作用域规则

规则说明
local变量只在本函数及子调用可见
全局不加 local 的变量默认全局
位置参数函数内 $1 $@ 覆盖为函数参数
return返回 0~255 的退出码
export -f把函数导出给子 shell 使用
counter=0
increment() {
  local step="${1:-1}"       # local 局部
  counter=$((counter + step)) # 修改全局
}
increment 2
echo "counter=$counter"      # 2

# 函数名冲突防护:命名带前缀
# 如 mylib_connect / mylib_close,避免覆盖系统函数

一句话总结: 函数内改全局变量要谨慎,配合 local 声明临时变量,函数间的副作用会大幅减少。

8. 总结

环节要点
变量无类型全字符串,赋值无空格,读取加 $ 与花括号
词分裂不引号被 IFS 拆分,"$@" 与 $@ 天壤之别
默认值${var:-默认} 兜底,:? 报错,#/% 截断
if 判断[[ ]] 优先,数字 -eq、字符串 =、文件 -f
循环for 遍历、while+read 逐行、until 等条件
case模式匹配分支,`
函数local 隔离、return 退出码、参数按位取

变量、流程控制与函数是 Shell 脚本的地基。引号习惯(一律加双引号)和默认值兜底(:? :-)这两件事做对了,脚本的健壮性就赢了一大半。下一步可以进入文本处理,让 awk 与 sed 接管数据加工。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 脚本性能优化实战
  2. 网络请求与诊断实战
  3. 定时任务调度实战