目录

Linux-09 管道、重定向与 shell 展开:2>&1 的位置、fd 的本质与七个展开阶段

这一篇讲的是 shell 里最容易「用对但说不清」的部分。它的价值在于:一旦理解了「重定向在命令执行前完成」和「展开有固定的七个阶段」,一大批诡异现象会同时变得可解释。

先看五个问题:

  1. cmd > f 2>&1cmd 2>&1 > f 有什么区别?为什么?
  2. echo hi > f 这条命令里,是 echo 打开了文件 f 吗?
  3. cat a.txt | wc -lwc -l < a.txt,哪个更好?为什么?
  4. count=0; cat f | while read l; do ((count++)); done; echo $count 为什么输出 0?
  5. 为什么 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              # ✅ 也行(输出会带文件名)

差异有三点:

  1. 性能cat f | cmd 多一个 fork+exec 和一次管道拷贝
  2. 能力:从管道读的程序不能 seek(管道不可随机访问),有些命令因此行为不同
  3. 子进程问题:如 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>&1cmd 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 -lwc -l < f 有什么区别?

三点差异:

  1. 性能:前者多一个 fork+execcat 进程)和一次管道数据拷贝
  2. 能力:从管道读不能 seekwc -c < bigfile 可以直接 lseek 到末尾瞬间拿到大小,而 cat bigfile | wc -c 必须把全部数据读一遍;tail -1 f 同理
  3. 子 shell:管道会引入子进程,带来上一题的变量丢失问题

但真的要拼接多个文件时 cat a b | cmd 是合理的。判断标准:只有一个输入文件时不要用 cat

Q:shell 的展开有几个阶段?为什么 f="my file"; ls $f 会失败?

七个阶段,顺序固定:① 花括号 → ② 波浪号 → ③ 变量/参数 → ④ 命令替换 → ⑤ 算术 → ⑥ 分词(IFS)→ ⑦ 路径展开(glob),之后才去引号、做重定向、执行。

ls $f 失败是因为 ③ 变量展开发生在 ⑥ 分词之前$f 先被替换成 my file,然后分词把它切成两个参数 myfilels 收到两个不存在的文件名。加双引号 ls "$f" 就阻止了分词。

这个顺序还解释了另外三个现象:变量里的 * 会被 glob 展开p="*.txt"; echo $p);花括号展开不能用变量n=3; echo {1..$n} 输出字面量,因为 ① 早于 ③);变量里的 ~ 不会被展开(② 早于 ③)。

铁律:变量引用永远加双引号。

Q:"$@""$*" 有什么区别?为什么转发参数必须用 "$@"

假设收到三个参数 ab cd

  • "$*" → 拼成一个字符串 "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)。

三个典型用途:

  1. 比较两个命令的输出,不用临时文件:diff <(ls d1) <(ls d2)comm -12 <(sort a) <(sort b)
  2. 避免管道子 shellwhile read l; do ...; done < <(grep E f) —— 循环在当前 shell 里,变量修改有效
  3. 给只接受文件名的命令喂动态内容,且避免敏感信息进入进程列表: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、隐藏敏感参数

下一篇讲 用户、组与权限的实操useraddadduser 的区别、/etc/passwdshadow 的分工、chmod 的数字与符号两套语法、SUID/SGID/Sticky 怎么用、ACL 细粒度授权、以及 sudoers 的正确写法。