Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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(&gt;&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]当前函数名
RANDOM0–32767 的随机整数
SECONDSShell 启动后的秒数

shift 把位置参数左移,手写解析时常用 while (( $# &gt; 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}未设置或为空时用 defdef
${v-def}仅未设置时用 defabc
${v:=def}未设置或为空时赋值v 变为 def
${v:?msg}未设置或为空时报错退出报 msg
${v:+alt}已设置且非空时用 altalt
${#v}长度3
${v:1:2}从下标 1 取 2 个字符bc
${v#a}删最短前缀bc
${v##*b}删最长前缀c
${v%c}删最短后缀ab
${v%%b*}删最长后缀a
${v/b/X}替换第一个 baXc
${v//b/X}替换所有 baXc
${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存在且非空
-Nmtime 晚于 atime(被修改过)
f1 -nt f2f1 比 f2 新
f1 -ot f2f1 比 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 不解释反斜杠,&lt; 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 终止
130SIGINT(Ctrl-C)
141SIGPIPE(管道下游关闭)

&& 与 || 按退出码短路:

mkdir -p /tmp/x && cd /tmp/x
grep -q pattern file || printf 'not found\n'

! 取反退出码:if ! systemctl is-active --quiet myapp; then ...。

set -e 的规则比大多数人想的复杂。它在以下情况不会退出:

  1. 命令出现在 if/elif/while/until 的条件里;
  2. 命令是 &&/|| 链的一部分(除了最后一个);
  3. 命令前有 !;
  4. 管道中除最后一个命令外的失败(未开 pipefail);
  5. 函数在条件上下文中被调用时,函数体内的 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 留在生产脚本里,它会把参数(包括密码)打进日志。

关于 Bash 基础的五个误解
  1. 「[ $var = x ] 在变量为空时也能用」——空变量会让 [ 少一个参数,报 unary operator expected 或 too many arguments。用 [[ $var == x ]],或 [ "$var" = x ]。
  2. 「set -e 能抓住所有失败」——条件、&&/||、!、未开 pipefail 的管道、local x=$(cmd) 都能绕过。关键步骤要显式检查退出码。
  3. 「for f in $(ls) 是遍历文件的标准写法」——它按空白分词,文件名带空格就断;还会把 glob 结果再展开一次。用 for f in ./* 或 find -print0。
  4. 「$@ 和 $* 一样」——不加引号时都不安全;加引号后 "$@" 是多个词,"$*" 是一个词。函数传参用 "$@"。
  5. 「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`@(ab)、!(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(废弃,优先级坑)&&/`
字符串比较=/!=,&lt;/&gt; 要转义==/!=,&lt;/&gt; 直接写
模式匹配无== 右侧是 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存在且非空
-Nmtime 晚于 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 不解释反斜杠,&lt; file 让循环在当前 Shell 执行。文件最后一行没有换行时,read 仍会返回一次非零但把内容放进 line;严格处理要 while IFS= read -r line || [[ -n $line ]]。

break/continue 可以带层数:break 2 跳出两层循环。循环也可以加重定向:done &lt; input &gt; 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&lt;
-a ARRAY读进数组按 IFS 分
-d DELIM自定义分隔符-d '' 读 NUL
-eReadline 编辑交互式

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 终止
130SIGINT
141SIGPIPE

set -e 的规则比大多数人想的复杂。以下情况不会退出:

  1. 命令在 if/elif/while/until 的条件位置;
  2. 命令是 &&/|| 链的非最后一项;
  3. 命令前有 !;
  4. 管道中除最后一段外的失败(未开 pipefail);
  5. 函数在条件上下文中被调用时,函数体内整段失效;
  6. local/declare/export/readonly 带赋值时,返回状态是内建命令的 0;
  7. ((expr)) 的值为 0 时返回 1(i=0; ((i++)) 会触发退出);
  8. let 的值为 0 时返回 1;
  9. 命令替换里的失败默认不传播(除非 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 '&lt;%s&gt;\n' a b c(三个参数)与 printf '&lt;%s&gt;\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 有内容)。如果把 &lt; "$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 陷阱清单

Bash 基础的十二个坑
  1. name = alice 不是赋值:Shell 把 name 当命令,等号两边不能有空格。
  2. 未加引号的变量会分词和通配:空值、空格、星号都会出事,默认写双引号。
  3. "$@"` 与 `$* 不同:前者每个参数一个词,后者按 IFS 拼一个词。
  4. [ $var = x ] 在空值时参数错乱:加引号,或改用 [[ ]]。
  5. [ "$a" < "$b" ] 会被当成重定向:[ 里的小于号要转义,或改用 [[ ]]。
  6. [[ $a == $b ]] 右侧是 glob;比较字面量要写 [[ $a == "$b" ]]。
  7. ((i++)) 在 i=0 时返回 1,set -e 下直接退出;用 ((++i))。
  8. local x=$(cmd) 掩盖失败:local 返回 0,先声明再赋值。
  9. for f in $(ls) 会按空格断文件名;用 for f in ./* 或 find -print0。
  10. 管道里的 while read 丢变量:管道右侧是子 Shell,用输入重定向。
  11. set -e 不是万能:条件、&&、||、!、local、(( )) 都能绕过。
  12. 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 -r0.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、shift0.7.13
解释 ERR trap知道它受 set -e 例外影响0.7.14
即时自测 · 0.7

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 Scripts0.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 Scripts0.7.12–0.7.14完整(判断式、循环、执行方式)

下一站:0.8 Bash 编程进阶与工程化把脚本变成可维护的工具:函数库、数组、trap、锁、并发、测试与发布。