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张三 count10 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_FILEapp_${STAMP}.log如果这里不小心用成单引号LOG_FILE就会变成字面量app_${STAMP}.log时间戳彻底失效。还有一个小细节单引号内不能再套单引号双引号内可以包含单引号。比如echo 他说今天天气不错这样写完全没问题但反过来在单引号里写双引号虽然能通过双引号却没有解析能力很容易让读者误解。所以我建议需要变量解析时用双引号不需要解析时尽量用单引号避免心里预期和实际行为不一致。2. 引用变量的姿势$var、${var}、$var 分别有哪些脾气2.1 什么时候必须给变量套上花括号定义变量用name张三引用变量用$name或${name}。大多数场景下二者等价但有一种情况必须用花括号变量名后面紧跟其他字符时。假设你想构造一个备份文件名规则是backup_20250115.tar.gzPREFIXbackup STAMP20250115 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 脚本里高频出现的文本匹配工具变量和它搭配时引号问题更是重灾区。最常见的错误是这种写法patternERROR|FATAL grep $pattern app.log你是不是以为grep会自动把pattern里的|当成正则表达式的或实际上$pattern展开后没有被引号保护Shell 会先把ERROR|FATAL按空格分词同时|是 Shell 的管道符号这一行会被解释成grep ERROR的结果交给FATAL这个命令处理最后得到的要么是报错要么是一堆莫名其妙的输出。正确写法是用引号包住变量并且显式告诉 grep 使用扩展正则patternERROR|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 时不能直接写$10Shell 会解读成$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) FORCE1 shift ;; -n|--name) NAME$2 shift 2 ;; *) echo 未知参数$1 exit 1 ;; esac done这种写法在很多系统管理脚本里很常见shift 2用来消费-n和它的值两段参数。对 shell 脚本来说能灵活处理任意数量的参数是一个很实用的能力而shift正是实现这个能力的最好工具。4. 环境变量、局部变量与默认值把脚本写稳的关键一步4.1 export 和环境变量的关系父子进程的遗传规则前面讲的变量都是当前 shell 私有的但有时候你需要让子进程也看到这些变量。比如你在当前终端里定义了APP_ENVproduction然后执行一个脚本./deploy.sh这个脚本内部读取$APP_ENV时会读到吗答案是不会除非你用了export。export APP_ENVproduction ./deploy.shexport的作用是给变量打上允许传给子进程的标记。打过标记的变量会进入环境变量列表当当前 shell 启动一个子进程时这份环境变量列表会原样复制给子进程。从原理上看这就是我常说的遗传规则每个进程都有自己的环境变量表子进程会继承父进程的环境变量但子进程对变量的修改不会被传回给父进程。理解了这一点就不会再疑惑为什么脚本里改了$PATH关掉终端再打开又变回原样。要注意的是环境变量名一般约定用大写比如PATH、HOME、LANG小写变量则更多用于局部脚本内部。这不是语法强制而是工程惯例。你的脚本如果定义了需要传给子命令的变量记得export只在当前脚本内部使用的变量不 export 反而安全因为这能减少环境污染。4.2 local 与全局变量函数内部别污染外部Shell 函数的变量作用域和很多编程语言不一样。默认情况下在函数里给变量赋值作用域是全局的——函数外也能看到。看这段代码#!/bin/bash count1 increment() { count10 count$((count 1)) echo 函数内部$count } increment echo 函数外部$count输出结果是函数内部11 函数外部11你没看错函数里对count的修改直接影响到了函数外部。如果你的脚本后面还要用count做其他计算这里就可能引入严重 bug。解决办法是在函数内部声明localincrement() { local count10 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赋值给varunset NAME echo ${NAME:guest} echo $NAME # 输出 guestNAME 已经被赋值了还有一个容易混淆的${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_FILEbackup_${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} PATTERNERROR|FATAL mkdir -p $BACKUP_DIR for f in $LOG_DIR/*.log; do [ -e $f ] || continue grep -E $PATTERN $f /dev/null 21 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) figrep -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 命令替换反引号与 $() 的取舍把命令执行结果保存到变量有两种写法# 老式写法 todaydate %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都能提前帮你揪出来。对我来说它就像一个经验老道的代码评审员每次检查完脚本都能发现一两处自己写的时候完全没意识到的隐患。