重定向在命令执行前改变它的文件描述符。Shell 从左到右处理每一项,遇到打开文件或复制描述符的操作,就在那个位置完成相应变更。描述符零、一、二通常分别表示标准输入、标准输出和标准错误。把命令行读成对这些句柄的顺序操作,就能解释诊断为什么去了错误的位置,也能解释文件为什么还没有被程序读取就已经变空。

打开文件本身就可能改变数据

>output 打开输出文件,并且通常把已存在的普通文件截断为零长度;>>output 使用追加方式,<input 则打开文件供读取。这些动作由 Shell 完成,不是命令所指程序的内部行为。如果必须打开的目标失败,命令不会执行,但此前已经成功的重定向仍可能创建或截断其他文件。不能把“程序没有启动”理解成“整条命令完全没有副作用”。

因此,sed expression input >input 会在转换程序读取数据之前破坏原文件。即便先写 <input 打开输入,后续重定向截断同一个底层文件,也不能保住那些内容。应写入独立临时文件,检查转换状态与结果,再根据文件归属、权限和所在文件系统,明确执行替换步骤。输入输出恰好同名时,不能指望 Shell 自动替你实现安全的原地编辑。

复制记住的是当下的目标

>log 2>&1 先让描述符一指向日志,再让描述符二引用同一个已打开目标。2>&1 >log 则先复制原来的标准输出,之后只改变描述符一,因此标准错误仍留在原目标,例如终端或负责捕获输出的管道。这里复制的是描述符引用,不是文件内容,也不是给两个数字建立一个会随之后续变化的动态连接。

Bash 的 &>log 可以简写同时重定向两条流,而 >log 2>&1 也适用于可移植 Shell。不要把它写成 >log 2>log:后者分别打开同一个文件,两份打开状态拥有独立偏移量,输出可能相互覆盖。即使描述符共享一个目标,应用程序自身的缓冲仍会影响消息真正写出的时间,所以合并日志也不自动保证业务事件严格按发生顺序排列。

管道会先把标准输出连接到下一阶段,然后才应用命令自己的重定向。因此 producer >file | consumer 的生产者把输出交给了文件,消费者反而读不到那些数据。相反,producer 2>&1 | consumer 会把诊断也送进管道。如果下游要求机器可解析的记录,把面向人的错误文本混进去,就可能破坏原本合法的输入格式。

坏掉的样子

文件名来自变量时应加引号,避免空格让目标展开成多个词。描述符编号必须紧贴操作符:2>errors 重定向标准错误,2 >errors 却把数字二作为普通参数传给命令,并重定向标准输出。一个空格就可能同时改变程序收到的输入参数和输出位置,排查时不能只看目标文件名有没有拼对。

Bash 的 set -C 启用禁止覆盖模式,通常会拒绝用普通输出重定向覆盖已存在的普通文件。这有助于防止误操作,但不是备份机制,也不是对所有文件类型的统一保护;>| 会明确绕过这个选项。还要区分单条命令的临时重定向与 exec >log:后者修改当前 Shell 的描述符,可能把后续所有命令的输出一起转走,不能在交互验证时无意间让整个会话失声。

快速验证

下面的完整实验使用 Bash 和常见 Unix 工具,只会截断、删除刚创建的专用临时文件。它比较两种重定向顺序,演示输入输出同文件时的数据丢失,并验证目标无法打开时函数体确实没有运行。清理步骤逐个列出实验文件,不使用递归删除,也不会触碰原有文件。

bash --noprofile --norc <<'BASH'
set -eu
demo_dir=$(mktemp -d)
trap 'rm -f "$demo_dir/both" "$demo_dir/out" "$demo_dir/input"; rmdir "$demo_dir"' EXIT
emit() { printf 'out\n'; printf 'err\n' >&2; }
emit > "$demo_dir/both" 2>&1
demo_captured=$(emit 2>&1 > "$demo_dir/out")
[ "$(cat "$demo_dir/both")" = "$(printf 'out\nerr')" ]
[ "$(cat "$demo_dir/out")" = out ]
[ "$demo_captured" = err ]
printf 'both:\n'
cat "$demo_dir/both"
printf 'out-file=[%s] captured=[%s]\n' \
  "$(cat "$demo_dir/out")" "$demo_captured"
printf 'seed\n' > "$demo_dir/input"
sed 's/seed/replaced/' "$demo_dir/input" > "$demo_dir/input"
[ ! -s "$demo_dir/input" ]
printf 'input truncated\n'
if (set -C; emit > "$demo_dir/both") 2>/dev/null; then
  printf 'unexpected overwrite\n' >&2
  exit 1
else
  printf 'noclobber rejected overwrite\n'
fi
demo_ran=no
mark_run() { demo_ran=yes; }
if mark_run 2>/dev/null > "$demo_dir/missing/output"; then
  printf 'unexpected redirection\n' >&2
  exit 1
else
  [ "$demo_ran" = no ]
  printf 'function-ran=%s\n' "$demo_ran"
fi
BASH

合并文件应打印 out 和 err。交换顺序后,输出文件只含 out,变量单独捕获到 err。转换实验报告输入已经变空,因为截断先于程序读取。最后两行分别证明禁止覆盖选项拒绝了替换,以及目标打开失败时仍为 function-ran=no,函数中的赋值没有执行。

明确比较与失败分支让异常结果以失败状态退出,不会只打印意外信息后继续成功结束。这些检查同时观察文件内容与命令是否真正执行,而不是只依赖最后一条打印命令成功。在真实脚本中,还要把生产者退出状态与日志一起保留下来:错误消息即使准确进入了日志,也不能把失败操作变成成功。只有分别保留流向和状态两类证据,后续恢复判断才有可靠依据。

延伸阅读

来源