如果你写的 Shell 脚本里出现过"取 IP、改文件名、拆配置、拼字符串"这类需求,那这讲的内容你会用得着。Bash 本身不是一门以字符串处理见长的语言,但它的参数展开语法把所有常用字符串操作都压缩成了几个符号,用熟了以后写起来非常顺手。这篇是 Bash 系列的第六讲,我把字符串运算符、查找长度、拆分、提取子串、拼接这五块内容一次性讲透,每个操作都会给出语法、示例、边界情况和踩坑笔记,方便你直接照着用。
1. 先理解 Bash 的"字符串运算符"到底是什么
很多教程说到字符串运算符就默认你已经懂了,但实际上这里有个容易被忽略的前提:Bash 里并不存在像 Python 那种+、*一眼就能看懂的运算符体系,它所谓的"字符串运算符",绝大多数其实是参数展开(Parameter Expansion)语法,以及[[ ]]条件表达式里的比较操作符。搞清楚这个底层机制,后面所有操作都顺理成章了。
1.1 条件表达式里的字符串比较运算
最常见的字符串"运算"其实是判断两个字符串是否相等、是否为空,这组操作在[[ ]]里表现最直观:
str1="hello" str2="world" if [[ "$str1" == "$str2" ]]; then echo "相等" else echo "不相等" fi if [[ -z "$str1" ]]; then echo "str1 是空字符串" fi==和!=用于等值判断,-z判断为空,-n判断非空。还有<和>用于字典序比较,比如[[ "$a" < "$b" ]]会按字母顺序比较。这里最关键的一点是:在[[ ]]内部,变量不加引号也不会被分词或路径扩展,但加上引号永远是更稳妥的习惯,尤其当变量可能包含空格或特殊字符时。
1.2 参数展开里的字符串变体运算
真正让 Bash 字符串处理显得"运算符化"的,是下面这一组${}语法,它们的共同点是:输入一个字符串变量,返回一个新的字符串结果。
${var:-default} # var 为空时返回 default,var 原值不变 ${var:=default} # var 为空时把 default 赋给 var 并返回 ${var:?错误信息} # var 为空时报错退出 ${var:+替代值} # var 非空时返回替代值举一个实际例子。写脚本时经常要读配置文件里的端口号,配置缺失时想用默认值 8080:
port=${config_port:-8080} echo "服务端口: $port"如果config_port这个变量是空的,最后port就是 8080;如果配置里写了 9090,那port就是 9090。这个语法我在写部署脚本时几乎天天用,比写if去判断省好几行。
1.3 模式匹配运算:#、##、%、%%、/
这组运算符专门做字符串的"掐头去尾"和替换,属于参数展开里最实用的一族。#从开头匹配删除,%从结尾匹配删除,单个符号是最短匹配,双符号是最长匹配。用一次你就能体会到它的精妙:
filename="archive.tar.gz" echo "${filename%.tar.gz}" # 输出 archive,删除结尾的 .tar.gz echo "${filename%%.*}" # 输出 archive,删除最后一个 . 及其后面内容 echo "${filename#*.}" # 输出 tar.gz,删除第一个 . 之前的内容 echo "${filename##*.}" # 输出 gz,删除最后一个 . 之前的内容 url="https://example.com/path/file.txt" echo "${url#https://}" # 输出 example.com/path/file.txt上面%%.*这种双符号写法是贪婪匹配,会一直找到最后一个.;单符号%.*只删最短匹配,找到第一个.就停。这个差异刚开始很容易搞混,我自己的记忆方法是:符号越double,心越贪婪。替换运算同样干脆:
message="Hello World" echo "${message/Hello/Hi}" # 输出 Hi World,只替换第一个匹配 echo "${message//o/0}" # 输出 Hell0 W0rld,双斜杠替换所有匹配这套 "运算符" 体系比你去写sed -i或awk快得多,因为它不启动外部进程,纯 Bash 内部完成,在循环里处理大量文件时性能优势非常明显。
2. 查找字符串长度:三种主流方式与边界问题
"这个字符串有多长"在脚本里经常是判断输入是否合法、决定是否截断的关键依据。Bash 里至少有三种写法,但它们对"长度"的定义并不完全一样,这个差异会让你在某些场景里踩坑。
2.1 优先级最高的写法:${#var}
语法非常简单:
str="hello" echo "${#str}" # 输出 5注意#在${}内部的作用是取长度,跟上面 1.3 节里${var#pattern}的删除匹配毫不相关,纯粹是位置不同决定了含义不同。如果要统计一个数组有多少个元素,用的是同一个符号:
arr=(a b c d) echo "${#arr[@]}" # 输出 4,获取数组元素个数千万别把${#arr}和${#arr[@]}搞混,前者取的是数组第一个元素的长度,后者才是数组长度,这是我一开始经常犯的错。
2.2expr length的字节数与多字节字符陷阱
expr是外部命令,传统用法是expr length "$str"。问题在于,expr的length在不同环境里对多字节字符的统计口径不一致,有的按字节数,有的按字符数。我在处理中文日志时遇到过这种情况:一个包含中文abc的字符串,expr在某些系统上返回 9(按字节:中文字符 3 字节 × 2 + abc 3 字节),在另一些系统上返回 5(按字符)。这带来的后果是,你的脚本换个系统运行,同一份数据算出的长度就变了。
相比之下,${#var}在主流 Bash 上统计的是字符数,更符合人的直觉。所以我的建议很明确:能用${#var}就不要用expr length,除非你在维护老脚本不能随便改。
2.3wc -L只能用来做特殊判断
wc -L统计的是"最长行的长度",它的本意跟"字符串长度"完全不搭边。有人会用echo "$str" | wc -L来数长度,这在处理单行短字符串时偶尔能碰对,但你要是给它传一个包含换行符的字符串,wc -L返回的是多行里最长的那个宽度,而不是总字符数。比如:
str=$'hello\nworld' echo "$str" | wc -L # 输出 5,因为 hello 和 world 都是 5 个字符这个行为很容易让脚本在"多行文本"场景下拿到错误结果。要数整个字符串的字符数,还是${#str}最可靠。
3. 拆分字符串:IFS 的三板斧与两个隐蔽陷阱
拆分字符串在 Shell 世界里是个高频操作,比如把"a,b,c"拆成三个独立参数,或者把一行日志按空格拆成字段。Bash 的原生方案靠的是 IFS(Internal Field Separator)加上read或数组,而不是像split()那样直接调用一个函数。
3.1 按任意分隔符拆成数组:IFS+read -ra
最常见也最稳的拆分方式是临时修改 IFS,配合read把结果读进数组:
data="apple,banana,cherry" IFS=',' read -ra fruits <<< "$data" for fruit in "${fruits[@]}"; do echo "水果: $fruit" done这段代码把data按逗号拆成了三个元素,存入数组fruits。这里有一个大坑:read -ra会受 IFS 影响完成自动切分,但read命令本身对 IFS 的首字符有特殊含义——它会作为行结束符的判定参考。日常用<<<输入单行文本不会触发这个坑,但如果你在处理多行文本时用IFS=',' read -ra,每行读入时逗号可能被误认,导致结果很奇怪。
提示:把 IFS 写在同一行命令的变量赋值前缀里,这个 IFS 只对当前这条命令生效。如果你是先单独
IFS=','再执行其他命令,那么后续的所有命令都会受影响,这是拆分操作里最常见的翻车原因。
3.2 按换行拆分的正规军:mapfile
如果你的字符串是一整个多行文本,比如读取配置文件内容,mapfile是最干净的方案:
content=$'第一行\n第二行\n第三行' mapfile -t lines <<< "$content" printf '共 %d 行\n' "${#lines[@]}" for line in "${lines[@]}"; do echo "行内容: $line" done-t会去掉每行末尾的换行符,这个细节很容易被忽略。如果不加-t,每个数组元素都会带一个\n,你后续做字符串拼接或提取时就会多出看不见的字符,也没法通过肉眼快速排查。另外要注意mapfile默认保留空行,如果文本中存在连续多个换行,数组中会出现空字符串元素,写循环时要做好空值过滤。
3.3 原地修改 IFS 必须记住恢复
很多人习惯这样写:
IFS=',' arr=($data) unset IFS这种做法在能跑的同时埋着雷。arr=($data)依赖分词完成拆分,但分词机制还涉及路径扩展,如果某个分隔出的字段里包含*、?这类通配符,它会被当成文件名匹配去展开,结果字段莫名其妙变成了匹配到的文件名。我个人更推荐用read -ra或mapfile,它们绕开了路径扩展这一层,行为更可控。
如果脚本老代码确实用了arr=($data)模式,至少要学会保存和恢复 IFS:
OLDIFS=$IFS IFS=',' arr=($data) IFS=$OLDIFS4. 提取子字符串:offset 语法的完整规则
提取子串对应的是其他语言里的substr(),Bash 的参数展开里用偏移量来实现。语法是${var:offset:length},offset 从 0 计数,length 可以不写,表示取到末尾。
4.1${var:offset:length}的边界行为
看几个例子:
str="Hello, Bash World" echo "${str:0:5}" # Hello,从第 0 个字符取 5 个 echo "${str:7:4}" # Bash,从第 7 个字符取 4 个 echo "${str:7}" # Bash World,从第 7 个字符取到末尾offset 如果超过字符串长度,结果不会报错,而是返回空字符串。这点和很多语言不一样,是一种"静默失败"的设计,所以我在脚本里如果担心越界,通常会先做一次长度判断,再决定是否提取。
另一个容易忽略的点:length 参数不能为负数。如果你试图${str:7:-2}表达"截掉末尾 2 个字符",Bash 会直接报参数错误。要实现"去掉末尾两个字符"的效果,正确姿势是用${str%??}:
str="hello world" echo "${str%??}" # 输出 hello wor,删掉了最后两个字符4.2 负数 offset:从字符串末尾数起
offset 为负时,表示从字符串末尾向前数,此时 offset 前需要一个空格或括号,否则会跟:-的默认值语法冲突:
str="abcdef" echo "${str: -3}" # 输出 def echo "${str: -4:2}" # 输出 cd第一次接触这个写法的人几乎都会在${str:-3}和${str: -3}之间犯错。前者表示"如果 str 为空则返回 3",后者才是从倒数位置开始提取。空格不是可有可无的装饰,它是 Bash 区分两种语义的关键。如果觉得空格容易忘记,用括号也行:
echo "${str:(-3)}" # 输出 def我自己在实际运维场景里最爱用负数 offset 提取文件后缀或日期部分,比如从20250115_backup.tar.gz里取出最后 7 个字符判断压缩格式。
4.3 和cut、expr substr的取舍对比
cut和expr都能做子串提取,什么时候用它们?我的结论是:在纯 Bash 脚本里优先用${var:offset:length},因为它不启动外部进程。批量处理成千上万个字符串时,内建语法和外部命令的性能差距是数量级的。
不过有两种例外。一种是要按字节而不是字符截取时,cut -b比 Bash 参数展开更直接(虽然 Bash 5.x 也支持${var:offset:length}按字符处理,多字节环境里两者的行为差异需要仔细验证)。另一种是代码可移植性要求极高,目标环境不一定用 Bash 时,cut和expr反而更通用。我的经验是:公司内部的脚本基本锁定 Bash,没必要为极端可移植性牺牲性能和可读性。
5. 拼接字符串:四种写法、引号细节与性能
拼接看起来最简单,就是把两段字符串接到一起,但这里有几个引号层面的细节一不留神就会掉进去。
5.1 直接拼接与+=
Bash 里直接并排写变量和字符串字面量就是拼接:
name="world" greeting="Hello, ${name}!" echo "$greeting" # Hello, world!拼接的关键原则是:用大括号明确变量边界。比如"$name_suffix"这个写法 Bash 会认为变量名是name_suffix,而不是name连接_suffix。写成像${name}_suffix这样,语义才清晰。
+=运算符是追加模式,可以原变量直接追加:
log="" log+="[INFO] 服务启动 " log+="端口 8080" echo "$log" # [INFO] 服务启动 端口 8080这在循环里累积日志内容时效率很高,比每次log="$log$new"写起来干净得多。要注意+=的行为在不同上下文里有差异:如果变量是普通字符串,就是追加文本;如果是数组,就相当于往数组里追加一个元素。
5.2 用printf做多变量拼接
当你要把很多变量拼成一行,且中间夹着分隔符时,printf是最清晰的写法:
host="192.168.1.10" port="3306" user="app" conn_str=$(printf "%s:%s@%s" "$user" "$port" "$host") echo "$conn_str" # app:3306@192.168.1.10printf的好处是格式完全由你控制,不会因为某个变量恰好以-开头而跟echo产生歧义,也不会因为变量内容含特殊字符导致意外扩展。我在脚本里处理日志格式、生成告警消息、拼 SQL 时都用它。
5.3 数组 join:把数组元素拼成新的字符串
有时你不仅仅想拼两个字符串,而是想把一个数组的所有元素拼成一个带分隔符的字符串。Bash 的做法是把数组展开成"${arr[*]}",然后利用 IFS 的首字符作为分隔符:
arr=("2025" "01" "15") IFS=- joined="${arr[*]}" echo "$joined" # 2025-01-15这里我用了命令前缀方式让IFS=-只影响当前命令,避免污染后续环境。"${arr[*]}"和"${arr[@]}"的区别必须清楚:双引号内*号会以 IFS 首字符连接所有元素成为一个字符串,@号会保留每个元素为独立参数。想要 join 效果就用*,想要逐元素处理就用@。
6. 把这些操作串起来:一个日志解析脚本
理论讲完,来一个能直接落地的小脚本,把长度、拆分、提取、拼接全部用上。场景是:某台服务器上有一些形如20250115_nginx_access.log的日志归档,需要提取日志文件名里的日期部分、判断文件大小、统计非空行数,最后把结果拼成一行摘要写进汇总文件。
#!/usr/bin/env bash log_dir="/var/log/archives" summary_file="/tmp/log_summary.txt" : > "$summary_file" for log_file in "$log_dir"/*.log; do [ -e "$log_file" ] || continue filename=$(basename "$log_file") # 1. 提取日期部分:删除下划线及其后面内容 date_part="${filename%%_*}" # 2. 提取扩展名:删除最后一个点之前内容 ext="${filename##*.}" # 3. 拆分文件名其余部分:按下划线拆分 tool_part="${filename%.log}" IFS=_ read -ra parts <<< "$tool_part" tool_name="${parts[0]}" date_info="${parts[1]}" # 4. 计算文件大小和行数 file_size=$(stat -c%s "$log_file") line_count=$(wc -l < "$log_file") # 5. 拼接摘要 summary=$(printf "%s | 日期=%s | 工具=%s | 大小=%s字节 | 非空行=%s\n" \ "$filename" "$date_part" "$tool_name" "$file_size" "$line_count") echo "$summary" >> "$summary_file" done cat "$summary_file"这个脚本里的逻辑是典型的"生产环境会遇到的组合":basename拿到纯文件名,%%_*从文件名里剥离出日期,read -ra配合 IFS 把20250115_nginx拆成20250115和nginx两个字段,printf负责最终拼装。整个过程没有用外部awk或sed,执行效率在批量文件场景下非常高。
6.1 脚本里的两个坑
第一个坑是stat -c%s在 BSD 系的系统上格式参数不同,有的环境要用stat -f%z。如果你有跨平台需求,这个命令不建议硬编码,最好封装成一个函数或者改用wc -c。第二个坑是wc -l < "$log_file"统计的是换行符数量,如果文件最后一行没有换行符,行数会比实际少 1。对于日志文件,我一般会接受这个误差,但你要是写统计类工具就要注意补一个尾部换行判断。
6.2 配套的规避清单
| 操作 | 推荐写法 | 关键注意事项 |
|---|---|---|
| 长度 | ${#var} | 字符数而非字节数;数组长度用${#arr[@]} |
| 拆分 | IFS=... read -ra arr <<< "$str" | 临时 IFS 前缀,避免原地修改后污染后续命令 |
| 按行拆分 | mapfile -t arr <<< "$str" | -t去掉换行符,注意空行会保留 |
| 提取子串 | ${var:offset:length} | 负数 offset 前必须有空格或括号 |
| 删除匹配 | ${var#pattern}/${var%pattern} | 单符号最短匹配,双符号最长匹配 |
| 拼接 | str+="..."或printf | 大括号圈定变量名边界,避免误读变量名 |
| 数组 join | IFS=x joined="${arr[*]}" | 只会用 IFS 第一个字符做分隔符 |
6.3 更进阶的组合用法
掌握基础后可以继续组合出更高级的操作,比如同时做"取子串再加默认值":
url="https://example.com" path="${url#https://}" echo "${path:-unknown}" # 如果 url 不以 https:// 开头,整个 url 都会被当作 path这里#删除匹配和:-默认值属于不同层级的功能,但参数展开允许这种嵌套写法。再比如用数组拼接做动态分隔符的 CSV 行生成:
fields=("id" "name" "age") IFS=, csv="${fields[*]}" echo "$csv" # id,name,age做数据导出、批量改文件名、解析命令行常量时,这套组合能让你完全不需要引入 Python 或 Perl,写出的纯 Bash 脚本在服务器上也没有额外依赖,部署成本几乎为零。
我在实际工作中最大的体会是:Bash 的字符串处理语法虽然刚开始看起来符号密集、规则碎,但它的设计是高度一致的——所有操作都围绕${}和几个修饰符展开。你不需要背语法表,只要理解"#表示开头、%表示结尾、/表示替换、:表示偏移或默认值"这套基础逻辑,遇到新需求时自然能推导出写法。希望这篇能帮你少走一些我当时反复查手册的弯路。