shell 输入输出和重定向

shell 输入/输出重定向

  • 大多数 Unix 系统从终端接收输入并将产生的输出发送至终端。命令通常从标准输入读取数据,默认指向终端。通常将输出写入到标准输出或标准错误,默认也是指向终端

  • 终端 /dev/pts/0 是一种特殊的文件;标准输入是文件描述符 0 的别名;文件描述符是进程访问文件(包括终端、管道、普通文件)的索引。(stdin 可理解为就是 FD 0

  • /dev/stdin 通常是一个符号链接,指向 /proc/self/fd/0,而在不进行输入重定向时 /proc/self/fd/0 通常指向 /dev/pts/0,所以说标准输入通常来自终端,因为命令大多从标准输入读取数据,而标准输入通常指向终端

  • 改变 FD 0 指向通常是由 shell 来完成的,在命令实际执行前,shell 会解析输入的命令行,不论是管道、HereDocument还是HereString,shell 会提前把 FD 0 的指向设置好,实际执行的命令只需要从标准输入读取数据,而不关心数据的来源。算是一种解耦。

  • 命令接收的数据其实包括两部分:

    • 参数 (Arguments):控制指令。告诉程序做什么、对谁做、怎么做。例如:grep "error" log.txt"error" 是匹配规则(参数)。log.txt 是数据源的位置(数据)。
    • 标准输入 (Stdin):数据负载。程序要处理的具体内容。例如:cat file.txt | grep "error"grep 不需要知道文件名,它只关心“给我数据流,我过滤给你”。

常见的重定向符号

命令 说明
command > file 将 stdout 重定向到 file
command >> file 将 stdout 追加到 file
command < file 将 stdin 重定向到 file
n > file 将文件描述符为 n 的文件重定向到 file
n >> file 将文件描述符为 n 的文件追加到file
n>&m 将 FD n 输出重定向到 FD m 指向的文件,让 n 变成 m 的只写副本(如果 m 是只写)
n<&m 将 FD n 输入重定向 FD m 指向的文件,让 n 变成 m 的只读副本(如果 m 是只读)
exec n>&m 打开文件描述符 n(复制 m)
exec n>&- 关闭文件描述符 n
<<tag abc tag 将开始标记 tag 和结束标记 tag 之间的内容 abc 作为输入
  • 输入输出重定向的底层逻辑是打开一个文件描述符 fd,然后让 FD 0, 1, n 指向这个文件描述符 fd,fd = open("file", O_WRONLY|O_CREAT|O_APPEND); dup2(fd, x)
  • 默认 FD:如果不写 n,>>> 默认操作 FD 1,< 默认操作 FD 0。
  • &m 的理解:& 的作用是 类型标记,告诉 Shell 解析器:后面的数字是文件描述符编号,而不是文件名。(类似于指针解引用)
  • 命令通常是从 stdin 读取输入,并将输出写入 stdout 或 stderr,重定向改变的是 stdin,stdout 和 stderr 的指向,但命令依然从 std 读取或输出,效果上看是从文件读取和输出,实际是由 std 作为中间连接
  • 输入重定向可以将 < 写在命令开头:< file grep "pattern",重定向操作符(<, >, >>, 2> 等)不属于命令参数,它们是由 Shell 解释的特殊标记
# 同时有输入和输出重定向
< input.txt grep "error" > errors.txt 2> /dev/null

# 混合位置
> output.txt < input.txt command args
2> errors.log command < input.txt > output.txt
  • /dev/null 文件:它是特殊文件,写入到它的内容都被丢弃,起到禁止输出的效果

管道中的重定向

  • 使用 | 时,Shell 会创建一个匿名管道文件(内存缓冲区),将左侧命令的 stdout 绑定到管道的写入端,右侧命令的 stdin 绑定到管道的读取端。

  • shell 执行命令时会 fork() 出一个子进程,同时复制父进程的 FD 表给子进程(两个独立的副本),重定向操作(open, dup2)只在子进程中进行,父 Shell 的 FD 表从未被修改,子进程结束后,其 FD 表随进程销毁
  • 重要例外情况,虽然重定向通常只影响单个命令,但有几种特殊情况会影响当前 Shell 或后续命令:
  • 例外 1:exec 不带命令名(修改当前 Shell 的 FD),exec 不带命令名时,重定向在当前 Shell 进程中执行,而不是子进程。
# 这会修改当前 Shell 进程的 FD 表!
exec 3> /tmp/log.txt

# 后续所有使用 FD 3 的命令都会写入 /tmp/log.txt
echo "line1" >&3
echo "line2" >&3

# 甚至新的命令也受影响(如果它们继承 FD 3)
bash -c 'echo "line3" >&3'  # 也会写入 /tmp/log.txt

# 恢复:关闭 FD 3
exec 3>&-
  • 例外 2:子 Shell 中的重定向不影响父 Shell
# 子 Shell 中的重定向
(
    exec 3> /tmp/sub.txt
    echo "in sub" >&3
)

# 父 Shell 的 FD 3 不存在(子 Shell 的 FD 表已销毁)
echo "test" >&3
# 报错:Bad file descriptor
  • 例外 3:管道中的每个命令都是独立进程
# 管道中的重定向只影响对应命令
cat file.txt | grep "pattern" > out.txt

# cat 的 stdout → 管道
# grep 的 stdin ← 管道,stdout → out.txt
# 两个命令的 FD 表独立,互不影响
  • 例外 4:source. 执行的脚本
# source 在当前 Shell 中执行,不 fork
source script.sh

# 如果 script.sh 中有 exec 重定向,会影响当前 Shell

实用技巧:保存和恢复 FD

  • 由于重定向是临时的,有时我们需要保存原始 FD 以便恢复:
# FD 3 也指向 FD 1 指向的文件,相当于记录原 FD 1 的指向(复制 FD 1)
exec 3>&1

# 现在可以安全地重定向 FD 1
echo "to file" > /tmp/output.txt

# 让 FD 1 再指向 FD 3 所指向的文件,相当于恢复 FD 1 原来的指向
exec 1>&3

# 关闭 FD 3
exec 3>&-

exec 命令

  • 有时在脚本开头写入 exec >> "$log_file" 2>&1 这会将所有输出(包括错误)自动记录到日志文件,无需每条命令单独重定向
  • exec >> "$log_file" 2>&1 的机制:将脚本中所有命令的标准输出(stdout)和标准错误(stderr)追加(>>)到日志文件 $log_file 中,不显示在终端。
  • exec > >(tee -a "$log_file") 2>&1 的机制:通过 tee 命令将标准输出和错误同时显示在终端并追加到日志文件。>(tee -a "$log_file"):使用进程替换(Process Substitution),将 tee 命令作为临时文件描述符接收输入。tee -a:追加模式,保留原有日志。

进程替换

  • > >(...) 的语法:第一个 > 表示重定向标准输出(exec >)。>(...) 是进程替换语法(Bash 特性,需确保脚本以 #!/bin/bash 开头而非 #!/bin/sh),将 tee 命令的输出管道作为文件描述符传递给 exec。这种写法是 Bash 中实现同时输出到终端和文件的标准方式。
  • >(...) 的基本语法与功能
    • 语法:>(command_list) 其中 command_list 是一组命令(用分号 ; 分隔),>( 之间不能有空格。
    • 作用:创建一个临时文件描述符(如 /dev/fd/63),将左侧命令的输出重定向到该文件,并作为右侧命令的输入
  • >(...) 的工作原理:
    • 临时文件描述符:Shell 会创建一个临时文件(如 /dev/fd/63 ),用于中转数据。
    • 数据流方向:左侧命令的输出被重定向到临时文件。右侧命令从该文件读取输入。
    • 异步执行:括号内的命令与主命令并行执行,通过临时文件传递数据
echo "hello" > >(cat)  # 实际步骤:
# 1. 创建 /dev/fd/63
# 2. echo "hello" → /dev/fd/63
# 3. cat 从 /dev/fd/63 读取并输出
  • >(...)<(...) 对比
特性 >(...) <(...)
数据方向 接收数据(输入重定向) 产生数据(输出重定向)
用途 将数据传递给命令的输入 将命令的输出作为文件读取
示例 cmd > >(grep "pattern") diff <(cmd1) <(cmd2)

进程替换和管道对比

  • 进程替换(<()>())和管道(|)都是 Shell 中用于命令间数据传递的机制

  • 数据流方向

    • 管道:数据是单向流动的,从左到右依次传递。前一个命令的标准输出(stdout)作为后一个命令的标准输入(stdin)。command1 | command2 command1 的输出传给 command2 作为输入
    • 进程替换:数据通过临时文件描述符(如 /dev/fd/63)传递,可以模拟文件输入或输出。
      • cmd1 < <(cmd2)cmd1 读取 cmd2 的输出作为输入(类似输入重定向)。
      • cmd1 > >(cmd2)cmd2 读取 cmd1 的输出作为输入(类似输出重定向)。
      • diff <(ls dir1) <(ls dir2) 比较两个目录的文件列表
  • 子进程与变量作用域

    • 管道:管道中的每个命令在子 Shell 中执行,变量赋值无法传递到父 Shell。例如:echo "hello" | read var; echo $var 输出为空(var 在子 Shell 中失效)
    • 进程替换:命令在当前 Shell 中执行,变量赋值可保留。例如:read var < <(echo "hello"); echo $var 输出 hello
  • 适用场景

    • 管道:适合线性数据流处理(如过滤、排序、统计)。示例:cat log.txt | grep "error" | sort | uniq -c
    • 进程替换:适合需要多输入/输出源或命令参数化的场景。示例:
# 比较两个命令的输出,需要两个多个数据源,没法用管道传递
diff <(command1) <(command2)

# 将输出同时传递给多个命令
# tee 从 log.txt 读取数据,并同时输出到所有进程替换的临时文件描述符
# 同时输出到终端
tee >(grep "error" > errors.txt) >(grep "warning" > warnings.txt) < log.txt
# 相当于 
cat log.txt|tee >(grep "error" > errors.txt) >(grep "warning" > warnings.txt)

个人理解:可以将进程替换视为一个临时文件

  • 例如 ls > >(grep "txt")>(grep "txt") 会生成 /dev/fd/63ls 的结果输出到此文件,grep 从此文件中查找 txt
  • cat <(echo "hello") 中,<(echo "hello") 会生成 /dev/fd/45echo "hello" 的结果写入此文件,cat 读取此文件

管道、HereDocument, HereString, - stdin

  • 这四个概念(管道、Here Document、Here String、-)的核心都是围绕标准输入(stdin) 的数据来源和处理方式。
  • 它们的关系可以概括为:管道、Here Document、Here String 是 Shell 的重定向操作符,用于“制造”或“转发”数据流;而 - 是一个约定俗成的文件名占位符,用于告诉命令“去读取标准输入”。
  • 四种方式都是为了解决“如何向需要文件作为输入的命令提供数据”的问题,而不必创建物理临时文件。

管道 (|):内核级的数据通道

  • 原理:Shell 调用 pipe() 系统调用,内核创建一个管道对象(内存缓冲区),返回两个 FD:fd[0] (读端) 和 fd[1] (写端)。
  • Shell fork 出两个子进程(命令 A 和命令 B)。
  • 在命令 A 中:dup2(fd[1], 1)。将 A 的 stdout (FD 1) 重定向到管道的写端 fd[1]。命令 A 的输出将写入该管道对象。
  • 在命令 B 中:dup2(fd[0], 0)。将 B 的 stdin (FD 0) 重定向到管道的读端。命令 B 将从该管道读取数据
  • 关闭多余 FD,执行 exec
  • 本质:命令 A 的 FD 1 和 命令 B 的 FD 0 指向了同一个内核管道对象。
  • 管道对象就相当于一个临时文件,命令 A 向其中写入,而命令 B 从中读取,不过这是流式的,不是命令 A 写完命令 B 才能读取,而是实时的写入、读取。

HereDocument 和 HereString

# HereDocument
cat > test.txt << EOF
This is a test file.
This is a test file.
EOF

# HereString
bc <<< '1+2' # 等价于 echo '1+2' | bc
  • 二者原理类似,shell 会解析命令中的文本,同时创建一个临时文件或者管道,将命令的输入重定向到这个文件。
  • Here Document/String 的内容是由当前 Shell 解析的,而不是由目标命令解析的。这意味着变量展开发生在 exec 之前。
  • << 'EOF' 里面的变量不展开,所有内容视为字面量
  • << "EOF" 里面的变量会展开,但抑制通配符展开
  • <<< "$var" 展开变量,自动加换行
  • <<-EOF Shell 只删除制表符 (Tab),不删除空格 (Space)。
  • 如果 Here Doc 中包含用户输入的变量,且未加引号,可能导致命令注入。应使用 << 'EOF' 防止展开
  • 结束标记 (EOF):是告诉 Shell 内容到此结束的信号,有以下要求:
    • 必须独占一行。
    • 对于 <<:它必须严格顶格(第 1 列),前面不能有任何空格或 Tab。内容行中的 Tab 和空格会保留
    • 对于 <<-:它前面可以有 Tab(会被忽略),但不能有空格。内容行中的 Tab 也会被删除,但空格会保留

<<- 设计初衷是为了让 Shell 脚本本身更美观(允许脚本缩进),而不是为了让传递的内容缩进。

  • 脚本层面:希望 EOF 能和上面的代码对齐(用 Tab 缩进),所以 Shell 把 EOF 前的 Tab 删掉。
  • 内容层面:Shell 假设用的 Tab 也是为了跟脚本对齐,所以顺便把内容行的 Tab 也删除了。
  • 空格层面:Shell 假设空格是实际数据(比如配置文件中的缩进),所以不会删除。

四种写法对比

写法 行为 场景
<<EOF 保留所有前导空白(Tab 和空格)。内容中的缩进会原样保留。 需要严格保持格式(如 Python 代码、Makefile、YAML)。
<<-EOF 仅去除前导 Tab 字符,保留空格。允许你在脚本中对 Here Doc 内容进行缩进以提高可读性。 大多数 Shell 脚本场景,希望代码整洁且内容不受缩进影响。
<< 'EOF' 禁止变量展开和命令替换,但保留所有前导空白。 内容包含 $、` 或 \ 且不希望被 Shell 解析时。
<<-'EOF' 禁止变量展开,且仅去除前导 Tab 字符。 最推荐的通用写法:既整洁又安全。

个人总结:

  • - 决定是否删除前导tab
  • ' 单引号决定内容是否展开

- 的特殊性

  • - 不是 Shell 的特性,也不是内核的特性,它是应用程序(Command)的代码逻辑,是约定而非机制
  • Shell 视角:- 只是一个普通的字符串参数 argv[1] = "-"。Shell 不做任何特殊处理。
  • 命令视角:程序解析 argv,发现 "-",于是主动去读取 FD 0。
  • 举例:cat file | tar -cvf out.tar -
    • tarargv 包含 "-cvf", "out.tar", "-"
    • tar 解析到最后一个 "-",知道这里需要填入一个文件流。
    • tar 读取 FD 0(此时 FD 0 已被 Shell 通过管道连接到了 cat 的 FD 1)。

觉得有帮助的话,还请多多关注。
3f30b7eef45e37ccfa179b30e3123322

posted @ 2026-07-17 23:07  九原鹰  阅读(4)  评论(0)    收藏  举报