Linux 文件操作深入解析
深入讲解 Unix 管道模型:stdin/stdout/stderr 与文件描述符、各种重定向写法、grep/awk/sed/cut/sort/uniq/xargs 工具链、命名管道与进程替换,配大量可直接套用的日志一行命令。
在命令行上拉开效率差距的,从来不是会多少命令,而是能不能把命令"拼起来"——把一堆小工具串成一条清晰的数据流。管道符 | 正是 Unix 哲学的体现:每个工具只做一件事并把它做好(grep 只过滤、awk 只提取字段、sort 只排序),然后通过管道把它们组合成一条可读、可调试、可维护的流水线。本文从数据流模型讲起——stdin、stdout、stderr 以及它们背后的文件描述符——再系统过一遍各种重定向写法(>、>>、<、2>、2>&1、&>),然后把文本处理工具链(grep、awk、sed、cut、tr、sort、uniq、xargs、tee)一次讲透,最后补上两个大多数入门教程跳过的话题:命名管道(FIFO)和进程替换。读完之后,你应该能把很多"得写个脚本才行"的小需求,用一两行可读的命令搞定,也能更轻松地看懂别人写的 one-liner。
数据流模型:stdin、stdout、stderr

每个 Linux 进程一启动就自带三个打开的文件描述符。理解了它们,本文后面的内容才会真正立得住。
| 流 | FD | 默认去向 | 典型用途 |
|---|---|---|---|
| stdin | 0 | 终端键盘 | 进程从这里读输入 |
| stdout | 1 | 终端屏幕 | 正常结果往这里写 |
| stderr | 2 | 终端屏幕 | 诊断信息、警告、错误往这里写 |
不那么显然的一点是 stdout 和 stderr 是分开的。它们默认都通向终端,所以交互式使用时看起来没区别——但内核把它们放在两个不同的文件描述符上。这种分离正是管道安全的根本原因:
- 管道符
|只传递 fd 1(stdout)。写到 fd 2 上的错误信息,不会污染流入下一阶段的数据。 - 你可以把正常输出存进文件,同时让错误信息留在终端实时刷新;反过来也可以。
- 脚本可以平时一句话不说,只有在出错时才通过 stderr 报警。
一个最小演示:
| |
只有 stdout 被存进了 out.txt,stderr 直接走到终端,因为我们没有重定向它。
文件描述符就是几个整数
文件描述符是内核给打开文件分配的句柄。在每个进程里,fd 0 / 1 / 2 留给三个标准流,之后每次 open() 拿到的就是 fd 3、4、5……。可以通过 /proc 看一个活进程的所有 fd:
| |
shell 里写的每一个重定向、每一个管道,归根结底都是 在程序启动前重新接线这几个小整数。> 的意思是"exec 之前把 fd 1 替换成这个文件";| 的意思是"exec 之前把生产者的 fd 1 和消费者的 fd 0 换成内核管道的两端"。
重定向:把文件描述符接到哪里去

上图把日常会用到的六种写法摆在一起,下面把全套写法过一遍。
stdout:> 和 >>
| |
> 是破坏性的——shell 会在命令真正运行之前就把目标文件清空。如果命令随后失败了,原内容已经丢了。拿不准的时候用 >> 然后再清理,或者先写到一个临时路径里。
stderr:2> 和 2>>
| |
一种常见组合是"丢掉无关输出,只留错误":
| |
同时处理两路
正确的写法有两种,还有一种被广为流传的错写法。
| |
传统写法有个顺序陷阱,每个人都会踩一次:
| |
从左往右读,记住 2>&1 的语义是"让 fd 2 指向 fd 1 此刻指向的地方"——而不是"把 fd 2 合并进 fd 1"。
丢弃输出:/dev/null
/dev/null 是内核里的"垃圾桶"。写进去的数据被全部丢弃,从它读则立刻得到 EOF。
| |
典型用途:cron 里的脚本(出错时才发邮件)、能力探测(command -v jq > /dev/null)、单纯关心"命令是不是 0 退出"的判断。
stdin:<、here-doc、here-string
| |
Here-doc 是把配置文件内嵌进脚本、把多行 SQL 喂给 psql、不开新文件就拼小模板的标准方式。默认会做变量展开;如果想要 body 里的内容原样保留,把分隔符引起来:<<'EOF'。
管道符:组合的精髓
管道是 Unix 里最简单的进程间通信原语,也是本文剩下内容能成立的前提。producer | consumer 同时做了三件事:
- 向内核申请一条匿名管道——一段内存里的小环形缓冲,有读端和写端两个。
- fork 出生产者,把它的 fd 1 通过 dup2 接到管道的写端。
- fork 出消费者,把它的 fd 0 通过 dup2 接到管道的读端。
两个进程随后 并发运行:生产者写、消费者读,缓冲满或空时由内核阻塞其中一方。整个过程不在硬盘上落任何临时文件。
一个标准例子:
| |
cat access.log——把日志内容流向自己的 stdoutgrep "404"——从 stdin 读,只保留匹配行wc -l——从 stdin 读,输出行数
这种风格之所以好,理由有三个:
- 没有中间文件,数据全在内存里流动,不需要清理。
- 流式,生产者刚写出第一行,消费者就开始处理,不必等整个文件读完。
- 可组合,每一步都能独立测试、独立替换。
“无意义地用 cat”(UUOC)
上面的例子可以更直接地写:
| |
绝大多数过滤工具都接受文件名参数,省掉那个 cat 既快一点也清楚一点。只有在你确实需要"流"——比如要拼多个文件、或为了让管道从上到下读起来更顺——才用 cat file | 开头。
用 tee 调试管道
tee 像水管的三通:把 stdin 写进若干文件 同时 也写到 stdout,下游阶段照样能拿到数据。
| |
现在你既得到了行数,又把所有匹配行存进了 404.log 方便事后查看。当一条管道行为不对时,往中间塞一个 tee /tmp/stage-N.txt,跑一次,再 head /tmp/stage-N.txt 看看那一段到底流的是什么。
tee -a 是追加而不是截断。tee 还有一个常见用法:在普通用户的 shell 里以 root 身份写文件——
| |
(直接写 sudo echo ... >> /etc/hosts 是不行的,因为重定向是由你当前的 shell 完成的,而那个 shell 不是 root。)
文本处理工具链
六个工具就能覆盖绝大多数日志和文本处理场景。把每个工具的 用途 想清楚,就不会动不动想去掏 Python 了。
grep:过滤行
grep 留下匹配模式的行,必须掌握的参数:
| 参数 | 含义 |
|---|---|
-i | 忽略大小写 |
-v | 反向匹配(保留 不 匹配的行) |
-n | 显示行号 |
-E | 扩展正则(` |
-F | 固定字符串(不当正则用,处理纯文本时更安全也更快) |
-r / -R | 递归子目录 |
-l | 只输出包含匹配的文件名 |
-c | 只输出匹配行数 |
-A N / -B N / -C N | 显示匹配后 / 前 / 前后各 N 行 |
-o | 只输出匹配的部分,每个匹配一行 |
实战组合:
| |
-F 值得单独提一下:当要搜的是一个含有正则特殊字符的字面字符串(路径、IP、堆栈片段),grep -F 比手动转义更安全也更快。
awk:列与聚合
awk 是一门小型编程语言,模型是"对每一行,按字段切开后执行一段动作"。只要你的数据有 列,要做投影、按列筛选、按列聚合,就该想到它。
心智模型:
- 默认分隔符是"任意空白",
-F可改。 $1、$2是字段引用,$0是整行;NF是字段数,NR是行号。- 程序由若干
pattern { action }构成,两部分都可省略。 BEGIN { ... }在读入前执行一次,END { ... }在读完后执行一次。
| |
count[$key]++ ... END { for (k in count) print count[k], k } 这个聚合套路是核心生产力:每次想写 Python 来"按某字段统计"之前,先看看一行 awk 是不是就够了。
sed:流编辑
sed 是非交互式编辑器。日常 95% 的场景是替换和删除。
| |
两个能省时间的小提醒:
- 替换的分隔符 不一定 是
/。当模式里本来就有斜杠时,sed 's|a/b|c/d|'比sed 's/a\/b/c\/d/'可读得多。 sed -i在 GNU sed 和 BSD sed(macOS)上行为不同。sed -i.bak '...'在两边都能用——总是写一个带后缀的备份,是值得养成的习惯。
cut、tr:用不上 awk/sed 时的轻量选手
| |
分隔符固定、只想取列时用 cut,比 awk 更省字。涉及字符级别的事(大小写、按字符切分、去掉某类字符)就用 tr。
sort、uniq:排序与分组
| |
关于 uniq 最重要的一句话:它只能处理相邻的重复。所以你几乎总要写成 sort | uniq -c | sort -nr 才能得到一份按频次排好的报表——第一个 sort 让重复挨在一起,uniq -c 计数,第二个 sort -nr 按数字倒序排。
实战:Nginx 日志分流
下面这几条命令值得每个值班的人背下来。假设 access.log 是标准的 combined 格式(第 1 列是客户端 IP,第 7 列是路径,第 9 列是状态码)。
| |

上图把第三条命令拆开了:每一阶段都在 收窄或聚合 数据,到右端就只剩屏幕能装下的一份排好序的统计了。任何中间位置都可以塞个 tee 进去看具体形态。
xargs:当下游需要的是参数而不是 stdin
管道传的是 stdin。但很多最想串进来的工具——rm、cp、mv、chmod、git checkout——接收的是 参数,不是 stdin。xargs 就是这两种接口之间的桥。
| |
没有 xargs 的话,find ... | rm 什么也不做:rm 根本不读 stdin。
空格与换行的坑
find | xargs 这种朴素写法,一旦文件名里有空格或换行(是的,文件名里出现换行是合法的)就会出错。xargs 默认按空白分词,于是 my file.txt 会被拆成两个参数。永远把 find -print0 和 xargs -0 配对使用:
| |
-print0 用 NUL 字节做分隔符;-0 让 xargs 也按 NUL 切。NUL 是唯一不可能出现在文件名里的字节,所以这种组合稳如磐石。
更简洁也常常更优雅的做法是干脆不用 xargs,让 find 自己调命令:
| |
-exec ... {} + 会把参数尽可能塞进一次调用里;-exec ... {} \; 是每个文件单独调一次(慢一些,但适用于"每次调用都要独立"的场景)。
常用参数
| |
-P 是把"扫一遍所有 JSON 文件做语法检查"从 30 秒变成 4 秒(8 核机器上)的关键。配合 -n 1 让每次调用只接一个文件,否则一个慢的会拖累整批。
命名管道(FIFO)

| 创建的是 匿名管道——存在于内核内存里,文件系统上没有名字,生产者和消费者退出后就消失。两端必须在同一条命令行上。
命名管道(FIFO)是文件系统上有名字的管道。用 mkfifo 创建之后,任意两个进程——可能在不同终端、不同时刻启动——都能挂上去通信。
| |
第一次用会被两条语义绊住:
- 对 FIFO 的
read会阻塞,直到有人写。同样,write也会阻塞,直到有人读(内核管道缓冲很小,一般 64 KiB)。 - 写还在但读全部退出时,写方会收到
SIGPIPE。读还在但写全部退出时,下一次 read 会返回 EOF。
这种阻塞行为正是它的 用处:FIFO 就是一个零配置的微型任务队列或信号通道。常见用法包括:脚本之间的简单信号、给一拨突发的生产者前面挡一个长时间运行的消费者、把多个短命进程的日志汇聚到一个轮转器。
用完 rm /tmp/jobs 清理掉就行——FIFO 就是个文件。
进程替换:<(cmd) 与 >(cmd)

进程替换回答了一个问题:“如果我想把命令的输出喂给一个只接受文件名参数的工具,怎么办?”
| |
bash 在背后做了这样一件事:<(sort file1) 展开成一个路径——通常是 /dev/fd/63——读这个路径就能得到内层命令的 stdout。diff 把它当作一个普通文件打开,完全意识不到对面是个进程。
写成等价的临时文件版本,对比一下就明白价值在哪:
| |
bash 替你做了所有 bookkeeping,而且内层命令是并发跑的,生产者和消费者在重叠执行而不是串行。
输出形式 >(cmd) 也存在——展开成的路径写入时会变成内层命令的 stdin。实战中少见,但能让人写出这样的模式:
| |
进程替换是 bash / zsh / ksh 的特性,纯 POSIX sh 没有。要写可移植脚本就退回到临时文件加 trap "rm -f $tmp" EXIT 的写法。
实战:批量文件操作
几个运维里反复出现的套路。
| |
mv 后面那个 -- 是个值得养成的小习惯:它告诉命令"参数列表结束了",这样以 - 开头的文件名就不会被当成选项。
安全与最佳实践
几条避免最常见管道事故的规则。
1. 永远不要解析 ls 的输出。 ls 是给人看的,输出格式不是稳定的交换格式。空格、换行、glob 字符、locale 相关的日期格式都会让朴素的解析挂掉。
| |
2. 破坏性命令先预览。 把 -delete 换成 -print(或把 rm 换成 echo rm),文件清单看着没问题再真删:
| |
3. 所有变量展开都加引号。 这是"在我机器上是好的"问题最大的源头:
| |
4. 脚本里加 set -euo pipefail。
| |
不加 pipefail 的话,管道的退出码是 最后一个 阶段的退出码。中间阶段悄悄失败(grep 没匹配到、sort 写不下盘)会被掩盖。加了 pipefail,第一个非零退出会传播到外面。
5. 不要 eval,更不要把外部输入直接塞进 shell 命令。 来自外部的值要么先做正则校验([[ "$x" =~ ^[A-Za-z0-9._-]+$ ]]),要么作为参数传给一个不调用 shell 的工具。
总结
到这里,你应该有了一张完整的图,而不是一堆零散的命令:
- 数据流模型:三个流(stdin/stdout/stderr)映射到三个文件描述符(0/1/2)。管道只传 stdout。
- 重定向:
>>><2>2>&1&><<<<<,六种写法覆盖所有场景,并且你理解 为什么2>&1 >file是错的。 - 工具链:
grep过滤、awk投影与聚合、sed编辑、cut/tr简单场景、sort | uniq -c | sort -nr出频次榜。 - 参数桥接:
xargs(以及find -exec ... {} +)打通 stdin 风格和 argv 风格的工具,并且记得用 NUL 分隔来兜住带空格的文件名。 - 两个少见但好用的模式:命名管道用于跨进程会合;进程替换用于"我需要文件名,但手头是命令"。
- 安全:变量加引号、删之前先预览、永不解析
ls,每个脚本顶上都写set -euo pipefail。
扩展阅读
- The Art of Command Line —— 一份精挑细选的命令行惯用法清单。
man bash,重点看 REDIRECTION、Pipelines、Process Substitution 三节。info coreutils——grep、sort、cut、tr、uniq等的权威参考。
这是 Linux 系列的最后一篇。前面七篇给了你模型——进程、文件、权限、磁盘、服务、软件包、用户——这一篇给的是 黏合剂。真实的运维工作大半就是 应用这层黏合剂:把"我得知道 X"翻译成一行命令,把"对这里所有文件做某操作"翻译成一句安全的 find ... -exec,把 shell 当成你手头最通用的可编程工具来用。