Shell 脚本里的$符号,大概是每个跟 Linux 打交道的人最早接触、也最容易栽跟头的东西。我见过太多人写了半年脚本,依然搞不清$?和$$的区别,也见过有人因为少写一对引号,把整个目录的文件名全带上了空格导致批量操作翻车。这个符号看着不起眼,实际上它是 Shell 里变量展开、参数传递、命令替换、算术运算的总开关,用得好能让脚本干净利落,用不好就是各种诡异 bug 的源头。这篇内容我打算把$在 Shell 脚本里的各种用法掰开揉碎讲一遍,从最基础的变量引用到参数处理、特殊变量、命令替换、算术展开,再到引号与转义的坑,最后给一份可以直接抄的排查清单。不管你是刚写脚本的新手,还是已经能写几百行脚本但总在细节上翻车的老手,应该都能从里面找到点有用的东西。
1. 先搞清楚$到底在干什么
1.1 一个符号,四种身份
很多人把$当成"取变量值"的符号,这个理解不算错,但太窄了。在 Shell 的解析器眼里,$是一个展开触发器,它后面跟的字符决定了触发哪种展开行为。我习惯把它分成四类身份来看:
- 变量展开:
$name或${name},把变量名替换成它的值。 - 位置参数展开:
$1、$2、$@、$#这类,取脚本或函数的入参。 - 特殊变量展开:
$?、$$、$!、$0这类,取进程状态、PID 等运行时信息。 - 命令与算术展开:
$(command)取命令输出,$((expr))做整数运算。
这四类身份共用同一个符号,Shell 靠$后面的第一个字符来区分。比如$(后面跟左括号就是命令替换,$((后面跟两个左括号就是算术展开,$后面跟数字就是位置参数,跟字母下划线就是变量名。理解这个"看下一个字符决定行为"的机制,后面所有的坑就都能串起来了。
提示:
$后面如果跟的是一个既不是字母、数字、下划线,也不是(、{的字符,Shell 通常会把$当普通字符处理。比如echo "价格是 $100"里,$1会被当成位置参数展开(如果没传参就是空),而不是你想显示的"$100"。这是新手最常踩的坑之一。
1.2 为什么非要区分$name和${name}
刚学的时候我也觉得${}是多余的,$name写起来多省事。直到有一次写循环拼接字符串,写了echo "$file_suffix",结果 Shell 去找一个叫file_suffix的变量,而不是file变量加_suffix后缀,输出一片空白,排查了半小时才反应过来。
这就是花括号的核心价值:界定变量名的边界。Shell 解析变量名时是贪婪的,会一直往后吃字母、数字、下划线,直到遇到非法字符为止。所以:
file="report" echo "$file.txt" # 输出空,因为 Shell 找的是变量 file.txt(不合法则报错或空) echo "${file}.txt" # 输出 report.txt,花括号明确告诉 Shell 变量名到 file 为止除了界定边界,${}还支持一堆高级操作,这是$name完全做不到的,后面第 3 节会专门讲。我的习惯是:只要变量后面还紧跟其他字符,一律用${},养成这个习惯能省掉大量莫名其妙的调试时间。
1.3 单引号、双引号、反斜杠:谁让$失效
$的展开不是无条件的,引号会改变它的行为,这是另一个高频翻车点。三者的区别我用一张表说清楚:
| 写法 | $是否展开 | 变量是否展开 | 适用场景 |
|---|---|---|---|
'...'单引号 | 否 | 否 | 需要字面量,比如正则、SQL 语句、含$的模板 |
"..."双引号 | 是 | 是 | 绝大多数场景,尤其是含空格的值 |
\反斜杠 | 转义单个字符 | 转义后不展开 | 需要输出字面$时 |
举个实际例子,你要打印一句提示"请设置 $HOME 环境变量",如果写成echo "请设置 $HOME 环境变量",$HOME会被展开成实际路径,提示就变味了。正确写法是echo '请设置 $HOME 环境变量'或者echo "请设置 \$HOME 环境变量"。
反过来,如果你要拼接一段带变量的字符串,比如生成一个日志路径,就必须用双引号:
log_dir="/var/log/myapp" echo "日志目录是: ${log_dir}/app.log" # 正确,变量展开 echo '日志目录是: ${log_dir}/app.log' # 错误,原样输出 ${log_dir}注意:双引号里
$依然展开,但$@和$*在双引号里的行为有微妙差别,第 2 节会细讲。这是参数处理里最容易出错的地方。
2. 位置参数与特殊变量:脚本的"输入接口"
2.1$0到$9,以及超过 9 个参数怎么办
脚本的入参通过位置参数访问,$0是脚本名本身,$1是第一个参数,以此类推。这里有个很多人不知道的细节:$10不是第十个参数,Shell 会把它解析成$1后面跟一个字符0。要取第十个及以后的参数,必须用${10}、${11}这种花括号写法。
#!/bin/bash echo "脚本名: $0" echo "第一个参数: $1" echo "第十个参数: ${10}" # 必须用花括号我见过有人写$10调试半天,以为参数没传进去,其实是语法问题。这个坑在参数多的脚本里特别隐蔽,因为前九个参数都正常,到第十个突然"失灵"。
2.2$#、$@、$*:参数个数与参数列表
这三个是处理批量参数的核心,但$@和$*的区别值得单独拎出来说,因为它们在不加引号时行为一样,加了引号就分道扬镳。
$#:参数个数,最常用于参数校验,比如if [ $# -lt 2 ]; then echo "用法: $0 参数1 参数2"; exit 1; fi。$@:所有参数,加双引号时每个参数独立成一个词,这是遍历参数的正确姿势。$*:所有参数,加双引号时所有参数合并成一个词,用$IFS的第一个字符(默认空格)连接。
我用一个具体场景说明差别。假设脚本收到三个参数:"a b"、c、d(注意第一个参数本身含空格):
#!/bin/bash echo "=== 用 \"\$@\" 遍历 ===" for arg in "$@"; do echo "[$arg]" done echo "=== 用 \"\$*\" 遍历 ===" for arg in "$*"; do echo "[$arg]" done输出结果:
=== 用 "$@" 遍历 === [a b] [c] [d] === 用 "$*" 遍历 === [a b c d]"$@"保留了参数的原始边界,三个参数还是三个;"$*"把它们揉成了一个字符串。所以遍历参数永远用"$@",这是铁律。"$*"只在你想把参数拼成一条命令或一行日志时才用得上。
提示:不加引号的
$@和$*都会经历单词拆分和通配符展开,含空格的参数会被拆散,含*的参数会被当通配符匹配文件名。所以无论哪种,加双引号是必须的。
2.3$?、$$、$!:运行时状态三兄弟
这三个特殊变量在写健壮脚本时离不开:
$?:上一条命令的退出状态码,0 表示成功,非 0 表示失败。这是做错误处理的基础。$$:当前脚本进程的 PID,常用于生成临时文件名,比如tmp_file="/tmp/myscript_$$.tmp"。$!:最近一个后台命令的 PID,配合wait做并发控制时很有用。
$?有个经典陷阱:它只反映紧邻的上一条命令。如果你在命令和判断之间插了一句echo,$?就变成echo的退出码了(通常是 0)。所以正确写法是命令后立刻判断,或者用if command; then的写法直接利用命令的退出码:
# 错误示范:$? 被 echo 覆盖 grep "pattern" file.txt echo "检查结果" if [ $? -eq 0 ]; then ... # 这里判断的是 echo 的退出码 # 正确示范一:立刻判断 grep "pattern" file.txt if [ $? -eq 0 ]; then ... # 紧邻,没问题 # 正确示范二:直接用 if 接命令 if grep -q "pattern" file.txt; then echo "找到了" fi$$生成临时文件名时要注意,如果脚本被并发执行多次,$$各不相同,能避免冲突,这点比固定文件名靠谱得多。但如果是同一脚本内多次生成临时文件,$$不变,还得再加个随机数或时间戳。
3.${}的高级玩法:不只是界定边界
3.1 默认值与空值处理::-、:=、:?、:+
这四个操作符是写健壮脚本的利器,能在一行里完成"变量为空时怎么办"的判断,省掉一堆 if。它们的区别我用表格列清楚:
| 语法 | 变量未设置或为空时 | 变量有值时 | 是否修改原变量 |
|---|---|---|---|
${var:-default} | 返回 default | 返回 var 的值 | 否 |
${var:=default} | 返回 default 并赋值给 var | 返回 var 的值 | 是 |
${var:?message} | 报错退出,输出 message | 返回 var 的值 | 否 |
${var:+alt} | 返回空 | 返回 alt | 否 |
实际用起来,${var:-default}最常用,比如给配置项设默认值:
port="${PORT:-8080}" log_level="${LOG_LEVEL:-info}"${var:?message}适合做必填参数校验,变量没设就直接报错退出,比手写 if 简洁:
db_host="${DB_HOST:?数据库地址未配置,请设置 DB_HOST 环境变量}"注意冒号的有无是有区别的:${var-default}(不带冒号)只在变量未设置时用默认值,变量设为空字符串时不触发;${var:-default}(带冒号)在未设置或为空时都触发。绝大多数场景我们要的是带冒号的版本,因为空字符串通常也视为"没配"。
3.2 字符串截取与替换:处理路径和文本的利器
${}还能做字符串的截取、删除、替换,处理文件路径时特别顺手,不用每次都调basename、dirname或sed。
截取子串:${var:offset:length}
str="abcdefghij" echo "${str:0:3}" # abc,从 0 开始取 3 个 echo "${str:3}" # defghij,从索引 3 取到末尾 echo "${str: -3}" # hij,从末尾倒数 3 个(注意冒号后有空格)删除前后缀:${var#pattern}、${var##pattern}、${var%pattern}、${var%%pattern}
这四个符号记起来有规律:#在键盘上靠左,对应"从头删";%靠右,对应"从尾删";单个符号是最短匹配,双符号是最长匹配。
path="/home/user/docs/report.tar.gz" echo "${path#*/}" # home/user/docs/report.tar.gz,删掉第一个 / 及之前 echo "${path##*/}" # report.tar.gz,删掉最后一个 / 及之前,等价于 basename echo "${path%/*}" # /home/user/docs,删掉最后一个 / 及之后,等价于 dirname echo "${path%%.*}" # /home/user/docs/report.tar,删掉最后一个 . 及之后 echo "${path%%.*}" # 注意:%% 是最长匹配,会删到第一个点这里有个容易混淆的点:${path%%.*}用双百分号是最长匹配,会从第一个点开始删,得到/home/user/docs/report;${path%.*}单百分号是最短匹配,从最后一个点删,得到/home/user/docs/report.tar。取文件扩展名时通常用${path##*.},取不带扩展名的文件名用${path%%.*}。
替换:${var/old/new}、${var//old/new}
str="a-b-c-d" echo "${str/-/_}" # a_b-c-d,只替换第一个 echo "${str//-/_}" # a_b_c_d,替换全部这个在处理用户输入、拼接路径时很好用,比sed轻量,而且不涉及子进程开销。
3.3 大小写转换与间接引用
较新的 Bash 版本(4.0+)还支持大小写转换:
str="Hello World" echo "${str^^}" # HELLO WORLD,全部转大写 echo "${str,,}" # hello world,全部转小写间接引用${!var}则是取"变量名存在 var 里的那个变量的值",听起来绕,实际场景是动态变量名:
name="config" config="myapp.conf" echo "${!name}" # 输出 myapp.conf这个在写配置加载、动态拼接变量名时有用,但可读性差,我一般只在确实需要动态性时才用,能静态写清楚就别炫技。
4. 命令替换与算术展开:$()和$(())
4.1$()与反引号:为什么推荐前者
命令替换是把命令的输出作为值嵌入到另一个命令或赋值里,两种写法:`command`和$(command)。功能上等价,但我强烈推荐$(),原因有三:
- 可嵌套:
$(echo $(date +%Y))能正常嵌套,反引号嵌套要转义,写起来反人类。 - 可读性好:
$()的左右括号清晰界定边界,反引号在字体里容易和单引号混淆。 - 转义简单:
$()内部的反斜杠处理更符合直觉。
# 推荐 today=$(date +%Y-%m-%d) files=$(ls *.txt 2>/dev/null | wc -l) # 不推荐但能用 today=`date +%Y-%m-%d`命令替换有个高频坑:输出里的换行和空格会被保留,但赋值时如果不加引号,后续使用会经历单词拆分。比如files=$(ls)得到的多行结果,在for f in $files时会被按空白拆成一个个文件名,文件名含空格就完蛋。正确做法是用while read配合进程替换,或者干脆用通配符for f in *.txt。
注意:命令替换会 fork 一个子 shell 执行,有性能开销。在循环里大量调用
$(...)会明显拖慢脚本,能提前算好的就提前算,能避免的就避免。
4.2$(())算术展开:只算整数
$((expr))做整数运算,支持+ - * / % **、位运算、比较、逻辑运算,变量名在里面不用加$:
a=10 b=3 echo $((a + b)) # 13 echo $((a / b)) # 3,整数除法,小数部分丢弃 echo $((a % b)) # 1 echo $((a ** 2)) # 100 echo $((a > b ? a : b)) # 10,支持三元运算几个必须知道的点:
- 只支持整数,
$((10 / 3))得 3 不是 3.33。要小数得用bc或awk。 - 除零会报错,脚本可能直接退出,做除法前最好判断除数非零。
- 变量名不用加
$,$((a + 1))和$(( $a + 1 ))都对,但前者更清晰。 - 八进制陷阱:
$((08))会报错,因为前导 0 被当成八进制,而 8 不是合法八进制数字。处理带前导零的数字(比如日期)时要小心,用10#$var强制按十进制解析。
month="08" echo $((month + 1)) # 报错:value too great for base echo $((10#$month + 1)) # 9,强制十进制这个坑在处理日期、编号时特别常见,我第一次遇到时完全没头绪,后来才知道是八进制解析惹的祸。
5. 引号与转义的实战避坑
5.1 变量不加引号的三大灾难
我总结了一下,变量不加双引号会引发三类问题,每一个都能让脚本在特定输入下神秘失败:
灾难一:空格拆分。变量值含空格时,不加引号会被拆成多个词。file="my document.txt"; rm $file会尝试删两个文件my和document.txt,如果恰好存在就误删了。
灾难二:通配符展开。变量值含*、?、[时会被当通配符匹配文件名。pattern="*.log"; echo $pattern会列出当前目录所有 log 文件,而不是输出*.log这个字符串。
灾难三:空值消失。变量为空时,不加引号会展开成"什么都没有",导致命令参数错位。cmd $empty_arg file里如果empty_arg为空,file就变成了第一个参数,命令语义完全变了。
所以我的原则很简单:变量引用一律加双引号,除非你明确知道自己在做什么。这条规则能挡掉 90% 的脚本 bug。
5.2$@与$*在函数传参中的差异
前面讲过"$@"和"$*"的区别,这里补充一个函数场景。函数内部用"$@"转发参数是最安全的:
log() { echo "[$(date +%H:%M:%S)] $*" >&2 } run_task() { log "开始执行: $*" some_command "$@" # 原样转发所有参数,保留边界 }注意log里用$*是为了把参数拼成一行日志,而some_command "$@"用"$@"是为了保留参数边界。同一个函数里两种用法各司其职,这个细节很多人没注意。
5.3eval与$的危险组合
eval会把参数当代码再解析一遍,如果参数里含用户输入,$展开可能被利用做命令注入。比如eval "echo $user_input",用户输入$(rm -rf /)就危险了。我的建议是能不用 eval 就不用,非用不可时确保输入完全可控,或者用数组 +"${arr[@]}"替代动态命令拼接。
6. 常见问题速查与排查思路
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 变量输出为空 | 变量名边界错误,如$file.txt | 改用${file}.txt |
$10取不到值 | 被解析成$1+0 | 改用${10} |
| 含空格参数被拆散 | 变量未加双引号 | 加"..." |
$?判断总为 0 | 中间插了其他命令 | 命令后立刻判断或用if cmd |
| 算术报错 value too great for base | 前导 0 被当八进制 | 用10#$var |
| 命令替换结果换行丢失 | 未加引号或用了$* | 用"$@"或while read |
提示语里$被展开 | 用了双引号 | 改单引号或转义\$ |
6.2 我的调试三板斧
遇到$相关的诡异问题,我一般按这三步走:
第一步,set -x打开跟踪。在脚本开头加set -x,执行时会把每条命令展开后的样子打印出来,变量到底展开成了什么一目了然。调试完记得set +x关掉,或者只在调试时临时加。
第二步,echo加引号打印。怀疑某个变量有问题,就echo "[$var]",用方括号包起来能看出前后有没有多余空格、是不是空值。这个技巧简单但极其有效。
第三步,shellcheck静态检查。这是个专门检查 Shell 脚本问题的工具,能自动发现未加引号的变量、$10写法、$?误用等大量问题。我现在的习惯是脚本写完先跑一遍shellcheck,能挡掉一大半低级错误。
提示:
shellcheck对$相关问题的检测非常准,尤其是 SC2086(未加引号的变量展开)这条,几乎每个新手脚本都会中招。把它集成到编辑器里,写的时候就有提示,比事后调试高效得多。
6.3 几个容易忽略的边界情况
$在 here-doc 里的行为。<<EOF(不加引号)会展开变量,<<'EOF'(加引号)不展开。写配置文件模板时如果不想让$被展开,记得给 EOF 加引号。
$在数组里的用法。${arr[@]}取所有元素,${#arr[@]}取元素个数,${arr[0]}取第一个。数组下标从 0 开始,${arr[@]}加引号才能保留含空格元素。
$在[[ ]]里的行为。[[ ]]内部不做单词拆分和通配符展开,所以[[ $var == *.txt ]]里$var不加引号也安全,这是[[ ]]比[ ]更推荐的原因之一。
7. 写在最后的一点个人习惯
用了这么多年 Shell,我对$的处理已经形成了几条肌肉记忆:变量引用默认加双引号,变量后面跟字符一律用${},遍历参数永远用"$@",命令替换优先$(),算术运算注意前导零。这几条规则覆盖了日常 95% 的场景,剩下的边角料靠set -x和shellcheck兜底。
真正让我少踩坑的,不是记住了多少语法,而是养成了"变量展开前先想清楚它会变成什么"的习惯。每次写$的时候停半秒,问自己:这个变量可能为空吗?可能含空格吗?后面紧跟的字符会不会被吃掉?想清楚这三点,大部分问题在写的时候就被消灭了。脚本这东西,写的时候多花十秒,调试的时候能省十分钟,这笔账怎么算都划算。