1. 为什么每个运维人最后都得回头补上Shell变量这一课
刚入行那会儿,我对Shell脚本的态度特别矛盾。一方面觉得它土,语法古怪,连个像样的类型系统都没有;另一方面又不得不承认,每次服务器上出点状况,最后能救命的往往就是几行临时敲出来的Shell命令。后来带过几个新人,发现一个特别普遍的现象:很多人能照着网上的教程写出for循环,能跑通if判断,但一旦脚本稍微复杂一点,变量赋值出了岔子,字符串拼接莫名其妙多了空格,整个人就卡住了。
问题的根子不在逻辑,在于对变量和字符串这两块地基没打牢。Shell里的变量跟Python、Java完全不是一回事,它没有类型声明,没有作用域关键字,赋值时等号两边不能有空格这种反直觉的规则,字符串拼接直接往一起写就行,取变量值要加$,但有时候又不用加。这些细节单独拎出来都不难,可它们凑在一起,就成了新手写脚本时踩坑最密集的区域。
这篇内容就是冲着这个来的。我会把Shell变量和字符串的用法从头到尾捋一遍,重点讲清楚那些教程里经常一笔带过、但实际写脚本时天天遇到的东西:变量什么时候该加引号、$*和$@到底差在哪、字符串截取和替换的几种写法怎么选、命令替换用反引号还是$()。适合已经能写简单脚本、但总觉得变量这块用得不踏实的读者,也适合完全从零开始、想一次性把基础打好的朋友。
2. Shell变量的本质:没有类型,但有规矩
2.1 变量赋值那条“等号两边不能有空格”的铁律
先看一段代码,你猜哪个能跑:
name = "server01" name="server01"第一个直接报错,name: command not found。原因在于Shell把空格当作命令和参数的分隔符,name = "server01"会被解析成“执行一个叫name的命令,参数是=和server01”。Shell根本不认识name这个命令,所以报错。第二个才是正确的赋值语法。
这条规则几乎每个Shell新手都会踩一次,而且踩完之后容易形成肌肉记忆,反而在写其他语言时也习惯性地在等号两边不加空格,搞得代码风格很别扭。我的建议是:写Shell的时候脑子里明确切换一个模式,赋值就是“变量名=值”,中间不留任何空白。
如果值里面本身包含空格,必须用引号包起来:
greeting="hello world"不加引号的话,Shell会把hello赋给greeting,然后试图把world当作一个命令去执行,又是一轮报错。
2.2 变量引用:什么时候必须加花括号
取值用$符号,这个大家都知道。但什么时候该写成${name},什么时候$name就够了,很多人是凭感觉来的。看这个场景:
prefix="log" echo "$prefix_file"你以为会输出log_file,实际上Shell会去找一个叫prefix_file的变量,找不到就输出空。正确写法是${prefix}_file。花括号的作用是明确变量名的边界,告诉Shell变量名到哪里结束。
我的习惯是:只要变量后面紧跟着字母、数字或下划线,一律用${}包起来。这样不用每次去纠结边界问题,代码可读性也更好。只有在变量后面跟的是空格、标点或者字符串结尾时,才偷懒用$name。
2.3 单引号、双引号、反引号:三种引号的分工
Shell里的引号是新手最容易混淆的地方,我把它拆成三种情况来说。
单引号是“所见即所得”,里面的一切都是普通字符,变量不会被展开:
name="world" echo 'hello $name' # 输出 hello $name echo "hello $name" # 输出 hello world双引号允许变量展开和命令替换,但会保留空格等特殊字符的字面意义。这是日常写脚本时用得最多的引号形式。
反引号用于命令替换,把命令的输出结果赋给变量:
today=`date +%Y-%m-%d`但反引号有个致命缺点:嵌套困难,而且视觉上容易和单引号混淆。现在推荐一律用$()代替:
today=$(date +%Y-%m-%d) files=$(ls -1 | wc -l)$()支持嵌套,可读性也好得多。我在code review时看到反引号,基本都会建议改成$()。
2.4 环境变量、局部变量与export的那道墙
Shell变量按作用范围分几种。直接在脚本里赋值的是普通变量,只在当前Shell进程里有效。子进程看不到父进程的普通变量,除非用export导出成环境变量。
myvar="hello" export myvar bash -c 'echo $myvar' # 输出 hello如果不export,子Shell里echo $myvar就是空的。这个机制在写多脚本协作的时候特别重要。比如你写了一个主脚本调用另一个子脚本,主脚本里定义的配置变量如果不export,子脚本根本读不到。
在函数内部可以用local声明局部变量,避免污染全局:
myfunc() { local tmp="temp value" echo "$tmp" }不加local的话,函数里赋值的变量默认是全局的,函数执行完还留在环境里,容易造成变量名冲突。这个坑我在维护老脚本时遇到过好几次,两个函数用了同一个临时变量名,互相覆盖,排查了半天。
3. 字符串操作:Shell里最被低估的一套工具
3.1 字符串长度、截取与位置查找
Shell内置了一套字符串处理语法,不用调用awk或sed就能完成大部分常见操作。先看长度:
str="hello world" echo ${#str} # 输出 11${#变量名}返回字符个数,注意是字符数不是字节数,中文环境下要留意编码问题。
截取用${变量:起始位置:长度}:
str="abcdefghij" echo ${str:2:3} # 输出 cde echo ${str:5} # 输出 fghij起始位置从0开始算。省略长度就截到末尾。这个语法在处理文件路径、日志行的时候特别顺手。
查找子串位置没有内置语法,得借助expr index:
expr index "$str" "d" # 输出 4注意expr index返回的是1-based的位置,跟大多数编程语言从0开始不一样,用的时候要换算。
3.2 字符串替换的四种模式
替换是字符串操作里用得最频繁的。Shell提供了四种替换模式,区别在于“替换第一个”还是“替换全部”,以及“从前面匹配”还是“从后面匹配”。
| 语法 | 作用 |
|---|---|
${str/old/new} | 替换第一个匹配 |
${str//old/new} | 替换全部匹配 |
${str/#old/new} | 只替换开头的匹配 |
${str/%old/new} | 只替换结尾的匹配 |
举个例子:
path="/home/user/docs/file.txt" echo ${path//\//_} # 输出 _home_user_docs_file.txt这里把所有的斜杠替换成下划线,注意斜杠在替换语法里需要转义。这个技巧在生成文件名、构造日志标签时很实用。
再比如去掉文件扩展名:
filename="report.pdf" echo ${filename%.*} # 输出 report%.*表示从结尾开始匹配最短的.*模式,也就是最后一个点及其后面的内容。如果要匹配最长的,用%%.*。
3.3 大小写转换与去空白
Bash 4.0以上支持大小写转换:
str="Hello World" echo ${str,,} # 输出 hello world echo ${str^^} # 输出 HELLO WORLD,,转小写,^^转大写。单个逗号或尖号只转换第一个字符。
去空白没有内置语法,通常用xargs或者参数扩展配合sed:
trimmed=$(echo " hello " | xargs)xargs默认会去掉首尾空白并把内部连续空白压缩成单个空格。如果只想单纯去首尾空白,用sed更精确:
trimmed=$(echo " hello " | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')3.4 字符串比较:==、=和-z的适用场景
字符串比较在条件判断里用得很多,但语法细节容易搞混。在[ ]里比较字符串用=或==:
if [ "$a" = "$b" ]; then echo "equal" fi注意变量一定要加双引号,否则变量为空时[ = "$b" ]会报语法错误。这是Shell脚本里最经典的坑之一。
判断字符串是否为空用-z,非空用-n:
if [ -z "$str" ]; then echo "empty" fi在[[ ]]里可以用==做模式匹配:
if [[ "$filename" == *.txt ]]; then echo "text file" fi[[ ]]是Bash的扩展语法,比[ ]更安全,支持正则和模式匹配,推荐在Bash脚本里优先使用。
4. 那些让脚本突然崩掉的变量陷阱
4.1 未定义变量与set -u的取舍
Shell默认对未定义变量很宽容,引用一个不存在的变量只会得到空字符串,不会报错。这在简单脚本里无所谓,但在复杂脚本里是灾难——变量名拼错了,脚本继续跑,结果全错,你还不知道问题出在哪。
set -u可以让Shell在引用未定义变量时直接报错退出:
set -u echo "$undefined_var" # 报错:undefined_var: unbound variable我现在的习惯是每个正式脚本开头都写set -euo pipefail,分别对应:遇错退出、未定义变量报错、管道中任一命令失败就整体失败。这三条能挡掉大量隐蔽bug。
但set -u有个副作用:$1、$2这类位置参数如果没传,也会触发报错。这时候可以用默认值语法兜底:
name=${1:-"default"}:-表示如果变量未定义或为空,就用后面的默认值。
4.2 命令替换中的换行与空格丢失
命令替换$(...)会把输出末尾的换行符去掉,但中间的换行会保留。如果直接不加引号使用,Shell会做单词拆分,把换行和空格都当作分隔符:
files=$(ls) echo $files # 所有文件名挤在一行 echo "$files" # 保留原始换行这个差异在处理文件名列表时特别关键。不加引号的话,带空格的文件名会被拆成多个词,后续处理全乱套。我的原则是:命令替换的结果只要不是明确要拆分的,一律加双引号。
4.3$*和$@:一个引号引发的血案
这两个特殊变量都表示所有位置参数,但不加引号时行为一样,加了引号就分道扬镳:
set -- "a b" "c d" echo "$*" # 输出 a b c d(作为一个整体) echo "$@" # 输出 a b 和 c d(两个独立参数)"$*"把所有参数拼成一个字符串,"$@"保持每个参数的独立性。写函数转发参数时,几乎总是应该用"$@":
myfunc() { other_func "$@" }用"$*"的话,原本带空格的参数会被合并,接收方拿到的参数结构就变了。这个坑我在写部署脚本时踩过,传路径参数带空格,结果目标脚本收到的参数数量不对,排查了很久。
4.4 变量名冲突与作用域污染
前面提过local的重要性,这里再展开说一个实际场景。假设你写了一个工具脚本,里面定义了一个tmp变量存临时目录。后来这个脚本被source到另一个脚本里使用,而外层脚本也用了tmp变量,两边就打架了。
# outer.sh tmp="/outer/tmp" source inner.sh echo "$tmp" # 可能已经被inner.sh改掉了解决办法有两个:一是函数内一律用local,二是给变量加前缀,比如MYTOOL_tmp。我倾向于两者结合,函数内用local,全局变量加项目前缀,最大限度避免冲突。
5. 从零写一个字符串处理脚本:完整实操
5.1 需求拆解与设计思路
假设我们要写一个脚本,处理一批日志文件名,完成以下任务:提取日期、判断是否为错误日志、生成对应的归档目录名。日志文件名格式类似app_20240315_error.log。
设计思路:用参数扩展提取各部分,用模式匹配判断类型,用字符串拼接生成新名字。整个脚本不依赖awk、sed等外部命令,纯Bash内置语法完成,这样执行效率高,也不受环境差异影响。
5.2 逐段实现与关键注释
#!/bin/bash set -euo pipefail process_log() { local filename="$1" local basename="${filename##*/}" local name_no_ext="${basename%.log}" local date_part="${name_no_ext#app_}" date_part="${date_part%%_*}" local type_part="${name_no_ext##*_}" local archive_dir="archive/${date_part}" if [[ "$type_part" == "error" ]]; then archive_dir="${archive_dir}/errors" else archive_dir="${archive_dir}/normal" fi echo "文件: $filename -> 归档目录: $archive_dir" } for f in "$@"; do process_log "$f" done逐段解释关键点。${filename##*/}去掉路径部分只留文件名,##表示从前面匹配最长模式。${basename%.log}去掉.log后缀,%表示从后面匹配最短模式。${name_no_ext#app_}去掉开头的app_,#表示从前面匹配最短模式。${date_part%%_*}从第一个下划线处截断,%%表示从后面匹配最长模式。
这几个符号#、##、%、%%的记忆方法是:#在键盘上在$左边,对应“前面”;%在$右边,对应“后面”。单个符号是“最短匹配”,双符号是“最长匹配”。
5.3 测试用例与边界情况
拿几个文件名跑一下:
./process.sh app_20240315_error.log app_20240316_info.log输出:
文件: app_20240315_error.log -> 归档目录: archive/20240315/errors 文件: app_20240316_info.log -> 归档目录: archive/20240316/normal边界情况要考虑:文件名不含下划线怎么办?${date_part%%_*}会返回整个字符串,不会报错但结果不对。文件名不以app_开头怎么办?${name_no_ext#app_}会原样返回,也不会报错。这些情况需要在调用前做校验,或者用[[ ]]做模式匹配判断。
我在实际使用中会加一层校验:
if [[ ! "$basename" =~ ^app_[0-9]{8}_(error|info)\.log$ ]]; then echo "跳过不识别的文件: $filename" >&2 return fi用正则做严格匹配,不符合格式的直接跳过,避免产生错误的归档目录。
6. 调试变量问题的几个实用手段
6.1set -x与PS4定制
脚本跑出来的结果不对,第一反应应该是打开跟踪。set -x会把每条执行的命令打印出来,变量会被展开成实际值:
set -x name="world" echo "hello $name"输出:
+ name=world + echo 'hello world' hello world行首的+是默认的提示符,可以通过PS4变量定制,加上行号和函数名:
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '这样每行跟踪信息都会带上文件名、行号和函数名,排查复杂脚本时定位快很多。
6.2 用declare -p查看变量真实状态
有时候变量值看起来对,但比较就是不相等,多半是藏了不可见字符。用declare -p可以看到变量的完整定义:
str="hello" declare -p str # 输出 declare -- str="hello"如果值里有尾随空格或换行,这里会暴露出来。另一个办法是用printf配合%q:
printf '%q\n' "$str"%q会把特殊字符转义显示,空格、换行、制表符都无所遁形。
6.3 常见报错信息对照表
| 报错信息 | 原因 | 解决 |
|---|---|---|
command not found | 赋值时等号两边有空格 | 去掉空格 |
unbound variable | 引用了未定义变量且开了set -u | 用${var:-default}兜底 |
bad substitution | 用了不支持的参数扩展语法 | 检查Shell版本,确认是Bash |
too many arguments | [ ]里变量没加引号且为空 | 加双引号 |
syntax error near unexpected token | 引号不匹配或缺少fi、done | 检查配对 |
这张表里的前四条,基本覆盖了我这些年遇到的大部分变量相关报错。特别是too many arguments,十有八九是[ $var = "x" ]这种写法,变量为空时变成[ = "x" ],[命令收到两个参数就懵了。
7. 我踩过的那些变量坑与个人习惯
说几个印象深刻的。有一次写批量重命名脚本,用for f in $(ls)遍历文件,测试时用的都是简单文件名,跑得好好的。上线后遇到一个文件名带空格,整个逻辑就崩了,因为$(ls)的输出被单词拆分了。后来改成for f in *或者find ... -print0 | while IFS= read -r -d ''才彻底解决。这个教训让我记住了:永远不要用$(ls)做遍历。
还有一次,脚本里用read读配置文件,值里带了反斜杠,结果被read吃掉了。read默认会把反斜杠当作转义符,加-r参数才能原样读取。现在我的习惯是read一律加-r,除非明确需要转义处理。
关于变量命名,我现在的个人规范是:环境变量全大写加下划线,脚本内部变量全小写加下划线,函数内临时变量加local,全局配置变量加项目前缀。这套规范没什么技术含量,但能省掉大量“这个变量哪来的”的困惑。
最后分享一个提高脚本健壮性的小技巧:在脚本开头加一段参数校验,用${var:?error message}语法,变量未设置时直接报错退出并打印提示:
: ${CONFIG_FILE:?"必须设置CONFIG_FILE环境变量"}这行代码在变量未定义时会输出必须设置CONFIG_FILE环境变量并退出,比等到后面用到时才报错要清晰得多。这个写法在写需要外部传参的脚本时特别有用,相当于给变量加了一道前置检查。