管道与文件描述符
第11章:管道、重定向与文件描述符深度解析
Linux 的 "一切皆文件" 哲学在管道与文件描述符上体现得淋漓尽致。理解文件描述符的工作机制——进程如何通过编号 0/1/2 读写数据流,管道如何在内核缓冲区中传递字节——是写出高效、可靠 Shell 脚本的核心基础。本章从内核视角拆解重定向与管道的完整实现原理。
11.1 文件描述符基础:0 / 1 / 2
**文件描述符(File Descriptor,FD)**是内核为每个打开的文件或数据流分配的非负整数索引。每个进程拥有自己独立的 FD 表,进程启动时默认打开三个:
- 0 — stdin:标准输入,默认来自键盘
- 1 — stdout:标准输出,默认写到终端
- 2 — stderr:标准错误,默认写到终端
Process (bash, PID 1234) ┌──────────────────────────────────────────┐ │ FD Table │ │ ┌────┬──────────────────────────────┐ │ │ │ 0 │ → /dev/pts/0 (stdin) │ │ │ │ 1 │ → /dev/pts/0 (stdout) │ │ │ │ 2 │ → /dev/pts/0 (stderr) │ │ │ │ 3 │ → /var/log/app.log (custom) │ │ │ └────┴──────────────────────────────┘ │ └──────────────────────────────────────────┘
After: exec 1>app.log ┌──────────────────────────────────────────┐ │ FD Table │ │ ┌────┬──────────────────────────────┐ │ │ │ 0 │ → /dev/pts/0 (stdin) │ │ │ │ 1 │ → app.log (stdout now!) │ │ │ │ 2 │ → /dev/pts/0 (stderr) │ │ │ └────┴──────────────────────────────┘ │ └──────────────────────────────────────────┘
内核通过 `/proc/PID/fd/` 目录暴露进程的 FD 表,每个条目是一个指向实际文件的符号链接。使用 `lsof -p` 可以获得更友好的格式。
# 查看当前 bash 进程的文件描述符
ls -la /proc/$$/fd
# lrwx------ 1 user user 64 Apr 25 10:00 0 -> /dev/pts/0
# lrwx------ 1 user user 64 Apr 25 10:00 1 -> /dev/pts/0
# lrwx------ 1 user user 64 Apr 25 10:00 2 -> /dev/pts/0
# 查看某个进程的 FD(以 PID 1234 为例)
ls -la /proc/1234/fd
# 用 lsof 查看进程打开的文件描述符
lsof -p $$
# 或过滤只看 FD 列
lsof -p $$ | awk 'NR==1 || $4 ~ /^[0-9]/'
# 查看 FD 数量限制
ulimit -n # 当前软限制(通常 1024)
cat /proc/sys/fs/file-max # 系统级最大 FD 数
11.2 重定向操作符全解
重定向的本质是在 fork() 之后、exec() 之前修改子进程的 FD 表,使标准流指向文件而非终端。Shell 提供了一整套简洁的语法来完成这一内核操作。
# === 输出重定向 ===
command > file # stdout 覆盖写入 file(FD1 → file)
command >> file # stdout 追加写入 file
command 2> file # stderr 覆盖写入 file(FD2 → file)
command 2>> file # stderr 追加写入 file
# === 合并 stderr 到 stdout ===
command 2>&1 # 将 FD2 复制为 FD1 的副本(两者指向同一目标)
command > file 2>&1 # stdout+stderr 都写入 file(顺序重要!)
command 2>&1 > file # 错误写法:stderr 先指向旧 stdout(终端),再重定向 stdout
# bash 4+ 的简写(等价于 > file 2>&1)
command &> file
command &>> file # 追加版本
# === 丢弃输出 ===
command > /dev/null # 丢弃 stdout
command 2> /dev/null # 丢弃 stderr
command > /dev/null 2>&1 # 丢弃所有输出
command &> /dev/null # 简写
# === 输入重定向 ===
command 不能覆盖已存在的文件
echo "test" > existing.txt # 报错:cannot overwrite existing file
echo "test" >| existing.txt # 强制覆盖(绕过 noclobber)
set +C # 关闭 noclobber
# === 实用组合示例 ===
# 同时记录 stdout 和 stderr 到各自文件
command > out.log 2> err.log
# 编译并只看错误
make 2>&1 | grep -i error
# 丢弃 stdout,只留 stderr
command > /dev/null
# 测试命令是否成功(不显示任何输出)
if grep -q "pattern" file 2>/dev/null; then
echo "found"
fi
陷阱:2>&1 的顺序
command > file 2>&1与command 2>&1 > file的效果完全不同。Shell 从左到右处理重定向:前者先将 FD1 指向 file,再将 FD2 复制为 FD1(也指向 file);后者先将 FD2 复制为当前 FD1(终端),再将 FD1 指向 file——stderr 仍然输出到终端。
| 操作符 | 作用 | 等价形式 |
|---|---|---|
| > file | stdout 覆盖写 | 1> file |
| >> file | stdout 追加写 | 1>> file |
| 2> file | stderr 覆盖写 | — |
| 2>&1 | stderr → stdout 目标 | — |
| &> file | stdout+stderr 写入 | > file 2>&1 |
| stdin 来自文件 | 0 | |
| > | file | 强制覆盖(绕过 noclobber) |
11.3 管道原理:内核缓冲区与子进程
管道 | 是 Unix 最伟大的发明之一。内核为管道创建一个环形缓冲区(默认 65536 字节,即 64 KB),左侧命令的 stdout 连接缓冲区的写端,右侧命令的 stdin 连接缓冲区的读端。两侧命令并发运行,内核负责协调数据流动。
# 基本管道:ls 的 stdout → grep 的 stdin
ls -la | grep ".sh"
# 多级管道
cat /var/log/syslog | grep "error" | sort | uniq -c | sort -rn | head -20
# |& 同时传递 stdout 和 stderr(bash 4+)
command |& grep "ERROR"
# 等价于:command 2>&1 | grep "ERROR"
# 查看管道缓冲区大小(Linux 默认 65536 字节)
cat /proc/sys/fs/pipe-max-size
# 管道状态:当缓冲区满时,写端阻塞;缓冲区空时,读端阻塞
# 利用这个特性可以实现背压(backpressure)
# 管道的退出状态问题
ls nonexistent | wc -l # ls 失败,但整个管道退出码是 wc 的退出码(0!)
echo $? # 0 — 掩盖了 ls 的错误
# 用 PIPESTATUS 获取管道中每个命令的退出码(bash 专有)
ls nonexistent | wc -l
echo "${PIPESTATUS[@]}" # 例:2 0 (ls 失败=2,wc 成功=0)
# set -o pipefail:让管道返回最右非零退出码(推荐!)
set -o pipefail
ls nonexistent | wc -l
echo $? # 现在是 2(ls 的退出码)
管道中的变量作用域问题
管道右侧的命令在子 shell 中运行,这意味着在管道右侧赋值的变量在管道结束后消失。这是 bash 脚本中最常见的"变量丢失"陷阱之一:
# 陷阱示例:count 在子 shell 中赋值,父 shell 看不见
count=0
echo "a b c" | while read word; do
count=$((count + 1))
done
echo "count=$count" # 输出:count=0 — 变量丢失!
# 解决方案 1:用进程替换(避免管道子 shell)
count=0
while read word; do
count=$((count + 1))
done /tmp/count.txt
count=$(cat /tmp/count.txt)
# 检查当前是否在子 shell 中
echo "BASH_SUBSHELL=$BASH_SUBSHELL" # 0=当前shell,1=子shell,2=子子shell
(echo "inside subshell: BASH_SUBSHELL=$BASH_SUBSHELL")
11.4 Here-Document:多行输入的优雅写法
Here-document(`
11.5 Here-String:单行字符串给 stdin
`
11.6 进程替换:()
进程替换(Process Substitution)是 bash/zsh 的高级特性,允许将命令的输出或输入伪装成文件路径(通过 /dev/fd/N 或 /proc/self/fd/N)。这解决了管道无法处理"需要两个文件参数"的场景。
# (command):将文件输出传给命令(可写)
# tee 同时写入文件和进一步处理
tee >(gzip > backup.gz) /dev/null
# 同时发送到两个处理管道
command | tee >(grep "ERROR" > errors.log) >(grep "WARN" > warnings.log) > /dev/null
# 结合使用:从进程替换读取并写入进程替换
cmp **为什么不用临时文件?** 进程替换相比临时文件有三个优势:1) 无需手动清理;2) 数据在内存中流动,不写磁盘(对大文件友好);3) 并发处理,左右两侧命令同时运行。缺点是仅 bash/zsh 支持,`sh` 不支持。
## 11.7 exec 操作文件描述符
Shell 内置命令 `exec` 不仅可以替换当前进程,还可以在不替换进程的情况下直接修改当前 Shell 的 FD 表。这是脚本级别日志重定向的基础,也是高级 Shell 编程中的核心技术。
```bash
# === 打开自定义文件描述符 ===
exec 3>output.txt # 打开 FD3 用于写(覆盖)
exec 4>>append.txt # 打开 FD4 用于追加写
exec 5bidirectional.txt # 打开 FD6 读写(少用)
# 向 FD3 写入
echo "line 1" >&3
echo "line 2" >&3
# 从 FD5 读取
read line &- # 关闭 FD3(写端)
exec 5"$LOGFILE"
exec 2>&1
echo "This goes to $LOGFILE" # 写入日志
ls /nonexistent # 错误也写入日志
# === 保存并恢复原始 stdout/stderr ===
exec 3>&1 # 将当前 stdout 保存到 FD3
exec 4>&2 # 将当前 stderr 保存到 FD4
exec 1>/tmp/script.log 2>&1 # 重定向
echo "In log"
ls /nonexistent
exec 1>&3 # 恢复 stdout
exec 2>&4 # 恢复 stderr
exec 3>&- # 关闭临时 FD3
exec 4>&- # 关闭临时 FD4
echo "Back to terminal" # 现在输出到终端
# === 使用高编号 FD 避免冲突 ===
# bash 4.1+ 支持 {var} 自动分配 FD(避免硬编码)
exec {myfd}>output.txt
echo "Using auto FD: $myfd" >&$myfd
exec {myfd}>&- # 关闭
生产级日志重定向脚本
#!/usr/bin/env bash
# 生产级脚本:同时输出到终端和日志文件
# 使用 exec + tee 实现双重输出
LOGFILE="/var/log/deploy-$(date +%Y%m%d-%H%M%S).log"
mkdir -p "$(dirname "$LOGFILE")"
# 技巧:用 tee 将所有输出同时发送到终端和日志文件
exec 1> >(tee -a "$LOGFILE") 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Deploy started ==="
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Log file: $LOGFILE"
# 以下所有输出自动记录到日志
echo "Step 1: Pulling latest code..."
git pull origin main
echo "Step 2: Installing dependencies..."
npm install --production
echo "Step 3: Restarting service..."
systemctl restart myapp
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Deploy completed ==="
11.8 命名管道(FIFO):持久化的管道
匿名管道(|)的生命周期与命令相同,而**命名管道(FIFO)**是文件系统中的一种特殊文件,允许无亲缘关系的进程之间通过文件路径进行通信。FIFO 同样是内核缓冲区,但通过文件名持久存在。
# 创建命名管道
mkfifo /tmp/mypipe
ls -la /tmp/mypipe
# prw-r--r-- 1 user user 0 Apr 25 10:00 /tmp/mypipe
# 'p' 表示这是 FIFO 文件
# === 生产者 / 消费者模式 ===
# 终端 1(消费者,先启动,会阻塞等待数据)
cat /tmp/mypipe
# 终端 2(生产者,写入数据后消费者自动退出)
echo "Hello from producer" > /tmp/mypipe
# === 后台消费者 + 实时日志 ===
mkfifo /tmp/logpipe
# 后台:持续读取管道数据并写入文件
while true; do
cat /tmp/logpipe >> /var/log/myapp.log 2>/dev/null || break
done &
LOG_READER_PID=$!
# 应用程序写日志到管道
echo "App started" > /tmp/logpipe
echo "Processing..." > /tmp/logpipe
# 清理
kill $LOG_READER_PID
rm /tmp/logpipe
# === 匿名管道 vs 命名管道对比 ===
# 匿名管道:只能用于有亲缘关系的进程(父子关系),命令行中自动创建
# 命名管道:任意进程都可通过文件路径通信,需要 mkfifo 显式创建
# 共同点:都是内核缓冲区,读写同步,单向流动
# 利用 FIFO 实现简单的进程间信号
mkfifo /tmp/ready_signal
# 进程 A:完成初始化后发信号
echo "ready" > /tmp/ready_signal
# 进程 B:等待信号再开始工作
read signal
## 11.9 tee:分流输出
`tee` 像 T 型管接头一样工作:从 stdin 读取数据,同时写入 stdout 和一个或多个文件。这使它成为在管道中途"插入记录"的完美工具。
```bash
# 基本用法:输出到终端的同时保存到文件
ls -la | tee file_list.txt
# -a:追加写(不覆盖)
command | tee -a output.log
# 写入多个文件
command | tee file1.txt file2.txt file3.txt
# 管道继续处理
ls -la | tee /tmp/raw_list.txt | grep "\.sh" | wc -l
# 结合 sudo 写入需要权限的文件(常见用法)
echo "new content" | sudo tee /etc/somefile.conf
# 注意:不能用 sudo echo "..." > /etc/somefile(重定向由普通用户执行)
# tee + 进程替换:一份输出,多个处理管道
command | tee >(grep "ERROR" | mail -s "Errors" [email protected]) \
>(grep "WARN" >> warnings.log) \
> full.log
# 实时监控并记录日志(显示到终端同时写文件)
tail -f /var/log/nginx/access.log | tee -a /tmp/monitoring.log | grep "500"
# 配合时间戳记录构建日志
make 2>&1 | tee >(awk '{print strftime("[%H:%M:%S]"), $0}' > build.log)
11.10 /dev 特殊文件:null / zero / random / tcp
Linux 的 /dev 目录下有一批虚拟设备文件,它们没有对应的物理硬件,而是内核提供的特殊数据源或数据漏。善用这些文件可以写出极其优雅的 Shell 脚本。
# === /dev/null — 黑洞设备 ===
# 读取:立即返回 EOF(空文件)
# 写入:丢弃所有数据
command > /dev/null 2>&1 # 丢弃所有输出
cat /dev/null > file.txt # 清空文件(比 echo -n > file 更语义化)
: > file.txt # 同样效果,更简洁
# === /dev/zero — 零字节生成器 ===
# 无限提供值为 0 的字节(NUL 字符)
# 创建指定大小的空文件(比 fallocate 更通用)
dd if=/dev/zero of=emptyfile bs=1M count=100 # 创建 100MB 全零文件
# 清零敏感数据文件(安全删除前)
dd if=/dev/zero of=secrets.txt bs=1 count=$(stat -c%s secrets.txt)
# === /dev/random 与 /dev/urandom — 随机源 ===
# /dev/random:阻塞式,熵池不足时等待(适合密钥生成)
# /dev/urandom:非阻塞式,熵池不足时用伪随机(适合一般场景)
# 生成随机密码(32字符,base64编码)
head -c 24 /dev/urandom | base64
# 生成随机十六进制字符串
head -c 16 /dev/urandom | xxd -p | tr -d '\n'
# 生成随机 UUID(手动实现)
cat /proc/sys/kernel/random/uuid # 更简单的方式
# 用 $RANDOM 生成简单随机数(范围 0-32767)
echo $RANDOM
echo $(( RANDOM % 100 )) # 0-99 的随机数
# === /dev/tcp — bash 内置 TCP 客户端 ===
# bash 特有功能(非设备文件,由 bash 内部处理)
# 格式:/dev/tcp/host/port
# 测试端口是否开放(比 nc 更便携,不需要安装额外工具)
if (: /dev/null; then
echo "Port 80 is open"
else
echo "Port 80 is closed"
fi
# 发送简单 HTTP 请求
exec 3<>/dev/tcp/example.com/80
echo -e "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n" >&3
cat &-
# 检查服务是否存活(用于监控脚本)
check_port() {
local host=$1 port=$2
(: /dev/null
}
check_port localhost 3306 && echo "MySQL is up" || echo "MySQL is down"
11.11 子 Shell 与管道环境隔离
理解什么时候创建子 shell、子 shell 与父 shell 如何隔离,是调试 Shell 脚本变量"神秘消失"的关键。以下是触发子 shell 的主要场景:
# === 显式子 shell:圆括号 () ===
# 子 shell 继承父 shell 的变量,但修改不影响父 shell
x=10
(
echo "In subshell: x=$x" # 10(继承)
x=999
echo "Modified in subshell: x=$x" # 999
)
echo "In parent: x=$x" # 10(不受影响)
# 子 shell 的 BASH_SUBSHELL 变量
echo "Parent: $BASH_SUBSHELL" # 0
(echo "Child: $BASH_SUBSHELL") # 1
((echo "Grandchild: $BASH_SUBSHELL")) # 注意:这是算术表达式,不是子 shell!
( ( echo "Grandchild: $BASH_SUBSHELL" ) ) # 2(正确嵌套子 shell)
# === 花括号组合命令 {} — 当前 Shell 执行 ===
# 变量修改对父 shell 可见!
y=10
{
y=999
echo "In group: y=$y" # 999
}
echo "After group: y=$y" # 999(变量保留!)
# 注意:{} 内最后一条命令后需要分号,且 { 后需要空格
# === 管道中的子 shell ===
# 管道的每个组件(默认)都在子 shell 中
VAR=""
echo "hello" | VAR=$(cat); echo "VAR=$VAR" # VAR=(空,子 shell)
# 正确做法:用进程替换避免子 shell
VAR=$(echo "hello"); echo "VAR=$VAR" # VAR=hello
# === 命令替换 $() 也是子 shell ===
result=$(
x=100
echo $((x * 2))
)
echo "result=$result" # result=200
echo "x=$x" # x=(空,x 在子 shell 中定义)
# === 后台命令 & 也是子 shell ===
bg_var=""
(bg_var="set in background") &
wait
echo "bg_var=$bg_var" # 空
章节总结: 本章从内核层面理解了文件描述符、重定向与管道的完整工作链路。核心要点:重定向是 fork+exec 之间的 FD 表修改;管道是 64KB 内核缓冲区;
2>&1的顺序至关重要;管道右侧运行在子 shell 中(变量不传递);exec N>file可以持久操作当前 Shell 的 FD;命名管道实现跨进程通信。掌握这些机制后,下一章的脚本工程化将如鱼得水。
上一章
← 第10章:函数与数组
下一章
第12章:脚本工程化 →