阶段四 · 管道与文本处理

awk:文本处理瑞士军刀

1. awk 是什么

awk 不是某一个功能的缩写--它是三位作者姓氏的首字母:Aho,Weinberger 和 Kernighan.它是一个专为文本处理设计的小型编程语言,内置于 Unix 系统中.

如果说 grep 是"找东西的手电筒",sed 是"批量修改的流水线",那么 awk 就是"带计算器,记事本和统计表格的办公桌"--它不只是找和改,还能按列切分,做算术,统计聚合,格式化输出.

1.1 grep · sed · awk 定位图

grep sed awk
核心功能 按行搜索 按行修改 按列处理 + 计算
列感知 无(需靠正则模拟) 原生支持
算术能力 完善
变量与流程控制 极有限(保持空间) 完整编程语言
典型一行命令 grep ERROR log sed 's/a/b/g' file awk '{print $1}' file

一句话定位:当处理的文本有"列"的概念(日志,CSV,ps 输出,任何按分隔符组织的文本),并且需要按列筛选,按列计算,按列统计时--awk 是最正解.

2. 执行模型:逐行 × 逐列

awk 和 sed 一样是逐行处理,但它多了一步自动拆列--每读入一行,就按分隔符切成若干字段(fields),可以直接用 $1,$2... 引用它们.

┌──────────┐
│ 行进入 │
└──────────┘


┌───────────────────────┐
│ 拆分成字段 (按 FS 分隔) │
└───────────────────────┘


┌───────────────────────┐
│ 模式匹配? (Pattern) │
└───────────────────────┘
│ "不匹配"

┌──────────┐
│ 跳过 │
└──────────┘


┌─────────────────────┐
│ 执行动作 (Action) │
└─────────────────────┘


┌──────────┐
│ 读下一行 │
└──────────┘


┌──────────┐
│ 还有下一行? │
└──────────┘
│ "否"

┌───────────────┐
│ 执行 END 块 → 结束 │
└───────────────┘

2.1 一行数据走完 awk 的全过程

假设文件有三行,执行 awk '{print $2}' file:

第 1 行 "Alice 95 A" 进入
→ 拆字段:$1="Alice", $2="95", $3="A"
→ 模式为空(所有行都匹配)
→ 执行动作:print $2 → 输出 "95"

第 2 行 "Bob 87 B" 进入
→ 拆字段:$1="Bob", $2="87", $3="B"
→ 执行动作:print $2 → 输出 "87"

...依此类推

2.2 Pattern { Action } -- awk 的双引擎

awk 脚本的核心结构是:

模式 { 动作 }
# ↑ ↑
# 筛选 执行
# 哪行 做啥

两个部分都是可选的:

写法 含义
awk '/ERROR/ {print $1}' 有模式有动作:匹配 ERROR 的行,打印第 1 列
awk '{print $1}' 无模式:所有行都执行 print $1
awk '/ERROR/' 无动作:匹配 ERROR 的行默认执行 print $0(打印整行)

awk 的默认动作 = print $0

当只写模式不写动作时,awk 等价于行级别的 grep:awk '/ERROR/' loggrep ERROR log 结果一样.但 awk 能写更丰富的模式--比如 awk '$3 > 500'(第 3 列大于 500 的行),这是 grep 做不到的.

3. 字段:按列处理的核心

3.1 $1 $2 ... $NF $0

符号 含义
$0 整行原文
$1, $2, $3, ... 第 1 列,第 2 列,第 3 列...
$NF 最后一列(NF = Number of Fields,字段总数)
$(NF-1) 倒数第二列
$ cat students.txt
Alice 95 A
Bob 87 B
Carol 92 A
Dave 78 C

# 打印第 1 列(名字)
$ awk '{print $1}' students.txt
Alice
Bob
Carol
Dave

# 打印第 1 和第 3 列
$ awk '{print $1, $3}' students.txt
Alice A
Bob B
Carol A
Dave C

# 打印最后一列($NF = 字段数)
$ awk '{print $NF}' students.txt
A
B
A
C

# 打印倒数第二列:$(NF-1) 先算出数字再取值
$ awk '{print $(NF-1)}' students.txt
95
87
92
78

# 打印整行(debug 用)
$ awk '{print $0}' students.txt

$1 不是 Shell 的 $1(脚本参数),不是 $PATH 里的变量引用--它是 awk 内部用于取字段的语法.Shell 变量在 awk 中需要用 -v 传进去:awk -v name="$USER" '{print name}'.

3.2 FS:自定义分隔符

awk 默认按任意空白字符(空格,制表符)分隔.处理 CSV,/etc/passwd,ps 输出等不同格式时,通过 -F 指定分隔符:

# /etc/passwd 用 : 分隔 → 提取所有用户名
$ awk -F: '{print $1}' /etc/passwd

# CSV 用逗号分隔 → 提取第 1 和第 3 列
$ awk -F, '{print $1, $3}' data.csv

# -F 等价于在 BEGIN 中设置 FS
$ awk 'BEGIN {FS=":"} {print $1}' /etc/passwd

# 多字符分隔符也支持(ERE 正则)
$ echo "a|||b|||c" | awk -F'[|]{3}' '{print $2}'
b

3.3 默认分隔符的神奇之处

awk 默认按空白分隔有一个特别的处理:连续多个空白会被视为一个分隔符,行首行尾空白也被忽略.这和 cut 的行为完全不同:

$ echo "  alice   30  engineer  " | awk '{print $1, $2, $3}'
alice 30 engineer # ← 干净!awk 自动处理了所有多余空格

# 对比:cut 要求明确指定分隔符,多余空格会导致"空列"
$ echo " alice 30 engineer " | cut -d' ' -f1
# ← 空的(第一个字符是空格)
$ echo " alice 30 engineer " | tr -s ' ' | cut -d' ' -f2
alice # ← tr 预处理后才能正确解析

OFS(Output Field Separator,输出字段分隔符)控制在 print $1, $2 中逗号用什么连接:

$ awk '{print $1, $2}' students.txt
Alice 95 # 默认 OFS=" "(空格)

$ awk -v OFS=, '{print $1, $2}' students.txt
Alice,95 # OFS 设为逗号

4. 记录与行号:NR · FNR · RS

变量 全称 含义
NR Number of Records 已处理的总行数(跨文件累加)
FNR File Number of Records 当前文件内的行号(每个文件重置为 1)
RS Record Separator 记录分隔符(默认 \n,即一行 = 一条记录)
# 给每行加行号(类似 cat -n)
$ awk '{print NR, $0}' students.txt
1 Alice 95 A
2 Bob 87 B
3 Carol 92 A
4 Dave 78 C

# 处理多个文件时 NR 和 FNR 的差异
$ awk '{print "总行号:", NR, "文件内行号:", FNR, "内容:", $0}' file1.txt file2.txt

# 只在第 3 到第 5 行执行操作
$ awk 'NR>=3 && NR<=5 {print $0}' file.txt

# 跳过前 2 行(比如跳过 CSV 表头)
$ awk 'NR>2 {print}' data.csv

5. 模式:筛选哪些行

5.1 BEGIN 与 END -- awk 的特殊"锚点"

BEGIN读任何行之前执行一次,END所有行处理完后执行一次.它们不参与逐行循环:

文件没开始读呢
→ BEGIN { 初始化变量,打印表头 }
文件逐行读入
→ { 每行处理 }
→ /pattern/ { 匹配的行 }
文件读完了
→ END { 打印汇总结果 }
→ awk 退出
# 最典型用法:计算平均值
$ awk '
BEGIN { print "--- 学生成绩 ---" }
{ sum += $2; count++ } # 每行累加第 2 列
END {
print "----------------"
print "总分:", sum
print "人数:", count
print "平均:", sum/count
}
' students.txt
--- 学生成绩 ---
----------------
总分: 352
人数: 4
平均: 88

5.2 正则模式

# 打印包含 "ERROR" 的行(等价于 grep ERROR)
$ awk '/ERROR/' app.log

# 正则匹配可以限定在某一列
$ awk '$3 ~ /ERROR/' app.log # 第 3 列包含 ERROR
$ awk '$3 !~ /DEBUG/' app.log # 第 3 列不包含 DEBUG

# 用正则匹配具体字段
$ awk '$1 ~ /^[AB]/' students.txt # 第 1 列以 A 或 B 开头
Alice 95 A
Bob 87 B

~ 表示"匹配正则",!~ 表示"不匹配正则".这是 awk 独有的字段级正则能力--grep 只能匹配整行.

5.3 比较模式

awk 支持完整的比较运算符,可以直接用数字或字符串比较作为模式:

$ cat employees.txt
Alice 30 85000
Bob 25 62000
Carol 35 95000
Dave 28 72000

# 第 3 列(工资)大于 80000 的员工
$ awk '$3 > 80000' employees.txt
Alice 30 85000
Carol 35 95000

# 第 2 列(年龄)<= 28 且第 3 列 >= 70000
$ awk '$2 <= 28 && $3 >= 70000' employees.txt
Dave 28 72000

# 精确字符串匹配
$ awk '$1 == "Alice"' employees.txt
Alice 30 85000

# 不等于
$ awk '$1 != "Bob"' employees.txt

5.4 空模式与无条件动作

不写模式 = 每一行都执行:

$ awk '{print NR, $1}' students.txt
1 Alice
2 Bob
3 Carol
4 Dave

6. 动作:做什么

6.1 print 与 printf

print 简单直接,自动加换行.逗号分隔的多个参数之间自动插入 OFS:

$ awk '{print $1, $2, $3}' students.txt
# 等价于:print $1 OFS $2 OFS $3

# 不加逗号 = 无分隔拼接
$ awk '{print $1 $2 $3}' students.txt
Alice95A

printf 提供精确的格式化控制,语法和 C 语言一致:

# %-10s = 左对齐,占 10 字符宽度;%5d = 右对齐,占 5 字符
$ awk '{printf "%-10s %5d\n", $1, $2}' students.txt
Alice 95
Bob 87
Carol 92
Dave 78

# 格式化浮点数
$ awk '{printf "%.2f\n", $2}' students.txt
95.00
87.00
92.00
78.00
格式符 含义
%s 字符串
%d 整数
%f 浮点数
%.2f 浮点数,保留 2 位小数
%-20s 左对齐,占 20 字符宽度
%10d 右对齐,占 10 字符宽度
\n 换行(printf 不会自动加)

6.2 变量与运算

awk 的变量无需声明,首次使用时自动初始化为 0 或空字符串:

# 累计求和(sum 首次使用为 0)
$ awk '{sum += $2} END {print sum}' students.txt
352

# 拼接所有名字(s 首次使用为空字符串)
$ awk '{s = s $1 " "} END {print s}' students.txt
Alice Bob Carol Dave

# 算术运算符:+ - * / % ^
$ awk '{print $1, $2 * 2}' students.txt
Alice 190
Bob 174
Carol 184
Dave 156

# 自增 / 自减
$ awk '{print NR, $1; count++} END {print "Total:", count}' students.txt

# 字符串拼接(直接写在一起就好,无运算符)
$ awk '{full = $1 " (" $3 ")"; print full}' students.txt
Alice (A)
Bob (B)
Carol (A)
Dave (C)

6.3 条件与循环

# if-else 语句
$ awk '{
if ($2 >= 90) print $1, "优秀"
else if ($2 >= 80) print $1, "良好"
else print $1, "加油"
}' students.txt
Alice 优秀
Bob 良好
Carol 优秀
Dave 加油

# for 循环 - 打印每行的每个字段
$ awk '{
for (i=1; i<=NF; i++)
printf "字段%d: %s ", i, $i
print "" # 换行
}' students.txt

# while 循环
$ awk '{i=1; while (i<=NF) {print NR":"i,$i; i++}}' students.txt

7. 内建变量速查表

变量 含义 默认值
NR 已处理的记录总数(跨文件累加) -
FNR 当前文件内的记录号(每换文件重置) -
NF 当前记录的字段数 -
FS 输入字段分隔符(Field Separator) 任意空白
OFS 输出字段分隔符 空格
RS 输入记录分隔符(Record Separator) \n
ORS 输出记录分隔符 \n
FILENAME 当前正在处理的文件名(只读) -
ARGC 命令行参数数量 -
ARGV 命令行参数数组 -

8. 常用函数

8.1 字符串函数

函数 含义 示例
length(s) 字符串长度 {print length($1)}
substr(s, i, n) 子串:从位置 i 取 n 个字符 {print substr($0, 1, 10)}
index(s, t) t 在 s 中首次出现的位置 index($0, "ERROR")
split(s, arr, sep) 按 sep 拆分 s 到数组 arr split($0, a, ":")
gsub(r, t, s) 全局替换 {gsub(/old/, "new")}
sub(r, t, s) 替换(仅第一个匹配) {sub(/^ +/, "")}
tolower(s) 转小写 {print tolower($1)}
toupper(s) 转大写 {print toupper($1)}
sprintf(fmt, ...) 格式化到字符串(不打印) sprintf("%.2f", $2)
# length:筛选名字超过 4 个字符的记录
$ awk 'length($1) > 4' students.txt
Alice 95 A
Carol 92 A

# substr:提取 YYYY-MM-DD 中的年份(第 1–4 个字符)
$ echo "2024-06-16" | awk '{print substr($0, 1, 4)}'
2024

# split:把 /etc/passwd 每行按 : 拆分
$ awk -F: '{split($5, gecos, ","); print $1, gecos[1]}' /etc/passwd

# gsub:把第 1 列中的所有元音字母替换为 *
$ echo "Hello World" | awk '{gsub(/[aeiouAEIOU]/, "*"); print}'
H*ll* W*rld

# tolower / toupper
$ awk '{print toupper($1)}' students.txt
ALICE
BOB
CAROL
DAVE

8.2 数值函数

函数 含义
int(x) 截断取整(向零舍入)
sqrt(x) 平方根
rand() 随机数 [0, 1)
srand(x) 设置随机种子
sin(x) / cos(x) 三角函数(弧度)
log(x) / exp(x) 自然对数 / e 的幂

9. 关联数组:统计与去重

awk 的数组和常规编程语言不同--它用任意字符串作键(不是从 0 开始的数字索引),这天然适合做统计和去重.

9.1 数组基础

# 赋值和取值
$ awk '{ count[$1]++ } END { print count["Alice"] }' students.txt
1
# count["Alice"] 初始为 0,每出现一次 Alice 就 +1

9.2 词频统计--awk 的招牌操作

# 统计每个 IP 的访问次数
$ awk '{count[$1]++} END {for (ip in count) print ip, count[ip]}' access.log
10.0.1.5 423
10.0.2.8 156
192.168.1.100 89

# 统计每个 HTTP 状态码的出现次数
$ awk '{status[$9]++} END {for (s in status) print s, status[s]}' access.log
200 15234
404 423
500 12

count[$1]++ 就像是给每一行打了一个即时标签贴纸,然后把相同标签的贴纸堆在一起.
第一行 "Alice" → 打开一个叫 "Alice" 的抽屉,放进去 +1;第二行又是 "Alice" → 打开同一个抽屉,再 +1.
最终,抽屉标签(键)就是所有独一无二的值,抽屉里的内容(值)就是每个值出现的次数.

9.3 遍历数组

# for (key in array) 语法
$ awk '{scores[$1]=$2}
END {
for (name in scores)
printf "%-10s %s\n", name, scores[name]
}' students.txt
Alice 95
Bob 87
Carol 92
Dave 78

# 注意:for-in 遍历的顺序是不确定的(哈希表顺序)
# 如果需要排序,管道给 sort:
$ awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head

awk 的数组不支持直接排序

for (key in array) 的遍历顺序是未定义的.需要排序就把输出管道给 sort,或者自己维护一个索引数组用 asort / asorti(GNU awk 扩展,macOS 不支持).这个局限恰好体现了 Unix 哲学--每个工具做好一件事,awk 负责统计,sort 负责排序.

10. 常用配方(Recipes)

# ── 列提取 ──
# 打印第 2 列和第 5 列
awk '{print $2, $5}' data.txt

# 交换两列的位置
awk '{print $2, $1}' data.txt

# 打印最后一列
awk '{print $NF}' data.txt

# ── 条件筛选 ──
# 第 3 列大于 100 的行
awk '$3 > 100' data.txt

# 第 1 列以字母开头(正则)
awk '$1 ~ /^[a-zA-Z]/' data.txt

# 验证每行完整性(字段数等于 5)
awk 'NF == 5' data.csv

# ── 统计汇总 ──
# 求和
awk '{sum += $3} END {print sum}' data.txt

# 求平均值
awk '{sum += $3; n++} END {print sum/n}' data.txt

# 最大值和最小值
awk 'NR==1 {max=$3; min=$3} $3>max {max=$3} $3<min {min=$3} END {print "max:", max, "min:", min}' data.txt

快速回顾

  • awk = 逐行处理 + 自动拆列:每一行按 FS 分隔符切成 $1 $2 ... $NF,$0 是整行
  • 核心结构:Pattern { Action }.模式筛选哪些行,动作决定做什么--两部分都可省略
  • BEGIN/END:BEGIN 在读任何行前执行(初始化,打印表头),END 在所有行后执行(打印汇总)
  • 变量无需声明:首次使用自动初始化为 0 或空串.支持 + - * / % ^ 算术运算和直接字符串拼接
  • 内建变量:NR(总行号),NF(当前行字段数),FS(输入分隔符),OFS(输出分隔符)
  • 字段级正则:$1 ~ /pattern/ 匹配指定列--grep 做不到的 awk 做到了
  • 关联数组:以字符串为键,天然适合词频统计和去重--count[$1]++ 是最经典的 awk 惯用语
  • 排序交给 sort:for-in 顺序不定,把结果管道给 sort,Unix 哲学各司其职
  • 三者互补:grep = 行搜索,sed = 行修改,awk = 列处理 + 计算 + 统计--管道串联威力最大

动手练习

  1. 列提取:用 ps aux | awk '{print $1, $3}' 列出所有进程的用户名和 CPU 占用率,按 CPU 降序排列
  2. 分隔符:用 awk -F: '{print $1, $7}' /etc/passwd 提取所有用户名及其登录 Shell,然后用 grep 找出用 bash 的用户
  3. CSV 处理:创建一份 CSV 数据(逗号分隔),练习 awk -F, '{print $2}' 和自定义 OFS
  4. BEGIN/END 统计:用 awk 计算下载文件夹的总大小:ls -l ~/Downloads | awk '{sum += $5} END {print sum/1024/1024 " MB"}'
  5. 去重:用 awk '!seen[$0]++' 对一个有重复行的文件去重,理解这个神奇的一行命令
  6. 词频统计:用 awk '{count[$1]++} END {for (k in count) print count[k], k}' 统计某个日志文件中各 IP 的访问次数,用 sort -rn | head 取 Top 10
  7. 格式化输出:用 awk '{printf "%-15s %5d\n", $1, $2}' 格式化输出一些数据,尝试不同的宽度和对齐方式