WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
匹配行尾;\n- `[0-9]` 匹配一个数字;\n- `+` 表示前一个原子出现一次或多次。\n\n可以将这个条件形式化为:\n\n\\[\n\\text{valid}(s) = s \\in [0-9]^+\n\\]\n\n`^` 和 ` Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则 - WR Blog
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
将“字符串中存在匹配”收紧为“整个字符串属于该语言”。\n\n### 2. 常见正则运算符\n\n| 写法 | 含义 |\n|---|---|\n| `.` | 任意单个字符,通常不匹配换行 |\n| `[abc]` | `a`、`b`、`c` 中任意一个 |\n| `[^abc]` | 不属于 `a`、`b`、`c` 的一个字符 |\n| `[[:digit:]]` | 当前 locale 定义的数字字符类 |\n| `*` | 前一个原子出现零次或多次 |\n| `+` | 前一个原子出现一次或多次 |\n| `?` | 前一个原子出现零次或一次 |\n| `{m,n}` | 出现至少 `m` 次、至多 `n` 次 |\n| `(...)` | 分组 |\n| `|` | 或 |\n| `^`、` Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则 - WR Blog
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
| 行首、行尾 |\n\n`+`、`?`、`|`、括号和 `{m,n}` 在扩展正则表达式中更自然,因此通常使用 `grep -E`、`sed -E` 或 `awk` 的正则语法。\n\n```bash\ngrep -E '^(ERROR|WARN):' app.log\n```\n\n这表示整行开头必须是 `ERROR:` 或 `WARN:`。\n\n### 3. BRE、ERE 和 PCRE 的区别\n\n`grep` 默认使用基本正则表达式(BRE):\n\n```bash\ngrep 'ab\\+' file\n```\n\n在 GNU grep 中,`\\+` 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。\n\n扩展正则表达式(ERE)使用:\n\n```bash\ngrep -E 'ab+' file\n```\n\n`sed` 默认也使用 BRE,使用 `-E` 可启用 ERE:\n\n```bash\nsed -E 's/[0-9]+/\u003cnumber>/g' file\n```\n\n`grep -P` 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 `grep -E`。\n\n### 4. Shell 引号和正则不是一回事\n\nShell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:\n\n```bash\ngrep -E '^[0-9]+ file\n```\n\n单引号可以防止 Shell 展开 ` Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则 - WR Blog
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 ` Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则 - WR Blog
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
可能被 Shell 当作变量展开:\n\n```bash\ngrep -E \"^[0-9]+$\" file\n```\n\n更稳妥的是:\n\n```bash\ngrep -E '^[0-9]+ file\n```\n\n正则中的 `*` 和 Shell 文件名通配符中的 `*` 也不是同一个机制:\n\n```bash\ngrep '*.log' file\n```\n\n这里的 `*` 是正则量词,表示前一个字符 `'` 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:\n\n```bash\ngrep -E '\\.log file\n```\n\n---\n\n## 三、grep:筛选包含匹配内容的行\n\n`grep` 的核心语义是:\n\n> 对输入中的每一行进行匹配,输出满足条件的行。\n\n### 1. 基本用法\n\n先准备示例文件:\n\n```bash\ncat >app.log \u003c\u003c'EOF'\n2025-03-01 INFO user=alice action=login\n2025-03-01 ERROR user=bob action=delete\n2025-03-01 WARN user=alice action=retry\n2025-03-02 ERROR user=carol action=login\nEOF\n```\n\n筛选包含 `ERROR` 的行:\n\n```bash\ngrep 'ERROR' app.log\n```\n\n输出:\n\n```text\n2025-03-01 ERROR user=bob action=delete\n2025-03-02 ERROR user=carol action=login\n```\n\n常用选项:\n\n```bash\ngrep -i 'error' app.log # 忽略大小写\ngrep -v 'ERROR' app.log # 输出不匹配的行\ngrep -n 'ERROR' app.log # 输出行号\ngrep -c 'ERROR' app.log # 统计匹配行数\ngrep -l 'ERROR' *.log # 只输出包含匹配的文件名\ngrep -h 'ERROR' *.log # 多文件输出时隐藏文件名\ngrep -F 'a.b' file # 固定字符串匹配,不解释正则\ngrep -E 'ERROR|WARN' app.log # 扩展正则\n```\n\n`grep -F` 对搜索用户输入的字面量尤其重要。如果用户输入:\n\n```text\na.b\n```\n\n普通正则会将 `.` 解释为任意字符,可能额外匹配 `aXb`。固定字符串模式则只匹配字面量 `a.b`。\n\n### 2. 单词边界和文件名边界\n\n```bash\ngrep -w 'run' file\n```\n\n`-w` 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。\n\n处理可能以 `-` 开头的文件名时,应使用 `--`:\n\n```bash\ngrep -- '-n' -- weird-file\n```\n\n这里第一个 `--` 结束选项,后面的 `-n` 被当作模式参数。更常见的写法是明确使用 `-e`:\n\n```bash\ngrep -e '-n' -- weird-file\n```\n\n### 3. grep 的边界\n\n`grep` 输出的是原始行。它不会:\n\n- 解析 CSV 的引号和转义;\n- 理解 JSON 对象层次;\n- 将日期自动转换为日期值;\n- 根据数字大小进行排序;\n- 将重复的非相邻行自动合并。\n\n例如:\n\n```bash\nprintf '%s\\n' 'name=\"a,b\",age=20' | cut -d, -f2\n```\n\n结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 `cut`。\n\n---\n\n## 四、sed:逐行执行编辑脚本\n\n`sed` 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:\n\n1. 读取一行到 pattern space;\n2. 执行编辑命令;\n3. 默认输出 pattern space;\n4. 读取下一行。\n\n可以抽象为:\n\n```text\n输入行 → pattern space → sed 脚本 → 输出或丢弃\n```\n\n### 1. 替换命令 `s`\n\n最常见的形式:\n\n```text\ns/正则/替换文本/标志\n```\n\n例如:\n\n```bash\nprintf '%s\\n' 'user=alice' 'user=bob' |\n sed -E 's/user=([a-z]+)/account=\\1/'\n```\n\n输出:\n\n```text\naccount=alice\naccount=bob\n```\n\n`([a-z]+)` 是捕获组,`\\1` 引用第一个捕获组。\n\n默认情况下,`s` 只替换每行第一个匹配:\n\n```bash\nprintf '%s\\n' 'a a a' | sed 's/a/x/'\n```\n\n输出:\n\n```text\nx a a\n```\n\n加上 `g` 才替换整行所有匹配:\n\n```bash\nprintf '%s\\n' 'a a a' | sed 's/a/x/g'\n```\n\n输出:\n\n```text\nx x x\n```\n\n其他常见标志:\n\n```bash\nsed -n 's/ERROR/FAIL/p' app.log\n```\n\n`-n` 关闭默认输出,`p` 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 `ERROR` 的行。\n\n### 2. 地址:只对部分行执行命令\n\n地址决定命令作用在哪些行:\n\n```bash\nsed -n '2p' app.log\nsed -n '/ERROR/p' app.log\nsed -n '2,4p' app.log\nsed -n '1,/WARN/p' app.log\n```\n\n例如:\n\n```bash\nsed -n '/ERROR/ s/user=/account=/p' app.log\n```\n\n处理顺序是:\n\n1. 当前行是否匹配 `/ERROR/`;\n2. 若匹配,执行替换;\n3. 只有替换成功的行才由 `p` 输出。\n\n删除注释和空行:\n\n```bash\nsed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt\n```\n\n这里分号分隔两条命令:\n\n- 匹配注释行则删除;\n- 匹配空白行则删除。\n\n### 3. `-i` 原地修改的风险\n\n```bash\nsed -i 's/old/new/g' config.txt\n```\n\n这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 `-i` 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。\n\n生产配置修改应先备份或生成新文件:\n\n```bash\ncp -- config.txt config.txt.bak\nsed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new\ndiff -u config.txt config.txt.new\nmv -- config.txt.new config.txt\n```\n\n如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 `mv` 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。\n\n### 4. sed 不是通用多行解析器\n\n`sed` 可以使用 `N`、`D`、`P` 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。\n\n---\n\n## 五、awk:按记录和字段进行计算\n\n`awk` 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:\n\n```awk\npattern { action }\n```\n\n对每条记录:\n\n1. 计算 `pattern`;\n2. 如果条件为真,执行 `action`;\n3. 继续处理下一条记录。\n\n### 1. 记录、字段和内置变量\n\n默认情况下:\n\n- `RS`:记录分隔符,通常是换行;\n- `FS`:字段分隔符,默认是空白;\n- `$0`:整条记录;\n- `$1`、`$2`:第一个、第二个字段;\n- `NF`:当前记录字段数;\n- `NR`:当前输入的总记录号;\n- `FNR`:当前文件内的记录号;\n- `OFS`:输出字段分隔符,默认是空格;\n- `ORS`:输出记录分隔符,默认是换行。\n\n示例:\n\n```bash\nawk '{print NR, $2, $NF}' app.log\n```\n\n输出类似:\n\n```text\n1 INFO login\n2 ERROR delete\n3 WARN retry\n4 ERROR login\n```\n\n这里 `$NF` 表示最后一个字段,因此不需要知道每行字段总数。\n\n### 2. 条件过滤和数值计算\n\n```bash\nawk '$2 == \"ERROR\" { count++ } END { print count+0 }' app.log\n```\n\n处理逻辑是:\n\n1. 每读一行,比较第二字段是否等于 `ERROR`;\n2. 匹配时将 `count` 加一;\n3. 全部输入结束后执行 `END`;\n4. `count+0` 确保没有匹配时输出 `0`,而不是空值。\n\n按用户统计错误数:\n\n```bash\nawk '\n$2 == \"ERROR\" {\n user = $3\n sub(/^user=/, \"\", user)\n errors[user]++\n}\nEND {\n for (user in errors)\n print user, errors[user]\n}\n' app.log\n```\n\n可能输出:\n\n```text\nbob 1\ncarol 1\n```\n\n`for (user in errors)` 的遍历顺序不保证。若需要稳定输出,应再排序:\n\n```bash\nawk '\n$2 == \"ERROR\" {\n user = $3\n sub(/^user=/, \"\", user)\n errors[user]++\n}\nEND {\n for (user in errors)\n print user, errors[user]\n}\n' app.log | LC_ALL=C sort\n```\n\n### 3. `BEGIN`、`END` 和格式控制\n\n统计不同级别:\n\n```bash\nawk '\nBEGIN { OFS=\"\\t\" }\n{\n level[$2]++\n}\nEND {\n for (x in level)\n print x, level[x]\n}\n' app.log\n```\n\n`BEGIN` 在读取第一条记录前执行,适合初始化分隔符和变量;`END` 在所有输入处理完成后执行,适合汇总结果。\n\n指定字段分隔符:\n\n```bash\nawk -F: '{ print $1, $3, $7 }' /etc/passwd\n```\n\n`/etc/passwd` 的字段由单个冒号分隔,因此 `-F:` 合适。输出默认使用空格。\n\n对于连续空白,awk 的特殊默认行为很有用:\n\n```bash\nawk '{ print $1, $2 }' file\n```\n\n默认 `FS` 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:\n\n```bash\nawk -F' ' '{ print $1, $2 }' file\n```\n\n在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 `-F'\\t'`。\n\n### 4. `NR` 和 `FNR` 的差异\n\n```bash\nawk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt\n```\n\n假设两个文件各有两行,输出中的 `FNR` 会在读取 `b.txt` 时重新从 `1` 开始,而 `NR` 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。\n\n### 5. awk 的正则匹配\n\n```bash\nawk '$2 ~ /^ERROR$/ { print $0 }' app.log\n```\n\n- `~`:字段匹配正则;\n- `!~`:字段不匹配正则;\n- `/^ERROR$/`:整字段必须等于 `ERROR`。\n\n这与:\n\n```bash\nawk '$2 == \"ERROR\" { print }' app.log\n```\n\n在这个例子中效果相似,但前者可以表达更复杂的模式。\n\n### 6. awk 中的字符串和数字转换\n\nawk 具有字符串和数字的双重转换语义:\n\n```bash\nawk 'BEGIN {\n print (\"10\" + 2)\n print (\"10\" \u003c \"2\")\n print (10 \u003c 2)\n}'\n```\n\n具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:\n\n```bash\nawk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file\n```\n\n需要注意,`awk` 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。\n\n---\n\n## 六、cut:按位置或单字符分隔符提取字段\n\n`cut` 设计目标是提取固定位置,不是通用分隔格式解析。\n\n### 1. 按字符、字节和字段提取\n\n```bash\ncut -c 1-8 file # 按字符位置\ncut -b 1-8 file # 按字节位置\ncut -d: -f1,3 /etc/passwd\n```\n\n三种模式:\n\n- `-c`:字符位置;\n- `-b`:字节位置;\n- `-f`:字段位置;\n- `-d`:字段分隔符,通常是单个字符;\n- `--complement`:取未选中的部分。\n\n中文环境下,`-b` 可能截断一个多字节 UTF-8 字符;`-c` 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 `LC_ALL=C`,需要按本地字符处理时则明确设置 UTF-8 locale。\n\n### 2. 字段提取示例\n\n```bash\nprintf '%s\\n' \\\n 'alice:1001:/home/alice' \\\n 'bob:1002:/home/bob' |\ncut -d: -f1,3\n```\n\n输出:\n\n```text\nalice:/home/alice\nbob:/home/bob\n```\n\n`cut` 的 `-d` 只表示一个字段分隔字符。它不能直接表达:\n\n- 任意数量的空白;\n- 多个候选分隔符;\n- 引号中的分隔符;\n- 需要转义的字段内容。\n\n因此,以下任务通常更适合 awk:\n\n```bash\nawk -F'[[:space:]]+' '{ print $1, $3 }' file\n```\n\n### 3. 缺失分隔符的边界\n\n默认情况下,如果一行不含指定分隔符,GNU `cut -d: -f1` 通常会把整行视为字段内容,而 `-f` 的组合行为还受 `-s` 影响:\n\n```bash\ncut -d: -f1 file\ncut -d: -f1 -s file\n```\n\n`-s` 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 `-s` 可能掩盖输入问题。\n\n---\n\n## 七、sort:定义全序并重新排列记录\n\n`sort` 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。\n\n### 1. 基本排序\n\n```bash\nprintf '%s\\n' 20 3 100 11 | sort\n```\n\n默认是字典序,输出:\n\n```text\n100\n11\n20\n3\n```\n\n因为比较的是字符串:\n\n```text\n\"100\" \u003c \"11\" \u003c \"20\" \u003c \"3\"\n```\n\n若要按数值排序:\n\n```bash\nprintf '%s\\n' 20 3 100 11 | sort -n\n```\n\n输出:\n\n```text\n3\n11\n20\n100\n```\n\n`sort -n` 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。\n\n### 2. 字段和键\n\n对于空白分隔的输入:\n\n```text\nalice 42\nbob 7\ncarol 19\n```\n\n按第二列数值排序:\n\n```bash\nsort -k2,2n users.txt\n```\n\n`-k2,2n` 的含义是:\n\n- 排序键从第 2 字段开始;\n- 到第 2 字段结束;\n- 使用数值比较。\n\n不要只写:\n\n```bash\nsort -k2n users.txt\n```\n\n因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。\n\n指定分隔符:\n\n```bash\nsort -t: -k3,3n /etc/passwd\n```\n\n按冒号分隔,使用第 3 字段的数值排序。\n\n常用选项:\n\n```bash\nsort -r # 逆序\nsort -u # 排序后只保留每个相等键的一行\nsort -f # 忽略大小写\nsort -h # 人类可读数字,如 10K、2M,GNU 扩展\nsort -c # 检查输入是否已有序\nsort -C # 检查是否有序但不输出\n```\n\n### 3. locale 会改变排序结果\n\n```bash\nsort file\n```\n\n的排序规则可能受到 `LC_COLLATE` 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:\n\n```bash\nLC_ALL=C sort file\n```\n\n但这不是“总是更正确”。`LC_ALL=C` 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。\n\n### 4. 大文件和临时空间\n\n`sort` 可能使用内存和临时文件完成外部排序:\n\n```bash\nsort -T /var/tmp large.log\n```\n\n如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、`TMPDIR`、`-T` 配置以及失败后的清理路径。\n\n---\n\n## 八、uniq:只合并相邻相同记录\n\n`uniq` 的核心定义不是“删除所有重复行”,而是:\n\n> 将相邻且相等的输入行压缩为一行。\n\n反例:\n\n```bash\nprintf '%s\\n' a b a | uniq\n```\n\n输出仍是:\n\n```text\na\nb\na\n```\n\n因为两个 `a` 不相邻。\n\n如果要统计所有重复项,通常先排序:\n\n```bash\nprintf '%s\\n' a b a a b | sort | uniq -c\n```\n\n输出:\n\n```text\n 3 a\n 2 b\n```\n\n这里的中间结果是:\n\n```text\na\na\na\nb\nb\n```\n\n`uniq -c` 对每个连续组计数,`sort` 先把相同值放入同一组,所以计数才覆盖全部输入。\n\n常用选项:\n\n```bash\nuniq -c # 每组前输出计数\nuniq -d # 只输出重复组\nuniq -u # 只输出只出现一次的组\nuniq -f N # 比较时跳过前 N 个字段\nuniq -w N # 只比较前 N 个字符\n```\n\n### `sort -u` 和 `sort | uniq` 的区别\n\n```bash\nsort -u file\nsort file | uniq\n```\n\n对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:\n\n- `sort -u` 在排序阶段处理相等项;\n- `sort | uniq` 先完成排序,再由 uniq 合并相邻项;\n- 若需要计数、只看重复项等,必须使用 `uniq` 的相应选项;\n- 排序比较规则会受到 `sort` 的选项和 locale 影响,而 `uniq` 的相等比较也应与排序条件保持一致。\n\n一个常见错误是:\n\n```bash\nsort -k2,2n file | uniq\n```\n\n如果目标是按第二字段去重,这条命令并没有做到,因为 `uniq` 默认比较整行。需要先明确“相等”的定义,例如:\n\n```bash\nsort -k2,2n -k1,1 file\n```\n\n但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:\n\n```bash\nawk '!seen[$2]++' file\n```\n\n这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。\n\n---\n\n## 九、组合算例:从日志中统计错误用户\n\n输入为:\n\n```text\n2025-03-01 INFO user=alice action=login\n2025-03-01 ERROR user=bob action=delete\n2025-03-01 WARN user=alice action=retry\n2025-03-02 ERROR user=carol action=login\n```\n\n目标:\n\n1. 只处理 `ERROR` 行;\n2. 提取用户;\n3. 统计每个用户的错误数;\n4. 按错误次数降序输出。\n\n可以直接使用 awk:\n\n```bash\nawk '\n$2 == \"ERROR\" {\n user = $3\n sub(/^user=/, \"\", user)\n count[user]++\n}\nEND {\n for (user in count)\n print count[user], user\n}\n' app.log | sort -k1,1nr -k2,2\n```\n\n中间数据为:\n\n```text\n1 bob\n1 carol\n```\n\n最终输出:\n\n```text\n1 bob\n1 carol\n```\n\n如果 `bob` 出现三次,则会得到:\n\n```text\n3 bob\n1 carol\n```\n\n并按数值降序排列。\n\n也可以拆成管道,以便逐步检查:\n\n```bash\ngrep -E '^[^ ]+ ERROR ' app.log |\nawk '{\n user = $3\n sub(/^user=/, \"\", user)\n print user\n}' |\nsort |\nuniq -c |\nsort -k1,1nr -k2,2\n```\n\n每一步的输入输出契约是:\n\n1. `grep`:输出第二列为 `ERROR` 的完整行;\n2. `awk`:从第三字段去掉 `user=`,输出纯用户名;\n3. 第一个 `sort`:将相同用户名排列到一起;\n4. `uniq -c`:统计每个相邻用户名组;\n5. 第二个 `sort`:按计数降序,再按用户名排序。\n\n分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。\n\n---\n\n## 十、正则匹配和文本替换中的常见失败\n\n### 1. 未锚定导致“部分匹配”\n\n```bash\ngrep -E '[0-9]+' input\n```\n\n会接受:\n\n```text\nabc123\n123xyz\n```\n\n若输入字段必须是纯数字:\n\n```bash\ngrep -E '^[0-9]+ input\n```\n\n若文件可能使用 CRLF,行尾可能包含回车字符,导致 ` Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则 - WR Blog
WR Blog 加载中...
返回文章
LinuxgrepawkShell

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则封面

Linux 基础体系 · 第 42/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则

在 Linux 中,“文本处理”通常不是某个命令独立完成全部工作,而是由多个只负责一小部分变换的程序组成管道:

文件或标准输入
      │
      ▼
  grep:筛选记录
      │
      ▼
  sed:按规则替换或删除
      │
      ▼
  awk:按字段计算和格式化
      │
      ▼
  cut:提取固定字段
      │
      ▼
  sort:排序
      │
      ▼
  uniq:合并相邻重复记录
      │
      ▼
标准输出、文件或下一个程序

这里的“记录”通常是一行,但具体含义由程序决定:

  • grep 默认以行为匹配单位;
  • sed 默认逐行读取,内部称为 pattern space;
  • awk 默认以行为记录,以空白分隔字段;
  • cutsortuniq 也通常按行工作,但对字段、排序键和重复关系的定义不同。

这些命令主要处理结构简单、边界明确的文本流。它们不是通用的 CSV、JSON 或 YAML 解析器。遇到嵌套结构、引号中的分隔符或转义规则时,应使用对应的结构化工具,例如 jqyq,而不是继续叠加正则和 cut


一、先理解 Shell 管道:程序处理的是字节流,不是“变量列表”

命令:

grep -E 'ERROR|WARN' app.log | awk '{print $1, $2}'

可以分成三步:

  1. grepapp.log 读取内容;
  2. grep 将匹配的行写入标准输出;
  3. Shell 将前一个程序的标准输出连接到 awk 的标准输入;
  4. awk 处理收到的每一行。

管道传递的是字节流。Shell 不会自动理解“这一行是一个对象”或“这个字段是一个整数”。字段如何划分、数字如何比较,必须由命令自己定义。

1. 标准输入、标准输出和重定向

grep -i 'timeout' app.log
grep -i 'timeout' < app.log
grep -i 'timeout' app.log > timeout.log

三者的区别是:

  • 第一条把 app.log 作为 grep 的文件参数;
  • 第二条通过标准输入提供内容;
  • 第三条将标准输出写入 timeout.log

重定向文件时,Shell 通常会先创建或截断目标文件,再启动命令。因此下面的写法会在命令启动前清空 app.log

grep 'ERROR' app.log > app.log

安全的做法是使用临时文件并在验证后替换:

tmp=$(mktemp)
if grep 'ERROR' app.log >"$tmp"; then
    mv -- "$tmp" error.log
else
    rm -f -- "$tmp"
fi

这里的 grep 退出状态也很重要:

  • 0:至少有一行匹配;
  • 1:没有匹配;
  • 2:发生错误,例如文件不存在或正则无效。

因此,“没有匹配”不一定是命令失败,但在 Shell 的 set -e、CI 或监控脚本中必须明确区分这几种状态。

2. 管道失败状态

默认情况下,Shell 管道的退出状态通常是最后一个命令的状态:

grep 'ERROR' missing.log | sort
echo "$?"

即使 grep 因文件不存在失败,sort 仍可能成功,导致整个管道返回成功。Bash、Zsh 等支持:

set -o pipefail

启用后,管道在任一命令失败时通常能够反映失败,但“无匹配”的 grep 状态 1 是否算业务失败,仍需要脚本自行处理。


二、正则表达式:描述字符串集合的规则

1. 正则的基本概念

正则表达式可以看作一个描述字符串集合的表达式。设正则表达式为 RR,其匹配语言记作 L(R)L(R)。例如:

cat|dog

表示字符串集合:

{"cat", "dog"}

而:

[0-9]+

表示一个或多个 ASCII 数字组成的字符串集合,例如:

1
42
20250101

在命令行中,正则通常被用来判断“某一行是否包含满足条件的子串”。这与“整行必须完全满足条件”不同:

printf '%s\n' 'id=42' 'abc' | grep -E '[0-9]+'

输出:

id=42

因为 [0-9]+ 只需在行中找到一个匹配片段。若要求整行只能由数字组成,需要使用锚点:

printf '%s\n' '42' 'id=42' '42x' | grep -E '^[0-9]+$'

输出:

42

其中:

  • ^ 匹配行首;
  • $ 匹配行尾;
  • [0-9] 匹配一个数字;
  • + 表示前一个原子出现一次或多次。

可以将这个条件形式化为:

valid(s)=s[09]+\text{valid}(s) = s \in [0-9]^+

^$ 将“字符串中存在匹配”收紧为“整个字符串属于该语言”。

2. 常见正则运算符

写法 含义
. 任意单个字符,通常不匹配换行
[abc] abc 中任意一个
[^abc] 不属于 abc 的一个字符
[[:digit:]] 当前 locale 定义的数字字符类
* 前一个原子出现零次或多次
+ 前一个原子出现一次或多次
? 前一个原子出现零次或一次
{m,n} 出现至少 m 次、至多 n
(...) 分组
` `
^$ 行首、行尾

+?|、括号和 {m,n} 在扩展正则表达式中更自然,因此通常使用 grep -Esed -Eawk 的正则语法。

grep -E '^(ERROR|WARN):' app.log

这表示整行开头必须是 ERROR:WARN:

3. BRE、ERE 和 PCRE 的区别

grep 默认使用基本正则表达式(BRE):

grep 'ab\+' file

在 GNU grep 中,\+ 可表示一次或多次,但这种写法不适合依赖跨实现兼容性。

扩展正则表达式(ERE)使用:

grep -E 'ab+' file

sed 默认也使用 BRE,使用 -E 可启用 ERE:

sed -E 's/[0-9]+/<number>/g' file

grep -P 使用 Perl 兼容正则表达式,但它不是 POSIX 标准接口,在不同发行版、不同 grep 构建方式中支持情况可能不同。除非明确依赖 PCRE 特性,否则优先使用 grep -E

4. Shell 引号和正则不是一回事

Shell 会先解释命令行,再把参数传给程序。正则最好放在单引号中:

grep -E '^[0-9]+$' file

单引号可以防止 Shell 展开 $、反引号、命令替换以及通配符。下面的双引号虽然也能保护大多数正则字符,但 $ 可能被 Shell 当作变量展开:

grep -E "^[0-9]+$" file

更稳妥的是:

grep -E '^[0-9]+$' file

正则中的 * 和 Shell 文件名通配符中的 * 也不是同一个机制:

grep '*.log' file

这里的 * 是正则量词,表示前一个字符 ' 重复零次或多次,通常不是“匹配任意字符”。如果要匹配字面量点号:

grep -E '\.log$' file

三、grep:筛选包含匹配内容的行

grep 的核心语义是:

对输入中的每一行进行匹配,输出满足条件的行。

1. 基本用法

先准备示例文件:

cat >app.log <<'EOF'
2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login
EOF

筛选包含 ERROR 的行:

grep 'ERROR' app.log

输出:

2025-03-01 ERROR user=bob action=delete
2025-03-02 ERROR user=carol action=login

常用选项:

grep -i 'error' app.log       # 忽略大小写
grep -v 'ERROR' app.log       # 输出不匹配的行
grep -n 'ERROR' app.log       # 输出行号
grep -c 'ERROR' app.log       # 统计匹配行数
grep -l 'ERROR' *.log         # 只输出包含匹配的文件名
grep -h 'ERROR' *.log         # 多文件输出时隐藏文件名
grep -F 'a.b' file            # 固定字符串匹配,不解释正则
grep -E 'ERROR|WARN' app.log  # 扩展正则

grep -F 对搜索用户输入的字面量尤其重要。如果用户输入:

a.b

普通正则会将 . 解释为任意字符,可能额外匹配 aXb。固定字符串模式则只匹配字面量 a.b

2. 单词边界和文件名边界

grep -w 'run' file

-w 常用于匹配单词,但“单词字符”的定义与实现和 locale 有关,不能简单等同于编程语言中的标识符规则。

处理可能以 - 开头的文件名时,应使用 --

grep -- '-n' -- weird-file

这里第一个 -- 结束选项,后面的 -n 被当作模式参数。更常见的写法是明确使用 -e

grep -e '-n' -- weird-file

3. grep 的边界

grep 输出的是原始行。它不会:

  • 解析 CSV 的引号和转义;
  • 理解 JSON 对象层次;
  • 将日期自动转换为日期值;
  • 根据数字大小进行排序;
  • 将重复的非相邻行自动合并。

例如:

printf '%s\n' 'name="a,b",age=20' | cut -d, -f2

结果会被错误地按逗号切成多个部分,因为 CSV 中的逗号可能位于引号内。此时应使用 CSV 解析器,而不是 cut


四、sed:逐行执行编辑脚本

sed 是流编辑器。它通常不会把整个文件装入内存,而是循环执行:

  1. 读取一行到 pattern space;
  2. 执行编辑命令;
  3. 默认输出 pattern space;
  4. 读取下一行。

可以抽象为:

输入行 → pattern space → sed 脚本 → 输出或丢弃

1. 替换命令 s

最常见的形式:

s/正则/替换文本/标志

例如:

printf '%s\n' 'user=alice' 'user=bob' |
    sed -E 's/user=([a-z]+)/account=\1/'

输出:

account=alice
account=bob

([a-z]+) 是捕获组,\1 引用第一个捕获组。

默认情况下,s 只替换每行第一个匹配:

printf '%s\n' 'a a a' | sed 's/a/x/'

输出:

x a a

加上 g 才替换整行所有匹配:

printf '%s\n' 'a a a' | sed 's/a/x/g'

输出:

x x x

其他常见标志:

sed -n 's/ERROR/FAIL/p' app.log

-n 关闭默认输出,p 只打印发生替换的行。因此这条命令只输出实际包含并成功替换 ERROR 的行。

2. 地址:只对部分行执行命令

地址决定命令作用在哪些行:

sed -n '2p' app.log
sed -n '/ERROR/p' app.log
sed -n '2,4p' app.log
sed -n '1,/WARN/p' app.log

例如:

sed -n '/ERROR/ s/user=/account=/p' app.log

处理顺序是:

  1. 当前行是否匹配 /ERROR/
  2. 若匹配,执行替换;
  3. 只有替换成功的行才由 p 输出。

删除注释和空行:

sed -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' config.txt

这里分号分隔两条命令:

  • 匹配注释行则删除;
  • 匹配空白行则删除。

3. -i 原地修改的风险

sed -i 's/old/new/g' config.txt

这是 GNU sed 常见用法,但它通常会通过临时文件和重命名完成更新,权限、符号链接行为、硬链接关系和不同 sed 实现的细节可能存在差异。macOS 的 BSD sed 对 -i 参数形式也不同,因此不能把 GNU 命令直接视为所有 Unix 的通用语法。

生产配置修改应先备份或生成新文件:

cp -- config.txt config.txt.bak
sed -E 's/^timeout=.*/timeout=30/' config.txt >config.txt.new
diff -u config.txt config.txt.new
mv -- config.txt.new config.txt

如果程序需要原子更新,临时文件应与目标位于同一文件系统,并在校验成功后使用 mv 替换。若配置更新过程涉及权限、属主、SELinux 标签或服务重载,还必须额外验证这些属性。

4. sed 不是通用多行解析器

sed 可以使用 NDP 等命令处理多行 pattern space,但脚本复杂度会迅速增加。它适合规则明确的流式替换、删除、抽取;对于嵌套 JSON、YAML 或需要语法验证的配置,不应依赖 sed 做结构化修改。


五、awk:按记录和字段进行计算

awk 不只是“按空格切字段”。它是一个按记录处理的文本编程语言,基本结构是:

pattern { action }

对每条记录:

  1. 计算 pattern
  2. 如果条件为真,执行 action
  3. 继续处理下一条记录。

1. 记录、字段和内置变量

默认情况下:

  • RS:记录分隔符,通常是换行;
  • FS:字段分隔符,默认是空白;
  • $0:整条记录;
  • $1$2:第一个、第二个字段;
  • NF:当前记录字段数;
  • NR:当前输入的总记录号;
  • FNR:当前文件内的记录号;
  • OFS:输出字段分隔符,默认是空格;
  • ORS:输出记录分隔符,默认是换行。

示例:

awk '{print NR, $2, $NF}' app.log

输出类似:

1 INFO login
2 ERROR delete
3 WARN retry
4 ERROR login

这里 $NF 表示最后一个字段,因此不需要知道每行字段总数。

2. 条件过滤和数值计算

awk '$2 == "ERROR" { count++ } END { print count+0 }' app.log

处理逻辑是:

  1. 每读一行,比较第二字段是否等于 ERROR
  2. 匹配时将 count 加一;
  3. 全部输入结束后执行 END
  4. count+0 确保没有匹配时输出 0,而不是空值。

按用户统计错误数:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log

可能输出:

bob 1
carol 1

for (user in errors) 的遍历顺序不保证。若需要稳定输出,应再排序:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    errors[user]++
}
END {
    for (user in errors)
        print user, errors[user]
}
' app.log | LC_ALL=C sort

3. BEGINEND 和格式控制

统计不同级别:

awk '
BEGIN { OFS="\t" }
{
    level[$2]++
}
END {
    for (x in level)
        print x, level[x]
}
' app.log

BEGIN 在读取第一条记录前执行,适合初始化分隔符和变量;END 在所有输入处理完成后执行,适合汇总结果。

指定字段分隔符:

awk -F: '{ print $1, $3, $7 }' /etc/passwd

/etc/passwd 的字段由单个冒号分隔,因此 -F: 合适。输出默认使用空格。

对于连续空白,awk 的特殊默认行为很有用:

awk '{ print $1, $2 }' file

默认 FS 是一个特殊的空白分隔规则,会把连续空格和制表符视为分隔符,并忽略行首行尾空白。但如果明确写:

awk -F' ' '{ print $1, $2 }' file

在常见 awk 中仍有相近的特殊处理;若需要严格区分空格字符和制表符,应使用明确正则,例如 -F'\t'

4. NRFNR 的差异

awk '{ print FILENAME, FNR, NR, $0 }' a.txt b.txt

假设两个文件各有两行,输出中的 FNR 会在读取 b.txt 时重新从 1 开始,而 NR 继续累计。比较两者是处理多个输入文件时识别文件边界的基础。

5. awk 的正则匹配

awk '$2 ~ /^ERROR$/ { print $0 }' app.log
  • ~:字段匹配正则;
  • !~:字段不匹配正则;
  • /^ERROR$/:整字段必须等于 ERROR

这与:

awk '$2 == "ERROR" { print }' app.log

在这个例子中效果相似,但前者可以表达更复杂的模式。

6. awk 中的字符串和数字转换

awk 具有字符串和数字的双重转换语义:

awk 'BEGIN {
    print ("10" + 2)
    print ("10" < "2")
    print (10 < 2)
}'

具体比较结果受操作数类型和 awk 实现规则影响,不应把所有输入都当作可靠数字。处理外部数据时,先验证格式:

awk '$1 ~ /^[0-9]+$/ && $1+0 >= 100 { print }' file

需要注意,awk 的整数精度和浮点行为依赖实现及底层类型。金额、超大整数或严格十进制计算不应直接交给 awk 做最终财务计算。


六、cut:按位置或单字符分隔符提取字段

cut 设计目标是提取固定位置,不是通用分隔格式解析。

1. 按字符、字节和字段提取

cut -c 1-8 file       # 按字符位置
cut -b 1-8 file       # 按字节位置
cut -d: -f1,3 /etc/passwd

三种模式:

  • -c:字符位置;
  • -b:字节位置;
  • -f:字段位置;
  • -d:字段分隔符,通常是单个字符;
  • --complement:取未选中的部分。

中文环境下,-b 可能截断一个多字节 UTF-8 字符;-c 按字符处理,但具体 locale 会影响字符定义。需要稳定的字节级协议处理时使用 LC_ALL=C,需要按本地字符处理时则明确设置 UTF-8 locale。

2. 字段提取示例

printf '%s\n' \
  'alice:1001:/home/alice' \
  'bob:1002:/home/bob' |
cut -d: -f1,3

输出:

alice:/home/alice
bob:/home/bob

cut-d 只表示一个字段分隔字符。它不能直接表达:

  • 任意数量的空白;
  • 多个候选分隔符;
  • 引号中的分隔符;
  • 需要转义的字段内容。

因此,以下任务通常更适合 awk:

awk -F'[[:space:]]+' '{ print $1, $3 }' file

3. 缺失分隔符的边界

默认情况下,如果一行不含指定分隔符,GNU cut -d: -f1 通常会把整行视为字段内容,而 -f 的组合行为还受 -s 影响:

cut -d: -f1 file
cut -d: -f1 -s file

-s 表示不输出不含分隔符的行。处理可能损坏或格式不统一的文件时,应明确决定:是保留异常行供诊断,还是静默丢弃它们。直接使用 -s 可能掩盖输入问题。


七、sort:定义全序并重新排列记录

sort 通常对每一行建立排序键,然后按照比较规则输出有序结果。排序顺序受 locale、数值解释、字段键和稳定性选项影响。

1. 基本排序

printf '%s\n' 20 3 100 11 | sort

默认是字典序,输出:

100
11
20
3

因为比较的是字符串:

"100" < "11" < "20" < "3"

若要按数值排序:

printf '%s\n' 20 3 100 11 | sort -n

输出:

3
11
20
100

sort -n 适用于常见十进制整数和固定格式数字。带单位、货币符号、科学计数法或复杂数值时,应先规范化,或使用其他语言进行解析。

2. 字段和键

对于空白分隔的输入:

alice 42
bob 7
carol 19

按第二列数值排序:

sort -k2,2n users.txt

-k2,2n 的含义是:

  • 排序键从第 2 字段开始;
  • 到第 2 字段结束;
  • 使用数值比较。

不要只写:

sort -k2n users.txt

因为键可能从第 2 字段延伸到行尾,后续字段会参与比较,可能产生意料之外的结果。

指定分隔符:

sort -t: -k3,3n /etc/passwd

按冒号分隔,使用第 3 字段的数值排序。

常用选项:

sort -r                 # 逆序
sort -u                 # 排序后只保留每个相等键的一行
sort -f                 # 忽略大小写
sort -h                 # 人类可读数字,如 10K、2M,GNU 扩展
sort -c                 # 检查输入是否已有序
sort -C                 # 检查是否有序但不输出

3. locale 会改变排序结果

sort file

的排序规则可能受到 LC_COLLATE 影响。为了在日志、构建和测试中获得可复现结果,常见做法是:

LC_ALL=C sort file

但这不是“总是更正确”。LC_ALL=C 往往按字节或 C locale 规则排序,不一定符合用户语言环境中的字典序。选择 locale 是接口语义的一部分,而不是单纯性能开关。

4. 大文件和临时空间

sort 可能使用内存和临时文件完成外部排序:

sort -T /var/tmp large.log

如果临时目录空间不足,排序会失败;如果输入包含敏感信息,临时文件位置和权限也需要考虑。生产环境应检查磁盘空间、TMPDIR-T 配置以及失败后的清理路径。


八、uniq:只合并相邻相同记录

uniq 的核心定义不是“删除所有重复行”,而是:

将相邻且相等的输入行压缩为一行。

反例:

printf '%s\n' a b a | uniq

输出仍是:

a
b
a

因为两个 a 不相邻。

如果要统计所有重复项,通常先排序:

printf '%s\n' a b a a b | sort | uniq -c

输出:

      3 a
      2 b

这里的中间结果是:

a
a
a
b
b

uniq -c 对每个连续组计数,sort 先把相同值放入同一组,所以计数才覆盖全部输入。

常用选项:

uniq -c       # 每组前输出计数
uniq -d       # 只输出重复组
uniq -u       # 只输出只出现一次的组
uniq -f N     # 比较时跳过前 N 个字段
uniq -w N     # 只比较前 N 个字符

sort -usort | uniq 的区别

sort -u file
sort file | uniq

对“整行去重”的常见输入,结果通常相同,但语义和资源特征不完全相同:

  • sort -u 在排序阶段处理相等项;
  • sort | uniq 先完成排序,再由 uniq 合并相邻项;
  • 若需要计数、只看重复项等,必须使用 uniq 的相应选项;
  • 排序比较规则会受到 sort 的选项和 locale 影响,而 uniq 的相等比较也应与排序条件保持一致。

一个常见错误是:

sort -k2,2n file | uniq

如果目标是按第二字段去重,这条命令并没有做到,因为 uniq 默认比较整行。需要先明确“相等”的定义,例如:

sort -k2,2n -k1,1 file

但这仍是按整行去重;如果只想按某个字段保留一条记录,通常使用 awk:

awk '!seen[$2]++' file

这表示对每个第二字段只输出第一次出现的记录。它不排序,输出顺序是首次出现顺序,并且会在内存中保存已见键。


九、组合算例:从日志中统计错误用户

输入为:

2025-03-01 INFO  user=alice action=login
2025-03-01 ERROR user=bob action=delete
2025-03-01 WARN  user=alice action=retry
2025-03-02 ERROR user=carol action=login

目标:

  1. 只处理 ERROR 行;
  2. 提取用户;
  3. 统计每个用户的错误数;
  4. 按错误次数降序输出。

可以直接使用 awk:

awk '
$2 == "ERROR" {
    user = $3
    sub(/^user=/, "", user)
    count[user]++
}
END {
    for (user in count)
        print count[user], user
}
' app.log | sort -k1,1nr -k2,2

中间数据为:

1 bob
1 carol

最终输出:

1 bob
1 carol

如果 bob 出现三次,则会得到:

3 bob
1 carol

并按数值降序排列。

也可以拆成管道,以便逐步检查:

grep -E '^[^ ]+ ERROR ' app.log |
awk '{
    user = $3
    sub(/^user=/, "", user)
    print user
}' |
sort |
uniq -c |
sort -k1,1nr -k2,2

每一步的输入输出契约是:

  1. grep:输出第二列为 ERROR 的完整行;
  2. awk:从第三字段去掉 user=,输出纯用户名;
  3. 第一个 sort:将相同用户名排列到一起;
  4. uniq -c:统计每个相邻用户名组;
  5. 第二个 sort:按计数降序,再按用户名排序。

分步管道更容易诊断,但中间输出必须保证不会把控制字符、换行或分隔符混入字段。若用户名来自不可信输入,最好使用明确的编码或结构化输出格式。


十、正则匹配和文本替换中的常见失败

1. 未锚定导致“部分匹配”

grep -E '[0-9]+' input

会接受:

abc123
123xyz

若输入字段必须是纯数字:

grep -E '^[0-9]+$' input

若文件可能使用 CRLF,行尾可能包含回车字符,导致 $ 前实际还有 \r。可以诊断:

cat -vet input
od -An -tx1 -c input

处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。

2. grep 的正则与 Shell 通配符混淆

查找字面量文件扩展名:

grep -E '\.log$' file

而不是:

grep '*.log' file

如果搜索内容来自变量,并且需求是字面量搜索:

needle='a.b'
grep -F -- "$needle" file

这里同时解决了两个问题:

  • -F 禁止正则解释;
  • -- 防止以 - 开头的内容被当成选项。

3. sed 替换内容包含分隔符

默认使用 / 作为分隔符:

sed 's/old/path/new/path/g' file

这会产生歧义。可以选择其他分隔符:

sed 's#old/path#new/path#g' file

但替换文本中的 & 具有特殊含义,表示整个匹配内容;若要插入字面量 &,需要转义:

printf '%s\n' 'a' | sed 's/a/A\&B/'

输出:

A&B

4. awk 输出字段时不要误解 $0

awk '{$1=$1; print}' file

这条常用于按 awk 的 OFS 重新格式化空白。赋值 $1=$1 会使 awk 重建 $0,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。


十一、文本编码、换行和 locale

这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:

grep -i
grep '[[:alpha:]]'
sort
cut -c
awk 的正则和字符串函数

查看当前环境:

locale

对协议字段、机器生成日志和可复现构建,常见做法是:

LC_ALL=C grep -E '^[A-Z_]+$' file
LC_ALL=C sort file

但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 LC_ALL=C 当作普遍正确的选择。

还要区分:

  • UTF-8 字符;
  • 字节;
  • Unicode 组合字符;
  • Windows CRLF;
  • Unix LF;
  • 文件名中的换行或 NUL。

grepsedawk 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。


十二、与 find、xargs 的边界:文件名不是普通文本行

处理文件列表时,下面的写法存在文件名安全问题:

find . -type f | xargs grep 'ERROR'

原因是:

  • 文件名可能包含空格;
  • 可能包含制表符或换行;
  • 可能以 - 开头;
  • 空输入时,某些 xargs 实现仍可能执行一次命令。

更安全的 GNU/Linux 写法是使用 NUL 分隔:

find . -type f -print0 |
    xargs -0 grep -H -- 'ERROR'

数据流变为:

find 输出:文件名\0文件名\0文件名\0
xargs -0:按 NUL 而不是空白拆分
grep --:将后续内容视为文件名而不是选项

如果支持 find -exec ... {} +,可以少依赖一个外部解析器:

find . -type f -exec grep -H -- 'ERROR' {} +

find-exec ... {} + 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU xargs 提供:

find . -type f -print0 |
    xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'

但并发会改变输出顺序,也会增加 I/O 竞争;grep 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。

不要把:

find . -name '*.log'

中的 *.log 写成未引用形式:

find . -name *.log

前者由 find 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。


十三、不要用行式工具解析 JSON、YAML 和复杂 CSV

下面的 JSON:

{"user":"alice","message":"a,b"}

不能可靠地通过:

cut -d, -f2

解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:

grep '"status":"ok"'

搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。

应使用结构化工具:

jq -r '.user' data.json
jq -r 'select(.status == "ok") | .user' data.json

其工作方式不是“搜索看起来像字段的文本”,而是:

  1. 词法解析 JSON;
  2. 验证语法;
  3. 构造对象和数组;
  4. 按路径和条件访问值;
  5. 按指定格式输出。

YAML 同样应使用 YAML 解析器,例如 yq。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。


十四、生产脚本中的验证、权限和恢复

1. 输入验证

不要假设输入格式永远正确:

awk -F: 'NF != 3 {
    print "invalid record at line " FNR > "/dev/stderr"
    bad=1
    next
}
{
    print $1, $3
}
END {
    exit bad
}' input

这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。

2. 权限和符号链接

文本过滤通常只读,但 sed -i、重定向和 mv 会修改文件。需要检查:

  • 当前用户是否有目录写权限;
  • 目标是否为符号链接;
  • 目标文件是否有特殊属主或权限;
  • 临时文件是否会被其他用户读取;
  • 更新是否需要保留扩展属性或安全标签。

对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。

3. 不要把未验证的文本当作 Shell 代码

例如:

for x in $(cat file); do
    ...
done

会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < file

处理任意文件名则优先使用 NUL 分隔接口:

while IFS= read -r -d '' path; do
    printf '%s\n' "$path"
done < <(find . -type f -print0)

即使文本来自 grepawk,也不应直接拼接后交给 eval。文本数据和 Shell 代码必须保持边界。


十五、如何选择这些工具

可以按数据模型选择:

  • 只判断或筛选行:grep
  • 对行做有限规则替换、删除、打印:sed
  • 需要字段、条件、计数、聚合、格式化:awk
  • 需要按位置或单字符分隔符取字段:cut
  • 需要按字典、数字或字段排序:sort
  • 需要合并相邻重复行或统计连续组:uniq
  • 需要表达匹配规则:正则;
  • 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;
  • 需要批量处理文件:find 配合 -exec 或 NUL 安全的 xargs

最容易被忽略的因果关系是:

uniq 只能处理相邻重复
        │
        ▼
要统计全局重复,必须先让相同值相邻
        │
        ▼
通常使用 sort | uniq -c

同样:

cut 只能按固定字段规则切分
        │
        ▼
字段规则若包含引号、转义或嵌套结构
        │
        ▼
切分模型失效,应改用解析器

掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,grepsedawkcutsortuniq 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
前实际还有 `\\r`。可以诊断:\n\n```bash\ncat -vet input\nod -An -tx1 -c input\n```\n\n处理文本转换时可使用专门工具或明确的 sed 规则,但不要在不了解文件格式时直接删除所有控制字符。\n\n### 2. `grep` 的正则与 Shell 通配符混淆\n\n查找字面量文件扩展名:\n\n```bash\ngrep -E '\\.log file\n```\n\n而不是:\n\n```bash\ngrep '*.log' file\n```\n\n如果搜索内容来自变量,并且需求是字面量搜索:\n\n```bash\nneedle='a.b'\ngrep -F -- \"$needle\" file\n```\n\n这里同时解决了两个问题:\n\n- `-F` 禁止正则解释;\n- `--` 防止以 `-` 开头的内容被当成选项。\n\n### 3. `sed` 替换内容包含分隔符\n\n默认使用 `/` 作为分隔符:\n\n```bash\nsed 's/old/path/new/path/g' file\n```\n\n这会产生歧义。可以选择其他分隔符:\n\n```bash\nsed 's#old/path#new/path#g' file\n```\n\n但替换文本中的 `&` 具有特殊含义,表示整个匹配内容;若要插入字面量 `&`,需要转义:\n\n```bash\nprintf '%s\\n' 'a' | sed 's/a/A\\&B/'\n```\n\n输出:\n\n```text\nA&B\n```\n\n### 4. awk 输出字段时不要误解 `$0`\n\n```bash\nawk '{$1=$1; print}' file\n```\n\n这条常用于按 awk 的 `OFS` 重新格式化空白。赋值 `$1=$1` 会使 awk 重建 `$0`,连续空白可能被压缩,行首行尾空白也可能改变。它不是无损处理。\n\n---\n\n## 十一、文本编码、换行和 locale\n\n这些命令通常处理字节序列,并通过 locale 判断字符类别、大小写和排序关系。以下命令的结果可能受 locale 影响:\n\n```bash\ngrep -i\ngrep '[[:alpha:]]'\nsort\ncut -c\nawk 的正则和字符串函数\n```\n\n查看当前环境:\n\n```bash\nlocale\n```\n\n对协议字段、机器生成日志和可复现构建,常见做法是:\n\n```bash\nLC_ALL=C grep -E '^[A-Z_]+ file\nLC_ALL=C sort file\n```\n\n但这会改变字符分类和排序语义。对于面向用户的自然语言文本,不能简单把 `LC_ALL=C` 当作普遍正确的选择。\n\n还要区分:\n\n- UTF-8 字符;\n- 字节;\n- Unicode 组合字符;\n- Windows CRLF;\n- Unix LF;\n- 文件名中的换行或 NUL。\n\n`grep`、`sed`、`awk` 等经典工具通常以换行为主要记录边界。若数据本身允许换行出现在字段内,行式处理模型就不再足够。\n\n---\n\n## 十二、与 find、xargs 的边界:文件名不是普通文本行\n\n处理文件列表时,下面的写法存在文件名安全问题:\n\n```bash\nfind . -type f | xargs grep 'ERROR'\n```\n\n原因是:\n\n- 文件名可能包含空格;\n- 可能包含制表符或换行;\n- 可能以 `-` 开头;\n- 空输入时,某些 `xargs` 实现仍可能执行一次命令。\n\n更安全的 GNU/Linux 写法是使用 NUL 分隔:\n\n```bash\nfind . -type f -print0 |\n xargs -0 grep -H -- 'ERROR'\n```\n\n数据流变为:\n\n```text\nfind 输出:文件名\\0文件名\\0文件名\\0\nxargs -0:按 NUL 而不是空白拆分\ngrep --:将后续内容视为文件名而不是选项\n```\n\n如果支持 `find -exec ... {} +`,可以少依赖一个外部解析器:\n\n```bash\nfind . -type f -exec grep -H -- 'ERROR' {} +\n```\n\n`find` 的 `-exec ... {} +` 会把多个找到的路径批量传给命令,同时正确处理路径中的空格和换行。若需要并发,GNU `xargs` 提供:\n\n```bash\nfind . -type f -print0 |\n xargs -0 -r -n 20 -P 4 grep -H -- 'ERROR'\n```\n\n但并发会改变输出顺序,也会增加 I/O 竞争;`grep` 退出状态聚合和错误诊断也更复杂。只有在任务可安全并发、目标文件不会被同时修改,并且确实需要并发时才使用。\n\n不要把:\n\n```bash\nfind . -name '*.log'\n```\n\n中的 `*.log` 写成未引用形式:\n\n```bash\nfind . -name *.log\n```\n\n前者由 `find` 解释模式,后者可能先被 Shell 展开,导致参数数量和含义变化。\n\n---\n\n## 十三、不要用行式工具解析 JSON、YAML 和复杂 CSV\n\n下面的 JSON:\n\n```json\n{\"user\":\"alice\",\"message\":\"a,b\"}\n```\n\n不能可靠地通过:\n\n```bash\ncut -d, -f2\n```\n\n解析,因为逗号是 JSON 字符串内容的一部分。类似地,使用:\n\n```bash\ngrep '\"status\":\"ok\"'\n```\n\n搜索 JSON 也可能因空格、字段顺序、转义或嵌套结构变化而失效。\n\n应使用结构化工具:\n\n```bash\njq -r '.user' data.json\njq -r 'select(.status == \"ok\") | .user' data.json\n```\n\n其工作方式不是“搜索看起来像字段的文本”,而是:\n\n1. 词法解析 JSON;\n2. 验证语法;\n3. 构造对象和数组;\n4. 按路径和条件访问值;\n5. 按指定格式输出。\n\nYAML 同样应使用 YAML 解析器,例如 `yq`。如果需要更新配置,应采用解析、修改、校验、写回的流程,而不是直接用 sed 替换可能出现在注释、字符串或其他层级中的相同文本。\n\n---\n\n## 十四、生产脚本中的验证、权限和恢复\n\n### 1. 输入验证\n\n不要假设输入格式永远正确:\n\n```bash\nawk -F: 'NF != 3 {\n print \"invalid record at line \" FNR > \"/dev/stderr\"\n bad=1\n next\n}\n{\n print $1, $3\n}\nEND {\n exit bad\n}' input\n```\n\n这个例子要求每行恰好有三个冒号分隔字段。异常行不会静默进入后续结果,并通过退出状态通知调用者。\n\n### 2. 权限和符号链接\n\n文本过滤通常只读,但 `sed -i`、重定向和 `mv` 会修改文件。需要检查:\n\n- 当前用户是否有目录写权限;\n- 目标是否为符号链接;\n- 目标文件是否有特殊属主或权限;\n- 临时文件是否会被其他用户读取;\n- 更新是否需要保留扩展属性或安全标签。\n\n对特权目录中的配置文件,不能仅凭命令成功退出就认为更新正确。至少应执行语法检查、差异检查和服务重新加载后的状态验证。\n\n### 3. 不要把未验证的文本当作 Shell 代码\n\n例如:\n\n```bash\nfor x in $(cat file); do\n ...\ndone\n```\n\n会发生命令替换结果的词拆分和通配符展开,无法安全表示任意文本。读取按行数据应使用:\n\n```bash\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\"\ndone \u003c file\n```\n\n处理任意文件名则优先使用 NUL 分隔接口:\n\n```bash\nwhile IFS= read -r -d '' path; do\n printf '%s\\n' \"$path\"\ndone \u003c \u003c(find . -type f -print0)\n```\n\n即使文本来自 `grep` 或 `awk`,也不应直接拼接后交给 `eval`。文本数据和 Shell 代码必须保持边界。\n\n---\n\n## 十五、如何选择这些工具\n\n可以按数据模型选择:\n\n- 只判断或筛选行:`grep`;\n- 对行做有限规则替换、删除、打印:`sed`;\n- 需要字段、条件、计数、聚合、格式化:`awk`;\n- 需要按位置或单字符分隔符取字段:`cut`;\n- 需要按字典、数字或字段排序:`sort`;\n- 需要合并相邻重复行或统计连续组:`uniq`;\n- 需要表达匹配规则:正则;\n- 需要处理 JSON、YAML、可靠 CSV 或嵌套结构:对应解析器;\n- 需要批量处理文件:`find` 配合 `-exec` 或 NUL 安全的 `xargs`。\n\n最容易被忽略的因果关系是:\n\n```text\nuniq 只能处理相邻重复\n │\n ▼\n要统计全局重复,必须先让相同值相邻\n │\n ▼\n通常使用 sort | uniq -c\n```\n\n同样:\n\n```text\ncut 只能按固定字段规则切分\n │\n ▼\n字段规则若包含引号、转义或嵌套结构\n │\n ▼\n切分模型失效,应改用解析器\n```\n\n掌握这些命令的关键,不是记住更多选项,而是先定义输入的记录边界、字段边界、匹配语义、排序关系和错误处理方式。只要这些条件明确,`grep`、`sed`、`awk`、`cut`、`sort`、`uniq` 组成的管道就能保持可解释、可验证,并在超出文本模型时及时让位给结构化工具。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维](https://wrblog.cn/articles/783307a4-e8a6-5d5a-9d9a-87ed9f345130)\n- 上一篇:[Linux find 与 xargs:条件、批处理、空字符、安全和并发执行](https://wrblog.cn/articles/e461ef81-c8af-5dd0-ad67-3de615cbe1c7)\n- 下一篇:[Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS](https://wrblog.cn/articles/995fd5b0-d269-5960-b7dc-42a0acae7b38)\n- 延伸:[命令行结构化数据:jq、yq、JSON、YAML、流式处理和安全更新](https://wrblog.cn/articles/2db8b9cb-7c2d-5c15-8fee-be10c6638bea)\n\n## 官方资料\n\n- [Linux man-pages](https://www.kernel.org/doc/man-pages/)\n- [Debian Administrator's Handbook](https://www.debian.org/doc/manuals/debian-handbook/)\n\n> 本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。\n","tags":["Linux","grep","awk","Shell"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:57:19","updateTime":"2026-09-01 13:57:19"}},"status":200,"locale":"zh-CN","theme":"light"}