19 - IO 重定向与管道深入
重定向和管道是 Linux 哲学”小工具组合”的核心机制。理解标准流、重定向操作符和管道缓冲,能让你编写出灵活高效的命令行流水线。
19.1 标准流概述
Linux 每个进程默认打开三个文件描述符:
| 文件描述符 | 名称 | 缩写 | 默认目标 | 惯常用途 |
|---|---|---|---|---|
| 0 | 标准输入 | stdin | 键盘 | 接收数据 |
| 1 | 标准输出 | stdout | 终端 | 输出正常结果 |
| 2 | 标准错误 | stderr | 终端 | 输出错误/诊断信息 |
# 验证三个文件描述符
echo "这是标准输出"
echo "这是标准错误" >&2
# 通过 /proc 查看进程的文件描述符
ls -la /proc/$$/fd/
# 输出示例:
# 0 -> /dev/pts/0 (stdin)
# 1 -> /dev/pts/0 (stdout)
# 2 -> /dev/pts/0 (stderr)
# 255 -> /dev/pts/0
# $0 不是标准流!$0 是脚本文件名(Shell 特殊变量),与文件描述符无关19.2 输出重定向
基本操作符
# 重定向 stdout 到文件(覆盖)
command > file
command 1> file # 1> 等同于 >
# 重定向 stdout 到文件(追加)
command >> file
command 1>> file
# 重定向 stderr 到文件
command 2> error.log
command 2>> error.log # 追加模式
# 分别重定向 stdout 和 stderr 到不同文件
command > output.log 2> error.log
command >> output.log 2>> error.log # 追加
# 合并重定向 — stdout 和 stderr 写入同一个文件
command &> all.log # Bash 特有
command > all.log 2>&1 # POSIX 方式(推荐,更可移植)
# 追加模式合并
command &>> all.log # Bash
command >> all.log 2>&1 # POSIX
# 只重定向 stderr 到 stdout
command 2>&1 # stderr 合并到 stdout (当前 stdout 指向)
command 2>&1 > /dev/null # stderr 合并到原 stdout,然后 stdout 到 /dev/null
# 结果: stdout 丢弃, stderr 显示在终端重定向顺序陷阱
# 重定向顺序很重要!从左到右解释
# 正确:将 stderr 定向到当前 stdout 所指向的位置,然后 stdout 重定向到文件
command > file 2>&1 # stdout 到 file,stderr 到 file
# 错误:
command 2>&1 > file # stderr 到终端(stdout 原始位置),stdout 到 file
# 结果:stderr 仍在终端,只有 stdout 写入 file
# 可视化理解:
# 初始: stdout → 终端, stderr → 终端
# 执行 2>&1 后: stderr → 终端 (stdout 的当前指向)
# 执行 > file 后: stdout → file, stderr 仍指向终端(不受后来 stdout 改变的影响)|& — 管道中的 stderr
# |& 将 stdout 和 stderr 一起传给管道(Bash 4.0+)
command |& other_command
# 等价于:
command 2>&1 | other_command
# 示例:搜索包含错误信息的输出
make |& grep -E "error|warning"
# 只将 stderr 传给管道
# 方案一:先交换 stderr 和 stdout
command 3>&1 1>&2 2>&3 | grep "error"
# 方案二:进程替换
command 2> >(grep "error" > error.log)19.3 输入重定向
# 从文件读取输入
command < file
command 0< file
# 标准输入到变量(通过管道或重定向)
# 方式一:Here String
read -r var <<< "hello world"
# 方式二:重定向往脚本传递
wc -l < /etc/passwd # 只输出行数(不输出文件名)
# 方式三:多条命令共享输入
{
read -r line1
read -r line2
echo "$line1 | $line2"
} < file.txt
# 方式四:在脚本中内嵌数据
while read -r line; do
echo "$line"
done << 'EOF'
这是内嵌数据第一行
这是内嵌数据第二行
EOF
# 方式五:用 exec 永久重定向
exec < input.txt # 此后所有 read 从 input.txt 读取
while read -r line; do
echo "$line"
done
exec < /dev/tty # 恢复从终端读取19.4 高级重定向技巧
自定义文件描述符(3-9)
# 打开文件描述符进行读写
exec 3> output3.txt # 打开 fd 3 写入
exec 4< input4.txt # 打开 fd 4 读取
exec 5<> fifo_pipe # 打开 fd 5 读写(常用于 FIFO)
# 写入自定义 fd
echo "写到 fd 3" >&3
echo "标准输出" >&1
# 从自定义 fd 读取
read -r line <&4
# 关闭自定义 fd
exec 3>&- # 关闭 fd 3
exec 4<&- # 关闭 fd 4
# 重定向到多个输出
echo "同时输出到终端和文件" | tee /tmp/output.txt
# 使用 fd 保存原始 stdout 以便恢复
exec 3>&1 # 保存 stdout 到 fd 3
exec > /tmp/output.txt # stdout 重定向到文件
echo "这行写入文件"
exec 1>&3 # 恢复 stdout
exec 3>&- # 关闭 fd 3
echo "这行回到终端"批量应用重定向
# 在代码块中应用重定向
{
echo "第一行"
echo "第二行"
date
whoami
} > combined_output.txt
# 函数输出重定向
myfunc() {
echo "函数输出1"
echo "函数输出2" >&2
echo "函数输出3"
}
myfunc > stdout.log 2> stderr.log
# for 循环重定向
for i in {1..5}; do
echo "Line $i"
done > loop_output.txt
# 条件块重定向
if grep -q "root" /etc/passwd; then
echo "root 用户存在"
else
echo "root 用户不存在"
fi > check_result.txt19.5 管道机制
管道基础
# 管道将 stdout 连接到一个命令的 stdin
command1 | command2 | command3
# 示例
cat /var/log/pacman.log | grep "installed" | sort | uniq -c | sort -rn
ps aux | grep nginx | awk '{print $2}' | xargs kill -HUP
# 管道缓冲
# 管道有缓冲区(通常 64KB)
# 产生者写满缓冲区 → 阻塞 → 消费者读取 → 空间释放
# 这实现了自然的背压(backpressure)机制
# 观察管道缓冲行为
(while true; do echo "data"; done) | (sleep 1; head -3)
# SIGPIPE 信号
# 当管道读取端关闭而写入端继续写入时,写入端收到 SIGPIPE
# 默认行为:进程终止
# 示例:
yes | head -5 # yes 在 head 退出后收到 SIGPIPE 终止
# 查看管道中的退出码
false | true
echo "${PIPESTATUS[0]} ${PIPESTATUS[1]}" # 1 0
# PIPESTATUS 是 Bash 数组,保存管道中每个命令的退出码
# 配合 set -o pipefail
set -o pipefail
false | true
echo "$?" # 1 (非零,因为有命令失败)管道实战模式
# 模式 1:多级过滤链
ps aux | grep -v grep | grep "python" | awk '{print $2, $11}'
# 模式 2:产生 → 排序 → 去重 → 计数
find . -type f -name "*.*" | sed 's/.*\.//' | sort | uniq -c | sort -rn
# 模式 3:控制管道中的子 shell
# 问题:管道右侧在子 shell 中执行,变量修改不会保留
count=0
find . -type f | while read -r file; do
((count++))
done
echo "count=$count" # 0!在子 shell 中的修改丢失了
# 解决方案 1:进程替换
count=0
while read -r file; do
((count++))
done < <(find . -type f)
echo "count=$count" # 正确的值
# 解决方案 2:使用命名管道
mkfifo /tmp/count_pipe
find . -type f > /tmp/count_pipe &
while read -r file; do
((count++))
done < /tmp/count_pipe
rm /tmp/count_pipe
# 解决方案 3:lastpipe 选项(Bash 4.2+)
shopt -s lastpipe # 管道最后一个命令在当前 shell 中执行
count=0
find . -type f | while read -r file; do
((count++))
done
echo "count=$count" # 正确!19.6 tee — 分流输出
# 基本用法:同时输出到文件和终端
echo "hello" | tee output.txt
# 追加模式
echo "world" | tee -a output.txt
# 写入多个文件
echo "data" | tee file1.txt file2.txt file3.txt
# 配合管道继续传递
./long_running_script 2>&1 | tee full.log | grep -E "ERROR|WARN"
# 需要 sudo 权限写入文件时
echo "config_line" | sudo tee /etc/some_config.conf
echo "config_line2" | sudo tee -a /etc/some_config.conf
# 丢弃 stdout,只写文件(模拟正常重定向)
echo "data" | tee file.txt > /dev/null
# 使用进程替换(Bash)分流到多个命令
echo "data" | tee >(gzip > data.gz) >(wc -c > size.txt) > /dev/null
# 实用模式:调试管道
# 在管道的任意位置插入 tee 查看中间结果
command1 | tee /dev/stderr | command2 | tee /dev/stderr | command319.7 xargs — 将标准输入转换为命令行参数
基础用法
# 基本:将输入转为参数
echo "file1 file2 file3" | xargs rm
# 等价于: rm file1 file2 file3
# 控制参数放置位置(-I 占位符)
find . -name "*.log" | xargs -I {} mv {} /tmp/logs/{}
find . -name "*.txt" | xargs -I % sh -c 'echo "文件: %"; wc -l %'
# 控制每次调用传递的参数数量(-n)
echo {1..10} | tr ' ' '\n' | xargs -n 3 echo
# 输出:
# 1 2 3
# 4 5 6
# 7 8 9
# 10
# 并行执行(-P)
echo {1..20} | tr ' ' '\n' | xargs -P 4 -I {} sh -c 'echo "处理 {}"; sleep 2'
# 交互确认(-p)
find . -name "*.tmp" | xargs -p rm
# 每个命令执行前询问处理特殊字符
# 默认情况下 xargs 会解释引号和反斜杠,并按空白分割输入
# 这可能导致文件名中的空格和特殊字符造成问题
# 使用 -0(空字符分隔,配合 find -print0)
find . -name "*.log" -print0 | xargs -0 rm
find . -type f -print0 | xargs -0 grep "pattern"
# 处理包含空格的文件名
find . -name "*.txt" -print0 | xargs -0 -I {} cp {} /backup/
# 限制最大参数长度(-s)
find /usr -name "*.so" -print0 | xargs -0 -s 65536 ls -lxargs 实战例子
# 批量重命名文件
ls *.jpg | xargs -I {} mv {} {}.bak
# 并行压缩多个文件
find . -name "*.log" -print0 | xargs -0 -P 4 -I {} gzip {}
# 批量下载(配合 curl 和文件中的 URL 列表)
xargs -n 1 -P 8 curl -O < urls.txt
# 批量删除 Docker 容器
docker ps -a -q | xargs docker rm
# 批量 kill 进程
ps aux | grep "worker" | grep -v grep | awk '{print $2}' | xargs kill -TERM
# 安全批量操作(先 dry-run)
find . -name "*.tmp" | xargs echo rm # 回显将执行的命令
find . -name "*.tmp" -print0 | xargs -0 rm # 确认无误后再实际执行
# 统计目录下所有文件的行数
find . -name "*.py" -print0 | xargs -0 wc -l | tail -119.8 特殊设备文件
# /dev/null — 数据黑洞(丢弃所有写入,读取返回 EOF)
command > /dev/null 2>&1 # 完全静默
grep -q "pattern" file # 无需输出时用 -q
large_process > /dev/null # 丢弃输出
# /dev/zero — 无限零字节流
dd if=/dev/zero of=empty.img bs=1M count=100 # 创建 100MB 空文件
cat /dev/zero > /dev/null & # 消耗 CPU(无 I/O 等待)
# /dev/random — 真随机数(熵池不足时阻塞)
head -c 32 /dev/random | base64 # 生成随机密钥
# /dev/urandom — 伪随机数(不阻塞,推荐)
head -c 32 /dev/urandom | base64
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 # 随机密码
dd if=/dev/urandom of=random_data.bin bs=1K count=1
# /dev/full — 始终返回"磁盘已满"错误(测试用)
echo "test" > /dev/full
echo "$?" # 1(写入失败)
# /dev/null 的重定向技巧
# 静默输出
command &> /dev/null
# 创建空文件
> file.txt # 清空或创建
: > file.txt # 同上(: 是空命令)
# 丢弃 stderr
command 2>/dev/null
# 测试命令退出码但不显示输出
if command &>/dev/null; then
echo "成功"
fi19.9 命名管道(FIFO)深入
# 创建命名管道
mkfifo /tmp/data_pipe
# 或指定权限
mkfifo -m 600 /tmp/private_pipe
# 命名管道 vs 匿名管道
# 匿名管道 |: 只能连接父子进程或兄弟进程,生命周期与进程绑定
# 命名管道: 任意两个进程可以使用,以文件系统节点形式存在
# 基本用法 —— 必须同时有读方和写方,否则阻塞
# 终端 1:
mkfifo /tmp/myfifo
cat < /tmp/myfifo # 阻塞,等待写入
# 终端 2:
echo "Hello Pipe!" > /tmp/myfifo
# 防止阻塞的技巧
# 读方超时
timeout 5 cat < /tmp/myfifo || echo "超时"
# 写方使用后台
echo "data" > /tmp/myfifo &
# 应用场景 1:进程间单向通信
mkfifo /tmp/trigger
# 守护进程
while read -r cmd < /tmp/trigger; do
echo "收到命令: $cmd"
done
# 控制端
echo "restart" > /tmp/trigger
# 应用场景 2:并行处理的扇出
mkfifo /tmp/pipe{1,2,3,4}
for i in 1 2 3 4; do
grep "ERROR" /tmp/pipe$i > "errors_$i.log" &
done
tee /tmp/pipe{1,2,3,4} < /var/log/app.log
rm /tmp/pipe{1,2,3,4}
# 应用场景 3:netcat 配合 FIFO 做双向通信
mkfifo /tmp/nc_fifo
nc -l -p 1234 < /tmp/nc_fifo | while read -r msg; do
echo "收到: $msg"
echo "回复: 已收到" > /tmp/nc_fifo
done19.10 /proc/self/fd/ 文件描述符
# /proc/PID/fd/ 目录包含进程所有打开的文件描述符
ls -l /proc/$$/fd/
# lrwx------ 0 -> /dev/pts/0
# lrwx------ 1 -> /dev/pts/0
# lrwx------ 2 -> /dev/pts/0
# lr-x------ 255 -> /path/to/script.sh
# /dev/fd/N 等价于 /proc/self/fd/N
# /dev/stdin = /dev/fd/0 = /proc/self/fd/0
# /dev/stdout = /dev/fd/1 = /proc/self/fd/1
# /dev/stderr = /dev/fd/2 = /proc/self/fd/2
# 实用技巧
# 1. 让支持"从文件读取"的工具从 stdin 读取
echo "hello" | diff - /dev/stdin # diff 比较 stdin 和文件
# 2. 在管道中使用需要文件参数的命令
some_command | while read -r line; do
echo "$line"
done
# 3. 查看进程打开了哪些文件(调试用)
ls -l /proc/$(pidof nginx | tr ' ' '\n' | head -1)/fd/
# 查看监听的 socket
ls -l /proc/$(pidof nginx | awk '{print $1}')/fd/ | grep socket
# 4. 恢复被删除但仍被进程打开的文件
# 假设某日志文件被误删但进程仍在写入:
# 1) 找到进程打开的 fd
ls -l /proc/PID/fd/ | grep deleted_file
# 2) 恢复文件内容
cat /proc/PID/fd/3 > recovered_file19.11 实用模式集合
模式 1:同时记录到终端和文件
# 全部输出(含 stderr)
./script 2>&1 | tee script.log
# 只 stdout 到文件,stderr 到终端
./script > script.log
# stdout 和 stderr 分别记录
./script > stdout.log 2> stderr.log
# stdout 到终端+文件,stderr 到独立文件
./script 2> error.log | tee output.log模式 2:多级过滤流水线
# 分析日志:提取特定时间段、特定级别的日志
grep "2026-07-24" /var/log/app.log \
| grep -E "ERROR|FATAL" \
| awk '{print $1, $2, $NF}' \
| sort | uniq -c | sort -rn \
| tee analysis.txt
# 直接修改:格式化 JSON 日志
tail -f /var/log/app.log \
| grep -E '^{.*}$' \
| jq '.message' \
| tee -a formatted.log模式 3:条件执行与重定向
# 只有成功时才输出
command > result.txt && cat result.txt
# 失败时输出错误
command 2> error.tmp && rm error.tmp || cat error.tmp
# 条件日志
if command &>/dev/null; then
echo "[OK] command 执行成功" | tee -a status.log
else
echo "[FAIL] command 执行失败" | tee -a status.log >&2
fi模式 4:批量文件操作
# 查找并处理
find . -type f -name "*.md" -print0 \
| xargs -0 grep -l "TODO" \
| xargs -0 sed -i 's/TODO/FIXME/g'
# 批量压缩
find /var/log -name "*.log" -mtime +7 -print0 \
| xargs -0 -P 4 -I {} gzip {}
# 批量修改权限
find /var/www -type d -print0 | xargs -0 chmod 755
find /var/www -type f -print0 | xargs -0 chmod 644模式 5:子 shell 与重定向
# 将一组命令的输出整体重定向
(
echo "=== 报告开始 ==="
date
echo ""
echo "--- 磁盘使用 ---"
df -h
echo ""
echo "--- 内存使用 ---"
free -h
echo ""
echo "=== 报告结束 ==="
) > system_report.txt 2>&1
# 切换工作目录执行命令
(cd /tmp && tar czf /backup/archive.tar.gz .)19.12 重定向速查表
| 操作符 | 含义 | 示例 |
|---|---|---|
cmd > file | stdout 覆盖写入 file | echo "hi" > out.txt |
cmd >> file | stdout 追加到 file | echo "hi" >> out.txt |
cmd < file | 从 file 读取 stdin | wc -l < data.txt |
cmd 2> file | stderr 覆盖写入 file | cmd 2> err.log |
cmd 2>> file | stderr 追加到 file | cmd 2>> err.log |
cmd &> file | stdout + stderr 写入 file | cmd &> all.log |
cmd > file 2>&1 | stdout + stderr 写入 file(POSIX) | cmd > all.log 2>&1 |
cmd >> file 2>&1 | stdout + stderr 追加到 file | cmd >> all.log 2>&1 |
cmd1 | cmd2 | cmd1 stdout → cmd2 stdin | ps aux | grep nginx |
cmd1 |& cmd2 | cmd1 stdout + stderr → cmd2 stdin | make |& grep error |
cmd | tee file | stdout 写入 file 并传至管道 | cmd | tee log.txt |
xargs cmd | stdin → cmd 的参数 | find . | xargs rm |
exec N> file | 打开 fd N 写入 file | exec 3> data.txt |
cmd N>&- | 关闭 fd N | exec 3>&- |
cmd > /dev/null | 丢弃 stdout | cmd > /dev/null |
cmd <<< "string" | Here String 作为 stdin | read x <<< "hello" |
cmd << EOF ... EOF | Here Document 多行 stdin | cat << EOF |
延伸阅读: 18-Bash编程基础 讲解 Shell 变量、循环等基础语法。20-正则与文本处理三剑客 展示 grep/sed/awk 在管道中的实战应用。19-Bash编程进阶 涵盖进程替换
<( )和>( )等高级用法。