Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.6 正则表达式与 grep/sed/awk

本章回答

BRE、ERE、PCRE 有什么区别?grep -P 什么时候用?sed 的 hold space 怎么理解?awk 的 BEGIN/END 与数组怎么用?面对一个文本任务,该选 grep、sed 还是 awk?

读完你会

写出不依赖猜测的正则;用 grep 做筛选与计数;用 sed 做流式替换与范围处理;用 awk 做字段统计与报表;判断什么时候该换 Python,而不是把正则写成怪物。

管道把数据交给下一个命令,接下来要回答的是:怎样描述你想要的文本。正则表达式是描述工具,grep、sed、awk 是三种执行模型:筛选、流编辑、记录处理。它们共享一部分语法,但方言与边界不同。

这一章不追求正则大全。目标是把三类任务分清:找(grep)、改(sed)、算(awk)。每个工具给五到八个真实任务,并说明失败模式。掌握这些之后,你写的命令会更短,也更容易被别人读懂。

所有示例在 Ubuntu 24.04 的 GNU grep 3.11、sed 4.9、gawk 5.2 上验证。macOS 的 BSD 工具参数不同,尤其是 sed -i 与 grep -P,脚本跨平台时要显式判断。

0.6.1 三种方言:BRE、ERE、PCRE

定义 0.6.1 · BRE / ERE / PCRE

BRE(basic regular expression)是 POSIX 的基础方言,+ ? | ( ) { } 默认是普通字符,要加反斜杠才有元字符含义。ERE(extended regular expression)把这些字符直接当元字符,grep -E、sed -E、awk 使用它。PCRE(Perl compatible regular expression)是 Perl 风格的扩展,支持 \d、\w、懒惰量词、环视等,grep -P 使用它,但不是 POSIX 标准。

特性BREEREPCRE
分组\(ab\)(ab)(ab)
或|(反斜杠 + 竖线)竖线竖线
加号\+(GNU 扩展)++
问号\?(GNU 扩展)??
花括号\{2,3\}{2,3}{2,3}
反向引用\1\1(GNU 支持)\1
懒惰量词不支持不支持.*?
字符类简写不支持不支持\d、\w、\s
环视不支持不支持(?=...)、(?!...)

同一件事在三种方言里的写法:

# 匹配 2024-10-17 这样的日期
grep    '^[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}$' dates.txt   # BRE
grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'       dates.txt   # ERE
grep -P '^\d{4}-\d{2}-\d{2}$'                dates.txt   # PCRE

BRE 里 + 是普通加号。写 grep 'a+' 会匹配字面 a+,不是「一个或多个 a」。这是新手最常见的正则错误;看到结果不对,先确认方言,再怀疑表达式。

POSIX 字符类用 [[:name:]],比 \d/\w 可移植:

类含义注意
[[:digit:]]数字受 locale 影响
[[:alpha:]]字母中文 locale 下可能包含非 ASCII
[[:alnum:]]字母数字
[[:space:]]空白(空格、tab、换行)
[[:punct:]]标点
[[:xdigit:]]十六进制数字

写日志解析时,把 LC_ALL=C 放在命令前,能让字符类按字节处理、速度更快,也避免 locale 导致的意外匹配:

LC_ALL=C grep -E '^[0-9]{4}-' app.log

grep -P 依赖 libpcre,不是所有系统都编进去。Ubuntu 的 GNU grep 通常支持;Alpine 的 busybox grep 不支持。脚本里用 grep -P 前先探测,或改用 -E 加 POSIX 写法。

0.6.2 语法骨架:锚点、字符类、量词、分组

先把最小骨架记住:

语法含义例子
^行首^ERROR
$行尾done$
.任意单个字符(不含换行)a.c 匹配 abc、a-c
[abc]字符集合[0-9a-f]
[^abc]排除集合[^0-9]
*前一项 0 次或多次ab*c
+前一项 1 次或多次(ERE)ab+c
?前一项 0 或 1 次(ERE)colou?r
{m,n}前一项 m 到 n 次(ERE)[0-9]{2,4}
|(BRE)或竖线(ERE)或cat 或 dog
(...)分组(ab)+
\1反向引用第 1 组(ab)\1 匹配 abab
\b单词边界(GNU/PCRE)\broot\b

贪婪是默认行为:.* 会尽可能多地匹配。<b>a</b> <b>b</b> 用 <b>.*</b> 会一次匹配整行;要只匹配第一个标签,用 <b>[^<]*</b> 或 PCRE 的 <b>.*?</b>。

printf '<b>a</b> <b>b</b>\n' | grep -oE '<b>.*</b>'    # 整段
printf '<b>a</b> <b>b</b>\n' | grep -oP '<b>.*?</b>'   # 两个标签
printf '<b>a</b> <b>b</b>\n' | grep -oE '<b>[^<]*</b>' # 两个标签,ERE

ERE 没有懒惰量词,用「排除字符类」代替 .*? 是更可移植的写法。只有确实需要环视或懒惰时才上 PCRE。

Shell 与正则有两层转义,容易叠加出错:

# 错误:shell 把 \. 变成 .,正则里 . 是任意字符
grep "1\.2\.3\.4" app.log
# 正确:单引号保护反斜杠
grep '1\.2\.3\.4' app.log
# 或者用字符类,避免反斜杠
grep '1[.]2[.]3[.]4' app.log

单引号里所有字符原样传给 grep;双引号里 \ 仍可能被 Shell 处理。写正则默认用单引号,除非要插入变量。

几个常见但不完美的模式,使用时要知道边界:

目标模式边界
IPv4^([0-9]{1,3}\.){3}[0-9]{1,3}$不校验 0–255,999.999.999.999 也过
日期^[0-9]{4}-[0-9]{2}-[0-9]{2}$不校验月份 13、2 月 30
邮箱^[^@[:space:]]+@[^@[:space:]]+$只做粗筛,不是 RFC 解析
日志级别`^(DEBUGINFO

正则适合「筛」,不适合「解析」。IPv4 的严格校验、CSV 的引号、HTML 的嵌套,都应该交给专门的解析器。把正则写到几百字符,通常说明选错工具了。

0.6.3 grep:筛选与计数

grep 的模型是「逐行匹配,输出命中的行」。常用选项:

选项作用典型场景
-E使用 ERE`grep -E ’error
-F固定字符串,不解释正则搜索含 .、[ 的文本
-P使用 PCRE需要 \d、懒惰、环视
-i忽略大小写
-w全词匹配grep -w root 不匹配 rootfs
-x整行匹配
-r / -R递归目录 / 跟随符号链接搜代码库
-l只输出文件名找哪些文件包含
-o只输出匹配部分提取 IP、状态码
-c计数行数统计命中条数
-n行号定位
-A/-B/-C n后/前/上下 n 行看异常上下文
-q静默,只看退出码脚本判断
--include/--exclude限定文件只搜 *.conf

grep 的退出码有三种:0 有匹配,1 无匹配,2 出错(文件不存在、正则非法)。脚本里 if grep -q ...; then 用的是这个约定;不要用 $? -ne 0 把「无匹配」和「出错」混在一起。

一组真实任务:

# 1. 递归搜配置,跳过 .git 与 node_modules
grep -rn --include='*.conf' --exclude-dir=.git --exclude-dir=node_modules 'listen' /srv

# 2. 找哪些文件引用了旧域名(只输出文件名)
grep -rl 'old.example.com' /srv/www

# 3. 提取日志里的状态码并计数
grep -oE ' (200|301|404|500) ' access.log | sort | uniq -c | sort -nr

# 4. 看异常前后 3 行
grep -n -C3 'Traceback' app.log

# 5. 固定字符串搜索,避免正则元字符
grep -F '[error]' app.log

# 6. 只判断是否存在,不输出
if grep -q '^PermitRootLogin yes' /etc/ssh/sshd_config; then
    echo "root 登录被允许"
fi

grep -r 默认会跳过二进制文件,输出 Binary file ... matches。要强制按文本处理,用 -a;要列出二进制文件名,用 -l。搜 /proc、/sys 时还会遇到「文件读不完」或权限错误,用 2>/dev/null 过滤,但要知道你屏蔽了什么。

性能上,-F 比正则快,LC_ALL=C 比 UTF-8 locale 快,-l 在找到第一个匹配后就能停止读文件。大日志上,先 grep -F 粗筛,再 awk 精算,比一上来写复杂正则更稳。

0.6.4 sed:流编辑器的执行模型

sed 按行循环:读一行到模式空间(pattern space),去掉换行;依次执行命令;除非 -n,自动打印模式空间并补换行;读下一行。理解这个循环,才能理解 -n、p、d 的组合。

命令作用
p打印模式空间(常配 -n)
d删除模式空间,直接进入下一轮
s/re/rep/flags替换
y/abc/xyz/字符一一映射
a\text / i\text在行后/行前插入文本
c\text替换整行
=打印行号
q退出

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

地址例子含义
行号3d删第 3 行
最后一行$d删最后一行
正则/^#/d删注释行
范围2,5p第 2 到 5 行
正则范围/BEGIN/,/END/p从匹配 BEGIN 到匹配 END
步长1~2p奇数行(GNU 扩展)
取反/^#/!p非注释行

最常用的三类任务:

# 1. 删注释与空行(先备份)
sed -i.bak -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' app.conf

# 2. 替换并保留备份;& 代表整个匹配
sed -i.bak 's/^listen 80;/listen 8080;/' /etc/nginx/sites-enabled/default

# 3. 只打印匹配范围
sed -n '/^\[database\]/,/^\[/p' app.ini

-i 是原地编辑。GNU sed 的 -i.bak 会先写 file.bak 再改原文件;BSD/macOS 的 -i 必须带参数(-i '')。这是脚本跨平台最常见的差异之一。

替换的 flags:

flag作用
g一行内替换所有匹配
p打印发生替换的行(配 -n)
I / i忽略大小写(GNU)
n只替换第 n 个匹配
e把替换结果当命令执行(危险,少用)

分组与反向引用:

# 把 2024-10-17 改成 17/10/2024
sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/\3\/\2\/\1/' dates.txt
# 把 key = value 改成 export key=value
sed -E 's/^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$/export \1=\2/' app.env

替换字符串里的 / 需要转义;也可以换分隔符,改成 s|/old/path|/new/path|,可读性更好。替换内容里有 & 时要写成 \&,否则它代表整个匹配。

sed 不适合做结构化计算。要按列求和、分组计数、连接两个文件,用 awk;要解析 JSON/CSV,用专门工具。

0.6.5 hold space:sed 的第二个缓冲区

sed 除了模式空间,还有一个保持空间(hold space)。默认是空的,命令在两者之间搬运:

命令作用
h模式空间 → 保持空间(覆盖)
H模式空间 → 保持空间(追加,带换行)
g保持空间 → 模式空间(覆盖)
G保持空间 → 模式空间(追加,带换行)
x交换两者

一个最小但完整的例子:倒序输出文件。

sed -n '1!G;h;$p' file.txt

逐步拆:

行命令模式空间保持空间
第 1 行 A1!G 跳过;hAA
第 2 行 BG 追加保持空间B\nAA
第 2 行 Bh 覆盖保持空间B\nAB\nA
第 3 行 CGC\nB\nAB\nA
第 3 行 ChC\nB\nAC\nB\nA
最后一行$pC\nB\nAC\nB\nA

1!G 的意思是「不是第 1 行才执行 G」;第 1 行没有内容可追加,直接 h。最后 $p 打印累积结果。等价的命令是 tac,但这个例子展示了 hold space 的用法:把「之前所有行」累积在另一个缓冲区里。

hold space 还能做「合并段落」:

# 把空行分隔的段落合并成一行
sed -n '1h;1!H;${g;s/\n/ /g;p}' file.txt

逻辑是:第一行覆盖到 hold,后续行追加到 hold,最后一行把 hold 取回模式空间,把换行换成空格,打印。实际工作中,这类任务用 awk 'BEGIN{RS=""; ORS="\n"}{gsub(/\n/," "); print}' 更直观。hold space 适合「sed 已经很熟了,想少起一个进程」的场景,不是必选项。

n、N、P、D 处理「一条记录跨多行」,与 hold space 解决的不是同一类问题:

命令作用
n打印当前模式空间(除非 -n),读下一行替换它,继续执行后续命令
N读下一行追加到模式空间并保留换行;不打印,继续执行
P打印模式空间到第一个换行(常配 -n)
D删除模式空间到第一个换行;若仍有内容,从脚本开头重新执行,不读新行
# 每行后加一个空行(加倍行距)
sed 'G' file.txt

# 文件里已有空行时,先删空行再加,避免出现连续空行
sed '/^[[:space:]]*$/d; G' file.txt

# 每两行合并成一行;$!N 保证最后一行不被 N 吞掉
sed '$!N; s/\n/ /' file.txt

G 把 hold space(默认空)追加到模式空间,等价于「每行后加一个换行」;N 把下一行读进来,让 s/\n/ / 能跨行替换。D 常与 N 配合做多行循环,但可读性差,超过两三行就换 awk 或 Python。sed 还有 :label、b label、t label 做分支:b 无条件跳,t 只在上一次 s/// 成功时跳;日常任务用 awk 更清楚。

0.6.6 awk:记录、字段与报表

awk 把输入看成记录(默认一行一条),记录按字段分隔符切成字段。$0 是整条记录,$1 到 $NF 是字段,NF 是字段数,NR 是全局记录号,FNR 是当前文件的记录号。

变量含义默认
FS输入字段分隔符空白(空格/tab,且压缩连续空白)
OFS输出字段分隔符空格
RS输入记录分隔符换行
ORS输出记录分隔符换行
NF当前记录字段数运行时
NR已读记录总数运行时
FNR当前文件的记录号运行时

语法是 pattern { action },BEGIN 在处理输入前执行,END 在读完所有输入后执行:

BEGIN { FS = ","; OFS = "\t"; print "name", "amount" }
$3 > 100 { print $1, $3; sum += $3; n++ }
END { printf "rows=%d sum=%.2f\n", n, sum }

命令行里用 -F 指定分隔符,用 -v 传变量:

awk -F, -v min=100 '$3 > min { print $1, $3 }' sales.csv

几个真实任务:

# 1. 按状态码计数,从高到低
awk '{count[$9]++} END {for (s in count) print count[s], s}' access.log | sort -nr

# 2. 计算 5xx 比例
awk '$9 ~ /^5/ {bad++} {all++} END {printf "5xx=%d total=%d rate=%.2f%%\n", bad, all, 100*bad/all}' access.log

# 3. 按第一列求和
awk '{sum[$1] += $2} END {for (k in sum) printf "%s\t%.2f\n", k, sum[k]}' data.txt

# 4. 去重但保留首次出现顺序(awk 数组无序,顺序由输入决定)
awk '!seen[$0]++' file.txt

# 5. 连接两个文件:NR==FNR 时读第一个文件
awk 'NR==FNR {name[$1]=$2; next} {print $0, name[$1]}' users.txt orders.txt

!seen[$0]++ 是 awk 的惯用法:seen[$0] 未定义时按 0 处理,!0 为真,打印;后缀 ++ 把它加一。之后同一行再出现,!1 为假,跳过。

awk 的数组是关联数组,遍历顺序不保证。需要排序时,把输出交给 sort,或在 gawk 里用 asort/asorti(非 POSIX)。不要假设 for (k in arr) 按插入顺序。

printf 的格式与 C 类似:%s 字符串,%d 整数,%.2f 两位小数,%10s 右对齐宽度 10。awk 的数值转换是自动的,"10" + 1 得 11;但前导零会被当成八进制(gawk 的 --characters-as-bytes/strtonum 有细节),解析编号时用 $3 + 0 明确转数值。

CSV 是 awk 的边界。-F, 只适合「字段内没有逗号」的简单 CSV;带引号、转义、换行的 CSV 要用 Python 的 csv 模块或 mlr/xsv。用 awk 硬解析带引号的 CSV,迟早会在某个字段上出错。

0.6.7 三者怎么选

任务首选理由
找包含某模式的行/文件grep最快,选项直接
计数、去重、排序grep/awk + sort/uniq管道组合简单
流式替换、删行、按范围打印sed语法短,适合一行流
按列统计、分组、报表awk字段与数组内建
连接两个文本表awk NR==FNR 或 join小文件够用
CSV/JSON/XML 解析Python / jq / mlr正则与 split 不够
复杂控制流、库、测试Python可维护性

一条经验:能用 grep 解决就不要上 sed,能用 sed 解决就不要上 awk,需要状态和数组时才用 awk,需要解析结构时换语言。 这不是教条,而是让命令的意图一目了然。

性能上,grep 的固定字符串最快;awk 在大文件上比「grep + cut + sort」多次 fork 更省;sed 的 -i 会重写整个文件,大文件上要先确认磁盘空间。真正的性能瓶颈通常在 I/O,不在正则引擎;先用 LC_ALL=C、-F、减少管道段数,再考虑换工具。

0.6.8 实战:日志统计与批量改名

假设有一份 nginx combined 日志,字段是:$1 客户端 IP,$7 路径,$9 状态码。

$ printf 'GET / 200\nGET /a 200\nPOST /login 401\nGET /x 500\n' > /tmp/access.log
$ grep -E ' (4|5)[0-9]{2}$' /tmp/access.log
POST /login 401
GET /x 500
$ grep -oE ' (4|5)[0-9]{2}$' /tmp/access.log
 401
 500
$ awk '{count[$3]++} END {for (s in count) print count[s], s}' /tmp/access.log | sort -nr
2 200
1 401
1 500
$ sed -n 's#^GET /x #path=#p' /tmp/access.log
path=500

这段会话把三种工具的分工摆在一起:grep -E 负责筛出 4xx/5xx 行,grep -oE 只保留状态码;awk 用数组按状态码计数;sed -n 只对匹配行做替换并打印。每一步都可以单独验证,组合起来才不容易错。

# Top 10 客户端 IP
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head -10

# 5xx 比例
awk '$9 ~ /^5/ {bad++} {all++}
     END {printf "5xx=%d total=%d rate=%.2f%%\n", bad, all, 100*bad/all}' access.log

# 每个路径的平均响应大小($10 是 body_bytes_sent)
awk '{sum[$7] += $10; n[$7]++}
     END {for (p in sum) printf "%10.0f %6d %s\n", sum[p]/n[p], n[p], p}' access.log \
  | sort -nr | head -20

第二个例子里,{all++} 没有模式,表示「每一行都执行」;$9 ~ /^5/ 只对 5xx 行执行 bad++。awk 的模式-动作可以写在同一行,也可以换行,只要 { 与前面的模式在同一逻辑行。

批量改名用 sed 生成新名字,注意 -- 与引号:

#!/usr/bin/env bash
set -euo pipefail

# 把当前目录的 .JPG 改成 .jpg;先 dry-run
for f in *.JPG; do
    [[ -e $f ]] || continue
    new=$(printf '%s\n' "$f" | sed 's/\.JPG$/.jpg/')
    printf 'mv -- %q %q\n' "$f" "$new"
done

# 确认后去掉 printf 的 dry-run,执行:
# mv -- "$f" "$new"

printf '%q' 会转义文件名里的特殊字符,便于肉眼检查。[[ -e $f ]] || continue 处理「没有匹配时通配符原样保留」的情况——在 nullglob 未开启时,*.JPG 没匹配会变成字面量 *.JPG。

关于正则与文本工具的五个误解
  1. 「grep 'a+' 匹配一个或多个 a」——BRE 里 + 是普通字符,匹配字面 a+;要用 grep -E 'a+' 或 grep 'a\+'。
  2. 「grep -P 到处都能用」——它依赖 PCRE 支持,busybox、部分精简系统没有;脚本要探测或改用 ERE。
  3. 「sed -i 的用法在 Linux 和 macOS 上一样」——GNU sed 的 -i 可选备份后缀,BSD sed 必须给参数(-i '')。跨平台脚本要分支处理。
  4. 「.* 能匹配换行」——默认逐行处理,. 不匹配换行;跨行要用 sed 的 N/D、awk 的 RS 或 PCRE 的 (?s)。
  5. 「awk 数组按插入顺序遍历」——awk 数组是关联数组,顺序未定义;要排序就输出给 sort。

0.6.9 正则引擎:从左最短到回溯

同一个正则在不同引擎里可能给出不同结果。差别不在语法表,而在匹配策略:POSIX 引擎要求「最左最长」,Perl 风格引擎用回溯取「最左优先」。写日志解析时,这个差别会决定你拿到的是整个标签还是第一个标签。

维度POSIX BRE/ERE(grep -E、sed、awk)PCRE(grep -P)
匹配策略最左最长(leftmost-longest)最左优先 + 回溯(leftmost-first)
量词贪婪,无懒惰贪婪/懒惰 *?、+?、??
分组捕获\1…\9\1…,命名组 (?P<name>...)
环视不支持(?=)、(?!)、(?<=)、(?<!)
字符类简写[[:digit:]]\d、\w、\s
回溯灾难一般不会(DFA/NFA 混合)可能,(a+)+$ 是经典

「最左最长」的意思是:在同一个起始位置,引擎选择能匹配的最长结果。grep -oE '<b>.*</b>' 对 <b>a</b> <b>b</b> 会一次匹配整段,因为 .* 可以吃到最后一个 </b>;PCRE 的 .*? 则只吃到第一个 </b>。这不是 bug,是两种语义。

回溯灾难出现在 PCRE 的嵌套量词上:

# 在长串 a 后跟一个 ! 时,回溯次数指数增长
printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' | grep -P '(a+)+$'

PCRE2 有 match limit,超过后返回错误(grep 退出码 2),但在此之前 CPU 已经被吃掉。防御方式是改写正则,避免嵌套量词:a+$ 就够了;或者用 grep -E/awk 做线性匹配,把复杂解析交给真正的解析器。

LC_ALL 影响字符类与排序:

LC_ALL=C grep -c '[[:alpha:]]' file      # 只认 ASCII 字母
LC_ALL=en_US.UTF-8 grep -c '[[:alpha:]]' file  # 可能把重音字母也算进去
LC_ALL=C sort file                       # 按字节排序,稳定可复现

[a-z] 在非 C locale 下不一定等价于 26 个 ASCII 字母;某些 locale 的 collation 范围会包含重音字符。日志、配置、协议字段一律用 LC_ALL=C,既快又可复现。中文文本处理才需要 UTF-8 locale,但那时 grep -o 的「字符」语义也依赖 locale,跨平台脚本要显式设置。

GNU grep 的实现是 DFA 做快速扫描,遇到反向引用等复杂构造才回退到回溯;grep -E 通常比 grep -P 快,grep -F 更快(Boyer-Moore/哈希)。选择顺序:固定字符串 -F → 简单模式 -E → 需要环视/懒惰才 -P。

0.6.10 grep 的完整选项表与退出码

grep 的选项按「选行、选文件、选输出、选性能」四类记,比死记字母表有用。

选行

选项作用边界
-E / -G / -F / -PERE / BRE / 固定串 / PCRE-P 依赖 PCRE2,busybox 没有
-i忽略大小写受 locale 影响
-v反向选择与 -o 组合时 -o 无输出
-w全词匹配词边界由字母数字下划线定义
-x整行匹配等价于 ^(...)$,但不受换行影响
-e PATTERN多个模式可重复;模式以 - 开头时必需
-f FILE从文件读模式一行一个模式;空行匹配一切
-m NUM每个文件最多 NUM 个匹配与 -c 组合时计数被截断

选文件与递归

选项作用边界
-r递归目录只跟随命令行给出的符号链接
-R递归并跟随所有符号链接可能绕进 /proc 或环
--include=GLOB只搜匹配的文件作用于 basename
--exclude=GLOB排除文件可重复
--exclude-dir=DIR排除目录搜代码库必备
-l / -L列出有/无匹配的文件找到第一个匹配就停止读
-H / -h强制/禁止文件名前缀多文件默认 -H
-a / -I二进制当文本 / 跳过二进制默认输出 Binary file ... matches
--null / -Z文件名后用 NUL配 xargs -0

选输出

选项作用边界
-n行号从 1 开始
-b字节偏移与 -o 组合定位匹配
-o只输出匹配部分空匹配可能输出空行
-c计数行不是匹配次数;要匹配数用 `grep -o …
-A/-B/-C N后/前/上下 N 行上下文之间用 -- 分隔
--group-separator=SEP自定义分隔多文件时也有文件名头
-q静默找到即退出,可能触发上游 SIGPIPE
-s抑制错误消息不改变退出码
--line-buffered行缓冲输出只影响输出,不影响读
--color=auto/always/never颜色always 配 less -R

退出码与性能

码含义
0至少一行匹配
1没有匹配
2出错(文件不存在、正则非法、-P 不支持)

脚本里判断「有没有」用 if grep -q ...; then;判断「出错」要单独看 $? == 2。grep -q 找到第一个匹配就退出,上游还在写时会收到 SIGPIPE,set -o pipefail 下要接受 141。

# 递归搜配置,排除版本库与依赖
grep -rn --include='*.conf' --exclude-dir=.git --exclude-dir=node_modules 'listen' /srv

# 提取状态码并计数
grep -oE ' (200|301|404|500) ' access.log | sort | uniq -c | sort -nr

# 带上下文看异常
grep -n -C3 'Traceback' app.log

# 文件名含空格:-Z 交给 xargs -0
grep -rlZ 'old.example.com' /srv/www | xargs -0 -r sed -i 's/old\.example\.com/new.example.com/g'

性能顺序通常是 -F > -E > -P,LC_ALL=C 再快一截;-l/-q 能在第一个匹配后停止,比 -n 扫全文件快。大日志先 grep -F 粗筛,再 awk 精算,不要一上来写复杂正则。

grep -r 会读到 /proc、/sys 里读不完的文件,表现为卡住或 No such device;用 --exclude-dir 或 find -xdev 限定范围。grep -R 跟随符号链接,可能绕进环,脚本里优先 -r。

0.6.11 sed 的执行模型与命令全表

sed 的循环只有四步:

  1. 从输入读一行到模式空间(pattern space),去掉行尾换行;
  2. 按脚本顺序执行命令,地址匹配才执行;
  3. 除非 -n,自动打印模式空间并补换行;
  4. 读下一行,重复。

-n 关闭第 3 步;p 手动打印;d 删除模式空间并直接进入下一轮(不自动打印);D 删除到第一个换行,若还有内容则从脚本开头重新执行,不读新行。理解这四条,sed -n '1!G;h;$p' 这类写法就能逐步推出来。

地址

地址例子含义
行号3d第 3 行
最后一行$d最后一行
正则/^#/d匹配行
自定义分隔\#^/etc#d模式里含 / 时换分隔符
范围2,5p第 2 到 5 行
正则范围/BEGIN/,/END/p从匹配 BEGIN 到匹配 END
步长1~2p奇数行(GNU)
相对范围/start/,+3p匹配行及其后 3 行(GNU)
到 Nth/start/,~5p到下一个 5 的倍数行(GNU)
从 0 开始0,/re/p从第一行到第一次匹配(GNU)
取反/^#/!p非注释行
组合1,10{/foo/d}1–10 行中包含 foo 的删掉

0,/re/ 解决「第一次匹配在第一行时,/re/,/re/ 不会进入范围」的问题;1~2 是 GNU 扩展,BSD sed 不支持。地址范围从匹配行开始,到下一次匹配行结束;如果结束模式从未匹配,范围一直到文件尾。

命令

命令作用备注
s/re/rep/flags替换flags:g、p、n、i/I、m/M、e、w file
y/abc/xyz/字符一一映射长度必须相同
p / P打印模式空间 / 到第一个换行P 用于多行
d / D删除 / 删除到第一个换行并重启D 不读新行
n / N读下一行替换 / 追加N 保留换行
h/H/g/G/x模式空间与保持空间搬运见下一节
a\text / i\text行后/行前插入GNU 支持一行写法
c\text替换整行范围匹配时只输出一次
=打印行号与 -n 搭配
l [n]显示不可见字符$ 表示行尾,\t 表示 tab
q [rc] / Q退出 / 不打印退出q 会打印当前行
r file / R file读文件 / 读一行追加到输出
w file / W file写文件 / 写第一行每次打开/截断
:label / b label / t label / T label标签、跳转、测试POSIX 标签 ≤7 字符
{ ... }命令组可配地址
z清空模式空间(GNU)少用
F打印当前文件名(GNU)多文件调试

最常用的三类任务:

# 删注释与空行,先备份
sed -i.bak -E '/^[[:space:]]*#/d; /^[[:space:]]*$/d' app.conf

# 只替换第 2 个匹配
sed -E 's/old/new/2' file

# 只打印 [database] 段
sed -n '/^\[database\]/,/^\[/p' app.ini

-i 是原地编辑。GNU sed 的 -i.bak 先写临时文件再 rename,原文件的 inode 会变(除非用 -i 加 --follow-symlinks);BSD/macOS 的 -i 必须带参数(-i '')。sed -i 会保留原文件的 mode/owner(GNU 会尽量复制),但硬链接关系会断:另一个名字仍指向旧 inode。需要保留硬链接时,改写临时文件再 cat 回去,或接受断链。

s 的替换串里,& 代表整个匹配,\1…\9 是分组,\L/\U/\E 是 GNU 的大小写转换。替换串里的 / 可以换分隔符(s|/old|/new|),但换行、&、反斜杠仍要转义。e flag 把替换结果当命令执行,等于给 sed 开了 shell,绝不要在不可信输入上用。

0.6.12 sed 进阶:hold space、分支与循环

保持空间(hold space)是第二个缓冲区,初始为空。五个搬运命令:

命令方向覆盖/追加
h模式 → 保持覆盖
H模式 → 保持追加(带换行)
g保持 → 模式覆盖
G保持 → 模式追加(带换行)
x交换—

倒序输出是标准例子:

sed -n '1!G;h;$p' file.txt

逐步走:

行命令模式空间保持空间
第 1 行 A1!G 跳过;hAA
第 2 行 BG 追加保持B\nAA
第 2 行 Bh 覆盖保持B\nAB\nA
第 3 行 CGC\nB\nAB\nA
第 3 行 ChC\nB\nAC\nB\nA
最后一行$pC\nB\nAC\nB\nA

1!G 是「不是第一行才 G」;第一行没有内容可追加,直接 h。tac 做同样的事,但这个例子展示了「把之前所有行累积到另一个缓冲区」的思路。

合并空行分隔的段落:

sed -n '1h;1!H;${g;s/\n/ /g;p}' file.txt

第一行覆盖到保持空间,后续追加,最后一行取回、把换行换成空格、打印。等价的 awk 写法更直观:

awk 'BEGIN{RS=""; ORS="\n"} {gsub(/\n/, " "); print}' file.txt

n、N、P、D 处理「一条记录跨多行」,与 hold space 解决的不是同一类问题:

命令对模式空间的影响是否读新行
n打印(除非 -n)后替换为下一行是
N追加下一行,保留换行是
P打印到第一个换行否
D删除到第一个换行;非空则重启脚本否
# 每行后加空行
sed 'G' file.txt
# 先删已有空行,避免连续空行
sed '/^[[:space:]]*$/d; G' file.txt
# 每两行合并;$!N 防止最后一行被 N 吞掉
sed '$!N; s/\n/ /' file.txt

$!N 是「不是最后一行才 N」;如果最后一行也 N,模式空间没有下一行,GNU sed 会打印并退出,结果仍可用,但显式 $! 更清楚。

分支让 sed 具备条件执行:

# 删除第一个逗号并打印,直到没有逗号;必须有 /,/b 终止条件
printf 'a,b,c\n' | sed -n ':loop; s/,//1p; /,/b loop'

# t 只在最近的 s 成功时跳转,等价于 if/else
sed '{s/^#/COMMENT:/; t done; s/^/LINE:/; :done}' file

b label 无条件跳转,t label 在上一次 s/// 成功时跳转,T label(GNU)在失败时跳转。标签以 : 开头,POSIX 限制 7 个字符;GNU 允许更长,但脚本跨平台时按 7 个字符写。分支写不好会死循环:s/,//; b loop 没有终止条件,sed 会一直跑到 Ctrl-C;加 /re/b 或 t 的隐式条件。

D 与 N 配合可以做「多行记录」:

# 把以反斜杠结尾的续行合并成一行
sed ':a; /\\$/N; s/\\\n//; ta' file.txt

逻辑:标签 a;如果行以 \ 结尾,N 读下一行;把 \ + 换行替换掉;t a 在替换成功时跳回。ta 不带标签等价于「跳到脚本结尾」,这里带 a 才是循环。

sed 不是通用编程语言。超过两三个分支、需要数组或浮点时,换 awk/Python;sed 的优势是「一次遍历、无状态、启动快」。

0.6.13 awk 的程序模型:记录、字段与变量

awk 把输入切成记录(默认一行一条),记录按 FS 切成字段。$0 是整条记录,$1…$NF 是字段,NF 是字段数,NR 是全局记录号,FNR 是当前文件的记录号。

pattern { action }
BEGIN { ... }        # 读输入前执行一次
END { ... }          # 读完所有输入后执行一次

BEGIN 里设 FS/OFS 最稳;命令行 -F 是 FS 的快捷方式;-v var=value 在 BEGIN 之前赋值,而 awk '{...}' var=value file 形式的赋值在 BEGIN 之后才生效:

awk -F, -v min=100 '$3 > min { print $1, $3 }' sales.csv
# 下面这个 min 在 BEGIN 里是空
awk 'BEGIN{print "min=" min} {print $3}' min=100 sales.csv

BEGIN 里能用 -v 的值,不能用「文件名前的赋值」。这个差别在写带默认值的脚本时很关键。

变量含义默认/注意
FS输入字段分隔符空白,且压缩连续空白
OFS输出字段分隔符空格
RS输入记录分隔符换行;RS="" 是段落模式
ORS输出记录分隔符换行
NF当前字段数修改它会重建 $0
NR已读记录总数跨文件累计
FNR当前文件记录号每个文件重置
FILENAME当前文件名- 表示 stdin
ARGIND当前文件在 ARGV 中的下标gawk
ARGC/ARGV参数个数/数组ARGV[0] 是 awk
ENVIRON环境变量关联数组ENVIRON["HOME"]
RSTART/RLENGTHmatch() 的起点/长度未匹配时 0/-1
SUBSEP多维下标分隔符默认 \034
CONVFMT/OFMT数字转字符串格式默认 %.6g
IGNORECASE忽略大小写gawk 扩展

NF 可以改:$0=$0 会按当前 FS 重建字段;NF=3 会截断字段并重建 $0。这在「只保留前几列」时有用,但不要依赖它做复杂改写。

RS 不只是换行:

# 段落模式:空行分隔记录,FS 默认换行
awk 'BEGIN{RS=""; FS="\n"} {print NR": "$1}' file

# 自定义记录分隔符
awk 'BEGIN{RS=";"} {gsub(/\n/," "); print}' file

# gawk:RS 可以是正则
gawk 'BEGIN{RS="[0-9]+"} {print}' file

FIELDWIDTHS(gawk)按固定列宽切字段,适合无分隔符的报表:

gawk 'BEGIN{FIELDWIDTHS="3 5 2 5"} {print $1, $3}' data

FPAT(gawk 4+)按「字段长什么样」切分,而不是按分隔符,适合带引号的 CSV 的简化解析:

gawk 'BEGIN{FPAT="([^,]*)|(\"[^\"]*\")"} {print $1}' data.csv

FPAT 仍不处理字段内换行、转义引号;真正的 CSV 用 Python csv 或 mlr --csv。FIELDWIDTHS 与 FS 互斥,设置后 FS 被忽略;FPAT 与 FS 也互斥,后设置的生效。

print 用 OFS 连接参数,printf 不经过 OFS。print $1, $2 与 print $1 $2 的区别是前者插入 OFS,后者直接拼接。printf 的格式与 C 类似:%s、%d、%i、%f、%.2f、%10s、%-10s、%x、%o、%c、%%。%d 会截断小数,%.0f 会按二进制浮点舍入(1.005 可能打成 1.00),金额计算用整数分或 bc。

next 跳过当前记录余下规则,nextfile 跳到下一个文件(gawk/BSD awk),exit [code] 跳到 END 并退出。getline 的三种形式语义不同:

形式读自更新 NR/FNR失败
getline主输入下一行是返回 0/EOF,-1 出错
getline var主输入下一行到 var是同上
getline < file文件否返回 0/EOF,-1 出错
cmd | getline命令输出否返回 0/EOF,-1 出错

getline 返回值必须检查:0 是 EOF,-1 是错误(ERRNO 有原因),正数是读到的记录长度?gawk 返回 1 表示成功。不要写 while (getline line) 而不处理 -1,否则错误会被当成 EOF。

0.6.14 awk 数组、内建函数与自定义函数

awk 的数组是关联数组:下标是字符串(数字会被转成字符串),不需要连续,也不能假设遍历顺序。

{ count[$1]++ }                     # 按第一列计数
END { for (k in count) print k, count[k] }

for (k in a) 的顺序由哈希实现决定;要排序,输出给 sort,或用 gawk 的 PROCINFO["sorted_in"] / asort / asorti。delete a[k] 删一个元素;delete a 清空整个数组是 gawk 扩展,POSIX 只保证 delete a[k],跨平台用 split("", a)。

多维数组用 a[i,j],实际键是 i SUBSEP j;(i,j) in a 判断存在。SUBSEP 默认是 \034,如果键里可能包含它,显式改 SUBSEP。

!seen[$0]++ 是去重惯用法:未定义按 0,!0 真,打印;后缀 ++ 加一。它保留首次出现顺序(按输入顺序),但不排序。

内建函数分四类:

类别函数备注
字符串length、substr(s,i,n)、index(s,t)、split(s,a[,r])、sub(r,s[,t])、gsub、match(s,r[,a])、sprintf、tolower、toupper、gensub(gawk)下标 1 起;sub/gsub 返回替换次数
数学int、sqrt、exp、log、sin、cos、atan2、rand、srand只有双精度浮点
时间systime()、strftime(fmt[,ts])、mktime("YYYY MM DD HH MM SS")gawk;时间戳是 epoch 秒
位运算and、or、xor、compl、lshift、rshiftgawk

substr 的第三个参数省略时取到末尾;substr(s, 0, 2) 的行为在 gawk 与 POSIX 间有差异,按 1 起写。split 的第三个参数可以是正则;不传时用 FS。match 设置 RSTART/RLENGTH,第三个参数(gawk)把捕获组放进数组。

自定义函数:

function pct(part, total,    p) {     # 参数列表里的空位是局部变量
    if (total == 0) return 0
    p = 100 * part / total
    return p
}
{ bad += ($9 ~ /^5/); all++ }
END { printf "5xx=%.2f%%\n", pct(bad, all) }

awk 函数没有 local 关键字:参数列表里多写的变量(用空格与实参隔开)就是局部变量;函数内其他变量是全局的。数组按引用传递,标量按值传递;要「返回数组」,把数组作为参数传入,函数里填充。递归可以,但没有尾调用优化,深递归会栈溢出。

printf 的 %d 对负数向零截断,int() 也向零截断(int(-5.6) == -5),不是 floor。金额、百分比用整数或 sprintf("%.2f"),不要依赖浮点相等比较。

awk 的字符串/数字比较有隐式转换:"10" < "9" 是字符串比较(真),10 < 9 是数值比较(假);字段 $1 如果看起来像数字,比较时按数值处理。解析编号时用 $1 + 0 强制数值,或统一用 -v 传入数字。

0.6.15 sort、uniq、cut、tr、paste、join、diff 速查

这些工具都按「行/字节」工作,不理解 CSV、JSON、引号。把它们的边界记住,比背选项重要。

sort

选项作用注意
-n数值排序只认行首/键开头的数字
-g通用数值(科学计数)比 -n 慢
-h人类可读(K/M/G)GNU
-V版本号排序1.10 > 1.9
-r降序
-k Start[,End]键范围-k2,2 只按第 2 字段
-t SEP字段分隔符默认空白
-u去重依据整个键
-s稳定排序禁用最后的整行比较
-f忽略大小写受 locale 影响
-b忽略前导空白
-o FILE输出到文件可与输入同文件
--parallel=N并行排序GNU
-zNUL 分隔配 -print0

sort 默认按 locale 排序,LC_ALL=C 才按字节;日志、ID、协议字段用 C。-k2 的键从第 2 字段开头一直到行尾,-k2,2 才是只按第 2 字段。-k3.7n 表示第 3 字段第 7 个字符起、按数值;这类写法可读性差,复杂排序交给 awk。

uniq

选项作用
-c前缀计数
-d只输出重复行
-D输出所有重复行(GNU)
-u只输出不重复行
-i忽略大小写
-f N跳过前 N 个字段
-s N跳过前 N 个字符
-w N只比较前 N 个字符
-zNUL 分隔

uniq 只删相邻重复,必须先 sort。sort -u 通常比 sort | uniq 快,但 uniq -c 需要保留计数。

cut

选项作用注意
-d CHAR分隔符只能是单个字符
-f LIST字段1,3-5;默认 tab
-c LIST字符位置按字节,UTF-8 下可能切坏
-b LIST字节位置与 -c 类似
--complement取反
-s没有分隔符的行不输出默认原样输出
--output-delimiter=STR输出分隔符

cut -d ' ' 不压缩连续空格,last 这类多空格对齐的输出会切错;用 awk 的默认空白分词。CSV 的引号字段不能用 cut,用 Python csv。

tr

写法作用
tr 'a-z' 'A-Z'大小写转换
tr -d '\r'删除 CR
tr -s ' '压缩连续空格
tr -d '[:digit:]'删除数字
tr -c '[:print:]' '\n'非打印字符换成换行
tr -cd '[:alnum:]'只保留字母数字

tr 按字节工作,不支持多字节字符;tr 'a-z' 'A-Z' 在 UTF-8 下只影响 ASCII。它没有文件名参数,只能读 stdin。

paste、join、comm

命令作用关键选项
paste a b按行横向拼接-d 分隔符、-s 每个文件一行
join a b按公共键连接-1 N、-2 N、-t、-a、-v、-o、-e
comm a b比较两个有序文件-1、-2、-3、--total
diff a b逐行差异-u、-r、-q、-N、-w、-B
patch -p1 < d.patch应用补丁-p 去掉几层目录

join 要求两个文件都按连接键排序,默认空白分隔、第一字段为键;-t, 指定逗号,-1 2 -2 1 指定不同键。comm 也要求排序,输出三列:只在 A、只在 B、两边都有,-12 只显示共同行。diff 的退出码:0 相同,1 不同,2 出错;脚本里 if diff -q a b; then 不能用 $? -ne 0 把「不同」和「出错」混在一起。

# 两个有序 TSV 按第 1 列连接,缺失填 NA
join -t $'\t' -a 1 -e NA -o '1.1,1.2,2.2' users.tsv orders.tsv

# 只显示两个文件的共同行
comm -12 <(sort a) <(sort b)

# 生成并应用补丁
diff -u old.conf new.conf > conf.patch
patch -p0 < conf.patch

diff -u 的 ---/+++ 行包含时间戳,打补丁时 patch 会忽略;-N 把新文件当空文件比较,适合生成「新增文件」的补丁。二进制文件用 cmp,不要用 diff。

0.6.16 实验:locale、sed -i 与 awk 精度

实验一:locale 与匹配引擎的代价

python3 - <<'PY' > /tmp/big.log
import random
random.seed(7)
for i in range(300000):
    print(f"10.0.0.{i%255} GET /p/{i%1000} {random.choice([200,301,404,500])} {i%5000}")
PY
for loc in C en_US.UTF-8; do
    printf '%s: ' "$loc"
    /usr/bin/time -f '%e s' env LC_ALL=$loc grep -c 'ERROR' /tmp/big.log
done
/usr/bin/time -f 'F %e s' grep -F ' 500 ' /tmp/big.log >/dev/null
/usr/bin/time -f 'E %e s' grep -E ' (4|5)[0-9]{2} ' /tmp/big.log >/dev/null

预期:LC_ALL=C 通常更快;grep -F 比 grep -E 快,尤其在模式没有元字符时。绝对秒数取决于机器与缓存,比较同一台机器上的相对值才有意义。en_US.UTF-8 不存在时 locale -a | grep -i en_US 先确认,或改用 C.UTF-8。

实验二:sed -i 改了 inode,也改了硬链接关系

cd /tmp && printf 'localhost\n' > sedfile && ln sedfile sedfile.hard
ls -li sedfile sedfile.hard
stat -c 'inode=%i links=%h mode=%a' sedfile
sed -i 's/localhost/localhost.localdomain/' sedfile
ls -li sedfile sedfile.hard
stat -c 'inode=%i links=%h mode=%a' sedfile sedfile.hard
cat sedfile.hard

预期:sed -i 后 sedfile 的 inode 变了、链接数变 1;sedfile.hard 仍指向旧 inode,内容是 localhost。GNU sed 通过「写临时文件 + rename」实现原地编辑,所以会断开硬链接;需要保留链接时,用 sed ... > tmp && cat tmp > file(保持 inode)或接受断链。

同一实验还能看权限:GNU sed 会尽量复制原文件的 mode/owner,但 ACL/xattr 不一定保留;stat -c '%a %U:%G' 对比前后。

实验三:sed -n l 与 awk 的浮点

printf 'a\tb  \n' | sed -n 'l'
# a\tb  $
printf 'a\tb  \n' | od -c | head -2
awk 'BEGIN { printf "%.2f %.2f %.0f\n", 1.005, 2.675, 2.5 }'
awk 'BEGIN { printf "%d %d\n", 3.9, -3.9 }'

sed -n l 把 tab 显示成 \t、行尾显示成 $,是排查「看不见的字符」最快的方法。printf "%.2f" 按二进制浮点舍入,1.005 可能显示 1.00;%d 向零截断,3.9 得 3、-3.9 得 -3。金额用整数分,不要用浮点累加。

0.6.17 三个可运行脚本

脚本一:logstat.sh——awk 数组、函数与外部排序

#!/usr/bin/env bash
# logstat.sh — 统计 access log 的状态码、5xx 比例、Top IP
# 用法: logstat.sh ACCESS_LOG [TOP]
set -euo pipefail

log=${1:?usage: logstat.sh ACCESS_LOG [TOP]}
top=${2:-10}
[[ -r $log ]] || { printf 'not readable: %s\n' "$log" >&2; exit 2; }
[[ $top =~ ^[1-9][0-9]*$ ]] || { printf 'TOP must be positive\n' >&2; exit 2; }

reader=(cat --)
[[ $log == *.gz ]] && reader=(gzip -cd --)

tmp=$(mktemp "${TMPDIR:-/tmp}/logstat.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT

"${reader[@]}" "$log" | LC_ALL=C awk '
    function pct(part, total) {
        if (total == 0) return 0
        return 100 * part / total
    }
    {
        ip[$1]++
        status[$9]++
        if ($9 ~ /^5/) bad++
        all++
    }
    END {
        printf "META\t%d\t%d\n", all, bad
        for (s in status) printf "STATUS\t%d\t%s\n", status[s], s
        for (i in ip)     printf "IP\t%d\t%s\n", ip[i], i
    }' >"$tmp"

awk -F'\t' '$1 == "META" { printf "total=%d 5xx=%d (%.2f%%)\n", $2, $3, ($2 ? 100*$3/$2 : 0) }' "$tmp"
printf '== status ==\n'
awk -F'\t' '$1 == "STATUS" { printf "%6d %s\n", $2, $3 }' "$tmp" | sort -k1,1nr
printf '== top %d ips ==\n' "$top"
awk -F'\t' '$1 == "IP" { printf "%6d %s\n", $2, $3 }' "$tmp" | sort -k1,1nr | head -n "$top"

关键点:awk 只做一次扫描,把三类结果以 TAG\tcount\tkey 写入临时文件;排序交给 sort,避免在 awk 里调用多个 sort 管道。pct() 是自定义函数,total==0 的除零保护是必需的。reader 数组让 .gz 与普通文件走同一路径,"${reader[@]}" 保留参数边界。LC_ALL=C 让 $9 的匹配与排序按字节,日志字段不受 locale 影响。

脚本二:rename-regex.sh——sed 生成新名,先查冲突

#!/usr/bin/env bash
# rename-regex.sh — 用 sed 表达式批量改名;默认 dry-run
# 用法: rename-regex.sh [-y] DIR 's/\.JPG$/.jpg/'
set -euo pipefail
usage() { printf "usage: %s [-y] DIR 's/REGEX/REPL/'\n" "${0##*/}" >&2; }
die()   { printf 'error: %s\n' "$*" >&2; exit 2; }

apply=0
while getopts ':yh' opt; do
    case $opt in
        y) apply=1 ;;
        h) usage; exit 0 ;;
        \?) die "unknown option -$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))
dir=${1:-} expr=${2:-}
[[ -n $dir && -n $expr ]] || { usage; exit 2; }
[[ -d $dir ]] || die "not a directory: $dir"
[[ $expr == s/* || $expr == s\|* || $expr == s#* ]] || die "expression must start with s/ s| or s#"

mapfile -d '' -t files < <(find "$dir" -maxdepth 1 -type f -print0)
declare -A target=()
for f in "${files[@]}"; do
    base=${f##*/}
    new=$(printf '%s\n' "$base" | sed -E "$expr") || die "sed failed for $base"
    [[ $new == "$base" ]] && continue
    [[ -n $new && $new != */* ]] || die "invalid target name: $new"
    if [[ -e $dir/$new || -n ${target[$new]:-} ]]; then
        die "collision: $new"
    fi
    target[$new]=$f
    printf 'mv -- %q %q\n' "$f" "$dir/$new"
    if (( apply )); then
        mv -n -- "$f" "$dir/$new"
    fi
done

关键点:find -print0 + mapfile -d '' 处理空格/换行;sed -E 只作用于 basename,不碰目录;declare -A target 记录本轮已生成的目标名,防止 a.JPG 与 a.jpg 同时改成 a.jpg;[[ -e ... ]] 检查磁盘上已存在的目标;mv -n 再兜底,绝不覆盖。默认 dry-run,-y 才执行。sed 表达式由调用者提供,脚本只校验前缀,不防恶意正则;这是「可信运维脚本」的边界。

脚本三:join-report.sh——排序校验 + join + 缺失值

#!/usr/bin/env bash
# join-report.sh — 按第 1 列连接两个 TSV,缺失值填 0
# 用法: join-report.sh USERS.tsv ORDERS.tsv OUT.tsv
set -euo pipefail
users=${1:?usage: join-report.sh USERS.tsv ORDERS.tsv OUT.tsv}
orders=${2:?usage: join-report.sh USERS.tsv ORDERS.tsv OUT.tsv}
out=${3:?usage: join-report.sh USERS.tsv ORDERS.tsv OUT.tsv}
for f in "$users" "$orders"; do
    [[ -r $f ]] || { printf 'not readable: %s\n' "$f" >&2; exit 2; }
    LC_ALL=C sort -c -t $'\t' -k1,1 -- "$f" 2>/dev/null ||
        { printf 'not sorted by key: %s\n' "$f" >&2; exit 2; }
done

tmp=$(mktemp "${TMPDIR:-/tmp}/join.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT

{
    printf 'user\tname\tamount\n'
    LC_ALL=C join -t $'\t' -a 1 -e 0 -o '1.1,1.2,2.2' -- "$users" "$orders"
} >"$tmp"
mv -- "$tmp" "$out"
printf 'wrote %s (%d rows)\n' "$out" "$(wc -l <"$out")"

关键点:join 要求两个输入按连接键排序,脚本先用 sort -c 验证,避免「看起来有输出但连接错位」;-t $'\t' 指定 tab,-a 1 保留左表全部行,-e 0 填缺失,-o '1.1,1.2,2.2' 明确输出列。trap 清理临时文件,mv 在同一目录内原子替换。若左表有重复键,join 会输出笛卡尔积;脚本不处理这种情况,需要时先用 sort -u 或 awk 聚合。

CSV 的边界用 Python 演示,不要硬写 awk:

python3 - <<'PY'
import csv, sys
with open('in.csv', newline='', encoding='utf-8') as f, \
     open('out.tsv', 'w', newline='', encoding='utf-8') as g:
    r = csv.reader(f)
    w = csv.writer(g, delimiter='\t', lineterminator='\n')
    for row in r:
        w.writerow(row)
PY

csv 模块处理引号、转义、字段内换行;awk -F, 在这些情况下必然错位。判断标准:数据里出现引号或换行,就换工具。

0.6.18 陷阱清单

正则与文本工具的十二个坑
  1. grep 'a+' 不匹配一个或多个 a——BRE 里 + 是字面量;用 grep -E 'a+' 或 grep 'a\+'。
  2. . 不匹配换行——sed/awk 默认逐行;跨行用 N、RS 或 PCRE (?s)。
  3. grep -o 不输出重叠匹配——echo aaa | grep -o 'aa' 只出一个;需要重叠时用 PCRE 环视或自己写循环。
  4. sed -i 在 GNU/BSD 上参数不同——GNU -i.bak 可选后缀,BSD 必须 -i ''。
  5. sed -i 会断开硬链接——它写临时文件再 rename;ls -li 能看到 inode 变化。
  6. G 在 hold space 为空时会加一个空行——sed 'G' 每行后加空行,末行也会多一个换行。
  7. /a/,/b/ 会反复进入范围——每次匹配 /a/ 都重新开始,直到下一次 /b/;不是只匹配第一次。
  8. awk 数组遍历无序——for (k in a) 不保证插入顺序;排序用 sort 或 gawk asort。
  9. 遍历中 delete a[k] 行为未定义——先收集键,再删;POSIX 不保证边遍历边删。
  10. sort 默认按 locale——LC_ALL=C 才是字节序;日志/ID 必须固定 locale。
  11. uniq 只删相邻重复——不 sort 就 uniq 等于没删。
  12. diff 退出 1 是「有差异」不是错误——if diff -q a b; then 只判断相同;错误是 2。

0.6.19 本章出口检查

检查项达标线没达标去哪
区分 BRE/ERE/PCRE能说出 +、()、\d 各属哪类0.6.1
写出锚点、字符类、量词、分组能解释贪婪匹配0.6.2
用 grep 做筛选与计数会用 -E/-F/-r/-l/-o/-c/-C0.6.3
解释 sed 的一轮循环能说清 -n 与 p/d 的组合0.6.4
用 hold space 倒序输出能逐步走一遍 1!G;h;$p0.6.5
用 awk 做分组统计会 NR/NF/FS、数组、END0.6.6
选对工具能判断何时换 Python/jq0.6.7、0.6.17
区分匹配引擎能说出最左最长与回溯的差别0.6.9
用全量 grep 选项会 -m、-z、-A/-B/-C、--include0.6.10
写 sed 地址与命令能解释 n/N/P/D 与 0,/re/0.6.11
用 hold space/分支会倒序、合并段落、t 条件跳转0.6.12
写 awk 变量与 getline能区分 -v 与命令行赋值0.6.13
用 awk 数组与函数会 for in、split、自定义函数0.6.14
选对文本工具知道 uniq 要先排序、diff 退出 1 不是错0.6.15
即时自测 · 0.6

Q1 grep '^a+' file 为什么匹配不到 aaa?给出两种正确写法。

Q2 sed -n '1!G;h;$p' 的作用是什么?如果文件只有一行,输出是什么?

Q3 awk '{c[$1]++} END {for (k in c) print c[k], k}' 的输出顺序稳定吗?怎样得到按次数降序的结果?

Q4 要统计 CSV 第三列的总和,什么情况下 awk -F, 会算错?应该换什么工具?

答案

A1 默认是 BRE,+ 被当成普通字符,所以模式是「行首 a 后跟一个字面加号」。正确:grep -E '^a+' file,或 grep '^a\+' file(GNU BRE 扩展),或 grep -P '^a+' file。

A2 倒序输出文件。1!G 从第二行起把 hold space 追加到模式空间,h 把当前累积写回 hold,$p` 在最后一行打印。只有一行时,`1!G` 跳过,`h` 保存该行,`$p 打印,输出仍是这一行。

A3 不稳定。awk 的关联数组遍历顺序未定义,取决于哈希实现与键。要按次数降序:把输出交给 sort -nr,或在 gawk 里用 PROCINFO["sorted_in"](非 POSIX)。

A4 当字段内包含逗号且被引号包围时,-F, 会把一个字段拆成多个,列号错位;字段内有换行时更糟。用 Python 的 csv 模块、mlr --csv 或 xsv,不要用 split/正则硬解析。

母本对应

母本章节本书落点程度
G 鸟哥第 11 章 正则表达式与文件格式化处理0.6.1–0.6.4完整(BRE/ERE、grep、sed)
S Shotts第 19–21 章 正则表达式、文本处理、格式化输出0.6.1–0.6.7完整(grep/sed/awk/printf)
B shell-book第 19–22 章 sed 与 gawk 基础、进阶0.6.4–0.6.8完整(地址、hold space、数组)
K Kerrisk第 2.5 节 文本流与文件模型0.6.3背景(逐行处理模型)
C CSAPP第 10 章 系统级 I/O(字节流)0.6.8背景(文本即字节)
S Shotts第 19–20 章 正则表达式、文本处理0.6.9–0.6.15完整(grep、sort/uniq/cut)
B shell-book第 19–22 章 sed 与 gawk 基础、进阶0.6.11–0.6.15完整(地址、hold space、数组、函数)
G 鸟哥第 11 章 正则表达式与文件格式化0.6.9、0.6.11、0.6.15完整(BRE/ERE、sed、sort/cut)

下一站:0.7 Bash 编程基础把这些命令组合成脚本;本章的管道与文本工具会成为脚本里的处理层。