0.7 Bash 编程基础
变量、引号、展开、条件、循环的准确语义是什么?哪些写法在空格、通配符、空变量下会出错?test、[、[[ 有什么区别?set -euo pipefail 到底保证了什么、漏掉了什么?
写出在空变量与带空格参数下都正确的脚本;用参数展开代替 cut/sed 做字符串处理;正确使用 [[ ]] 与 (( ));解释并规避 set -e 的例外。
前几章的命令都是一次性的。真实工作里,你会把同一组命令重复执行、传参数、判断失败、记录日志——这就是脚本。Bash 的语法看起来宽松,实际上每个细节都有语义:空格决定赋值还是命令,引号决定是否分词,[ 与 [[ 是两个不同的东西。
这一章只讲基础:变量、引号、展开、条件、循环、退出码。函数、数组、trap、并发、getopts 留到 0.8。把这一章的地基打牢,后面的工程化才不会变成「复制一堆模板,出错也不知道为什么」。
所有示例用 #!/usr/bin/env bash,在 bash 5.2(Ubuntu 24.04)上验证。需要 POSIX sh 兼容时会明确标注;[[ ]]、数组、<( ) 都是 bash 扩展,dash 不支持。
0.7.1 脚本骨架与执行方式
一个可用的脚本至少包含四部分:shebang、严格模式、用法说明、入口。
#!/usr/bin/env bash
# backup-name.sh — 生成带日期的备份文件名
set -euo pipefail
usage() {
printf 'usage: %s <name> [dir]\n' "${0##*/}" >&2
}
main() {
if (( $# < 1 )); then
usage
return 2
fi
local name=$1
local dir=${2:-.}
printf '%s/%s-%s.tar.gz\n' "$dir" "$name" "$(date +%Y%m%d)"
}
main "$@"
逐项解释:
- shebang
#!/usr/bin/env bash让内核用env找到$PATH里的 bash。写成#!/bin/bash在多数 Linux 上可用,但在 macOS、NixOS、部分容器里可能不是同一个版本。用env更可移植,代价是依赖$PATH。 set -euo pipefail是三个开关:-e命令失败即退出,-u使用未定义变量报错,pipefail让管道任一命令失败即失败。它们有例外,见 0.7.8。usage()把用法写到 stderr(>&2),不污染 stdout。脚本的 stdout 应该只放数据,错误与提示放 stderr。main "$@"把参数原样传给入口函数,"$@"的引号不能省。
执行方式有四种,语义不同:
| 方式 | 是否读 shebang | 是否在当前 Shell 执行 | 典型用途 |
|---|---|---|---|
./script.sh | 是 | 否,子进程 | 正常运行 |
bash script.sh | 否,显式用 bash | 否,子进程 | 调试、忽略 shebang |
source script.sh / . script.sh | 否 | 是,当前 Shell | 加载函数/变量 |
sh script.sh | 否,显式用 sh | 否,子进程 | 测试 POSIX 兼容性 |
chmod +x script.sh 之后才能 ./script.sh。没有执行位时,./script.sh 报 Permission denied;bash script.sh 仍然能跑,因为它把文件当输入,不需要执行位。
$ cat > /tmp/hello.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail printf 'hello, %s\n' "${1:-world}" EOF $ chmod +x /tmp/hello.sh $ /tmp/hello.sh hello, world $ /tmp/hello.sh "a b" hello, a b $ bash -n /tmp/hello.sh && echo syntax-ok syntax-ok
bash -n 只做语法检查,不执行;适合放进 CI 或 pre-commit。shellcheck 更进一步,能抓出未引用变量、无效条件、常见陷阱。两个工具配合,能在运行前消掉大部分低级错误。
0.7.2 变量、作用域与只读
赋值时等号两边不能有空格:
name=alice # 赋值
name = alice # 错误:执行名为 name 的命令,参数是 = 和 alice
变量名由字母、数字、下划线组成,不能以数字开头。值可以包含空格,但要加引号:name="alice smith"。
Shell 变量与环境变量是两层:
name=alice # Shell 变量:只有当前 Shell 能看到
export name # 导出为环境变量:子进程能继承
export PATH="$HOME/bin:$PATH" # 常见写法:前置自己的目录
env | grep '^name=' # 查看已导出的变量
子进程只能看到导出的变量。脚本里 export 的变量会传给它的子进程;父 Shell 不会看到子脚本里的变量(除非 source)。
local 只能在函数里用,声明函数局部变量:
f() {
local tmp=/tmp/x
tmp=changed
}
tmp=outer
f
printf '%s\n' "$tmp" # outer
bash 的 local 是动态作用域:被调用函数能看到调用者的 local 变量。这与 C/Java 的词法作用域不同,调试时要小心。local 还可以带属性:local -r pi=3.14(只读)、local -i n=0(整数)、local -a arr(数组)。
readonly 让变量不可修改,也不能 unset:
readonly CONFIG=/etc/myapp.conf
CONFIG=/tmp/x # 报错:readonly variable
declare 还能给变量加属性:-i 整数、-a 索引数组、-A 关联数组、-r 只读、-x 导出;declare -p var 打印定义,适合调试。
常用的特殊变量:
| 变量 | 含义 |
|---|---|
$0 | 脚本名(source 时是当前 Shell 名) |
$1 … $9 | 位置参数;${10} 起要加花括号 |
$# | 位置参数个数 |
$@ | 所有位置参数,可展开成多个词 |
$* | 所有位置参数,按 IFS 连成一个词 |
$? | 上一条命令的退出码 |
$$ | 当前 Shell 的 PID |
$! | 最近一个后台进程的 PID |
$- | 当前 Shell 的选项标志 |
$_ | 上一条命令的最后一个参数 |
BASH_SOURCE[0] | 当前脚本路径 |
LINENO | 当前行号 |
FUNCNAME[0] | 当前函数名 |
RANDOM | 0–32767 的随机整数 |
SECONDS | Shell 启动后的秒数 |
shift 把位置参数左移,手写解析时常用 while (( $# > 0 )); do case $1 in ...; esac; shift; done;更完整的 getopts 见 0.8。
0.7.3 引号三态与分词
Bash 里引号不是「可选的美化」,而是控制展开的开关。三种状态:
| 写法 | 变量展开 | 分词 | 通配符 | 命令替换 | 反斜杠 |
|---|---|---|---|---|---|
$var | 是 | 是 | 是 | 是 | 转义 |
"$var" | 是 | 否 | 否 | 是 | 部分转义 |
'$var' | 否 | 否 | 否 | 否 | 字面 |
分词(word splitting)是空格问题的根源。未加引号的变量会按 IFS(默认空格、tab、换行)拆成多个词,然后每个词还会做通配符展开:
s='a b c'
printf '<%s>\n' $s # 三个参数:<a> <b> <c>
printf '<%s>\n' "$s" # 一个参数:<a b c>
f='*.log'
printf '<%s>\n' $f # 当前目录的 .log 文件(通配符展开)
printf '<%s>\n' "$f" # 字面量 <*.log>
"$@" 与 $@ 的差别在参数个数:
set -- "a b" c
printf 'quoted: <%s>\n' "$@"
# quoted: <a b>
# quoted: <c>
printf 'unquoted: <%s>\n' $@
# unquoted: <a>
# unquoted: <b>
# unquoted: <c>
"$*" 把所有参数用 IFS 的第一个字符连成一个词;"$@" 保持每个参数独立。函数和脚本里默认用 "$@",只有明确要拼字符串时才用 "$*"。
单引号里不能出现单引号,需要拼接:
msg='it'"'"'s fine' # it's fine
$'...' 是 ANSI-C 引用,支持 \n、\t、\x41,常见于 IFS=$'\n\t';$"..." 做 locale 翻译,普通脚本很少用。
两个安全习惯:
- 变量一律加双引号,除非你明确要分词或通配符。
rm -rf "$dir"不是啰嗦,是防止空变量与空格。 read用-r,否则反斜杠会被解释:while IFS= read -r line; do ... done。IFS=保留行首尾空白。
0.7.4 参数展开:不调用外部命令的字符串处理
Bash 的参数展开能在进程内完成大部分字符串操作,比 $(echo ... | cut ...) 快,也没有子 Shell 的坑。
| 写法 | 含义 | 例子(v=abc) |
|---|---|---|
${v:-def} | 未设置或为空时用 def | def |
${v-def} | 仅未设置时用 def | abc |
${v:=def} | 未设置或为空时赋值 | v 变为 def |
${v:?msg} | 未设置或为空时报错退出 | 报 msg |
${v:+alt} | 已设置且非空时用 alt | alt |
${#v} | 长度 | 3 |
${v:1:2} | 从下标 1 取 2 个字符 | bc |
${v#a} | 删最短前缀 | bc |
${v##*b} | 删最长前缀 | c |
${v%c} | 删最短后缀 | ab |
${v%%b*} | 删最长后缀 | a |
${v/b/X} | 替换第一个 b | aXc |
${v//b/X} | 替换所有 b | aXc |
${v^^} | 转大写 | ABC |
${v,,} | 转小写 | abc |
${!name} | 间接引用:取 name 的值作为变量名 |
最常用的是「取文件名/目录/扩展名」:
path=/var/log/app/app.log
printf 'file=%s\n' "${path##*/}" # app.log
printf 'dir=%s\n' "${path%/*}" # /var/log/app
printf 'base=%s\n' "${path%.*}" # /var/log/app/app
printf 'ext=%s\n' "${path##*.}" # log
name=archive.tar.gz
printf '%s\n' "${name%%.*}" # archive
printf '%s\n' "${name#*.}" # tar.gz
# 从前缀删,% 从后缀删;单个符号是最短匹配,双写是最长匹配。模式是 glob,不是正则:*、?、[...] 可用,+、| 不可用。
默认值与错误处理是脚本健壮性的关键:
: "${LOG_DIR:=/var/log/myapp}" # 未设置则赋默认值
: "${PORT:?PORT is required}" # 未设置则报错退出
printf 'log=%s port=%s\n' "$LOG_DIR" "$PORT"
${VAR:?} 是「配置缺失就早失败」的标准写法。比起让脚本用空路径继续跑、最后删错目录,早失败安全得多。
0.7.5 命令替换与算术
命令替换把子命令的 stdout 变成字符串:
now=$(date -Is)
kernel=$(uname -r)
count=$(grep -c '^ERROR' app.log || true)
要点:
- 用
$(...),不要用反引号。反引号难嵌套,转义规则也不同。 - 命令替换会删除末尾所有换行;中间换行保留。需要保留末尾换行时,用
printf或临时文件。 - 命令替换的结果如果没加引号,会分词与通配符展开。
files=$(ls)然后for f in $files是错误示范。 x=$(false)的$?是 1,不是 0;但local x=$(false)的$?是local的 0,失败被吞掉。这是set -e的著名例外。
算术用 $(( )) 或 (( )):
a=7 b=3
printf '%d %d %d\n' $((a + b)) $((a / b)) $((a % b))
((a++)) # 表达式求值,退出码取决于结果
if ((a > b)); then
printf 'a is greater\n'
fi
Bash 算术只支持整数。除法向零截断,7/3 得 2。浮点用 awk 或 bc:
awk 'BEGIN {printf "%.2f\n", 7/3}'
printf 'scale=2; 7/3\n' | bc
前导零会被当成八进制:$((08)) 报错。解析带前导零的编号时,用 10# 强制十进制:
n=08
printf '%d\n' $((10#$n)) # 8
((i++)) 在 i=0 时返回 1(旧值为 0),配合 set -e 会让脚本退出。用 ((++i)) 或 i=$((i + 1))。这是 set -e 最常见的误伤之一。
0.7.6 条件:test、[ 与 [[
test 与 [ 是命令,[[ 是关键字。三者都能做条件判断,但语义不同:
| 维度 | test / [ | [[ ]] |
|---|---|---|
| 本质 | 内建命令/外部命令 | Shell 关键字 |
| 空格要求 | [ -f x ],[ 与 ] 都是参数 | [[ -f x ]],语法层面 |
| 空变量 | [ -f $f ] 在 f 为空时参数错乱 | [[ -f $f ]] 安全 |
| 分词/通配 | 会发生 | 不会 |
| 逻辑运算 | -a/-o(已废弃) | &&/` |
| 模式匹配 | 不支持 | == 右侧是 glob |
| 正则 | 不支持 | =~,结果在 BASH_REMATCH |
文件测试:
| 测试 | 含义 |
|---|---|
-e | 存在 |
-f | 普通文件 |
-d | 目录 |
-L | 符号链接 |
-r / -w / -x | 可读/可写/可执行 |
-s | 存在且非空 |
-N | mtime 晚于 atime(被修改过) |
f1 -nt f2 | f1 比 f2 新 |
f1 -ot f2 | f1 比 f2 旧 |
字符串与数值:
[[ -z $s ]] # 空串
[[ -n $s ]] # 非空
[[ $a == "$b" ]] # 字符串相等(右侧加引号才是字面量)
[[ $a != "$b" ]]
[[ $a < $b ]] # 字典序比较
(( a < b )) # 数值比较
[[ $n -eq 0 ]] # 数值也可以用 -eq/-lt 等
[[ $a == *.log ]] 的右侧是 glob;[[ $a == "$b" ]] 的右侧加引号才是字面量。写 [[ $a == $b ]] 时,$b 会被当成模式,b='*' 就能匹配任何东西。
正则匹配:
line='ERROR 42: disk full'
if [[ $line =~ ^([A-Z]+)[[:space:]]+([0-9]+):[[:space:]]+(.*)$ ]]; then
printf 'level=%s code=%s msg=%s\n' \
"${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
fi
BASH_REMATCH[0] 是整个匹配,[1] 起是分组。正则里的空格、引号要小心:在 [[ ]] 里,=~ 右侧不要加引号,否则会被当字面量。
[ 必须与参数之间有空格:[ -f "$f" ] 正确,[-f "$f"] 会被当成命令名 [-f。[ 的 -a/-o 已废弃且有优先级陷阱,不要用;需要组合时用 [[ ]] 的 &&/||,或分开写 if。
0.7.7 分支与循环
if 判断的是命令的退出码,不是布尔值:
if grep -q '^ERROR' app.log; then
printf 'found errors\n'
elif [[ -s app.log ]]; then
printf 'log is non-empty but clean\n'
else
printf 'no log or empty\n'
fi
case 适合多分支的字符串匹配,模式是 glob:
case $1 in
start|stop|restart) systemctl "$1" myapp ;;
status) systemctl status --no-pager myapp ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown: %s\n' "$1" >&2; exit 2 ;;
esac
;; 结束一个分支;;& 继续执行下一个分支(fallthrough);;;& 继续匹配后面的模式(bash 4+)。日常脚本用 ;; 就够。
for 有两种:
# 遍历列表;默认按 IFS 分词,列表里的 glob 会展开
for f in *.log; do
[[ -e $f ]] || continue # 没有匹配时,glob 原样保留
printf '%s\n' "$f"
done
# C 风格;适合计数
for ((i = 0; i < 10; i++)); do
printf '%d\n' "$i"
done
# 遍历位置参数
for arg in "$@"; do
printf 'arg=<%s>\n' "$arg"
done
while 与 until:
while IFS= read -r line; do
printf '%s\n' "$line"
done < app.conf
until [[ -f /tmp/ready ]]; do
sleep 1
done
while read 的三件套:IFS= 保留行首尾空白,-r 不解释反斜杠,< file 让循环在当前 Shell 执行(不是管道,避免变量丢失,见 0.5)。
break 与 continue 可以带层数:break 2 跳出两层循环。select 生成编号菜单(提示符由 PS3 控制),适合交互式小工具,不适合无人值守脚本;后者用参数解析,见 0.8。
0.7.8 退出码、&&/|| 与 set -e 的例外
退出码约定:
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 一般错误 |
| 2 | 用法错误(参数不对) |
| 126 | 找到但不可执行 |
| 127 | 命令找不到 |
| 128+n | 被信号 n 终止 |
| 130 | SIGINT(Ctrl-C) |
| 141 | SIGPIPE(管道下游关闭) |
&& 与 || 按退出码短路:
mkdir -p /tmp/x && cd /tmp/x
grep -q pattern file || printf 'not found\n'
! 取反退出码:if ! systemctl is-active --quiet myapp; then ...。
set -e 的规则比大多数人想的复杂。它在以下情况不会退出:
- 命令出现在
if/elif/while/until的条件里; - 命令是
&&/||链的一部分(除了最后一个); - 命令前有
!; - 管道中除最后一个命令外的失败(未开
pipefail); - 函数在条件上下文中被调用时,函数体内的
set -e也被抑制。
还有几个「看起来该退出却没退出」的坑:
# local 的退出码是 0,命令替换的失败被吞掉
f() { local x=$(false); printf 'x=%s\n' "$x"; }
f; echo "status=$?" # status=0
# 赋值给已有变量时,退出码是命令替换的
x=$(false); echo "status=$?" # status=1
set -u 在变量未定义时报错,但 "$@" 在无参数时是合法的;$1 未定义会报错。用 ${1:-} 给默认值。
pipefail 让管道任一命令失败即失败,但无法告诉你是哪一段;需要细节时看 PIPESTATUS。set -e 与 pipefail 组合时,管道失败会立即退出,清理逻辑可能来不及跑;在清理段临时 set +e,或改用显式 if。
set -x 打印执行的命令,PS4 定制前缀:
PS4='+${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
输出会变成 +script.sh:42:main: grep ...,比默认的 + 有用得多。set +x 关闭;不要把 set -x 留在生产脚本里,它会把参数(包括密码)打进日志。
- 「
[ $var = x ]在变量为空时也能用」——空变量会让[少一个参数,报unary operator expected或too many arguments。用[[ $var == x ]],或[ "$var" = x ]。 - 「
set -e能抓住所有失败」——条件、&&/||、!、未开pipefail的管道、local x=$(cmd)都能绕过。关键步骤要显式检查退出码。 - 「
for f in $(ls)是遍历文件的标准写法」——它按空白分词,文件名带空格就断;还会把 glob 结果再展开一次。用for f in ./*或find -print0。 - 「
$@和$*一样」——不加引号时都不安全;加引号后"$@"是多个词,"$*"是一个词。函数传参用"$@"。 - 「
echo -e可以移植」——echo的行为随 Shell 与实现变化;需要转义时用printf,需要原样输出用printf '%s\n'。
0.7.9 展开顺序:一行命令在真正执行前发生了什么
Shell 执行一条简单命令前,会按固定顺序做七类展开。顺序错了,引号与通配符的行为就无法解释。
| 顺序 | 展开 | 例子 | 是否受双引号抑制 |
|---|---|---|---|
| 1 | 花括号展开 | {a,b}、{1..3} | 是 |
| 2 | 波浪号展开 | ~/x、~user/x | 是(~ 在引号内不展开) |
| 3 | 参数展开 | $var、${var:-x} | 否(双引号内仍展开) |
| 4 | 命令替换 | $(cmd)、`cmd` | 否 |
| 5 | 算术展开 | $((1+2)) | 否 |
| 6 | 分词 | 按 IFS 拆词 | 是(双引号内不拆) |
| 7 | 路径名展开 | *、?、[...] | 是 |
| 8 | 引号移除 | 去掉语法引号 | — |
记忆点是:花括号最先,分词在参数/命令/算术之后、通配符之前。所以 x='*'; echo $x 会展开成文件名,而 echo "$x" 输出 *;echo {a,b}$x 先得到 a$x b$x,再分别展开 $x。
mkdir -p /tmp/expand && cd /tmp/expand && touch a.txt b.txt
v='*'
printf '<%s>\n' $v # <a.txt> <b.txt>
printf '<%s>\n' "$v" # <*>
printf '<%s>\n' {x,y}$v # <x*> <y*>,因为花括号先展开,再对每个词做参数/通配
最后一行是理解顺序的关键:花括号展开产生两个词 x$v、y$v,之后每个词各自做参数展开与通配符展开。如果先做参数展开,结果会不同。
分词只看 IFS,默认是空格、tab、换行。换行也是分隔符,所以 echo $(cal) 会把日历的每一行拆成独立参数,而 echo "$(cal)" 保留换行:
printf 'unquoted: %s\n' $(printf 'a\nb\n')
# 两个参数:a、b
printf 'quoted: %s\n' "$(printf 'a\nb\n')"
# 一个参数:a\nb(末尾换行被剥掉)
命令替换会删除末尾所有换行,中间的保留;"$(cmd)" 不删除中间换行。需要保留末尾换行时用 printf 或临时文件。
通配符展开受 shopt 影响:
| 选项 | 效果 | 风险 |
|---|---|---|
nullglob | 无匹配时展开为空 | for f in *.log 不进入循环 |
failglob | 无匹配时报错 | 脚本直接失败,适合严格模式 |
dotglob | * 匹配以 . 开头的文件 | 会碰到 .、..?bash 排除 |
globstar | ** 递归匹配 | 大目录很慢 |
extglob | `@(a | b)、!(x)` 等 |
nocaseglob | 忽略大小写 | 受 locale 影响 |
noglob/set -f | 关闭通配符 | 处理含 * 的字面量时用 |
for f in *.log 在没有匹配时会把字面量 *.log 传给循环体(默认行为),所以循环里要 [[ -e $f ]] || continue。开 nullglob 后循环直接不执行,反而更符合直觉;开 failglob 则让脚本早失败。
IFS 可以改,但改完要恢复:
old_ifs=$IFS
IFS=:
for p in $PATH; do printf '<%s>\n' "$p"; done
IFS=$old_ifs
IFS=: 只在分词时生效,不影响 "$@";IFS= 空值让 read 保留行首尾空白,这是 while IFS= read -r line 的标准写法。修改 IFS 会影响同一 Shell 里后续所有未加引号的展开,脚本里尽量用 local IFS=: 限定在函数内。
0.7.10 参数展开全表
参数展开是 Bash 里最划算的字符串工具:不 fork、不依赖外部命令、没有子 Shell。下表按用途分组,v 是变量,pat 是 glob 模式。
默认值与错误
| 写法 | 条件 | 结果 |
|---|---|---|
${v:-def} | 未设置或为空 | def,不改变 v |
${v-def} | 仅未设置 | def |
${v:=def} | 未设置或为空 | def,并赋值给 v |
${v=def} | 仅未设置 | 同上 |
${v:?msg} | 未设置或为空 | 报错 msg,非交互 Shell 退出 |
${v?msg} | 仅未设置 | 同上 |
${v:+alt} | 已设置且非空 | alt |
${v+alt} | 已设置 | alt |
带冒号的形式把「空串」也当作未设置;不带冒号只判断「是否设置」。${v:?} 是配置校验的标准写法:变量缺失就早失败,避免脚本拿空路径继续跑。
: "${LOG_DIR:=/var/log/myapp}"
: "${PORT:?PORT is required}"
printf 'log=%s port=%s\n' "$LOG_DIR" "$PORT"
长度、切片与大小写
| 写法 | 结果(v=abcdef) |
|---|---|
| 长度 | 见下方示例 |
${v:2} | cdef |
${v:2:3} | cde |
${v: -2} | ef(负偏移前要有空格) |
${v:2:-1} | cde(长度负数表示去掉末尾) |
${v^} | Abcdef(首字母大写) |
${v^^} | ABCDEF |
${v,} | abcdef(首字母小写) |
${v,,} | abcdef |
${v^^[bc]} | aBCdef(只改匹配字符) |
v=abcdef
printf 'len=%s\n' "${#v}" # len=6
负偏移必须写成 ${v: -2},空格不能省;${v:-2} 是「默认值 -2」,语义完全不同。数组切片用 ${a[@]:1:2},从下标 1 取 2 个元素;关联数组不支持位置切片。
前缀、后缀与替换
| 写法 | 结果(p=/a/b/c.log) |
|---|---|
${p#*/} | a/b/c.log(删最短前缀) |
${p##*/} | c.log(删最长前缀) |
${p%/*} | /a/b(删最短后缀) |
${p%%/*} | 空(删最长后缀) |
${p/b/B} | /a/B/c.log(第一个) |
${p//b/B} | /a/B/c.log(所有) |
${p/#\/a/\/x} | /x/b/c.log(锚定开头) |
${p/%log/txt} | /a/b/c.txt(锚定结尾) |
模式是 glob,不是正则:*、?、[...]、extglob 可用,+、| 默认不可用。#/% 的方向容易记反:# 在键盘左边,删前缀;% 在右边,删后缀;单写最短,双写最长。
path=/var/log/app/app.log
printf '%s\n' "${path##*/}" # app.log
printf '%s\n' "${path%/*}" # /var/log/app
printf '%s\n' "${path%.*}" # /var/log/app/app
printf '%s\n' "${path##*.}" # log
间接引用、名称查询与引用转换
| 写法 | 含义 |
|---|---|
${!name} | 间接引用:把 name 的值当作变量名 |
${!prefix*} | 所有以 prefix 开头的变量名(一个词) |
${!prefix@} | 同上,每个名字一个词 |
${!a[@]} | 数组下标/关联数组键 |
${!a[*]} | 同上,一个词 |
${v@Q} | 把 v 转成可被 Shell 重新读取的引用形式 |
${v@E} | 解释反斜杠转义 |
${v@P} | 把 v 当提示符展开 |
${v@A} | 转成 declare 形式的赋值语句 |
${v@a} | 变量属性(只读、数组等) |
${v@U}/${v@u}/${v@L} | 全大写/首字母大写/全小写 |
${!prefix*} 在写「读取所有 APP_ 开头的配置」时很有用;${v@Q} 用于生成可复制的命令或日志,printf '%q' 是它的 printf 版本。
name=HOME
printf '%s\n' "${!name}" # /home/wang
printf '%q\n' "a b" # a\ b
v='a\nb'
printf '%s\n' "${v@E}" # 两行:a、b
数组展开
| 写法 | 结果 |
|---|---|
${a[@]} | 每个元素一个词 |
${a[*]} | 一个词,元素用 IFS 首字符连接 |
"${a[@]}" | 保留元素内的空格,推荐 |
"${a[*]}" | 一个字符串 |
${#a[@]} | 元素个数 |
${!a[@]} | 下标/键 |
${a[@]:1:2} | 切片 |
${a[@]@Q} | 每个元素加引用 |
遍历一律用 "${a[@]}";for x in ${a[@]} 会再分词与通配,元素含空格就断。关联数组的键可能含空格,遍历键也要加引号:for k in "${!h[@]}"。
0.7.11 test、[ 与 [[:三套条件的边界
test 与 [ 是命令,[[ 是关键字。三者的能力与坑完全不同。
| 维度 | test / [ | [[ ]] |
|---|---|---|
| 本质 | 内建/外部命令 | Shell 关键字 |
| 分词/通配 | 参数先展开,可能分词 | 不分词、不通配 |
| 空变量 | [ -f $f ] 少参数报错 | [[ -f $f ]] 安全 |
| 逻辑 | -a/-o(废弃,优先级坑) | &&/` |
| 字符串比较 | =/!=,</> 要转义 | ==/!=,</> 直接写 |
| 模式匹配 | 无 | == 右侧是 glob |
| 正则 | 无 | =~,结果在 BASH_REMATCH |
| 变量测试 | 无 | -v var、-R var(nameref) |
| Shell 选项 | 无 | -o optname |
[ 的解析规则按参数个数走:
| 参数个数 | 行为 |
|---|---|
| 0 | 假 |
| 1 | 非空为真 |
| 2 | ! expr 或一元测试(-f、-n 等) |
| 3 | 二元测试(a = b、1 -eq 2)或 ! + 二元 |
| 4 | ! + 3 参数,或 ( expr ) |
| 更多 | 未定义,容易 too many arguments |
所以 [ $var = x ] 在 var 为空时变成 [ = x ](3 个参数,被当成二元测试),报 unary operator expected;[ -f $f ] 在 f 为空时变成 [ -f ](1 个参数,测试字符串 -f 非空,结果为真)。变量一律加引号:[ -f "$f" ]、[ "$var" = x ]。
文件测试:
| 测试 | 含义 | 备注 |
|---|---|---|
-e | 存在 | 跟随符号链接 |
-f | 普通文件 | |
-d | 目录 | |
-L/-h | 符号链接 | 不跟随 |
-r/-w/-x | 当前用户可读/写/执行 | 按 access(2),受 ACL |
-s | 存在且非空 | |
-N | mtime 晚于 atime | 被修改过 |
-O/-G | 属主/属组是当前 euid/egid | |
-nt/-ot | 比另一个文件新/旧 | [[ f1 -nt f2 ]] |
-ef | 同一 inode | 比字符串比较可靠 |
字符串与数值:
[[ -z $s ]] # 空
[[ -n $s ]] # 非空
[[ $a == "$b" ]] # 字面量比较(右侧加引号)
[[ $a == *.log ]] # glob 模式
[[ $a =~ ^[0-9]+$ ]] # ERE
(( a < b )) # 数值比较
[[ $n -eq 0 ]] # 数值也可以用 -eq/-lt
[[ $a == $b ]] 的右侧不加引号时是 glob,b='*' 会匹配一切;比较用户输入时写 [[ $a == "$b" ]]。[[ $a =~ $re ]] 的右侧不要加引号,否则被当字面量;正则里的空格要转义或放进变量。
[[ ]] 的 -v 判断变量是否设置(不展开它的值),-R 判断 nameref 指向的变量是否存在:
[[ -v PATH ]] && echo "PATH is set"
[[ -v 1 ]] && echo "first positional arg is set"
[[ -R ref ]] && echo "ref points to a set variable"
(( )) 是算术命令,表达式非零返回 0,为零返回 1。((i++)) 在 i=0 时返回 1,配合 set -e 会让脚本退出;用 ((++i)) 或 i=$((i + 1))。
0.7.12 分支、循环、select 与 read
if 判断的是命令退出码,不是布尔值:
if grep -q '^ERROR' app.log; then
printf 'found errors\n'
elif [[ -s app.log ]]; then
printf 'non-empty but clean\n'
else
printf 'no log or empty\n'
fi
case 的模式是 glob,不是正则:
case $1 in
start|stop|restart) systemctl "$1" myapp ;;
status) systemctl status --no-pager myapp ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown: %s\n' "$1" >&2; exit 2 ;;
esac
分支结尾的三种写法:
| 结尾 | 行为 |
|---|---|
;; | 结束整个 case |
;& | 继续执行下一个分支的语句(fallthrough) |
;;& | 继续测试后面的模式,可能匹配多个 |
;&/;;& 是 bash 扩展,POSIX 只有 ;;。日常脚本用 ;; 就够;需要 fallthrough 时确认 shebang 是 bash。
for 有三种:
for f in *.log; do [[ -e $f ]] || continue; printf '%s\n' "$f"; done
for ((i = 0; i < 10; i++)); do printf '%d\n' "$i"; done
for arg in "$@"; do printf '<%s>\n' "$arg"; done
for f in $(ls) 是错误示范:按空白分词,文件名带空格就断,还会把 glob 再展开一次。用 for f in ./* 或 find -print0 + while read -d ''。
while/until:
while IFS= read -r line; do printf '%s\n' "$line"; done < app.conf
until [[ -f /tmp/ready ]]; do sleep 1; done
while read 的三件套:IFS= 保留行首尾空白,-r 不解释反斜杠,< file 让循环在当前 Shell 执行。文件最后一行没有换行时,read 仍会返回一次非零但把内容放进 line;严格处理要 while IFS= read -r line || [[ -n $line ]]。
break/continue 可以带层数:break 2 跳出两层循环。循环也可以加重定向:done < input > output。
select 生成编号菜单:
PS3='选择操作: '
select action in start stop status quit; do
case $action in
start|stop|status) printf 'you chose %s\n' "$action" ;;
quit) break ;;
*) printf 'invalid\n' >&2 ;;
esac
done
select 把菜单打到 stderr,提示符是 PS3,用户输入存进 REPLY,选中项存进 action。它适合交互式小工具,不适合无人值守脚本;后者用 getopts 或参数。
read 的选项:
| 选项 | 作用 | 注意 |
|---|---|---|
-r | 不解释反斜杠 | 几乎总是要加 |
-p PROMPT | 提示符 | 写到 stderr |
-n N | 读 N 个字符 | 不需要回车 |
-N N | 精确读 N 个字符 | 忽略分隔符 |
-s | 不回显 | 密码 |
-t N | 超时 N 秒 | 超时返回 >128 |
-u FD | 从 fd 读 | 配 exec 3< |
-a ARRAY | 读进数组 | 按 IFS 分 |
-d DELIM | 自定义分隔符 | -d '' 读 NUL |
-e | Readline 编辑 | 交互式 |
read 的退出码:成功 0,EOF 非零,超时大于 128。read -s 只是不回显,密码仍在变量里;不要把它写进日志。
0.7.13 位置参数、shift 与 getopts
位置参数 $1…${10}、$#、"$@"、"$*" 的语义在 0.7.2/0.7.3 已讲。这里补三个工程细节。
shift 的边界:shift 不带参数移 1 位,shift 3 移 3 位;n 大于 $# 时返回 1,位置参数保持原样。脚本里 while (( $# )); do case $1 in ...; esac; shift; done 要确保每个分支都 shift,否则死循环。
-- 终止选项:rm -- -file、grep -- -pattern。自己解析时,遇到 -- 就 shift 并把剩下的都当操作数:
while (( $# )); do
case $1 in
--) shift; break ;;
-*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;;
*) break ;;
esac
shift
done
operands=("$@")
getopts 是 POSIX 内建,逐次解析短选项:
while getopts ':s:d:k:nh' opt; do
case $opt in
s) src=$OPTARG ;;
d) dest=$OPTARG ;;
k) keep=$OPTARG ;;
n) dry_run=1 ;;
h) usage; exit 0 ;;
:) printf 'option -%s requires an argument\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'unknown option -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
| 变量/写法 | 含义 |
|---|---|
optstring 开头的 : | 静默模式:缺参数时 opt=:、OPTARG=选项名 |
OPTARG | 当前选项的参数值 |
OPTIND | 下一个待处理参数的下标,初值 1 |
OPTERR=0 | 抑制 getopts 自己的错误输出 |
\? | 未知选项;静默模式下 OPTARG 是选项字符 |
shift $((OPTIND - 1)) | 去掉已解析的选项,剩下操作数 |
getopts 在遇到第一个非选项参数时停止。所以 cmd file -n 不会解析 -n,需要把选项放前面,或用 -- 明确分隔。getopts 不支持长选项 --source;要长选项用外部 getopt 或手写 case(见 0.8)。
0.7.14 退出码、set -e 的例外与 ERR trap
退出码约定:
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 一般错误 |
| 2 | 用法错误/内建命令误用 |
| 126 | 找到但不可执行 |
| 127 | 命令找不到 |
| 128+n | 被信号 n 终止 |
| 130 | SIGINT |
| 141 | SIGPIPE |
set -e 的规则比大多数人想的复杂。以下情况不会退出:
- 命令在
if/elif/while/until的条件位置; - 命令是
&&/||链的非最后一项; - 命令前有
!; - 管道中除最后一段外的失败(未开
pipefail); - 函数在条件上下文中被调用时,函数体内整段失效;
local/declare/export/readonly带赋值时,返回状态是内建命令的 0;((expr))的值为 0 时返回 1(i=0; ((i++))会触发退出);let的值为 0 时返回 1;- 命令替换里的失败默认不传播(除非
shopt -s inherit_errexit)。
f() { local x=$(false); printf 'x=%s\n' "$x"; }
f; echo "status=$?" # status=0,local 掩盖了 false
x=$(false); echo "status=$?" # status=1
i=0; ((i++)); echo "after" # set -e 下不会到这里
set -u(nounset)在变量未定义时报错,但 "$@" 在无参数时合法,$1 未定义会报错。用 ${1:-} 给默认值。
set -o pipefail 让管道返回最右侧非零状态,但 cmd | head 的上游会收到 SIGPIPE(141),这是正常现象,不要当故障重试。set -e + pipefail 下管道失败会立即退出,清理逻辑可能来不及跑;在清理段临时 set +e,或改用显式 if。
trap '...' ERR 在命令失败时执行,但它遵循与 set -e 相同的例外:条件位置、&&/|| 左侧、!、local x=$(...) 都不会触发。set -E(errtrace)让 ERR trap 继承到函数/子 Shell;set -T(functrace)让 DEBUG/RETURN 继承。没有 -E 时,函数里的失败不会触发外层的 ERR trap。
set -Eeuo pipefail
trap 'printf "ERR at %s:%s: %s\n" "${BASH_SOURCE[0]}" "$LINENO" "$BASH_COMMAND" >&2' ERR
ERR trap 里不要 exit 再触发自身;先 trap - ERR。$BASH_COMMAND 是失败的命令文本,$LINENO 是行号,${BASH_SOURCE[0]} 是文件。调试时它比 set -x 安静,生产里可用来记录失败上下文。
set -x 打印执行的命令,PS4 定制前缀:
PS4='+${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
set +x 关闭;不要把 set -x 留在生产脚本里,它会把参数(包括密码)打进日志。set -v 打印读入的原始行,set -n 只做语法检查(不执行),bash -n script.sh 是它的命令行形式。
0.7.15 实验:展开、内建命令与静态检查
实验一:bash -x 看展开结果
cat > /tmp/expand.sh <<'EOF'
#!/usr/bin/env bash
set -u
PS4='+${BASH_SOURCE##*/}:${LINENO}: '
set -x
list='a b c'
printf '<%s>\n' $list
printf '<%s>\n' "$list"
set -- "x y" z
printf 'arg=<%s>\n' "$@"
printf 'star=<%s>\n' "$*"
EOF
bash /tmp/expand.sh
预期 trace 里能看到 printf '<%s>\n' a b c(三个参数)与 printf '<%s>\n' 'a b c'(一个参数)。set -x 输出的是展开后的命令,不是原始文本;正好用来验证分词与引号。把 PS4 加上 ${FUNCNAME[0]:-main} 能看到函数调用层级。
BASH_XTRACEFD 把 trace 写到独立 fd,避免和业务输出混在一起:
exec {fd}>/tmp/trace.log
BASH_XTRACEFD=$fd
set -x
# ...
set +x
exec {fd}>&-
实验二:内建命令不 fork
strace -f -e trace=execve -o /tmp/builtin.trace bash -c '
echo builtin
cd /tmp
[[ -f /etc/passwd ]] && echo yes
/bin/echo external
'
grep execve /tmp/builtin.trace
预期只看到 execve("/bin/echo", ...);echo、cd、[[ 是内建/关键字,不产生 execve。type -a echo 会显示 echo is a shell builtin 与 /usr/bin/echo 两个候选,command -v 只给第一个。这个区别在循环里很关键:循环里调用外部命令会 fork,调用内建不会。
实验三:shellcheck 与 bash -n
cat > /tmp/bad.sh <<'EOF'
#!/bin/sh
for f in $(ls); do
if [ $f = "a b" ]; then
echo $f
fi
done
EOF
bash -n /tmp/bad.sh && echo "syntax ok"
shellcheck /tmp/bad.sh || true
bash -n 只查语法,for f in $(ls) 是合法语法,所以它通过;shellcheck 会报 SC2045(迭代 ls)、SC2086(未引用)、SC2016? 以及 shebang 为 sh 却用了 bash 风格(这里没有)。安装:sudo apt install shellcheck。报告里把 shellcheck 的退出码当门禁:shellcheck -x script.sh 返回非零就阻断合并。
0.7.16 三个可运行脚本
脚本一:args-demo.sh——空参数、空格与 --
#!/usr/bin/env bash
# args-demo.sh — 展示 "$@"、$*、shift 与 -- 的边界
set -euo pipefail
printf 'count=%d\n' "$#"
i=0
for arg in "$@"; do
i=$((i + 1))
printf 'arg[%d]=<%s>\n' "$i" "$arg"
done
printf 'quoted-@:\n'
printf ' <%s>\n' "$@"
printf 'unquoted-*: <%s>\n' "$*"
# 跳过选项,直到 -- 或第一个非选项
while (( $# )); do
case $1 in
--) shift; break ;;
-*) printf 'option: %s\n' "$1"; shift ;;
*) break ;;
esac
done
printf 'operands=%d\n' "$#"
for arg in "$@"; do printf ' op=<%s>\n' "$arg"; done
运行 ./args-demo.sh "a b" "" c -- -x:count=5;arg[2] 是空参数;"$@" 输出五个带尖括号的行;"$*" 输出一个字符串。注意这里的 -- 仍是普通参数,因为解析在第一个非选项 a b 处已经停止;把 -- 放最前(./args-demo.sh -- "a b" "" -x)才是分隔符,操作数是后三个。"$@" 的空参数不会消失,这是它与 $*/未引用 $@ 的关键差别。
脚本二:read-lines.sh——保留空格、处理无尾换行
#!/usr/bin/env bash
# read-lines.sh — 逐行读取,统计行数/空行/最后一个无换行行
set -euo pipefail
file=${1:?usage: read-lines.sh FILE}
[[ -r $file ]] || { printf 'not readable: %s\n' "$file" >&2; exit 2; }
count=0 blank=0 last=''
while IFS= read -r line || [[ -n $line ]]; do
count=$((count + 1))
[[ -z $line ]] && blank=$((blank + 1))
last=$line
done < "$file"
printf 'lines=%d blank=%d last=%q\n' "$count" "$blank" "$last"
IFS= 保留行首尾空格;-r 不把 \ 当转义;|| [[ -n $line ]] 处理最后一行没有换行的情况(read 返回非零但 line 有内容)。如果把 < "$file" 换成 cat "$file" | while ...,循环进入子 Shell,count 在循环外仍是 0(见 0.5)。
脚本三:getopts-tool.sh——短选项、操作数与 select
#!/usr/bin/env bash
# getopts-tool.sh — 解析 -d DIR、-n、-i、-h,剩余为操作数
set -euo pipefail
usage() { printf 'usage: %s [-n] [-i] [-d DIR] [FILE...]\n' "${0##*/}" >&2; }
dir=. dry=0 interactive=0
while getopts ':d:nih' opt; do
case $opt in
d) dir=$OPTARG ;;
n) dry=1 ;;
i) interactive=1 ;;
h) usage; exit 0 ;;
:) printf 'option -%s requires an argument\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'unknown option -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[[ -d $dir ]] || { printf 'not a directory: %s\n' "$dir" >&2; exit 2; }
printf 'dir=%s dry=%d interactive=%d operands=%d\n' "$dir" "$dry" "$interactive" "$#"
if (( interactive )); then
PS3='选择操作: '
select action in list count quit; do
case $action in
list) printf '%s\n' "${@:-$dir}" ;;
count) printf 'operands=%d\n' "$#" ;;
quit) break ;;
*) printf 'invalid\n' >&2 ;;
esac
done
else
for f in "$@"; do
if (( dry )); then
printf 'would process %q\n' "$f"
else
printf 'process %q\n' "$f"
fi
done
fi
getopts 的 OPTARG 只在带参数的选项里有效;: 分支处理缺参数;\? 处理未知选项;shift $((OPTIND - 1)) 把操作数留下。select 只在 -i 时进入,菜单写 stderr,PS3 控制提示符;quit 用 break 退出。无人值守时 -i 不应出现,否则会读到 EOF 并循环。
0.7.17 陷阱清单
name = alice不是赋值:Shell 把name当命令,等号两边不能有空格。- 未加引号的变量会分词和通配:空值、空格、星号都会出事,默认写双引号。
"$@"` 与 `$*不同:前者每个参数一个词,后者按IFS拼一个词。[ $var = x ]在空值时参数错乱:加引号,或改用[[ ]]。[ "$a" < "$b" ]会被当成重定向:[里的小于号要转义,或改用[[ ]]。[[ $a == $b ]]右侧是 glob;比较字面量要写[[ $a == "$b" ]]。((i++))在i=0时返回 1,set -e下直接退出;用((++i))。local x=$(cmd)掩盖失败:local返回 0,先声明再赋值。for f in $(ls)会按空格断文件名;用for f in ./*或find -print0。- 管道里的
while read丢变量:管道右侧是子 Shell,用输入重定向。 set -e不是万能:条件、&&、||、!、local、(( ))都能绕过。echo -e不可移植:用printf;原样输出用printf '%s\n'。
0.7.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 写出可执行脚本骨架 | 含 shebang、set -euo pipefail、main "$@" | 0.7.1 |
| 解释变量作用域 | 能说出 local 与 export 的差别 | 0.7.2 |
| 判断何时必须加引号 | 能举空变量、空格、通配符三个例子 | 0.7.3 |
| 用参数展开处理路径 | 会 ##、%、//、:- | 0.7.4 |
正确使用 [[ ]] | 会文件测试、glob、=~ | 0.7.6 |
| 写循环读文件 | 会用 while IFS= read -r | 0.7.7 |
解释 set -e 的例外 | 至少说出条件与 local 两个 | 0.7.8、0.7.14 |
| 说出展开顺序 | 能解释花括号在分词之前、通配在之后 | 0.7.9 |
| 用参数展开处理字符串 | 会 ##、%、//、@Q、负偏移 | 0.7.10 |
区分 [ 与 [[ | 能说出参数个数与空变量行为 | 0.7.11 |
写循环与 select | 会用 while read、select、read 选项 | 0.7.12 |
用 getopts 解析参数 | 会 OPTARG、OPTIND、shift | 0.7.13 |
解释 ERR trap | 知道它受 set -e 例外影响 | 0.7.14 |
Q1 name = alice 会发生什么?为什么?正确的赋值怎么写?
Q2 [[ $a == $b ]] 与 [[ $a == "$b" ]] 在 b='*' 时结果有何不同?应该用哪个?
Q3 下面这段为什么在 i=0 时可能直接退出?给出两种修复。
set -e
i=0
((i++))
echo "i=$i"
Q4 local out=$(grep -c ERROR app.log) 在 grep 返回 1(无匹配)时,函数会退出吗?为什么?怎样让失败可见?
答案
A1 Shell 会把 name 当成命令名,= 与 alice 当成参数,报 name: command not found。赋值时等号两边不能有空格,正确写法是 name=alice;值里有空格时写 name="alice smith"。
A2 [[ $a == $b ]] 中右侧未加引号,被当成 glob 模式,b='*' 时匹配任何字符串;[[ $a == "$b" ]] 右侧是字面量 *,只匹配真正的星号。比较用户输入时用后者;明确要做模式匹配时才用前者。
A3 ((i++)) 返回旧值 0,算术命令的退出码是 1,set -e 触发退出。修复:((++i))(返回新值 1),或 i=$((i + 1))(赋值命令返回 0),或把 ((i++)) 放进 if/|| true 的条件上下文(不推荐掩盖问题)。
A4 不会退出。local 是内建命令,它自己的退出码是 0,命令替换的失败被 local 的返回值覆盖。修复:先声明再赋值——local out; out=$(grep -c ERROR app.log) || true,并把 || true 换成对 grep 退出码的显式处理(0 有匹配、1 无匹配、2 出错)。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| G 鸟哥 | 第 10 章 认识与学习 BASH、第 12 章 学习 Shell Scripts | 0.7.1–0.7.8 | 完整(变量、引号、条件、循环) |
| S Shotts | 第 24–29、31–34 章 脚本、流程控制、位置参数、字符串与数字 | 0.7.1–0.7.8 | 完整(if/while/case/for、参数) |
| B shell-book | 第 11–13、14 章 构建基础脚本、结构化命令、处理用户输入 | 0.7.1–0.7.8 | 完整(test、循环、位置参数) |
| K Kerrisk | 第 2.2 节 shell、第 3.2 节库函数 | 0.7.1、0.7.8 | 背景(Shell 是用户进程) |
| C CSAPP | 第 1 章 程序到进程(argv/environ) | 0.7.2 | 背景(参数与环境) |
| S Shotts | 第 7 章 “Shell 眼”看世界 | 0.7.9、0.7.10 | 完整(展开、引用、分词) |
| B shell-book | 第 12–14 章 结构化命令、循环、处理用户输入 | 0.7.11–0.7.13 | 完整(test、循环、getopts) |
| G 鸟哥 | 第 12 章 学习 Shell Scripts | 0.7.12–0.7.14 | 完整(判断式、循环、执行方式) |
下一站:0.8 Bash 编程进阶与工程化把脚本变成可维护的工具:函数库、数组、
trap、锁、并发、测试与发布。