跳到主要内容

输入输出重定向

输入输出重定向是 Shell 最强大的特性之一。它允许你控制命令的输入来源和输出目标,将命令组合成复杂的数据处理流程。本章详细介绍文件描述符、重定向操作符、Here Document、管道等核心概念。

文件描述符

在 Unix/Linux 系统中,每个进程都有三个标准文件描述符(File Descriptor),它们是进程与外部世界通信的通道。

文件描述符名称默认设备用途
0标准输入(stdin)键盘程序读取输入
1标准输出(stdout)屏幕程序输出正常结果
2标准错误(stderr)屏幕程序输出错误信息

文件描述符本质上是一个非负整数,内核用它来跟踪进程打开的文件。当你打开一个文件时,内核返回一个文件描述符,后续的读写操作都通过这个描述符进行。

查看文件描述符

# 查看当前 Shell 打开的文件描述符
ls -la /proc/$$/fd

# 输出示例:
# lrwx------ 1 user user 64 Jan 1 00:00 0 -> /dev/pts/0
# lrwx------ 1 user user 64 Jan 1 00:00 1 -> /dev/pts/0
# lrwx------ 1 user user 64 Jan 1 00:00 2 -> /dev/pts/0

为什么需要重定向

默认情况下,命令从键盘读取输入,将结果输出到屏幕。但在很多场景下,我们需要改变这种默认行为:

  • 将命令输出保存到文件
  • 让命令从文件读取输入
  • 将错误信息与正常输出分开处理
  • 将多个命令串联起来,前一个的输出作为后一个的输入

输出重定向

覆盖写入 >

> 将命令的标准输出重定向到指定文件。如果文件不存在,会创建新文件;如果文件存在,会清空原有内容。

# 将 ls 命令的输出保存到文件
ls -la > filelist.txt

# 将命令输出重定向到文件
echo "Hello, World" > greeting.txt

# 清空文件内容
> empty.txt

注意:覆盖写入会丢失原有内容。如果你不确定文件是否重要,可以先备份或使用追加模式。

追加写入 >>

>> 将命令的标准输出追加到文件末尾,不会清空原有内容。

# 追加内容到日志文件
echo "$(date): Script started" >> script.log

# 收集多次命令的输出
for i in 1 2 3; do
echo "Iteration $i" >> results.txt
done

标准错误重定向 2>2>>

标准错误和标准输出是两个独立的流,需要分别重定向。

# 将错误信息重定向到文件
ls /nonexistent 2> errors.log

# 追加错误信息
find / -name "*.conf" 2>> errors.log

# 丢弃错误信息(发送到 /dev/null)
ls /nonexistent 2> /dev/null

/dev/null 是一个特殊的设备文件,写入它的数据会被丢弃,读取它会立即返回 EOF。它常用于丢弃不需要的输出。

同时重定向标准输出和标准错误

# 方式一:分别重定向到不同文件
command > output.txt 2> error.txt

# 方式二:重定向到同一个文件(传统方式)
command > all.txt 2>&1

# 方式三:重定向到同一个文件(Bash 简写)
command &> all.txt

# 方式四:追加模式
command &>> all.txt

重要:重定向的顺序非常关键。考虑这两个命令的区别:

# 正确:先将 stdout 重定向到文件,再将 stderr 重定向到 stdout
ls > output.txt 2>&1
# 结果:stdout 和 stderr 都写入 output.txt

# 错误:先将 stderr 重定向到 stdout(此时指向屏幕),再将 stdout 重定向到文件
ls 2>&1 > output.txt
# 结果:stdout 写入 output.txt,stderr 仍然输出到屏幕

Bash 从左到右处理重定向。在第一个命令中,> output.txt 先将 fd 1 指向文件,然后 2>&1 将 fd 2 指向 fd 1 当前指向的目标(文件)。在第二个命令中,2>&1 时 fd 1 还指向屏幕,所以 fd 2 也指向屏幕。

丢弃所有输出

# 丢弃所有输出
command > /dev/null 2>&1
command &> /dev/null

输入重定向

从文件输入 <

# 从文件读取输入
wc -l < file.txt

# 排序文件内容
sort < unsorted.txt

# 统计单词数
wc -w < document.txt

cat file.txt | wc -l 不同,wc -l < file.txt 直接将文件作为 wc 的标准输入,不经过管道,效率更高。

Here Document <<

Here Document 允许在脚本中嵌入多行文本,作为命令的标准输入。

# 基本语法
command << DELIMITER
text
DELIMITER

# 示例:创建配置文件
cat > config.ini << EOF
[database]
host = localhost
port = 3306
user = root
password = secret
EOF

# 示例:发送邮件
mail -s "Report" [email protected] << END
Dear User,

Here is the weekly report.
Please review it at your earliest convenience.

Best regards,
Admin
END

变量替换

默认情况下,Here Document 内的变量和命令会被替换:

name="World"
cat << EOF
Hello, $name!
Current directory: $(pwd)
Date: $(date)
EOF
# 输出:
# Hello, World!
# Current directory: /home/user
# Date: Fri Jan 1 00:00:00 UTC 2024

禁用变量替换

在分隔符前后加单引号,可以禁用变量替换:

cat << 'EOF'
$HOME will not be expanded
$(pwd) will not execute
EOF
# 输出:
# $HOME will not be expanded
# $(pwd) will not execute

去除前导制表符

使用 <<- 可以去除每行开头的制表符(注意:只能去除制表符,不能去除空格):

cat <<- EOF
This line starts with a tab
This line has two tabs
EOF
# 输出:
# This line starts with a tab
# This line has two tabs

Here String <<<

Here String 是 Here Document 的简化形式,用于传递单行字符串:

# 基本用法
grep "pattern" <<< "search in this string"

# 使用变量
name="World"
cat <<< "Hello, $name!"

# 传递多行(使用换行符)
awk '{print NR, $0}' <<< "line1
line2
line3"

Here String 会自动在字符串末尾添加换行符,所以命令能正确读取。

文件描述符操作

打开文件描述符

使用 exec 命令可以打开、关闭和操作文件描述符。

# 打开文件描述符用于写入
exec 3> output.txt
echo "Line 1" >&3
echo "Line 2" >&3

# 打开文件描述符用于读取
exec 4< input.txt
read -u 4 line
echo "Read: $line"

# 打开文件描述符用于读写
exec 5<> data.txt
echo "Write something" >&5
read -u 5 line

关闭文件描述符

# 关闭输出文件描述符
exec 3>&-

# 关闭输入文件描述符
exec 4<&-

# 关闭读写文件描述符
exec 5>&-

复制文件描述符

# 保存当前 stdout
exec 3>&1

# 重定向 stdout 到文件
exec > log.txt
echo "This goes to file"

# 恢复 stdout
exec >&3
exec 3>&-

echo "This goes to screen"

实际应用:同时写入文件和屏幕

#!/bin/bash

# 打开文件描述符 3 指向日志文件
exec 3> script.log

# 创建日志函数
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee /dev/fd/3
}

log "Script started"
log "Processing data..."
log "Script finished"

# 关闭文件描述符
exec 3>&-

使用命名文件描述符

Bash 4.1+ 支持命名文件描述符,使代码更易读:

# 打开命名文件描述符
exec {log_fd}> application.log

# 使用命名文件描述符
echo "Log message" >&$log_fd

# 关闭
exec {log_fd}>&-

特殊文件

Bash 对一些特殊的文件名有特殊处理:

文件说明
/dev/null丢弃所有写入,读取返回 EOF
/dev/zero读取时返回无限个零字节
/dev/random读取时返回随机字节
/dev/urandom读取时返回随机字节(非阻塞)
/dev/stdin等同于文件描述符 0
/dev/stdout等同于文件描述符 1
/dev/stderr等同于文件描述符 2
/dev/fd/n等同于文件描述符 n
/dev/tcp/host/portTCP 连接
/dev/udp/host/portUDP 连接

网络重定向

# 通过 TCP 发送 HTTP 请求
exec 3<>/dev/tcp/www.example.com/80
echo -e "GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n" >&3
cat <&3
exec 3>&-

# 检查端口是否开放
(echo > /dev/tcp/google.com/80) 2>/dev/null && echo "Port 80 is open"

管道

管道是连接命令的标准机制,它将一个命令的标准输出连接到另一个命令的标准输入。

基本用法

# 简单管道
ls | grep ".txt"

# 多级管道
cat access.log | grep "error" | sort | uniq -c | sort -rn | head -10

# 管道与重定向结合
ls | grep ".txt" > txt_files.txt

管道的工作原理

管道是内核提供的一种进程间通信机制。当你执行 cmd1 | cmd2 时:

  1. Shell 创建一个管道(两个文件描述符:一个用于读,一个用于写)
  2. Shell 创建两个子进程
  3. 第一个子进程将 stdout 重定向到管道的写端,执行 cmd1
  4. 第二个子进程将 stdin 重定向到管道的读端,执行 cmd2
  5. 数据从 cmd1 流向 cmd2

管道中的退出状态

默认情况下,管道的退出状态是最后一个命令的状态:

false | true | false
echo $? # 输出:1(最后一个命令的状态)

使用 PIPESTATUS 数组可以获取每个命令的退出状态:

false | true | false
echo "${PIPESTATUS[@]}" # 输出:1 0 1

使用 set -o pipefail 可以让管道在任意命令失败时返回失败:

set -o pipefail
false | true | false
echo $? # 输出:1

tee 命令

tee 命令将输入同时输出到文件和标准输出,就像管道的 T 型接头。

# 保存中间结果
ls | tee filelist.txt | grep ".txt"

# 同时保存到多个文件
ls | tee filelist1.txt filelist2.txt

# 追加模式
ls | tee -a log.txt | grep ".txt"

进程替换

进程替换将命令的输出(或输入)表现为文件,可以用于需要文件参数的场景。

输出替换 >(...)

# 将输出同时发送到多个命令
echo "Hello" | tee >(grep -o "H" > h.txt) >(grep -o "e" > e.txt)

# 比较两个目录
diff <(ls dir1) <(ls dir2)

输入替换 <(...)

# 合并并排序多个文件
sort -m <(sort file1.txt) <(sort file2.txt)

# 对比命令输出
diff <(grep "error" log1.txt) <(grep "error" log2.txt)

命名管道(FIFO)

命名管道是一种特殊类型的文件,允许不相关的进程进行通信。

创建和使用

# 创建命名管道
mkfifo mypipe

# 或者使用 mknod
mknod mypipe p

# 在一个终端写入
echo "Hello from writer" > mypipe

# 在另一个终端读取
cat < mypipe
# 输出:Hello from writer

# 删除命名管道
rm mypipe

实际应用

#!/bin/bash

pipe="/tmp/my_pipe"

# 创建命名管道
[ -p "$pipe" ] || mkfifo "$pipe"

# 消费者:后台读取
while read line < "$pipe"; do
echo "Received: $line"
done &

# 生产者:写入数据
echo "Message 1" > "$pipe"
echo "Message 2" > "$pipe"

# 清理
rm "$pipe"

常见陷阱

重定向顺序错误

# 错误:stderr 仍然输出到屏幕
command 2>&1 > output.txt

# 正确:先重定向 stdout,再复制到 stderr
command > output.txt 2>&1

覆盖输入文件

# 错误:文件在读取前就被清空
cat file.txt | sort > file.txt

# 正确:使用临时文件
sort file.txt > file.txt.tmp && mv file.txt.tmp file.txt

# 或者使用 sponge(moreutils 包)
sort file.txt | sponge file.txt

# 或者使用 -o 选项(部分命令支持)
sort -o file.txt file.txt

权限问题

# 错误:重定向由当前用户执行,可能没有权限
echo "content" | sudo > /root/file.txt

# 正确:使用 tee 或 dd
echo "content" | sudo tee /root/file.txt > /dev/null

子 Shell 中的变量

# 错误:管道在子 Shell 中执行,变量修改无效
echo "hello" | read var
echo $var # 输出为空

# 解决方案一:使用进程替换
read var <<< "hello"
echo $var # 输出:hello

# 解决方案二:使用 lastpipe
shopt -s lastpipe
echo "hello" | read var
echo $var # 输出:hello

# 解决方案三:使用命令替换
var=$(echo "hello")

实用示例

日志记录脚本

#!/bin/bash

LOG_FILE="script.log"

# 重定向所有输出到日志文件
exec > >(tee -a "$LOG_FILE")
exec 2>&1

echo "Script started at $(date)"
echo "Doing some work..."
echo "Script finished"

交互式命令自动化

#!/bin/bash

# 自动回答交互式命令的问题
ftp -n ftp.example.com << EOF
user username password
cd /remote/path
get file.txt
bye
EOF

# 自动输入密码(注意:密码会暴露在进程列表中)
mysql -u root -p << EOF
your_password
SHOW DATABASES;
EOF

并发数据处理

#!/bin/bash

# 使用命名管道实现生产者-消费者模式
pipe=/tmp/data_pipe
[ -p "$pipe" ] || mkfifo "$pipe"

# 消费者:处理数据
process_data() {
while read data; do
echo "Processing: $data"
sleep 0.1
done < "$pipe"
}

# 启动多个消费者
for i in 1 2 3; do
process_data &
done

# 生产者:发送数据
for item in {1..20}; do
echo "Item $item" > "$pipe"
done

wait
rm "$pipe"

捕获命令输出和错误

#!/bin/bash

# 捕获 stdout 和 stderr 到不同变量
{
output=$(command 2>&1 1>&3)
error=$?
} 3>&1

echo "Output: $output"
echo "Exit code: $error"

# 另一种方法:使用临时文件
tmp_file=$(mktemp)
output=$(command 2>"$tmp_file")
error=$(<"$tmp_file")
rm "$tmp_file"

echo "Output: $output"
echo "Error: $error"

实时日志处理

#!/bin/bash

# 实时处理日志文件
tail -f /var/log/app.log | while read line; do
# 提取错误信息
if echo "$line" | grep -q "ERROR"; then
# 发送告警
echo "$line" | mail -s "Error Alert" [email protected]
fi

# 写入统计
echo "$line" >> /var/log/app_archive.log
done

小结

本章详细介绍了 Shell 输入输出重定向:

  • 文件描述符:理解 stdin、stdout、stderr 三个标准流
  • 输出重定向> 覆盖写入、>> 追加写入、2> 错误重定向
  • 输入重定向< 从文件输入、<< Here Document、<<< Here String
  • 文件描述符操作:使用 exec 打开、关闭、复制文件描述符
  • 管道:连接命令的输出和输入,tee 命令分发输出
  • 进程替换<(...)>(...) 将命令输出作为文件
  • 命名管道:FIFO 实现进程间通信
  • 常见陷阱:重定向顺序、覆盖输入文件、权限问题

掌握输入输出重定向是编写复杂 Shell 脚本的关键技能。下一章将学习 Shell 的高级特性。