在 Linux 下干活,重定向和管道是绕不开的两个基本功。我发现很多新手手里捏着一堆命令,却总觉得它们像散装零件,没法拼成一台能转起来的机器。其实,只要把重定向和管道搞明白,你就能像流水线一样把命令串起来,让每一段只做一件事。这篇文章会从最底层的三个数据流讲起,把>、>>、2>&1、<、<<和|一个个拆开,再穿插我实际踩坑后总结出来的排查方法。适合刚接触 Linux 的同学,也适合那些命令行用了很久但遇到重定向顺序还是要打开搜索页面的人。
文章里的示例我都在 CentOS 和 Ubuntu 的真实环境跑过,不同发行版在 bash 和 sh 下的行为略有差异,我会特别标注。读完你会发现,这些操作不是背出来的,而是顺理成章推导出来的。
1. 不搞懂三个数据流,重定向和管道永远是死记硬背
1.1 每个命令都有两条出口和一条入口
当你在终端输入一条命令并按下回车时,Linux 内核为这个进程准备了三条默认通道。文件描述符 0 是标准输入(stdin),通常对应键盘;文件描述符 1 是标准输出(stdout),通常对应屏幕;文件描述符 2 是标准错误(stderr),也对应屏幕。所以你在终端看到的打印信息,其实混着两个来源:正常的输出和错误提示。命令本身不一定知道自己是在终端里还是被脚本调用,它只负责往 1 号通道写正常结果、往 2 号通道写错误信息。
这里有个非常典型的例子:
$ ls /etc/hostname /no/such/file /etc/hostname ls: cannot access '/no/such/file': No such file or directory第一条路径存在,所以正常结果走 stdout;第二条路径不存在,所以错误走 stderr。你看屏幕觉得都是“输出”,但对程序来说这两条路完全独立。你可以把命令想象成一家餐厅:0 号窗口接收客人点单,1 号窗口上正常菜品,2 号窗口负责处理退菜和投诉。默认情况下 1 号和 2 号窗口都对着大厅,客人分不清哪个是哪个。重定向要做的,就是把其中一个窗口接到后厨、另一个接到留言板,由你决定信息到底去哪。
很多人一开始没有意识到 stdout 和 stderr 是分开的,结果写脚本时把所有输出混到一个文件里,出了问题才发现错误日志被正常日志淹没了。比如编译 C 程序时,gcc main.c产生的代码错误和警告都会走 stderr,如果你只想保存警告文件,就需要把它们单独重定向出来。理解这三条通道,是后续所有操作的基础,一点也不夸张。
1.2 重定向是“文件”,管道是“命令”
判断一个场景该用重定向还是管道,我自己的标准非常简单:看右边接的是文件还是命令。
command > file:右边是文件,把输出写入文件。command | next_command:右边是命令,把输出交给另一个程序继续处理。
这个区别看起来显而易见,但实际操作里我见过不少把两者混用的例子。比如有人想保存/tmp目录列表,顺手写了ls -l /tmp | /tmp/list.txt,然后得到一堆Permission denied或者is a directory。原因就是管道符右侧必须是一条可执行命令,不是文件名。正确写法是ls -l /tmp > /tmp/list.txt。
不过重定向和管道之间还有一座桥,叫命名管道(FIFO)。用mkfifo创建出来的文件,看起来像一个普通文件,但它的数据并不落盘,而是由内核里的管道缓冲区直接转发给另一个读取进程。这和我们平时用|创建的匿名管道不太一样:匿名管道由 shell 临时创建,用完就消失;命名管道是文件系统里长期存在的节点,适合跨终端、跨进程通信。
举个例子,终端 A 先读取:
mkfifo mypipe cat mypipe终端 B 写入:
echo "hello" > mypipe终端 A 就会收到hello。这里写入端用的“大于号”其实是重定向,但目标不是普通文件,而是一个 FIFO 节点。所以说重定向和管道不是非此即彼,它们会在命名管道这里汇合。理解这条桥,对你后面处理复杂脚本会很有帮助。
2. 输出重定向从 > 到 >>,再到 2>&1:一次讲透
2.1 > 和 >>:覆盖还是追加,这是第一个选择
最基本的输出重定向就是>和>>。前者把文件整个覆盖掉,后者在文件末尾追加。这个知识点大家都会背,但真正用起来有几个容易被忽略的细节。
先看覆盖:
echo "start" > log.txt cat log.txt # start echo "next" > log.txt cat log.txt # next第二次执行>时,log.txt原来的内容直接没了。这是因为 shell 在打开文件时,默认使用O_TRUNC标志,文件只要存在就会被截断。很多新手第一次写脚本时不了解这一点,不小心把重要的配置或数据清空了,追悔莫及。
追加模式就安全得多:
echo "start" > log.txt echo "next" >> log.txt cat log.txt # start # next>>会打开文件并定位到末尾,不存在则创建。日常我写构建脚本时,常用这种方式把时间戳、编译结果逐步累积到一个日志文件:
date '+%F %T' > /tmp/build.log make >> /tmp/build.log 2>&1这里先>表示“本次构建从零开始记录”,后面用>>不断追加,2>&1则把编译错误也一并写进去。你可能已经看到了,这里混用了两种语法,下一节重点拆2>&1。
另外分享一个实用技巧:如果只是想快速清空一个日志文件,但不想删除文件本身,可以直接用> app.log。这样文件还在,但长度变为 0,正在写文件的进程不会因为 fd 失效而中断。唯一要注意的是,千万别在配置文件上随手敲> xxx.conf,一旦清空再想找回就麻烦了。
2.2 2>&1:为什么这个顺序不能乱
很多后台运行或日志记录的推荐写法都是command > file 2>&1,意思是把标准输出和标准错误都重定向到同一个文件。但这条命令有个经典变体:command 2>&1 > file,看起来只是换了个顺序,效果却完全不一样。我见过不少人在这个坑里栽过跟头。
关键在于,shell 处理重定向时,是从左到右依次设置文件描述符的。> file 2>&1的流程是:
- 先把 stdout(fd 1)指向 file;
- 再把 stderr(fd 2)指向“当前的 stdout”,也就是 file。
所以两者都进文件。而2>&1 > file的流程是:
- 先把 stderr 指向“当前的 stdout”,也就是屏幕;
- 再把 stdout 指向 file。
结果 stdout 进了文件,stderr 还是打在屏幕上。很多教程只会丢给你“最好用2>&1”,但没说顺序背后的含义。你看完这段,应该就彻底明白了。
| 写法 | stdout 去向 | stderr 去向 | 实际效果 |
|---|---|---|---|
cmd > file | file | 屏幕 | 最常见的错误 |
cmd > file 2>&1 | file | file | 推荐写法 |
cmd 2>&1 > file | file | 屏幕 | 顺序反了,坑 |
cmd &> file | file | file | bash 简写 |
cmd 2>&1 | grep err | 管道 | 管道 | 合并后过滤 |
&> file是 bash 提供的简写,等价于> file 2>&1。但它不是 POSIX 标准,在sh或某些精简环境下会报语法错误,所以我更推荐写全> file 2>&1,通用性最好。
如果你想把正常输出和错误输出分别存到两个文件,也不难:
find / -name "*.conf" > /tmp/conf_found.txt 2> /tmp/conf_error.txt这条命令里,正常结果写入conf_found.txt,权限不足等错误写入conf_error.txt,两者互不干扰。分析日志时用这种方式,可以快速对比“哪些路径找到了”和“哪些路径没权限查”。
2.3 <、<<、<<<:输入也能重定向
说完了输出,再看输入。<可以把文件内容当作命令的标准输入,在管道概念出现之前,这是命令之间传递数据的常用方式。
sort < unsorted.txt tr 'a-z' 'A-Z' < file.txt有个容易忽略的差别:wc -l < file和wc -l file的输出格式不一样。前者因为命令是从 stdin 读取,不知道文件名,只输出行数;后者会带上文件名。写脚本做数值运算时,通常用wc -l < file,因为结果更干净,不会多出个文件名字段。
<<是 Here Document,主要用于在脚本里内嵌多行内容,然后把它喂给某个命令。最典型的场景是生成配置:
cat > /etc/nginx/conf.d/example.conf <<'EOF' server { listen 80; server_name example.com; } EOF这里EOF是分隔符,可以换成任意字符串,但要保持前后一致。如果分隔符加了单引号'EOF',内容中的$VAR、反引号等不会做展开,原样写入文件。如果没有加引号,shell 会把里面的变量替换掉。我做部署脚本时,经常用这个特性在运行时把环境变量渲染进配置文件:
cat > /tmp/config.yml <<EOF url: ${APP_URL} token: ${APP_TOKEN} EOF<<<是 Here String,比<<更轻量,直接把一个变量或字符串作为标准输入。比如:
grep -i error <<< "$LOG"这条命令等价于echo "$LOG" | grep -i error,但不用额外开一个管道进程,在循环里调用时会清爽很多。我个人很喜欢用<<<配合bc做数值计算:
bc <<< "scale=2; 10 / 3"输出3.33,不回车不换行,非常干净。
3. 管道:把命令串成流水线
3.1 管道是怎么做到“隐形连接”的
管道符|的底层原理并不神秘。shell 看到cmd1 | cmd2时,会调用pipe()创建一个内核管道缓冲区,然后让两个进程同时运行:cmd1的 stdout 指向管道写端,cmd2的 stdin 从管道读端读取。数据不会落到磁盘,而是在内核内存里流动。管道缓冲区满时,写端会阻塞;缓冲区空时,读端会等待。这天然实现了背压控制,所以不用担心一串命令会把内存撑爆。
我经常跟别人打一个比方:管道就是车间里的传送带。前面的工人把加工完的零件放到传送带上,后面的工人拿起来继续加工,两边可以同时干活。这个比喻虽然简单,但能解释很多现象:比如你执行ping -c 3 127.0.0.1 | grep time,实际上ping和grep是并行启动的,不是等ping全跑完才交给grep。
区别在于,普通文件的重定向是“把整本笔记本丢给你”,而管道是“一边写一边读”。所以当我们写cat huge.log | grep error时,grep不需要等cat读完整个文件就能输出第一批匹配结果,这在大日志分析中是实打实的效果。
命名管道(FIFO)和匿名管道的区别也可以在这里讲清楚。匿名管道没有文件名,只在创建它的 shell 和他的子进程之间有效。命名管道会出现在文件系统里,任何拥有权限的进程都能通过文件路径来访问它。前面我演示过用重定向往 FIFO 里写数据,这里再补充一个注意事项:如果先执行echo "x" > mypipe,而读端还没就绪,这条命令会阻塞住。所以使用 FIFO 时,最好先让读端运行,或者把写端放到后台。这是新手最容易卡住的地方。
3.2 高频命令组合:日志分析、进程管理和参数终结者
管道的价值在于组合,而组合能力来源于对常用命令的熟悉程度。我挑三个最常见的场景来演示。
第一个是统计日志里的客户端 IP 排名:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20我解析 nginx 访问日志时几乎天天用。awk提取第一列 IP →sort把所有 IP 排到一起 →uniq -c统计每项连续出现的次数 →sort -rn按次数从大到小排 →head -20只看前二十。很多人会忽略sort这一步,但uniq只能合并相邻重复行,不排序就会统计出多个相同 IP 的行。
第二个是找进程并终止:
ps -ef | grep nginx | grep -v grep | awk '{print $2}' | xargs kill这里有个反模式grep -v grep,目的是把grep nginx自身那条进程过滤掉,但写起来很啰嗦。我更推荐用正则技巧:ps -ef | grep "[n]ginx"。因为[n]ginx匹配的是nginx,而不会匹配到带有[n]ginx字样的 grep 进程本身。当然,现代系统里pgrep -f nginx更简洁,但有时候你还是需要在ps输出里保留其他信息,这个技巧依然有用。
第三个是批量删除文件。当文件数量特别多时,参数列表会过长,find直接接rm会报Argument list too long。此时xargs是很好的搭档:
find . -type f -name "*.bak" -print0 | xargs -0 rm -f-print0让文件名以 null 字符分隔,-0让xargs按 null 而不是空格或换行切分,这样文件名里再大的空格、引号也不会出错。这是处理批量文件时的必备组合。
3.3 管道与重定向混搭:一边过滤一边保存
管道和重定向不是互斥关系,把它们混在一行里,才能写出真正实用的命令。最常见的格式是“前面重定向,中间管道,最后重定向”:
find /var -name "*.log" 2>/dev/null | grep system > /tmp/system_logs.txt这条命令的意思是:忽略find遇到的无权限错误,把找到的日志文件名交给grep过滤,最后把包含system的行写入/tmp/system_logs.txt。注意2>/dev/null只影响find,不会影响管道后面的grep,因为重定向是针对当前命令本身设置的。
还有一种很常见的场景,是把程序的标准错误也并入管道,让管道后面的命令能统一处理:
java -jar app.jar 2>&1 | grep "Exception"如果没有2>&1,grep只能看到 java 的正常日志,错误栈全部漏掉。这里和前面讲的2>&1 > file顺序问题不一样,因为管道是在整个命令级别创建的。java -jar app.jar 2>&1 | grep的实际含义是:先把 stderr 重定向到 stdout,然后 stdout 进入管道,所以 stderr 也进管道。这行命令如果写成java -jar app.jar | grep也没有语法错,但拦不到错误信息。
如果你既想把输出保存到文件,又想实时在终端看到,那就轮到tee出场了。这也是管道和重定向混搭的一个经典延伸,后面单独讲。
4. 实战中的坑与排查技巧实录
4.1 一边保存一边看:tee 的正确打开方式
tee这个名字来源于 T 型三通,一个输入两个输出:一份到标准输出,一份到文件。我最早用它是因为要做部署日志,既想在终端实时看进度,又想把完整日志留到文件里方便事后排查:
./deploy.sh 2>&1 | tee deploy.log这行的效果是:脚本的输出会在终端滚动显示,同时完整写入deploy.log。如果没有tee,你只能二选一,要么让日志直接铺满屏幕,要么静默进文件。
tee默认覆盖文件,如果希望追加到历史日志后面,要加-a:
./test.sh 2>&1 | tee -a test.log更进阶的玩法是把tee放在管道中间,把日志存下来之后继续传给下一个命令:
./test.sh 2>&1 | tee test.log | grep -q "PASS" && echo "测试通过"这样一份日志,同时完成“展示、存档、条件判断”三件事。在 CI 脚本里,这种写法能省不少临时文件。
不过有一点要注意:tee会把输入原样送到 stdout 和文件,如果前面的命令输出量极大,终端刷屏会让你看不清重点。我一般会配合tail或grep只显示关键行:
./build.sh 2>&1 | tee build.log | tail -20这样终端只显示最后 20 行,完整日志依然保存在文件里。
4.2 管道退出码:脚本里最容易翻车的地方
管道虽然好用,但有一个非常隐蔽的坑:管道返回的退出码,默认是最后一条命令的退出码,而不是所有命令的。比如:
grep "something" /var/log/syslog | head -1 echo $?这条管道最后的head -1基本不会失败,所以$?大概率是 0。可如果前面的grep因为没匹配到任何内容返回了 1,这个失败信息就被后面的命令掩盖了。在自动化脚本里,这可能导致错误被忽略,整个任务继续往下跑,最后出来的结果千奇百怪。
要查看管道中每一段的退出码,bash 提供了一个数组变量PIPESTATUS:
false | true echo "${PIPESTATUS[0]} ${PIPESTATUS[1]}"输出是1 0,清楚地告诉你第一段失败、第二段成功。这个变量在排查问题时很实用。举个例子,我见过有人用curl ... | grep ...来判断接口是否正常,如果curl因为网络问题根本没连上,grep自然匹配不到,但$?可能返回 1,然后脚本就跟着报错。通过打印PIPESTATUS[0]才能定位到真正原因是网络而不是内容。
更稳妥的做法是在脚本开头设置set -o pipefail。这个选项让管道中任何一个命令失败,整个管道就返回失败,避免最后一条命令“掩盖罪证”。比如:
set -o pipefail curl -s https://example.com/api | grep "success"如果curl失败,整个管道的退出码就是非 0。这个选项只在 bash 里支持,写成sh脚本时可能不生效,所以我的习惯是在脚本第一行写#!/bin/bash。
4.3 变量丢失、缓冲延迟、文件乱名:常见问题速查
这些年我积累了不少和重定向、管道相关的翻车案例,整理成一张速查表,方便你对照排查。
| 症状 | 原因 | 解决方法 |
|---|---|---|
| 重定向后文件为空,屏幕却有错误 | 只重定向了 stdout,stderr 没处理 | cmd > file 2>&1 |
cmd 2>&1 > file后错误仍打在屏幕 | 重定向顺序错误 | 换成> file 2>&1 |
| 管道里 while 循环修改变量,结束后变量没变 | 管道会启动子 shell,变量只在子 shell 里有效 | 使用进程替换while ... done < <(cmd) |
tail -f管道到 grep 输出延迟 | grep 在管道里变全缓冲 | 用grep --line-buffered |
文件名带空格,xargs处理出错 | 默认按空格切分输入 | find ... -print0加xargs -0 |
后台任务nohup cmd 2>&1 &仍输出到终端 | 只重定向了 stdout,stderr 没有合并 | nohup cmd > /dev/null 2>&1 & |
脚本里管道grep没匹配到,导致整体失败 | 默认管道退出码只看最后一段 | 用PIPESTATUS或set -o pipefail |
第一个变量丢失问题,很多人会踩得莫名其妙。看这段代码:
count=0 printf "a\nb\n" | while read line; do count=$((count+1)) done echo $count你可能会以为输出2,但实际输出0。原因是管道右侧的while运行在子 shell 里,它修改的是子 shell 的count,父 shell 的count根本没动。解决方法是去掉管道,用进程替换:
count=0 while read line; do count=$((count+1)) done < <(printf "a\nb\n") echo $count这里的< <(printf ...)是先做进程替换,再通过输入重定向把结果喂给while,整个过程都发生在当前 shell,所以变量能保留下来。注意写法中间有空格,写成< <(...),不要和<<<搞混。
第二个缓冲问题出现在实时日志处理里。比如:
tail -f access.log | grep "error"在使用管道时,grep默认采用全缓冲,可能积攒一批数据才输出,看起来就像卡住了。加上--line-buffered就能让每读一行立刻输出:
tail -f access.log | grep --line-buffered "error"这也是我在实时监控场景里必用的参数。
这些坑没有一个是看文档能记得住的,全是真实环境里被坑出来的。尤其是2>&1的顺序、管道退出码和子 shell 变量问题,如果没有亲自动手调过,很可能会在某个凌晨被线上脚本折磨到怀疑人生。希望这篇偏实操的记录,能帮你少走点弯路。