☰
Shell变量从入门到避坑:引号、位置参数与实战脚本
2026/9/28 15:42:46 网站建设 项目流程

1. 变量不是玄学:它就是一个贴上标签的盒子

1.1 先看一段没有变量的脚本,你就明白它解决什么问题

很多人第一次接触 shell 变量时,总觉得这是个需要背语法的小知识点。其实你完全可以把它理解成:变量就是在内存里放了一个盒子,盒子外面贴了张标签,标签上写着名字,盒子里面装着你要用的数据。

我见过不少初学者写脚本,第一条命令就把路径写死:

rm -rf /var/log/myapp/*.log rm -rf /var/log/myapp/*.tmp

看起来没什么问题,但第二天要把路径从/var/log/myapp改成/data/app/logs,就得把所有命令翻出来逐个改。如果脚本有 30 行,你就要改 30 处,改漏任何一处,备份脚本就可能删错目录。但如果你一开始就定义变量:

LOG_DIR="/var/log/myapp" rm -rf "$LOG_DIR"/*.log rm -rf "$LOG_DIR"/*.tmp

后续只需要改第一行,整个脚本的所有路径就全部生效。这就是 Shell 变量最原始、也最核心的价值:把重复出现的值抽出来,单独管理。再往深一层说,变量还承担了"给数据起名字"的作用。$?代表上一条命令的退出码,$HOME代表当前用户的主目录,$PATH代表命令搜索路径——这些名字背后都绑定了程序运行时真正关心的数据。你的脚本写得是不是灵活、是不是好维护,八成取决于你对变量这一层的理解程度。

1.2 定义变量时踩得最多的一个坑:等号两边的空格

先记住 Shell 里定义变量的标准姿势:

name="张三" count=10 APP_HOME="/opt/myapp"

语法就一句话:变量名、等号、变量值,中间不能有空格。我知道你心里肯定在嘀咕:其他语言里name = "张三"这种写法到处都是,为什么 Shell 偏偏不行?

原因在于,Shell 的命令解析逻辑是"按空格切词"。你写name = "张三",Shell 会把name当成一个命令名,然后尝试去执行它,自然会报command not found。这几乎是所有第一次接触 shell 脚本的人都会踩的坑,我早期做自动化部署时,也曾经因为在变量赋值语句里多打了一个空格,导致整行命令被当成"执行一个叫 name 的程序",排查了半天才发现是这种低级问题。

还有一种情况要留心:变量值里本身带空格,赋值时必须加引号。

# 错误 path=/Users/me/My Documents # 正确 path="/Users/me/My Documents"

不加引号的话,Shell 会把path=/Users/me/My和Documents切分成两条独立的"词",含义完全变了。这属于 Shell 分词机制的经典问题,后面讲引用变量时还会专门展开。

1.3 单引号和双引号:同样是包住内容,行为完全相反

新手最容易混淆的就是单引号和双引号。表面上它们都用来"括住字符串",实际行为天差地别:

name="张三" echo '你好,$name' # 输出:你好,$name echo "你好,$name" # 输出:你好,张三

一句话总结:双引号里的变量会被解析成它的值,单引号里的内容则原封不动地输出。单引号相当于是"铁壳",里面的一切都不做任何解释;双引号是"玻璃壳",里面的变量一眼就会被识别出来。

那到底什么时候用单引号、什么时候用双引号?我自己的习惯是:如果字符串里没有变量、没有特殊符号,用单引号和双引号都行,但凡是字符串里要拼接变量,一律用双引号。例如给日志文件名加时间戳:

STAMP=$(date +%Y%m%d) LOG_FILE="app_${STAMP}.log"

如果这里不小心用成单引号,LOG_FILE就会变成字面量app_${STAMP}.log,时间戳彻底失效。

还有一个小细节:单引号内不能再套单引号,双引号内可以包含单引号。比如:

echo "他说:'今天天气不错'"

这样写完全没问题,但反过来,在单引号里写双引号虽然能通过,双引号却没有解析能力,很容易让读者误解。所以我建议:需要变量解析时用双引号,不需要解析时尽量用单引号,避免心里预期和实际行为不一致。

2. 引用变量的姿势:$var、${var}、$var 分别有哪些脾气

2.1 什么时候必须给变量套上花括号

定义变量用name="张三",引用变量用$name或${name}。大多数场景下二者等价,但有一种情况必须用花括号:变量名后面紧跟其他字符时。

假设你想构造一个备份文件名,规则是backup_20250115.tar.gz:

PREFIX="backup" STAMP="20250115" echo "$PREFIX_$STAMP.tar.gz"

这里$PREFIX_会被 Shell 解释成变量名为PREFIX_的一个引用,而不是PREFIX加下划线。结果就是什么都输出不出来。正确的是:

echo "${PREFIX}_${STAMP}.tar.gz"

我几乎每次写带后缀的变量拼接都会直接写上花括号,不是因为我不记得$var的写法,而是因为花括号能明确划清变量名的边界,让脚本在修改、嵌套、拼接时都不会突然出 bug。特别是写大段 shell 脚本时,花括号还能提高可读性——别人看你的代码,一眼就能判断出哪里是变量名、哪里是普通文本。

2.2 双引号防止"分词"和通配符展开

很多人写脚本时觉得$var和"$var"没啥区别,直到遇到带空格的路径或文件名,才发现事情没那么简单。看这个例子:

path="/Users/me/My Documents" ls $path # 会执行 ls /Users/me/My 和 ls Documents,直接报错 ls "$path" # 正确,把整个路径当成一个整体

原因还是那个分词机制:Shell 在解析ls $path时,会先把$path展开成/Users/me/My Documents,然后按空格把这段文本切成两段,再分别当成ls的参数。加了双引号之后,整段展开结果会作为一个整体参数传给ls。

通配符也有类似的坑。假设目录里有一堆.txt文件:

pattern="*.txt" echo $pattern # 输出:a.txt b.txt c.txt(通配符被展开成文件名了) echo "$pattern" # 输出:*.txt(老老实实显示字面量)

不加双引号时,pattern展开后携带的*会被 Shell 做文件名通配,行为就失控了。这在实际脚本里非常危险,尤其是把用户输入存到变量里,再拿去做文件操作时,一个带*的值可能会把脚本执行范围扩大到你完全没想到的目录。所以我给出的操作建议很简单:能用双引号就双引号,别嫌多余。引用变量一律写成"$var",这是降低 shell 脚本出错率性价比最高的一个习惯。

2.3 变量与 grep 搭配时的引号陷阱

grep 是 shell 脚本里高频出现的文本匹配工具,变量和它搭配时,引号问题更是重灾区。最常见的错误是这种写法:

pattern="ERROR|FATAL" grep $pattern app.log

你是不是以为grep会自动把pattern里的|当成正则表达式的"或"?实际上,$pattern展开后没有被引号保护,Shell 会先把ERROR|FATAL按空格分词,同时|是 Shell 的管道符号,这一行会被解释成grep ERROR的结果交给FATAL这个命令处理,最后得到的要么是报错,要么是一堆莫名其妙的输出。

正确写法是用引号包住变量,并且显式告诉 grep 使用扩展正则:

pattern="ERROR|FATAL" grep -E "$pattern" app.log

这里还需要注意一点:grep "$pattern"会把变量里的内容当普通字符串匹配,grep -E "$pattern"才会开启扩展正则,|才有"或"的意思。不同版本 grep 的默认行为有差异,你可以在自己的环境里跑一下grep --version确认。我个人的习惯是:带有正则元字符的模式一律存到变量后,用grep -E "$pattern"这种组合来使用,宁可多写一点,也不能让管道符在不知不觉中把脚本带跑偏。

3. 位置参数和 shift:脚本怎么接收外部输入

3.1 从 $0 到 $9:参数就是脚本的命令行入口

写脚本和写函数一样,总得有能力接收外部传进来的数据。Shell 脚本里,这个入口就是位置参数:执行脚本时后面跟的第 1 个参数对应$1,第 2 个对应$2,依此类推,$0是脚本自身路径。

写个简单示例,保存为greet.sh:

#!/bin/bash echo "脚本名:$0" echo "第一个参数:$1" echo "第二个参数:$2"

执行方式:

./greet.sh Alice Bob

输出:

脚本名:./greet.sh 第一个参数:Alice 第二个参数:Bob

这里最值得注意的限制是:数字超过 9 时不能直接写$10,Shell 会解读成$1后面跟一个 0,必须写成${10}、${11}。如果参数不多,直接逐个取没什么问题;如果参数很多,用起来就痛苦了。更实用的方式是搭配后面要讲的shift和循环,这样无论脚本接收多少个参数,处理逻辑都一样。

3.2 $# 和 "$@":参数个数的统计与安全遍历

两个和参数相关的内置变量需要分清。$#是参数个数,$@和$*都表示"所有参数",但行为有差异。

直接看对比:

#!/bin/bash echo "参数个数:$#" for arg in "$@"; do echo "参数:$arg" done

如果执行./test.sh "hello world" "second",输出应该是:

参数个数:2 参数:hello world 参数:second

但如果把"$@"换成不加引号的$@或$*,hello world就会被拆成hello和world两段。原因是那个老熟人:分词机制。所以遍历参数时请无条件使用"$@",它保证了每个参数都能作为一个完整的整体被处理,哪怕参数里带空格、换行、特殊字符,都不会被破坏。

$*和$@在加引号的场景下也不一样。简单记忆:"$@"是把每个参数分开保留,"$*"是把所有参数合成一个字符串。日常脚本里我几乎只用"$@",极少用"$*"。

3.3 shift 在循环里怎么用:逐个消费参数

shift的含义是"把参数列表整体左移一位"。执行一次shift,原来的$1就被丢弃,$2变成新的$1,$3变成新的$2,依此类推。shift还支持指定移动位数,比如shift 2表示一次移两位。

这玩意儿最典型的应用就是配合while循环逐个消费参数:

#!/bin/bash while [ $# -gt 0 ]; do echo "正在处理参数:$1" shift done

比如执行./process.sh a b c,它会循环三次,依次输出a、b、c,每次循环后$#都会减少,直到退出循环。手动指定参数个数时,也可以配合shift做成"先处理选项,再处理剩余参数"的经典结构:

#!/bin/bash while [ $# -gt 0 ]; do case "$1" in -f|--force) FORCE=1 shift ;; -n|--name) NAME="$2" shift 2 ;; *) echo "未知参数:$1" exit 1 ;; esac done

这种写法在很多系统管理脚本里很常见,shift 2用来消费"-n"和它的值两段参数。对 shell 脚本来说,"能灵活处理任意数量的参数"是一个很实用的能力,而shift正是实现这个能力的最好工具。

4. 环境变量、局部变量与默认值:把脚本写"稳"的关键一步

4.1 export 和环境变量的关系:父子进程的"遗传规则"

前面讲的变量都是当前 shell 私有的,但有时候你需要让子进程也看到这些变量。比如你在当前终端里定义了APP_ENV=production,然后执行一个脚本./deploy.sh,这个脚本内部读取$APP_ENV时,会读到吗?答案是不会,除非你用了export。

export APP_ENV=production ./deploy.sh

export的作用是给变量打上"允许传给子进程"的标记。打过标记的变量会进入环境变量列表,当当前 shell 启动一个子进程时,这份环境变量列表会原样复制给子进程。

从原理上看,这就是我常说的"遗传规则":每个进程都有自己的环境变量表,子进程会继承父进程的环境变量,但子进程对变量的修改不会被传回给父进程。理解了这一点,就不会再疑惑为什么脚本里改了$PATH,关掉终端再打开又变回原样。

要注意的是,环境变量名一般约定用大写(比如PATH、HOME、LANG),小写变量则更多用于局部脚本内部。这不是语法强制,而是工程惯例。你的脚本如果定义了需要传给子命令的变量,记得export;只在当前脚本内部使用的变量,不 export 反而安全,因为这能减少环境污染。

4.2 local 与全局变量:函数内部别污染外部

Shell 函数的变量作用域和很多编程语言不一样。默认情况下,在函数里给变量赋值,作用域是全局的——函数外也能看到。看这段代码:

#!/bin/bash count=1 increment() { count=10 count=$((count + 1)) echo "函数内部:$count" } increment echo "函数外部:$count"

输出结果是:

函数内部:11 函数外部:11

你没看错,函数里对count的修改直接影响到了函数外部。如果你的脚本后面还要用count做其他计算,这里就可能引入严重 bug。解决办法是在函数内部声明local:

increment() { local count=10 count=$((count + 1)) echo "函数内部:$count" }

加上local之后,函数内的count就是一个局部变量,函数运行完就被销毁,外部count仍然是 1。我在写稍微复杂一点的脚本时,几乎每个函数内都要用local声明变量,即使函数里只有一个临时变量也不漏掉。这样能最大限度防止"变量泄漏"带来的意外串台。

4.3 ${var:-default} 和 ${var:=default}:为参数缺失兜底

一段健壮的脚本应该能处理"用户没传某个参数"的情况。假设你写了一个部署脚本,允许通过第一个参数指定环境,不传时默认用 production:

#!/bin/bash ENV="${1:-production}" echo "目标环境:$ENV"

这样无论用户是否传参,ENV都会有值。${var:-default}的语义是:如果var未定义或为空,则用default作为结果;否则用var的原值。注意,它只是在使用时给出默认值,并不修改变量本身的值。

另一个类似语法是${var:=default},区别在于它会把default赋值给var:

unset NAME echo "${NAME:=guest}" echo "$NAME" # 输出 guest,NAME 已经被赋值了

还有一个容易混淆的${var:+value},它的逻辑是"如果 var 有值,返回 value;否则返回空"。这三兄弟在写脚本时非常实用,能让脚本面对残缺的参数输入依然稳定运行,而不是动不动就报"参数为空"的错误。我通常会把这种默认值处理放在脚本入口处集中定义,后续代码一律引用变量,绝不在中间逻辑里靠猜。

5. 把知识串起来:一个"时间戳日志扫描"脚本的诞生过程

5.1 获取当前日期时间并转为数字串的常见写法

不少真实需求都要求脚本拿到"当前时间点"。最常见的写法是利用date命令结合$()命令替换:

STAMP=$(date +%Y%m%d_%H%M%S) echo "$STAMP"

输出类似:

20250115_143205

%Y是四位年份,%m是两位月份,%d是两位日期,%H、%M、%S同理对应时分秒。这种数字串的好处是排序方便、可读性高,尤其适合拼到文件名里:

BACKUP_FILE="backup_${STAMP}.tar.gz"

另外还有一个容易踩坑的地方:date后面必须用引号包住格式串。因为+%Y里的+在部分 shell 环境下可能被特殊处理,所以建议写成date '+%Y%m%d_%H%M%S',两侧都包上单引号最稳妥。还有一种获取"时间戳秒数"的写法:

EPOCH=$(date +%s)

+%s输出从 1970 年 1 月 1 日 0 点至今经过的秒数,适合用于计算时间差或生成全局唯一数字,很多临时文件命名我都会直接用它。

5.2 for 循环里处理文件列表,变量怎么用才不出事

for 循环是 shell 脚本里最常见的循环结构,和变量搭配使用时,最大的陷阱在于"循环内引用变量是否加引号"。先看一个典型场景:遍历/var/log/myapp/下的全部.log文件。

#!/bin/bash LOG_DIR="/var/log/myapp" for f in "$LOG_DIR"/*.log; do echo "找到文件:$f" done

"$LOG_DIR"/*.log在整个路径上没有空格的情况下,加不加双引号都一样;但如果目录路径本身含空格,不加引号就又会触发分词故障。我建议始终写成"$LOG_DIR"/*.log,这样无论目录怎么变,路径总是作为一个整体参与通配匹配。

另一个容易忽略的问题是:如果目录下没有任何.log文件,for f in "$LOG_DIR"/*.log里的通配符不会展开,而是保留字面量/var/log/myapp/*.log,循环体依然会执行一次。为了避免这种空跑,可以在循环开头加一个判断:

for f in "$LOG_DIR"/*.log; do [ -e "$f" ] || continue echo "处理文件:$f" done

[ -e "$f" ]检查是否存在,不存在就跳过。这是处理"目录为空"场景的常用兜底方式,能让脚本在真实环境中少很多莫名其妙的报错。

5.3 用 $? 和 grep 做检查:完整脚本走一遍

把前面讲的知识点串起来,写一个实用脚本:扫描应用日志目录,找出包含ERROR或FATAL的日志文件,备份到带时间戳的目录里。

#!/bin/bash LOG_DIR="/var/log/myapp" BACKUP_ROOT="/backup" STAMP=$(date '+%Y%m%d_%H%M%S') BACKUP_DIR="${BACKUP_ROOT}/myapp_${STAMP}" PATTERN="ERROR|FATAL" mkdir -p "$BACKUP_DIR" for f in "$LOG_DIR"/*.log; do [ -e "$f" ] || continue grep -E "$PATTERN" "$f" > /dev/null 2>&1 if [ $? -eq 0 ]; then cp "$f" "${BACKUP_DIR}/$(basename "$f")" echo "已备份:$f" fi done echo "备份完成,目录:$BACKUP_DIR"

这个脚本里,grep -E "$PATTERN"如果匹配到内容,退出码$?为 0,否则为 1。通过检查$?决定是否执行备份,这是 shell 脚本里最基础的"按命令结果做判断"的写法。实际上,if grep -q ...这种带 if 的写法在大多数场景下更简洁:

if grep -qE "$PATTERN" "$f"; then cp "$f" "${BACKUP_DIR}/$(basename "$f")" fi

grep -q安静模式,只关心退出码,不输出匹配内容。两者都能跑,但我个人更推荐后者,少一个变量、少一行判断,逻辑也更直观。注意,$?必须在命令执行后立刻读取,插入任何其他命令都会覆盖它的值,这是很多调试半天最后发现自己拿错了退出码的原因。

6. 我在 shell 变量上踩过的坑:一次整理,下次绕开

6.1 变量名命名规范与保留字冲突

Shell 的变量名只能由字母、数字和下划线组成,且不能用数字开头。比如2name是非法的,name2完全没问题。这个基础规则大多数人不会犯错,但容易被忽略的是:变量名不能和 shell 的保留字混淆。

比如if、then、else、fi、while、do、done这些,都是 shell 语法层面的保留字,虽然技术上可以拿来做变量名,但脚本里一出现$if这种引用,可读性会瞬间崩塌。还有一个很容易踩的坑:变量名全部用大写时,很可能覆盖系统已有的环境变量。比如你自定义一个PATH="/tmp",那接下来的ls、cp、grep等命令都可能找不到,整个脚本瞬间瘫痪。所以我现在的习惯是:普通脚本变量一律小写,加下划线分词,比如backup_dir、log_file;环境变量和 export 出去的变量用大写,比如APP_ENV、BACKUP_ROOT。这个规范能避免掉一半以上想起来就冒冷汗的"灵异事件"。

6.2 别忽略变量为空的情况:set -u 的妙用

Shell 默认对未定义的变量采取"睁一只眼闭一只眼"的态度。你写echo "$name"即使name从未定义过,Shell 也能正常运行,只是输出空行。这在交互式终端里挺方便,但在脚本里就是隐患:变量拼错一个字母,命令照常执行,只不过行为完全错了,而且没有任何报错。

set -u(-u表示 unset variable 报错)可以让 Shell 在碰到未定义变量时直接报错退出:

#!/bin/bash set -u echo "$name" # 报错:name: unbound variable

我写任何超过十行的脚本,基本都会在开头加上set -u,配合set -e(命令出错即退出)和set -o pipefail(管道中任一命令出错则整个管道失败),能提前拦截大量低级错误。注意,set -u也会影响刚才讲的${var:-default}——它不会报错,因为语法本身就允许变量未定义。所以默认值语法可以放心使用,它和set -u不冲突。

6.3 命令替换:反引号与 $() 的取舍

把命令执行结果保存到变量,有两种写法:

# 老式写法 today=`date +%Y%m%d` # 推荐写法 today=$(date +%Y%m%d)

两者大部分场景等价,但$()有几个明显优势。第一,嵌套能力强:反引号嵌套时要不停处理转义,眼睛都看花了;$()天然支持嵌套,结构清晰。第二,可读性好:反引号在字体里有时候和单引号长得几乎一样,老花眼很难分清楚;$()的开闭符号一目了然。第三,行为更可预测:反引号内部的反斜杠处理机制在新旧 shell 版本里表现不完全一致,容易引发怪问题。

如果你需要在循环里给每个文件生成一个时间戳,推荐这样写:

for f in *.log; do STAMP=$(date '+%Y%m%d_%H%M%S') cp "$f" "${f%.log}_${STAMP}.log" done

注意${f%.log}是变量替换的另一种用法,含义是"去掉变量f值末尾的.log后缀",属于 shell 变量基础能力中的字符串处理,非常常用。如果你还不知道这个语法,可以找个时间专门把${var},${var:-default},${var%word},${var#word}这几个变体都过一遍,能明显提升脚本的灵活度。

最后,再分享一个我自己的小习惯:写完脚本后,用shellcheck工具做一次静态检查。它是一个开源的 shell 脚本检查器,能自动指出变量引用漏了引号、$?被覆盖、set -u未开启等常见问题。运行方式很简单,直接输入shellcheck myscript.sh即可。千万别小看这一步,很多我在文章里描述的坑,shellcheck都能提前帮你揪出来。对我来说,它就像一个经验老道的代码评审员,每次检查完脚本,都能发现一两处自己写的时候完全没意识到的隐患。

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

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

立即咨询