☰
Linux文件描述符0、1、2解读:重定向、管道与/dev/null实战
2026/10/13 10:36:03 网站建设 项目流程

很多人在命令行里泡了几年,ls、grep、cat用得很熟,但真被问一句“数据通道 0、1、2 到底是什么”,能立刻讲清楚的人其实不多。这个题目看着基础,却是整个 Linux 命令行和 Shell 脚本的命根子:标准输入、标准输出、标准错误,再加上那个专门用来“吞掉”数据的/dev/null黑洞,说它们是进程的三条命脉也不夸张。这篇文章就从这三个编号说起,一路聊到重定向顺序、管道数据流、日志清空、脚本陷阱,把底层逻辑和实战操作放在一起讲透,适合刚入门的读者,也适合写脚本总踩坑的人对照排查。

1. 0、1、2到底是什么:进程的数据通道编号

1.1 三个数字背后是三条默认通道

在 Linux 里,一切皆文件,连键盘、屏幕、网络连接也都是文件。进程要想读写文件,得先向内核申请一个文件描述符(File Descriptor,简称 fd),内核返回一个非负整数,之后所有操作都拿这个数字来指代目标文件。不过,每个进程在诞生的时候,内核早就给你额外开了三个默认描述符:

  • 0:标准输入(stdin),默认读数据的地方
  • 1:标准输出(stdout),正常结果写出的地方
  • 2:标准错误(stderr),错误信息写出的地方

你可以在终端里执行下面这条命令,亲眼看一下这三个编号指向哪里:

ls -l /proc/self/fd

输出大概是这样的:

lrwx------ 1 user user 64 ... 0 -> /dev/pts/0 lrwx------ 1 user user 64 ... 1 -> /dev/pts/0 lrwx------ 1 user user 64 ... 2 -> /dev/pts/0

也就是说,默认情况下,0、1、2 都指向同一个终端设备文件/dev/pts/0,数据从键盘进来,正常结果和错误都打到屏幕。想直观理解这三个编号,可以把进程想象成后厨里的一块操作台:0是进货口,食材从这儿进来;1是摆盘出口,做好的菜从这儿端出去;2是废料出口,切坏、烧焦的东西从这儿扔出去。三个口一开始都对着同一个大厅,也就是你面前的屏幕。

1.2 标准错误为什么要单独占一个编号

很多人第一次接触重定向时都会问:正常输出和错误输出都打到屏幕,为什么非要分两个口?答案是:分开之后,你才能灵活地决定把哪一路留给自己、哪一路写进文件、哪一路直接扔掉。

举个例子。你执行grep foo /etc/passwd,结果正常,匹配的行会走1号通道。但如果你执行的是grep foo /no/such/file,文件不存在这个错误会走2号通道。表面上看都是“屏幕上有字”,但来源不同。正是因为1和2是独立的,你可以只隐藏错误、保留正常结果:

grep foo /etc/passwd 2>/dev/null

也可以反过来,只保留错误、丢掉正常结果:

grep foo /no/such/file >/dev/null

甚至在脚本里把正常结果写进业务日志、把错误写进单独的错误日志,将来排查时互不干扰:

grep foo file.txt > out.log 2> err.log

所以从设计之初,Linux 就把“人的判断”交给了命令行本身:正常结果和错误信息是两类东西,不该混在一个管道里流走。

1.3 没有重定向时,一切从父进程继承

还有一个容易忽略的点:新进程启动时,0、1、2 并不是“自动打开一个新终端”,而是从父进程那里继承下来的。当你打开终端,Shell 启动,它先打开终端设备,得到 0、1、2;之后你执行的每个命令,都是 Shell 创建的子进程,这些子进程会直接继承 Shell 的 0、1、2。这也是为什么你在终端里输出的内容会显示在屏幕上——因为所有后代进程的出口都沿着这条链指到了同一个终端。

理解这一点很重要。以后你在脚本里执行exec 1>>run.log,这条命令改的是 Shell 自身的1号文件描述符,之后脚本里所有命令,包括你敲的echo、ls、python,它们的 stdout 都会继承这个已经指向run.log的描述符,输出自然全部落进日志。很多人写脚本时对“为什么输出消失了”百思不解,多半就是没意识到继承关系。

2. 重定向基础:>、>>、<、2>&1的实操逻辑

2.1 输出与输入重定向,先记住两张表

Shell 的重定向本质,就是临时修改某个文件描述符指向的目标。绝大多数日常操作都逃不出这几条:

写法含义
cmd > file把cmd的 stdout(fd 1)指到文件,覆盖写入
cmd >> file把 stdout 指到文件,追加写入
cmd 2> file把 stderr(fd 2)指到文件
cmd 2>> file把 stderr 追加到文件
cmd < file把 stdin(fd 0)从文件读取
cmd 2>&1把 stderr 指到 stdout 当前指向的位置
cmd >/dev/null 2>&1两路输出全部丢弃

拿wc -l < /etc/passwd来说,这里<把wc的标准输入从键盘换成了/etc/passwd文件,于是wc的read系统调用拿到的就是文件内容。输入重定向在现代交互式命令中使用频率不高,但它几乎是所有 Shell 脚本里while read的基础。

2.2 顺序决定一切:2>&1和>file的先后不能乱

这是我认为整个重定向体系里最重要、也最容易翻车的一点。Shell 解析重定向时是从左到右执行的,每一步只是把某个编号指向“当前”的另一个编号指向的目标。

先看这个几乎人人都会写的命令:

cmd > log 2>&1

执行顺序是:先把 fd 1 指向log,再把 fd 2 指向 fd 1 当前指向的目标——也就是log。所以最终 stdout 和 stderr 都进了log,预期正确。

再看一个据我观察至少有一半新手会搞反的写法:

cmd 2>&1 > log

执行顺序变成了:先把 fd 2 指向 fd 1 当前指向的目标(此时此刻 fd 1 还指向终端),再把 fd 1 指向log。最终结果是:stdout 进log,stderr 仍然打到终端。如果你本来想把错误也收进日志,这个顺序就是灾难,而且非常隐蔽——屏幕上的报错会让你误以为命令还没执行完,或者日志里少了关键信息。

还有一个 Bash 里的便捷写法cmd &> log,它等价于cmd > log 2>&1,不用管顺序。但要注意,&>是 Bash 扩展写法,在严格 POSIX sh 环境里不一定支持。我在生产环境的定时任务脚本里吃过这个亏:本地用 Bash 调试一切正常,部署到只装了sh的极简容器里就报语法错误。从那以后,我在跨环境脚本里都老老实实写> log 2>&1。

2.3 输入重定向的隐藏用法:here document

除了从普通文件重定向,Shell 还支持 here document 的方式来喂给标准输入,最常见的是配合cat生成配置文件,或者给脚本里的交互程序喂参数:

cat > /tmp/test.conf <<EOF server=127.0.0.1 port=8080 EOF

这里的<<EOF会把两行内容作为 stdin 送给cat,cat再把 stdin 原样重定向写入/tmp/test.conf。有一个细节值得记一下:定界符EOF加不加引号,变量展开行为完全不同。

name=zhangsan cat <<EOF # $name 会被展开成真实值 hello $name EOF cat <<'EOF' # 原样输出 $name 这五个字符 hello $name EOF

我在写安装脚本时经常用单引号包裹定界符,防止用户输入里的$被无脑展开,造成配置内容错乱。这种“程序员以为写的是字面量,Shell 却当成变量展开”的问题,排查起来相当费劲,至少要花几分钟。

3. Linux黑洞底座:/dev/null的真实面目

3.1 一个能吞掉所有数据,又永远填不满的“文件”

/dev/null在 Linux 下是一个非常特殊的字符设备文件。你执行ls -l /dev/null会看到类似这样的信息:

crw-rw-rw- 1 root root 1, 3 ... /dev/null

注意开头那个c,它表示字符设备,后面跟着的1, 3是设备号。对普通用户来说,只需要记住它的行为:向/dev/null写入多少数据,内核都会直接丢弃,不会占用磁盘;从/dev/null读取数据,立刻返回 EOF,什么都读不到。这也是它被叫“黑洞”的原因:无底洞,怎么扔都装不满。

为什么需要这么个东西?因为很多命令会打印大量正常结果,而有些结果你根本不需要看。比如用find全局搜文件,权限不足时它会打一堆Permission denied;用curl测试接口只需要看返回码,不关心响应体;定时任务里可能有耗时的清理脚本,你却不想让它的输出淹没收件箱。/dev/null就是那个完美的垃圾桶,配上重定向语法,你想丢哪一路就丢哪一路。

3.2 典型用法:把不想看、不敢看、不能留的输出全丢进去

最基础的用法是“只丢一路”:

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

这条命令会把 find 因为权限不足产生的错误信息全部扔掉,只保留真正匹配的文件路径。另一个高频场景是服务启动脚本,你只关心进程有没有起来,不关心启动日志细节:

nohup myserver > /dev/null 2>&1 &

这里先把 stdout 丢进黑洞,再把 stderr 指向 stdout 当前的位置,于是两路输出都进了黑洞。配合nohup和&,既使终端关闭也能继续运行,还不会在终端留下任何噪音。

还有一类场景是“只保留错误,丢掉正常结果”。比如编译时,你会被满屏的编译过程刷得心烦,但真正的警告和错误又必须看。这时候可以:

make >/dev/null

只把 stdout 丢掉,stderr 照旧打到屏幕。等编译结束,你看到的也就只有真正需要关心的错误警告。这个用法最好先想清楚再写:make >/dev/null 2>&1会让所有输出消失,万一编译失败你也什么都看不见,等于闭着眼开夜车。

3.3 用“黑洞 + 重定向”清空一个已经被占用的大文件

/dev/null另一个实用场景是清空日志文件。生产服务器的日志经常动辄几个 GB,直接rm再新建同名文件是一个常见错误。原因在于:你的服务进程一开始就用某个文件描述符打开了旧的access.log,rm删掉的只是目录项,文件本体(inode)还在,占用空间也不会释放;随后touch access.log创建的是另一个全新的 inode,进程完全没有感知,继续向旧的已删除文件写入数据,磁盘占用依然只增不减。最终你会看到 log 文件大小永远为 0,但df -h显示的磁盘占用却一点没降。

正确的做法是在原有的 inode 上做截断,让正在写的进程继续写同一个文件:

cat /dev/null > access.log

或者更简单的 Shell 内置写法:

: > access.log

也可以使用更适合大文件的truncate:

truncate -s 0 access.log

这三条命令的效果本质一样:把文件长度清零,进程的文件描述符不变,后续日志继续正常写入。我处理过太多起“磁盘满了”的工单,都是因为半夜值班的人图省事执行了rm log,第二天业务日志全断、磁盘空间还爆满。记住一句话:被占用的大文件,优先截断,不要删除。

4. 管道联动:0、1、2在进程间怎么流动

4.1 管道的本质,是把左边的 1 接到右边的 0

Shell 管道cmd1 | cmd2对很多人来说是“把 cmd1 的结果过滤一下”,但底层要比这个具体得多。Shell 会先调用pipe()系统调用,在内核里创建一个管道对象,它有两个端点:读端和写端。然后同时调整两个子进程的描述符:

  • 左边进程cmd1的 stdout(fd 1)被指向管道写端;
  • 右边进程cmd2的 stdin(fd 0)被指向管道读端。

于是数据就这么自然地从cmd1的1号口流出,再从cmd2的0号口流入。你可以把管道想象成一根真正的管子,两个进程一个对着管口吹气,一个对着管口吸气。关键点在于:管道只连接了一边的1和另一边的0,和2号口没有任何关系。

这一点对调试脚本非常重要。很多人以为cmd1 | cmd2会把cmd1的所有终端输出都塞给cmd2,其实只有 stdout 会经过管道,stderr 依然会原封不动地打到当前终端。原因前面说过,2号口默认继承了父进程,管道根本没有碰过它。

4.2 经典翻车现场:find 的错误刷屏,grep 却管不着

举个例子,你想在/etc下找所有包含关键字admin的文件,顺手用了管道:

find /etc -type f | grep admin

结果屏幕上可能会冒出大量find: ‘/etc/ssl/private’: Permission denied这样的信息,而且完全没经过grep的过滤。这是因为那些权限错误是从find的 stderr 打出来的,管道压根没接 stderr。真正经过管道的只有 find 的 stdout——文件名列表,这些文件再被 grep 筛选。

想彻底清静,需要主动把find的 stderr 丢进黑洞:

find /etc -type f 2>/dev/null | grep admin

如果你希望 stderr 也参与管道过滤,得先合并到 stdout:

cmd1 2>&1 | cmd2

但这里还要注意顺序:cmd1 2>&1 | cmd2会把 cmd1 的 stderr 合并到 stdout,然后一起通过管道。如果你写成cmd1 | cmd2 2>&1,合并的就是 cmd2 的 stderr,不是 cmd1 的。这个顺序问题,我在排查别人脚本时见过太多次,几乎成了管道问题的头号嫌疑。

4.3 管道与黑洞组合:只要“最后一行”或“只看错误”

把管道和/dev/null配合起来,威力更大。例如某些命令会刷出海量输出,而你只关心前几行,可以:

dmesg 2>/dev/null | tail -20

再比如一条命令耗时很长,你想测试它能否成功,但不想在终端留下完整输出:

backup_script >/dev/null 2>&1

这里管道没参与,但思路一样:把不想要的输出吞掉,只看退出码。一个实用的排查方法是:在关键命令后立刻echo $?,如果返回 0,说明整个链路成功;如果非 0,再去掉黑洞重跑一遍定位。不要一上来就盲目接管道和黑洞,先用最小命令确认行为,再逐步加过滤,这样出问题时才知道锅到底该甩给谁。

还要提一个管道场景下的隐藏问题:当右侧命令提前退出,比如cmd | head,左侧进程可能收到 SIGPIPE 信号而异常终止。这在某些服务进程里会导致“任务没跑完”的假象,所以如果你在脚本里依赖管道结果的完整性,最好使用带set -o pipefail的写法,让管道中任何一端的失败都能反映到最终退出码上。

5. 脚本实战:用0、1、2做出能自愈的日志系统

5.1 用 exec 把脚本全部输出重定向进日志

写一个跑很久的部署脚本或定时任务脚本时,最怕的就是“人走茶凉”——当时没在终端盯着,第二天来看一无所获。给脚本加日志最简单的办法,是在脚本开头把 Shell 自身的 stdout 和 stderr 都重定向到固定文件:

#!/bin/bash exec 1>>/var/log/myjob.log 2>&1 echo "$(date) start job"

这里的exec会直接修改当前 Shell 进程的1和2号描述符,而不是影响某一条外部命令。之后脚本里所有 echo、ls、python 等命令,因为都是 Shell 的子进程,会继承这个已经被重定向的 stdout 和 stderr,所以它们产生的所有输出都会自动落进日志。

如果你还想在终端同步看到一部分输出,可以保留原有 stdout,在关键位置手动tee:

echo "backup done" | tee /dev/tty >> /var/log/myjob.log

不过这种写法比较进阶,普通脚本还是建议集中写日志,运行结束后再按需查看。好处是日志有完整的天然时间线,命令和返回码一目了然。

5.2 把正常结果和错误分开落盘,排查效率翻倍

如果日志只写一个文件,错误信息和正常输出混在一起,出了问题还得自己脑补哪一行是错误。更讲究的做法是在脚本里为重要命令同时保留两个日志通道:

backup_cmd > /var/log/backup.out 2> /var/log/backup.err if [ $? -eq 0 ]; then echo "backup ok" else echo "backup failed, see err log" fi

这样可以把“命令实际干了什么”和“命令哪里炸了”分开查看。配合tail -f /var/log/backup.err,你甚至可以实时观察是否有异常。

但有两点要小心。第一,if判断的是命令的退出码,跟文件描述符重定向没有关系,所以无论你重定向到哪,$?都还是真实命令的退出码,这点很常用,放心用。第二,如果你用了管道,命令的退出码可能被管道右边的命令“顶替”,这时候要用set -o pipefail。比如:

set -o pipefail find /etc -type f 2>/dev/null | grep admin > /tmp/result.txt

没有pipefail,即便 find 半路崩溃,只要 grep 正常返回,$?照样是 0,脚本会误以为成功。这个坑在数据完整性要求高的脚本里非常致命,强烈建议所有管道脚本开头都打开pipefail。

5.3 输入通道 0 的坑:while read 与内部 read 冲突

脚本里循环读取文件是最容易踩stdin的坑。看这段代码:

while read line do echo "line: $line" read -p "continue? " answer done < /tmp/tasks.txt

乍看没问题,但一旦进入循环,read answer会发现自己的 stdin 已经被重定向到了tasks.txt,于是根本没有机会等用户从键盘输入,而是一次一次地从文件里“吞”下一行内容。结果就是循环逻辑彻底错乱。这是典型的“0 号口被占用”问题。

正确做法是把文件描述符换成别的编号,比如3,避免和 stdin 抢:

while read -u3 line do echo "line: $line" read -p "continue? " answer done 3< /tmp/tasks.txt

这里的3<是把文件重定向到第 3 号文件描述符,read -u3明确告诉命令从 3 号口读,键盘的0号口就留给了read answer。这个技巧是脚本控制权的关键,尤其是写交互式脚本时特别管用。不要小看 0 号通道,它和 1、2 一样,同样可以被重定向、被占用、被挤占,只是平时你不太感觉得到而已。

6. 常见问题与排查思路速查

6.1 为什么日志文件明明被清空了,磁盘空间还是没变小

这个问题前面提过一个场景:文件被进程占用时,rm + touch会导致旧 inode 泄漏。但即使你用了正确的cat /dev/null > log,在某些情况下空间依然释放不了。最常见的原因是:你清空的只是一个软链接或硬链接文件的其中一个名字,而不是进程真正写数据的那个文件。排查思路是先用lsof找到占用文件的进程:

lsof /var/log/access.log

如果输出里出现(deleted)标记,说明文件已经被删除了,但进程还在写旧 inode。此时不要犹豫,先找到进程确认能否重启,重启后旧 inode 才会被真正释放。如果进程不能重启,那就只能想办法让它重新打开文件,通常意味着调整日志轮转配置,让进程在“写一个全新文件”而不是“继续写已经删除的 inode”。记住:磁盘空间释放,看的是有没有进程持有旧 inode,而不是你是不是删除了目录项。

6.2 为什么我已经重定向了 2>/dev/null,屏幕还是能收到报错

这个问题出现频率很高,原因也分好几种。第一种,你把2>/dev/null放错了位置,比如:

cmd 2>/dev/null | grep error

这个写法其实没问题,管道右侧的命令 grep 也会继承 cmd 的 stdout 输出,但2>/dev/null只作用于 cmd 自己。如果你是想让整个管道的任何一端都看不到 stderr,得两边都处理,或者用cmd 2>&1 | grep error把错误并入 stdout。第二种,某些程序本身不把错误写到 stderr,而是直接打开/dev/tty往终端写。这类程序大多是交互式工具,或者某些 CLI 明确要“不论如何都提示用户”。这种情况,Shell 层面的重定向无用,只能从程序自己的配置里关闭交互提示。第三种,你在某个子 Shell 里重定向了,但外层 Shell 没改,例如(cmd 2>/dev/null)只作用于括号内的子进程,外层不受影响。排查时先确认你关注的那条命令到底在哪一层执行。

6.3 为什么 nohup 或者后台进程的日志更新特别慢

很多人以为“重定向到文件之后,echo 出来马上就能在日志里看到”,但真实情况受缓冲机制影响。当 stdout 指向终端时,C 标准库默认行缓冲,遇到换行就刷出来;当 stdout 指向文件或管道时,默认全缓冲,也许要攒够 4KB 才有一次真正写入。所以程序明明在运行,日志却一动不动,就是因为数据被塞在用户态缓冲区里。解决办法是用stdbuf调整行缓冲,或者用unbuffer模拟伪终端:

stdbuf -oL -eL yourcmd > /var/log/yourcmd.log 2>&1

-oL表示 stdout 行缓冲,-eL表示 stderr 行缓冲。调试脚本时如果发现日志不实时,可以按这个方向处理。但要注意,stdbuf只对使用 C 标准库的动态链接程序有效,对静态链接程序无效,所以别指望它解决所有“日志迟钝”问题。如果程序崩溃,缓冲区里的数据可能会彻底丢失,这也是为什么排查崩溃问题时建议用strace或者gdb直接抓系统调用,而不是依赖日志文本。

6.4 快速自查表,救急用

最后整理一张极简自查表,直接贴在脚本里都不丢人:

场景标准写法
stdout 进日志,stderr 丢进黑洞cmd >> app.log 2>/dev/null
全部输出进同一个日志cmd >> app.log 2>&1
全部输出丢干净,只留退出码cmd >/dev/null 2>&1
只看 stderr,不看 stdoutcmd >/dev/null
让管道也能看到 stderrcmd 2>&1 | cmd2
脚本内部同时保护 stdout 和 stderrexec >> job.log 2>&1

这张表看着简单,但只要每条都吃透,再诡异的输出问题也能从里面找到对应思路。0、1、2 和/dev/null,说到底是 Linux 给每个进程配好的三根管道和一个垃圾桶,你真正掌握它们之后,写脚本的心态会完全不一样:不是被命令的输出追着跑,而是想让它去哪就去哪。

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

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

立即咨询