如果说循环是脚本的发动机,那字符串处理就是它的方向盘。日常写 shell 脚本,绕不开两类事情:一是处理半结构化的文本——日志、配置、CSV、URL、文件名;二是从一堆乱七八糟的字符里挑出我们想要的部分。前者靠 shell 自带的字符串操作和变量展开就能搞定,后者就得靠正则。两个东西合起来,构成了 shell 文本处理的核心。
这一篇我们把字符串处理和正则匹配一起讲清楚。重点不是罗列语法,而是让你看完之后知道什么场景该用什么工具。
一、字符串基础操作
在 shell 里,字符串的地位比大多数语言都高——所有变量在你不显式声明类型的情况下,默认就是字符串。所以搞懂 shell 字符串,是写好脚本的必修课。
1.1 单引号、双引号、不加引号的区别
这个话题被讲烂了,但它就是字符串处理的入口,不说不行。
#!/bin/bash
name="张三"
age=25
echo '$name 今年 $age 岁'
echo "$name 今年 $age 岁"
echo $name 今年 $age 岁
输出:
$name 今年 $age 岁
张三 今年 25 岁
张三
今年
25
岁
三种写法的差异一目了然:
- 单引号
'...':所见即所得,里面所有字符都原样输出,变量不会被替换,转义字符也无效。 - 双引号
"...":里面的变量会被替换,转义字符(如\n、\t)也生效。 - 不加引号:等同于双引号,但会按 IFS 切分。如果字符串里有空格,又没加引号,就会被当成多个参数传给 echo。上面第三个 echo 之所以变成五行,就是因为
$name后面那个空格被当成了分隔符。
实操经验:除非你确定字符串里没有特殊字符,任何时候都加双引号。这是 shell 编程的"第一铁律",能避免 80% 莫名其妙的 bug。
1.2 字符串拼接
shell 里没有专门的字符串拼接运算符,直接挨着写就行:
#!/bin/bash
first="Hello"
last="World"
greeting="$first, $last!"
echo $greeting
输出:Hello, World!。注意这里 $first, 的逗号和后面的空格都是字面字符,会原样出现在结果里。如果你想要更明确的"分隔",也可以这样:
greeting="${first}, ${last}!"
${first} 比 $first 更稳——花括号明确告诉 shell 变量的边界,避免它把 first, 当成一个未定义的变量名(虽然不会报错,但会留空格)。
1.3 获取字符串长度
#!/bin/bash
str="hello world"
echo "长度:${#str}"
输出:长度:11。${#var} 是 shell 内置的"取长度"语法,比调用 expr length "$str" 或者 echo -n "$str" | wc -c 快得多。
1.4 子字符串提取
shell 也内置了子串提取,语法是 ${var:offset:length}:
#!/bin/bash
str="hello world"
echo "${str:0:5}" # 从第 0 个字符开始,取 5 个
echo "${str:6}" # 从第 6 个字符开始,取到末尾
echo "${str: -5}" # 倒数 5 个字符
echo "${str:6:5}" # 从第 6 个字符开始,取 5 个
输出:
hello
world
world
world
注意 str: -5 前面有一个空格——这个空格是语法的一部分,用来和"从正数位置开始"区分开。少了这个空格,shell 会把 0 当作负数起点,结果可能不是你要的。
提取子串是 shell 的强项之一。比起 cut、awk 这些外部命令,这种纯字符串展开速度更快、依赖更少。
二、变量展开
如果说字符串基础是入门,那变量展开就是 shell 字符串处理的核心。shell 提供了一组基于 ${var...} 的展开语法,能完成"删除前后缀"、“替换”、"默认值"等操作,写起来非常紧凑,也是和 Python、Go 这类语言最大的区别之一。
2.1 删除前后缀
这类操作在处理文件名、URL、配置项的时候特别有用。语法有四个:
${var#pattern}:从左边开始,删除最短匹配${var##pattern}:从左边开始,删除最长匹配${var%pattern}:从右边开始,删除最短匹配${var%%pattern}:从右边开始,删除最长匹配
光看描述容易晕,直接看例子:
#!/bin/bash
path="/usr/local/bin/bash"
echo "${path#*/}" # 删左最短
echo "${path##*/}" # 删左最长
echo "${path%/*}" # 删右最短
echo "${path%%/*}" # 删右最长
输出:
usr/local/bin/bash
bash
/usr/local/bin
(empty)
解释一下:
${path#*/}:从左边开始,删掉第一个/以及它之前的所有内容。结果留下usr/local/bin/bash。${path##*/}:从左边开始,删掉最后一个/之前的所有内容。结果留下bash——这就是取文件名的经典写法。${path%/*}:从右边开始,删掉第一个/以及它之后的所有内容。结果留下/usr/local/bin——取目录的标准姿势。${path%%/*}:从右边开始,删掉最后一个/之后的所有内容。因为路径以/开头,所以把全部内容都删了,结果是空。
这四个语法是处理路径的"四件套",务必熟练。一个真实的例子,批量改文件名:
#!/bin/bash
for file in *.txt
do
[ -e "$file" ] || continue
newname="${file%.txt}.md"
mv "$file" "$newname"
echo "$file -> $newname"
done
${file%.txt} 是从右边删掉最短匹配 .txt,得到不带后缀的文件名,再拼上 .md。这比 $(echo $file | sed 's/.txt$//') 快得多,可读性也更好。
通配符 * 匹配任意字符串,记住一个规律:
#和##后面接的模式,是"我不要的左半部分"%和%%后面接的模式,是"我不要的右半部分"
2.2 字符串替换
shell 也支持变量内替换,语法是 ${var/old/new}:
#!/bin/bash
str="hello world, hello shell"
echo "${str/hello/hi}" # 只替换第一个
echo "${str//hello/hi}" # 替换所有
echo "${str/#hello/hi}" # 只替换开头
echo "${str/%shell/hi}" # 只替换结尾
输出:
hi world, hello shell
hi world, hi shell
hi world, hello shell
hello world, hi hi
四种写法的区别:
- 一个
/:只替换第一个匹配 - 两个
/(//):替换所有匹配 #:只匹配开头%:只匹配结尾
注意 ${str/#hello/hi} 要求 hello 在开头才替换,而 ${str/hello/hi} 是任意位置的第一个。如果 old 包含特殊字符(.、*、[),它们在 shell 展开里被当作通配符,不是正则。要做真正的正则替换,用 sed。
2.3 默认值与变量检查
shell 提供了一套"如果变量未定义或为空就怎样"的语法,是写健壮脚本的关键:
| 语法 | 含义 |
|---|---|
${var:-default} | var 未定义或为空,返回 default(var 不变) |
${var:=default} | var 未定义或为空,把 default 赋给 var 并返回 |
${var:+alt} | var 已定义且非空,返回 alt;否则返回空 |
${var:?msg} | var 未定义或为空,打印 msg 并退出脚本 |
实战中 :- 和 := 用得最多。一个典型的场景是读环境变量:
#!/bin/bash
# 如果 ENV 没设置,使用默认值
: "${ENV:=production}"
echo "当前环境:$ENV"
: "${ENV:=production}" 里的 : 是 shell 的空命令(啥也不干),它配合 ${...} 用作"赋值"语句。这行代码的副作用是:如果 $ENV 没被设置,就把它设为 production。比写 if 判断简洁多了。
${var:?msg} 在写脚本时也很实用,可以用来检查必填参数:
#!/bin/bash
: "${1:?用法: $0 <文件名>}"
echo "处理文件:$1"
如果调用脚本时没传参数,shell 会直接报错退出:
用法: ./script.sh <文件名>
这种"快速失败"的写法比在脚本开头写一堆 if 判断要优雅得多。
三、字符串比较
字符串操作说完了,接下来是比较。shell 里字符串比较有两种写法:经典的 test 命令和更现代的 [[ ]] 复合命令。
3.1 用 test 命令比较
#!/bin/bash
a="hello"
b="world"
[ "$a" = "$b" ] && echo "相等" || echo "不相等"
[ "$a" != "$b" ] && echo "不等" || echo "等"
[ -z "$a" ] && echo "a 为空" || echo "a 非空"
[ -n "$a" ] && echo "a 有内容" || echo "a 没内容"
输出:
不相等
不等
a 非空
a 有内容
[ 实际上是 test 命令的简写,两边必须有空格,里面的运算符两侧也必须有空格。这是 shell 字符串比较最常见的语法。
四个常用操作符:
=:相等!=:不等-z:字符串长度为零-n:字符串长度非零
注意 = 和 == 在 [ ] 里是等价的,但 == 在 [[ ]] 里是更"现代"的写法,逻辑上更清晰。
3.2 用 [[ ]] 复合命令
[[ ]] 是 bash 提供的增强版条件判断,它解决了一些 [ ] 的老问题:
#!/bin/bash
file="report.log"
# 经典写法
[ -e "$file" ] && echo "文件存在"
# 增强写法
[[ -e "$file" ]] && echo "文件存在"
[[ "$file" == *.log ]] && echo "是日志文件"
[[ "$file" =~ .log$ ]] && echo "以 .log 结尾"
[[ ]] 的几个优势:
- 不需要对变量加双引号(内部不会做 word splitting)
- 支持
==通配符匹配(*.log会被当作模式而不是字面字符串) - 支持
=~正则匹配(这才是重点,我们后面会细讲)
如果你的脚本只在 bash 下跑,优先用 [[ ]] ;如果需要兼容 sh、dash,就老老实实用 [ ]。
四、正则匹配
[[ ]] 里的 =~ 是 bash 给我们的小礼物——它让你不用调用 grep、sed 就能做正则匹配。这对"判断一个字符串是否符合某种模式"特别有用。
4.1 基础匹配
#!/bin/bash
email="user@example.com"
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$ ]]
then
echo "邮箱格式正确"
else
echo "邮箱格式错误"
fi
[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@...$ ]] 的意思是:用右边的正则去匹配 $email,匹配成功就返回 0。=~ 默认使用的是扩展正则(ERE),不是基础正则(BRE),所以 +、?、{n,m} 这些元字符可以直接用,不需要反斜杠转义。
注意正则两边的引号:当正则里没有空格时,可以不加引号;但有空格时必须加引号,否则会被 shell 切碎。
4.2 捕获组
=~ 还有一个杀手级特性——它会把正则里的捕获组自动存到数组 BASH_REMATCH 里:
#!/bin/bash
log="2024-01-15 10:23:45 [ERROR] 用户登录失败"
if [[ "$log" =~ ^([0-9-]+)\ +([0-9:]+)\ +[([A-Z]+)]\ +(.*)$ ]]
then
echo "日期:${BASH_REMATCH[1]}"
echo "时间:${BASH_REMATCH[2]}"
echo "级别:${BASH_REMATCH[3]}"
echo "消息:${BASH_REMATCH[4]}"
fi
输出:
日期:2024-01-15
时间:10:23:45
级别:ERROR
消息:用户登录失败
BASH_REMATCH[0] 是整个匹配的字符串,[1]、[2]… 是各个捕获组。这个特性在解析日志、URL、配置文件时极其方便,完全不用起 awk 子进程。
4.3 一个完整的例子:校验密码强度
#!/bin/bash
read -p "请输入密码: " -s pwd
echo
if [[ ${#pwd} -lt 8 ]]
then
echo "密码太短,至少 8 位"
exit 1
fi
if [[ ! "$pwd" =~ [A-Z] ]]
then
echo "密码必须包含大写字母"
exit 1
fi
if [[ ! "$pwd" =~ [a-z] ]]
then
echo "密码必须包含小写字母"
exit 1
fi
if [[ ! "$pwd" =~ [0-9] ]]
then
echo "密码必须包含数字"
exit 1
fi
echo "密码强度合格"
这段脚本用 =~ 做了四层校验,每一层都是单独的正则匹配。比起调用 grep 一次次匹配,纯 bash 实现既快又干净。
五、grep日志分析
[[ =~ ]] 适合"在 shell 脚本里嵌入简单判断",但要做复杂的文本分析,还得靠 grep、sed、awk 三大文本处理工具。先看 grep。
5.1 最常用的选项
# 在 file.log 里找包含 "ERROR" 的行
grep "ERROR" file.log
# 忽略大小写
grep -i "error" file.log
# 显示行号
grep -n "ERROR" file.log
# 反向匹配(不包含 "DEBUG" 的行)
grep -v "DEBUG" file.log
# 显示匹配行后 2 行(-A: After)
grep -A 2 "异常" file.log
# 显示匹配行前 2 行(-B: Before)
grep -B 2 "异常" file.log
# 显示匹配行前后各 2 行(-C: Context)
grep -C 2 "异常" file.log
# 统计匹配的行数
grep -c "ERROR" file.log
# 只输出匹配到的部分,不输出整行
grep -o "[0-9]+.[0-9]+.[0-9]+.[0-9]+" file.log
5.2 基础正则 vs 扩展正则
grep 默认用基础正则(BRE),想用扩展正则(ERE)需要 -E。两者的主要区别在于元字符是否需要反斜杠:
# BRE:+ 需要转义
grep "ERROR+" file.log
grep "ERROR+" file.log
# ERE:+ 不需要转义
grep -E "ERROR+" file.log
其他常见的 BRE/ERE 差异:
| 字符 | BRE | ERE |
|---|---|---|
+ | + | + |
? | ? | ? |
() | () | () |
{} | {} | {} |
| ` | ` | | |
实操建议:日常用 grep -E 或者 egrep(两者等价),可读性更好。除非你在写非常底层的脚本,否则没必要和反斜杠较劲。
5.3 递归搜索
# 在当前目录所有 .log 文件里找 "ERROR"
grep -rn "ERROR" --include="*.log" .
# 排除某些目录
grep -rn "ERROR" --exclude-dir={.git,node_modules} .
# 排除某些文件
grep -rn "ERROR" --exclude="*.tmp" .
-r 递归,-n 显示行号,--include 和 --exclude 限定文件类型。日常查日志基本就是这几条命令的组合。
5.4 用 grep 提取字段
-o(only matching)配合 -P(Perl 风格正则)能实现相当复杂的字段提取:
# 从日志里提取所有 IP 地址
grep -oP "\d+.\d+.\d+.\d+" access.log
# 用 \K 排除掉不想要的部分(Perl 风格特有)
# 下面这行只输出引号里的内容,不包括引号本身
echo 'name="张三"' | grep -oP 'name="\K[^"]+'
-P 让 grep 使用 PCRE(Perl Compatible Regular Expressions),支持 \d、\s、\K 这些更现代的语法。代价是某些精简环境不支持,需要 GNU grep。
六、sed流编辑器
sed 是 “stream editor” 的缩写,它的核心思想是"按行处理,每行执行一组命令"。如果说 grep 是"找东西",那 sed 就是"改东西"。
6.1 最常用:替换
# 把每行第一个 "foo" 替换成 "bar"
sed 's/foo/bar/' file.txt
# 把所有 "foo" 替换成 "bar"(加 g 标志)
sed 's/foo/bar/g' file.txt
# 只替换第 3 行
sed '3s/foo/bar/' file.txt
# 替换第 2 到 5 行
sed '2,5s/foo/bar/g' file.txt
# 直接修改原文件(慎用)
sed -i 's/foo/bar/g' file.txt
sed 's/old/new/' 是 sed 最常用的命令。s 是 substitute 的意思,后面跟三个斜号分隔的部分:被替换的内容、替换成什么、标志(g 表示全局替换)。注意默认只替换每行的第一个匹配,要替换所有必须加 g。
-i 是 “in-place” 修改,会直接改原文件。生产环境用之前最好先不带 -i 跑一遍,确认输出符合预期再 sed -i。
6.2 删除行
# 删除第 3 行
sed '3d' file.txt
# 删除第 2 到 5 行
sed '2,5d' file.txt
# 删除空行
sed '/^$/d' file.txt
# 删除以 # 开头的注释行
sed '/^#/d' file.txt
d 是 delete,配合正则可以做条件删除。/^$/ 表示空行(行首紧跟行尾),/^#/ 表示以 # 开头。
6.3 插入和追加
# 在第 3 行前插入
sed '3i\插入的新行' file.txt
# 在第 3 行后追加
sed '3a\追加的新行' file.txt
# 替换整行
sed '3c\整行替换' file.txt
i 是 insert,a 是 append,c 是 change。这些在写配置文件模板时偶尔会用。
6.4 多个命令组合
sed 一次可以执行多条命令,用 -e 或者分号分隔:
# 同时删除空行和注释行
sed -e '/^$/d' -e '/^#/d' file.txt
# 等价写法
sed '/^$/d; /^#/d' file.txt
6.5 例子:批量修改配置
# 把所有 yaml 文件里的 image: nginx 改成 image: nginx:1.25
find . -name "*.yaml" -exec sed -i 's/image: nginx$/image: nginx:1.25/' {} ;
find 找文件,-exec 让 sed 对每个文件执行替换。这是 K8s 运维、CI/CD 脚本里非常常见的模式。
七、awk报表生成
如果说 grep 是"找",sed 是"改",那 awk 就是"算"。awk 是一门完整的脚本语言,但日常用得最多的是它的字段处理和统计能力。
7.1 基础语法
awk 'pattern { action }' file
pattern是过滤条件,匹配的行才执行 actionaction是要执行的操作,默认动作是print $0(打印整行)
7.2 字段处理
# 打印文件第 3 列(默认空格分隔)
awk '{ print $3 }' file.txt
# 打印第 1 和第 3 列
awk '{ print $1, $3 }' file.txt
# 自定义分隔符
awk -F: '{ print $1 }' /etc/passwd
# 多个分隔符(用 | 或正则)
awk -F'[,:]' '{ print $1 }' file.txt
# 输出分隔符(OFS)
awk -F: -v OFS=' -> ' '{ print $1, $7 }' /etc/passwd
$1、$2… 是第 N 个字段,$0 是整行,NF 是字段总数,NR 是当前行号。这几个内置变量是 awk 最常用的"接口"。
7.3 条件过滤
# 只处理第 3 列大于 100 的行
awk '$3 > 100 { print $1, $3 }' file.txt
# 匹配包含 "ERROR" 的行
awk '/ERROR/ { print }' file.txt
# 范围匹配(从 "开始" 到 "结束")
awk '/START/,/END/' file.txt
7.4 BEGIN 和 END
awk 有两个特殊模式 BEGIN 和 END,分别在处理开始前和处理结束后执行:
awk 'BEGIN { print "开始统计" }
{ sum += $1; count++ }
END { print "总和:", sum, "平均:", sum/count }' numbers.txt
BEGIN 块常用来打印表头、初始化变量,END 块常用来输出统计结果。这两个块的存在让 awk 在做汇总统计时比 sed 顺手得多。
7.5 例子:分析访问日志
# 统计 access.log 里每个 IP 的访问次数,按次数降序
awk '{ count[$1]++ } END {
for (ip in count) print count[ip], ip
}' access.log | sort -rn | head -10
这段组合拳输出访问次数最多的 10 个 IP。awk 负责计数(count[$1]++ 是关联数组的经典用法),sort -rn 负责排序,head 负责取前 10。
八、字符串处理与正则的组合实战
理论和命令分开讲都比较碎,最后给几个综合例子,把前面学的东西串起来。
8.1 解析 URL
#!/bin/bash
url="https://www.example.com:8080/path/to/resource?id=123&name=test#section"
# 用 =~ 解析
if [[ "$url" =~ ^https?://([^:/]+):?([0-9]*)(/[^?#]*)??([^#]*)(#.*)?$ ]]
then
host="${BASH_REMATCH[1]}"
port="${BASH_REMATCH[2]}"
path="${BASH_REMATCH[3]}"
query="${BASH_REMATCH[4]}"
frag="${BASH_REMATCH[5]}"
echo "协议: https"
echo "主机: $host"
echo "端口: ${port:-443}"
echo "路径: $path"
echo "查询: $query"
echo "片段: $frag"
fi
输出:
协议: https
主机: www.example.com
端口: 8080
路径: /path/to/resource
查询: id=123&name=test
片段: #section
这种"一个正则搞定 URL 解析"的方式比开 python -c "import urlparse..." 要快得多,也更符合 shell 脚本的"轻量"定位。
8.2 提取文件名、扩展名、目录
#!/bin/bash
filepath="/home/user/docs/report.tar.gz"
# 完整文件名
filename="${filepath##*/}" # report.tar.gz
# 不带扩展名的文件名
basename="${filename%.*}" # report.tar
# 真正的"主名"(去掉最后一个扩展名)
mainname="${filepath##*/}" # 先取文件名
mainname="${mainname%.*}" # 再去最后一个扩展名
echo "主名: $mainname" # report
# 最后一个扩展名
ext="${filepath##*.}" # gz
# 目录
dir="${filepath%/*}" # /home/user/docs
# 当前目录
dir="$( cd "$( dirname "$filepath" )" && pwd )"
echo "文件名: $filename"
echo "主名: $mainname"
echo "扩展名: $ext"
echo "目录: $dir"
这种"前后缀删除"的组合是 shell 脚本里的常用模式。## 和 % 用好了,能省掉很多 sed 和 awk 调用。
8.3 日志分析:找出耗时最长的请求
#!/bin/bash
# 假设 access.log 每行格式: 时间 状态 耗时(ms) 路径
awk '$3 > 1000 {
print $3, $4
}' access.log | sort -rn | head -20
或者用 [[ =~ ]] 做更精细的过滤:
#!/bin/bash
while IFS=' ' read -r time status duration path
do
# 用正则判断 path 是不是 API 请求
if [[ "$path" =~ ^/api/ ]] && [ "$duration" -gt 1000 ]
then
echo "慢请求: $path 耗时 ${duration}ms"
fi
done < access.log
8.4 校验输入格式
#!/bin/bash
validate_ip() {
local ip=$1
if [[ ! "$ip" =~ ^([0-9]{1,3}.){3}[0-9]{1,3}$ ]]
then
return 1
fi
# 检查每个段是否在 0-255
IFS='.' read -r a b c d <<< "$ip"
for seg in $a $b $c $d
do
if [ "$seg" -gt 255 ] || [ "$seg" -lt 0 ]
then
return 1
fi
done
return 0
}
read -p "请输入 IP: " ip
if validate_ip "$ip"
then
echo "IP 合法"
else
echo "IP 非法"
fi
<<< 是 here-string,把字符串当作标准输入传给 read。配合 IFS='.' 就能按点切分 IP。
九、总结
字符串处理和正则匹配是 shell 脚本的"日常工具箱",熟练之后能解决 90% 的文本问题。我们这一篇覆盖的内容回顾一下:
- 字符串基础:单/双引号区别、拼接、长度、子串提取
- 变量展开:
#、##、%、%%删前后缀;/、//替换;:-、:=、:+、:?默认值 - 字符串比较:test 命令 vs
[[ ]]复合命令 - 正则匹配:bash 内置的
=~和BASH_REMATCH - 三剑客:grep 查找、sed 改写、awk 统计
- 实战场景:URL 解析、文件路径处理、日志分析、输入校验
下个主题我们顺着这条线往更深入走——聊一聊 shell 里的数组、关联数组和 mapfile 这些"容器",以及它们和循环、字符串处理怎么配合起来写更复杂的数据处理脚本。

1196

被折叠的 条评论
为什么被折叠?



