Linux-09 管道、重定向与 shell 展开:2>&1 的位置、fd 的本质与七个展开阶段
这一篇讲的是 shell 里最容易「用对但说不清」的部分。它的价值在于:一旦理解了「重定向在命令执行前完成」和「展开有固定的七个阶段」,一大批诡异现象会同时变得可解释。
先看五个问题:
cmd > f 2>&1和cmd 2>&1 > f有什么区别?为什么?echo hi > f这条命令里,是echo打开了文件f吗?cat a.txt | wc -l和wc -l < a.txt,哪个更好?为什么?count=0; cat f | while read l; do ((count++)); done; echo $count为什么输出 0?- 为什么
echo $HOME展开成家目录,echo '$HOME'不展开,echo "$HOME"又展开了?
1. 一切从 fd 开始
1.1 三个标准 fd
每个进程启动时,内核默认给它准备三个文件描述符(file descriptor,简称 fd):
| fd | 名字 | 全称 | 默认指向 | 用途 |
|---|---|---|---|---|
| 0 | stdin | standard input | 终端键盘 | 读输入 |
| 1 | stdout | standard output | 终端屏幕 | 正常输出 |
| 2 | stderr | standard error | 终端屏幕 | 错误输出 |
fd 就是一个整数,是进程「打开文件表」的下标(04 篇讲过)。可以直接看到:
ls -l /proc/self/fd/
# lrwx------ 1 lhx lhx 64 Aug 12 19:30 0 -> /dev/pts/0
# lrwx------ 1 lhx lhx 64 Aug 12 19:30 1 -> /dev/pts/0
# lrwx------ 1 lhx lhx 64 Aug 12 19:30 2 -> /dev/pts/0
# ^ 三个都指向同一个终端设备
# 重定向之后再看
ls -l /proc/self/fd/ > /tmp/fds.txt 2>/dev/null; cat /tmp/fds.txt
# 1 -> /tmp/fds.txt <- fd 1 被换成了文件
# 2 -> /dev/null
为什么要把正常输出和错误输出分成两个 fd? 因为这样才能分别处理:
./build.sh > build.log 2> error.log # 正常输出和错误分开存
./build.sh > /dev/null # 只看错误(丢弃正常输出)
./build.sh 2> /dev/null # 只看正常输出(丢弃错误)
./build.sh | grep -i warn # ⚠️ 管道只传 stdout,stderr 直接打到屏幕
这也解释了一个常见困惑:为什么错误信息「穿过」了管道直接显示在屏幕上? 因为 | 只接管 fd 1。
1.2 stderr 为什么不带缓冲
回顾 05 篇讲的缓冲:stderr 永远是无缓冲的,stdout 连着终端时行缓冲、连着管道时全缓冲。
这个设计是有意的:错误信息必须立即可见,不能因为攒在缓冲区里而在程序崩溃时丢失。代价是 stderr 的输出效率低,所以不要用它输出大量数据。
# 一个副作用:stdout 和 stderr 混在一起时,顺序可能是乱的
./prog > mixed.log 2>&1
# stdout 被缓冲、stderr 不缓冲 -> 日志里的时间顺序可能对不上
# ✅ 要严格顺序就分开存,或者让程序统一往一个 fd 输出
2. 重定向:在命令执行之前就完成了
2.1 开篇第二问:谁打开了文件
echo hi > f
不是 echo 打开的 f,是 shell 打开的。 完整过程:
bash 解析出「命令 echo,参数 hi,把 fd 1 重定向到文件 f」
|
├─ fork() 出子进程
| |
| ├─ 【在 exec 之前】open("f", O_WRONLY|O_CREAT|O_TRUNC) 得到一个 fd(假设是 3)
| ├─ dup2(3, 1) 把 fd 3 复制到 fd 1,原来的 fd 1 被关闭替换
| ├─ close(3) 清理
| └─ execve("/bin/echo", ["echo", "hi"])
| ^^ echo 程序此时才开始运行,它对重定向【完全不知情】
| 它只是照常往 fd 1 写,而 fd 1 已经是文件了
|
└─ 父进程 wait()
用 strace 可以直接验证:
strace -f -e trace=openat,dup2,write bash -c 'echo hi > /tmp/f' 2>&1 | grep -E 'openat|dup2|write'
# openat(AT_FDCWD, "/tmp/f", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
# dup2(3, 1) = 1
# write(1, "hi\n", 3) = 3
# ^^ echo 只是往 fd 1 写,不知道那是文件
这个认知能解释四件事:
# ① 为什么 > 会立即清空文件,即使命令根本不存在
nosuchcommand > important.txt
# bash: nosuchcommand: command not found
cat important.txt # 空的!文件在命令执行【前】就被 O_TRUNC 截断了
# ② 为什么 sudo cmd > /etc/f 不管用
sudo echo "x" > /etc/protected
# bash: /etc/protected: Permission denied
# 因为重定向是【当前 shell】做的,而 shell 是普通用户,sudo 只作用于 echo
# ✅ 正确做法
echo "x" | sudo tee /etc/protected
sudo sh -c 'echo "x" > /etc/protected'
sudo tee /etc/protected <<< "x"
# ③ 为什么 cmd > f 会先清空 f,导致 sort f > f 得到空文件
sort data.txt > data.txt # 💀 data.txt 先被截断成 0 字节,sort 读到空文件
sort -o data.txt data.txt # ✅ sort 的 -o 参数专门处理这种情况
sort data.txt > tmp && mv tmp data.txt # ✅ 或者用临时文件
sponge < data.txt > data.txt # ✅ moreutils 的 sponge:读完全部再写
# ④ 为什么 >> 不会清空(用的是 O_APPEND 而不是 O_TRUNC)
2.2 开篇第一问:2>&1 的位置
重定向是从左到右依次执行的,每一步都是「把某个 fd 指向当前某个目标」。
cmd > f 2>&1
# ^^^^ ① fd 1 -> 文件 f
# ^^^^^ ② fd 2 -> 【fd 1 现在指向的地方】= 文件 f
# 结果:stdout 和 stderr 都进 f ✅
cmd 2>&1 > f
# ^^^^^ ① fd 2 -> 【fd 1 现在指向的地方】= 终端
# ^^^^ ② fd 1 -> 文件 f
# 结果:stderr 去了终端,只有 stdout 进 f ❌(这几乎总是写错了)
关键是理解 2>&1 的含义是「把 fd 2 指向 fd 1 当前指向的目标」,而不是「把 fd 2 绑定到 fd 1」。它拷贝的是「当时的指向」,之后 fd 1 再改变不影响 fd 2。
用一张图说明:
cmd > f 2>&1 cmd 2>&1 > f
初始: 1 --> tty 初始: 1 --> tty
2 --> tty 2 --> tty
> f : 1 --> f 2>&1: 2 --> tty (复制 1 当时的指向)
2 --> tty 1 --> tty
2>&1: 2 --> f (复制 1 当时的指向) > f : 1 --> f
1 --> f 2 --> tty (不受影响)
结果: 两者都进 f ✅ 结果: 只有 stdout 进 f ❌
# 亲手验证
bash -c 'echo OUT; echo ERR >&2' > /tmp/a 2>&1
cat /tmp/a # OUT 和 ERR 都在
bash -c 'echo OUT; echo ERR >&2' 2>&1 > /tmp/b
# ERR <- 直接打在屏幕上
cat /tmp/b # 只有 OUT
记忆方法:2>&1 永远写在最后(除非你真的知道自己在干什么)。
bash 还提供了简写:
cmd &> f # bash 简写:等价 cmd > f 2>&1 ← 推荐(不会写错顺序)
cmd &>> f # 追加版本
cmd >& f # 同 &>(但 &> 更常见)
cmd |& grep x # 等价 cmd 2>&1 | grep x(stdout 和 stderr 都进管道)
# ⚠️ &> 是 bash/zsh 扩展,不是 POSIX。写 #!/bin/sh 脚本时要用 > f 2>&1
2.3 完整的重定向语法
| 语法 | 作用 |
|---|---|
> f |
fd 1 → 文件 f(截断,不存在则创建) |
>> f |
fd 1 → 文件 f(追加) |
< f |
fd 0 ← 文件 f |
2> f |
fd 2 → 文件 f |
2>> f |
fd 2 追加到 f |
n> f |
任意 fd n → 文件 f |
&> f / >& f |
stdout + stderr → f(bash 扩展) |
&>> f |
同上,追加 |
2>&1 |
fd 2 → fd 1 当前的目标 |
1>&2 |
fd 1 → fd 2 当前的目标(给函数输出错误信息用) |
>&- / 2>&- |
关闭 fd |
<> f |
以读写方式打开 f 并绑到 fd 0 |
n<> f |
以读写方式打开并绑到 fd n |
| cmd |
fd 1 → 管道 |
|& cmd |
stdout + stderr → 管道(bash 4+) |
<<< "str" |
here-string:把字符串作为 stdin |
<< EOF ... EOF |
here-doc:多行文本作为 stdin |
<<- EOF |
here-doc 且忽略行首 tab(便于缩进) |
<(cmd) |
进程替换:把命令输出当成一个文件名 |
>(cmd) |
进程替换:把命令输入当成一个文件名 |
set -o noclobber / set -C |
禁止 > 覆盖已存在的文件 |
>| f |
在 noclobber 下强制覆盖 |
# 常用组合
cmd > /dev/null 2>&1 # 完全静默(POSIX 写法)
cmd &> /dev/null # 同上(bash 简写)
cmd > out.log 2> err.log # 分开存
cmd 2>&1 | tee build.log # 都存下来且显示在屏幕
cmd > >(gzip > out.gz) 2>&1 # 边输出边压缩(进程替换)
# 在函数/脚本里把提示信息发到 stderr,避免污染管道
info() { echo "[INFO] $*" >&2; }
# ^^^^ 1>&2 的简写
get_value() { info "正在获取..."; echo "42"; }
v=$(get_value) # v 只拿到 42,提示信息去了终端 ✅
# 关闭 fd(少见但有用)
cmd 2>&- # 关闭 stderr(程序写 stderr 会得到 EBADF)
exec 3>&- # 关闭自己打开的 fd 3
2.4 exec:改变当前 shell 自己的 fd
# ① 不带命令的 exec = 永久改变当前 shell 的重定向
exec > /tmp/script.log 2>&1 # 【之后所有输出】都进日志文件
echo "这行进了日志"
echo "错误也进日志" >&2
# 脚本里常用:在开头加这一行,整个脚本的输出自动落盘
# ② 打开自定义 fd
exec 3< input.txt # 用 fd 3 读文件
read line <&3 # 从 fd 3 读一行
exec 3<&- # 用完关闭
exec 4> output.txt # 用 fd 4 写文件
echo "data" >&4
exec 4>&-
# ③ 同时保留原始输出(备份 fd)
exec 3>&1 # 把当前 stdout 备份到 fd 3
exec > /tmp/log # 重定向 stdout 到文件
echo "进文件"
echo "进终端" >&3 # 通过备份的 fd 3 输出到原终端
exec 1>&3 3>&- # 恢复 stdout 并关闭 fd 3
echo "又回到终端了"
# ④ 带命令的 exec = 用新程序【替换】当前进程(不 fork)
exec ./myapp # bash 进程被 myapp 取代,PID 不变
# 容器 entrypoint 脚本必须用 exec,否则信号收不到(见 13 篇)
容器里最重要的一个用法:
#!/bin/sh
# entrypoint.sh
setup_config
# ❌ 错误:myapp 成为 shell 的子进程
./myapp
# 后果:docker stop 发的 SIGTERM 给了 shell(PID 1),shell 不会转发给 myapp,
# 于是 10 秒后被 SIGKILL 强杀,来不及优雅退出
# ✅ 正确:用 exec 替换掉 shell,myapp 直接成为 PID 1
exec ./myapp
# 现在 SIGTERM 直达 myapp,能正常执行 graceful shutdown
3. 管道:两个进程与一个内核缓冲区
3.1 管道的本质
cmd1 | cmd2
shell 做的事:
① pipe() 系统调用 -> 得到一对 fd:读端 r 和写端 w(内核里的一块环形缓冲区)
② fork 出进程 A:把它的 fd 1 dup2 成 w,关掉 r,exec cmd1
③ fork 出进程 B:把它的 fd 0 dup2 成 r,关掉 w,exec cmd2
④ 父 shell 关掉 r 和 w,等两个子进程
cmd1 (进程A) cmd2 (进程B)
fd 1 ──写──> [ 内核管道缓冲区 ] ──读──> fd 0
默认 64KB
三个重要推论:
# ① 管道两端是【两个独立进程】,并发执行(不是先跑完 cmd1 再跑 cmd2)
time (sleep 2 | sleep 3)
# real 0m3.0s <- 不是 5 秒,两个 sleep 同时在跑
# ② 缓冲区满了写端会【阻塞】,空了读端会阻塞 —— 这是天然的流控
yes | head -1 # head 拿到一行就退出,yes 收到 SIGPIPE 后死掉
# 所以这条命令不会无限跑下去
# ③ 每一段都是独立进程 -> 有自己的变量、cwd、环境(下一节的坑)
3.2 开篇第四问:管道里的变量为什么丢了
count=0
cat f | while read line; do ((count++)); done
echo $count
# 0 <- 明明循环执行了!
因为 管道的每一段都在子进程里执行(bash 默认把管道的所有段都放进子 shell)。while 循环在子进程里给 count 加了值,子进程一退出,改动就消失了——回顾 01 篇 3.2 讲的 cd 为什么必须是 builtin,同一个道理。
# 验证
echo x | { echo "子进程 PID: $BASHPID, 父 shell PID: $$"; }
# 子进程 PID: 12346, 父 shell PID: 12345
# ✅ 解法一:用重定向代替管道(循环就在当前 shell 里了)
count=0
while read line; do ((count++)); done < f
echo $count # 正确
# ✅ 解法二:用进程替换
count=0
while read line; do ((count++)); done < <(grep ERROR f)
echo $count # 正确
# ✅ 解法三:bash 4.2+ 的 lastpipe(让管道最后一段在当前 shell 执行)
shopt -s lastpipe
set +m # 需要关闭作业控制
count=0
cat f | while read line; do ((count++)); done
echo $count # 正确
# ✅ 解法四:干脆别用循环
count=$(wc -l < f)
count=$(grep -c ERROR f)
同样的坑还有:
# 管道里的 cd 不影响外面
echo x | cd /tmp
pwd # 还在原目录
# 管道里读到的变量赋值丢失
echo "42" | read v
echo "[$v]" # [] <- 空的
read v <<< "42"; echo "[$v]" # [42] ✅ 用 here-string
v=$(echo 42); echo "[$v]" # [42] ✅ 用命令替换
3.3 退出码与 pipefail
false | true; echo $?
# 0 <- $? 只反映【最后一个】命令
false | true; echo "${PIPESTATUS[@]}"
# 1 0 <- 每一段的退出码都在这个数组里
set -o pipefail
false | true; echo $?
# 1 <- 任意一段失败就算整体失败
# 生产脚本三件套
set -euo pipefail
# ^^^ e: 命令失败就退出 u: 用未定义变量报错 pipefail: 管道任意段失败即失败
# ⚠️ pipefail 的一个副作用
set -o pipefail
head -1 huge.log | grep x
# head 拿到一行就退出 -> 上游被 SIGPIPE 杀死 -> 退出码 141 -> 整个管道"失败"
# 这类情况要显式处理:
{ head -1 huge.log || true; } | grep x
3.4 开篇第三问:cat f | cmd vs cmd < f
cat a.txt | wc -l # ❌ 起两个进程,多一次数据拷贝
wc -l < a.txt # ✅ 一个进程,内核直接把文件给它
wc -l a.txt # ✅ 也行(输出会带文件名)
差异有三点:
- 性能:
cat f | cmd多一个fork+exec和一次管道拷贝 - 能力:从管道读的程序不能
seek(管道不可随机访问),有些命令因此行为不同 - 子进程问题:如 3.2 所述,管道会引入子 shell
# 第 2 点的实际影响
wc -c < bigfile # 瞬间完成:wc 直接 lseek 到文件末尾拿大小
cat bigfile | wc -c # 慢:必须把全部数据读一遍
tail -1 bigfile # 瞬间:从末尾反向读
cat bigfile | tail -1 # 慢:必须读完整个文件
但也有必须用 cat 的场景:
cat a.log b.log | grep x # ✅ 真的要拼接多个文件
cat *.csv | ... # ✅ 合并
tar -cf - dir | ssh host 'tar -xf -' # ✅ 用 - 表示 stdin/stdout
3.5 SIGPIPE:管道的下游退出会怎样
yes | head -3
# y y y 然后 yes 被杀死
# 原理:head 读够 3 行就 exit 并关闭读端;
# yes 继续往已关闭的管道写 -> 内核发 SIGPIPE -> 默认行为是终止进程
bash -c 'yes | head -3; echo "yes 的退出码: ${PIPESTATUS[0]}"'
# yes 的退出码: 141 <- 128 + 13(SIGPIPE 是 13 号信号)
Go 服务里的相关坑:
// Go 默认【忽略】SIGPIPE(对非 stdout/stderr 的 fd),写已关闭的连接返回 EPIPE 错误
// 所以要检查 Write 的返回值,而不是指望进程被信号杀死
if _, err := conn.Write(data); err != nil {
if errors.Is(err, syscall.EPIPE) {
// 客户端已断开
}
}
// 但如果是往【stdout/stderr】写且对端关闭,Go 会让 SIGPIPE 生效终止程序
// 这是为了让 `mygoapp | head` 这类用法正常工作
4. shell 展开的七个阶段
4.1 顺序
这是解释「引号该怎么加」的唯一正确框架。bash 在执行命令前,按固定顺序做这些展开:
① 花括号展开 {a,b} {1..5}
② 波浪号展开 ~ ~user
③ 参数与变量展开 $var ${var} ${var:-default}
④ 命令替换 $(cmd) `cmd`
⑤ 算术展开 $((1+2))
⑥ 分词(word splitting) 按 IFS 切分 ← 大部分 bug 的来源
⑦ 路径展开(globbing) * ? [abc]
─────────────────
然后:去引号(quote removal)→ 重定向 → 执行
注意 ③④⑤ 是同一轮从左到右处理的,而 ⑥⑦ 发生在它们之后 —— 这个顺序至关重要。
4.2 为什么顺序重要
# ① 变量展开【之后】才分词 -> 变量值里的空格会被切开
f="my file.txt"
ls $f # ls: 'my' 不存在; ls: 'file.txt' 不存在 ← 被分成两个参数
ls "$f" # ✅ 引号阻止了分词
# ② 变量展开【之后】才做路径展开 -> 变量里的 * 会被展开
p="*.txt"
echo $p # a.txt b.txt <- * 被展开了
echo "$p" # *.txt <- 引号阻止
# ③ 花括号展开在【最前面】-> 它不能用变量
n=3
echo {1..$n} # {1..3} <- ❌ 花括号展开时 $n 还没被替换
echo $(seq 1 $n) # 1 2 3 ✅
eval echo {1..$n} # 1 2 3 ✅(eval 让它多走一轮,但要小心注入)
# ④ 波浪号展开在变量展开【之前】-> 变量里的 ~ 不会被展开
p="~/data"
ls $p # ls: '~/data' 不存在 <- ~ 是字面量了
ls "$HOME/data" # ✅
p="$HOME/data"; ls "$p" # ✅
# ⑤ 命令替换的结果会被分词
files=$(ls) # 如果文件名带空格就散了
mapfile -t files < <(ls) # ✅ bash 4+:按行读入数组
4.3 三种引号
| 写法 | 阻止什么 | 保留什么 |
|---|---|---|
'单引号' |
阻止所有展开 | 内容完全字面(连 \ 都不特殊);不能包含单引号 |
"双引号" |
阻止分词和路径展开 | 仍会展开 $var、$(cmd)、$((expr))、`cmd`、\ |
\转义 |
阻止下一个字符的特殊含义 | — |
| (无引号) | 什么都不阻止 | 全部展开 + 分词 + globbing |
echo $HOME # /home/lhx 全部展开
echo "$HOME" # /home/lhx 双引号仍展开变量 ← 开篇第五问
echo '$HOME' # $HOME 单引号不展开
echo \$HOME # $HOME 转义
# 单引号里想放单引号(三种绕法)
echo 'it'\''s' # it's 结束单引号 + 转义单引号 + 重开
echo "it's" # it's 用双引号
echo $'it\'s' # it's $'...' 支持 C 风格转义
# $'...' 的实用价值:写特殊字符
printf $'a\tb\n' # 真的 tab 和换行
grep $'\t' file # 搜索 tab
IFS=$'\n' # 把分隔符设为换行
一条铁律:变量引用永远加双引号,除非你明确需要分词或 globbing。
# 唯一常见的例外:故意让参数分词
opts="-l -a -h"
ls $opts # 需要拆成三个参数
# 但更稳的写法是用数组
opts=(-l -a -h)
ls "${opts[@]}" # ✅ 每个元素作为一个独立参数
4.4 $@ 与 $*:四种写法的区别
# 假设脚本收到三个参数:a "b c" d
$* -> a b c d 所有参数拼成【一个】字符串再分词 -> 4 个词
$@ -> a b c d 同上(不加引号时两者一样)-> 4 个词
"$*" -> "a b c d" 拼成【一个】字符串,用 IFS 第一个字符连接 -> 1 个词
"$@" -> "a" "b c" "d" 每个参数独立成词,【完整保留】-> 3 个词 ✅
# 验证脚本
cat > t.sh <<'EOF'
show() { echo "共 $# 个参数:"; for a in "$@"; do echo " [$a]"; done; }
echo '--- $* ---'; show $*
echo '--- $@ ---'; show $@
echo '--- "$*" ---'; show "$*"
echo '--- "$@" ---'; show "$@"
EOF
bash t.sh a "b c" d
# --- $* --- 共 4 个参数: [a] [b] [c] [d]
# --- $@ --- 共 4 个参数: [a] [b] [c] [d]
# --- "$*" --- 共 1 个参数: [a b c d]
# --- "$@" --- 共 3 个参数: [a] [b c] [d] ← 只有这个是对的
规则:转发参数永远用 "$@"。 数组同理:"${arr[@]}" 而不是 ${arr[*]}。
4.5 IFS:分词的依据
echo "$IFS" | xxd | head -1
# 00000000: 2009 0a0a <- 默认是 空格(0x20) tab(0x09) 换行(0x0a)
# 临时改 IFS 做切分
line="root:x:0:0:root:/root:/bin/bash"
IFS=: read -r user pass uid gid desc home shell <<< "$line"
# ^ 只对这条 read 生效
echo "$user $uid $shell" # root 0 /bin/bash
# 按逗号切成数组
IFS=, read -ra arr <<< "a,b,c"
echo "${arr[1]}" # b
# ⚠️ 全局改 IFS 很危险,用完要恢复
old_IFS=$IFS
IFS=$'\n'
# ... 处理 ...
IFS=$old_IFS
# 更安全:在子 shell 里改
( IFS=,; for x in $csv; do echo "$x"; done )
4.6 花括号展开的实用技巧
# 序列
echo {1..5} # 1 2 3 4 5
echo {1..10..2} # 1 3 5 7 9 (步进,bash 4+)
echo {a..e} # a b c d e
echo {05..10} # 05 06 07 08 09 10 (补零)
# 组合
echo file{1,2,3}.txt # file1.txt file2.txt file3.txt
mkdir -p project/{src,test,docs}/{main,util} # 一次创建 6 个目录
cp config.yaml{,.bak} # cp config.yaml config.yaml.bak ← 备份的经典写法
mv app.log{,.1} # mv app.log app.log.1
diff <(cmd1) <(cmd2)
# 嵌套
echo {a,b}{1,2} # a1 a2 b1 b2
echo {A..C}{1..2} # A1 A2 B1 B2 C1 C2
# ⚠️ 花括号展开与路径展开的区别
echo {a,b}.txt # a.txt b.txt <- 无论文件是否存在都展开
echo *.txt # 只列出【存在的】文件;不存在时保持原样 *.txt
4.7 参数展开:默认值与字符串操作
# ── 默认值(脚本健壮性必备)──
${var:-default} # var 未设置或为空 -> 用 default(不改变 var)
${var-default} # var 未设置 -> 用 default(空字符串算已设置)
${var:=default} # var 为空 -> 用 default 并【赋值给 var】
${var:?错误信息} # var 为空 -> 打印错误并【退出】 ← 参数校验
${var:+替代值} # var 【非空】时才用替代值
PORT=${PORT:-8080} # 环境变量没设就用默认值
: "${DIR:?DIR 必须设置}" # 强制校验(配合 04 篇的 rm 防护)
opts=${DEBUG:+--verbose} # DEBUG 非空才加 --verbose
# ── 字符串操作(比调用 sed/cut 快得多,不起进程)──
p=/var/log/app.log
echo "${#p}" # 16 长度
echo "${p##*/}" # app.log 删最长的前缀 */ = basename
echo "${p%/*}" # /var/log 删最短的后缀 /* = dirname
echo "${p%.log}" # /var/log/app 删后缀
echo "${p##*.}" # log 取扩展名
echo "${p/log/LOG}" # /var/LOG/app.log 替换【第一个】
echo "${p//log/LOG}" # /var/LOG/app.LOG 替换【全部】
echo "${p/#\/var/\/data}" # /data/log/app.log 只替换开头
echo "${p/%log/txt}" # /var/log/app.txt 只替换结尾
echo "${p:5}" # og/app.log 从第 5 个字符开始
echo "${p:5:3}" # og/ 从第 5 个开始取 3 个
echo "${p: -3}" # log 最后 3 个(注意冒号后有空格)
echo "${p^^}" # /VAR/LOG/APP.LOG 转大写(bash 4+)
echo "${p,,}" # 转小写
echo "${p^}" # 首字母大写
# 一个记忆技巧:# 在键盘上 $ 左边 -> 删前缀;% 在 $ 右边 -> 删后缀
# 单个 = 最短匹配,双个 = 最长匹配
4.8 命令替换与算术展开
# 命令替换
v=$(date +%F) # ✅ 推荐:可嵌套、可读
v=`date +%F` # 旧式反引号:不能嵌套,转义规则混乱
v=$(dirname "$(readlink -f "$0")") # 嵌套很自然
# ⚠️ 命令替换会【去掉末尾的所有换行】
v=$(printf 'a\n\n\n')
echo "[$v]" # [a]
# 想保留就加个哨兵
v=$(printf 'a\n\n\n'; echo x); v=${v%x}
# 算术展开
echo $((1 + 2 * 3)) # 7
echo $((10 / 3)) # 3 整数除法
echo $((10 % 3)) # 1
echo $((2 ** 10)) # 1024
i=5; echo $((i++)) # 5(后置递增)
echo $((0x1F)) # 31 十六进制
echo $((8#17)) # 15 八进制(base#num 语法)
n=5; ((n++)); echo $n # 6 (( )) 用于纯计算,不输出
# ⚠️ 算术里不用加 $
echo $((n + 1)) # ✅ 里面可以直接用变量名
echo $(($n + 1)) # 也行但没必要
# 浮点要用外部工具
echo "scale=2; 10/3" | bc # 3.33
awk 'BEGIN{printf "%.2f\n", 10/3}' # 3.33
python3 -c 'print(10/3)'
5. here-doc、here-string 与进程替换
5.1 here-doc
# 基本形式
cat <<EOF
当前用户: $USER
家目录: $HOME
EOF
# ^^ 【会展开】变量和命令替换
# 阻止展开:给定界符加引号
cat <<'EOF'
这里的 $USER 不会被展开
$(date) 也不会执行
EOF
# ✅ 写配置文件模板、写脚本时几乎总要用这个
# 忽略行首 tab(便于在缩进的代码块里写)
if true; then
cat <<-EOF
这行前面的 tab 会被去掉
EOF
fi
# ⚠️ <<- 只忽略【tab】,不忽略空格
# 实用场景
sudo tee /etc/systemd/system/app.service > /dev/null <<'EOF'
[Unit]
Description=My App
[Service]
ExecStart=/usr/local/bin/app
EOF
mysql -u root <<SQL
CREATE DATABASE IF NOT EXISTS mydb;
GRANT ALL ON mydb.* TO 'user'@'%';
SQL
ssh host <<'REMOTE'
cd /opt/app
git pull
systemctl --user restart app
REMOTE
5.2 here-string
grep ERROR <<< "$log_content" # 把变量内容作为 stdin
read -r a b <<< "hello world" # 拆分字符串到变量 ← 常用
jq . <<< "$json"
base64 -d <<< "$encoded"
# 与 echo | 的区别:不起子进程,且不受管道子 shell 影响
read v <<< "42"; echo "$v" # 42 ✅
echo "42" | read v; echo "$v" # (空)❌ 管道子 shell
5.3 进程替换:把命令输出当文件
<(cmd) # 命令的输出变成一个"文件"(实际是 /dev/fd/63 这样的路径)
>(cmd) # 命令的输入变成一个"文件"
# 看它的真实面目
echo <(date)
# /dev/fd/63 <- 一个匿名管道的 fd 路径
# ── 核心用途:比较两个命令的输出 ──
diff <(ls dir1) <(ls dir2) # ✅ 无需临时文件
diff <(sort a.txt) <(sort b.txt)
diff <(ssh host1 'rpm -qa' | sort) <(ssh host2 'rpm -qa' | sort)
comm -12 <(sort a) <(sort b) # 交集
# ── 避免管道子 shell ──
while read line; do ((n++)); done < <(grep ERROR log)
# ^ ^^^^^^^^^^^^^^^^ 注意 < 和 <( 之间有空格
# ── 多路输出 ──
cmd > >(gzip > out.gz) 2> >(gzip > err.gz) # 输出边压缩
cmd | tee >(grep ERROR > errors.txt) >(wc -l > count.txt) > /dev/null
# ── 给只接受文件名参数的命令喂动态内容 ──
mysql --defaults-extra-file=<(printf '[client]\npassword=%s\n' "$PASS")
# ^^ 密码不出现在进程列表和历史里,比 -p 安全
openssl dgst -sha256 <(tar -c dir)
# ⚠️ 进程替换是 bash/zsh 扩展,不是 POSIX(#!/bin/sh 里不能用)
# ⚠️ 不能 seek,所以需要随机访问的命令用不了
6. 命名管道(FIFO)
mkfifo /tmp/mypipe # 创建一个命名管道(在文件系统里可见)
ls -l /tmp/mypipe
# prw-r--r-- 1 lhx lhx 0 Aug 12 19:30 /tmp/mypipe
# ^ p = pipe
# 终端 1
cat /tmp/mypipe # 阻塞等待数据
# 终端 2
echo "hello" > /tmp/mypipe # 终端 1 立即显示 hello
# 与匿名管道的区别:
# ① 有文件系统里的名字,【不相关的进程】也能通信
# ② 生命周期独立于进程(要手动 rm)
# ③ 打开时会阻塞,直到另一端也打开
# 实用场景:让两个已在运行的程序对接
mkfifo /tmp/dbdump
mysqldump mydb > /tmp/dbdump & # 一端写
gzip < /tmp/dbdump > backup.sql.gz # 一端读(不占中间磁盘)
rm /tmp/dbdump
# 实用场景:给容器/服务发指令
mkfifo /run/myapp/cmd
# 服务端: while read cmd < /run/myapp/cmd; do handle "$cmd"; done
# 客户端: echo "reload" > /run/myapp/cmd
7. 知识点扩展
7.1 重定向语法速查
见 2.3 的完整表格。最常用的六个:
cmd > f # stdout 覆盖写文件
cmd >> f # stdout 追加
cmd 2> f # stderr 写文件
cmd > f 2>&1 # 两者都进 f(顺序不能反!)
cmd &> f # 同上,bash 简写
cmd < f # 从文件读
7.2 tee:分流
| 参数 | 作用 |
|---|---|
| (无) | 写入文件并输出到 stdout |
-a |
--append 追加而不是覆盖 |
-i |
--ignore-interrupts 忽略 SIGINT |
-p |
检测写入错误(配合管道) |
--output-error=warn|exit |
出错时的行为 |
cmd | tee out.log # 显示 + 保存
cmd 2>&1 | tee -a build.log # 连错误一起,追加
echo "x" | sudo tee /etc/f # ✅ 用 sudo 写文件
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward
cmd | tee >(grep ERR > e.log) > full.log # 多路分流
cmd | tee /dev/tty | wc -l # 显示在终端同时统计
7.3 xargs 与管道的关系
# 有些命令不读 stdin,需要 xargs 转成参数(见 07 篇)
echo /tmp/f | rm # ❌ rm 不读 stdin
echo /tmp/f | xargs rm # ✅
# 有些命令两者都支持
echo "text" | grep t # ✅ grep 读 stdin
grep t file # ✅ 也接受文件名参数
7.4 常用「静默」写法
cmd > /dev/null # 丢弃 stdout
cmd 2> /dev/null # 丢弃 stderr(常用于忽略 permission denied)
cmd > /dev/null 2>&1 # 全部丢弃(POSIX)
cmd &> /dev/null # 全部丢弃(bash)
cmd 2>&1 | grep -v '^$' # 合并后过滤空行
find / -name x 2>/dev/null # 典型:忽略无权限的报错
7.5 检查与调试
set -x # 打印每条命令展开后的样子 ← 调试展开问题的利器
set +x # 关闭
bash -x script.sh # 整个脚本开启追踪
PS4='+ ${BASH_SOURCE}:${LINENO}: ' # 让 -x 的输出带文件名和行号
# 只看展开结果不执行
set -n # 只做语法检查
bash -n script.sh # 检查脚本语法
echo cmd args # 手动在前面加 echo 看展开成什么
# 查看 fd 状态
ls -l /proc/$$/fd/ # 当前 shell 的 fd
ls -l /proc/<pid>/fd/ # 某个进程的 fd
lsof -p <pid> # 同上,更详细
# ShellCheck:静态检查(强烈推荐)
shellcheck script.sh
# 它能查出未加引号的变量、$@ vs $*、2>&1 位置错误等一大批问题
8. 面试题
Q:cmd > f 2>&1 和 cmd 2>&1 > f 有什么区别?
第一个把 stdout 和 stderr 都写进 f;第二个只有 stdout 进 f,stderr 仍去终端。
原因是重定向从左到右依次执行,2>&1 的含义是「把 fd 2 指向 fd 1 当前指向的目标」,它复制的是「当时的指向」而不是建立绑定关系:
> f 2>&1:先让 fd 1 指向 f,再让 fd 2 指向「fd 1 当前的目标」= f ✅2>&1 > f:先让 fd 2 指向「fd 1 当前的目标」= 终端,再让 fd 1 指向 f,fd 2 不受影响 ❌
记忆方法:2>&1 永远写在最后。 bash 也提供了不会写错的简写 &> f(但它不是 POSIX,#!/bin/sh 脚本里要用 > f 2>&1)。
Q:sudo echo "x" > /etc/protected 为什么会 Permission denied?
因为重定向是由当前 shell 完成的,而不是被执行的命令。shell 在 fork 之后、exec 之前就调用了 open("/etc/protected", O_WRONLY|O_CREAT|O_TRUNC)——此时执行 open 的是普通用户身份的 shell,sudo 还没起作用(它只作用于 echo 这个程序)。
正确做法:
echo "x" | sudo tee /etc/protected # 让提权的 tee 去写
sudo sh -c 'echo "x" > /etc/protected' # 让提权的 shell 去做重定向
sudo tee /etc/protected <<< "x"
同一个原理还解释了两件事:① nosuchcommand > important.txt 会清空文件(命令不存在,但 O_TRUNC 已经执行了);② sort f > f 得到空文件(f 先被截断,sort 才开始读),要用 sort -o f f。
Q:cat f | while read l; do ((n++)); done; echo $n 为什么输出 0?
因为管道的每一段都在子进程里执行。while 循环在子 shell 里给 n 加了值,子进程退出后改动就消失了——和 01 篇讲的「cd 必须是 builtin」是同一个道理:子进程改不了父进程的状态。
四种解法:
while read l; do ((n++)); done < f # ✅ 用重定向代替管道
while read l; do ((n++)); done < <(grep E f) # ✅ 进程替换
shopt -s lastpipe; set +m # ✅ bash 4.2+,让最后一段在当前 shell 跑
n=$(grep -c E f) # ✅ 干脆别用循环
同类现象:echo x | cd /tmp 不改变当前目录,echo 42 | read v 拿不到值(要用 read v <<< "42")。
Q:cat f | wc -l 和 wc -l < f 有什么区别?
三点差异:
- 性能:前者多一个
fork+exec(cat进程)和一次管道数据拷贝 - 能力:从管道读不能
seek。wc -c < bigfile可以直接lseek到末尾瞬间拿到大小,而cat bigfile | wc -c必须把全部数据读一遍;tail -1 f同理 - 子 shell:管道会引入子进程,带来上一题的变量丢失问题
但真的要拼接多个文件时 cat a b | cmd 是合理的。判断标准:只有一个输入文件时不要用 cat。
Q:shell 的展开有几个阶段?为什么 f="my file"; ls $f 会失败?
七个阶段,顺序固定:① 花括号 → ② 波浪号 → ③ 变量/参数 → ④ 命令替换 → ⑤ 算术 → ⑥ 分词(IFS)→ ⑦ 路径展开(glob),之后才去引号、做重定向、执行。
ls $f 失败是因为 ③ 变量展开发生在 ⑥ 分词之前:$f 先被替换成 my file,然后分词把它切成两个参数 my 和 file,ls 收到两个不存在的文件名。加双引号 ls "$f" 就阻止了分词。
这个顺序还解释了另外三个现象:变量里的 * 会被 glob 展开(p="*.txt"; echo $p);花括号展开不能用变量(n=3; echo {1..$n} 输出字面量,因为 ① 早于 ③);变量里的 ~ 不会被展开(② 早于 ③)。
铁律:变量引用永远加双引号。
Q:"$@" 和 "$*" 有什么区别?为什么转发参数必须用 "$@"?
假设收到三个参数 a、b c、d:
"$*"→ 拼成一个字符串"a b c d"(用 IFS 第一个字符连接),得到 1 个词"$@"→ 每个参数独立成词"a" "b c" "d",得到 3 个词 ✅- 不加引号时
$@和$*一样,都会被分词成 4 个词(b c被拆开)
所以只有 "$@" 能完整保留原始参数边界,转发参数、写 wrapper 脚本必须用它。数组同理:用 "${arr[@]}" 而不是 ${arr[*]}。
Q:容器的 entrypoint 脚本为什么必须用 exec 启动主程序?
不用 exec 的话,shell 会 fork 出子进程运行主程序,shell 自己成为 PID 1。而 docker stop / K8s 优雅关闭发送的 SIGTERM 是发给 PID 1 的——shell 收到后既不会转发给子进程,也不会做任何处理,于是主程序毫无察觉,10 秒(默认 terminationGracePeriodSeconds)后被 SIGKILL 强杀,来不及关连接、flush 日志、完成正在处理的请求。
exec ./myapp 用新程序替换当前进程映像(不 fork,PID 不变),主程序直接成为 PID 1,SIGTERM 直达它,可以正常执行 graceful shutdown。
补充:exec 不带命令时是另一个用法——永久改变当前 shell 自己的 fd,如脚本开头写 exec > /var/log/script.log 2>&1 就能把之后所有输出落盘。
Q:<(cmd) 进程替换有什么用?它的本质是什么?
它把命令的输出伪装成一个文件名,实际是一个匿名管道的 fd 路径(echo <(date) 会输出 /dev/fd/63)。
三个典型用途:
- 比较两个命令的输出,不用临时文件:
diff <(ls d1) <(ls d2)、comm -12 <(sort a) <(sort b) - 避免管道子 shell:
while read l; do ...; done < <(grep E f)—— 循环在当前 shell 里,变量修改有效 - 给只接受文件名的命令喂动态内容,且避免敏感信息进入进程列表:
mysql --defaults-extra-file=<(printf '[client]\npassword=%s\n' "$PASS")比-p"$PASS"安全得多
注意它是 bash/zsh 扩展(不是 POSIX,#!/bin/sh 里不可用),而且不能 seek,需要随机访问的命令用不了。
小结
- fd 是进程打开文件表的下标,0/1/2 分别是 stdin/stdout/stderr。分成两个输出 fd 才能分别处理;
|只接管 fd 1,所以 stderr 会「穿过」管道 - 重定向由 shell 在
exec之前完成,命令本身对此毫不知情。这解释了sudo cmd > /etc/f失败、命令不存在也会清空文件、sort f > f得到空文件 2>&1是「复制 fd 1 当时的指向」,所以必须写在最后;用&>更保险- 管道两端是两个并发进程,中间是内核的环形缓冲区。每段都在子 shell 里 → 变量修改会丢失,用重定向或进程替换代替
- 展开有固定的七个阶段:花括号 → 波浪号 → 变量 → 命令替换 → 算术 → 分词 → glob。变量展开早于分词,这是「必须加双引号」的根本原因
"$@"是唯一能保留参数边界的写法exec有两个用法:替换当前进程(容器 entrypoint 必用)、改变当前 shell 的 fd(脚本日志落盘)- 进程替换
<(cmd)把输出伪装成文件,用于diff、避免子 shell、隐藏敏感参数
下一篇讲 用户、组与权限的实操:useradd 与 adduser 的区别、/etc/passwd 与 shadow 的分工、chmod 的数字与符号两套语法、SUID/SGID/Sticky 怎么用、ACL 细粒度授权、以及 sudoers 的正确写法。
xingliuhua