name=value给变量赋值。等号两边不能有空格,`name = value` 会被当成命令调用,不是赋值。
⚠ 常见坑: `x = 1` 会去执行一个叫 `x` 的程序,参数是 `=` 和 `1`,Shell 直接报 `x: command not found`,卡十分钟才反应过来。
name="Lei Li"
count=42
today=$(date +%F)
Bash 速查表,100+ 命令和习语,涵盖变量、条件、循环、函数、管道、信号陷阱,真实一行命令。
name=value给变量赋值。等号两边不能有空格,`name = value` 会被当成命令调用,不是赋值。
⚠ 常见坑: `x = 1` 会去执行一个叫 `x` 的程序,参数是 `=` 和 `1`,Shell 直接报 `x: command not found`,卡十分钟才反应过来。
name="Lei Li"
count=42
today=$(date +%F)
export NAME=value把变量导出给子进程用。不写 `export`,这个变量只在当前 Shell 里看得见,启动的子进程拿不到。
export PATH="$HOME/bin:$PATH"
export EDITOR=vim
"${var}"加大括号 + 双引号的取值写法。大括号让你能在变量名后紧贴别的字符,不会被误解析。
⚠ 常见坑: 不加大括号,`$prefix_v1` 会被当成变量 `prefix_v1`,而不是 `$prefix` 拼上 `_v1`。要写成 `"${prefix}_v1"`。
echo "Hello, ${name}!"file="${base}_${date}.log"${var:-default}$var 有值且非空就用它,否则用 `default` 顶上。这种写法不会改 $var 本身。
port="${PORT:-8080}"name="${1:-world}" # default first arg${var:=default}和 `:-` 一样,但同时把 default 写回 $var。位置参数 ($1、$2…) 不能这么用。
: "${LOG_DIR:=/var/log/app}" # ensure LOG_DIR is set${var:?error message}$var 未设或为空就把消息打到 stderr 然后退出。脚本顶部当硬性前置检查特别好用。
: "${API_KEY:?need to set API_KEY in env}"${var:+alt}$var 有值就替换成 `alt`,没值就替换成空。"X 设了才加这个参数" 的场景最常用。
args="${DEBUG:+--verbose}" # add --verbose only if DEBUG is set${var//old/new}把 $var 里所有 `old` 都换成 `new`。单斜杠 (`${var/old/new}`) 只换第一处。
path="${path//\\//}" # backslashes to forward slashessafe="${name// /_}"${var#prefix} / ${var##prefix}从 $var 开头去掉最短 (#) 或最长 (##) 匹配的 `prefix`。模式是 glob,不是正则。
file=/path/to/file.tar.gz
echo "${file##*/}" # file.tar.gzecho "${file#*/}" # path/to/file.tar.gz${var%suffix} / ${var%%suffix}从 $var 末尾去掉最短 (%) 或最长 (%%) 匹配的 `suffix`,和 # / ## 镜像对称。
file=archive.tar.gz
echo "${file%.gz}" # archive.tarecho "${file%%.*}" # archive${#var}取 $var 的字符长度。
s="hello"
echo "${#s}" # 5${var:start:length}$var 子串:跳过 `start` 个字符,再取 `length` 个。两个数可以是表达式。
s="abcdefgh"
echo "${s:2:3}" # cdeecho "${s:(-3)}" # fgh (last 3)${var^^} / ${var,,}$var 全部转大写 (^^) 或全部转小写 (,,)。需要 Bash 4 以上。
⚠ 常见坑: macOS 自带的是 Bash 3.2 (协议原因),想用 4+ 要 `brew install bash`,或者退回 `tr` 实现。
s="hello"
echo "${s^^}" # HELLOecho "${s,,}" # hello (already)$RANDOMBash 内置变量,每次取值返回一个 0–32767 之间的随机整数。
echo $RANDOM
echo $((RANDOM % 100)) # 0-99
tmp="/tmp/job-$RANDOM"
readonly NAME=value声明只读常量,之后再赋值给 NAME 会报错。放真不能被覆盖的配置。
readonly VERSION=1.2.3
readonly LOG_DIR=/var/log/app
unset name把变量整个删掉。`unset x` 之后,`$x` 和 `${x:-}` 都展成空。
unset TMPDIR
unset -f my_function # also unsets a function
${var/old/new}只替换第一处 `old` 为 `new`。双斜杠 `//` 才是全部替换。
p="a.b.c"
echo "${p/./_}" # a_b.cecho "${p//./_}" # a_b_c${var/#prefix/new}带锚点的替换:`/#` 只匹配开头,`/%` 只匹配结尾。
s="test.log"
echo "${s/#test/prod}" # prod.logecho "${s/%.log/.txt}" # test.txt${!prefix*} / ${!prefix@}间接展开:列出所有以 `prefix` 开头的变量名。看配置变量时好用。
APP_HOST=a; APP_PORT=b
for v in "${!APP_@}"; do echo "$v=${!v}"; done${!ref}间接引用:若 `ref=path`,则 `${!ref}` 取出 $path 的值。相当于一层指针。
path="/usr/bin"
ref="path"
echo "${!ref}" # /usr/bindeclare -i num把变量标成整数。之后赋值按算术求值,`num=2+3` 存的是 5 而不是字符串。
declare -i n
n="3 * 4"
echo "$n" # 12
declare -r NAME=value声明只读变量。效果同 `readonly`,只是统一用 `declare` 语法。
declare -r MAX=100
MAX=200 # bash: MAX: readonly variable
${@:start:count}对位置参数切片:从第 `start` 个起取 `count` 个。`${@:2}` 丢掉第一个参数。
# args: a b c d
echo "${@:2}" # b c decho "${@:2:2}" # b clocal -n ref=namenameref:让 `ref` 成为 `name` 所指变量的别名,函数因此能改调用方的变量。需要 Bash 4.3+。
fill() { local -n out=$1; out=(x y z); }fill result
echo "${result[@]}" # x y zBASH_VERSINFO[0]当前 bash 的主版本号 (整数)。用它判断特性,不用去解析 $BASH_VERSION 字符串。
if (( BASH_VERSINFO[0] < 4 )); then echo "need bash 4+" >&2; exit 1 fi
$LINENO / $FUNCNAME$LINENO 是当前行号;$FUNCNAME[0] 是正在执行的函数名。拿来拼精确的日志/报错前缀。
die() { echo "[$FUNCNAME:${BASH_LINENO[0]}] $*" >&2; exit 1; }if [[ condition ]]; then ... fiBash 条件判断。优先用 `[[ ]]`,比老的 `[ ]` 更安全:空格、正则、glob 都不踩坑。
if [[ -f config.yaml ]]; then echo "found"; fi
if [[ "$user" == "root" ]]; then echo "boss"; fi
if [[ "$a" == "$b" ]]字符串相等比较。`[[ ]]` 里 `==` 和 `=` 都行。右边除非你想做 glob 匹配,否则一律加引号。
⚠ 常见坑: `[[ ]]` 里右边不加引号会变成 glob:`[[ $f == *.log ]]` 匹配所有 .log 结尾。要做字面相等比较一定加引号。
if [[ "$name" == "alice" ]]; then ... fi
if [[ "$file" == *.log ]]; then ... # glob match, no quotes
if [[ "$a" != "$b" ]]字符串不相等。和 `==` 对称。
if [[ "$env" != "prod" ]]; then echo "safe to delete"; fi
if [[ "$s" =~ regex ]]正则匹配。这里的正则是 Bash 的 ERE 方言,捕获组放在 BASH_REMATCH 数组里。
⚠ 常见坑: 正则那一侧不要加引号,加了引号元字符变字面字符,`[[ $s =~ "^[0-9]+$" ]]` 是匹配字面串不是数字。
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "looks like ipv4"; fi
if [[ "$s" =~ ^([a-z]+)=([0-9]+)$ ]]; then echo "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"; fiif (( a < b ))算术比较。数字比较用 `(( ))`,`<`、`>`、`==`、`!=`、`<=`、`>=` 都是 C 风格。
if (( count > 10 )); then echo "many"; fi
if (( a + b == 100 )); then echo "match"; fi
[[ "$a" -lt "$b" ]]`[[ ]]` 里的 POSIX 数字比较。运算符:-lt -le -gt -ge -eq -ne。把数字当字符串比较时用。
⚠ 常见坑: `[[ "5" > "10" ]]` 结果是真,字符串比较里 "5" 排在 "1" 后。要按数字比就用 `-gt` 或者 `(( 5 > 10 ))`。
if [[ "$age" -ge 18 ]]; then echo "adult"; fi
[[ -f file ]] / [[ -d dir ]] / [[ -e path ]]文件类型判断。-f:普通文件;-d:目录;-e:任何形式存在;-L:符号链接;-s:存在且非空。
[[ -f /etc/hosts ]] && echo "exists"
[[ -d "$HOME/.config" ]] || mkdir -p "$HOME/.config"
[[ -e /tmp/lock ]] && exit 1
[[ -z "$s" ]] / [[ -n "$s" ]]-z:字符串为空 (长度为零);-n:字符串非空。
⚠ 常见坑: 一定加引号:`[[ -z $s ]]` 在 $s 含空格时会出错;`[[ -z "$s" ]]` 才是稳的。
[[ -z "$NAME" ]] && NAME="anon"
if [[ -n "$DEBUG" ]]; then set -x; fi
cmd && echo ok || echo fail短路链。&& 只在上一条成功 (退出码 0) 时执行下一条;|| 只在失败时执行。
⚠ 常见坑: `cmd && a || b` 不等于 `if cmd then a else b`。如果 `a` 自己挂了,`b` 也会跑。要分支就老老实实写 `if`。
ping -c1 host && echo "up" || echo "down"
[[ -d build ]] || mkdir build
if ! cmd; then ... fi取反命令退出码。`!` 把成功/失败翻一下,在 `if` 和 `while` 里方便。
if ! grep -q "ok" status.log; then echo "missing ok line"; fi
while ! curl -fs http://app/health; do sleep 1; done
case "$var" in pattern) ... ;; esac按 glob 模式做多路分支。匹配字符串比一堆 elif 更清爽也更快。
case "$1" in start) start_service ;; stop) stop_service ;; *) echo "usage: $0 start|stop"; exit 1 ;; esac
[[ "$a" && "$b" ]]`[[ ]]` 里的逻辑与,写 `&&` (不是 `-a`)。`[[ ]]` 是 Bash 语法,不是 POSIX test。
if [[ -f config && -r config ]]; then echo "readable"; fi
[[ file1 -nt file2 ]]当 file1 的修改时间比 file2 新 (或 file1 存在而 file2 不存在) 时为真。`-ot` 是更旧。
if [[ src.c -nt build/src.o ]]; then echo "need rebuild" fi
[[ -r f ]] / [[ -w f ]] / [[ -x f ]]针对当前用户的权限判断:-r 可读、-w 可写、-x 可执行 (目录则是可进入)。
[[ -x "$(command -v git)" ]] && echo "git runnable"
[[ -w /etc/hosts ]] || echo "need sudo to edit hosts"
[[ -s file ]]文件存在且大小非零时为真。判断"输出到底写进去没有"最干净的写法。
curl -o out.json "$url"
[[ -s out.json ]] || { echo "empty download" >&2; exit 1; }[[ -t 1 ]]当 fd 1 (stdout) 连着终端时为真。用它决定"只在没被管道/重定向时才上色"。
if [[ -t 1 ]]; then COLOR=1; else COLOR=0; fi
[[ -v var ]]当变量已被设置 (哪怕是空) 时为真。能区分"没设"和"设成空串"。需要 Bash 4.2+。
x=""
[[ -v x ]] && echo "x is set (but empty)"
[[ -v y ]] || echo "y is unset"
case with | (alternation)case 的一个分支可以用 `|` 同时匹配多个模式,比重复写分支干净。
case "$ans" in y|Y|yes) proceed ;; n|N|no) abort ;; *) echo "?" ;; esac
shopt -s nocasematch让本 Shell 之后的 `[[ ]]` 和 `case` 匹配忽略大小写。用 `shopt -u` 关掉。
shopt -s nocasematch
[[ "HELLO" == hello ]] && echo "match"
shopt -u nocasematch
[[ "$a" < "$b" ]]`[[ ]]` 里的字典序字符串比较,按当前 locale 的排序规则。
⚠ 常见坑: `[[ ]]` 里 `<` `>` 是字符串运算符,不用转义。但在 `[ ]` (单括号) 里必须写 `\<`,否则会被当成重定向。
[[ "apple" < "banana" ]] && echo "apple first"
for x in a b c; do ... done遍历一组字面值。每轮把一个词赋给 $x。
for env in dev staging prod; do echo "deploying $env" done
for f in *.log; do ... done遍历匹配 glob 的文件名。空格也安全,文件名里的空白不会被切开。
⚠ 常见坑: glob 没匹配到文件时会展成字面串 `*.log`,循环体里 $f 就是 `*.log`。加 `shopt -s nullglob` 让无匹配时直接跳过循环。
shopt -s nullglob
for f in *.log; do gzip "$f" done
for ((i=0; i<10; i++)); do ... doneC 风格 for 循环。需要计数器本身时用这种。
for ((i=1; i<=5; i++)); do echo "attempt $i" done
for x in $(cmd); do ... done遍历命令输出的每一个词。按 $IFS 切分 (默认是空格、Tab、换行)。
⚠ 常见坑: 文件名含空格就会把这种写法撞坏。要按行处理用 `while read -r line` + 重定向,或者把 IFS 设成 $'\n' 只按换行切。
for pid in $(pgrep nginx); do echo "nginx pid: $pid" done
while read -r line; do ... done < file一行一行安全地读文件。-r 关掉反斜杠转义;末尾的 < 重定向让循环不进子 Shell。
⚠ 常见坑: 写 `cat file | while read line` 时循环跑在子 Shell 里,里面赋值的变量出循环就消失了。要用 `done < file` 这种重定向写法。
while read -r line; do echo "got: $line" done < urls.txt
while [[ condition ]]; do ... done条件为真就一直循环。重试和队列消费场景最常见。
attempts=0
while ! curl -fs http://app/health; do ((attempts++)) (( attempts > 30 )) && exit 1 sleep 1 done
until [[ condition ]]; do ... done一直循环到条件为真为止 (即条件为假时继续)。和 `while` 对称。
until ping -c1 -W1 host >/dev/null 2>&1; do sleep 1; done
break / continue`break` 立刻退出最内层循环;`continue` 跳到下一轮;`break 2` 一次退两层。
for f in *; do [[ -d "$f" ]] || continue [[ "$f" == "stop" ]] && break echo "$f" done
select x in a b c; do ... done交互式编号菜单。Bash 打选项让用户输数字,选中值赋给 $x。要 `break` 才退出循环。
select env in dev staging prod quit; do [[ "$env" == "quit" ]] && break echo "you chose $env" done
for x in {1..10}; do ... done花括号展开生成数字序列。{1..10..2} 步长 2。花括号在变量替换之前展开。
⚠ 常见坑: `{1..$n}` 不行,花括号展开发生在 $n 替换之前。要么用 `seq $n`,要么写 C 风格 for 循环。
for i in {1..5}; do echo "$i"; donefor i in {0..100..10}; do echo "$i"; donewhile IFS=, read -r a b c; do ... done < file.csv逐行解析 CSV:把 IFS 设成逗号,read 就把每行按逗号切到指定字段名。
while IFS=, read -r name age role; do echo "$name ($age) is a $role" done < team.csv
for i in $(seq 1 $n); do ... done循环次数在运行时才确定时用 `seq`,因为 `{1..$n}` 不会展开。
n=5
for i in $(seq 1 "$n"); do echo "$i"; done
while :; do ... done无限循环。`:` 是永远返回真的内置空命令。靠循环体里的 `break` 或条件退出。
while :; do read -r line || break echo "got: $line" done
for f in dir/*/; do ... done只遍历子目录。末尾的斜杠让 glob 只匹配目录 (含指向目录的符号链接)。
for d in /var/log/*/; do
echo "dir: ${d%/}"
donewhile read -r a b rest; do ... doneread 按 $IFS 把每行切成字段,最后一个变量吃掉剩下全部。适合"取前两列 + 其余整段"。
while read -r user uid rest; do echo "$user has uid $uid" done < <(getent passwd)
find ... -print0 | while IFS= read -r -d "" f安全遍历 find 结果,任何文件名都不怕。-print0 用 NUL 分隔,-d "" 读到 NUL。文件名带空格、换行都没事。
⚠ 常见坑: 普通的 `for f in $(find ...)` 遇到带空格或换行的文件名就崩。NUL 分隔的这种写法才稳。
find . -name "*.tmp" -print0 | while IFS= read -r -d "" f; do rm -- "$f" done
for x in "${arr[@]}"; do ... done逐元素遍历数组,带引号最安全。每个元素都是一个词,哪怕里面含空格。
files=("a b.txt" "c.txt")for f in "${files[@]}"; do echo "[$f]"; donecontinue N`continue N` 跳到第 N 层外围循环的下一轮。`continue 2` 直接推进外层循环。
for i in 1 2 3; do
for j in a b c; do
[[ "$j" == b ]] && continue 2
echo "$i$j"
done
donecoproc NAME { cmd; }启动协进程:cmd 在后台跑,它的 stdin/stdout 接到 ${NAME[1]} / ${NAME[0]} 这两个 fd 上。
coproc BC { bc -l; }echo "2^10" >&"${BC[1]}"read -r ans <&"${BC[0]}"echo "$ans" # 1024
name() { ...; }定义函数。`function` 关键字在 Bash 里可省,`name()` 写法更兼容 POSIX。
greet() {
echo "Hello, $1!"
}greet "Lei" # → Hello, Lei!
function name { ...; }Bash 专属的函数定义写法。和 `name() { ... }` 等价,只是多了关键字。
function deploy {
echo "deploying $1 to $2"
}$1 $2 ... $9 ${10}函数或脚本里的位置参数。$1 是第一个,$0 是脚本或函数名。
⚠ 常见坑: 两位数参数要加大括号:`$10` 会被读成 `$1` 后面跟字面 `0`。第 10 个参数要写 `${10}`。
copy() {
cp "$1" "$2"
}"$@" vs "$*""$@" 把每个参数当独立的词传 (一般你要的就是这个);"$*" 用 $IFS 的第一个字符把所有参数粘成一个串。
⚠ 常见坑: 不加引号时两个行为差不多,而且都会被空格撞坏。转发参数一律用带引号的 `"$@"`。
run_with_logging() {
echo "[$(date)] running: $*"
"$@"
}local var=value声明函数局部变量。不写 `local`,函数里的每个赋值都会污染全局作用域。
greet() {
local who="$1"
echo "Hello, $who"
}return N设置函数退出码。0 = 成功,1-255 = 失败。这不是"返回值"的概念,想返回数据要打到 stdout,调用方用 $(fn) 接。
is_valid() {
[[ "$1" =~ ^[0-9]+$ ]] && return 0 || return 1
}if is_valid "$x"; then echo ok; fi
result=$(my_function arg1 arg2)捕获函数输出。函数返回数据的方式和命令一样:打到 stdout,调用方用 $(...) 接住。
get_timestamp() { date +%s; }now=$(get_timestamp)
echo "now is $now"
declare -f name打印函数定义。`declare -F` 只列名字。调试时确认到底加载了哪个版本的函数。
declare -f greet
declare -F | head # list all defined functions
local -a arr / local -A map声明函数局部数组 (-a 索引、-A 关联),避免数组状态泄到全局。
parse() {
local -a parts
IFS=: read -ra parts <<< "$1"
echo "${#parts[@]} fields"
}shift / shift N丢掉第一个位置参数 (或前 N 个)。`shift` 后 $1 变成原来的 $2,$# 减一。
cmd="$1"; shift
echo "running $cmd with: $*"
set -- a b c把位置参数 ($1、$2…) 重置成给定值。`set --` 后面不带东西就全清空。
set -- one two three
echo "$1 $3" # one three
set -- # now $# is 0
local var; var=$(cmd)先声明 local,再单独赋值。合写成 `local var=$(cmd)` 会让命令退出码被 local 的成功盖住。
⚠ 常见坑: `local x=$(false)` 永远成功,因为 `local` 自己返回 0,里面的失败被吞掉。拆成两行才能让 `set -e` 抓到。
build() {
local out
out=$(make 2>&1) || { echo "$out" >&2; return 1; }
}getopts inside a functiongetopts 在函数里能用,但开头要把 `OPTIND=1` 重置,第二次调用才会从头解析。
greet() {
local OPTIND opt loud=0
while getopts "l" opt; do
case $opt in l) loud=1 ;; esac
done
shift $((OPTIND-1))
(( loud )) && echo "HELLO $1" || echo "hi $1"
}unset -f name删除函数定义。必须加 `-f`,因为同名的变量和函数可以并存。
greet() { echo hi; }unset -f greet
greet # bash: greet: command not found
cmd > file把 stdout 重定向到文件,覆盖写。要追加用 `>>`。
ls > files.txt
date >> log.txt # append
cmd 2> file把 stderr (fd 2) 重定向到文件。stdout 仍然走终端。
make 2> build-errors.log
cmd 2>/dev/null # discard errors
cmd > file 2>&1stdout 和 stderr 都重定向到同一个文件。顺序很关键:先把 stdout 改到文件,再让 stderr 跟着 stdout 现在的去处走。
⚠ 常见坑: `cmd 2>&1 > file` 反了,先把 stderr 接到终端,再把 stdout 改到文件,结果错误还在屏幕上。`2>&1` 必须放在 `> file` 后面。
build.sh > build.log 2>&1
cmd &> file # bash shortcut for the same thing
cmd &> fileBash 简写:stdout 和 stderr 都丢到文件。等价于 `> file 2>&1`。
⚠ 常见坑: `&>` 是 Bash 特有的,POSIX sh 不认。要可移植脚本就写 `> file 2>&1`。
./test.sh &> test.log
cmd < file把 file 当 stdin 喂给 cmd。和 `cat file | cmd` 等价,但少一个 cat 进程。
mysql -u root db < schema.sql
while read -r line; do echo "$line"; done < input.txt
cmd <<EOF
...text...
EOFHere-document:把一段字面多行文本当 stdin 喂进去。终止符 (这里是 EOF) 单独占一行,只要在文档里独一无二即可。
cat <<EOF > config.yaml name: app port: 8080 EOF
cmd <<'EOF'
...
EOF加引号的 heredoc:终止符加单引号会关闭变量和命令替换,文本完全按字面来。
cat <<'EOF' This $will not expand and `nor will this`. EOF
cmd <<<"$var"Here-string:把一个字符串当 stdin 喂进去。比 `echo "$var" | cmd` 写法更干净。
grep -c "ERROR" <<< "$log"
tr a-z A-Z <<< "hello"
exec > file从这一行起重定向整个脚本自己的 stdout。之后脚本里所有命令的输出都进 file,不再到终端。
#!/bin/bash exec > script.log 2>&1 echo "this goes to script.log"
# tee pattern: exec > >(tee script.log) 2>&1
cmd 2>/dev/null把 stderr 扔掉。命令会喷已知噪声时常用,比如 `find` 抱怨没权限的目录。
⚠ 常见坑: 把错误藏起来也会藏掉真 bug。少用,优先过滤特定信号:`cmd 2> >(grep -v "已知噪声" >&2)`。
find / -name foo 2>/dev/null
which gcc &>/dev/null && echo "have gcc"
cmd >> file把 stdout 追加到文件而不是覆盖。文件不存在会被创建。
echo "$(date): started" >> run.log
cat extra.txt >> combined.txt
exec 3< file把文件开到自定义 fd (这里是 3)。之后 `read -u 3` 从它读,`exec 3<&-` 关闭它。
exec 3< data.txt
read -r first <&3
read -r second <&3
exec 3<&-
cmd 2>&1 | tee log在管道前把 stderr 并进 stdout,这样 tee 把两路都写进日志并显示到屏幕。
⚠ 常见坑: `cmd | tee log 2>&1` 是错的,只有 stdout 进 tee,stderr 还是直接到终端。`2>&1` 要放在 `|` 前面。
make 2>&1 | tee build.log
cmd <<-EOF`<<-` 的 heredoc 会去掉每行开头的 Tab (只去 Tab,不去空格),让你能在函数里缩进 heredoc 内容。
gen() {
cat <<-EOF
line one
line two
EOF
}> file (truncate)只有重定向、没有命令,会把文件清空 (不存在则建空文件)。显式写法是 `: > file`。
> app.log # empty the log
: > /tmp/marker # create/empty a marker file
cmd 3>&1 1>&2 2>&3借临时 fd 3 把 stdout 和 stderr 对调。之后 stdout 走原来 stderr 的去处,反之亦然。
# send only stdout (not stderr) down a pipe to grep: noisy_cmd 3>&1 1>&2 2>&3 | grep -v warning
read var < file只把文件第一行读进变量。比 `$(head -1 file)` 便宜,不开子 Shell 也不起额外进程。
read -r version < VERSION
echo "building $version"
cmd1 | cmd2管道:cmd1 的 stdout 变成 cmd2 的 stdin。每个命令各自起子进程并行跑。
ps aux | grep nginx
cat access.log | awk '{print $7}' | sort | uniq -c | sort -rncmd1 |& cmd2把 stdout 和 stderr 都喂给 cmd2。Bash 4+ 的简写,等价 `cmd1 2>&1 | cmd2`。
make |& tee build.log
cmd &后台运行 cmd,立即返回,PID 放在 $! 里。`wait` 阻塞直到所有后台任务结束。
long_task &
pid=$!
# do other work
wait $pid
$(cmd)命令替换:用 cmd 的 stdout 替换。比反引号好,因为 $(...) 能干净嵌套。
⚠ 常见坑: 末尾换行会被裁掉。要保留末尾换行,加个哨兵:`out=$(cmd; echo END)`,再自己处理。
today=$(date +%F)
count=$(grep -c ERROR app.log)
files=$(find . -name "*.py" | head -5)
`cmd`旧式命令替换。能用但嵌套很难看,引号处理也别扭。建议改用 `$(cmd)`。
date=`date +%F` # works but legacy
echo "Today: $(date +%F)" # better
<(cmd)进程替换:把 cmd 的输出包装成一个临时文件名。让那些只接受文件参数的工具能吃流。
⚠ 常见坑: 进程替换是 Bash 语法,POSIX sh 不认。`<(...)` 在 dash / busybox sh 下会语法报错。
diff <(sort a.txt) <(sort b.txt)
comm -12 <(sort users-old) <(sort users-new)
set -o pipefail让管道中任何一条命令失败时整条管道都报失败。不开这个,只看最后一条命令的退出码。
set -euo pipefail
curl -fsS bad-url | jq . # without pipefail, jq exit 0 hides curl failure
cmd | tee file管道一份写到文件,一份继续打到 stdout。加 `-a` 改成追加。
make 2>&1 | tee build.log
echo "192.168.1.1 host" | sudo tee -a /etc/hosts
( cmd1; cmd2 )在子 Shell 里执行一组命令。里面赋值的变量出去不可见,给 `cd`、`set -x`、环境变量加作用域时好用。
( cd /tmp && tar xzf "$tarball" ) # the parent shell stays where it was
{ cmd1; cmd2; }在当前 Shell 里执行一组命令 (不开子 Shell)。组里的变量出来还在。注意花括号要带空格,末尾要分号。
{ echo "header"; cat file; echo "footer"; } > out.txtwait / wait -n阻塞等待后台任务。`wait` 等全部;`wait -n` 任意一个结束就返回 (bash 4.3+)。
job1 & job2 & job3 &
wait # block until all three done
echo "all finished"
cmd1 & cmd2 & wait并行跑两条命令再等它们都完。不依赖外部工具的"展开再汇合"写法。
download a.url & download b.url &
wait
echo "both downloaded"
PIPESTATUS array管道结束后,${PIPESTATUS[@]} 按顺序存每一段命令的退出码。不开 pipefail 也能查某一段。
curl -fsS "$url" | gzip > out.gz
echo "curl=${PIPESTATUS[0]} gzip=${PIPESTATUS[1]}"cmd | xargs -P4 -n1 worker把输入分发给最多 4 个并行的 `worker`,每个吃一个参数。从一个列表做有上限的并行。
cat urls.txt | xargs -P4 -n1 curl -sO
cmd | xargs -0让 xargs 读 NUL 分隔的输入。配 `find -print0` 安全处理带空格或换行的文件名。
find . -name "*.bak" -print0 | xargs -0 rm -v
diff <(cmd1) <(cmd2)直接比较两条命令的输出,不用临时文件。进程替换把每一侧包装成伪文件。
diff <(curl -s host1/v) <(curl -s host2/v)
diff <(echo "$a") <(echo "$b")
cmd | read -r var (caveat)从管道里 read 会让右侧跑在子 Shell 里,读完变量是空的。改用 here-string 或进程替换。
⚠ 常见坑: `echo "$x" | read y` 之后父 Shell 里 $y 还是空。要写 `read y <<< "$x"` 或 `read y < <(cmd)`。
read -r first <<< "$line"
read -r n < <(wc -l < file)
echo -n "$var" | wc -c取 $var 的字节长度 (单字节字符集下 `${#var}` 直接返回字符数)。
⚠ 常见坑: `${#var}` 数字符不数字节,一个汉字算 1 字符但 UTF-8 里占 3 字节。`wc -c` 数字节,`wc -m` 按当前 locale 数字符。
s="hello"
echo "${#s} chars"echo -n "$s" | wc -c
IFS=":" read -ra parts <<< "$path"按分隔符把字符串切到数组里。`-a parts` 把结果读进索引数组 parts。
IFS=":" read -ra dirs <<< "$PATH"
for d in "${dirs[@]}"; do echo "$d"; doneprintf -v var "format" args直接把格式化结果写进变量,不开子 Shell,没命令替换的开销。
printf -v fname "report-%s-%02d.csv" "$dept" "$month"
echo "$fname" # report-sales-05.csv
echo "${s// /_}"把所有空格换成下划线。单斜杠只替换第一个匹配。
name="hello world"
safe="${name// /_}"echo "$safe" # hello_world
[[ "$s" == prefix* ]]用 `[[ ]]` 里的 glob 测试前缀。后缀是 `*suffix`。
if [[ "$file" == /etc/* ]]; then echo "system config"; fi
if [[ "$url" == https://* ]]; then ...; fi
tr "a-z" "A-Z" <<< "$s"可移植地把字符串转大写 (macOS 自带 Bash 3.2 也能跑)。tr 做字符级转换。
upper=$(tr "a-z" "A-Z" <<< "$name")
echo "$upper"
printf "%s\n" "$var"打印任意字符串比 echo 安全:`printf` 不会把开头的 `-` 当选项,跨 Shell 行为也一致。
⚠ 常见坑: `echo "$x"` 遇到 `-n`、`-e` 这种值会当成选项,而且各 Shell 行为不一。`printf "%s\n"` 才稳定。
printf "%s\n" "-n looks like a flag"
printf "%s=%s\n" key value
printf "%q" "$s"把字符串打成可安全再用作输入的 Shell 转义形式。记录真实命令或生成脚本时好用。
printf "run: %q %q\n" "$cmd" "$arg with space"
trimmed="${s#"${s%%[![:space:]]*}"}"纯参数展开去掉开头空白 (不开子 Shell)。配一个去尾部空白的模式就能两头都修。
s=" hi "
lead="${s#"${s%%[![:space:]]*}"}"echo "[$lead]" # [hi ]
IFS= read -rd "" var < file把整个文件 (含末尾换行) 读进一个变量。`-d ""` 读到 NUL 为止,文本文件里没有 NUL 所以读全。
IFS= read -rd "" content < notes.md
echo "${#content} bytes read"[[ "$s" =~ ^[0-9]+$ ]]用正则判断字符串是不是纯数字 (非负整数)。前面加 `-?` 就允许负号。
is_int() { [[ "$1" =~ ^-?[0-9]+$ ]]; }is_int "42" && echo ok
is_int "4a" || echo "not an int"
mapfile -t arr < <(grep ...)把命令输出按行切进数组,行内空格保留。比会按词切的 `arr=($(cmd))` 安全。
mapfile -t hits < <(grep -n TODO src.py)
echo "${#hits[@]} TODOs"set -e任一命令返回非零就立即退出。"快速失败"开关,配合 -u 和 -o pipefail 才安全。
⚠ 常见坑: set -e 边界情况一堆。`cmd1 && cmd2` 里 cmd1 失败不会触发 -e。预期会失败的命令写 `cmd || true` 兜底。
set -euo pipefail
IFS=$'\n\t' # the "unofficial bash strict mode" header
set -u用到未定义变量就报错退出。能立刻抓住 `$ptah` 这种拼写错误,不像默认那样静默展成空串。
⚠ 常见坑: 开了 -u 后,变量没设直接 `${var}` 就会挂。"我知道可能没设" 写 `${var:-}`,"必须设" 写 `${var:?消息}`。
set -u
echo "$undefined" # bash: undefined: unbound variable
set -x执行每条命令前都把展开后的命令打出来。调试脚本最快的招。
set -x
name="lei"
echo "hello $name"
# stderr: + name=lei # + echo 'hello lei'
set -o pipefail管道的退出码取最右边失败的那条命令,不是简单取最后一条。配 -e 一起用。
set -o pipefail
curl bad-url | jq . # now fails properly when curl fails
trap "cleanup" EXIT脚本退出时执行 `cleanup`,不管是正常退、出错退还是被信号杀。临时文件清理的标配。
tmp=$(mktemp)
trap "rm -f \"$tmp\"" EXIT
# tmp gets removed even if the script crashes
trap "..." INT TERM拦 Ctrl-C (INT) 和 `kill` (TERM)。打个消息、清理一下,再优雅退出。
trap "echo '^C — bye'; exit 130" INT
trap "stop_server; exit" TERM
exit N以退出码 N 结束脚本。0 = 成功。约定 1 = 一般错误,2 = 用法不对,127 = 命令找不到。
exit 0
exit 1 # generic failure
[[ $# -eq 0 ]] && { echo "need an arg"; exit 2; }$?最后一条命令的退出码。要用就立刻存到变量里,之后每条命令 (包括 `[ ]`) 都会覆盖它。
⚠ 常见坑: `cmd; if [[ $? -eq 0 ]]; then echo "ok"; fi` 写复杂了,直接 `if cmd; then echo "ok"; fi` 就行。
ping -c1 host >/dev/null
rc=$?
echo "ping returned $rc"
time cmd打印 cmd 耗时 (real / user / sys)。Bash 内置,对整条管道也生效。
time make build
time (find /usr | wc -l)
trap "cleanup" ERR在 `set -e` 下任何命令失败 (非零) 时触发处理函数。退出前记录出错那一行很有用。
set -e
trap "echo \"failed at line $LINENO\" >&2" ERR
false # triggers the trap
trap - SIGNAL把某信号的 trap 重置回 Shell 默认行为。`trap - EXIT` 取消之前装的退出处理。
trap cleanup EXIT
# ... later, when no longer needed:
trap - EXIT
set +e / set +x前缀 `+` 是关掉某个开关 (`-` 是打开)。给易失败的片段包一层:`set +e; risky; set -e`。
set +e
might_fail # do not abort on failure here
rc=$?
set -e
exec cmd用 cmd 替换当前 Shell 进程,不起新进程也不返回。脚本到此结束,cmd 接管原 PID。
#!/usr/bin/env bash # set up env, then hand off: export APP_ENV=prod exec ./server "$@"
exit code 124 / 130 / 137常见由信号衍生的退出码:124 = 被 `timeout` 杀,130 = Ctrl-C (128+SIGINT 2),137 = SIGKILL (128+9,如 OOM)。
timeout 5 long_task
case $? in 0) echo ok ;; 124) echo "timed out" ;; esac
timeout 10s cmd运行 cmd,超过设定时长就杀掉,超时退出码 124。加 `-k 5s` 在它不理首个信号时补发 SIGKILL。
timeout 30 curl -s "$url" || echo "slow endpoint"
timeout -k 5s 60s ./batch.sh
set -E (errtrace)让 ERR trap 继承进函数、命令替换和子 Shell。不开它,函数里触发的错误不会走 `trap ... ERR`。
set -Eeuo pipefail
trap "echo err >&2" ERR
f() { false; }f # now the trap fires
#!/usr/bin/env bashShebang。`env bash` 在 $PATH 里找 bash,不死写 /bin/bash (macOS 的 /bin/bash 是上古 3.2 版本)。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
while getopts ":hf:v" opt; do ... donePOSIX 风格的选项解析。字母后跟 `:` 表示要带参数 (放 $OPTARG);开头加 `:` 是静默错误模式,自己控制。
while getopts ":hf:v" opt; do
case $opt in
h) echo "usage: ..."; exit 0 ;;
f) file="$OPTARG" ;;
v) verbose=1 ;;
\?) echo "bad flag: -$OPTARG" >&2; exit 2 ;;
esac
doneshift $((OPTIND - 1)) # consume parsed flags
while [[ $# -gt 0 ]]; do case "$1" in ... esac; done手写参数解析,支持长选项 (--flag value、--flag=value)。比 getopts 灵活,但代码也多。
while [[ $# -gt 0 ]]; do
case "$1" in
--port) port="$2"; shift 2 ;;
--port=*) port="${1#*=}"; shift ;;
--) shift; break ;;
*) args+=("$1"); shift ;;
esac
donetmp=$(mktemp); trap "rm -f \"$tmp\"" EXIT创建一个保证唯一的临时文件,出口处清理掉。要临时目录用 `mktemp -d`。
⚠ 常见坑: BSD mktemp (macOS) 要模板:`mktemp /tmp/myapp.XXXXXX`。GNU mktemp 不要也行。可移植写法是 `mktemp -t prefix`。
tmp=$(mktemp)
trap "rm -f \"$tmp\"" EXIT
curl -o "$tmp" https://example.com/data.json
echo -e "\033[31mred\033[0m"用 ANSI 转义打彩色文本。\033[31m 是红、\033[0m 是重置。仅在 stdout 是 TTY 时才开 (用 `[[ -t 1 ]]` 判断)。
RED=$'\033[31m'
RESET=$'\033[0m'
[[ -t 1 ]] || RED='' RESET='' # disable colors when piped
echo "${RED}ERROR${RESET}: bad config"progress() { printf "\r[%-50s] %d%%" "$bar" "$pct"; }简单进度条。\r 把光标回到行首,每次更新覆盖上一次的输出。
for i in {1..100}; do
pct=$i
bar=$(printf "%.0s#" $(seq 1 $((pct/2))))
printf "\r[%-50s] %3d%%" "$bar" "$pct"
sleep 0.05
done
echoecho "$0"脚本自己的调用名。打用法信息常用:`echo "usage: $0 [options]"`。
if [[ $# -lt 2 ]]; then echo "usage: $0 <src> <dst>" >&2 exit 2 fi
BASH_SOURCE / readlink -f解析脚本自己所在目录,即使通过符号链接调用也准。"找到同目录兄弟文件"的标准写法。
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"source "$SCRIPT_DIR/lib.sh"
command -v cmd >/dev/null判断命令是否存在但不执行它。找到返回 0。比 `which` 更可移植。
if ! command -v jq >/dev/null; then echo "need jq, please install" >&2 exit 1 fi
read -p "Continue? [y/N] " ans提示用户输入一行到 $ans。-p 设提示语;-s 隐藏输入 (输密码时用)。
read -p "Continue? [y/N] " ans
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || exit 0log() { echo "[$(date +%H:%M:%S)] $*" >&2; }便宜的带时间戳日志函数,写到 stderr (不污染管道里下游的输入)。
log() { echo "[$(date +%H:%M:%S)] $*" >&2; }log "starting deploy"
log "done"
: "${VAR:?must be set}"脚本开头断言必需变量。`:` 是空命令,这行只为触发 `:?` 检查。
: "${DEPLOY_ENV:?set DEPLOY_ENV to dev|prod}": "${TOKEN:?missing TOKEN}"cd "$(dirname "$0")"切到脚本自己所在目录,这样里面的相对路径不管从哪调用都对。
⚠ 常见坑: 通过符号链接调用时会出错,`$0` 是链接路径。要防符号链接用 `${BASH_SOURCE[0]}` + `readlink -f`。
cd "$(dirname "$0")" || exit 1
source ./config.sh
usage() { cat <<EOF ... EOF; }用 heredoc 写的帮助函数。`-h` 或参数错时打印它再 `exit`。
usage() {
cat <<EOF
usage: $0 [-v] <src> <dst>
-v verbose
EOF
}[[ "$1" == "-h" ]] && { usage; exit 0; }shopt -s globstar开启 `**` 递归跨目录匹配。开了之后 `**/*.js` 能找到任意层级的 .js。需要 Bash 4+。
shopt -s globstar
for f in src/**/*.test.ts; do echo "$f"; done
shopt -s extglob开启扩展 glob:`?(x)` `*(x)` `+(x)` `@(a|b)` `!(x)`,让 glob 支持类似正则的或与取反。
shopt -s extglob
rm -- !(*.keep) # delete everything except *.keep
echo @(jpg|png|gif) # any of these
trap cleanup EXIT INT TERM一条 trap 同时覆盖正常退出、Ctrl-C 和 kill。建临时资源的脚本最常用的清理写法。
workdir=$(mktemp -d)
cleanup() { rm -rf "$workdir"; }trap cleanup EXIT INT TERM
IFS=$'\n\t'"bash 严格模式"的一部分:把 IFS 只设成换行+Tab,这样未加引号的展开不会按空格切。配 `set -euo pipefail`。
set -euo pipefail
IFS=$'\n\t'
flock -n lockfile cmd只有抢到独占锁才运行 cmd,防止同一个 cron 任务的两份实例叠跑。`-n` 抢不到立刻失败而不是等。
flock -n /tmp/job.lock ./nightly.sh || echo "already running"
read -rsp "Password: " pw; echo读密码且不回显:-s 关回显,-p 显示提示。末尾补个 `echo` 补换行。
read -rsp "Password: " pw
echo
curl -u "user:$pw" "$url"
declare -a arr声明索引数组 (数字键)。Bash 数组下标从 0 开始。
declare -a fruits=(apple banana cherry)
echo "${fruits[0]}" # appledeclare -A map声明关联数组 (字符串键)。需要 Bash 4 及以上。macOS 自带 Bash 3.2 不支持。
⚠ 常见坑: 必须先 `declare -A map` 再用。不然 `map["key"]="v"` 会把 "key" 当 0,变成只有一个元素的索引数组,默默出错。
declare -A color
color[apple]=red
color[grape]=purple
echo "${color[apple]}" # redarr=(a b c)字面初始化索引数组。空格分隔,括号包起来。
envs=(dev staging prod)
echo "${envs[1]}" # stagingarr+=(new1 new2)往索引数组末尾追加元素。
files=()
for f in *.log; do files+=("$f"); doneecho "${#files[@]} log files""${arr[@]}"展开成所有元素,每个一个独立的带引号词。遍历就用这个。
⚠ 常见坑: `"${arr[*]}"` 用 $IFS 把所有元素粘成一个串。99% 的场景你要的是 `"${arr[@]}"`。
for f in "${files[@]}"; do
echo "$f"
done"${#arr[@]}"数组元素个数。
echo "${#files[@]} files matched"if (( ${#errors[@]} > 0 )); then echo "had errors"; fi"${!arr[@]}"展开成所有键 (索引数组就是下标,关联数组就是字符串键)。
for i in "${!arr[@]}"; do
echo "$i -> ${arr[$i]}"
donefor k in "${!color[@]}"; do
echo "$k is ${color[$k]}"
doneunset "arr[2]"删一个元素。删完下标不重排,`${#arr[@]}` 数量变了,但 `${arr[3]}` 还在原位。
arr=(a b c d)
unset "arr[1]"
echo "${arr[@]}" # a c decho "${!arr[@]}" # 0 2 3mapfile -t arr < file把文件每行读到数组,一行一个元素。`-t` 去掉每行末尾的换行。
mapfile -t lines < urls.txt
echo "read ${#lines[@]} urls""${arr[@]:s:n}"数组切片:从下标 `s` 起取 `n` 个。`"${arr[@]:1}"` 丢掉第一个元素。
a=(a b c d e)
echo "${a[@]:1:2}" # b cecho "${a[@]: -2}" # d e (note the space)arr=("${arr[@]}" extra)重建数组并加元素,也是给稀疏数组重排下标 (填上 `unset` 留下的空洞) 的办法。
a=(x y z)
unset "a[1]"
a=("${a[@]}") # now indices are 0,1 againecho "${!a[@]}" # 0 1"${arr[*]}" with custom IFS把数组元素拼成一个字符串。先把 IFS 设成分隔符,`[*]` 用 IFS 第一个字符粘起来。
parts=(2026 05 30)
IFS=-; echo "${parts[*]}"; unset IFS # 2026-05-30declare -p arr以可复用的 `declare` 形式打印数组 (或任意变量)。调试时看清数组到底装了什么最快。
a=(one "two words" three)
declare -p a # declare -a a=([0]="one" [1]="two words" [2]="three")
map[key]+=value就地往关联数组的值上追加。常用于累加计数或按键拼接列表。
declare -A seen
for w in a b a c a; do seen[$w]=$(( ${seen[$w]:-0} + 1 )); doneecho "${seen[a]}" # 3[[ -v map[key] ]]判断关联数组里某个键是否存在 (哪怕值为空)。需要 Bash 4.3+。
declare -A cfg=([host]=a [port]="")
[[ -v cfg[port] ]] && echo "port key exists"
[[ -v cfg[tls] ]] || echo "no tls key"
readarray -t -d "" arr < <(find ... -print0)把 NUL 分隔的项读进数组,一项一个元素。`readarray` 是 `mapfile` 的别名;`-d ""` 按 NUL 切。
readarray -t -d "" files < <(find . -name "*.log" -print0)
echo "${#files[@]} logs"Always quote your variables不加引号的变量展开会被切词 (按 $IFS) 再做 glob 展开。文件名带空格或带 glob 字符就会被拆成多个参数。
⚠ 常见坑: `rm $file` 当 file="report 2024.csv" 时会变成 `rm report 2024.csv` 两个参数。`rm "$file"` 才安全。默认全部加引号,要切词时再特意不加。
cp "$src" "$dst" # safe
for f in "$@"; do echo "$f"; done # safe
cd may fail — guard with &&`cd /missing/dir; rm -rf *` 在 cd 失败时会把当前目录删了。一定要串起来:`cd dir && rm ...`,或者 `cd || exit`。
⚠ 常见坑: set -e 拦不住这个,`cd` 失败后 `;` 接的 rm 还是会跑。要写 && 或者 `cd dir || { echo "目录不存在" >&2; exit 1; }`。
cd "$build_dir" && rm -rf * # safe
pushd "$dir" || exit; do_work; popd
Pipe into while loop runs in a subshell`cat file | while read line; do count=$((count+1)); done` 跑完 $count 还是 0,因为 while 在子 Shell 里。要么 `done < file`,要么用进程替换。
# wrong: # cat file | while read l; do n=$((n+1)); done # right: while read -r l; do n=$((n+1)); done < file echo "$n"
set -e does not catch every failureset -e 忽略 if、while、until、&&、||、管道 (没开 pipefail 时) 里的失败。它只对顶层的裸命令生效。
⚠ 常见坑: 加 `set -o pipefail`,但别只靠 -e 当唯一防线。显式检查 (`|| { echo 错误; exit 1; }`) 才是真防御。
set -euo pipefail
critical_cmd || { echo "critical failed" >&2; exit 1; }globbing surprises (no match)没匹配到任何文件的 glob 会展成字面模式,而不是空列表。`for f in *.log` 没找到 log 时会进一轮循环,$f 就是 "*.log"。
⚠ 常见坑: 修复方式:`shopt -s nullglob` (没匹配 → 空列表) 或 `shopt -s failglob` (没匹配 → 报错)。一个脚本选一种用到底。
shopt -s nullglob
for f in *.log; do echo "$f"; done
wc -l misses the last line without a newline`wc -l` 数的是换行符,不是行数。文件末尾没换行的话,最后那一行就漏数了。
⚠ 常见坑: 想要真实行数用 `awk "END{print NR}"`,或者 `grep -c ""` (会把末尾没换行的也算上)。
printf "a\nb" | wc -l # 1 (!)
printf "a\nb" | awk "END{print NR}" # 2Arithmetic vs string in [[ ]]`[[ "5" > "10" ]]` 是真,因为它做 ASCII 比较。比数字用 `(( 5 > 10 ))` 或 `[[ 5 -gt 10 ]]`。
(( age >= 18 )) # numeric
[[ "$name" > "alice" ]] # alphabetic
$_ vs $0 in sourced scripts被 source 的脚本里,`$0` 是当前 Shell 名 (bash、zsh),不是脚本路径。要拿真实文件路径用 `${BASH_SOURCE[0]}`。
# inside lib.sh:
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "run directly"
else
echo "sourced from $0"
fimacOS bash is stuck at 3.2Apple 自带 bash 3.2 (2007 年 5 月) 因为新版本是 GPLv3 协议。关联数组、${var^^}、mapfile、|& 都要 bash 4+。
⚠ 常见坑: `brew install bash`,然后脚本 shebang 指 `/opt/homebrew/bin/bash`。或者直接用 zsh,新版 macOS 默认就是 zsh。
bash --version # check yours
#!/opt/homebrew/bin/bash # macOS modern bash
Subshell variable scope小括号 `(...)` 在子 Shell 里跑,里面的赋值和 `cd` 不会影响外层 Shell。
x=outer
(x=inner; echo "in: $x") # in: inner
echo "out: $x" # out: outer
getopts cannot do long options内置 `getopts` 只支持单字符短选项,像 `-f file`。要 `--file file` 这种长选项得用 `getopt` (注意没 s,是另一个程序) 或自己写解析。
⚠ 常见坑: GNU getopt 和 BSD getopt 互不兼容。macOS 自带的是原始 BSD 版,不支持长选项。要跨平台支持长选项就手写解析循环。
# portable long-option parsing:
while [[ $# -gt 0 ]]; do
case "$1" in
--file) file="$2"; shift 2 ;;
--) shift; break ;;
*) shift ;;
esac
doneecho is not portable`echo -e` 和 `echo -n` 在不同 Shell 和构建里行为不一。要转义或不换行,改用 `printf`。
⚠ 常见坑: 某些系统上 `echo "-n"` 会原样打出 `-n`。`printf "%s"` / `printf "%b"` 才是可预期的替代。
printf "no newline"
printf "%b\n" "tab\there"
a=$(cmd) hides the exit code把命令替换赋给变量,赋值本身总成功,所以即使 `cmd` 失败,`set -e` 也不会中止。
⚠ 常见坑: 拆开也不够:`out=$(cmd)` 单独一行仍吞错。要显式 `out=$(cmd) || exit 1`,或紧接着判断 `$?`。
out=$(grep ERR log) || { echo "grep failed"; exit 1; }unquoted $() in arrays splits wrong`arr=($(cmd))` 会按 $IFS 切还会做 glob,带空格或 `*` 的文件名直接炸。要用 `mapfile -t arr < <(cmd)`。
⚠ 常见坑: `my file.txt` 这种名字会被拆成两个元素;值是 `*` 会展成整个目录列表。`mapfile -t` 只按行切。
mapfile -t names < <(ls) # safe, line-by-line
trailing newline stripped by $()命令替换会去掉末尾所有换行。拿 `$(cmd)` 去和带末尾换行的文本比会出乎意料。
⚠ 常见坑: 想保留就加哨兵再去掉:`out=$(cmd; printf x); out=${out%x}`。
out=$(printf "a\n\n")
echo "${#out}" # 1 — the two newlines are gonerm -rf "$dir/" with empty dir若 `$dir` 未设或为空,`rm -rf "$dir/"` 就变成 `rm -rf "/"`。删除前一定先校验变量。
⚠ 常见坑: 加 `[[ -n "$dir" && -d "$dir" ]] || exit 1` 兜底,并开 `set -u` 让未设变量报错而不是展成空。
[[ -n "${dir:-}" ]] || { echo "dir unset" >&2; exit 1; }rm -rf -- "$dir"
read drops the last line without newline`while read -r line` 会漏掉末尾没换行的最后一行,因为读到行中 EOF 时 read 返回非零。
⚠ 常见坑: 用 `while read -r line || [[ -n "$line" ]]; do ...; done`,这样残缺的最后一行也能处理。
while read -r l || [[ -n "$l" ]]; do echo "$l"; done < no-trailing-newline.txt
tilde ~ does not expand in quotes引号里的 `~` 不展开:`path="~/x"` 是字面的 `~/x`,不是家目录。要用 `"$HOME/x"`。
⚠ 常见坑: 波浪号只在不加引号且位于词首时展开。`cd "~"` 会失败;`cd ~` 或 `cd "$HOME"` 才行。
log="$HOME/app.log" # right
log=~/app.log # also right (unquoted)
[ ] vs [[ ]] with unset vars老的 `[ ]` 里,空的未加引号变量会变成语法错误:x 为空时 `[ $x = y ]` 成了 `[ = y ]`。`[[ ]]` 没这问题,或者一律加引号。
⚠ 常见坑: `[ ]` 的经典防御写法是 `[ "x$a" = "x$b" ]`。用 `[[ ]]` 就不必这么写。
[[ "$a" == "$b" ]] # safe even if both empty
cron has a minimal PATHcron 跑的脚本只有极简 PATH (常只有 /usr/bin:/bin),/usr/local/bin 里的工具找不到。用绝对路径或脚本顶部设好 PATH。
⚠ 常见坑: 在终端能跑的脚本到 cron 里可能报"command not found"。加 `export PATH=/usr/local/bin:$PATH`,或用全路径调用。
#!/usr/bin/env bash export PATH="/usr/local/bin:/usr/bin:/bin"
sourcing a script runs it now`source f` (或 `. f`) 在当前 Shell 里执行 f,它的 `exit` 会杀掉你的 Shell,它的 `set -e` 影响你,它的变量也留下。
⚠ 常见坑: 打算被 source 的库不要直接 `exit`,用 `return`,加载失败时调用方才不会跟着挂。
# in lib.sh, guard "run vs sourced":
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0可搜索的 Bash 速查表,100+ 条命令和习语,按你凌晨两点真 会翻的 11 类组织。变量:"${var}"、默认值 ${var:-default}、 未设就赋值 ${var:=default}、未设就报错 ${var:?msg}、前后 缀去除 ${var#…} ${var%…}、查找替换 ${var//old/new}、长度 ${#var}、子串 ${var:0:5}、大小写 ${var^^} ${var,,}。条件: 优先 [[ ]];字符串相等和 glob 匹配;=~ 正则配 BASH_REMATCH; 算术 (( ));-lt -gt -eq;文件类型 -f -d -e -L -s;-z -n 判空串;短路 && ||;case 配 glob。循环:遍历列表和 glob 的 for、C 风格 for ((i=0;i<n;i++))、安全按行读文件的 while read -r line < file、until、交互菜单 select、break / continue、花括号展开 {1..10}。函数:name() 和 function 两种定义、位置参数 $1 ${10}、"$@" 和 "$*" 转发的区别、 local 局部变量、return 设退出码、x=$(fn) 捕获输出。重定向: > >> 2> 2>&1 &> < <<EOF <<'EOF' <<<、exec 重定向脚本自身、 /dev/null。管道:| |& & 配 $!、$(cmd)、<(cmd)、set -o pipefail、tee、( 子 Shell ) 和 { 当前 Shell 分组; } 的区 别。字符串:字节长度和字符长度、按 IFS 切到数组、printf -v 直接写变量、glob 前缀判断。流程:set -e -u -x、pipefail、 trap EXIT 收尾、trap INT TERM 抓信号、退出码约定、$?、 time 计时。脚本写法:#!/usr/bin/env bash、getopts 解析 短选项、手写解析长选项、mktemp 配 trap、带 TTY 检测的 ANSI 彩色、进度条、BASH_SOURCE 找脚本自己的目录、command -v 检查命令是否装了、read -p 交互提示、写到 stderr 的 日志函数。数组:declare -a / -A、+=() 追加、"${arr[@]}" 遍历、"${#arr[@]}" 数个数、"${!arr[@]}" 拿键、unset、 mapfile 从文件读。常见坑:变量永远加引号、cd 可能失败 (用 && 别用 ;)、管道喂进 while 是子 Shell 所以计数器出 循环就没了、set -e 拦不住 if 和 && 和管道 (没开 pipefail 时) 里的失败、glob 没匹配会变成字面模式 (开 nullglob)、 wc -l 数的是换行符所以末尾没 \n 的最后一行漏数、 [[ "5" > "10" ]] 是真 (按字符串比)、被 source 的脚本里 $0 是 Shell 名要用 ${BASH_SOURCE[0]}、macOS 自带 Bash 3.2 没关联数组没 ${var^^} 没 mapfile (brew install bash 升 4+)、getopts 不支持长选项 (要 getopt 或手写)。每条都有 中英文双语说明、一条能直接拷贝的例子,关键的还配一条 "常见坑"提醒。搜索框跨命令、说明、坑、例子四个字段一起 过滤,分类胶囊切到单个分区,一键复制。完全浏览器里跑。
把内容粘贴或拖入工具面板。
点击按钮,在浏览器内本地处理,文件不上传。
一键复制结果或下载到本地。
适合穿插在写代码、查问题、做 Review、上线前的小任务里。
这些入口会把当前任务接到更完整的工具链里。
一个 200 行的 deploy.sh 在 CI 里报成功,实际却跳过了一半步骤。 你在速查表搜「pipefail」和「set -e」,把 `set -euo pipefail` 贴进脚本头,立刻定位到那条吞掉 404 的 `curl | tar` 管道。 五分钟搞定,不用开 Stack Overflow 标签页,下一次跑直接在 正确的那行大声报错。
线上挂了,你要从一个 4GB 日志里按分钟数 5xx 行数。直接抄 `while read -r line < file` 的写法(不是 `cat | while`,那个 出循环计数器就没了),配上按 IFS 切片和 `printf -v` 格式化 时间戳。循环跑在同一个 Shell 里,计数活下来,三分钟内就能 往故障群里贴出一个准数。
你的脚本用了 `declare -A` 和 `${path,,}`,到同事的 Alpine 机器上就挂,报一句看不懂的错。你翻到 macOS 和 Linux 那一节, 才知道 Mac 上偷偷跑的是 Bash 3.2,于是加上 `BASH_VERSINFO` 版本断言,把关联数组换成 case 语句。一遍过,两台机器都绿, 省掉「我这能跑」的来回扯皮。
组里新人手写 `$1 == "-f"` 判断,碰到 `--file=foo` 就翻车。 你把 getopts 那条和手写长选项的循环并排打开,把 `case "$1" in --file=*)` 这段骨架直接贴进他的 PR,顺手讲 清楚为啥要 `shift 2`。例子能直接拷,review 评论就是一次 粘贴,不用长篇说教。
用 `cat file | while read` 累加计数器:循环跑在子 Shell 里,出循环 count 还是 0。改成重定向 `while read -r line; do …; done < file`。
在管道里只信 `set -e`。`bad-cmd | tee out` 照样报成功;加 `set -o pipefail`,让管道在最左边失败处就挂。
在 `[[ ]]` 里用 `>` 比数字,比如 `[[ "5" > "10" ]]` 是真,因为它按字符串比。数字比较要用 `(( 5 > 10 ))`。
全部在你的浏览器里跑。命令列表是内存里的静态数组,搜索框、分类 胶囊、复制按钮都不发任何网络请求。你在搜索框里打的字不会被记录, 也不会写进 URL,所以这张表离网、在公司代理后面、在离网主机上 都能用,一个字节都不外泄。
做你这行的人, 还会一起用这些。