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
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 标准。
| 特性 | BRE | ERE | PCRE |
|---|---|---|---|
| 分组 | \(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 解析 |
| 日志级别 | `^(DEBUG | INFO |
正则适合「筛」,不适合「解析」。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 行 A | 1!G 跳过;h | A | A |
| 第 2 行 B | G 追加保持空间 | B\nA | A |
| 第 2 行 B | h 覆盖保持空间 | B\nA | B\nA |
| 第 3 行 C | G | C\nB\nA | B\nA |
| 第 3 行 C | h | C\nB\nA | C\nB\nA |
| 最后一行 | $p | C\nB\nA | C\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。
- 「
grep 'a+'匹配一个或多个 a」——BRE 里+是普通字符,匹配字面a+;要用grep -E 'a+'或grep 'a\+'。 - 「
grep -P到处都能用」——它依赖 PCRE 支持,busybox、部分精简系统没有;脚本要探测或改用 ERE。 - 「
sed -i的用法在 Linux 和 macOS 上一样」——GNU sed 的-i可选备份后缀,BSD sed 必须给参数(-i '')。跨平台脚本要分支处理。 - 「
.*能匹配换行」——默认逐行处理,.不匹配换行;跨行要用sed的N/D、awk 的RS或 PCRE 的(?s)。 - 「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 / -P | ERE / 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 的循环只有四步:
- 从输入读一行到模式空间(pattern space),去掉行尾换行;
- 按脚本顺序执行命令,地址匹配才执行;
- 除非
-n,自动打印模式空间并补换行; - 读下一行,重复。
-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 行 A | 1!G 跳过;h | A | A |
| 第 2 行 B | G 追加保持 | B\nA | A |
| 第 2 行 B | h 覆盖保持 | B\nA | B\nA |
| 第 3 行 C | G | C\nB\nA | B\nA |
| 第 3 行 C | h | C\nB\nA | C\nB\nA |
| 最后一行 | $p | C\nB\nA | C\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/RLENGTH | match() 的起点/长度 | 未匹配时 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、rshift | gawk |
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 |
-z | NUL 分隔 | 配 -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 个字符 |
-z | NUL 分隔 |
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 陷阱清单
grep 'a+'不匹配一个或多个 a——BRE 里+是字面量;用grep -E 'a+'或grep 'a\+'。.不匹配换行——sed/awk 默认逐行;跨行用N、RS或 PCRE(?s)。grep -o不输出重叠匹配——echo aaa | grep -o 'aa'只出一个;需要重叠时用 PCRE 环视或自己写循环。sed -i在 GNU/BSD 上参数不同——GNU-i.bak可选后缀,BSD 必须-i ''。sed -i会断开硬链接——它写临时文件再 rename;ls -li能看到 inode 变化。G在 hold space 为空时会加一个空行——sed 'G'每行后加空行,末行也会多一个换行。/a/,/b/会反复进入范围——每次匹配/a/都重新开始,直到下一次/b/;不是只匹配第一次。- awk 数组遍历无序——
for (k in a)不保证插入顺序;排序用sort或 gawkasort。 - 遍历中
delete a[k]行为未定义——先收集键,再删;POSIX 不保证边遍历边删。 sort默认按 locale——LC_ALL=C才是字节序;日志/ID 必须固定 locale。uniq只删相邻重复——不sort就uniq等于没删。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/-C | 0.6.3 |
| 解释 sed 的一轮循环 | 能说清 -n 与 p/d 的组合 | 0.6.4 |
| 用 hold space 倒序输出 | 能逐步走一遍 1!G;h;$p | 0.6.5 |
| 用 awk 做分组统计 | 会 NR/NF/FS、数组、END | 0.6.6 |
| 选对工具 | 能判断何时换 Python/jq | 0.6.7、0.6.17 |
| 区分匹配引擎 | 能说出最左最长与回溯的差别 | 0.6.9 |
| 用全量 grep 选项 | 会 -m、-z、-A/-B/-C、--include | 0.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 |
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 编程基础把这些命令组合成脚本;本章的管道与文本工具会成为脚本里的处理层。