Shell正则实战:grep、sed、awk避坑指南
2026/9/24 8:51:44 网站建设 项目流程

别的不说,就光grepsedawk这三个命令,我敢说,凡是写过三个月以上Shell脚本的人,绝对都跟它们打过交道。但是你有没有过这种经历:网上搜了一段正则,直接粘到脚本里,结果要么匹配不到,要么把文件内容给替换得乱七八糟,甚至有时候sed直接报错,你盯着那一堆反斜杠,完全不知道问题出在哪。

其实问题往往不在正则本身,而在Shell环境对正则的“二次加工”。这篇文章我不会给你铺一堆理论,而是直接告诉你:正则表达式在Shell里到底是怎么被处理的,写脚本时该怎么用才能不踩坑。不管你是刚学Linux的小白,还是写过一阵子脚本但总被正则搞晕的开发者,我相信这篇都能帮你把这块拼图补完整。

1. 正则表达式基础与Shell环境分析

1.1 元字符速览:先建立“语言直觉”

正则表达式说白了就是一套描述字符串模式的“小语言”。它的核心不是背语法,而是建立一种直觉:看到一段文本,能下意识判断“这个能不能用一条正则描述出来”。

我先把最常用、出现频率最高的元字符列出来,这组字符是你在Shell里写任何正则都会反复用到的:

元字符含义匹配示例
.匹配任意单个字符(换行符除外)a.c匹配abca1c
*匹配前一个字符零次或多次ab*c匹配acabcabbc
+匹配前一个字符一次或多次(需ERE)ab+c匹配abc,不匹配ac
?匹配前一个字符零次或一次(需ERE)ab?c匹配acabc
^匹配字符串开头^abc匹配以abc开头的行
$匹配字符串结尾abc$匹配以abc结尾的行
[abc]字符集合,匹配其中任意一个[0-9]匹配任意数字
[^abc]反向字符集合,匹配不在其中的字符[^0-9]匹配非数字
\转义字符\.匹配字面量点号
|(ERE为|或逻辑(BRE写法不同)a|b匹配ab
\{m,n\}(ERE为{m,n}匹配前一个字符m到n次a\{2,3\}匹配aaaaa

注意一个关键点:上面表格里我特别标注了“需ERE”或“BRE写法不同”。这就是Shell正则最坑的地方——同一个元字符,在“基本正则”(BRE)和“扩展正则”(ERE)里的表现不一样。比如+在BRE里是普通字符,只有写成\+才表示“一次或多次”;而{m,n}在BRE里必须写成\{m,n\},在ERE里直接写{m,n}就行。

1.2 Shell环境里的正则家族:BRE、ERE与PCRE

我工作里见过太多人在这上面栽跟头。你以为写的是“正则”,但实际grepsedawk对正则的解析规则并不完全一样,这就导致了同一段正则换个工具就失效。

简单梳理一下三者的关系:

  • BRE(基本正则):最古老的一套规则。元字符只有.*[]^$,其他如+?|{}都需要加反斜杠才能获得特殊含义。忠于POSIX标准的grep(不带任何参数)、sed默认用的就是BRE。
  • ERE(扩展正则):把+?|(){}直接当作元字符,不需要反斜杠。grep -Eegrepsed -Eawk用的都是ERE。
  • PCRE(Perl兼容正则):这是最接近现代编程语言(比如Python、Java、JavaScript)的一套规则,支持\d\w\s这类快捷字符,还支持环视、非贪婪匹配等高级特性。grep -P在GNU grep里就是走PCRE。

我给个实际对比,假设要匹配“至少包含一个数字”的字符串:

流派写法说明
BRE[0-9][0-9]*手动写“一个数字再加零个或多个数字”
ERE[0-9]++直接表示一次或多次
PCRE\d+\d快捷字符

如果你在grep里直接写grep '[0-9]+' file,它用的BRE,+被当作普通字符,结果就是匹配所有包含“数字后跟一个加号”的行,压根达不到你想要的“匹配含数字的行”的效果。正确写法是grep -E '[0-9]+' file或者grep '[0-9][0-9]*' file

1.3 单引号、双引号与不加引号:Shell对正则的“预处理”

这是Shell正则最容易被忽略、却又最致命的一环。正则表达式是在命令行里作为参数传给grepsedawk的,而这个参数是先经过Shell解析,再传给命令的。Shell不是透明的,它会先对参数里的变量、通配符、特殊符号做一套自己的解释。

我总结出三条铁律:

  1. 能用单引号就用单引号:单引号里的内容Shell完全不做任何处理,原封不动传给命令。这是最安全的方式,正则里的$*!\都不会被Shell误解析。
  2. 双引号会做变量替换和命令替换:如果正则里有$,比如你想匹配行尾的$符号,在双引号里$会被Shell当成变量标志,直接变成空字符串或者报错。
  3. 不加引号极度危险:正则里的*会被Shell当作通配符展开,可能把当前目录下的文件名都给展开传进去。

举个例子,我想用grep查找所有以err开头的行:

# 正确写法 grep '^err' /var/log/app.log # 错误写法:不加引号 grep ^err /var/log/app.log

不加引号时,Shell把^err当作一个普通单词,在某些情况下可能没问题,但一旦正则里出现*?[这些字符,Shell就会尝试做路径名展开(glob)。比如你想匹配包含数字的行,写成grep [0-9]+ file,如果当前目录下恰好有个文件叫0,Shell会把[0-9]展开成0,然后命令就变成grep 0+ file,完全不是你想要的效果。

另外一个容易踩的坑是:在双引号里写正则,关键词含!时,在交互式Shell里会被历史展开机制吃掉。比如grep "foo!" file,在bash里!后面跟字符可能触发历史命令替换,直接给你报错。我统一建议:正则两边永远套单引号,除非你要在正则里用变量做动态匹配。

2. 核心工具实战:grep、sed、awk 的正则正确打开方式

2.1 grep:筛选是基本功,但“提取”才是进阶

grep是Shell里最常用的正则工具,但很多人对它理解停留在“搜索包含某关键字的行”。实际上,在正则的配合下,grep完全可以做到“提取出想要的内容,而不是整行输出”。

常用参数我整理成一张表:

参数作用实战场景
-E使用ERE扩展正则避免了+?、`
-o只输出匹配到的部分,而不是整行从日志里抠出IP、时间戳
-v反向匹配,输出不包含模式的行过滤掉注释行、空行
-P使用PCRE,支持\d、环视等需要复杂断言时用
-f从文件读取模式,每行一个正则批量匹配多个关键词
-c计数匹配行数快速统计错误次数
-n输出行号定位日志位置
-i忽略大小写不区分大小写的搜索

实际工作中我经常用-o配合正则来抠数据。比如要从访问日志里提取所有IPv4地址:

grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort | uniq -c | sort -rn

这条命令的意思:-o只输出匹配部分,-E用扩展正则,正则本体匹配“1到3位数字加点号重复三次,再接1到3位数字”。管道接sort | uniq -c | sort -rn还能统计每个IP出现的次数并降序排列,这个组合在分析日志时非常好用。

再比如,系统里有多个服务的PID文件,我想提取所有正在监听的端口号:

ss -tlnp | grep -oE ':[0-9]+' | tr -d ':' | sort -n | uniq

这里grep -oE ':[0-9]+'会把类似:8080的部分抠出来,后面的tr -d ':'去掉冒号,再排序去重。用-o的灵活之处就在于,正则只描述你关心的那部分内容,输出也不会被无关信息干扰。

有一点需要提醒:grep -P虽然支持\d这种简洁写法,但某些精简版系统或macOS自带的BSD grep可能不支持-P参数。我写脚本时为了可移植性,一般优先用-E,用[0-9]代替\d,这样在Linux和macOS上都不会出问题。

2.2 sed:替换和删除离不开正则的分组与引用

sed是流编辑器,逐行读取、处理、输出。它最有价值的功能就是利用正则做替换(s命令)和删除(d命令)。很多人背了sed 's/foo/bar/g'这个模板,但遇到更复杂的需求就卡壳。

先说替换命令的结构:sed 's/正则/替换内容/标志'。三个关键点:

  • 正则部分用BRE,默认需要给+?{}|()加反斜杠。想用简洁的ERE写法,加-E参数:sed -E 's/正则/替换/g'
  • 替换内容里用\1\2引用正则分组捕获的内容。
  • 末尾的g表示全局替换,不加的话每行只替换第一个匹配。

我举个例子,假设有这样一个文本文件users.txt,每行格式是“姓名:电话:邮箱”,我想去掉中间的电话,只保留姓名和邮箱:

sed -E 's/^([^:]+):[^:]+:(.*)$/\1:\2/' users.txt

拆解一下:^([^:]+)用分组捕获开头的姓名(连续的非冒号字符),接着:[^:]+:匹配冒号、电话、冒号,最后(.*)$捕获邮箱。替换部分用\1:\2把两组捕获重新拼起来。

这个例子典型展示了正则分组捕获的实际价值——它不只是“找出来”,还能“拆开重组”。

还有个常用技巧是删除空行和注释行:

# 删除空行(含只包含空格的行) sed -E '/^[[:space:]]*$/d' config.conf # 删除以#开头的注释行 sed '/^#/d' config.conf

注意第一行里的[[:space:]],这是POSIX字符类,匹配空格、制表符等空白字符,比写[ \t]更规范,而且在Linux和macOS上行为一致。

sed做文件内替换时有个经典坑:sed -i在Linux(GNU sed)和macOS(BSD sed)上语法不一样。GNU的写法是sed -i 's/foo/bar/g' file,BSD则要求sed -i '' 's/foo/bar/g' file(必须加一个备份后缀参数,空字符串表示不备份)。我写跨平台脚本时一般会先检测系统类型,或者干脆用临时文件加mv的方式避开这个差异。

2.3 awk:正则匹配字段列,超越grep的存在

awk不只是一个文本处理工具,它更像是“面向行的数据引擎”。它把每一行按分隔符拆成多个字段(默认按空格拆分),然后你可以在每个字段上做正则匹配和逻辑操作。

最基本的用法是:awk '正则 {动作}' file。不带字段时,正则匹配整行;带了字段,就只在指定字段上匹配。

比如/etc/passwd文件每行用冒号分隔,我想找出所有使用bash作为Shell的用户:

awk -F: '/bash$/ {print $1}' /etc/passwd

-F:指定冒号为分隔符,/bash$/这个正则匹配行尾是bash的行,print $1输出第一个字段(用户名)。

awk真正的优势是可以在字段上用“正则比较”。比如我只想匹配第二列包含“error”的行:

awk -F, '$2 ~ /error/ {print $1, $2}' data.csv

这里的~是“匹配”运算符,含义是“第二个字段匹配正则/error/”。反过来,!~表示“不匹配”。

再分享一个我在处理监控数据时经常用的组合:统计日志中各错误类型的数量。

awk '{for(i=1;i<=NF;i++) if($i ~ /^ERROR:/) count[$i]++} END {for(k in count) print count[k], k}' app.log | sort -rn

这个脚本遍历每行所有字段,只要字段以ERROR:开头就把它作为键存到count数组里计数,最后输出统计结果。整个过程只用了一条awk命令,配合正则做到了从日志到统计报表的“一站式处理”。

awk的正则用的是ERE,所以+?|直接写就可以,不需要反斜杠。这点跟grep -E一致,和sed不加-E时相反,写的时候要区分清楚。

2.4 三剑客选择指南:什么时候用哪个

很多人总是纠结“这个需求该用grep还是sed还是awk”。我的经验法则很简单:

  • 只要筛选行,用grep。速度最快,写法最简单。
  • 要改内容、按规则替换或删除,用sed。它在流式处理上优势明显。
  • 要做统计、按字段逻辑处理、多条件判断,用awk。它是三者中编程能力最强的。
  • 组合使用grep先筛行,sed再改格式,awk最后统计,这是非常常用的管道流水线。

3. Shell脚本里的正则动态构造:变量传递与循环配合

3.1 引号层次问题:当正则遇上Shell变量

在命令行里写正则是一回事,在Shell脚本里写正则又是另一回事。最大的区别是:脚本里你常常需要把变量拼进正则里做动态匹配。

举个例子,写一个脚本统计日志里某个IP出现了多少次,IP存在变量ip里:

ip="192.168.1.100" grep -c "$ip" access.log

这样写没问题,因为grep的第一个参数被双引号包裹,Shell会把$ip展开成实际的IP字符串。但如果你把双引号换成单引号:

grep -c '$ip' access.log

单引号里$ip不会被展开,grep就会去搜索字面量$ip,结果肯定是0。这就是所谓的“引号层次”问题——单引号内不能展开变量,双引号内可以。

但如果IP里带了正则元字符呢?比如你要匹配“以192.168开头”的IP,几种写法效果完全不同:

# 错误:把.当成正则元字符,会匹配“192x168”这种 grep -c "$ip" access.log # 正确:先用sed转义正则里的点 escaped_ip=$(echo "$ip" | sed 's/\./\\./g') grep -c "$escaped_ip" access.log

我专门写了一个regex_escape函数放在脚本库里,凡是外部输入的变量要进正则,先过一遍转义:

regex_escape() { # 把正则里的特殊字符统一加反斜杠 printf '%s' "$1" | sed -E 's/([.+*?^$()\[\]{}|\\])/\\\1/g' }

使用场景比如:用户输入一个关键词,你希望它只按字面匹配,不要被解释成正则:

read -p "请输入要搜索的关键词: " keyword escaped=$(regex_escape "$keyword") grep "$escaped" data.txt

这个细节极其重要,否则用户输入一个a.b,你本意是搜字母a、任意字符、字母b,结果搜索出来的内容可能完全超出预期。

3.2 正则与for循环:批量处理多个文件模式

Shell脚本里,for循环配合正则最常见的用法有两种:一是遍历文件列表,二是循环处理匹配结果。

第一种场景:批量重命名文件。比如把当前目录下所有.txt后缀改成.md

for file in *.txt; do newname=$(echo "$file" | sed -E 's/\.txt$/\.md/') mv "$file" "$newname" done

这里sed的正则\.txt$匹配结尾的.txt\.md是替换内容。注意点在$符号前面必须加反斜杠转义点号,不然点号会匹配任意字符。

第二种场景:循环处理grep的输出。比如读取日志里所有的ERROR行,逐行提取时间戳和错误消息:

grep 'ERROR' app.log | while IFS= read -r line; do timestamp=$(echo "$line" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}') message=$(echo "$line" | sed -E 's/^.*ERROR: //') echo "时间: $timestamp, 错误: $message" done

这里用了while read而不是for,是因为for按空格拆分会破坏包含空格的日志行,while IFS= read -r line能保证整行作为一个变量传入。这也是Shell脚本里处理带空格文本的标准姿势。

还有个小技巧,for循环里经常需要去掉文件扩展名或获取路径中的目录部分,正则结合参数扩展会更高效:

for file in /var/log/nginx/access.log.*; do # 用正则提取日志文件名中间的日期部分 date_part=$(echo "$file" | grep -oE '[0-9]{8}') echo "$date_part" done

3.3 与其他命令联动:find、expect、Android调试场景

正则表达式在Shell里的第三大价值是“粘合剂”,能把各种命令串成一条流水线。这里举几个我实际用过的场景。

场景一:find配合-regex按模式找文件

默认的find按通配符(*.log)找文件是不够用的,比如我想找所有“包含backup_和后缀是.tar.gz或.zip”的文件:

find /data -regex '.*backup_.*\.\(tar\.gz\|zip\)$'

注意find -regex匹配的是“完整路径”,不是像grep那样匹配行内任意位置,所以正则开头结尾都要写.*

场景二:expect里用正则匹配交互式输出

写自动化脚本时,expect经常用来交互式登录并执行命令。它的expect子命令支持正则匹配:

expect -c ' spawn ssh user@host expect { "password:" { send "mypassword\r" } -re "Permission denied.*" { exit 1 } } '

这里-re表示后面的模式是正则表达式,.*表示任意字符,用于捕获登录失败的情况。

场景三:Android ADB调试里的常见操作

如果你做Android开发或测试,adb shell里也经常用正则处理设备信息。比如查看电池状态中的某个字段:

adb shell dumpsys battery | grep -E 'level:|status:'

再比如用adb shell pm list packages列出包名,然后grep -E筛选包含特定关键词的应用包:

adb shell pm list packages | grep -E 'com\.(google|tencent|alibaba)\.'

这些场景里的正则用法和普通Linux环境完全一致,核心思路都是“先取回文本,再用正则过滤、提取”。

3.4 动态生成正则和性能陷阱

在脚本里动态生成正则时,最容易出问题的不是正则本身,而是转义层级。比如你在sed -E的替换内容里又想嵌一个正则分组,又想输出&字符,会碰到多层转义问题。

我举一个最典型的例子:用sedhttp://链接自动换成https://

sed -E 's|http://|https://|g' file.txt

注意这里我用了|作为分隔符,而不是默认的/,因为http://里面本身就含有斜杠,再用/当分隔符就会产生歧义。同理,替换路径时也建议切换分隔符:

# 把/usr/bin改成/usr/local/bin sed -E 's|/usr/bin|/usr/local/bin|g' path.txt

关于性能,正则匹配在数据量大时会成为瓶颈。最常见的性能陷阱是“灾难性回溯”,尤其是(a+)+这种嵌套量词,在匹配失败时会发生指数级回溯,导致脚本卡死。在Shell工具里遇到这种情况,优先用更具体的字符类来限定匹配范围,尽量避免连续的模糊量词。

还有一个实用经验:能用grep先筛掉大部分数据,再给sedawk做精细化处理,性能会好很多。比如处理100万行日志,先grep 'ERROR'把候选行缩到几千行,再交给awk做统计,比直接awk处理全量快一个数量级。

4. 常见问题与排查技巧实录

4.1 高频错误对照表

写Shell正则时踩过的坑,我整理成一个速查表,基本覆盖了90%的报错和异常:

常见现象根本原因解决方案
grep匹配不到结果,但模式明明是对的忘记加-E+?被当成普通字符grep -E,或把+写成\+
sed -i在macOS上报错BSD sed的-i参数要求显式指定备份后缀sed -i '' 's/foo/bar/g' file
正则里的$被当成变量替换为空参数加了双引号,Shell把$当成了变量标志外层改用单引号,或用\$转义
匹配*号没效果正则写成"*",Shell对双引号内的*做了路径名展开单引号包裹,写成'*'并配合前面的模式
awk匹配变量时用//包变量,匹配不到//里的内容不会被Shell展开$变量 ~ /regex/$变量 ~ pattern
grep -o输出为空正则没分组,或者-o在BSD和GNU下行为差异先确认正则在grep -P下能匹配,再换-E
sed替换内容里的&变成整行&在替换部分有特殊含义,表示整个匹配串需要输出字面&时写成\&
匹配中文字符时结果不对区域设置或字符编码不一致用字符类[一-龥]或设置LC_ALL=C.UTF-8
正则匹配了不想要的行没加行首行尾锚点^$模糊匹配改成精确锚定

4.2 调试正则的三个实用技巧

正则写出来不生效,别反复猜,用对调试手段,十分钟内能定位问题。

技巧一:用echogrep做最小测试

不要在完整脚本里调试,先单独验证正则本身:

echo "192.168.1.100:8080" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}'

看到输出结果再往脚本里放。这个方法简单粗暴,但能快速判断到底是正则错了,还是变量传递错了。

技巧二:用grep -P配合环视测试复杂需求

PCRE的环视(lookahead/lookbehind)是验证边界条件的好工具。比如提取IP但不包含端口号:

echo "192.168.1.100:8080" | grep -oP '^[0-9.]+(?=:)'

(?=:)表示“后面跟着冒号”的断言,但断言本身不参与匹配。测试通过后,如果环境不支持-P,再换成-E配合分组捕获的写法。

技巧三:在脚本里打印正则参数

调试脚本时,最常见的情况是“命令看不出错,但输出不对”。这时候在调用grepsed前加一行调试输出,打印出最终传进去的正则是什么:

echo "DEBUG: pattern=[$pattern]" grep "$pattern" "$file"

很多次我都是靠这个发现变量里混进了换行符或空格。尤其在从配置文件读取变量时,一不小心把换行符带进正则,匹配自然全盘失败。

4.3 关于正则可读性的个人习惯

最后说点脚本维护的体会。正则表达式是出了名的“写起来爽,读起来难”,三个月后回看自己写的脚本,看着那一长串特殊字符,经常要当场重新推导一遍。我自己有几个强制要求,写进团队规范里了:

一是复杂正则必须加注释。Shell本身不支持正则内嵌注释,但可以在正则前面用变量名说明意图:

# 匹配形如 2024-01-15 14:30:00 的时间戳 ts_pattern='[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}' grep -E "$ts_pattern" app.log

二是分段构造。一个超长正则很难看懂,拆成几个变量再拼接:

ip_part='([0-9]{1,3}\.){3}[0-9]{1,3}' time_part='[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}' pattern="^${time_part} .* ${ip_part} .*ERROR" grep -E "$pattern" app.log

三是正则的缩进和空格。Shell的正则不支持松散模式(x模式),但通过变量拼接,至少可以保证每个片段都有一行说明。

5. 写在后面:再分享一个正则调试的小工具习惯

做了这么多年Shell脚本,我越来越觉得,正则这东西本质上不是“背出来的”,而是“试出来的”。你不可能一次性记住所有元字符的优先级和边界行为,但如果你掌握了一套快速验证的方法,任何复杂需求都能在几分钟内跑通。

我自己的调试工作流是这样的:在本地环境里开一个临时脚本文件,比如/tmp/re.sh,内容就是一行echo "$1" | grep -E "$2",然后反复用不同的测试文本去试。这样调试完,再把验证过的正则挪进正式脚本里,非常省事。

另外再提一个点:正则的“贪婪匹配”是新手最容易忽略的。.*组合在一起默认是贪婪的,它会尽可能多地匹配。比如用sed -E 's/<.*>//g'删除HTML标签,结果会把一整行全删光。这时候就要用非贪婪写法,但BRE和ERE里并没有原生的非贪婪语法,常见的替代方案是精确限定字符范围,比如用[^>]*代替.*来匹配标签内部内容。

这个道理放到更大的层面也成立:正则不是越“炫”越好,而是越“贴切”越好。能用字符类精确描述的,就不要用通配符扫;能锚定开头结尾的,就不要让正则去猜边界。写Shell脚本时多花几分钟把正则打磨精准,后面数据处理的结果才会稳定可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询