3、基础语法:变量定义与引用、特殊变量、命令替换、算术运算、引号规则
Shell脚本的本质是命令的序列化与自动化,而变量、命令替换、算术运算与引号规则则是构成这一序列的“语法骨架”。初学者往往能写出能跑的脚本,但遇到变量名拼接、空格处理、特殊字符转义或需要动态计算时,就会陷入“引号地狱”或“变量未定义”的困惑。本章将系统拆解这五大基础语法模块,从定义规范到引用陷阱,从特殊变量的语义到命令替换的嵌套逻辑,配合可复制的实战案例,帮助你建立一套稳健的Shell语法心智模型。
3.1 变量定义与引用:从赋值到取值的完整链路
核心原理
Shell变量本质上是存储在内存中的键值对,键为变量名,值为字符串(即使看起来像数字,默认也按字符串处理)。变量名必须以字母或下划线开头,后续字符可以是字母、数字或下划线,且区分大小写。赋值语法为 变量名=值,注意等号两侧不能有空格,否则Shell会将其解析为命令加参数。引用变量时,使用 $变量名 或 ${变量名} 两种形式,后者在变量名与后续字符需要隔离时尤为重要。
$var 与 ${var} 在大多数场景下等价,但当变量名后紧跟字母、数字或下划线时,必须使用花括号。例如 ${var}_suffix 能正确解析为变量 var 的值加上 _suffix,而 $var_suffix 会被误认为变量 var_suffix。
操作流程与判断方法
- 定义变量:
name="John"或count=10。若值包含空格或特殊字符,必须用引号包裹。 - 引用变量:使用
$name或${name}获取值。 - 判断变量是否已定义:使用
${var:-default}提供默认值,或${var:+alternate}在已定义时返回替代值。 - 变量只读:
readonly var=value防止后续修改。 - 删除变量:
unset var释放变量。
案例拆解:配置文件读取与默认值处理
假设我们需要编写一个部署脚本,从配置文件中读取数据库端口,若未配置则使用默认值3306。配置文件 app.conf 内容为 DB_HOST=192.168.1.10,没有端口字段。脚本片段如下:
#!/bin/bash
# 读取配置文件
source ./app.conf 2>/dev/null || echo "配置文件不存在,使用默认值"
# 使用默认值机制
DB_PORT=${DB_PORT:-3306}
echo "连接数据库:${DB_HOST}:${DB_PORT}"
# 输出:连接数据库:192.168.1.10:3306
这里 ${DB_PORT:-3306} 在变量未定义或为空时返回3306,既保证了脚本健壮性,又避免了显式if判断。若需要强制变量必须存在,可使用 ${DB_HOST:?错误:未配置DB_HOST},脚本会立即报错退出。
常见误区与总结
- 误区1:赋值时等号两侧加空格,导致命令找不到。
- 误区2:在双引号内使用
$var时,若变量值包含空格,会被正确保留为一个整体;但若不加引号,则可能被拆分为多个单词。 - 误区3:忘记使用
export导出环境变量,导致子进程无法访问。
set -u 命令,使脚本在引用未定义变量时立即报错,避免静默使用空值导致逻辑错误。
3.2 特殊变量:脚本的“元信息”通道
核心原理
Shell预置了一组特殊变量,用于获取脚本自身、参数、退出状态等运行时信息。这些变量以 $ 开头,后跟单个符号或数字,是脚本与执行环境交互的桥梁。理解它们,是编写健壮脚本的基础。
| 变量 | 含义 | 典型用途 |
|---|---|---|
$0 | 脚本名称(含路径) | 打印脚本名、定位脚本所在目录 |
$# | 参数个数 | 校验参数数量是否满足要求 |
$@ | 所有参数列表(每个参数独立) | 遍历所有参数 |
$* | 所有参数(整体为一个字符串) | 将参数合并传递 |
$? | 上一条命令的退出码(0表示成功) | 判断命令执行结果 |
$$ | 当前Shell进程PID | 生成临时文件名、日志标识 |
$! | 最近一个后台进程PID | 等待后台任务、监控进程状态 |
操作流程与判断方法
- 参数校验:先检查
$#是否等于期望值,不满足则打印用法并退出。 - 参数遍历:使用
for arg in "$@"逐个处理,注意加双引号保留参数中的空格。 - 退出码判断:命令执行后立即检查
$?,但注意任何中间命令都会覆盖该值,需及时保存。 - 后台任务管理:启动后台任务后用
$!记录PID,配合wait命令等待完成。
案例拆解:带参数校验的备份脚本
编写一个备份脚本,要求传入源目录和目标目录两个参数,且源目录必须存在。脚本需要打印参数个数、源目录内容,并在备份完成后返回成功状态。
#!/bin/bash
# 检查参数个数
if [ $# -ne 2 ]; then
echo "用法: $0 <源目录> <目标目录>"
exit 1
fi
SRC_DIR="$1"
DST_DIR="$2"
# 检查源目录是否存在
if [ ! -d "$SRC_DIR" ]; then
echo "错误: 源目录 $SRC_DIR 不存在" >&2
exit 2
fi
echo "参数个数: $#"
echo "脚本名称: $0"
echo "源目录内容:"
ls -la "$SRC_DIR"
# 执行备份(此处用cp模拟)
cp -r "$SRC_DIR" "$DST_DIR" 2>/dev/null
if [ $? -eq 0 ]; then
echo "备份成功"
exit 0
else
echo "备份失败" >&2
exit 3
fi
该脚本通过 $# 校验参数数量,$0 输出脚本名,$? 判断cp命令是否成功。注意 >&2 将错误信息重定向到标准错误输出,这是专业脚本的常见做法。
常见误区与总结
- 误区1:混淆
$@和$*。在双引号中,"$@"将每个参数视为独立单词,而"$*"将所有参数合并为一个字符串。遍历参数时务必使用"$@"。 - 误区2:在命令链中多次使用
$?,导致获取到的是中间命令的退出码而非目标命令的。 - 误区3:忽略
$0可能包含路径,若需纯文件名应使用basename "$0"。
set -e 时,任何非零退出码都会导致脚本立即退出。此时若某条命令的失败是可接受的,需在命令后追加 || true 或使用 if 包裹。
3.3 命令替换:将命令输出“注入”变量
核心原理
命令替换允许将一条命令的标准输出作为值赋给变量或嵌入到另一条命令中。语法有两种:$(命令)(推荐)和反引号 `命令`。前者支持嵌套,且可读性更好;后者在旧脚本中常见,但嵌套时需转义,容易出错。命令替换会执行子Shell,因此命令中的变量修改不会影响父Shell。
操作流程与判断方法
- 基本用法:
current_date=$(date +%Y-%m-%d)将日期字符串存入变量。 - 嵌套使用:
file_count=$(ls -1 "$(dirname "$0")" | wc -l)先获取脚本目录,再统计文件数。 - 处理多行输出:命令替换会保留换行符,若需按行处理,可配合
while read循环。 - 性能考量:命令替换会启动子Shell,频繁调用会降低性能,可考虑用
read或进程替换替代。
案例拆解:动态生成日志文件名与进程监控
某运维脚本需要每天生成带日期的日志文件,并监控某个后台进程是否存活。使用命令替换动态获取信息:
#!/bin/bash
# 动态生成日志文件名
LOG_FILE="/var/log/app_$(date +%Y%m%d_%H%M%S).log"
echo "日志文件: $LOG_FILE"
# 启动后台任务
sleep 300 &
BG_PID=$!
echo "后台任务PID: $BG_PID"
# 等待3秒后检查进程是否存活
sleep 3
if kill -0 $BG_PID 2>/dev/null; then
echo "进程 $BG_PID 仍在运行"
else
echo "进程 $BG_PID 已退出"
fi
# 获取当前系统负载(多行输出示例)
LOAD=$(uptime | awk -F'load average:' '{print $2}')
echo "系统负载: $LOAD"
这里 $(date +%Y%m%d_%H%M%S) 生成时间戳,kill -0 检查进程存在性,awk 从uptime输出中提取负载信息。命令替换让脚本能动态适应环境变化。
常见误区与总结
- 误区1:使用反引号嵌套时忘记转义,如
`echo \`date\``,容易出错。推荐统一使用$()。 - 误区2:命令替换会去除末尾换行符,但保留内部换行。若需去除所有空白,可配合
tr -d '\n'或xargs。 - 误区3:在命令替换中使用双引号时,内部变量会被展开,若需字面量需使用单引号。
<(命令) 以避免内存占用过高。例如 diff <(ls dir1) <(ls dir2) 可直接比较两个目录内容。
3.4 算术运算:让脚本具备计算能力
核心原理
Shell原生支持整数算术运算,但不支持浮点数(需借助 bc 或 awk)。推荐使用 $((表达式)) 语法,它支持C语言风格的运算符(+、-、*、/、%、**、+=、-=等),且内部变量可省略 $ 符号。其他方式如 let 命令和 expr 命令已逐渐被替代,但旧脚本中仍可见。
操作流程与判断方法
- 基本运算:
result=$((a + b * 2)),注意乘号*在$(( ))内不需要转义。 - 自增自减:
((count++))或((count += 1)),常用于循环计数。 - 逻辑运算:
$((a > b ? a : b))实现三目运算符效果。 - 进制转换:
$((16#FF))将十六进制转为十进制。 - 浮点运算:使用
echo "scale=2; 10/3" | bc保留两位小数。
案例拆解:计算脚本执行时间与文件大小汇总
我们需要统计一个目录下所有文件的总大小(以KB为单位),并计算脚本执行耗时。使用算术运算实现:
#!/bin/bash
START_TIME=$(date +%s)
TARGET_DIR="/var/log"
TOTAL_SIZE=0
# 遍历文件并累加大小(单位:字节)
for file in "$TARGET_DIR"/*; do
if [ -f "$file" ]; then
SIZE=$(stat -c%s "$file")
TOTAL_SIZE=$((TOTAL_SIZE + SIZE))
fi
done
# 转换为KB
TOTAL_KB=$((TOTAL_SIZE / 1024))
echo "总大小: ${TOTAL_KB} KB"
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo "脚本耗时: ${ELAPSED} 秒"
这里 stat -c%s 获取文件字节数,$((TOTAL_SIZE + SIZE)) 累加,最后除以1024得到KB。时间戳差值计算耗时,体现了算术运算在性能监控中的典型应用。
常见误区与总结
- 误区1:在
$(( ))内使用$var语法,虽然可行但冗余,直接写var更简洁。 - 误区2:尝试用
expr处理浮点数,会报语法错误。浮点必须用bc或awk。 - 误区3:除数为0时脚本会报错退出,需提前判断。
${var:-0} 显式初始化。
3.5 引号规则:控制Shell解析的“开关”
核心原理
Shell中引号决定了字符串的解析方式:单引号(' ')内的所有字符保持字面量,不进行变量展开、命令替换或转义;双引号(" ")内允许变量展开和命令替换,但禁止大部分特殊字符(如 *、?)的路径扩展;反引号(` `)用于命令替换,但推荐用 $() 替代。理解引号规则,是避免“参数被意外拆分”和“特殊字符被错误解释”的关键。
| 引号类型 | 变量展开 | 命令替换 | 路径扩展 | 转义 | 典型场景 |
|---|---|---|---|---|---|
单引号 ' ' | 否 | 否 | 否 | 否 | 字面量字符串,如正则表达式、密码 |
双引号 " " | 是 | 是 | 否 | 是(\ 可转义 $、`、"、\) | 包含变量的字符串,保留空格 |
| 无引号 | 是 | 是 | 是 | 是 | 简单命令参数,需路径扩展时 |
操作流程与判断方法
- 判断是否需要变量展开:需要则用双引号,不需要则用单引号。
- 判断是否包含空格或特殊字符:包含则必须加引号,否则会被拆分为多个参数。
- 嵌套引号:外层用双引号,内层用单引号,或使用转义。
- 空字符串处理:
""表示空参数,而''同样表示空字符串,但""内可包含变量。
案例拆解:处理带空格的文件名与特殊字符
假设目录下有一个文件名为 my report (final).txt,我们需要将其复制到备份目录并重命名。若不加引号,命令会因空格和括号而失败:
#!/bin/bash
FILE_NAME="my report (final).txt"
# 错误示例(不加引号)
# cp $FILE_NAME /backup/ # 会尝试复制多个文件
# 正确示例(加双引号)
cp "$FILE_NAME" "/backup/${FILE_NAME%.txt}_backup.txt"
# 使用单引号处理正则表达式
PATTERN='^[0-9]{3}-[0-9]{4}$'
if [[ "123-4567" =~ $PATTERN ]]; then
echo "电话号码格式正确"
fi
# 双引号内使用转义
echo "当前用户: \"$USER\" 主目录: $HOME"
这里 "$FILE_NAME" 确保整个文件名作为一个参数传递,${FILE_NAME%.txt} 进行后缀替换。单引号包裹正则表达式防止 ^ 和 $ 被Shell解释。双引号内使用 \" 输出字面量双引号。
常见误区与总结
- 误区1:在单引号内使用
\'转义单引号,这是无效的。单引号内无法包含单引号,需用'\''技巧或改用双引号。 - 误区2:在双引号内使用
!历史扩展(在交互式Shell中),脚本中默认关闭,但需注意set -H可能开启。 - 误区3:将用户输入直接放入双引号内执行,可能导致命令注入。应使用
printf '%q'转义或参数化查询。
args=("file one.txt" "file two.txt"); cp "${args[@]}" /dest/。数组配合 "${args[@]}" 是处理复杂参数的最佳实践。
3.6 综合实战:构建一个带参数校验和日志记录的系统信息收集脚本
将本章所有语法点融合到一个真实场景中:我们需要一个脚本,接受一个可选参数(输出格式:text或json),收集系统信息(主机名、IP、内存使用率、磁盘使用率),并记录执行日志。脚本需具备参数校验、默认值、算术运算、命令替换和引号处理能力。
#!/bin/bash
# 脚本: sysinfo.sh
# 功能: 收集系统信息并输出,支持text或json格式
set -u # 未定义变量即报错
# 参数校验与默认值
FORMAT="${1:-text}" # 默认text格式
if [[ "$FORMAT" != "text" && "$FORMAT" != "json" ]]; then
echo "错误: 格式仅支持 text 或 json" >&2
exit 1
fi
# 日志记录函数
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> /var/log/sysinfo.log 2>/dev/null || echo "日志写入失败"
}
# 收集数据
HOSTNAME=$(hostname)
IP_ADDR=$(hostname -I | awk '{print $1}')
MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')
MEM_USED=$(free -m | awk '/^Mem:/{print $3}')
MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL))
DISK_USED=$(df -h / | awk 'NR==2{print $5}' | tr -d '%')
# 输出结果
if [[ "$FORMAT" == "text" ]]; then
echo "===== 系统信息 ====="
echo "主机名: $HOSTNAME"
echo "IP地址: $IP_ADDR"
echo "内存使用: ${MEM_USED}MB / ${MEM_TOTAL}MB (${MEM_PERCENT}%)"
echo "磁盘使用: ${DISK_USED}%"
else
echo "{"
echo " \"hostname\": \"$HOSTNAME\","
echo " \"ip\": \"$IP_ADDR\","
echo " \"memory_percent\": $MEM_PERCENT,"
echo " \"disk_percent\": $DISK_USED"
echo "}"
fi
# 记录日志
log "收集完成: 格式=$FORMAT, 内存=${MEM_PERCENT}%, 磁盘=${DISK_USED}%"
exit 0
该脚本展示了:${1:-text} 默认值、[[ ]] 条件判断、$( ) 命令替换、$(( )) 算术运算、双引号内变量展开、tr -d 清理字符、日志重定向与错误处理。执行 ./sysinfo.sh json 可输出JSON格式,便于其他程序解析。
MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL)) 先乘后除避免精度丢失;df -h / | awk 'NR==2{print $5}' 提取根分区使用率;tr -d '%' 删除百分号便于算术运算。整个脚本体现了基础语法在真实运维场景中的组合应用。
3.7 本章总结与避坑清单
基础语法是Shell脚本的“砖瓦”,但也是最容易埋坑的地方。以下是本章核心要点与高频错误清单:
| 语法模块 | 核心要点 | 高频错误 | 推荐实践 |
|---|---|---|---|
| 变量 | 等号无空格;引用用 ${} | 赋值空格、未加引号 | 统一使用 ${var},开启 set -u |
| 特殊变量 | $@ 与 $* 区别 | 遍历用错变量 | 遍历参数用 "$@" |
| 命令替换 | 优先 $(),支持嵌套 | 反引号嵌套转义错误 | 避免反引号,使用 $() |
| 算术运算 | 整数用 $(( )),浮点用 bc | 浮点直接运算 | 明确数值类型,初始化变量 |
| 引号规则 | 单引号字面量,双引号展开 | 单引号内转义、空格拆分 | 含空格必加双引号,正则用单引号 |