☰
Linux输出重定向完全指南:从文件描述符到Shell脚本实战
2026/10/17 12:59:00 网站建设 项目流程

刚接触Linux那阵子,我干过一件特别蠢的事:写了个备份脚本丢进定时任务里,每天凌晨4点执行。跑了两个礼拜,某天想看看日志,打开一看,文件是空的。更诡异的是,脚本里的命令手动执行明明有输出。排查了半天,最后发现罪魁祸首就是一行>重定向——脚本里用的是单箭头,每次执行都会把上一次的日志截断清空。那个文件里只剩最后一次运行的输出,而最后一次恰好是空输出。

从那次事故之后,"Linux 输出重定向"这六个字在我心里的分量就不一样了。它不只是"把输出写到文件里"这么简单,背后牵扯到文件描述符、标准流的去向、命令执行顺序,甚至进程的生命周期。无论是写Shell脚本、维护服务日志,还是排查线上问题,几乎每天都在跟它打交道。这篇文章想把这几年用重定向攒下的经验一次性讲透,既有最基础的>、>>,也有2>&1、/dev/null、tee、heredoc、exec这些进阶玩法,还有大量实测中踩出来的坑。不管你是刚入门Linux的新手,还是已经在写脚本的老手,应该都能从中找到点有用的东西。

1. 三个标准流的底层逻辑:0、1、2到底在指向谁

1.1 进程诞生的瞬间,终端已经替它开好了三条"管道"

在Linux的世界里,“一切皆文件”这句话在输出重定向这件事上体现得格外透彻。当你在终端里敲下一条命令,shell会先创建一个子进程,这个进程在诞生的一瞬间,操作系统就给它准备好了三个默认的文件描述符:

  • 0号,标准输入(stdin),默认指向键盘;
  • 1号,标准输出(stdout),默认指向终端屏幕;
  • 2号,标准错误(stderr),默认也指向终端屏幕。

文件描述符是什么?你可以把它理解成进程手里拿着的一组“票据”,每张票据上写着“我打开的是哪个文件、读写权限如何”。这个文件可以是真正的磁盘文件,也可以是一个字符设备,甚至可以是另一个进程开出来的管道。

这里最关键的一点,是stdout和stderr虽然都“默认指向终端”,但它们是两个完全独立的句柄。这意味着你可以单独操作其中任何一个:只让正常输出进文件、错误输出留在屏幕,或者反过来。这正是后面所有重定向操作的基础。

先举两个例子感受一下:

ls /tmp # 正常输出,走stdout ls /not_exist # 报错,走stderr

第二条命令的报错信息,在终端上看起来跟普通输出差不多,但内核知道它走的是2号通道。这个区分,就是重定向能够“分流”的根本原因。

1.2 为什么错误信息老是“不翼而飞”:2>的单独捕获

很多人第一次接触输出重定向时都遇到过这种情况:执行some_command > log.txt,跑完打开log.txt,里面干干净净,但终端上明明刷了一堆红色报错。原因很简单,>只重定向了1号标准输出,2号标准错误仍然直接打在屏幕上。

想要把错误也收进文件,就要显式操作2号描述符:

some_command 2> error.log

这才是完整的第一步。理解了0、1、2这三条“管道”的独立性,后面的2>&1、&>file、1>&2这些写法就都可以推导出来了。

顺带提一个细节:&>file是bash的简写,等价于>file 2>&1,但POSIX sh不支持。如果你的脚本需要用sh script.sh直接跑,最好老老实实写>file 2>&1,别图省事。

1.3 输入方向的补充:<与<<的日常形态

输出重定向经常和输入重定向一起出现。<表示把文件内容作为命令的标准输入,典型例子:

wc -l < notes.txt

别看这个简单,它在循环处理和批量场景里很常用。而<<(heredoc)则是把一段多行文本整体喂给命令,本质也属于输入重定向。这两种方式我会在第5节再深入展开,因为heredoc在真实脚本里的可玩性比想象中大得多。

2. 单箭头与双箭头的取舍:截断与追加背后的事故现场

2.1 >的截断机制:文件在命令执行前就被清空了

Shell执行cmd > file时,并不是先把命令跑完、再把结果写进文件。真正的顺序是:shell先以写模式打开目标文件,这一步会立刻把文件截断为空,然后才启动命令,命令的输出再写入这个已经打开的描述符。

这个顺序带来两个经典后果:

第一,只要命令被执行,文件内容必然被清空,哪怕命令随后立刻报错退出、什么都没输出。很多日志丢失事故就是这么来的。我在开头提到的定时备份脚本,就是栽在这一条上——单箭头把前一天的所有日志截断了,而最后一次执行因为某种原因没有产生输出,整个文件变成了0字节。

第二,如果命令执行失败,空文件已经被创建出来了,在脚本判断里会形成“文件存在但内容为空”的假象。检查日志时,不要只ls -l看文件在不在,还要看stat里的大小和修改时间。

2.2 追加模式:>>与O_APPEND的并发语义

双箭头>>不会截断,而是在文件末尾追加。注意,“追加”和“写入到同一个文件”不是一回事。>>打开文件时带了O_APPEND标志,内核会保证每次write都写到当前文件末尾,而不是从文件头开始覆盖。

这个特性在多个进程同时写同一个日志文件时特别重要。如果都用>,会有互相截断、覆盖的竞争问题;用>>,虽然单次写入的内容可能会在多进程间交错(毕竟不是原子的大块写入),但至少不会把另一个进程的旧内容清掉。实际部署脚本里常这么写:

echo "backup started at $(date)" >> /var/log/deploy.log

目的就是保留完整历史。但也要提醒一句:>>并不适合高并发下的精细日志结构,那是专业日志库的活,别自己硬写。

2.3 umask:重定向生成的文件权限是怎么定出来的

还有一个容易被忽略的细节:用>或>>新建文件时,权限不是随便给的,而是由0666 & ~umask计算得到。默认umask通常是022,所以最终权限是0644(rw-r--r--)。如果服务器上umask设成了077,生成的文件就只有600,其他用户一律打不开。

我曾经排查过一个“脚本运行成功但另一个服务读不到日志”的诡异问题,最后发现就是umask导致日志权限只有600,而读日志的进程属于另一个用户。如果你的脚本要生成日志给其他账号读,最好在脚本开头显式设置:

umask 022 exec > /var/log/app.log 2>&1

这样日志权限可控,不会因为环境变量不同而翻车。

3. 2>&1翻车实录:重定向顺序与描述符指向的本质

3.1 顺序不同,结果天壤之别

这是输出重定向里最高频的坑。看这两条命令:

command > file 2>&1 command 2>&1 > file

第一行的意图是:stdout先指向file,然后再把stderr指向1号描述符当前指向的位置(也就是file),最后两个流都进文件。这是正确写法。

第二行的顺序则完全不同:先把2号指向1号当前指向的位置(此刻1号还指向终端),再把1号指向file。最终结果:stdout进了文件,stderr却仍然留在终端。

很多初学者以为“反正都写了1和2,顺序无所谓”,结果错误信息照旧打在屏幕上,文件里只有正常输出。定位这类问题,记住一个根本原则:重定向本质上是在调整文件描述符的“指向”,从左到右逐个执行,后面的语句引用的是前面语句已经调整过的状态。

写法打开顺序最终效果
cmd > file 2>&11→file,2→1(当前是file)stdout、stderr都进file
cmd 2>&1 > file2→1(当前是终端),1→filestdout进file,stderr留在终端
cmd &> file同时把1和2指向filestdout、stderr都进file

3.2 nohup和定时任务里的顺序陷阱

比如nohup command > /dev/null 2>&1 &这串常用命令,为什么必须先> /dev/null再2>&1?因为目标是把两个流都丢弃。如果写成nohup command 2>&1 > /dev/null &,stderr会被留在nohup.out里,文件越滚越大,早晚占满磁盘。

定时任务里我个人的习惯是:至少留一份真实输出,防止排查时两眼一抹黑。比如:

0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

正常输出和错误都写在一起,时间线也是完整的。要用管道的时候,再配合第5节说的pipefail处理退出码。

3.3 反向用法:1>&2什么时候派得上用场

有正写反,也有反写。1>&2把标准输出指向标准错误当前指向的位置。这种写法常用在脚本的提示信息上,让所有“提示性消息”都走stderr,和真正的结果输出分开:

echo "something went wrong" 1>&2

为什么要这么做?因为当脚本输出要作为另一个程序的输入时,只有stdout会被读取。如果你把提示信息混进stdout,下游解析就会收到脏数据。这是一个非常重要的工程意识:人是看stderr的,机器是读stdout的。

4. 黑洞与分流器:/dev/null、tee在日志工程里的正确用法

4.1 /dev/null:一个永远装不满的字符设备

我看过不少人把/dev/null叫做“垃圾桶”,其实它更像一个“黑箱”:任何写入它的数据会立刻被内核丢弃,而且你永远不可能从里面读到东西。它是一个字符设备文件,不是普通文件,所以不用担心它“写满”。

最常见的用法是屏蔽不需要的输出:

find / -name "*.conf" 2>/dev/null

这里为什么单独重定向2?因为find在遍历目录树时经常碰到权限不足的目录,那些Permission denied刷屏毫无价值,直接丢弃;而正常结果保留在stdout上继续用。

顺带提一个我踩过的冷门设备:/dev/full。向它写入任何内容都会返回磁盘已满(ENOSPC),而且设备本身不会“变满”。在测试脚本有没有正确处理磁盘满错误时,这是个非常好用的测试靶子。真实场景里,日志盘满、>重定向失败这类问题很常见,提前用/dev/full演练过错误处理逻辑,心里会踏实很多。

4.2 tee:既要落盘,又要实时在终端看

如果需要“既要留档,又要实时看”,tee比重定向更合适。它的语义是:从stdin读入,原样写一份给stdout,同时写入文件。

./run_tests.sh 2>&1 | tee test.log

这样终端能看到完整输出,文件里也留了一份。注意:运行完想追加而不是覆盖,用tee -a。

还有一个隐藏点:管道会改变左边命令的输出归宿,所以左边命令的stderr要提前合并到stdout(2>&1),否则错误输出不会经过tee。像上面例子,如果漏了2>&1,报错照样打在屏幕上,而日志文件里缺了错误段,整个日志就不完整了。

4.3 日志轮转后的重定向失效问题

生产服务常配合logrotate做日志切分。注意一个关键机制:logrotate把旧日志改名后,服务的文件描述符还指向旧文件的inode,从外部看就是“日志不再增长了”。解决办法通常是让服务重新打开日志文件,很多程序支持向它发送HUP信号重新初始化;如果是shell脚本自己管理日志句柄,就要用exec >> newfile 2>&1手动重开描述符。

实际中我见过有人依赖cron重新触发脚本“顺便”把文件重新打开,但如果服务是常驻的daemon,这样并不保险。最稳的做法是在脚本里设计好重新打开日志的入口,或者干脆把日志托管给logrotate能直接HUP到的那个进程。

5. 重定向的进阶形态:heredoc、exec与pipefail的组合拳

5.1 heredoc:把多行文本整体喂给命令

<<加分隔符,可以把多行文本作为命令的标准输入。最简单的例子:

python3 <<EOF print("hello from heredoc") EOF

更实用的是往MySQL或PostgreSQL里灌SQL:

mysql -u root -p mydb <<SQL SELECT * FROM users WHERE status=1; SQL

注意一个细节:默认情况下heredoc的正文里,变量和命令会展开。如果希望原样处理(比如生成一段本身含$的配置模板),就把定界符用单引号包起来:

cat <<'EOF' $HOME will not be expanded here EOF

这在脚本里生成配置文件时非常常用,能省掉一堆转义。

5.2 exec配合重定向:让脚本后面的所有输出自动进日志

exec不仅能执行新命令,还能直接修改当前shell进程的文件描述符。最有用的写法是在脚本开头一次性完成全局重定向:

exec > >(tee -a /var/log/app.log) 2>&1

这行的含义是:把当前脚本(包括后续所有子命令)的标准输出重定向到一个进程替换,tee在子进程中运行,同时把内容写到文件和继承过来的shell stdout。效果是脚本里所有echo、命令输出,之后都不需要再手动追加>> log 2>&1。

这种写法在长时间运行的脚本里特别爽。我现在的做法是:搭建好这个全局输出后,剩下的业务逻辑里我可以专注写命令,不用每一条都带着重定向尾巴,脚本一下子清爽很多。

进程替换还有另一个方向<(...),把命令输出伪装成一个文件路径:

diff <(sort file1) <(sort file2)

这在对比两个命令结果时极好用,也免去了写临时文件的麻烦。

5.3 pipefail:管道与重定向组合后的退出码陷阱

管道把前一个命令的stdout接到下一个命令的stdin,但shell在管道结束后只看最后一个命令的退出码。这会造成一个非常隐蔽的问题:

grep "pattern" file.txt | wc -l

即使grep因为文件不存在而报错,wc -l也会正常输出0,整条管道的退出码是wc的0。在脚本里用if判断就会误判为“一切正常”。

解决办法是在脚本开头设置:

set -o pipefail

开启后,管道返回最左边失败命令的退出码(如果后续还有失败命令则取决于最后那个)。如果和我一样习惯写set -euo pipefail,这条已经默认带上了。要提醒的是:pipefail开启后,某些命令“预期的非零退出”会被放大,比如grep没匹配到任何内容时,原本可能不想让脚本退出,现在就会触发set -e,这种场景需要酌情处理。

6. 一套可复用的脚本日志方案:从设计到落地的模板

6.1 需求拆解:一份日志要满足哪些基本要求

在写任何带日志的脚本前,先想清楚需求:

  • 需要时间戳,方便回溯;
  • stdout和stderr都要能进日志;
  • 最好是追加而非覆盖;
  • 能同时看到实时输出和留档;
  • 脚本退出码不因管道而失真。

简而言之:完整、可查、不丢、不漏、退出码准确。这五条看着朴素,真凑齐了并不容易。

6.2 模板脚本

基于前面的全部知识点,一个中等复杂度的bash脚本开头可以长这样:

#!/usr/bin/env bash set -euo pipefail LOG_DIR="/var/log/myapp" LOG_FILE="${LOG_DIR}/run_$(date +%Y%m%d).log" mkdir -p "${LOG_DIR}" # 把后续所有输出同时导向终端和日志文件 exec > >(tee -a "${LOG_FILE}") 2>&1 log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2 } log_info "backup job started" # 业务逻辑... rc=$? log_info "backup job finished, exit=$rc"

设计重点是几个部分分开:set -euo pipefail让脚本在未定义变量、早期失败时及时停滞;日志按天分文件,避免单个文件无限膨胀;exec配合进程替换,后续命令零成本获得重定向能力;log_error特意用>&2走stderr,因为如果脚本被另一个程序捕获输出,错误信息和普通日志不会混在同一个机器可读流里。

6.3 边界情况:全缓冲、unbuffered和崩溃丢日志

写到日志文件时,会因为缓冲问题出现“脚本都退出了,日志才出现”的现象。stdout在非终端(也就是重定向到文件)时默认是全缓冲,数据会先在缓冲区攒着,攒满或进程正常结束时才批量写入。如果脚本中途被kill -9,缓冲区里的内容会全部丢失。stderr则默认无缓冲,立即输出。这在线排查“脚本被kill后为什么日志缺尾巴”时非常关键。

对策有几种:

  • 关键日志直接走stderr,或者在自己的log函数里调用sync前显式打印标记;
  • 用stdbuf -oL把stdout从全缓冲切成行缓冲;
  • 更实用的做法,是别在脚本里依赖最后一条日志作为“成功证据”,多打几个阶段标记。

我自己的习惯是把这套模板脚本保存好,新脚本直接复制头部那几行,比每次临时思考重定向写法要少踩很多坑。输出重定向这块知识,初看零散,一旦你理解了“描述符是指针,重定向就是改指针指向”这个本质,所有写法都能推导出来,花一次时间弄透,后面十年都受益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询