做shell脚本这些年,我越来越确信一个判断:shell编程重定向操作是区分“会写脚本”和“写好脚本”的一道分水岭。很多人能写出一串能跑的命令,但一旦涉及日志收集、交互输入、错误输出分离、后台任务管理,就开始靠猜和试。重定向看起来就是几个符号,>、>>、<、2>&1,背后却是完整的文件描述符模型。把这个模型吃透,你能让脚本自己记录日志、自动应答输入、把不同来源的输出分流到不同文件,也能在排查故障时一眼看出命令到底死在哪一步。这篇文章适合所有写过几个shell脚本、但总觉得对重定向“差点意思”的人,我会从文件描述符讲起,一路讲到here document、exec重定向和常见坑位,全程用可复现的命令行示例说话。
1. 重定向的本质:一切都是文件
1.1 标准输入、输出和错误都在那三个编号里
在Linux的世界里,有一个极其底层的设计哲学:一切都是文件。普通文件是文件,目录是文件,设备是文件,管道是文件,甚至网络连接也抽象成文件描述符。一个进程启动后,操作系统会为它打开一些默认的“文件”,每个文件都有一个非负整数作为编号,这就是文件描述符(file descriptor)。
shell启动一条命令时,该命令通常自带三个已经打开的描述符:
- 0号描述符:标准输入(stdin),默认连着键盘。
- 1号描述符:标准输出(stdout),默认连着当前终端屏幕。
- 2号描述符:标准错误(stderr),也默认连着屏幕,但它和标准输出是两条独立的通道。
很多初学者最容易忽略的点就是:标准输出和标准错误虽然都显示在同一个终端上,但它们是两个不同的“文件描述符”,各自拥有独立的偏移量和缓冲区。你在屏幕上看到报错信息,并不代表它走了标准输出。这个区别平时看不出来,一旦做重定向,就会暴露得干干净净。
举个例子,执行一条命令,然后用重定向把“正常结果”写到文件里:
ls /nonexistent > result.txt你会看到屏幕上依然报错“ls: cannot access '/nonexistent': No such file or directory”,而result.txt是空的。原因很简单:报错信息走的是2号描述符,>只重定向了1号描述符。这就是为什么很多新手觉得“我明明重定向了,为什么还有输出”的原因——你以为自己已经把全部输出逮住了,其实只逮住了一半。
1.2 文件描述符是怎么跟终端、文件、管道串起来的
要理解重定向,就要理解shell在启动一个命令之前做了什么。正常情况下,命令的0、1、2都指向终端设备,也就是同一个tty。当你写> file时,shell会先以写入(或追加)方式打开目标文件,拿到一个新的文件描述符,然后把这个新描述符复制到1号位置。命令实际运行时,向1号描述符写入的内容就不再去终端,而是进入那个文件。
这个过程可以理解为“改道”:文件描述符本身是一个抽象的通道,重定向就是把这个通道的出口或入口从默认位置切换到另一个位置。默认位置是终端,切换后可能是文件、管道、另一个描述符,甚至是一台远程设备。
管道的本质也在这里。管道符|会把左边命令的1号描述符接到右边命令的0号描述符上。两边一接,左边输出就变成右边的输入。整个过程不经过磁盘,只在内存里传递数据。从文件描述符的角度看,管道的引入等于把进程之间的数据流动也统一成了“读写文件”这套模型。
当你真正用“三个描述符加改道”的视角去看shell,很多行为都会变得顺理成章。比如2>&1的意思就是把2号描述符改道到1号描述符当前指向的位置;而>&2则反过来。它不是“把错误输出变成正常输出”,而是“让错误输出和正常输出走同一条路”。一字之差,含义完全不同。
2. 最常用的重定向操作,先把手练熟
2.1 输出重定向:> 和 >> 的区别不只是覆盖和追加
先看最基础的两个符号。
echo "hello" > test.txt cat test.txt这条命令把“hello”写入test.txt。如果test.txt原本有内容,会被清空重写;如果文件不存在,会自动创建。使用>时要特别注意:它的“截断文件”动作发生在命令真正执行之前。也就是说,即使后面的命令执行失败,目标文件也已经被清空了。这是个非常阴险的行为,我在生产环境清理日志时踩过不止一次。
提示:想避免“命令失败但文件被清空”的问题,可以使用set -o noclobber,让shell拒绝覆盖已有文件,除非显式使用>|。
set -o noclobber echo "hello" > test.txt # 如果test.txt存在,这里会报错 echo "hello" >| test.txt # 强制覆盖追加用>>,它不会清空原有内容,而是把新内容附加到文件末尾。行为上的差异看似简单,但在日志场景里是性命攸关的。写日志应该用>>,因为日志只增不减;生成临时快照、导出文件才用>,因为我们要的是“当前状态的一次性结果”。
除了普通文件,输出重定向还可以指向设备文件。比如把输出丢掉,最经典的做法是写到/dev/null,这是一个“黑洞设备”,任何写进去的内容都会被系统直接丢弃。
command > /dev/null 2>&1这大概是shell里最常见的组合拳,意思是“正常输出和错误输出全部丢进黑洞”。它的顺序有讲究:必须先把2号重定向到1号当前的位置,才能让两者都被丢弃。如果写成command 2>&1 > /dev/null,结果会变成错误输出留在屏幕,只有正常输出进黑洞,因为2>&1执行时1号还指向屏幕。理解这里的执行顺序,能帮你避开大量诡异现象。
2.2 输入重定向:< 不是摆设,它是脚本交互的基础
输出方向大家用得熟,输入重定向<用得就没那么频繁了。它的作用是把命令的0号描述符从键盘改道到某个文件。
最典型的场景是给交互式程序喂数据。比如fc命令默认打开编辑器让你编辑历史命令,但你可以用输入重定向直接提供内容;又比如给sqlite、mysql等数据库命令行工具批量执行SQL,与其复制粘贴,不如写一个.sql文件然后用<读入:
sqlite3 test.db < init.sql输入重定向本身并不复杂,可一旦遇到需要“交互应答”的程序,就有新问题。比如某安装脚本运行时会问“Do you agree? [y/n]”,你想自动回答y,可以这么做:
echo "y" | ./install.sh这里用管道把echo的输出接入install.sh的标准输入。管道其实也是一种输入重定向,只不过数据源不是文件,而是另一个进程。想用文件做到同样的事,可以提前准备一个answer.txt,里面预先写好所有回答:
./install.sh < answer.txt输入重定向还有一个容易忽略的用途:把循环体的输入统一从文件读取。比如用while read处理文件每一行时,如果循环体内还有read命令,两者会互相抢夺标准输入,导致循环只读第一行或者行为错乱。解决方法是把文件重定向到循环结构上,而不是内部命令上:
while read line; do echo "line: $line" read -t 1 extra done < data.txt这里的< data.txt是作用在整个while循环上的,循环内的所有read命令都会从同一输入流读取。这是个很隐蔽的细节,不踩几次坑很难体会。
2.3 错误重定向:2> 和 2>&1 必须一起记住
单独使用2>的效果很直白:只把错误输出送进指定文件,正常输出保持原样。
ls /etc /nonexistent 2> error.log正常列出的/etc内容依然显示在屏幕,error.log里只有“不存在”那条报错。这种分离在排障时非常有用,你可以只关注错误,也可以只关注正常结果。
如果想两者合并到一个文件,有两种主流写法:
command > all.log 2>&1另一种是bash、zsh等支持的简写:
command &> all.log两种写法效果相同,但历史原因不同。&>在POSIX标准里没有,不过主流shell都支持。如果你写的脚本要严格跑在很多新环境上,我建议用> file 2>&1这种写法,兼容性更好。注意顺序:> file 2>&1是先打开file作为1号描述符的出口,再把2号复制到1号当前的位置,所以两者都进了文件。而2>&1 > file是先把2号指向屏幕(1号当时的位置),再把1号指向文件,最终错误输出还是留在屏幕。掌握“从右往左描述、从左往右执行”的口诀,这类问题基本告别翻车。
需要把正常输出和错误输出分别存到不同文件时,可以这样:
command > ok.log 2> err.log这种分离在很多数据同步场景里特别有用:正常日志看进度,错误日志查失败。比如用rsync同步大量文件时,把“同步成功的文件列表”和“同步失败的文件列表”分开存储,后续分析会轻松很多。
3. 高级玩法:让重定向变成脚本里的“隐形管道”
3.1 Here Document:一段文本当作输入,别只在SQL里用
here document是输入重定向的一种扩展,语法是<<后跟着一个分隔符,然后在脚本片段里直接写入多行文本,直到分隔符再次出现为止。最常见的写法是:
cat << EOF Hello, this is line 1. This is line 2. EOF这段命令会把两个换行文本作为cat的标准输入,然后在屏幕上打印出来。这里用EOF只是习惯,实际上可以用任意不冲突的字符串,比如END、EOS,甚至CASE_CHECK。唯一的要求是结束分隔符必须独占一行,行首不能有其他字符。
here document默认会做变量展开和命令替换。如果你想原样输出$符号、反引号这些特殊字符,需要给分隔符加上引号:
cat << 'EOF' $HOME `date` EOF加了引号后,$HOME会被原样打印,而不是展开成/home/xxx。这个技巧在生成配置模板时非常常用。比如要生成一个包含大量特殊字符的nginx配置片段,你可以把整个模板放进单引号包裹的here document里,保证原样输出。
here document最常见的实战场景是给数据库命令提供SQL脚本。以前我总喜欢把SQL写进临时文件再导入,后来发现直接用here document更方便:
mysql -u root -p mydb << SQL SELECT * FROM users WHERE age > 20; UPDATE users SET status = 1; SQL注意,这里的分隔符是SQL,它既不是关键字,也不是命令,只是一个标记。整个命令执行完毕后,mysql读到的标准输入就是两行SQL文本。这种写法最大的优势是脚本自包含,不需要额外生成临时文件,也不容易残留垃圾文件。临时文件虽然也能用,但每次都要记得清理,一旦脚本异常退出,很容易留一堆半成品文件。
3.2 Here String:比 echo 管道更省事的输入方式
here string是here document的轻量版本,语法是<<<后面直接跟一个字符串。它会把字符串当成标准输入喂给命令,并在末尾自动补一个换行符。看个最简单的例子:
cat <<< "hello"效果相当于echo "hello" | cat,但不需要多起一个管道进程,更简洁。在bash中,here string常用于向read命令提供输入:
read -r name <<< "Alice" echo "$name"这种写法和echo "Alice" | read name的主要区别在于执行环境。管道右侧的命令通常运行在子shell中,read得到的变量在管道结束后就消失了;而here string是在当前shell中完成的,变量可以保留下来。这一差异在实际写脚本时影响非常大。
还有一类很实用的小场景,就是处理带前导空白的字符串。比如用read按字段拆一个csv行:
while IFS=',' read -r f1 f2 f3; do echo "$f1 | $f2 | $f3" done <<< "apple,banana,orange"这里借助here string把整行交给循环体,循环体内的read从标准输入读取,从而完成字段拆分。如果你尝试用管道只能处理第一行,因为整个循环在管道后面是单独的子shell,变量和状态都会丢失。关于这一点,我后面排查部分还会详细说。
3.3 exec 重定向:把整个脚本的输出引到日志文件
exec是shell内建命令,它的一个高级用法是在当前shell中修改文件描述符,而不是启动新进程。最常见的需求是把整个脚本后续的所有输出都重定向到日志文件,而不用给每条命令单独加>。
#!/usr/bin/env bash exec >> /var/log/myscript.log 2>&1 echo "脚本开始" date echo "脚本结束"加了exec之后,当前shell的1号描述符和2号描述符都被改道到日志文件,后续所有命令的输出默认都会进这个文件。因为exec执行后不会启动新的子进程,改的是当前shell自己的描述符,所以这个重定向会持续生效,直到脚本结束或手动恢复。
这种做法特别适合后台任务的日志收集。你不需要在每条命令后面费力地加>>,更不需要担心漏掉某个命令的输出导致日志不完整。只需一行,全局生效。当然,使用exec重定向后,脚本里若还有其他想输出到终端的命令,就需要把那个命令单独重定向回来:
echo "这条信息专门写到另一个地方" >&3或者先备份原描述符,用完后恢复。下面这个备份恢复的技巧,在复杂脚本里能救命:
exec 3>&1 # 先把当前标准输出的位置备份到3号描述符 exec >> logfile 2>&1 echo "这句进日志" exec 1>&3 # 恢复标准输出 echo "这句回到终端"这里的exec 3>&1相当于复制了一个“指向原终端的通道”到3号描述符备用。等脚本的日志部分处理完,exec 1>&3把标准输出重新指向备份的通道,一切恢复原样。如果你不备份,一旦执行exec >> logfile,再想找回终端就要靠诡异的方法,得不偿失。
3.4 文件描述符的复制与恢复:别让脚本“哑”了
既然提到了3号描述符,那就展开说说描述符复制。shell允许你像复制1号、2号那样创建自定义描述符(通常是3到9),然后通过读写这些描述符来操作文件或设备。
打开一个文件作为输入:
exec 4< input.txt read -u 4 line1 read -u 4 line2 exec 4<&-这里的-u 4让read从指定描述符读取,而不是默认的标准输入。读取完毕用exec 4<&-关闭描述符,释放资源。这个模式在处理多个输入文件时非常强大,因为你可以同时打开多个文件,分别用不同的描述符读取,而不会互相干扰。
输出方向同理:
exec 5> result.txt echo "abc" >&5 exec 5>&-自定义描述符不只是为了炫技。实际的场景包括:临时替换标准输出、同时维护多个日志流、给函数单独分配日志通道。比如你在一个脚本里既要记录业务日志,又要记录调试日志,就可以用两个自定义描述符分别指向不同文件,需要哪种就写哪种。这样比不停地在重定向上切换要清晰得多。
需要注意,自定义描述符是继承给子进程的,如果不关闭,子进程可能会意外占用,造成资源泄漏。所以用完一定要关闭。写复杂脚本时,文件的打开和关闭要尽量对称,就像你编程时要记得释放资源一样。
4. 避坑指南与问题排查
4.1 我踩过的五个重定向坑
第一个坑是“覆盖文件前没有确认”。前面提到过,>会先清空文件,即使命令执行失败。我曾经写过一个生成报告的脚本,因为临时改动了重定向目标,结果把前一天的报告清空后又执行失败,辛苦一天的数据全没了。从那以后凡是覆盖性输出,我都会先做一次文件存在性判断,或者干脆用>>追加。这个习惯值得刻进骨子里。
第二个坑是“管道只看得到标准输出”。默认情况下,管道只传递1号描述符的数据,2号描述符还是会直接打到终端。如果你用管道做日志分析,却把错误信息留在屏幕,分析结果必然不完整。解决办法是把错误输出合并到标准输出:
command 2>&1 | tee /tmp/debug.log有时写成command |& tee /tmp/debug.log,这是bash的简写,效果相同。做巡检、备份等操作时,这一步往往是发现根因的关键。
第三个坑是“在子shell里重定向丢了变量”。管道右侧的命令运行在子shell里,子shell里的变量修改不会影响父shell。这里有个经典案例:
count=0 cat data.txt | while read line; do ((count++)) done echo "$count" # 输出0,因为while跑在子shell里改成here string或者输入重定向到循环体后,count才能正确累加:
count=0 while read line; do ((count++)) done < data.txt echo "$count"这个坑初看和重定向关系不大,根子在于shell对管道的实现方式。如果你不熟悉,每次写统计类脚本前都要先想清楚:我到底是在当前shell读取,还是在子shell读取。
第四个坑是“noclobber选项导致的诡异失败”。我见过有的脚本开头set -o noclobber防止覆盖,后面的命令却因为没有使用>|而反复报错,测试环境又经常被误扣“权限”帽子。这类问题排查时,可以先运行set +o noclobber恢复默认行为,再逐条重试命令,就能快速定位是不是覆盖保护引起的。
第五个坑是“后台任务输出没人接”。在交互式终端里跑后台作业,如果不重定向输出,作业结束后的输出会直接打到终端上,干扰当前工作。更麻烦的是,登录退出后,后台任务可能因为“无法向终端写数据”而挂起或退出。解决办法是给后台任务统一重定向到日志文件,并关闭标准输入:
nohup mytask.sh >/tmp/mytask.log 2>&1 &nohup的作用是让任务在会话结束时忽略挂断信号,但输出重定向同样必不可少。只写nohup而不重定向,很多时候依然会出问题。
4.2 排查重定向问题的三板斧
碰到重定向行为异常,我一般按三步走。
第一步,先确认命令到底输出到了哪个描述符。可以用一个临时命令分别测试:
echo "stdout" echo "stderr" >&2如果看到两个都显示在屏幕,再分别执行:
cmd > /tmp/out.txt cmd 2> /tmp/err.txt观察out.txt和err.txt里分别有什么。很多时候问题就出在“我以为是标准输出,其实它是标准错误”。
第二步,检查是否被别处的重定向影响了。脚本里如果有exec重定向全局修改了描述符,后续命令可能全被“带偏”。逐行排查时可以用bash -x加重定向查看脚本执行过程:
bash -x myscript.sh > /tmp/debug.out 2>&1输出里会显示每条命令实际执行前的展开内容,包括重定向符号。如果看到某条命令的参数里出现了不是你写的文件路径,很可能是变量展开或者函数返回值出了问题。
第三步,验证文件系统和权限。重定向到一个目录路径、磁盘写满、文件权限不足,都会导致看起来“命令没执行”或者“输出丢失”。用dmesg或df检查磁盘,用ls -l检查目标文件权限,用id确认当前用户。这些基础排查有时候反而最有效。
再补充一个实用小技巧:用strace追踪系统调用。虽然strace可能不是每个环境都有,但一旦拥有,判断重定向问题就像开了天眼。执行:
strace -f -e trace=open,dup2,pipe,read,write myscript.sh 2>&1 | less能看到每条命令打开哪些文件、调用哪些系统调用、复制了哪些描述符。很多时候,一条dup2的调用顺序就解释了你百思不得其解的“为什么顺序反了”。这个工具能让你从“相信直觉”变成“看见真相”,我强烈推荐遇到疑难杂症时使用。
4.3 一个综合示例:日志轮转里的重定向组合
说一个综合场景:假设你要写一个每分钟执行一次的监控脚本,它要收集系统负载、磁盘使用和内存信息,然后把这些信息追加到同一个日志文件,同时把错误单独记录,还要在一个安全位置写一份去重的摘要。
我倾向的写法是这样的:
#!/usr/bin/env bash log_dir=/var/log/node_monitor mkdir -p "$log_dir" exec >> "$log_dir/raw.log" 2>> "$log_dir/error.log" echo "=====$(date '+%F %T')=====" uptime df -h free -m这里用exec把整个脚本的标准输出和错误输出分别导出到不同日志,你不用在每条命令后面手动处理。等运行一段时间后,error.log里如果出现大量内容,就说明系统或者脚本本身出现了需要关注的问题,而raw.log则保持了干净的指标数据。
再配合一个定时轮转任务,你就可以在cron里把这个脚本的输出“绑定”到固定日志,同时cron自身的邮件通知也不会被海量日志淹没。等你需要做故障回溯时,raw.log和error.log分开存放,排查效率会明显提高。
这种“exec + 分别重定向”的组合,几乎是我写所有长期运行脚本的默认起手式。它让脚本的输出结构变得可预期,也让后续的日志分析变得简单许多。
5. 最后几个手边常用的经验
写到这里,我觉得有一个观点值得反复强调:重定向的真正难点不在符号本身,而在“理解shell是怎么在进程启动前准备好这些描述符的”。每次碰到重定向行为诡异,先回到文件描述符模型去推演一遍,通常都能找到答案。我个人的习惯是,在写任何超过二十行的脚本前,先把“标准输出、标准错误、日志文件、临时文件”这四类数据流向画一遍。这一遍画下来,脚本的结构往往也清晰了大半。
最后分享一个小技巧:排查脚本时,给每条命令临时加上set -x,再配合重定向把调试信息单独导出,比直接在屏幕上刷屏要清爽得多。调试完成后去点set -x,脚本保持干净。如果你平时维护的脚本不多,建议把exec配合备份描述符、here string配合循环、2>&1与顺序敏感这几组技巧贴在编辑器旁边,写多了自然就变成肌肉记忆。
重定向这个主题,说到底是“文件描述符的操纵术”。把这张牌打熟了,你的shell脚本会从“能跑”进化成“好维护、好排查、好复用”。学的时候慢一点没关系,踩坑也是学费。只要每次遇到问题都能回到三个默认描述符上重新思考,你会发现,大部分shell问题其实都是同一个问题。