系列 · Linux · 第 8 篇

Linux(八):文件操作深入解析

深入讲解 Unix 管道模型:stdin/stdout/stderr 与文件描述符、各种重定向写法、grep/awk/sed/cut/sort/uniq/xargs 工具链、命名管道与进程替换,配大量可直接套用的日志一行命令。

在 Linux 系统中,真正能大幅提升效率的不是死记硬背更多命令,而是学会将这些小工具巧妙组合,构建清晰、流畅的数据处理管道。管道符 | 是 Unix 哲学的核心体现:每个工具专注于完成一项任务,并且做到极致(比如 grep 专门用来过滤,awk 专注于字段提取,sort 只负责排序),然后通过管道将它们串联成一个逻辑清晰、易于调试和维护的工作流。本文从数据流的基本模型入手——包括 stdinstdoutstderr 以及它们背后的文件描述符——接着系统性地讲解常见的重定向用法(如 >>><2>2>&1&>),然后深入剖析文本处理工具链中的核心成员(如 grepawksedcuttrsortuniqxargstee),最后补充两个大多数入门教程忽略的高级主题:命名管道(FIFO)和进程替换。读完本文,你就能用一两行简洁明了的命令解决许多原本需要编写脚本的小任务,同时也能更轻松地读懂别人写的复杂单行命令。

Linux(八):文件操作深入解析 — 章节概览图


数据流模型: stdin、 stdout、 stderr#

管道数据流:stdin / stdout / stderr

每个 Linux 进程在启动时都会自动打开三个文件描述符,理解它们的作用是掌握本文后续内容的基础。

文件描述符 (FD)默认目标典型用途
stdin0终端键盘输入进程从这里读取输入数据
stdout1终端屏幕输出正常的输出结果会发送到这里
stderr2终端屏幕输出错误信息、警告和诊断信息会发送到这里

初看之下,stdout 和 stderr 都默认输出到终端,交互运行时似乎并无区别;但实际上,内核将它们分配到了两个独立的文件描述符上,这一设计正是管道机制安全性的关键。

  • 管道符号 | 只处理 fd 1 (即 stdout)。写入 fd 2 的错误信息不会干扰传递到下一阶段的数据流。
  • 你可以将正常输出保存到文件,同时让错误信息实时显示在终端上;或者反过来操作。
  • 脚本可以保持静默运行,只有在出现问题时才通过 stderr 输出提示信息。

一个简单示例即可说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ ls /etc /nonexistent
ls: cannot access '/nonexistent': No such file or directory   # stderr
/etc:                                                          # stdout
adduser.conf
...

$ ls /etc /nonexistent > out.txt
ls: cannot access '/nonexistent': No such file or directory   # stderr 仍然显示在终端
$ cat out.txt
/etc:
adduser.conf
...

在这个例子中,只有 stdout 被重定向到了 out.txt 文件中,而 stderr 则直接输出到终端,因为我们没有对它进行重定向。

文件描述符的本质:几个简单的整数#

文件描述符是内核为每个打开的文件分配的一个标识符。在进程内部, fd 0、 1 和 2 分别预留给标准输入、标准输出和标准错误输出。之后每次调用 open() 打开新文件时,系统会依次分配 fd 3、 4、 5 等等。你可以通过 /proc 查看某个运行中进程的所有文件描述符:

1
2
3
4
5
$ ls -l /proc/$$/fd          # $$ 表示当前 shell 的进程 ID
lrwx------ 1 kchen kchen 64 ... 0 -> /dev/pts/0
lrwx------ 1 kchen kchen 64 ... 1 -> /dev/pts/0
lrwx------ 1 kchen kchen 64 ... 2 -> /dev/pts/0
lr-x------ 1 kchen kchen 64 ... 3 -> /var/log/syslog

shell 中的每条重定向或管道命令,本质上都是在程序启动前对这些整数文件描述符进行“重定向绑定”。例如:

  • > 的作用是“在执行 exec 前,将 fd 1 替换为指定的文件”;
  • | 的作用是“在执行 exec 前,将生产者的 fd 1 和消费者的 fd 0 替换为内核管道的两端”。

重定向:把文件描述符接到哪里去#

重定向:每个文件描述符的去向

上图总结了日常工作中最常用的六种重定向形式,完整的内容会在下面展开。

标准输出(stdout):>>>#

1
2
3
4
echo "hello" > out.txt        # 清空文件后写入
echo "world" >> out.txt       # 追加内容,保留原有数据
ls -l > filelist.txt          # 将目录列表保存到文件
date >> deploy.log            # 在日志中追加时间戳标记

> 是一种破坏性操作——它会在命令执行前清空目标文件。如果命令随后失败,原文件内容就永久丢失了。不确定时可以先用 >>,之后再清理文件,或者先把输出写到临时路径中。

标准错误(stderr):2>2>>#

1
2
make 2> build-errors.log              # 只将错误信息写入文件,正常输出留在终端
./long-job 2>> errors.log             # 多次运行的错误信息追加到同一文件

一种常见的模式是丢弃正常输出,只保留错误信息:

1
./scan-disks > /dev/null 2>> scan-errors.log

同时处理标准输出和标准错误#

有两种正确的写法,还有一种常见但错误的写法需要注意。

1
2
3
4
5
6
# 现代推荐写法(适用于 bash / zsh):
cmd &> out.log
cmd &>> out.log         # 追加模式

# 传统写法,兼容老式 shell:
cmd > out.log 2>&1

传统写法存在一个易被忽视的顺序陷阱,初学者几乎都会遇到:

1
2
3
4
5
6
7
# 错误写法:先将 fd 2 指向当前 fd 1 的位置(此时 fd 1 还指向终端),
# 然后再将 fd 1 重定向到文件。结果是 stderr 输出到终端,
# 而 stdout 写入文件——与预期完全相反。
cmd 2>&1 > out.log

# 正确写法:先将 fd 1 重定向到文件,再将 fd 2 指向 fd 1 当前的位置。
cmd > out.log 2>&1

从左到右阅读时,记住 2>&1 的含义是“让 fd 2 指向 fd 1 当前指向的位置”,而不是“将 fd 2 合并到 fd 1”。

丢弃输出:/dev/null#

/dev/null 是内核提供的“黑洞”。任何写入它的数据都会被丢弃,而从它读取则会立即返回 EOF。

1
2
3
4
cmd > /dev/null              # 静默正常输出
cmd 2> /dev/null             # 静默错误输出
cmd &> /dev/null             # 静默所有输出
cmd > /dev/null 2>&1 &       # 完全静默地在后台运行

典型使用场景包括: cron 任务(仅在出错时发送邮件)、功能探测(如 command -v jq > /dev/null),以及仅关心“命令是否成功退出”的检查(完全忽略输出内容)。

标准输入(stdin):<、 here-doc、 here-string#

1
sort < names.txt              # 使用文件作为标准输入

Here-document:多行文本作为标准输入

1
2
3
4
cat <<EOF > /etc/motd
Welcome to $(hostname)
Today is $(date '+%Y-%m-%d')
EOF

Here-string:单行文本作为标准输入

1
grep -E "ERROR|WARN" <<< "$line"

Here-document 是嵌入配置文件、向 psql 提供多行 SQL 或构建小型模板而不创建额外文件的标准方法。默认情况下会进行变量替换;如果需要保留文本内容不变,可以用引号包裹分隔符,例如 <<'EOF'

管道符:组合的精髓#

管道是 Unix 中最基础的进程间通信机制,也是本文后续内容得以实现的关键。producer | consumer 这一简单语法背后其实完成了三件重要的事情:

  1. 向操作系统内核申请一个匿名管道——这是一个位于内存中的小型环形缓冲区,分为读端和写端。
  2. 创建生产者进程,并将其标准输出(fd 1)通过 dup2 绑定到管道的写端。
  3. 创建消费者进程,并将其标准输入(fd 0)通过 dup2 绑定到管道的读端。

随后,两个进程会 并发运行:生产者负责写入数据,消费者负责读取数据。当缓冲区满或空时,内核会自动阻塞其中一方。整个过程完全在内存中完成,不会在磁盘上生成任何临时文件。

来看一个经典例子:

1
2
$ cat access.log | grep "404" | wc -l
137
  • cat access.log——将日志文件内容输出到标准输出。
  • grep "404"——从标准输入读取数据,筛选出包含“404”的行。
  • wc -l——统计从标准输入接收到的行数。

这种写法之所以强大,主要有三个原因:

  • 无需中间文件:数据直接在内存中流动,省去了清理临时文件的麻烦。
  • 流式处理:生产者刚输出第一行,消费者就可以开始处理,无需等待整个文件加载完毕。
  • 高度可组合:每个步骤都可以独立测试和替换,灵活性极强。

“无意义地使用 cat”(UUOC)#

上面的例子其实可以更简洁地写成:

1
grep "404" access.log | wc -l

大多数过滤工具都支持直接指定文件名作为输入,因此省略 cat 不仅能稍微提升性能,还能让命令更加清晰易懂。只有在确实需要流式处理时(例如合并多个文件,或者为了让管道逻辑从上到下更直观),才需要用 cat file | 的形式。

使用 tee 调试管道#

tee 命令就像水管中的三通接头:它会将输入数据同时写入一个或多个文件,并继续向下游传递。这使得我们可以在不影响管道流程的情况下保存中间结果。

1
grep "404" access.log | tee 404.log | wc -l

这条命令不仅统计了匹配行的数量,还将所有匹配行保存到了 404.log 文件中,方便后续检查。如果某个管道的行为不符合预期,可以在中间插入 tee /tmp/stage-N.txt,运行后用 head /tmp/stage-N.txt 查看具体的数据流向。

tee -a 参数表示追加写入,而不是覆盖文件。此外,tee 还有一个巧妙的用途:在普通用户 shell 中以 root 权限写入文件。例如:

1
echo "127.0.0.1 example.local" | sudo tee -a /etc/hosts

(直接写 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仅输出匹配的部分,每段匹配单独一行

实际场景中的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 多关键词搜索,忽略大小写,带 3 行上下文
grep -iEC 3 "error|fail|timeout" /var/log/syslog

# 全局查找所有 import requests 的 Python 文件
grep -rln --include='*.py' "^import requests" .

# 查找包含 ERROR 但排除 ERROR_HANDLED 的行
grep "ERROR" app.log | grep -v "ERROR_HANDLED"

# 提取日志中的所有 URL 并去重
grep -oE 'https?://[^ "]+' access.log | sort -u

特别提一下 -F:当你需要搜索的是一个包含正则特殊字符的字面字符串(比如路径、 IP 地址或堆栈片段),grep -F 比手动转义更高效也更可靠。

awk:列操作与聚合#

awk 是一门小巧但强大的编程语言,核心思想是对每一行按字段分割后执行指定操作。只要你的数据有 ,并且需要做投影、筛选或聚合,awk 就是最佳选择。

基本概念:

  • 默认分隔符是任意空白字符,可以通过 -F 自定义。
  • $1$2 分别表示第 1 列、第 2 列,$0 表示整行;NF 是字段总数,NR 是当前行号。
  • 程序由若干 pattern { action } 块组成,两部分都可以省略。
  • BEGIN { ... } 在处理输入前执行一次,END { ... } 在处理完所有输入后执行一次。

常见用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 投影:从 Nginx 日志中提取 IP 和状态码
awk '{ print $1, $9 }' access.log

# 条件过滤:只显示 5xx 错误
awk '$9 >= 500 { print }' access.log

# 聚合:统计每个 IP 的请求数
awk '{ count[$1]++ } END { for (ip in count) print count[ip], ip }' access.log \
  | sort -nr | head

# 自定义分隔符:解析 /etc/passwd 文件
awk -F: '{ print $1, $7 }' /etc/passwd

# 计算平均值(假设第 10 列是响应时间,单位毫秒)
awk '{ sum += $10; n++ } END { if (n) print sum / n }' access.log

# 多条件过滤:5xx 错误且响应时间超过 1 秒
awk '$9 >= 500 && $10 > 1000 { print $1, $7, $9, $10 }' access.log

count[$key]++ ... END { for (k in count) print count[k], k } 是一个非常常用的聚合模式。每次想写 Python 脚本来“按某个字段统计”之前,先试试一行 awk 能不能搞定。

sed:流式编辑#

sed 是一个非交互式的文本编辑器,日常使用中 95% 的场景都是替换和删除。

常用操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 替换(默认只替换每行的第一个匹配)
sed 's/foo/bar/'      file.txt
sed 's/foo/bar/g'     file.txt    # 替换每行的所有匹配
sed -E 's|/old/path|/new/path|g'  file.txt   # 使用 | 作为分隔符,避免转义斜杠

# 删除行
sed '/^$/d'           file.txt    # 删除空行
sed '/^#/d'           file.txt    # 删除注释行
sed '1,10d'           file.txt    # 删除前 10 行
sed '$d'              file.txt    # 删除最后一行

# 只打印某范围内的行(-n 抑制默认输出)
sed -n '10,20p' file.txt

# 插入或追加内容
sed '1i\#!/usr/bin/env bash'  script.sh
sed '$a\# end of file'        script.sh

# 原地修改并保留备份
sed -i.bak 's/listen 80;/listen 8080;/' /etc/nginx/nginx.conf

两个实用小技巧:

  • 替换分隔符不一定非得是 /。如果模式中包含斜杠,sed 's|a/b|c/d|'sed 's/a\/b/c\/d/' 更易读。
  • sed -i 在 GNU 和 BSD (macOS)上的行为不同。使用 sed -i.bak '...' 可以兼容两者,并且会生成一个带 .bak 后缀的备份文件,建议养成习惯。

cut、 tr:轻量级工具#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# cut:按分隔符提取列
cut -d',' -f1,3        data.csv         # 提取 CSV 第 1 和第 3 列
cut -d':' -f1,7        /etc/passwd      # 提取用户名和 shell

# cut:按字符位置提取(适用于定宽数据)
cut -c1-8              file.txt         # 提取每行前 8 个字符

# tr:字符级转换或删除
echo "HELLO" | tr 'A-Z' 'a-z'           # 转为小写
echo "a b c" | tr ' ' '\n'              # 空格转为换行
echo "abc123" | tr -d '0-9'             # 删除数字
tr -s ' ' < file                        # 压缩连续空格为一个

当分隔符固定且只需要提取某些列时,cutawk 更简洁。涉及字符级别的操作(如大小写转换、字符替换、删除特定字符)时,tr 是更好的选择。

sort、 uniq:排序与去重#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
sort file.txt              # 字典序排序
sort -n file.txt           # 数字排序(确保 "10" 排在 "9" 后面)
sort -h file.txt           # 人类可读的单位排序(如 1K、2M、3G)
sort -r file.txt           # 逆序排序
sort -k2,2 file.txt        # 按第 2 列排序(默认以空白分隔)
sort -t',' -k3,3n data.csv # 按 CSV 第 3 列数字排序
sort -u file.txt           # 排序并去重

uniq           file        # 合并相邻重复行
uniq -c        file        # 统计相邻重复行的出现次数
uniq -d        file        # 只显示有重复的行
uniq -u        file        # 只显示没有重复的行

关于 uniq 最重要的一点:它只能合并相邻的重复行。因此,你几乎总是需要用 sort | uniq -c | sort -nr 来生成一份按频次排序的统计报告——第一个 sort 让重复行挨在一起,uniq -c 统计频次,第二个 sort -nr 按数字倒序排列。

实战: Nginx 日志分流#

作为一名工程师,以下这些命令是值班时必备的技能,建议熟记于心。假设 access.log 使用的是标准的 combined 格式(第 1 列是客户端 IP,第 7 列是请求路径,第 9 列是 HTTP 状态码)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 统计访问量最高的 10 个客户端 IP
awk '{ print $1 }' access.log | sort | uniq -c | sort -nr | head

# 统计请求次数最多的 10 个 URL
awk '{ print $7 }' access.log | sort | uniq -c | sort -nr | head

# 查看状态码的分布情况
awk '{ print $9 }' access.log | sort | uniq -c | sort -nr

# 最近 1 小时内的所有 5xx 错误(假设日志时间戳是按顺序排列的)
grep "$(date -d '1 hour ago' '+%d/%b/%Y:%H')" access.log \
  | awk '$9 >= 500'

# 哪些 IP 在访问哪些 4xx 的路径?
awk '$9 >= 400 && $9 < 500 { print $1, $9, $7 }' access.log \
  | sort | uniq -c | sort -nr | head -20

grep | awk | sort | uniq 流水线

上图展示了第三条命令的执行过程:每个步骤都在 筛选或汇总 数据,最终在右侧生成一份简洁的统计结果,方便直接查看。如果需要,可以在任意中间环节插入 tee 命令,检查数据的中间形态。

xargs:当下游需要的是参数而不是 stdin#

管道传递的是标准输入(stdin),但许多我们常用的命令——比如 rmcpmvchmodgit checkout——并不读取标准输入,而是依赖参数。这时,xargs 就成了连接两者的桥梁。

1
find . -name "*.tmp" | xargs rm        # 删除当前目录下所有 *.tmp 文件

如果没有 xargs,直接用 find ... | rm 是毫无意义的,因为 rm 根本不会从标准输入中读取数据。

文件名中的空格和换行问题#

简单地使用 find | xargs 会遇到一个常见问题:如果文件名中包含空格或换行符(是的,文件名中允许换行符),命令就会出错。这是因为 xargs 默认以空白字符(包括空格、制表符和换行符)作为分隔符,导致像 my file.txt 这样的文件名会被错误地拆分成两个部分。为了避免这个问题,务必搭配 find -print0xargs -0 使用:

1
find . -name "*.tmp" -print0 | xargs -0 rm

-print0 使用 NUL 字节(\0)作为分隔符,而 -0 则告诉 xargs 按照 NUL 字节来分割输入。由于 NUL 是唯一不可能出现在文件名中的字节,这种组合非常可靠。

当然,还有一种更简洁的做法,就是完全绕过 xargs,让 find 自己调用命令:

1
2
find . -name "*.tmp" -exec rm {} +     # 批量调用,效果等同于 xargs -0
find . -name "*.tmp" -delete           # 内置删除功能,无需额外命令

-exec ... {} + 会将多个文件名打包成尽可能少的命令调用;而 -exec ... {} \; 则会对每个文件单独调用一次命令(虽然速度较慢,但在某些场景下更适合,比如每次调用需要独立处理时)。

常用选项#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 使用占位符
find . -name "*.log" | xargs -I{} cp {} {}.bak

# 限制每次调用的参数数量(避免参数列表过长)
find . -name "*.json" -print0 | xargs -0 -n 100 jq -c .

# 并行处理:利用多核加速
find . -name "*.json" -print0 | xargs -0 -P 8 -n 1 jq -c . > /dev/null

# 每次执行前交互确认(谨慎模式)
find . -name "*.tmp" -print0 | xargs -0 -p rm

-P 是提升效率的关键,它可以让任务并行运行在多个 CPU 核心上。例如,在一台 8 核机器上,原本需要 30 秒的任务可能只需 4 秒即可完成。为了确保每个任务独立运行,可以结合 -n 1 使用,这样每次调用只处理一个文件,避免因某个文件处理缓慢而拖累整个批次。

命名管道(FIFO)#

匿名管道 vs 命名管道(FIFO)

| 创建的管道是 匿名管道,它存在于内核内存中,在文件系统上没有名字,生产者和消费者退出后管道也就消失了。而且,管道的两端必须在同一条命令行上。

命名管道(FIFO)则不同,它是文件系统上的一个实体,有名字可以被引用。你可以通过 mkfifo 创建一个命名管道,之后任意两个进程——即使运行在不同的终端、启动时间不同——都可以通过这个管道进行通信。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ mkfifo /tmp/jobs
$ ls -l /tmp/jobs
prw-r--r-- 1 kchen kchen 0 ... /tmp/jobs    # 开头的 'p' 表示这是一个 FIFO

# 消费者(终端 A):阻塞等待 FIFO 中的数据,逐行处理
while read job; do
    echo "[worker] 正在处理 $job"
    sleep 1
done < /tmp/jobs

# 生产者(终端 B):向 FIFO 写入任务
echo "task-1" > /tmp/jobs
echo "task-2" > /tmp/jobs

初次使用命名管道时,容易被以下两点行为特性搞懵:

  • 对 FIFO 的 read 操作会 阻塞,直到有人写入数据;同样,write 操作也会阻塞,直到有人读取数据(内核管道缓冲区很小,通常只有 64 KiB)。
  • 如果所有读端都关闭了,而写端还在尝试写入,那么写端会收到 SIGPIPE 信号;如果所有写端都关闭了,那么下一次 read 操作会返回 EOF。

这种阻塞机制正是命名管道的 核心功能:它本质上是一个无需配置的轻量级任务队列或信号通道。实际应用场景包括:脚本间的简单信号传递、让一个长时间运行的消费者处理突发的任务流、将多个短生命周期进程的日志汇总到一个日志轮转器中。

用完之后,记得用 rm /tmp/jobs 清理掉——毕竟, FIFO 在文件系统上就是一个普通文件而已。

进程替换:<(cmd)>(cmd)#

进程替换:0 将命令的标准输出伪装成一个文件名

如果你曾经思考过这样一个问题:“如何把一个命令的输出传递给只接受文件名作为参数的工具?”那么进程替换就是答案。

1
diff <(sort file1) <(sort file2)

在上面的例子中,<(sort file1) 会被 Bash 替换为一个路径(通常是类似 /dev/fd/63 的形式)。当你读取这个路径时,实际上读取的是 sort file1 的标准输出。而 diff 命令则像打开普通文件一样处理它,完全不知道背后其实是一个正在运行的进程。

为了更清楚地理解它的价值,我们来看一个等价的实现方式,使用临时文件:

1
2
3
4
sort file1 > /tmp/a
sort file2 > /tmp/b
diff /tmp/a /tmp/b
rm /tmp/a /tmp/b

对比之下, Bash 替你完成了所有繁琐的管理工作,并且内层命令是并发执行的,而不是串行。这意味着生产者和消费者可以同时工作,效率更高。

除了输入形式 <(cmd),还有输出形式 >(cmd)。它会生成一个路径,写入该路径的内容会被当作内层命令的标准输入。虽然这种用法在实际场景中较少见,但它确实能实现一些有趣的模式,比如:

1
2
# 使用 tee 将数据流分发给两个并行的消费者
some_command | tee >(gzip > out.gz) >(sha256sum > out.sha256) > /dev/null

需要注意的是,进程替换是 Bash、 Zsh 和 Ksh 的特性,在纯 POSIX sh 中并不支持。如果你需要编写可移植的脚本,可以退回到使用临时文件的方式,并结合 trap "rm -f $tmp" EXIT 来清理临时文件。

实战:批量文件操作#

在日常运维工作中,经常会遇到一些重复性任务。以下是几个常见场景及其解决方案。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 递归设置网站目录的权限:文件设为 644,目录设为 755
find /var/www/html -type f -exec chmod 644 {} +
find /var/www/html -type d -exec chmod 755 {} +

# 压缩超过 7 天的日志文件
find /var/log -name "*.log" -mtime +7 -exec gzip {} +

# 删除临时目录中的空文件(通常是写入失败后遗留的)
find /tmp -type f -empty -delete

# 使用 Shell 参数替换将 img_*.jpg 批量重命名为 photo_*.jpg
for f in img_*.jpg; do
    mv -- "$f" "${f/img_/photo_}"
done

# 使用 rename 工具实现同样的功能(Debian/Ubuntu 上叫 perl-rename)
rename 's/^img_/photo_/' img_*.jpg

# 查找大于 100 MB 的文件,并按大小倒序列出前几项
find / -xdev -type f -size +100M -exec du -h {} + 2>/dev/null | sort -hr | head

mv 命令中使用 -- 是一个值得养成的好习惯。它的作用是告诉命令“后面的内容都不是选项”,从而避免以 - 开头的文件名被误认为是参数。

安全与最佳实践#

以下是一些能够有效避免管道操作中常见问题的规则,帮助你写出更可靠、更安全的脚本。

1. 别尝试解析 ls 的输出。
ls 是为人类设计的工具,它的输出格式并不稳定,不适合作为程序解析的数据源。空格、换行符、通配符(glob)以及依赖于区域设置的日期格式都会让简单的解析逻辑出问题。

1
2
3
4
5
6
7
# 错误示范
ls *.log | xargs rm

# 正确做法
find . -maxdepth 1 -name "*.log" -print0 | xargs -0 rm
# 或者直接用:
rm -- *.log

2. 在执行破坏性操作前先预览结果。
在确认文件列表无误之前,可以用 -print 替代 -delete,或者用 echo rm 替代 rm 来模拟操作,确保不会误删文件。

1
2
find . -name "*.tmp" -print     # 预览将要删除的文件
find . -name "*.tmp" -delete    # 确认无误后执行删除

3. 变量展开时一定要加引号。
这是导致“在我机器上能跑,到别人那儿就崩”的最常见的原因。变量未加引号可能会被错误解析,尤其是当值中包含空格或特殊字符时。

1
2
3
dir="my reports"
rm -rf $dir       # 错误:会被解析为 "rm -rf my reports",结果会误删 'my' 和 'reports' 两个目录
rm -rf -- "$dir"  # 正确:加引号确保变量内容被正确处理

4. 在脚本中启用 set -euo pipefail

1
2
#!/usr/bin/env bash
set -euo pipefail   # -e: 出错立即退出;-u: 使用未定义变量时退出;-o 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 用于编辑文本,cuttr 处理简单任务,而 sort | uniq -c | sort -nr 则是统计频率的标准套路。
  • 参数衔接xargs(以及 find -exec ... {} +)可以在 stdin 风格和 argv 风格工具之间架起桥梁,处理带空格的文件名时记得用 NUL 分隔。
  • 两个冷门但实用的技巧:命名管道适合跨进程通信;进程替换则解决了“我需要一个文件名,但手头只有命令”的问题。
  • 安全习惯:变量一定要加引号,删除操作前先预览,永远不要解析 ls 的输出,每个脚本开头都加上 set -euo pipefail

扩展阅读

  • The Art of Command Line —— 一份精心整理的命令行技巧速查表。
  • man bash,重点阅读 REDIRECTIONPipelinesProcess Substitution 这几节。
  • info coreutils —— 这是 grepsortcuttruniq 等工具的权威参考文档。

这是 Linux 系列的最后一篇文章。前面七篇介绍了核心模型——进程、文件、权限、磁盘、服务、软件包、用户——而这篇文章讲的是如何将这些模块串联起来的“胶水”。实际的运维工作,很大程度上就是 灵活运用这些胶水:把“我需要知道 X”转化为一行命令,把“对这里的每个文件执行某个操作”变成一句安全的 find ... -exec,并把 shell 当作你最通用、最强大的可编程工具来使用。

本系列

Linux 9 篇

  1. 01 Linux(一):使用基础
  2. 02 Linux(二):文件权限 —— rwx、chmod、chown 与超越它们的机制
  3. 03 Linux(三):磁盘管理
  4. 04 Linux(四):软件包管理
  5. 05 Linux(五):用户管理
  6. 06 Linux(六):系统服务管理
  7. 07 Linux(七):进程与资源管理 —— 从 top 到 cgroups
  8. 08 Linux(八):文件操作深入解析 当前
  9. 09 Linux(九):Vim 编辑器精要

读有所得?

GitHub 关注我 → 新文周更

GitHub