1. 为什么值得花时间把Shell变量和字符串吃透
很多人第一次写Shell脚本,都是从一行echo "hello world"开始的。写完之后觉得“不过如此”,然后真正上手干活时,立刻被各种问题打脸:变量赋值多了空格报错、字符串里带空格被拆成好几段、$var和${var}混着用结果输出完全不是自己想要的、明明变量有值却判断为空……这些坑几乎每个写Shell的人都踩过。
Shell脚本入门这件事,变量和字符串是绕不过去的第一道坎。它不像Python那样有严格的类型系统帮你兜底,也不像Java那样编译器会提前告诉你哪里写错了。Shell的变量是弱类型的,字符串和数字经常混在一起用,语法又极其依赖空格和引号,稍不留神就是“语法正确但结果错误”的隐蔽bug。所以我把变量和字符串单独拎出来做一次系统梳理,把赋值、引用、拼接、截取、替换、比较这些高频操作讲透,同时把那些文档里不会写、只有实际踩过才知道的坑一并说清楚。
这篇文章适合刚接触Shell脚本、能看懂基本命令但一写脚本就出问题的朋友,也适合写了几年脚本但一直靠“试出来”而不是“想明白”的从业者。我会尽量用生活化的类比解释原理,用可直接复制的代码演示操作,让你看完就能用,用了不出错。
2. Shell变量的本质与赋值规则拆解
2.1 变量到底是什么:一个带名字的盒子
你可以把变量理解成一个贴了标签的盒子。标签就是变量名,盒子里装的东西就是变量值。在Shell里,这个盒子对“装什么”几乎没有限制,你可以装字符串、装数字、装命令的输出结果,甚至装一个空值。Shell不关心盒子里装的是什么类型,它统一按字符串来处理,需要做数学运算时再临时转换。
这一点和Python、C语言完全不同。C语言定义int a = 10;之后,这个盒子只能装整数,你塞个字符串进去编译器直接报错。Shell没有这个约束,a=10和a="hello"在语法上是一样的,都是赋值。理解这一点非常关键,因为后面很多“奇怪”的行为都源于此。
变量名有命名规范:只能包含字母、数字和下划线,且不能以数字开头。my_var、_count、name2都是合法的,2name、my-var不合法。虽然Shell对大小写敏感,Name和name是两个不同的变量,但行业惯例是变量名统一用大写表示环境变量或常量,小写表示脚本内部的局部变量,这样读代码时一眼就能区分。
2.2 赋值的三条铁律:等号两边不能有空格
这是新手最容易犯的错误,没有之一。看下面三行:
name = "zhangsan" # 错误 name= "zhangsan" # 错误 name="zhangsan" # 正确第一种写法,Shell会把name当成一个命令去执行,然后发现找不到这个命令,报command not found。第二种写法,Shell会把name=当成一个临时环境变量赋值给后面的命令"zhangsan",同样报错。只有第三种,等号紧贴变量名和值,才是正确的赋值语法。
为什么Shell要这么设计?因为Shell最初是一个命令解释器,它的核心工作是“执行命令”。name = "zhangsan"在Shell眼里就是“执行名为name的命令,参数是=和zhangsan”。等号两边不留空格,是Shell区分“赋值”和“执行命令”的方式。记住这条规则,能帮你省下大量排查时间。
提示:如果你确实需要在赋值时保留空格,比如值本身包含空格,必须用引号把值包起来,例如
name="zhang san"。
2.3 单引号、双引号、反引号的区别与选择
赋值时值可以用引号包裹,也可以用引号,但不同引号的行为差异很大,这是Shell字符串处理的核心知识点。
单引号是“所见即所得”,里面写什么就是什么,变量不会被展开,特殊字符也不会被解释。双引号允许变量展开和命令替换,但会保留大部分特殊字符的字面含义。反引号用于命令替换,把命令的输出结果作为值,不过现在更推荐用$()写法,因为反引号在嵌套时容易出错。
name="world" echo 'hello $name' # 输出 hello $name echo "hello $name" # 输出 hello world echo "today is $(date +%F)" # 输出 today is 2025-01-15选择哪种引号,取决于你是否希望变量被展开。如果值里包含$、反引号、反斜杠这些字符,而你希望它们保持字面含义,就用单引号。如果值里需要嵌入变量或命令输出,就用双引号。我个人的习惯是:除非明确需要变量展开,否则一律用双引号,因为双引号能防止值中的空格导致意外的单词拆分,这个坑在后面讲字符串时会详细展开。
2.4 环境变量与局部变量的作用域差异
Shell变量按作用域分两类:环境变量和局部变量。环境变量用export导出后,会被当前Shell及其所有子进程继承。局部变量只在当前Shell进程中有效,子进程看不到。
my_var="local" export my_env="global" bash -c 'echo $my_var' # 输出空行 bash -c 'echo $my_env' # 输出 global这个差异在实际写脚本时非常重要。如果你在脚本里定义了一个变量,然后调用另一个脚本或命令,期望那个脚本能读到这个变量,就必须用export。反过来,如果你不希望变量污染子进程的环境,就不要导出。
查看当前所有环境变量用env或printenv,查看所有变量(包括局部变量)用set。这两个命令在排查“变量为什么没传进去”这类问题时非常有用。
3. 字符串操作的核心技法与高频场景
3.1 字符串拼接:三种写法与性能考量
Shell的字符串拼接非常直接,把两个字符串挨着写就行,不需要像Java那样用+号,也不需要像Python那样用join。
first="hello" second="world" result1="$first $second" # 双引号内直接拼接 result2="${first}${second}" # 用花括号明确边界 result3=$first$second # 不加引号也能拼接三种写法都能得到拼接结果,但推荐用第二种${first}${second}。原因是花括号明确了变量名的边界,避免歧义。比如你想把变量$name和字符串s拼在一起,写成$names会被Shell理解成变量names,而${name}s才是你想要的。这个细节在批量生成文件名、构造路径时特别容易踩坑。
不加引号的拼接在值不含空格时没问题,但一旦值里有空格,Shell会做单词拆分,结果就不可控了。所以我的建议是:拼接时始终用双引号包裹,变量用花括号界定边界。
3.2 字符串长度:一个井号搞定
获取字符串长度用${#var},这是Shell内置的能力,不需要调用外部命令,效率很高。
str="hello world" echo ${#str} # 输出 11注意这里的花括号不能省,$#str是另一个意思($#表示脚本参数个数,后面跟str就是普通文本)。这个语法在判断输入是否为空、校验字段长度时非常实用。
如果字符串包含多字节字符(比如中文),${#str}返回的是字符数还是字节数,取决于当前locale设置。在UTF-8环境下通常返回字符数,但为了保险,处理中文时建议实测确认。
3.3 字符串截取:从指定位置取子串
Shell内置了字符串截取语法,格式是${var:offset:length},offset是起始位置(从0开始),length是要取的长度,省略length则取到末尾。
str="abcdefghij" echo ${str:0:3} # 输出 abc echo ${str:3} # 输出 defghij echo ${str: -3} # 输出 hij,注意负号前有空格负数偏移表示从末尾往前数,但负号前面必须有一个空格,否则Shell会把它当成默认值语法${var:-default}来解析。这个坑很隐蔽,我第一次用时排查了半天。
截取操作在处理日志、解析固定格式的字符串(比如日期20250115取年月日)时特别方便,不用调用cut或awk,纯Shell就能完成。
3.4 字符串替换:批量修改的利器
替换语法有两种:${var/old/new}替换第一个匹配,${var//old/new}替换所有匹配。
path="/home/user/docs/file.txt" echo ${path/user/admin} # 输出 /home/admin/docs/file.txt echo ${path//\//-} # 输出 -home-user-docs-file.txt第二个例子把路径中所有的斜杠替换成短横线,//表示全局替换,\/是对斜杠做转义。这个技巧在生成文件名、构造URL时很常用。
如果new部分留空,就是删除匹配内容:${path// /}可以删除字符串中所有空格。这比用sed或tr更轻量,而且不启动子进程,性能更好。
3.5 字符串比较:==、=与-eq的边界
字符串比较用=或==,数值比较用-eq、-ne、-lt、-gt等。混用会导致意想不到的结果。
a="10" b="9" if [ "$a" = "$b" ]; then echo "相等"; fi # 字符串比较,不相等 if [ "$a" -eq "$b" ]; then echo "数值相等"; fi # 数值比较,不相等 if [ "$a" -gt "$b" ]; then echo "a大于b"; fi # 数值比较,10>9成立如果用>或<做字符串比较,在[ ]中会被当成重定向符号,必须写成\>或在[[ ]]中使用。这也是为什么推荐用[[ ]]而不是[ ],前者支持更多语法且不容易出错。
注意:比较时变量一定要加双引号,否则变量为空时
[ $a = $b ]会变成[ = ],直接报语法错误。
4. 从零写一个字符串处理脚本:完整实操记录
4.1 需求定义与脚本框架设计
假设我们要写一个脚本,处理一批用户提交的邮箱地址,完成以下任务:去除首尾空格、统一转成小写、提取用户名和域名部分、统计每个域名的出现次数。这个需求在数据清洗场景中很典型。
脚本框架设计如下:先定义一个函数处理单个邮箱,返回规范化后的结果;然后遍历输入列表,调用函数处理;最后用关联数组统计域名频次。整个脚本不依赖外部命令,纯Shell实现,保证可移植性。
#!/bin/bash normalize_email() { local email="$1" email="${email#"${email%%[![:space:]]*}"}" email="${email%"${email##*[![:space:]]}"}" email="${email,,}" echo "$email" }这里用到了两个不常见的语法:${email%%[![:space:]]*}用于去除开头的空白字符,${email##*[![:space:]]}用于去除末尾的空白字符。${email,,}是Bash 4.0以上版本支持的转小写语法。如果你的环境是较老的Bash,可以用tr '[:upper:]' '[:lower:]'替代。
4.2 参数校验与默认值处理
脚本入口处需要做参数校验,确保用户传入了必要的参数。Shell提供了${var:-default}语法设置默认值,${var:?message}在变量为空时报错退出。
input_file="${1:?请提供输入文件路径}" output_file="${2:-/tmp/result.txt}"第一行表示如果$1为空,就打印错误信息并退出。第二行表示如果$2为空,就用/tmp/result.txt作为默认值。这两个语法在写健壮的脚本时非常有用,能避免变量为空导致的后续错误。
参数校验还包括检查文件是否存在、是否可读:
if [[ ! -f "$input_file" ]]; then echo "文件不存在: $input_file" >&2 exit 1 fi注意错误信息输出到>&2(标准错误),这是好习惯,方便调用者区分正常输出和错误信息。
4.3 逐行处理与域名统计实现
读取文件用while read循环,配合IFS=防止行首尾空格被吃掉:
declare -A domain_count while IFS= read -r line; do [[ -z "$line" ]] && continue email=$(normalize_email "$line") domain="${email#*@}" ((domain_count["$domain"]++)) done < "$input_file"${email#*@}表示删除第一个@及其左边的所有内容,剩下的就是域名部分。declare -A声明关联数组,用域名作为key统计次数。((domain_count["$domain"]++))是算术运算语法,对数组元素做自增。
最后输出统计结果:
for domain in "${!domain_count[@]}"; do printf "%-20s %d\n" "$domain" "${domain_count[$domain]}" done | sort -k2 -rn${!domain_count[@]}获取所有key,sort -k2 -rn按第二列数值降序排列。整个脚本跑下来,一个几十行的Shell脚本就完成了数据清洗和统计的核心功能。
4.4 脚本执行与结果验证
准备一个测试文件emails.txt:
ZhangSan@Example.COM LiSi@test.org wangwu@EXAMPLE.com zhaoliu@test.org执行脚本./process.sh emails.txt,预期输出:
example.com 2 test.org 2验证要点:首尾空格是否被去除、大小写是否统一、域名统计是否准确。如果结果不对,用bash -x process.sh emails.txt开启调试模式,逐行查看变量值的变化,这是排查Shell脚本问题最有效的手段。
5. 常见问题与排查技巧实录
5.1 变量为空导致的判断失效
这是最高频的问题。看下面这段代码:
if [ $name = "admin" ]; then echo "welcome" fi如果$name为空,这行会变成[ = "admin" ],Shell报unary operator expected。解决方法很简单:变量加双引号。
if [ "$name" = "admin" ]; then双引号保证即使变量为空,也会变成[ "" = "admin" ],语法正确,判断结果为false。这个习惯要刻进肌肉记忆里,所有变量引用都加双引号,除非你明确知道自己在做什么。
5.2 字符串包含空格时的单词拆分陷阱
files="a.txt b.txt c.txt" for f in $files; do echo "$f" done这段代码能正常输出三个文件名,因为Shell对未加引号的$files做了单词拆分。但如果你希望把整个字符串当成一个整体,就会出问题:
msg="hello world" if [ $msg = "hello world" ]; then # 报错,参数过多$msg被拆成hello和world两个参数,[命令收到三个参数,报too many arguments。加双引号即可解决。记住:变量引用加双引号,是Shell脚本的第一安全准则。
5.3 命令替换中的换行符丢失
files=$(ls) echo "$files"$(ls)会把多行输出合并成一行,换行符被替换成空格。如果你需要保留换行,用while read循环:
ls | while read -r f; do echo "文件: $f" done或者临时修改IFS。这个坑在处理文件列表、日志行时经常遇到,理解命令替换会做“单词拆分和换行合并”是解决问题的关键。
5.4 常见问题速查表
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
command not found | 赋值时等号两边有空格 | 去掉空格,var=value |
unary operator expected | 变量为空且未加引号 | 变量引用加双引号 |
too many arguments | 字符串含空格被拆分 | 变量引用加双引号 |
| 变量在子进程中读不到 | 未用export导出 | 赋值后加export var |
${var: -3}报错 | 负号前缺空格 | 写成${var: -3} |
| 中文长度计算不对 | locale设置问题 | 检查LANG环境变量 |
| 替换只生效一次 | 用了单斜杠 | 改用双斜杠${var//old/new} |
5.5 几个我踩过的坑和私房技巧
第一个坑:在[[ ]]里用==做模式匹配时,右边的模式不要加引号,否则会变成字面比较。[[ $name == zhang* ]]是模式匹配,[[ $name == "zhang*" ]]是字面比较,结果完全不同。
第二个技巧:用${var@Q}可以输出变量的引号转义形式,调试时特别有用,能看清变量里到底有什么不可见字符。
第三个技巧:处理用户输入时,先用read -r读取,再用[[ "$input" =~ ^[a-zA-Z0-9@.]+$ ]]做格式校验,正则表达式在[[ ]]中直接支持,不需要调用grep。
第四个坑:local关键字只能在函数内使用,在函数外写local var=1会报错。而且local声明的变量在函数返回后就失效,不要指望它能跨函数传递。
6. 变量与字符串的进阶用法与性能建议
6.1 间接引用与变量名动态构造
有时候你需要通过一个变量的值来访问另一个变量,这叫间接引用。Bash提供了${!var}语法:
name="zhangsan" key="name" echo ${!key} # 输出 zhangsan这个技巧在遍历配置项、动态构造变量名时很有用。但要注意,间接引用会降低代码可读性,能用数组解决的就用数组,不要滥用。
6.2 大小写转换与字符类操作
Bash 4.0以上支持${var^^}转大写、${var,,}转小写。如果环境不支持,用tr命令替代:
lower=$(echo "$str" | tr '[:upper:]' '[:lower:]')字符类替换也很实用:${var//[0-9]/}删除所有数字,${var//[^a-zA-Z]/}只保留字母。这些操作在数据清洗时能省下大量sed调用。
6.3 什么时候该用外部命令,什么时候纯Shell
纯Shell的字符串操作不启动子进程,性能远高于sed、awk、cut。但纯Shell的语法可读性差,复杂逻辑写起来容易出错。我的经验是:简单的截取、替换、长度计算用纯Shell;复杂的正则匹配、多字段解析用awk或sed。不要为了“纯Shell”而牺牲可维护性,脚本是给人看的,顺便给机器执行。
6.4 脚本调试的三种有效手段
第一种:bash -x script.sh,打印每条执行的命令和变量展开后的值,最直观。第二种:在关键位置插入echo "DEBUG: var=$var" >&2,输出到标准错误,不影响正常输出。第三种:用set -e让脚本遇到错误立即退出,用set -u让引用未定义变量时报错,这两个选项能帮你提前发现很多隐藏问题。
注意:
set -e在某些场景下会失效,比如命令在if条件中、在&&或||连接中。不要完全依赖它,关键步骤还是要显式检查返回值。
6.5 写可维护Shell脚本的几条个人原则
变量名要有意义,不要用a、b、c,用user_name、file_path这种自解释的名字。所有变量引用加双引号,这是铁律。函数内的变量用local声明,避免污染全局。脚本开头写set -euo pipefail,让脚本在严格模式下运行。复杂逻辑拆成函数,每个函数只做一件事。注释写“为什么”而不是“做什么”,代码本身能说明做什么,注释要说明为什么这么做。
这些原则看起来简单,但坚持下来能让你的脚本从“能跑”变成“可靠”。我见过太多脚本因为一个未加引号的变量,在生产环境跑了半年后突然出错,排查成本远超当初多写几个引号的时间。